feat: backend API key auth + access-endpoint route for external OpenAI-compatible access

- Auto-generate sk-unsloth-* API key on model load, clear on unload
- Add dual-auth dependency: accepts JWT (Studio UI) or API key (external consumers)
- Relax auth on /v1/chat/completions and /v1/models to accept API key
- Add GET /api/inference/access-endpoint returning key, local URL, and external URL
- Store bind_host in app.state for external IP resolution
- Frontend dialog fetches key from backend, shows local + network URLs
This commit is contained in:
Roland Tannous 2026-04-09 01:23:36 +00:00
commit 659b08866e
4 changed files with 152 additions and 38 deletions

View file

@ -5,7 +5,7 @@ import secrets
from datetime import datetime, timedelta, timezone
from typing import Optional, Tuple
from fastapi import Depends, HTTPException, status
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
import jwt
@ -170,3 +170,30 @@ async def _get_current_subject(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Invalid or expired token",
)
# ── Dual-auth for OpenAI-compatible endpoints ───────────────────
_optional_security = HTTPBearer(auto_error = False)
async def get_current_subject_or_api_key(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_optional_security),
) -> str:
"""Accept a valid JWT or the auto-generated API key for OpenAI-compatible endpoints."""
if credentials is None:
raise HTTPException(
status_code = status.HTTP_401_UNAUTHORIZED,
detail = "Missing Authorization header",
)
token = credentials.credentials
# Check auto-generated API key (fast path for external consumers)
external_key = getattr(request.app.state, "external_api_key", None)
if external_key and token == external_key:
return "__api_user__"
# Fall back to JWT validation (Studio frontend sessions)
return await _get_current_subject(credentials, allow_password_change = False)

View file

@ -6,6 +6,7 @@ Inference API routes for model loading and text generation.
"""
import os
import secrets as _secrets
import sys
import time
import uuid
@ -84,7 +85,7 @@ from models.inference import (
ValidateModelRequest,
ValidateModelResponse,
)
from auth.authentication import get_current_subject
from auth.authentication import get_current_subject, get_current_subject_or_api_key
import io
import wave
@ -121,6 +122,7 @@ def get_llama_cpp_backend() -> LlamaCppBackend:
@router.post("/load", response_model = LoadResponse)
async def load_model(
request: LoadRequest,
fastapi_request: Request,
current_subject: str = Depends(get_current_subject),
):
"""
@ -301,6 +303,11 @@ async def load_model(
inference_config = load_inference_config(config.identifier)
# Auto-generate API key for external OpenAI-compatible access
fastapi_request.app.state.external_api_key = (
f"sk-unsloth-{_secrets.token_urlsafe(32)}"
)
return LoadResponse(
status = "loaded",
model = config.identifier,
@ -439,6 +446,11 @@ async def load_model(
except Exception:
pass
# Auto-generate API key for external OpenAI-compatible access
fastapi_request.app.state.external_api_key = (
f"sk-unsloth-{_secrets.token_urlsafe(32)}"
)
return LoadResponse(
status = "loaded",
model = config.identifier,
@ -523,6 +535,7 @@ async def validate_model(
@router.post("/unload", response_model = UnloadResponse)
async def unload_model(
request: UnloadRequest,
fastapi_request: Request,
current_subject: str = Depends(get_current_subject),
):
"""
@ -530,6 +543,9 @@ async def unload_model(
Routes to the correct backend (llama-server for GGUF, Unsloth otherwise).
"""
try:
# Clear API key for external access
fastapi_request.app.state.external_api_key = None
# Check if the GGUF backend has this model loaded or is loading it
llama_backend = get_llama_cpp_backend()
if llama_backend.is_active and (
@ -883,11 +899,53 @@ def _extract_content_parts(
return system_prompt, chat_messages, first_image_b64
# ── Access Endpoint (external OpenAI-compatible access) ──────────
@router.get("/access-endpoint")
async def get_access_endpoint(
request: Request,
current_subject: str = Depends(get_current_subject),
):
"""Return API key, local/external URLs, and model info for the Access Endpoint dialog."""
api_key = getattr(request.app.state, "external_api_key", None)
if not api_key:
raise HTTPException(status_code = 400, detail = "No model loaded")
port = getattr(request.app.state, "server_port", None)
local_url = f"http://127.0.0.1:{port}/v1" if port else f"{request.base_url}v1"
# Resolve external IP when bound to all interfaces (same as startup banner)
external_url = None
bind_host = getattr(request.app.state, "bind_host", None)
if bind_host in ("0.0.0.0", "::") and port:
from run import _resolve_external_ip
ext_ip = _resolve_external_ip()
if ext_ip and ext_ip not in ("127.0.0.1", "0.0.0.0", "localhost"):
external_url = f"http://{ext_ip}:{port}/v1"
llama_backend = get_llama_cpp_backend()
backend = get_inference_backend()
model = (
llama_backend.model_identifier
if llama_backend.is_loaded
else (list(backend.models.keys())[0] if backend.models else "default")
)
return {
"api_key": api_key,
"local_url": local_url,
"external_url": external_url,
"model": model,
}
@router.post("/chat/completions")
async def openai_chat_completions(
payload: ChatCompletionRequest,
request: Request,
current_subject: str = Depends(get_current_subject),
current_subject: str = Depends(get_current_subject_or_api_key),
):
"""
OpenAI-compatible chat completions endpoint.
@ -1783,7 +1841,7 @@ async def serve_sandbox_file(
@router.get("/models")
async def openai_list_models(
current_subject: str = Depends(get_current_subject),
current_subject: str = Depends(get_current_subject_or_api_key),
):
"""
OpenAI-compatible model listing endpoint.

View file

@ -331,6 +331,7 @@ def run_server(
# binds (port==0) leave it unset and let request handlers fall back
# to the ASGI request scope or request.base_url.
app.state.server_port = port if port and port > 0 else None
app.state.bind_host = host
# Run server in a daemon thread
def _run():

View file

@ -55,6 +55,7 @@ import {
} from "react";
import { Streamdown } from "streamdown";
import { toast } from "sonner";
import { authFetch } from "@/features/auth";
import { listLocalModels } from "./api/chat-api";
import { ChatSettingsPanel } from "./chat-settings-sheet";
import { ContextUsageBar } from "./components/context-usage-bar";
@ -83,16 +84,8 @@ type LoraCandidate = {
updatedAt?: number;
};
const ENDPOINT_BASE_URL_FALLBACK = "http://127.0.0.1:8001/v1";
const SHIKI_THEME = ["github-light", "github-dark"] as ["github-light", "github-dark"];
function buildDefaultEndpointBaseUrl(): string {
if (typeof window === "undefined") {
return ENDPOINT_BASE_URL_FALLBACK;
}
return `${window.location.origin}/v1`;
}
function HighlightedSnippet({
language,
source,
@ -544,10 +537,10 @@ export function ChatPage(): ReactElement {
const [view, setView] = useState<ChatView>(getInitialSingleChatView);
const [settingsOpen, setSettingsOpen] = useState(false);
const [endpointDialogOpen, setEndpointDialogOpen] = useState(false);
const [endpointBaseUrl, setEndpointBaseUrl] = useState(
buildDefaultEndpointBaseUrl,
);
const [endpointApiKey, setEndpointApiKey] = useState("sk-no-key-required");
const [endpointLocalUrl, setEndpointLocalUrl] = useState("");
const [endpointExternalUrl, setEndpointExternalUrl] = useState<string | null>(null);
const [endpointBaseUrl, setEndpointBaseUrl] = useState("");
const [endpointApiKey, setEndpointApiKey] = useState("");
const [modelSelectorOpen, setModelSelectorOpen] = useState(false);
const [modelSelectorLocked, setModelSelectorLocked] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(true);
@ -616,18 +609,22 @@ print(completion.choices[0].message.content)`,
[endpointApiKey, endpointBaseUrl, modelAlias],
);
useEffect(() => {
if (typeof window === "undefined") return;
setEndpointBaseUrl((prev) =>
prev === ENDPOINT_BASE_URL_FALLBACK ? buildDefaultEndpointBaseUrl() : prev,
);
}, []);
useEffect(() => {
if (!endpointDialogOpen) return;
setEndpointBaseUrl((prev) =>
prev.trim().length === 0 ? buildDefaultEndpointBaseUrl() : prev,
);
let cancelled = false;
authFetch("/api/inference/access-endpoint")
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (cancelled || !data) return;
setEndpointApiKey(data.api_key);
setEndpointLocalUrl(data.local_url);
setEndpointExternalUrl(data.external_url ?? null);
setEndpointBaseUrl(data.local_url);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [endpointDialogOpen]);
const handleCheckpointChange = useCallback(
@ -1101,16 +1098,47 @@ print(completion.choices[0].message.content)`,
</DialogHeader>
<div className="space-y-3">
<div className="grid gap-1.5">
<label htmlFor="endpoint-base-url" className="text-xs font-medium">
Base URL
</label>
<Input
id="endpoint-base-url"
value={endpointBaseUrl}
onChange={(event) => setEndpointBaseUrl(event.target.value)}
className="font-mono text-xs"
/>
<label className="text-xs font-medium">Base URL (Local)</label>
<div className="flex items-center gap-2">
<Input
value={endpointLocalUrl}
readOnly
className="font-mono text-xs"
/>
{endpointBaseUrl !== endpointLocalUrl && (
<Button
variant="ghost"
size="sm"
className="h-8 shrink-0 text-xs"
onClick={() => setEndpointBaseUrl(endpointLocalUrl)}
>
Use
</Button>
)}
</div>
</div>
{endpointExternalUrl && (
<div className="grid gap-1.5">
<label className="text-xs font-medium">Base URL (Network)</label>
<div className="flex items-center gap-2">
<Input
value={endpointExternalUrl}
readOnly
className="font-mono text-xs"
/>
{endpointBaseUrl !== endpointExternalUrl && (
<Button
variant="ghost"
size="sm"
className="h-8 shrink-0 text-xs"
onClick={() => setEndpointBaseUrl(endpointExternalUrl)}
>
Use
</Button>
)}
</div>
</div>
)}
<div className="grid gap-1.5">
<label htmlFor="endpoint-api-key" className="text-xs font-medium">
API Key
@ -1118,18 +1146,18 @@ print(completion.choices[0].message.content)`,
<Input
id="endpoint-api-key"
value={endpointApiKey}
onChange={(event) => setEndpointApiKey(event.target.value)}
readOnly
className="font-mono text-xs"
/>
</div>
<div className="grid gap-1.5">
<label htmlFor="endpoint-model-alias" className="text-xs font-medium">
Model Alias
Model
</label>
<Input
id="endpoint-model-alias"
value={modelAlias}
readOnly={true}
readOnly
className="font-mono text-xs"
/>
</div>