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:
parent
228af61155
commit
659b08866e
4 changed files with 152 additions and 38 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue