Studio: add API key authentication for programmatic access

External users want to hit the Studio API (chat completions with tool
calling, training, export, etc.) without going through the browser
login flow. This adds sk-unsloth- prefixed API keys that work as a
drop-in replacement for JWTs in the Authorization: Bearer header.

Backend:
- New api_keys table in SQLite (storage.py)
- create/list/revoke/validate functions with SHA-256 hashed storage
- API key detection in _get_current_subject before the JWT path
- POST/GET/DELETE /api/auth/api-keys endpoints on the auth router

Frontend:
- /api-keys page with create form, one-time key reveal, keys table
- API Keys link in desktop and mobile navbar
- Route registered with requireAuth guard

Zero changes to any existing route handler -- every endpoint that uses
Depends(get_current_subject) automatically works with API keys.
This commit is contained in:
Daniel Han 2026-04-10 13:33:14 +00:00
commit d2a917b9a4
9 changed files with 711 additions and 2 deletions

View file

@ -10,10 +10,12 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
import jwt
from .storage import (
API_KEY_PREFIX,
get_jwt_secret,
get_user_and_secret,
load_jwt_secret,
save_refresh_token,
validate_api_key,
verify_refresh_token,
)
@ -137,6 +139,18 @@ async def _get_current_subject(
...
"""
token = credentials.credentials
# --- API key path (sk-unsloth-...) ---
if token.startswith(API_KEY_PREFIX):
username = validate_api_key(token)
if username is None:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Invalid or expired API key",
)
return username
# --- JWT path ---
subject = _decode_subject_without_verification(token)
if subject is None:
raise HTTPException(

View file

@ -103,6 +103,21 @@ def get_connection() -> sqlite3.Connection:
);
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
key_prefix TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
name TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
last_used_at TEXT,
expires_at TEXT,
is_active INTEGER NOT NULL DEFAULT 1
);
"""
)
columns = {row["name"] for row in conn.execute("PRAGMA table_info(auth_user)")}
if "must_change_password" not in columns:
conn.execute(
@ -357,3 +372,107 @@ def revoke_user_refresh_tokens(username: str) -> None:
conn.commit()
finally:
conn.close()
# ---------------------------------------------------------------------------
# API key management
# ---------------------------------------------------------------------------
API_KEY_PREFIX = "sk-unsloth-"
def create_api_key(
username: str,
name: str,
expires_at: Optional[str] = None,
) -> Tuple[str, dict]:
"""Create a new API key for *username*.
Returns ``(raw_key, row_dict)`` where *raw_key* is shown to the user
exactly once. The database only stores the SHA-256 hash.
"""
raw_key = API_KEY_PREFIX + secrets.token_hex(16)
key_hash = _hash_token(raw_key)
key_prefix = raw_key[len(API_KEY_PREFIX): len(API_KEY_PREFIX) + 8]
now = datetime.now(timezone.utc).isoformat()
conn = get_connection()
try:
conn.execute(
"""
INSERT INTO api_keys (username, key_prefix, key_hash, name, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(username, key_prefix, key_hash, name, now, expires_at),
)
conn.commit()
cur = conn.execute(
"SELECT * FROM api_keys WHERE key_hash = ?", (key_hash,)
)
row = cur.fetchone()
return raw_key, dict(row)
finally:
conn.close()
def list_api_keys(username: str) -> list:
"""Return all API keys for *username* (never exposes ``key_hash``)."""
conn = get_connection()
try:
cur = conn.execute(
"""
SELECT id, username, key_prefix, name, created_at, last_used_at, expires_at, is_active
FROM api_keys
WHERE username = ?
ORDER BY created_at DESC
""",
(username,),
)
return [dict(row) for row in cur.fetchall()]
finally:
conn.close()
def revoke_api_key(username: str, key_id: int) -> bool:
"""Soft-delete an API key. Returns True if a matching row was found."""
conn = get_connection()
try:
cursor = conn.execute(
"UPDATE api_keys SET is_active = 0 WHERE id = ? AND username = ?",
(key_id, username),
)
conn.commit()
return cursor.rowcount > 0
finally:
conn.close()
def validate_api_key(raw_key: str) -> Optional[str]:
"""Validate *raw_key* and return the owning username, or ``None``.
Also updates ``last_used_at`` on success.
"""
key_hash = _hash_token(raw_key)
conn = get_connection()
try:
cur = conn.execute(
"SELECT id, username, is_active, expires_at FROM api_keys WHERE key_hash = ?",
(key_hash,),
)
row = cur.fetchone()
if row is None:
return None
if not row["is_active"]:
return None
if row["expires_at"] is not None:
expires = datetime.fromisoformat(row["expires_at"])
if datetime.now(timezone.utc) > expires:
return None
conn.execute(
"UPDATE api_keys SET last_used_at = ? WHERE id = ?",
(datetime.now(timezone.utc).isoformat(), row["id"]),
)
conn.commit()
return row["username"]
finally:
conn.close()

View file

@ -5,6 +5,8 @@
Pydantic schemas for Authentication API
"""
from typing import Optional
from pydantic import BaseModel, Field
@ -45,3 +47,42 @@ class ChangePasswordRequest(BaseModel):
new_password: str = Field(
..., min_length = 8, description = "Replacement password (minimum 8 characters)"
)
# ---------------------------------------------------------------------------
# API key schemas
# ---------------------------------------------------------------------------
class CreateApiKeyRequest(BaseModel):
"""Request body to create a new API key."""
name: str = Field(..., description = "Human-readable label for this key")
expires_in_days: Optional[int] = Field(
None, description = "Number of days until the key expires (None = never)"
)
class ApiKeyResponse(BaseModel):
"""Public representation of an API key (never contains the raw key)."""
id: int
name: str
key_prefix: str = Field(..., description = "First 8 characters after sk-unsloth- for display")
created_at: str
last_used_at: Optional[str] = None
expires_at: Optional[str] = None
is_active: bool
class CreateApiKeyResponse(BaseModel):
"""Returned once when a key is created -- ``key`` is never shown again."""
key: str = Field(..., description = "Full API key (shown once)")
api_key: ApiKeyResponse
class ApiKeyListResponse(BaseModel):
"""List of API keys for the authenticated user."""
api_keys: list[ApiKeyResponse]

View file

@ -7,11 +7,17 @@ Authentication API routes
from fastapi import APIRouter, Depends, HTTPException, status
from datetime import datetime, timedelta, timezone
from models.auth import (
ApiKeyListResponse,
ApiKeyResponse,
AuthLoginRequest,
RefreshTokenRequest,
AuthStatusResponse,
ChangePasswordRequest,
CreateApiKeyRequest,
CreateApiKeyResponse,
RefreshTokenRequest,
)
from models.users import Token
from auth import storage, hashing
@ -131,3 +137,68 @@ async def change_password(
token_type = "bearer",
must_change_password = False,
)
# ---------------------------------------------------------------------------
# API key management
# ---------------------------------------------------------------------------
def _row_to_api_key_response(row: dict) -> ApiKeyResponse:
return ApiKeyResponse(
id = row["id"],
name = row["name"],
key_prefix = row["key_prefix"],
created_at = row["created_at"],
last_used_at = row.get("last_used_at"),
expires_at = row.get("expires_at"),
is_active = bool(row["is_active"]),
)
@router.post("/api-keys", response_model = CreateApiKeyResponse)
async def create_api_key(
payload: CreateApiKeyRequest,
current_subject: str = Depends(get_current_subject),
) -> CreateApiKeyResponse:
"""Create a new API key. The raw key is returned once and cannot be retrieved later."""
expires_at = None
if payload.expires_in_days is not None:
expires_at = (
datetime.now(timezone.utc) + timedelta(days = payload.expires_in_days)
).isoformat()
raw_key, row = storage.create_api_key(
username = current_subject,
name = payload.name,
expires_at = expires_at,
)
return CreateApiKeyResponse(
key = raw_key,
api_key = _row_to_api_key_response(row),
)
@router.get("/api-keys", response_model = ApiKeyListResponse)
async def list_api_keys(
current_subject: str = Depends(get_current_subject),
) -> ApiKeyListResponse:
"""List all API keys for the authenticated user (raw keys are never exposed)."""
rows = storage.list_api_keys(current_subject)
return ApiKeyListResponse(
api_keys = [_row_to_api_key_response(r) for r in rows],
)
@router.delete("/api-keys/{key_id}")
async def revoke_api_key(
key_id: int,
current_subject: str = Depends(get_current_subject),
) -> dict:
"""Revoke (soft-delete) an API key."""
if not storage.revoke_api_key(current_subject, key_id):
raise HTTPException(
status_code = status.HTTP_404_NOT_FOUND,
detail = "API key not found",
)
return {"detail": "API key revoked"}

View file

@ -13,6 +13,7 @@ import { Route as loginRoute } from "./routes/login";
import { Route as onboardingRoute } from "./routes/onboarding";
import { Route as changePasswordRoute } from "./routes/change-password";
import { Route as studioRoute } from "./routes/studio";
import { Route as apiKeysRoute } from "./routes/api-keys";
const routeTree = rootRoute.addChildren([
indexRoute,
@ -25,6 +26,7 @@ const routeTree = rootRoute.addChildren([
exportRoute,
dataRecipesRoute,
dataRecipeRoute,
apiKeysRoute,
]);
export const router = createRouter({ routeTree });

View file

@ -0,0 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { createRoute } from "@tanstack/react-router";
import { lazy } from "react";
import { requireAuth } from "../auth-guards";
import { Route as rootRoute } from "./__root";
const ApiKeysPage = lazy(() =>
import("@/features/auth/api-keys-page").then((m) => ({ default: m.ApiKeysPage })),
);
export const Route = createRoute({
getParentRoute: () => rootRoute,
path: "/api-keys",
beforeLoad: () => requireAuth(),
component: ApiKeysPage,
});

View file

@ -29,6 +29,7 @@ import {
ChefHatIcon,
Copy01Icon,
CursorInfo02Icon,
Key01Icon,
PackageIcon,
Tick02Icon,
ZapIcon,
@ -416,6 +417,20 @@ export function Navbar() {
</HoverCardContent>
</HoverCard>
</div>
<div className="flex shrink-0 items-center">
<Link
to="/api-keys"
className={cn(
"flex h-9 items-center gap-1.5 rounded-md px-3 text-sm font-medium transition-colors hover:bg-accent",
pathname === "/api-keys"
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
<HugeiconsIcon icon={Key01Icon} className="size-4" />
API Keys
</Link>
</div>
{tourId ? (
<div className="flex shrink-0 items-center">
<button
@ -529,11 +544,24 @@ export function Navbar() {
</Link>
);
})}
<Link
to="/api-keys"
onClick={() => setMobileOpen(false)}
className={cn(
"mt-3 flex items-center gap-2 rounded-md border px-3 py-2 text-sm font-medium",
pathname === "/api-keys"
? "border-foreground bg-foreground text-background"
: "border-border text-foreground hover:bg-accent",
)}
>
<HugeiconsIcon icon={Key01Icon} className="size-4" />
API Keys
</Link>
<a
href="https://unsloth.ai/docs"
target="_blank"
rel="noopener noreferrer"
className="mt-3 flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium text-foreground hover:bg-accent"
className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium text-foreground hover:bg-accent"
onClick={() => setMobileOpen(false)}
>
<HugeiconsIcon icon={Book03Icon} className="size-4" />

View file

@ -0,0 +1,415 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { DashboardLayout } from "@/components/layout/dashboard-layout";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import {
AlertCircleIcon,
Copy01Icon,
Delete02Icon,
Key01Icon,
Tick02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { authFetch } from "./api";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface ApiKey {
id: number;
name: string;
key_prefix: string;
created_at: string;
last_used_at: string | null;
expires_at: string | null;
is_active: boolean;
}
// ---------------------------------------------------------------------------
// API helpers
// ---------------------------------------------------------------------------
async function fetchApiKeys(): Promise<ApiKey[]> {
const res = await authFetch("/api/auth/api-keys");
if (!res.ok) throw new Error("Failed to load API keys");
const data = (await res.json()) as { api_keys: ApiKey[] };
return data.api_keys;
}
async function createApiKey(
name: string,
expiresInDays: number | null,
): Promise<{ key: string; api_key: ApiKey }> {
const res = await authFetch("/api/auth/api-keys", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
expires_in_days: expiresInDays,
}),
});
if (!res.ok) throw new Error("Failed to create API key");
return res.json();
}
async function revokeApiKey(keyId: number): Promise<void> {
const res = await authFetch(`/api/auth/api-keys/${keyId}`, {
method: "DELETE",
});
if (!res.ok) throw new Error("Failed to revoke API key");
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatDate(iso: string | null): string {
if (!iso) return "--";
const d = new Date(iso);
return d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
// ---------------------------------------------------------------------------
// Components
// ---------------------------------------------------------------------------
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
const handleCopy = () => {
if (!copyToClipboard(text)) return;
setCopied(true);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setCopied(false), 2000);
};
return (
<Button variant="outline" size="sm" onClick={handleCopy} className="shrink-0">
<HugeiconsIcon
icon={copied ? Tick02Icon : Copy01Icon}
className={cn("size-3.5 mr-1.5", copied && "text-emerald-600")}
/>
{copied ? "Copied" : "Copy"}
</Button>
);
}
function RevealKeyDialog({
open,
rawKey,
onClose,
}: {
open: boolean;
rawKey: string;
onClose: () => void;
}) {
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>API Key Created</DialogTitle>
<DialogDescription>
Copy this key now. It will not be shown again.
</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 p-3">
<code className="min-w-0 flex-1 break-all font-mono text-sm">
{rawKey}
</code>
<CopyButton text={rawKey} />
</div>
<div className="flex items-start gap-2 rounded-md border border-amber-500/20 bg-amber-50 p-3 text-amber-800 dark:border-amber-400/20 dark:bg-amber-950/30 dark:text-amber-300">
<HugeiconsIcon icon={AlertCircleIcon} className="mt-0.5 size-4 shrink-0" />
<p className="text-xs leading-relaxed">
Store this key securely. You will not be able to see it again after closing this dialog.
</p>
</div>
<DialogFooter>
<Button onClick={onClose}>Done</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function CreateKeyForm({ onCreated }: { onCreated: (rawKey: string) => void }) {
const [name, setName] = useState("");
const [expiresInDays, setExpiresInDays] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
setLoading(true);
try {
const days = expiresInDays ? parseInt(expiresInDays, 10) : null;
const result = await createApiKey(name.trim(), days);
onCreated(result.key);
setName("");
setExpiresInDays("");
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4 rounded-lg border border-border p-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="key-name">Key name</Label>
<Input
id="key-name"
placeholder="e.g. My application"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="key-expiry">Expires in (days)</Label>
<Input
id="key-expiry"
type="number"
min={1}
placeholder="Leave blank for no expiry"
value={expiresInDays}
onChange={(e) => setExpiresInDays(e.target.value)}
/>
</div>
<Button type="submit" disabled={loading || !name.trim()} className="self-start">
{loading ? "Creating..." : "Create API key"}
</Button>
</form>
);
}
function KeysTable({
keys,
onRevoke,
}: {
keys: ApiKey[];
onRevoke: (id: number) => void;
}) {
if (keys.length === 0) {
return (
<p className="py-8 text-center text-sm text-muted-foreground">
No API keys yet. Create one above.
</p>
);
}
return (
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/40">
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">Name</th>
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">Key</th>
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">Created</th>
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">Last used</th>
<th className="px-4 py-2.5 text-left font-medium text-muted-foreground">Expires</th>
<th className="px-4 py-2.5 text-right font-medium text-muted-foreground" />
</tr>
</thead>
<tbody>
{keys.map((k) => (
<tr
key={k.id}
className={cn(
"border-b border-border last:border-b-0",
!k.is_active && "opacity-50",
)}
>
<td className="px-4 py-2.5 font-medium">{k.name}</td>
<td className="px-4 py-2.5">
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
sk-unsloth-{k.key_prefix}...
</code>
</td>
<td className="px-4 py-2.5 text-muted-foreground">{formatDate(k.created_at)}</td>
<td className="px-4 py-2.5 text-muted-foreground">{formatDate(k.last_used_at)}</td>
<td className="px-4 py-2.5 text-muted-foreground">{formatDate(k.expires_at)}</td>
<td className="px-4 py-2.5 text-right">
{k.is_active ? (
<Button
variant="ghost"
size="sm"
onClick={() => onRevoke(k.id)}
className="text-destructive hover:text-destructive"
>
<HugeiconsIcon icon={Delete02Icon} className="size-3.5 mr-1" />
Revoke
</Button>
) : (
<span className="text-xs text-muted-foreground">Revoked</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function UsageExamples() {
const curlExample = `curl http://localhost:8888/v1/chat/completions \\
-H "Authorization: Bearer sk-unsloth-YOUR_KEY" \\
-H "Content-Type: application/json" \\
-d '{
"messages": [{"role": "user", "content": "Hello"}],
"stream": true
}'`;
const pythonExample = `from openai import OpenAI
client = OpenAI(
base_url="http://localhost:8888/v1",
api_key="sk-unsloth-YOUR_KEY",
)
response = client.chat.completions.create(
model="current",
messages=[{"role": "user", "content": "Hello"}],
stream=True,
)
for chunk in response:
print(chunk.choices[0].delta.content or "", end="")`;
const toolsExample = `curl http://localhost:8888/v1/chat/completions \\
-H "Authorization: Bearer sk-unsloth-YOUR_KEY" \\
-H "Content-Type: application/json" \\
-d '{
"messages": [{"role": "user", "content": "Search for Python 3.13 features"}],
"stream": true,
"enable_tools": true,
"enabled_tools": ["web_search", "python"],
"session_id": "my-session"
}'`;
return (
<div className="flex flex-col gap-4">
<h3 className="text-sm font-semibold">Usage examples</h3>
<div className="flex flex-col gap-3">
<div>
<p className="mb-1.5 text-xs font-medium text-muted-foreground">curl</p>
<pre className="overflow-x-auto rounded-md border border-border bg-muted/40 p-3 font-mono text-xs leading-relaxed">
{curlExample}
</pre>
</div>
<div>
<p className="mb-1.5 text-xs font-medium text-muted-foreground">Python (OpenAI SDK)</p>
<pre className="overflow-x-auto rounded-md border border-border bg-muted/40 p-3 font-mono text-xs leading-relaxed">
{pythonExample}
</pre>
</div>
<div>
<p className="mb-1.5 text-xs font-medium text-muted-foreground">With tools (web search + code execution)</p>
<pre className="overflow-x-auto rounded-md border border-border bg-muted/40 p-3 font-mono text-xs leading-relaxed">
{toolsExample}
</pre>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export function ApiKeysPage() {
const [keys, setKeys] = useState<ApiKey[]>([]);
const [revealedKey, setRevealedKey] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const loadKeys = useCallback(async () => {
try {
setError(null);
const loaded = await fetchApiKeys();
setKeys(loaded);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load API keys");
}
}, []);
useEffect(() => {
void loadKeys();
}, [loadKeys]);
const handleCreated = (rawKey: string) => {
setRevealedKey(rawKey);
void loadKeys();
};
const handleRevoke = async (keyId: number) => {
try {
await revokeApiKey(keyId);
void loadKeys();
} catch {
setError("Failed to revoke key");
}
};
return (
<DashboardLayout>
<div className="flex flex-col gap-8">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg border border-border bg-muted/40">
<HugeiconsIcon icon={Key01Icon} className="size-5" />
</div>
<div>
<h1 className="text-xl font-bold font-heading">API Keys</h1>
<p className="text-sm text-muted-foreground">
Create keys to access Unsloth Studio programmatically via the OpenAI-compatible API.
</p>
</div>
</div>
{error && (
<div className="flex items-center gap-2 rounded-md border border-destructive/20 bg-destructive/5 p-3 text-sm text-destructive">
<HugeiconsIcon icon={AlertCircleIcon} className="size-4 shrink-0" />
{error}
</div>
)}
<CreateKeyForm onCreated={handleCreated} />
<KeysTable keys={keys} onRevoke={handleRevoke} />
<UsageExamples />
</div>
<RevealKeyDialog
open={revealedKey !== null}
rawKey={revealedKey ?? ""}
onClose={() => setRevealedKey(null)}
/>
</DashboardLayout>
);
}

View file

@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
export { ApiKeysPage } from "./api-keys-page";
export { LoginPage } from "./login-page";
export { ChangePasswordPage } from "./change-password-page";
export { authFetch, refreshSession } from "./api";