Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,30 @@ No `git tag`, no `git push --tags`, no Actions clicks.

## [Unreleased]

## [0.3.0] — 2026-10-25

LoRA adapters on a shared base (public beta).

### Added

- `custom_models.create(lora_enabled=True, max_lora_rank=16|64)` deploys
a shared LoRA base. Omitting `max_lora_rank` raises
`graphn.ValidationError` before any request is sent.
- `custom_models.adapters`: `attach` (Hugging Face, presigned S3 or
assume-role S3, with an optional idempotency key), `list`, `get`,
`delete`, `wait_until_ready` and `wait_until_deleted`. Call a ready
adapter with `chat.completions.create(model=adapter.id, ...)`.
- `custom_models.list(include_adapters=True)` lists adapters alongside
bases.
- `CustomModel` fields `lora_enabled`, `max_lora_rank`, `max_adapters`,
`parent_model_id` and `adapter_digest`, and the `LoraRank` type.
- `_generated/` regenerated from OpenAPI 0.26.1.

### Fixed

- `custom_models.wait_until_ready` works on an adapter: the server
refuses `refresh` for adapters, so it polls `get` instead.

## [0.2.0] — 2026-08-15

First-party wrappers for the full CLI-reachable Graphn API (OpenAPI 0.7.0).
Expand Down
51 changes: 50 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ Get an API key from the [Graphn dashboard](https://graphn.ai).
| `client.blueprints` | Public catalog list/get and workspace deploy |
| `client.storages` | REST object-store overlay plus S3-host get/put/delete |
| `client.batch` | Gateway batch submit, poll, items, JSONL output, cancel |
| `client.custom_models` | Import from HuggingFace / S3; `wait_until_ready`, validate |
| `client.custom_models` | Import from HuggingFace / S3; `wait_until_ready`, validate; LoRA adapters on a shared base (`.adapters`) |
| `client.secrets` | CRUD for workspace secrets |
| `client.chat.completions` | OpenAI-compatible chat, streaming + non-streaming, **with auto-wake on cold start** |
| `client.models` | List every callable model: CP catalog plus imported/custom from inference |
Expand Down Expand Up @@ -201,6 +201,55 @@ auto-wake, addressing by `model.id` — is identical regardless of
weight source. See [`examples/import_from_s3.py`](examples/import_from_s3.py)
for an end-to-end runnable script.

## LoRA adapters on a shared base

Deploy one base with `lora_enabled=True`, attach up to eight LoRA
adapters to it, and call each adapter as its own model. Adapters run on
the base's replicas, so they add no GPUs. LoRA adapters are in public
beta; see the [guide](https://graphn.ai/docs/lora-adapters) for the
adapter format and limits.

```python
base = c.custom_models.create(
name="qwen3-8b-base",
huggingface_model_id="Qwen/Qwen3-8B",
lora_enabled=True,
max_lora_rank=64, # 16 or 64, fixed once created
)
c.custom_models.wait_until_ready(base.id)

adapter = c.custom_models.adapters.attach(
base.id,
huggingface_model_id="my-org/qwen3-8b-support-lora",
name="support",
)
adapter = c.custom_models.adapters.wait_until_ready(adapter.id)

resp = c.chat.completions.create(
model=adapter.id,
messages=[{"role": "user", "content": "Hello"}],
)
```

Adapters come from Hugging Face or S3, like a base: pass
`s3_url="https://...presigned..."` for a `.tar.gz` of the adapter
folder, or `s3_url="s3://bucket/prefix/"` with `s3_role_arn`. Attaching
takes a few minutes, and the base and its other adapters keep serving
while it does. A failed adapter raises `graphn.APIError` whose message
starts with the reason, such as `ADAPTER_RANK_EXCEEDS_BASE`.

```python
c.custom_models.adapters.list(base.id) # oldest first
c.custom_models.list(include_adapters=True) # bases and adapters together

c.custom_models.adapters.delete(adapter.id)
c.custom_models.adapters.wait_until_deleted(adapter.id)
c.custom_models.delete(base.id) # ConflictError (HAS_ADAPTERS) until every adapter is gone
```

See [`examples/lora_adapters.py`](examples/lora_adapters.py) for a
runnable script.

## Streaming

```python
Expand Down
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ python examples/import_and_chat.py
|---|---|
| [`import_and_chat.py`](import_and_chat.py) | Full lifecycle from HuggingFace: validate → create → wait → chat → delete. The "hello world" of the SDK. |
| [`import_from_s3.py`](import_from_s3.py) | Import weights from S3 — presigned URL or assume-role flavor — then chat. |
| [`lora_adapters.py`](lora_adapters.py) | Several LoRA adapters on one shared base: create the base, attach, chat with each, clean up. |
| [`streaming.py`](streaming.py) | Streaming chat completions (server-sent events). |
| [`async_client.py`](async_client.py) | Async equivalent of `import_and_chat.py` using `graphn.AsyncClient`. |
| [`openai_compat.py`](openai_compat.py) | Calling Graphn from the official `openai` Python SDK directly, for migrating existing code. |
Expand Down
69 changes: 69 additions & 0 deletions examples/lora_adapters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Serve several LoRA adapters from one shared base.

Usage:
GRAPHN_API_KEY=gn_... GRAPHN_WORKSPACE_ID=ws_... \
python examples/lora_adapters.py \
--adapter my-org/qwen3-8b-support-lora \
--adapter my-org/qwen3-8b-sql-lora

Creates a shared LoRA base, attaches each adapter, asks every adapter
the same question, then deletes the adapters and the base. Pass --keep
to leave them deployed. Each adapter must be a plain PEFT LoRA trained
on --base with a rank no higher than --max-rank.
"""

from __future__ import annotations

import argparse
import time

import graphn


def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base", default="Qwen/Qwen3-8B", help="Hugging Face base model.")
parser.add_argument("--max-rank", type=int, choices=(16, 64), default=64)
parser.add_argument(
"--adapter", action="append", required=True, help="Hugging Face adapter repo."
)
parser.add_argument("--prompt", default="In one sentence, what do you do?")
parser.add_argument("--keep", action="store_true", help="Leave the base and adapters.")
args = parser.parse_args()

with graphn.Client() as c:
base = c.custom_models.create(
name=f"lora-base-{int(time.time())}",
huggingface_model_id=args.base,
lora_enabled=True,
max_lora_rank=args.max_rank,
)
print(f"base {base.id}: waiting for it to be ready ...", flush=True)
c.custom_models.wait_until_ready(base.id)

adapters = [
c.custom_models.adapters.attach(base.id, huggingface_model_id=repo)
for repo in args.adapter
]
for adapter in adapters:
adapter = c.custom_models.adapters.wait_until_ready(adapter.id)
resp = c.chat.completions.create(
model=adapter.id,
messages=[{"role": "user", "content": args.prompt}],
max_tokens=120,
)
print(f"\n{adapter.name} ({adapter.id}): {resp.choices[0].message.content}")

if args.keep:
print(f"\nkeeping base {base.id} and {len(adapters)} adapter(s)")
return
for adapter in adapters:
c.custom_models.adapters.delete(adapter.id)
for adapter in adapters:
c.custom_models.adapters.wait_until_deleted(adapter.id)
c.custom_models.delete(base.id)
print(f"\ncleaned up base {base.id}")


if __name__ == "__main__":
main()
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "graphn"
version = "0.2.0"
version = "0.3.0"
description = "Official Python SDK for the Graphn API: workflows, agents, knowledge bases, custom models, and OpenAI-compatible inference."
readme = "README.md"
requires-python = ">=3.10"
Expand Down
2 changes: 2 additions & 0 deletions src/graphn/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
CustomModelAccess,
CustomModelStatus,
GpuHoursResponse,
LoraRank,
Quantization,
SupportedArchitectures,
ValidateModelResponse,
Expand Down Expand Up @@ -76,6 +77,7 @@
"ImportedModel",
"IngestJob",
"KnowledgeBase",
"LoraRank",
"McpServer",
"NotFoundError",
"Organization",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
from http import HTTPStatus
from typing import Any, cast
from urllib.parse import quote

import httpx

from ... import errors
from ...client import AuthenticatedClient, Client
from ...models.admin_invoice_create_request import AdminInvoiceCreateRequest
from ...models.admin_invoice_create_response import AdminInvoiceCreateResponse
from ...models.error import Error
from ...types import Response


def _get_kwargs(
org_id: str,
*,
body: AdminInvoiceCreateRequest,
) -> dict[str, Any]:
headers: dict[str, Any] = {}

_kwargs: dict[str, Any] = {
"method": "post",
"url": "/v1/admin/orgs/{org_id}/invoices".format(
org_id=quote(str(org_id), safe=""),
),
}

_kwargs["json"] = body.to_dict()

headers["Content-Type"] = "application/json"

_kwargs["headers"] = headers
return _kwargs


def _parse_response(
*, client: AuthenticatedClient | Client, response: httpx.Response
) -> AdminInvoiceCreateResponse | Any | Error | None:
if response.status_code == 200:
response_200 = AdminInvoiceCreateResponse.from_dict(response.json())

return response_200

if response.status_code == 400:
response_400 = Error.from_dict(response.json())

return response_400

if response.status_code == 404:
response_404 = Error.from_dict(response.json())

return response_404

if response.status_code == 409:
response_409 = cast(Any, None)
return response_409

if response.status_code == 502:
response_502 = cast(Any, None)
return response_502

if client.raise_on_unexpected_status:
raise errors.UnexpectedStatus(response.status_code, response.content)
else:
return None


def _build_response(
*, client: AuthenticatedClient | Client, response: httpx.Response
) -> Response[AdminInvoiceCreateResponse | Any | Error]:
return Response(
status_code=HTTPStatus(response.status_code),
content=response.content,
headers=response.headers,
parsed=_parse_response(client=client, response=response),
)


def sync_detailed(
org_id: str,
*,
client: AuthenticatedClient | Client,
body: AdminInvoiceCreateRequest,
) -> Response[AdminInvoiceCreateResponse | Any | Error]:
"""Create or replay an immutable invoice

Args:
org_id (str):
body (AdminInvoiceCreateRequest):

Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.

Returns:
Response[AdminInvoiceCreateResponse | Any | Error]
"""

kwargs = _get_kwargs(
org_id=org_id,
body=body,
)

response = client.get_httpx_client().request(
**kwargs,
)

return _build_response(client=client, response=response)


def sync(
org_id: str,
*,
client: AuthenticatedClient | Client,
body: AdminInvoiceCreateRequest,
) -> AdminInvoiceCreateResponse | Any | Error | None:
"""Create or replay an immutable invoice

Args:
org_id (str):
body (AdminInvoiceCreateRequest):

Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.

Returns:
AdminInvoiceCreateResponse | Any | Error
"""

return sync_detailed(
org_id=org_id,
client=client,
body=body,
).parsed


async def asyncio_detailed(
org_id: str,
*,
client: AuthenticatedClient | Client,
body: AdminInvoiceCreateRequest,
) -> Response[AdminInvoiceCreateResponse | Any | Error]:
"""Create or replay an immutable invoice

Args:
org_id (str):
body (AdminInvoiceCreateRequest):

Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.

Returns:
Response[AdminInvoiceCreateResponse | Any | Error]
"""

kwargs = _get_kwargs(
org_id=org_id,
body=body,
)

response = await client.get_async_httpx_client().request(**kwargs)

return _build_response(client=client, response=response)


async def asyncio(
org_id: str,
*,
client: AuthenticatedClient | Client,
body: AdminInvoiceCreateRequest,
) -> AdminInvoiceCreateResponse | Any | Error | None:
"""Create or replay an immutable invoice

Args:
org_id (str):
body (AdminInvoiceCreateRequest):

Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.

Returns:
AdminInvoiceCreateResponse | Any | Error
"""

return (
await asyncio_detailed(
org_id=org_id,
client=client,
body=body,
)
).parsed
Loading
Loading