Metadata-Version: 2.4
Name: bios-sdk
Version: 0.2.1
Summary: Official Python SDK for the BIOS training and deployment platform API
Project-URL: Homepage, https://usbios.ai
Project-URL: Documentation, https://usbios.ai/docs/sdk-python
Project-URL: Support, https://usbios.ai/docs/support
Author-email: BIOS <bios@us.inc>
License-Expression: MIT
License-File: LICENSE
Keywords: bios,deployment,fine-tuning,inference,llm,machine-learning,sdk
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: requests<3,>=2.33.0
Requires-Dist: urllib3<3,>=2.7.0
Description-Content-Type: text/markdown

# bios-sdk

Official Python SDK for the [BIOS](https://usbios.ai) fine-tuning platform API.

## Installation

The distribution is `bios-sdk`; the import name is `bios`. (`bios` on PyPI is
an unrelated project — installing it will not give you this SDK.)

```bash
pip install bios-sdk
```

## Quick Start

```python
from bios import BiOS

client = BiOS(api_key="bios-...")

# Search the hosted catalog. Rows come straight from the model registry, so
# they are snake_case, and the HANDLE you pass everywhere else is repo_id
# (`id` is the registry UUID).
result = client.models.search(query="llama", type="llm", limit=5)
for model in result["models"]:
    print(f"{model['repo_id']} -- {model['params_total_b']}B params")

# Create a training job
job = client.training.create(
    idempotency_key="training-create-20260711-0001",
    model="meta-llama/Llama-3.1-8B-Instruct",
    dataset_ids=["ds_abc123", "ds_def456"],
    method="sft",
    adapter="lora",
    epochs=3,
    learning_rate=2e-4,
    lora_rank=16,
)
print(f"Job {job['id']} created -- status: {job['status']}")
```

## Authentication

The SDK supports two authentication methods:

**API Key** (recommended). Keys start with `bios-` (legacy `usf-` keys stay
valid):
```python
client = BiOS(api_key="bios-...")
```

**Environment variables**. When `api_key`, `base_url`, or `inference_key` is
omitted, the SDK reads `BIOS_API_KEY`, `BIOS_BASE_URL`, and
`BIOS_INFERENCE_KEY` from the environment:
```bash
export BIOS_API_KEY=bios-...
export BIOS_BASE_URL=https://api.usbios.ai   # optional; this is the default
export BIOS_INFERENCE_KEY=sk-bios-...        # optional; defaults to BIOS_API_KEY
```
```python
client = BiOS()  # uses BIOS_API_KEY / BIOS_BASE_URL / BIOS_INFERENCE_KEY
```

**JWT Access Token**:
```python
client = BiOS(
    access_token="eyJhbG...",
    org_id="org_abc123",
)
```

## Configuration

| Parameter | Type | Default | Description |
|---|---|---|---|
| `api_key` | `str` | `BIOS_API_KEY` env var | API key for authentication (prefix: `bios-`; legacy `usf-` keys stay valid) |
| `access_token` | `str` | `None` | JWT access token (alternative to API key) |
| `org_id` | `str` | `None` | Organization ID (required for JWT auth) |
| `workspace_id` | `str` | `None` | Workspace ID (optional, overrides key default) |
| `base_url` | `str` | `BIOS_BASE_URL` env var, then `https://api.usbios.ai` | Canonical production hostname (release-gated; this documentation does not assert current availability). During prelaunch/dev, pass `https://api-dev.usbios.ai` explicitly. |
| `timeout` | `float` | `30.0` | Request timeout in seconds |
| `inference_key` | `str` | `BIOS_INFERENCE_KEY` env var, then `api_key` | Key used by `client.inference` for `/v1` calls. A per-deployment `sk-bios-...` key, or the platform `api_key` itself when it carries the serverless scope — you never pass the same key twice |
| `inference_base_url` | `str` | `base_url` | Explicit dev or production inference hostname |
| `inference_timeout` | `float` | `900.0` | End-to-end inference/stream timeout in seconds |

## Resources

### Inference

Inference keys are separate from control-plane API keys, but you never pass one
twice: `inference_key` falls back to `BIOS_INFERENCE_KEY` and then to the
control-plane `api_key`, so a platform key carrying the serverless scope calls
`/v1` directly. Pass an explicit `inference_key` for a per-deployment
`sk-bios-...` key. Streaming yields each OpenAI SSE chunk as a dictionary and
closes the upstream response when the iterator is closed. Calls are never retried automatically; if your application
chooses to retry, reuse the same `idempotency_key`. The header is propagated,
but the SDK does not claim server-side replay/deduplication unless the endpoint
returns an explicit replay acknowledgement.

```python
client = BiOS(
    api_key="bios-control-plane-key",
    inference_key="sk-bios-deployment-key",  # omit to reuse api_key
    # Use https://api-dev.usbios.ai explicitly during dev.
)

tools = [{
    "type": "function",
    "function": {
        "name": "lookup",
        "parameters": {"type": "object", "properties": {"id": {"type": "integer"}}},
    },
}]

stream = client.inference.stream_chat_completions(
    messages=[{"role": "user", "content": "Look up record 42"}],
    tools=tools,
    idempotency_key="chat-42-attempt-1",
)
try:
    for chunk in stream:
        print(chunk)
finally:
    stream.close()  # cancels/disconnects an unfinished generation
```

#### Serverless catalog models

Call any catalog model by id on the unified `/v1` endpoint with a workspace
platform key that carries the serverless scope — no per-deployment key. The
gateway routes by `model`; dedicated deployments and serverless models share the
same endpoint. `reasoning_effort` is forwarded, and streaming surfaces
`content` and `reasoning_content` deltas incrementally.

```python
# A platform key with the serverless scope is enough — `api_key` doubles as the
# inference key, so BiOS(api_key=K).inference.chat_completions(...) just works.
client = BiOS(api_key="bios-platform-key-with-serverless-scope")

stream = client.inference.stream_chat_completions(
    model="meta-llama/Llama-3.1-8B-Instruct",  # serverless catalog id
    messages=[{"role": "user", "content": "Explain tensor parallelism briefly."}],
    reasoning_effort="low",
)
try:
    for chunk in stream:
        delta = (chunk.get("choices") or [{}])[0].get("delta", {})
        if delta.get("reasoning_content"):
            print(delta["reasoning_content"], end="", flush=True)
        if delta.get("content"):
            print(delta["content"], end="", flush=True)
finally:
    stream.close()

# Non-streaming; the final response carries "usage" when the model reports it.
completion = client.inference.chat_completions(
    model="meta-llama/Llama-3.1-8B-Instruct",
    messages=[{"role": "user", "content": "One sentence on GPUs."}],
)
choice = completion["choices"][0]
answer = choice["message"].get("content")
if answer:
    print(answer)
else:
    # A 200 is NOT proof the model answered. Reasoning models can spend the
    # whole budget inside reasoning_content and return content: null with
    # finish_reason "length" — an empty answer that looks like success. Always
    # read choices[0].message.content, and raise max_tokens (or lower
    # reasoning_effort) when finish_reason is "length".
    print("no answer:", choice.get("finish_reason"),
          "reasoning tokens only:", bool(choice["message"].get("reasoning_content")))
```

Streaming billing is charged server-side on completed usage; the SDK only needs
to request `usage` where the endpoint exposes it (no client change).

### Models

Search the model catalog, fetch training configs, and check adapter compatibility.
The catalog lists only models hosted on BiOS (the platform's own verified
registry, mirrored in BiOS storage) — every result can be trained and
deployed; it is never a live Hugging Face search.

Search results are the registry's own rows: snake_case fields, `repo_id` as
the model handle (`id` is the registry UUID), plus `maxContext` — the native
context window that caps a deployment's `context_length` — and `weightBytes`,
the on-disk weight size. `query` becomes the registry's `q` filter, which is
the only search parameter it reads.

```python
# Search models
result = client.models.search(query="llama", type="llm", limit=10)
for m in result["models"]:
    print(m["repo_id"], m["params_total_b"], m["surface_type"],
          m.get("maxContext"), m.get("weightBytes"))

# One model, by its author/name handle
detail = client.models.get("meta-llama/Llama-3.1-8B-Instruct")
print(detail["model"]["architecture"], detail["model"]["maxContext"])

# The context ceiling on its own — None when the registry does not record it
print(client.models.native_max_context("meta-llama/Llama-3.1-8B-Instruct"))

# Get model config
config = client.models.get_config("meta-llama/Llama-3.1-8B-Instruct")
print(f"{config['totalParams']}B params, MoE: {config['isMoE']}")

# Check adapter compatibility
compat = client.models.get_adapter_compatibility(
    model_type="llama",
    training_method="rlhf",
    rlhf_algorithm="dpo",
)
usable = [a for a in compat["adapters"] if a["compatible"]]
print(f"{len(usable)} compatible adapters")
```

### Datasets

Upload, import, preview, and manage training datasets.

```python
# List datasets
datasets = client.datasets.list()

# Upload a dataset
uploaded = client.datasets.upload(
    file_path="./training_data.jsonl",
    name="My SFT Dataset",
)
print(f"Uploaded: {uploaded['id']}")

# Import from HuggingFace
imported = client.datasets.import_from_huggingface(
    repo_id="databricks/dolly-15k",
    integration_id="int_abc123",
    name="Dolly 15k",
)

# Preview dataset rows
preview = client.datasets.preview("ds_abc123", page=1, page_size=5)
print(preview["columns"])

# Validate before uploading
result = client.datasets.validate("./data.jsonl")
if result["format_valid"]:
    print(f"Valid {result['detected_format']} with {result['num_samples']} rows")
else:
    print("Errors:", result["validation_errors"])

# Search HuggingFace Hub
hub_results = client.datasets.search_hub(query="code instruct")

# Preview a Hub dataset
hub_preview = client.datasets.preview_hub(
    dataset_id="databricks/dolly-15k",
    split="train",
    limit=5,
)

# Get format specs
specs = client.datasets.get_format_specs()

# Get storage usage
usage = client.datasets.get_storage_usage()

# Delete a dataset
client.datasets.delete("ds_abc123")
```

### Training

Create, monitor, stop, and resume fine-tuning jobs.

```python
request = {
    "idempotency_key": "training-create-20260711-0001",
    "model": "meta-llama/Llama-3.1-8B-Instruct",
    "dataset_ids": ["ds_abc123", "ds_def456"],
    "method": "sft",
    "adapter": "lora",
    "epochs": 3,
    "learning_rate": 2e-4,
    "lora_rank": 16,
    "lora_alpha": 32,
    "gpu_type": "A100_80GB",
    "gpu_count": 1,
    "gpu_priorities": [
        {"gpu_type": "A100_80GB", "gpu_count": 1},
        {"gpu_type": "H100_80GB", "gpu_count": 1},
        {"gpu_type": "L40S_48GB", "gpu_count": 1},
    ],
    "queue_if_unavailable": True,
    "queue_deadline": "2026-07-18T00:00:00Z",
    "max_price_hour_cents": 500,
}

# Side-effect-free validation, canonical sizing, live stock and alternatives
check = client.training.preflight(request)
print(check["request_hash"], check.get("recommended"), check["queue_eligible"])

# Create the paid job only after reviewing preflight
job = client.training.create(**request)

# List jobs
jobs = client.training.list(status="running")
page = client.training.list_page(limit=50, offset=0)

# Get job details
job = client.training.get("job_abc123")
print(f"Status: {job['status']}, Progress: {job.get('progress', 0)}%")

# Get metrics
metrics = client.training.get_metrics("job_abc123")
for point in metrics["metrics"]:
    print(point["step"], point.get("loss"))

# Get checkpoints
checkpoints = client.training.get_checkpoints("job_abc123")
for cp in checkpoints:
    print(f"{cp['name']}: {cp['size_bytes']} bytes")

# Get logs
logs = client.training.get_logs("job_abc123")
for entry in logs["logs"]:
    print(entry["level"], entry["message"])

# Stop a job
client.training.stop("job_abc123", keep_data=True)

# Resume a stopped job
client.training.resume("job_abc123", idempotency_key="training-resume-20260711-0001")

# Delete a checkpoint
client.training.delete_checkpoint("job_abc123", "cp_xyz789")
```

### Wallet

View wallet balance and transaction history.

`balance_cents` is the deposited balance; `available_balance_cents` is what can
actually be spent right now (balance minus `active_holds_cents` and
`accruing_cents`). Spend decisions read the second one. Auto top-up is flat
(`auto_topup_enabled` / `auto_topup_threshold` / `auto_topup_amount`), and the
transaction list is a wrapped page.

```python
# Get balance
balance = client.wallet.get_balance()
print(f"Balance:   ${balance['balance_cents'] / 100:.2f}")
print(f"Spendable: ${balance['available_balance_cents'] / 100:.2f}")
print(f"On hold:   ${balance['active_holds_cents'] / 100:.2f}")
print(f"Accruing:  ${balance['accruing_cents'] / 100:.2f}")

# List transactions -- the rows are WRAPPED, so iterate ["transactions"]
page = client.wallet.get_transactions(limit=20)
print(f"{page['total']} transactions")
for t in page["transactions"]:
    print(f"{t['type']}/{t['category']}: ${t['amount_cents'] / 100:.2f} -- {t.get('description')}")

# Get pricing
pricing = client.wallet.get_pricing()
```

### Inference deployment management

The same `client.inference` resource that makes OpenAI-compatible chat calls also
manages model-serving deployments (create, monitor, stop, and delete).

`allow_capacity_queue=True` is explicit consent to wait for stock, so it
requires 3 to 5 ranked `gpu_priorities` — the SDK rejects the combination
locally before any request. Leave the queue off to book exactly one placement.

`serving_mode`, `model_task`, and `supports_images` are all **server-derived and
immutable**: the platform reads them off the resolved model and rejects a
conflicting assertion, so omit them and read the result back from the
deployment. Which `model_task` values are accepted depends on the model, so the
SDK does not judge one locally — it forwards whatever you pass and the server
rules on it. `preflight()` echoes the derived task at
`canonical_request["model_task"]` if you want to see it before creating.

`context_length` is optional and follows one policy: omit it and the server
pre-fills `min(native_max, 262144)`; the window is adjustable only when the
model's native max exceeds the 32,768 floor, and it can never exceed the
model's own native max. It is also a **sizing input** — a bigger window means a
bigger KV cache, which can raise the minimum GPU count. Read the ceiling with
`client.models.native_max_context(model_id)` first, and read it back from
`native_max_context` on the deployment detail. Pass it as an `int`: the local
capacity pre-check only runs on a window it can prove fits, so anything else
(a numeric string included) is forwarded for the server to answer.

```python
request = {
    "name": "llama-api",
    "source_type": "hf_model",
    "hf_model_id": "meta-llama/Llama-3.1-8B-Instruct",
    "gpu_type": "H100_80GB",
    "gpu_count": 1,
    # Queueing needs 3-5 ranked placements; the first is the one booked now.
    "allow_capacity_queue": True,
    "gpu_priorities": [
        {"gpu_type": "H100_80GB", "gpu_count": 1},
        {"gpu_type": "A100_80GB", "gpu_count": 1},
        {"gpu_type": "L40S_48GB", "gpu_count": 2},
    ],
    "max_price_hour_cents": 500,
}

# Never ask for more context than the model has.
native_max = client.models.native_max_context(request["hf_model_id"])
if native_max:
    request["context_length"] = min(65536, native_max)

# No wallet mutation and no GPU allocation.
check = client.inference.preflight(request)
print(check["selected_gpu"], check["alternatives"], check["billing"])

deployment = client.inference.create(request, idempotency_key="deploy-create-20260711-0001")
print(deployment["inference_key"])  # returned once; store securely

status = client.inference.status(deployment["id"])
print(status["status"], status.get("status_reason"), status.get("queue_expires_at"), status.get("wallet_authorization_status"))
# The detail resolves the per-deployment serving settings a list row omits.
print(status["context_length"], status["native_max_context"], status["model_task"])
# status stays "failed" for existing filters when status_reason is "queue_expired".
notification_history = client.inference.notifications(deployment["id"])
print([(row["event_type"], row["state"], row["attempt_count"]) for row in notification_history])

# Bounded newest-first listing; keep filters unchanged while using next_cursor.
# LIST rows are a lean grid projection: the model handle is `model_ref` (there
# is no `model` key) and the serving settings above are absent -- call
# client.inference.get(id) for those.
page = client.inference.list_page(limit=100, status="running", search="llama")
if page["has_more"]:
    older = client.inference.list_page(
        limit=100, status="running", search="llama", cursor=page["next_cursor"]
    )
    print(older["deployments"])
# Lazy traversal avoids one unbounded response.
for item in client.inference.iter_all(status="running"):
    print(item["name"], item["model_ref"], item["requests_total"])

client.inference.stop(deployment["id"])
client.inference.delete(deployment["id"])
```

### GPU

View GPU pricing and get hardware recommendations.

```python
# Get all GPU pricing
pricing = client.gpu.get_pricing()
for gpu in pricing["gpus"]:
    print(f"{gpu['display_name']}: {gpu['price_display']} -- {gpu['vram_gb']}GB VRAM")

# Authoritative model-aware choices, live stock, total pricing, and alternatives
options = client.gpu.get_options(
    "meta-llama/Llama-3.1-8B-Instruct",
    train_type="qlora",
    method="sft",
)

# Get recommended GPU for a model
rec = client.gpu.get_recommended("meta-llama/Llama-3.1-8B-Instruct")
if rec:
    print(f"Recommended: {rec['display_name']} x{rec['recommended_count']}")
    print(f"Total VRAM: {rec['total_vram_gb']}GB")
    print(f"Cost: ${rec['estimated_cost_per_hour_cents'] / 100:.2f}/hr")
    print(f"Reason: {rec['reason']}")
```

### Introspect

Discover the permissions and scope of your API key.

```python
info = client.introspect()
print(f"Org: {info['org']['name']}")
print(f"Scopes: {', '.join(info['scopes'])}")
print(f"Allowed tools: {len(info['allowed_mcp_tools'])}")
```

## Error Handling

All API errors raise `ApiError` with `status`, `code`, `request_id`, and `message` attributes.

```python
from bios import BiOS, ApiError

client = BiOS(api_key="bios-...")

try:
    job = client.training.get("bad_id")
except ApiError as e:
    if e.status == 404:
        print("Job not found")
    elif e.status == 401:
        print("Invalid API key")
    elif e.status == 403:
        print("Insufficient permissions")
    else:
        print(f"API error {e.status}: {e.message}")
        if e.request_id:
            print(f"Request ID: {e.request_id}")
```

### GPU rejections

Every rejected GPU selection raises `CapacityUnavailableError` (a subclass of
`ApiError`), for training and for inference, so one `except` reaches all of them
and every one carries the same recovery data: `alternatives` (the canonical
`available_gpus` list), `minimum_requirement`, `selected`, `reason`, and
`checked_at`.

The rejections split into two families. Read `permanent` (or `queue_offered`),
never the status or the code, to tell them apart:

| `reason` | `code` | HTTP | `queue_offered` | `permanent` |
| --- | --- | --- | --- | --- |
| `insufficient_stock` | `CAPACITY_UNAVAILABLE` | 409 | `True` | `False` |
| `model_too_large` | `GPU_TYPE_TOO_SMALL` | 400 | `False` | `True` |
| `below_model_minimum` | `GPU_COUNT_BELOW_MINIMUM` | 400 | `False` | `True` |
| `invalid_gpu_count` | `GPU_COUNT_INVALID` | 400 | `False` | `True` |
| `gpu_unsupported` | `GPU_TYPE_UNSUPPORTED` | 400 | `False` | `True` |

`CAPACITY_UNAVAILABLE` means the GPU is not in stock right now, so waiting or
joining the capacity queue can still get the request booked. The other four are
fixed facts about the request: waiting does not help and the capacity queue
cannot help either, however long the request waits. Only a different GPU type or
a different GPU count can make it run.

```python
from bios import BiOS, CapacityUnavailableError

try:
    deployment = client.inference.create(request, idempotency_key="deploy-create-20260725-0001")
except CapacityUnavailableError as e:
    print(e.message)
    print(e.reason, e.code, e.minimum_requirement)
    if e.permanent:
        # Never offer to wait here. Pick from the alternatives instead.
        for gpu in e.alternatives:
            print(gpu["gpu_type"], gpu["min_gpus"], gpu["valid_counts"])
    elif e.queue_offered:
        print("Out of stock right now. Retry later, or submit with the capacity queue.")
    if e.gpu_priorities_entry:
        print(f"gpu_priorities entry {e.gpu_priorities_entry} is the one to change")
```

`inference.create` runs the same check before it submits anything, so a
selection that can never run this model raises the identical error (same code,
same 400, `queue_offered` False) without a round trip. The service stays the
authority and re-checks on create.

## Python Version Support

- Python 3.10+

## License

MIT
