diff --git a/studio/backend/auth/authentication.py b/studio/backend/auth/authentication.py index b39f915764..4a18a72f9e 100644 --- a/studio/backend/auth/authentication.py +++ b/studio/backend/auth/authentication.py @@ -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) diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 30ff7da49c..0b72a636b8 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -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. diff --git a/studio/backend/run.py b/studio/backend/run.py index 86c1194661..db2b83b300 100644 --- a/studio/backend/run.py +++ b/studio/backend/run.py @@ -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(): diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index 5d1c31d9e6..55ddabc1dc 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -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(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(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)`,
- - setEndpointBaseUrl(event.target.value)} - className="font-mono text-xs" - /> + +
+ + {endpointBaseUrl !== endpointLocalUrl && ( + + )} +
+ {endpointExternalUrl && ( +
+ +
+ + {endpointBaseUrl !== endpointExternalUrl && ( + + )} +
+
+ )}