Metadata-Version: 2.4
Name: afrilink-sdk
Version: 0.9.3
Summary: AfriLink SDK — Train, finetune, and generate on GPUs from your notebook
Home-page: https://github.com/dataspires/afrilink-sdk
Author: DataSpires
Author-email: DataSpires <info@dataspires.com>
License: MIT
Project-URL: Homepage, https://dataspires.com
Project-URL: Documentation, https://www.dataspires.com/docs
Project-URL: Repository, https://github.com/DataSpires/afrilink-sdk
Project-URL: Bug Tracker, https://github.com/DataSpires/afrilink-sdk/issues
Keywords: hpc,high-performance-computing,finetuning,pretrain,generate,vlm,llm,lora,notebook,gpu,k3s,slurm,afrilink
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Science/Research
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Classifier: Topic :: System :: Distributed Computing
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: requests>=2.28.0
Provides-Extra: full
Requires-Dist: psutil>=5.9.0; extra == "full"
Provides-Extra: build
Requires-Dist: cryptography>=41.0.0; extra == "build"
Provides-Extra: dev
Requires-Dist: pytest>=7.0.0; extra == "dev"
Requires-Dist: black>=23.0.0; extra == "dev"
Requires-Dist: mypy>=1.0.0; extra == "dev"
Dynamic: author
Dynamic: home-page
Dynamic: license-file
Dynamic: requires-python

# AfriLink SDK

**Version:** 0.9.3

One-line access to GPUs for training, finetuning, and inference — from any notebook.

```bash
pip install 'afrilink-sdk>=0.9.3'
```

**Works in:** Google Colab · Kaggle · Jupyter · VS Code · any Python 3.8+ environment

> **Pilot scope:** This documentation covers the **`k3s` backend only** (the default). Other backends exist in the full SDK but are **not** part of this pilot and are not documented here. You do not need to set `AFRILINK_BACKEND`.

---

## Contents

1. [60-second quickstart](#60-second-quickstart)
2. [Authentication](#authentication)
3. [Which method should I use?](#which-method-should-i-use)
4. [Known limitations](#known-limitations)
5. [Backend (k3s)](#backend-k3s)
6. [Guides by task](#guides-by-task)
   — [Pretrain](#pretrain) · [Finetune](#finetune) · [Generate](#generate)
7. [Working with your output model](#working-with-your-model)
8. [API reference](#api-reference)
9. [Hardware & billing](#hardware--billing)
10. [Model & dataset registry](#model--dataset-registry)
11. [Troubleshooting](#troubleshooting)
12. [Built-in help](#built-in-help)

---

## 60-second quickstart

```python
from afrilink import AfriLinkClient

client = AfriLinkClient()
client.authenticate(api_key="afk_live_…")  # or set AFRILINK_API_KEY

job = client.pretrain(
    kind="image-classify",
    model="resnet18",
    data="./images/",
    params={"epochs": 5, "num_classes": 2},
    gpus=1,
)
result = job.run(wait=True)
print(result["status"])  # "completed" | "failed" | "timeout" | …
if result["status"] != "completed":
    print(result.get("error") or result.get("reason"))
```

`finetune()` and `generate()` use the same `client → job → job.run()` pattern. See [Guides by task](#guides-by-task).

---

## Authentication

Auth is a single stateless API key — no passwords, no certificate refresh, no SSH key management on your side.

### Get a key

1. Sign up at [dataspires.com](https://dataspires.com).
2. **Profile → AfriLink SDK keys → Create new key.** Copy the `afk_live_…` value — it's shown once.
3. Store it as `AFRILINK_API_KEY`.

### Set the key

| Environment | How |
| --- | --- |
| Google Colab | 🔑 sidebar → **Add secret** → name `AFRILINK_API_KEY` → enable for notebook |
| Kaggle | Add-ons → **Secrets** → name `AFRILINK_API_KEY` → attach to notebook |
| Local Jupyter / VS Code | `os.environ["AFRILINK_API_KEY"] = "afk_live_…"` before `authenticate()` |
| Anywhere | `client.authenticate(api_key="afk_live_…")` |

```python
client = AfriLinkClient()
client.authenticate()   # resolves from argument / env / notebook secret, in that order
```

### Errors

`authenticate()` **raises** `Exception` (message like `API key auth failed: …`) on a bad or revoked key. Wrap it if you want a soft failure:

```python
try:
    client.authenticate()
except Exception as e:
    print("Auth failed:", e)
```

After a successful call, the short-lived session JWT lives **in memory for the kernel lifetime** — nothing is written to disk. Rotate a key by revoking it on the dashboard and minting a new one.

---

## Which method should I use?

| Your goal | Call | You provide |
| --- | --- | --- |
| Train a **YOLO / CNN / transformer from scratch** | `client.pretrain(kind=…)` | `kind`, `data`, optional `weights=` / `scratch=` |
| Fine-tune an **LLM / VLM** foundation model | `client.finetune()` | `model`, `training_mode`, `data`, optional `task=` |
| Run a model **without** changing weights | `client.generate()` | `model`, prompts / image+question manifest |

> **Rule of thumb:** train from zero → `pretrain()`. Adapt existing weights → `finetune()`. Frozen answers → `generate()`.

### Deprecated APIs (removed in 1.0.0)

| Old (0.8.x) | Use instead |
| --- | --- |
| `client.train(script=…, container="afrilink-yolo", …)` | `client.pretrain(kind="yolo-detect", …)` or `pretrain(script=…)` |
| `client.adapt(recipe="yolo-detect", …)` | `client.pretrain(kind="yolo-detect", weights=…)` |
| `client.initialize(recipe=…, …)` | `client.pretrain(kind=…, scratch=True)` |

```python
# Before (deprecated)
job = client.train(script="train_yolo.py", container="afrilink-yolo", data="./data/")

# After
job = client.pretrain(kind="yolo-detect", weights="yolo11n.pt", data="./data/", data_config="data.yaml")
```

---

## Known limitations

| Topic | Reality on this pilot |
| --- | --- |
| Backend | **`k3s` only** in this doc. Other backends are out of scope. |
| `build_and_train()` / custom Cloud Build images | **Not available on `k3s`** — those APIs require a backend this pilot does not use. Prefer curated `pretrain` / `finetune` / `generate`. |
| Result `billing` field | Often `null` on k3s until cluster billing is fully wired. |
| `estimated_cost_usd()` | Still returns a **ceiling estimate** from `gpus × time_limit × $0.60/GPU-hr`. It is not the same as the `billing` field on the result. |
| Concurrent jobs | No hard client-side cap; the cluster schedules by capacity. Abandoned jobs are cleaned up after ~3 days. |
| Wall-clock max | `time_limit` may not exceed **24 hours**. Past the limit, the job is stopped (`timeout` / cancelled). |

---

## Backend (k3s)

| | `k3s` **(default)** |
| --- | --- |
| **How to select** | Default — nothing to set |
| **What it targets** | DataSpires K3s GPU cluster |
| **Job model** | Async — submit, then poll or `list_jobs()`, even from a new session |
| **File I/O** | S3-compatible presigned URLs |
| **Outputs** | Write under `/workspace/job/output/` — see [K3s output retrieval](#k3s-output-retrieval) |
| **Multi-GPU** | Supported when the cluster has capacity |

### `status` vs `state` (not a typo)

Two related schemas:

| API | Field | Example values |
| --- | --- | --- |
| `job.run()` return dict | **`status`** | `submitted`, `completed`, `failed`, `timeout`, `cancelled` |
| `client.list_jobs()` / `client.get_job_status()` | **`state`** | same lifecycle strings; may also include `queued`, `running`, plus optional `reason` / `message` / `error` |

```python
result = job.run(wait=True)
print(result["status"])                 # job.run() → status

for entry in client.list_jobs():
    print(entry["job_id"], entry["state"], entry.get("reason"))

print(client.get_job_status(result["job_id"])["state"])
```

---

## Guides by task

### Pretrain

*(YOLO, CNN, or transformer recipes — platform owns the training loop)*

```python
job = client.pretrain(
    kind="image-classify",
    model="resnet18",
    data="./images/",
    params={"epochs": 5, "num_classes": 2},
    gpus=1,
    time_limit="02:00:00",   # HH:MM:SS string — see note below
)
result = job.run(wait=True)
```

**YOLO:**

```python
job = client.pretrain(
    kind="yolo-detect",
    weights="yolo11n.pt",    # or scratch=True for random init
    data="./dataset/",
    data_config="data.yaml",
    gpus=1,
    time_limit="02:00:00",
)
result = job.run(wait=True)
```

> **Time limits:** `pretrain()`, `finetune()`, and `generate()` take `time_limit` as a string `HH:MM:SS`. (Other SDK APIs outside this pilot use `time_limit_hours` as a float — do not mix them up.)

Curated containers from `kind=`:

| `kind` prefix | Container | Frameworks |
| --- | --- | --- |
| `yolo-*` | `afrilink-yolo` | Ultralytics, PyTorch, torchvision |
| `image-classify` | `afrilink-vision` | PyTorch, torchvision |
| transformer/ViT | `afrilink-pretrain` | Transformers, accelerate |

#### K3s output retrieval

Write artefacts to `/workspace/job/output/`. The orchestrator syncs that prefix on completion.

```python
from pathlib import Path
job.k8s_runner.download_output(job.job_id, Path("./my-outputs"))
```

Passing `data=` on `pretrain` / `finetune` / `generate` already uploads your dataset. You only need `client.upload_dataset(...)` for advanced, separate staging:

```python
remote = client.upload_dataset("./my_data/", dataset_name="assignment2")
```

### Finetune

*(LoRA/QLoRA on an LLM/VLM — `afrilink-finetune` container)*

```python
import pandas as pd

data = pd.DataFrame({"text": ["Below is an instruction...\n\n### Response:\n..."]})

job = client.finetune(
    model="qwen2.5-0.5b",
    training_mode="low",     # low | medium | high
    data=data,
    gpus=1,
    time_limit="01:00:00",
)
result = job.run(wait=True)
if result["status"] == "completed":
    client.download_model(result["job_id"], "./my-model")
else:
    print(result.get("error") or result.get("reason"))
```

| Mode | Strategy | Quantization |
| --- | --- | --- |
| `low` | QLoRA (rank 8) | 4-bit |
| `medium` | LoRA (rank 16) | 8-bit / none |
| `high` | Full LoRA (rank 64) | none |

For VLMs, pass `task=` (captioning / VQA) with image+text data. See `afrilink/finetune` in [Built-in help](#built-in-help).

### Generate

*(frozen inference — no weight updates)*

```python
job = client.generate(
    model="smolvlm-256m",
    data="./manifest_dir/",
    outputs=["text", "confidence"],   # add "hidden_states" for analysis
    max_new_tokens=64,
    temperature=0.0,
)
result = job.run(wait=True)
```

### Job failures (all three verbs)

`job.run(wait=True)` **does not raise** on a failed training/generate job. It returns a dict:

```python
{
  "job_id": "a1b2c3d4",
  "status": "failed",          # or "timeout", "cancelled", …
  "error": "<log tail>",       # often present
  "reason": "…",               # optional (k3s)
  "billing": null              # often null on this pilot
}
```

Check `result["status"] == "completed"` before downloading outputs. Auth and submit-time validation errors (bad key, not authenticated, invalid `gpus` / `time_limit`) **do** raise.

---

## Working with your model

### Convert to GGUF for Ollama / llama.cpp

**1. Merge the adapter in Python (notebook):**

```python
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel

base = AutoModelForCausalLM.from_pretrained("Qwen/Qwen2.5-0.5B")
model = PeftModel.from_pretrained(base, "./my-model")
merged = model.merge_and_unload()
merged.save_pretrained("./my-model-merged")
AutoTokenizer.from_pretrained("Qwen/Qwen2.5-0.5B").save_pretrained("./my-model-merged")
```

**2. Run these in a terminal (not a notebook cell):**

```bash
python convert_hf_to_gguf.py ./my-model-merged --outfile my-model.gguf --outtype f16
./llama-quantize my-model.gguf my-model-q4.gguf Q4_K_M
ollama create my-model -f Modelfile && ollama run my-model
```

### Publish to Hugging Face Hub

```python
from huggingface_hub import HfApi

api = HfApi(token="hf_...")
api.create_repo("your-username/my-finetuned-model", exist_ok=True)
api.upload_folder(folder_path="./my-model", repo_id="your-username/my-finetuned-model")  # adapter only
```

---

## API reference

### `AfriLinkClient` (pilot-relevant)

| Method | Description |
| --- | --- |
| `authenticate(api_key=None)` | Resolve key; **raises** on failure |
| `pretrain(kind, data, gpus, time_limit, …)` | Returns a job object (`PretrainJob` / `TrainJob`) — call `.run()` |
| `finetune(model, training_mode, data, gpus, time_limit, …)` | Returns `FinetuneJob` — call `.run()` |
| `generate(model, data, outputs, …)` | Returns `GenerateJob` — call `.run()` |
| `download_model(job_id, local_dir)` | Download synced `output/` for a completed job |
| `upload_dataset(local_path, dataset_name=None)` | Optional separate staging (usually unnecessary if you pass `data=`) |
| `list_containers()` / `list_available_models()` / `list_available_datasets()` | Registry lookups |
| `get_model_requirements(model, training_mode)` | GPU/memory recommendation |
| `cancel_job(job_id)` | Stop a job |
| `list_jobs(include_completed=False)` | List jobs → dicts with **`state`** |
| `get_job_status(job_id)` | Poll by ID → dict with **`state`** (works across sessions) |
| `get_job(job_id)` | In-session job object, if still tracked |

### Job objects (`PretrainJob` / `FinetuneJob` / `GenerateJob`)

Returned by `pretrain()` / `finetune()` / `generate()`. **Not** returned by `build_and_train()` (that API is out of pilot scope and returns a nested dict when used on its own backend).

| Member | Description |
| --- | --- |
| `run(wait=True, poll_interval=30)` | Submit; with `wait=True`, poll until done and return a result **dict** with **`status`** |
| `cancel()` | Stop the job |
| `get_logs(tail=100)` | Recent log lines |
| `estimated_cost_usd()` | Ceiling estimate: GPUs × `time_limit` × $0.60/GPU-hr (not live billing) |
| `status`, `job_id` | Current tracked state / job ID |
| `k8s_runner` | For `download_output()` on k3s |

**Async discovery:**

```python
result = job.run(wait=False)          # {"job_id", "status": "submitted", …}
status = client.get_job_status(result["job_id"])
print(status["state"], status.get("reason"))
```

`list_jobs(include_completed=True)` includes finished jobs for up to ~3 days.

---

## Hardware & billing

Jobs run on the DataSpires **k3s** GPU cluster. Prefer small models and `training_mode="low"` (QLoRA) for workshop / assignment budgets.

| Model size | Mode | Fits on 1 GPU? |
| --- | --- | --- |
| 0.5B – 3B | low / medium | yes |
| 3B – 7B | low (QLoRA 4-bit) | yes |
| 7B | medium (LoRA 8-bit) | yes |
| 13B | low (QLoRA 4-bit) | tight |
| 30B+ | low (QLoRA 4-bit) | unlikely (OOM risk) |

**Limits:** up to **24h** `time_limit`; hard max **32 GPUs** (SDK warns above 8). Past `time_limit`, the run stops — check for `status`/`state` of `timeout` or cancelled.

**Billing:** invoices at [dataspires.com/dashboard/billing](https://dataspires.com/dashboard/billing). On this pilot, `result["billing"]` is often `null`; use `job.estimated_cost_usd()` only as a planning ceiling.

---

## Model & dataset registry

```python
client.list_available_models()
client.list_available_models(size="tiny")   # tiny | small | medium | large
client.list_available_datasets()
client.get_model_requirements("qwen2.5-0.5b", "low")
```

| ID | Type | Params | Min VRAM |
| --- | --- | --- | --- |
| `qwen2.5-0.5b` | text | 0.5B | 4 GB |
| `gemma-3-270m` | text | 0.27B | 2 GB |
| `llama-3.2-1b` | text | 1.0B | 4 GB |
| `deepseek-r1-1.5b` | text | 1.5B | 6 GB |
| `ministral-3b` | text | 3.3B | 8 GB |
| `florence-2-base` | vision | 0.23B | 4 GB |
| `smolvlm-256m` | vision | 0.26B | 2 GB |
| `moondream2` | vision | 1.9B | 8 GB |
| `internvl2-1b` | vision | 1.0B | 4 GB |
| `llava-1.5-7b` | vision | 7.0B | 16 GB |

---

## Troubleshooting

| Symptom | Likely cause | Fix |
| --- | --- | --- |
| `API key auth failed` | Bad / revoked key | Mint a new key; set `AFRILINK_API_KEY` |
| Job stuck in `queued` | Cluster at capacity | `client.list_jobs()`; wait or try later |
| `KeyError: 'status'` on `list_jobs()` | Wrong field | Use `entry["state"]` for list/status APIs; `result["status"]` only for `job.run()` |
| `KeyError: 'state'` on `job.run()` result | Wrong field | Use `result["status"]` |
| Gated HF model fails | Missing token | Add `HUGGINGFACE_TOKEN` notebook secret |
| Empty download / no artefacts | Script didn't write under `/workspace/job/output/` | Fix output path; `job.k8s_runner.download_output(...)` |
| Job ends as `timeout` | Hit `time_limit` | Raise `time_limit` (max 24h) or reduce work |

---

## Built-in help

Prefer **`afrilink.docs("topic")`** — it always works. The slash form is also supported when the topic is a **quoted string**.

```python
import afrilink

afrilink.docs("help")       # recommended
afrilink.docs("finetune")
afrilink.docs("pretrain")
afrilink.docs("generate")
afrilink.docs("choose")
afrilink.docs("billing")

# Equivalent quoted slash form (module implements __truediv__):
afrilink / "help"
afrilink / "finetune"
```

`afrilink/help` (unquoted) works only because `help` is a Python builtin; we map that object to the `"help"` page. Unquoted names like `afrilink/finetune` usually raise **`NameError`** unless `finetune` is already bound in your scope — do not copy those bare forms from older docs.

---

## Links

- **Docs:** [dataspires.com/docs](https://www.dataspires.com/docs)
- **Repository / Issues:** [github.com/DataSpires/afrilink-sdk](https://github.com/DataSpires/afrilink-sdk)
- **License:** MIT
