dataspires 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- afrilink/__init__.py +205 -0
- afrilink/adapt.py +60 -0
- afrilink/api_key_auth.py +255 -0
- afrilink/auth.py +459 -0
- afrilink/billing_auth.py +38 -0
- afrilink/build.py +806 -0
- afrilink/checkpoint.py +124 -0
- afrilink/cineca_auth.py +924 -0
- afrilink/client.py +1797 -0
- afrilink/cluster_metrics_hook.py +67 -0
- afrilink/container.py +753 -0
- afrilink/credentials.py +266 -0
- afrilink/distributed.py +515 -0
- afrilink/docker_runner.py +437 -0
- afrilink/finetune.py +1660 -0
- afrilink/gcp_auth.py +180 -0
- afrilink/generate.py +565 -0
- afrilink/help.py +1086 -0
- afrilink/inference.py +269 -0
- afrilink/initialize.py +52 -0
- afrilink/k8s_runner.py +422 -0
- afrilink/metrics.py +170 -0
- afrilink/opentoken_auth.py +207 -0
- afrilink/pretrain.py +646 -0
- afrilink/pretrain_api.py +245 -0
- afrilink/progress.py +125 -0
- afrilink/registry.py +657 -0
- afrilink/routing.py +384 -0
- afrilink/session.py +433 -0
- afrilink/slurm.py +636 -0
- afrilink/telemetry.py +816 -0
- afrilink/train.py +1419 -0
- afrilink/train_config.py +203 -0
- afrilink/transfer.py +591 -0
- afrilink/vlm.py +141 -0
- afrilink/vouchers.py +176 -0
- afrilink/watchdog.py +315 -0
- dataspires/__init__.py +26 -0
- dataspires-0.1.0.dist-info/METADATA +451 -0
- dataspires-0.1.0.dist-info/RECORD +43 -0
- dataspires-0.1.0.dist-info/WHEEL +5 -0
- dataspires-0.1.0.dist-info/licenses/LICENSE +21 -0
- dataspires-0.1.0.dist-info/top_level.txt +2 -0
afrilink/__init__.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""
|
|
2
|
+
DataSpires SDK — Train, finetune, and generate on GPUs from your notebook.
|
|
3
|
+
|
|
4
|
+
Quick start:
|
|
5
|
+
from dataspires import DataSpiresClient
|
|
6
|
+
client = DataSpiresClient()
|
|
7
|
+
client.authenticate()
|
|
8
|
+
|
|
9
|
+
ft = client.finetune(model="qwen2.5-0.5b", training_mode="low", data=my_dataset, gpus=1)
|
|
10
|
+
result = ft.run(wait=True)
|
|
11
|
+
|
|
12
|
+
User guide:
|
|
13
|
+
import dataspires
|
|
14
|
+
dataspires.docs("help")
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.0"
|
|
18
|
+
|
|
19
|
+
# Credentials provisioning (auto-runs in Colab)
|
|
20
|
+
from .credentials import (
|
|
21
|
+
provision_credentials,
|
|
22
|
+
get_cineca_credentials,
|
|
23
|
+
CredentialProvider,
|
|
24
|
+
OrgCredentials,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Core authentication
|
|
28
|
+
from .auth import AfriLinkAuth, authenticate, AuthResult
|
|
29
|
+
from .cineca_auth import CinecaDirectAuth, authenticate_cineca, CinecaAuthResult
|
|
30
|
+
|
|
31
|
+
# Telemetry
|
|
32
|
+
from .telemetry import TelemetryClient, JobUsageTracker, track_cineca_jobs
|
|
33
|
+
|
|
34
|
+
# Finetuning API
|
|
35
|
+
from .finetune import finetune, FinetuneJob, FinetuneJobSpec, TrainingMode, TrainingConfig, SUPPORTED_BACKENDS, DEFAULT_BACKEND
|
|
36
|
+
|
|
37
|
+
# Generate API (frozen run)
|
|
38
|
+
from .generate import generate, GenerateJob, GenerateJobSpec, GENERATE_RUNNER_MODELS
|
|
39
|
+
|
|
40
|
+
# Pretrain API (managed recipes + architecture scratch)
|
|
41
|
+
from .pretrain import pretrain, PretrainJob, PretrainJobSpec
|
|
42
|
+
|
|
43
|
+
# Training API (deprecated — use pretrain)
|
|
44
|
+
from .train import train, TrainJob, TrainJobSpec, CONTAINER_REGISTRY
|
|
45
|
+
|
|
46
|
+
# Routing helpers
|
|
47
|
+
from .routing import KIND_REGISTRY, ARCHITECTURE_CATALOG, ArchitectureClass, get_kind
|
|
48
|
+
|
|
49
|
+
# SLURM job management
|
|
50
|
+
from .slurm import SlurmJobManager, SlurmConfig
|
|
51
|
+
|
|
52
|
+
# Distributed training
|
|
53
|
+
from .distributed import DistributedConfig, DistributedStrategy, get_optimal_strategy
|
|
54
|
+
|
|
55
|
+
# Model/dataset registry
|
|
56
|
+
from .registry import (
|
|
57
|
+
ModelRegistry,
|
|
58
|
+
ModelInfo,
|
|
59
|
+
DatasetInfo,
|
|
60
|
+
ModelSize,
|
|
61
|
+
ModelType,
|
|
62
|
+
get_registry,
|
|
63
|
+
list_models,
|
|
64
|
+
list_datasets,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Vouchers
|
|
68
|
+
from .vouchers import generate_voucher, validate_voucher, generate_batch
|
|
69
|
+
|
|
70
|
+
# Data transfer
|
|
71
|
+
from .transfer import DataTransferManager, create_transfer_manager
|
|
72
|
+
|
|
73
|
+
# Session watchdog & recovery
|
|
74
|
+
from .watchdog import SessionWatchdog, EmailNotifier, JobRecoveryInfo
|
|
75
|
+
|
|
76
|
+
# HuggingFace inference
|
|
77
|
+
from .inference import HFInferenceClient, InferenceResult
|
|
78
|
+
|
|
79
|
+
# Custom-image build service (Cloud Build + Artifact Registry)
|
|
80
|
+
from .build import BuildSpec, ImageBuilder, ModelSource, PRESETS as BUILD_PRESETS
|
|
81
|
+
from .gcp_auth import GcpServiceAccountAuth
|
|
82
|
+
|
|
83
|
+
# Main client (recommended entry point)
|
|
84
|
+
from .client import AfriLinkClient, ClientConfig, create_client
|
|
85
|
+
|
|
86
|
+
# Public name for the same client class.
|
|
87
|
+
DataSpiresClient = AfriLinkClient
|
|
88
|
+
|
|
89
|
+
# Help / manual system — prefer: import afrilink; afrilink.docs("help")
|
|
90
|
+
from .help import help_system as _help_system, _resolve_topic as _resolve_help_topic
|
|
91
|
+
|
|
92
|
+
import sys as _sys
|
|
93
|
+
import types as _types
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class _AfriLinkModule(_types.ModuleType):
|
|
97
|
+
"""Module subclass that implements afrilink / \"topic\" slash help."""
|
|
98
|
+
|
|
99
|
+
def __truediv__(self, topic):
|
|
100
|
+
_help_system._show(_resolve_help_topic(topic))
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
def docs(self, topic: str = "help"):
|
|
104
|
+
"""Print an inline help page. Preferred over slash syntax."""
|
|
105
|
+
_help_system._show(topic)
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
def __repr__(self):
|
|
109
|
+
return (
|
|
110
|
+
f"<module 'afrilink' v{__version__}> — "
|
|
111
|
+
'run afrilink.docs("help") for the user guide.'
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
# Replace this module with the instrumented subclass so that
|
|
116
|
+
# `afrilink / "specs"` and `afrilink.docs("specs")` work at module level.
|
|
117
|
+
_current = _sys.modules[__name__]
|
|
118
|
+
_upgraded = _AfriLinkModule(__name__, __doc__)
|
|
119
|
+
_upgraded.__dict__.update({k: v for k, v in _current.__dict__.items()
|
|
120
|
+
if not k.startswith("_upgraded")})
|
|
121
|
+
_sys.modules[__name__] = _upgraded
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
__all__ = [
|
|
125
|
+
"__version__",
|
|
126
|
+
# Main client
|
|
127
|
+
"AfriLinkClient",
|
|
128
|
+
"DataSpiresClient",
|
|
129
|
+
"ClientConfig",
|
|
130
|
+
"create_client",
|
|
131
|
+
# Credentials
|
|
132
|
+
"provision_credentials",
|
|
133
|
+
"get_cineca_credentials",
|
|
134
|
+
"CredentialProvider",
|
|
135
|
+
"OrgCredentials",
|
|
136
|
+
# Authentication
|
|
137
|
+
"AfriLinkAuth",
|
|
138
|
+
"authenticate",
|
|
139
|
+
"AuthResult",
|
|
140
|
+
"CinecaDirectAuth",
|
|
141
|
+
"authenticate_cineca",
|
|
142
|
+
"CinecaAuthResult",
|
|
143
|
+
# Finetuning
|
|
144
|
+
"finetune",
|
|
145
|
+
"FinetuneJob",
|
|
146
|
+
"FinetuneJobSpec",
|
|
147
|
+
"TrainingMode",
|
|
148
|
+
"TrainingConfig",
|
|
149
|
+
"SUPPORTED_BACKENDS",
|
|
150
|
+
"DEFAULT_BACKEND",
|
|
151
|
+
# Generate (frozen run)
|
|
152
|
+
"generate",
|
|
153
|
+
"GenerateJob",
|
|
154
|
+
"GenerateJobSpec",
|
|
155
|
+
"GENERATE_RUNNER_MODELS",
|
|
156
|
+
# Pretrain (managed recipes + architecture scratch)
|
|
157
|
+
"pretrain",
|
|
158
|
+
"PretrainJob",
|
|
159
|
+
"PretrainJobSpec",
|
|
160
|
+
"KIND_REGISTRY",
|
|
161
|
+
"ARCHITECTURE_CATALOG",
|
|
162
|
+
"ArchitectureClass",
|
|
163
|
+
"get_kind",
|
|
164
|
+
# Training (deprecated — use pretrain)
|
|
165
|
+
"train",
|
|
166
|
+
"TrainJob",
|
|
167
|
+
"TrainJobSpec",
|
|
168
|
+
"CONTAINER_REGISTRY",
|
|
169
|
+
# SLURM
|
|
170
|
+
"SlurmJobManager",
|
|
171
|
+
"SlurmConfig",
|
|
172
|
+
# Distributed
|
|
173
|
+
"DistributedConfig",
|
|
174
|
+
"DistributedStrategy",
|
|
175
|
+
"get_optimal_strategy",
|
|
176
|
+
# Registry
|
|
177
|
+
"ModelRegistry",
|
|
178
|
+
"ModelInfo",
|
|
179
|
+
"DatasetInfo",
|
|
180
|
+
"ModelSize",
|
|
181
|
+
"ModelType",
|
|
182
|
+
"get_registry",
|
|
183
|
+
"list_models",
|
|
184
|
+
"list_datasets",
|
|
185
|
+
# Vouchers
|
|
186
|
+
"generate_voucher",
|
|
187
|
+
"validate_voucher",
|
|
188
|
+
"generate_batch",
|
|
189
|
+
# Transfer
|
|
190
|
+
"DataTransferManager",
|
|
191
|
+
"create_transfer_manager",
|
|
192
|
+
# Telemetry
|
|
193
|
+
"TelemetryClient",
|
|
194
|
+
"JobUsageTracker",
|
|
195
|
+
"track_cineca_jobs",
|
|
196
|
+
# Watchdog & recovery
|
|
197
|
+
"SessionWatchdog",
|
|
198
|
+
"EmailNotifier",
|
|
199
|
+
"JobRecoveryInfo",
|
|
200
|
+
# HuggingFace inference
|
|
201
|
+
"HFInferenceClient",
|
|
202
|
+
"InferenceResult",
|
|
203
|
+
# Help system
|
|
204
|
+
"help_system",
|
|
205
|
+
]
|
afrilink/adapt.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Deprecated adapt() — use pretrain(kind=..., pretrained=True).
|
|
3
|
+
|
|
4
|
+
Kept as a thin wrapper for one release; removed in 1.0.0.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import warnings
|
|
10
|
+
from typing import Any, Dict, Optional
|
|
11
|
+
|
|
12
|
+
from .train import TrainJob, TrainJobSpec
|
|
13
|
+
|
|
14
|
+
AdaptJob = TrainJob
|
|
15
|
+
AdaptJobSpec = TrainJobSpec
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def adapt(
|
|
19
|
+
recipe: Optional[str] = None,
|
|
20
|
+
data: Any = None,
|
|
21
|
+
weights: Optional[str] = None,
|
|
22
|
+
model: Optional[str] = None,
|
|
23
|
+
data_config: Optional[str] = None,
|
|
24
|
+
params: Optional[Dict[str, Any]] = None,
|
|
25
|
+
gpus: int = 1,
|
|
26
|
+
time_limit: str = "04:00:00",
|
|
27
|
+
backend: str = None,
|
|
28
|
+
output_dir: str = None,
|
|
29
|
+
script: Optional[str] = None,
|
|
30
|
+
script_content: Optional[str] = None,
|
|
31
|
+
container: str = "afrilink-yolo",
|
|
32
|
+
**kwargs,
|
|
33
|
+
) -> AdaptJob:
|
|
34
|
+
"""Deprecated — use ``pretrain(kind=..., pretrained=True)``."""
|
|
35
|
+
warnings.warn(
|
|
36
|
+
"adapt() is deprecated in AfriLink SDK 0.9.0. "
|
|
37
|
+
"Use pretrain(kind=..., weights=..., data=..., pretrained=True) instead. "
|
|
38
|
+
"adapt() will be removed in 1.0.0.",
|
|
39
|
+
DeprecationWarning,
|
|
40
|
+
stacklevel=2,
|
|
41
|
+
)
|
|
42
|
+
from .pretrain import pretrain
|
|
43
|
+
|
|
44
|
+
return pretrain(
|
|
45
|
+
kind=recipe,
|
|
46
|
+
data=data,
|
|
47
|
+
weights=weights,
|
|
48
|
+
model=model,
|
|
49
|
+
data_config=data_config,
|
|
50
|
+
params=params,
|
|
51
|
+
pretrained=True,
|
|
52
|
+
gpus=gpus,
|
|
53
|
+
time_limit=time_limit,
|
|
54
|
+
backend=backend,
|
|
55
|
+
output_dir=output_dir,
|
|
56
|
+
script=script,
|
|
57
|
+
script_content=script_content,
|
|
58
|
+
container=container,
|
|
59
|
+
**kwargs,
|
|
60
|
+
)
|
afrilink/api_key_auth.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AfriLink API-Key Auth — stateless SDK auth via Cloudflare Worker exchange.
|
|
3
|
+
|
|
4
|
+
Users mint API keys at https://dataspires.com/dashboard/profile and pass them
|
|
5
|
+
to the SDK either as the `api_key=` arg to `client.authenticate()` or via the
|
|
6
|
+
`AFRILINK_API_KEY` env var (Colab/Kaggle "Add a secret" UI).
|
|
7
|
+
|
|
8
|
+
The SDK hits `https://api.dataspires.com/v1/sdk-auth/exchange` with the key
|
|
9
|
+
in `Authorization: Bearer ...`, and receives back everything needed for the
|
|
10
|
+
session: Supabase JWT (for RLS-protected writes against `sessions` and
|
|
11
|
+
`deduct_credits`), Supabase project URL + anon key, and the shared A100 SSH
|
|
12
|
+
key (base64) which the SDK lands in a per-process tempfile at 0600.
|
|
13
|
+
|
|
14
|
+
Nothing persists. Kill the process and the SSH key tempfile + JWT in memory
|
|
15
|
+
are gone with it.
|
|
16
|
+
|
|
17
|
+
NOTE: v1 uses a single shared `dataspires` SSH key for the A100 across all
|
|
18
|
+
users. Per-user keys are planned as a follow-up so the A100's `last login`
|
|
19
|
+
audit reflects the actual DataSpires identity.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import base64
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import urllib.error
|
|
26
|
+
import urllib.request
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from typing import Optional
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# The afrilink-sdk-auth Cloudflare Worker. Default points at the working
|
|
32
|
+
# .workers.dev URL; override via AFRILINK_AUTH_URL once api.dataspires.com
|
|
33
|
+
# is wired to its Custom Domain in the Cloudflare dashboard.
|
|
34
|
+
DEFAULT_EXCHANGE_URL = (
|
|
35
|
+
"https://afrilink-sdk-auth.sweet-star-2474.workers.dev/v1/sdk-auth/exchange"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class ApiKeyExchangeResult:
|
|
41
|
+
"""Everything the SDK needs to run jobs after an API-key exchange."""
|
|
42
|
+
success: bool
|
|
43
|
+
error: Optional[str] = None
|
|
44
|
+
user_id: Optional[str] = None
|
|
45
|
+
email: Optional[str] = None
|
|
46
|
+
balance_usd: float = 0.0
|
|
47
|
+
supabase_jwt: Optional[str] = None
|
|
48
|
+
supabase_url: Optional[str] = None
|
|
49
|
+
supabase_anon_key: Optional[str] = None
|
|
50
|
+
a100_host: Optional[str] = None
|
|
51
|
+
a100_user: Optional[str] = None
|
|
52
|
+
a100_ssh_key_material: Optional[str] = None # decoded PEM, not base64
|
|
53
|
+
# GHCR creds for pulling private curated containers (org-owned)
|
|
54
|
+
ghcr_token: Optional[str] = None
|
|
55
|
+
ghcr_username: Optional[str] = None
|
|
56
|
+
# GCP build service bundle (Cloud Build + Artifact Registry on anadrome)
|
|
57
|
+
gcp_sa_key_json: Optional[str] = None # single-line JSON of the SA key
|
|
58
|
+
gcp_project: Optional[str] = None
|
|
59
|
+
gcp_region: Optional[str] = None
|
|
60
|
+
gcp_ar_repo: Optional[str] = None
|
|
61
|
+
gcp_build_bucket: Optional[str] = None
|
|
62
|
+
expires_at: Optional[int] = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _post_json(url: str, body: dict, headers: dict, timeout: int = 15) -> dict:
|
|
66
|
+
"""Minimal HTTP POST with stdlib fallback so the SDK works without `requests`."""
|
|
67
|
+
try:
|
|
68
|
+
import requests
|
|
69
|
+
resp = requests.post(url, json=body, headers=headers, timeout=timeout)
|
|
70
|
+
try:
|
|
71
|
+
return {"status": resp.status_code, "body": resp.json()}
|
|
72
|
+
except Exception:
|
|
73
|
+
return {"status": resp.status_code, "body": {"raw": resp.text[:500]}}
|
|
74
|
+
except ImportError:
|
|
75
|
+
req = urllib.request.Request(
|
|
76
|
+
url,
|
|
77
|
+
data=json.dumps(body).encode("utf-8") if body else b"",
|
|
78
|
+
headers={**headers, "Content-Type": "application/json"},
|
|
79
|
+
method="POST",
|
|
80
|
+
)
|
|
81
|
+
try:
|
|
82
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
83
|
+
return {"status": r.status, "body": json.loads(r.read().decode("utf-8"))}
|
|
84
|
+
except urllib.error.HTTPError as e:
|
|
85
|
+
try:
|
|
86
|
+
return {"status": e.code, "body": json.loads(e.read().decode("utf-8"))}
|
|
87
|
+
except Exception:
|
|
88
|
+
return {"status": e.code, "body": {"error": str(e)}}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def resolve_api_key(explicit: Optional[str] = None) -> Optional[str]:
|
|
92
|
+
"""
|
|
93
|
+
Resolution order:
|
|
94
|
+
1. `explicit` argument
|
|
95
|
+
2. API_KEY env / notebook secret
|
|
96
|
+
3. AFRILINK_API_KEY env / notebook secret (legacy)
|
|
97
|
+
4. None — caller must prompt
|
|
98
|
+
"""
|
|
99
|
+
return resolve_secret("API_KEY", explicit=explicit) or resolve_secret(
|
|
100
|
+
"AFRILINK_API_KEY", explicit=None
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def resolve_secret(name: str, explicit: Optional[str] = None) -> Optional[str]:
|
|
105
|
+
"""
|
|
106
|
+
Generic secret resolver. Resolution order:
|
|
107
|
+
1. `explicit` arg
|
|
108
|
+
2. `os.environ[name]`
|
|
109
|
+
3. Colab notebook secret (google.colab.userdata)
|
|
110
|
+
4. Kaggle notebook secret (kaggle_secrets.UserSecretsClient)
|
|
111
|
+
5. None
|
|
112
|
+
|
|
113
|
+
Used for AFRILINK_API_KEY, HUGGINGFACE_TOKEN, and other third-party
|
|
114
|
+
tokens users set as notebook secrets without exporting to os.environ.
|
|
115
|
+
"""
|
|
116
|
+
if explicit:
|
|
117
|
+
return explicit.strip()
|
|
118
|
+
env = os.environ.get(name)
|
|
119
|
+
if env:
|
|
120
|
+
return env.strip()
|
|
121
|
+
|
|
122
|
+
# Colab
|
|
123
|
+
try:
|
|
124
|
+
from google.colab import userdata # type: ignore
|
|
125
|
+
try:
|
|
126
|
+
v = userdata.get(name)
|
|
127
|
+
if v:
|
|
128
|
+
# Mirror into os.environ so downstream SDK paths (subprocesses,
|
|
129
|
+
# docker -e flags, dataset loaders) see it without re-resolving.
|
|
130
|
+
os.environ[name] = v.strip()
|
|
131
|
+
return v.strip()
|
|
132
|
+
except Exception:
|
|
133
|
+
pass
|
|
134
|
+
except ImportError:
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
# Kaggle
|
|
138
|
+
try:
|
|
139
|
+
from kaggle_secrets import UserSecretsClient # type: ignore
|
|
140
|
+
try:
|
|
141
|
+
v = UserSecretsClient().get_secret(name)
|
|
142
|
+
if v:
|
|
143
|
+
os.environ[name] = v.strip()
|
|
144
|
+
return v.strip()
|
|
145
|
+
except Exception:
|
|
146
|
+
# Kaggle raises if the secret isn't attached to the notebook;
|
|
147
|
+
# we want to fall through to the explicit-prompt path, not crash.
|
|
148
|
+
pass
|
|
149
|
+
except ImportError:
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def exchange_api_key(
|
|
156
|
+
api_key: str,
|
|
157
|
+
exchange_url: str = DEFAULT_EXCHANGE_URL,
|
|
158
|
+
) -> ApiKeyExchangeResult:
|
|
159
|
+
"""
|
|
160
|
+
Exchange a plaintext API key for a session bundle.
|
|
161
|
+
|
|
162
|
+
Network failure → ApiKeyExchangeResult(success=False, error="…")
|
|
163
|
+
401 → "Invalid or revoked API key"
|
|
164
|
+
Anything else → the Worker's `error` field
|
|
165
|
+
"""
|
|
166
|
+
if not api_key or not api_key.startswith(("afk_live_", "afk_test_")):
|
|
167
|
+
return ApiKeyExchangeResult(
|
|
168
|
+
success=False,
|
|
169
|
+
error="Malformed API key (expected to start with afk_live_ or afk_test_)",
|
|
170
|
+
)
|
|
171
|
+
try:
|
|
172
|
+
resp = _post_json(
|
|
173
|
+
exchange_url,
|
|
174
|
+
body={},
|
|
175
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
176
|
+
)
|
|
177
|
+
except Exception as e:
|
|
178
|
+
return ApiKeyExchangeResult(success=False, error=f"Network error: {e}")
|
|
179
|
+
|
|
180
|
+
status = resp.get("status", 0)
|
|
181
|
+
data = resp.get("body") or {}
|
|
182
|
+
if status != 200 or not data.get("ok"):
|
|
183
|
+
return ApiKeyExchangeResult(
|
|
184
|
+
success=False,
|
|
185
|
+
error=(data.get("error") or f"HTTP {status}"),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Decode the SSH key from base64 into PEM material
|
|
189
|
+
key_b64 = data.get("a100_ssh_key_b64") or ""
|
|
190
|
+
try:
|
|
191
|
+
key_material = base64.b64decode(key_b64).decode("utf-8")
|
|
192
|
+
except Exception:
|
|
193
|
+
key_material = None
|
|
194
|
+
|
|
195
|
+
return ApiKeyExchangeResult(
|
|
196
|
+
success=True,
|
|
197
|
+
user_id=data.get("user_id"),
|
|
198
|
+
email=data.get("email"),
|
|
199
|
+
balance_usd=float(data.get("balance_usd") or 0),
|
|
200
|
+
supabase_jwt=data.get("supabase_jwt"),
|
|
201
|
+
supabase_url=data.get("supabase_url"),
|
|
202
|
+
supabase_anon_key=data.get("supabase_anon_key"),
|
|
203
|
+
a100_host=data.get("a100_host"),
|
|
204
|
+
a100_user=data.get("a100_user"),
|
|
205
|
+
a100_ssh_key_material=key_material,
|
|
206
|
+
ghcr_token=data.get("ghcr_token"),
|
|
207
|
+
ghcr_username=data.get("ghcr_username"),
|
|
208
|
+
gcp_sa_key_json=data.get("gcp_sa_key_json"),
|
|
209
|
+
gcp_project=data.get("gcp_project"),
|
|
210
|
+
gcp_region=data.get("gcp_region"),
|
|
211
|
+
gcp_ar_repo=data.get("gcp_ar_repo"),
|
|
212
|
+
gcp_build_bucket=data.get("gcp_build_bucket"),
|
|
213
|
+
expires_at=data.get("expires_at"),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ── Convenience: a minimal AuthClient stand-in that the rest of the SDK
|
|
218
|
+
# (telemetry.SupabaseTelemetryWriter, JobUsageTracker) treats just like
|
|
219
|
+
# AfriLinkAuth. Same attribute surface, populated from the exchange.
|
|
220
|
+
|
|
221
|
+
class ApiKeyAuthClient:
|
|
222
|
+
"""
|
|
223
|
+
Drop-in replacement for AfriLinkAuth when authenticating via API key.
|
|
224
|
+
|
|
225
|
+
Exposes the same attributes the rest of the SDK reads:
|
|
226
|
+
user_id, access_token, supabase_url, supabase_key, balance_usd,
|
|
227
|
+
is_authenticated
|
|
228
|
+
"""
|
|
229
|
+
|
|
230
|
+
def __init__(self, result: ApiKeyExchangeResult, api_key: str = None, exchange_url: str = None):
|
|
231
|
+
self._result = result
|
|
232
|
+
self._api_key = api_key
|
|
233
|
+
self._exchange_url = exchange_url or DEFAULT_EXCHANGE_URL
|
|
234
|
+
self.user_id = result.user_id
|
|
235
|
+
self.email = result.email
|
|
236
|
+
self.access_token = result.supabase_jwt
|
|
237
|
+
self.supabase_url = result.supabase_url
|
|
238
|
+
self.supabase_key = result.supabase_anon_key
|
|
239
|
+
self.balance_usd = result.balance_usd
|
|
240
|
+
|
|
241
|
+
def refresh_access_token(self) -> bool:
|
|
242
|
+
"""Re-exchange API key for a fresh Supabase JWT (billing writes)."""
|
|
243
|
+
if not self._api_key:
|
|
244
|
+
return False
|
|
245
|
+
result = exchange_api_key(self._api_key, exchange_url=self._exchange_url)
|
|
246
|
+
if not result.success or not result.supabase_jwt:
|
|
247
|
+
return False
|
|
248
|
+
self._result = result
|
|
249
|
+
self.access_token = result.supabase_jwt
|
|
250
|
+
self.balance_usd = result.balance_usd
|
|
251
|
+
return True
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def is_authenticated(self) -> bool:
|
|
255
|
+
return self._result.success and bool(self._result.supabase_jwt)
|