Compare commits
18 commits
main
...
feat/acces
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
783ea77b9f | ||
|
|
46fcf98e74 | ||
|
|
37f816d21d | ||
|
|
84fe5a7b48 | ||
|
|
ccb36cad3b | ||
|
|
b82e05af62 | ||
|
|
545aecfe92 | ||
|
|
bb63463ac8 | ||
|
|
b510a82be7 | ||
|
|
81778a2c2d | ||
|
|
8a574de915 | ||
|
|
b502ec065a | ||
|
|
43a633e550 | ||
|
|
f40f7c36e7 | ||
|
|
5973978079 | ||
|
|
ca1a801300 | ||
|
|
659b08866e | ||
|
|
228af61155 |
12 changed files with 1541 additions and 7 deletions
|
|
@ -5,7 +5,7 @@ import secrets
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional, Tuple
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, status
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
import jwt
|
import jwt
|
||||||
|
|
||||||
|
|
@ -170,3 +170,61 @@ async def _get_current_subject(
|
||||||
status_code = status.HTTP_401_UNAUTHORIZED,
|
status_code = status.HTTP_401_UNAUTHORIZED,
|
||||||
detail = "Invalid or expired token",
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_subject_or_api_key_anthropic(
|
||||||
|
request: Request,
|
||||||
|
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_optional_security),
|
||||||
|
) -> str:
|
||||||
|
"""Accept x-api-key header, Authorization: Bearer, or JWT.
|
||||||
|
|
||||||
|
The Anthropic SDK sends ``x-api-key: <key>`` instead of
|
||||||
|
``Authorization: Bearer <key>``. This dependency checks both so that
|
||||||
|
the ``/v1/messages`` endpoint works with both Anthropic and OpenAI SDKs.
|
||||||
|
"""
|
||||||
|
external_key = getattr(request.app.state, "external_api_key", None)
|
||||||
|
|
||||||
|
# Check x-api-key header first (Anthropic SDK default)
|
||||||
|
x_api_key = request.headers.get("x-api-key")
|
||||||
|
if x_api_key and external_key and x_api_key == external_key:
|
||||||
|
return "__api_user__"
|
||||||
|
|
||||||
|
# Fall through to standard Bearer / JWT check
|
||||||
|
if credentials is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code = status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail = "Missing Authorization or x-api-key header",
|
||||||
|
)
|
||||||
|
|
||||||
|
token = credentials.credentials
|
||||||
|
if external_key and token == external_key:
|
||||||
|
return "__api_user__"
|
||||||
|
|
||||||
|
return await _get_current_subject(credentials, allow_password_change = False)
|
||||||
|
|
|
||||||
95
studio/backend/core/inference/key_exchange.py
Normal file
95
studio/backend/core/inference/key_exchange.py
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||||
|
|
||||||
|
"""
|
||||||
|
RSA + AES-GCM hybrid encryption for transmitting the Access Endpoint
|
||||||
|
API key to the Studio frontend over plain HTTP.
|
||||||
|
|
||||||
|
Flow (server -> client):
|
||||||
|
1. Server generates an RSA-2048 key pair at startup (in memory).
|
||||||
|
2. Frontend fetches the public key via GET /access-endpoint/public-key.
|
||||||
|
3. Frontend generates a fresh AES-256 session key per reveal.
|
||||||
|
4. Frontend RSA-OAEP-encrypts the session key with the server's public key
|
||||||
|
and POSTs it to /access-endpoint/reveal.
|
||||||
|
5. Server decrypts the session key with its private key, then AES-GCM
|
||||||
|
encrypts the API key payload and returns {iv, ciphertext}.
|
||||||
|
6. Frontend AES-GCM-decrypts the payload locally using the session key it
|
||||||
|
generated.
|
||||||
|
|
||||||
|
The key pair is regenerated on every restart — there is no long-lived secret
|
||||||
|
material on disk. The only plaintext bearer token ever hitting the wire in
|
||||||
|
either direction is protected by RSA-OAEP-SHA256 + AES-256-GCM.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives import hashes, serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_private_key: rsa.RSAPrivateKey | None = None
|
||||||
|
_public_key_pem: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_key_pair() -> None:
|
||||||
|
"""Generate an RSA-2048 key pair. Called once at server startup."""
|
||||||
|
global _private_key, _public_key_pem
|
||||||
|
_private_key = rsa.generate_private_key(
|
||||||
|
public_exponent = 65537,
|
||||||
|
key_size = 2048,
|
||||||
|
)
|
||||||
|
_public_key_pem = (
|
||||||
|
_private_key.public_key()
|
||||||
|
.public_bytes(
|
||||||
|
serialization.Encoding.PEM,
|
||||||
|
serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||||
|
)
|
||||||
|
.decode("utf-8")
|
||||||
|
)
|
||||||
|
logger.info("RSA key pair generated for access endpoint reveal")
|
||||||
|
|
||||||
|
|
||||||
|
def get_public_key_pem() -> str:
|
||||||
|
"""Return the PEM-encoded public key for the frontend."""
|
||||||
|
if _public_key_pem is None:
|
||||||
|
raise RuntimeError("Key pair not initialized. Call init_key_pair() first.")
|
||||||
|
return _public_key_pem
|
||||||
|
|
||||||
|
|
||||||
|
def _decrypt_session_key(encrypted_session_key_b64: str) -> bytes:
|
||||||
|
"""Decrypt a client-supplied AES session key with the server private key."""
|
||||||
|
if _private_key is None:
|
||||||
|
raise RuntimeError("Key pair not initialized. Call init_key_pair() first.")
|
||||||
|
ciphertext = base64.b64decode(encrypted_session_key_b64)
|
||||||
|
session_key = _private_key.decrypt(
|
||||||
|
ciphertext,
|
||||||
|
padding.OAEP(
|
||||||
|
mgf = padding.MGF1(algorithm = hashes.SHA256()),
|
||||||
|
algorithm = hashes.SHA256(),
|
||||||
|
label = None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if len(session_key) not in (16, 24, 32):
|
||||||
|
raise ValueError("Session key must be 128/192/256 bits")
|
||||||
|
return session_key
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_payload(encrypted_session_key_b64: str, plaintext: str) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Decrypt the client's session key, then AES-GCM encrypt ``plaintext`` with it.
|
||||||
|
|
||||||
|
Returns a dict ``{"iv": <b64>, "ciphertext": <b64>}`` where ``ciphertext``
|
||||||
|
includes the 16-byte GCM tag appended by the AESGCM primitive.
|
||||||
|
"""
|
||||||
|
session_key = _decrypt_session_key(encrypted_session_key_b64)
|
||||||
|
iv = os.urandom(12) # 96-bit IV is the GCM standard
|
||||||
|
aesgcm = AESGCM(session_key)
|
||||||
|
ciphertext = aesgcm.encrypt(iv, plaintext.encode("utf-8"), associated_data = None)
|
||||||
|
return {
|
||||||
|
"iv": base64.b64encode(iv).decode("ascii"),
|
||||||
|
"ciphertext": base64.b64encode(ciphertext).decode("ascii"),
|
||||||
|
}
|
||||||
|
|
@ -129,6 +129,8 @@ class LlamaCppBackend:
|
||||||
self._stdout_thread: Optional[threading.Thread] = None
|
self._stdout_thread: Optional[threading.Thread] = None
|
||||||
self._cancel_event = threading.Event()
|
self._cancel_event = threading.Event()
|
||||||
self._api_key: Optional[str] = None
|
self._api_key: Optional[str] = None
|
||||||
|
self._launch_cmd: Optional[list[str]] = None
|
||||||
|
self._launch_env: Optional[dict] = None
|
||||||
|
|
||||||
self._kill_orphaned_servers()
|
self._kill_orphaned_servers()
|
||||||
atexit.register(self._cleanup)
|
atexit.register(self._cleanup)
|
||||||
|
|
@ -1283,7 +1285,7 @@ class LlamaCppBackend:
|
||||||
"-c",
|
"-c",
|
||||||
str(effective_ctx) if effective_ctx > 0 else "0",
|
str(effective_ctx) if effective_ctx > 0 else "0",
|
||||||
"--parallel",
|
"--parallel",
|
||||||
"1", # Single-user studio, saves VRAM
|
"4", # Match LM Studio default: supports concurrent Studio chat + external API access
|
||||||
"--flash-attn",
|
"--flash-attn",
|
||||||
"on", # Force flash attention for speed
|
"on", # Force flash attention for speed
|
||||||
]
|
]
|
||||||
|
|
@ -1516,6 +1518,8 @@ class LlamaCppBackend:
|
||||||
if gpu_indices is not None:
|
if gpu_indices is not None:
|
||||||
env["CUDA_VISIBLE_DEVICES"] = ",".join(str(i) for i in gpu_indices)
|
env["CUDA_VISIBLE_DEVICES"] = ",".join(str(i) for i in gpu_indices)
|
||||||
|
|
||||||
|
self._launch_cmd = list(cmd)
|
||||||
|
self._launch_env = dict(env)
|
||||||
self._stdout_lines = []
|
self._stdout_lines = []
|
||||||
self._process = subprocess.Popen(
|
self._process = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
|
|
@ -1631,6 +1635,58 @@ class LlamaCppBackend:
|
||||||
torch.cuda.empty_cache()
|
torch.cuda.empty_cache()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def set_parallel(self, n: int) -> bool:
|
||||||
|
"""Restart llama-server with a different --parallel value.
|
||||||
|
|
||||||
|
Kills the running process and relaunches with the same command
|
||||||
|
but ``--parallel`` swapped. Returns True if the restarted server
|
||||||
|
passes the health check.
|
||||||
|
"""
|
||||||
|
if not self._launch_cmd or not self._launch_env:
|
||||||
|
raise RuntimeError("No launch command stored — cannot restart")
|
||||||
|
|
||||||
|
cmd = list(self._launch_cmd)
|
||||||
|
# Swap --parallel value
|
||||||
|
try:
|
||||||
|
idx = cmd.index("--parallel")
|
||||||
|
cmd[idx + 1] = str(n)
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
cmd.extend(["--parallel", str(n)])
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._kill_process()
|
||||||
|
self._port = self._find_free_port()
|
||||||
|
# Update port in cmd
|
||||||
|
try:
|
||||||
|
pi = cmd.index("--port")
|
||||||
|
cmd[pi + 1] = str(self._port)
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
cmd.extend(["--port", str(self._port)])
|
||||||
|
|
||||||
|
self._launch_cmd = list(cmd)
|
||||||
|
self._stdout_lines = []
|
||||||
|
self._process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout = subprocess.PIPE,
|
||||||
|
stderr = subprocess.STDOUT,
|
||||||
|
text = True,
|
||||||
|
env = self._launch_env,
|
||||||
|
)
|
||||||
|
self._stdout_thread = threading.Thread(
|
||||||
|
target = self._drain_stdout, daemon = True, name = "llama-stdout"
|
||||||
|
)
|
||||||
|
self._stdout_thread.start()
|
||||||
|
|
||||||
|
if not self._wait_for_health(timeout = 120.0):
|
||||||
|
self._kill_process()
|
||||||
|
raise RuntimeError("llama-server failed to restart with new --parallel")
|
||||||
|
|
||||||
|
self._healthy = True
|
||||||
|
logger.info(
|
||||||
|
f"llama-server restarted with --parallel {n} on port {self._port}"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
def _kill_process(self):
|
def _kill_process(self):
|
||||||
"""Terminate the subprocess if running."""
|
"""Terminate the subprocess if running."""
|
||||||
if self._process is None:
|
if self._process is None:
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,12 @@ async def lifespan(app: FastAPI):
|
||||||
# Detect hardware first — sets DEVICE global used everywhere
|
# Detect hardware first — sets DEVICE global used everywhere
|
||||||
detect_hardware()
|
detect_hardware()
|
||||||
|
|
||||||
|
# Generate RSA key pair for encrypted access endpoint reveal.
|
||||||
|
# Lives in memory only — regenerated on each restart.
|
||||||
|
from core.inference.key_exchange import init_key_pair
|
||||||
|
|
||||||
|
init_key_pair()
|
||||||
|
|
||||||
from storage.studio_db import cleanup_orphaned_runs
|
from storage.studio_db import cleanup_orphaned_runs
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -131,6 +137,27 @@ async def lifespan(app: FastAPI):
|
||||||
print("=" * 60 + "\n")
|
print("=" * 60 + "\n")
|
||||||
else:
|
else:
|
||||||
app.state.bootstrap_password = storage.get_bootstrap_password()
|
app.state.bootstrap_password = storage.get_bootstrap_password()
|
||||||
|
|
||||||
|
# Restore access endpoint API key from disk (if one was left enabled).
|
||||||
|
# Persisted at ~/.unsloth/studio/auth/access_endpoint.json so the key
|
||||||
|
# survives backend restarts and external clients don't need to re-fetch
|
||||||
|
# it every time Studio is restarted.
|
||||||
|
try:
|
||||||
|
from routes.inference import _read_endpoint_state
|
||||||
|
|
||||||
|
_ep_state = _read_endpoint_state()
|
||||||
|
if _ep_state and _ep_state["enabled"]:
|
||||||
|
app.state.external_api_key = _ep_state["api_key"]
|
||||||
|
else:
|
||||||
|
app.state.external_api_key = None
|
||||||
|
except Exception as exc:
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
structlog.get_logger(__name__).warning(
|
||||||
|
"Failed to restore access endpoint state: %s", exc
|
||||||
|
)
|
||||||
|
app.state.external_api_key = None
|
||||||
|
|
||||||
yield
|
yield
|
||||||
# Cleanup
|
# Cleanup
|
||||||
_hw_module.DEVICE = None
|
_hw_module.DEVICE = None
|
||||||
|
|
|
||||||
|
|
@ -315,7 +315,7 @@ class ChatCompletionRequest(BaseModel):
|
||||||
description = "Model identifier (informational; the active model is used)",
|
description = "Model identifier (informational; the active model is used)",
|
||||||
)
|
)
|
||||||
messages: list[ChatMessage] = Field(..., description = "Conversation messages")
|
messages: list[ChatMessage] = Field(..., description = "Conversation messages")
|
||||||
stream: bool = Field(True, description = "Whether to stream the response via SSE")
|
stream: bool = Field(False, description = "Whether to stream the response via SSE")
|
||||||
temperature: float = Field(0.6, ge = 0.0, le = 2.0)
|
temperature: float = Field(0.6, ge = 0.0, le = 2.0)
|
||||||
top_p: float = Field(0.95, ge = 0.0, le = 1.0)
|
top_p: float = Field(0.95, ge = 0.0, le = 1.0)
|
||||||
max_tokens: Optional[int] = Field(
|
max_tokens: Optional[int] = Field(
|
||||||
|
|
@ -444,3 +444,71 @@ class ChatCompletion(BaseModel):
|
||||||
model: str = "default"
|
model: str = "default"
|
||||||
choices: list[CompletionChoice]
|
choices: list[CompletionChoice]
|
||||||
usage: CompletionUsage = Field(default_factory = CompletionUsage)
|
usage: CompletionUsage = Field(default_factory = CompletionUsage)
|
||||||
|
|
||||||
|
|
||||||
|
# ── OpenAI Responses API (/v1/responses) ──────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class ResponsesContentPart(BaseModel):
|
||||||
|
"""A content part in the Responses API input/output.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- ``{"type": "input_text", "text": "..."}`` — text input
|
||||||
|
- ``{"type": "input_image", "image_url": "data:image/...;base64,..."}`` — image input
|
||||||
|
- ``{"type": "output_text", "text": "..."}`` — text output
|
||||||
|
"""
|
||||||
|
|
||||||
|
type: str
|
||||||
|
text: Optional[str] = None
|
||||||
|
image_url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ResponsesInputMessage(BaseModel):
|
||||||
|
"""A single message in the Responses API ``input`` array."""
|
||||||
|
|
||||||
|
role: str = "user"
|
||||||
|
content: Union[str, list[ResponsesContentPart]]
|
||||||
|
type: str = "message"
|
||||||
|
|
||||||
|
|
||||||
|
class ResponsesRequest(BaseModel):
|
||||||
|
"""OpenAI Responses API request."""
|
||||||
|
|
||||||
|
model: str = Field("default", description = "Model identifier (informational)")
|
||||||
|
input: Union[str, list[ResponsesInputMessage]] = Field(
|
||||||
|
..., description = "Plain text or array of message objects"
|
||||||
|
)
|
||||||
|
stream: bool = Field(False, description = "Whether to stream the response via SSE")
|
||||||
|
temperature: float = Field(0.6, ge = 0.0, le = 2.0)
|
||||||
|
top_p: float = Field(0.95, ge = 0.0, le = 1.0)
|
||||||
|
max_output_tokens: Optional[int] = Field(
|
||||||
|
None, ge = 1, description = "Maximum tokens to generate"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ResponsesOutputContent(BaseModel):
|
||||||
|
type: Literal["output_text"] = "output_text"
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
class ResponsesOutputMessage(BaseModel):
|
||||||
|
type: Literal["message"] = "message"
|
||||||
|
role: Literal["assistant"] = "assistant"
|
||||||
|
content: list[ResponsesOutputContent]
|
||||||
|
|
||||||
|
|
||||||
|
class ResponsesUsage(BaseModel):
|
||||||
|
input_tokens: int = 0
|
||||||
|
output_tokens: int = 0
|
||||||
|
total_tokens: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class ResponsesResponse(BaseModel):
|
||||||
|
"""Non-streaming Responses API response."""
|
||||||
|
|
||||||
|
id: str = Field(default_factory = lambda: f"resp_{uuid.uuid4().hex[:12]}")
|
||||||
|
object: Literal["response"] = "response"
|
||||||
|
created_at: int = Field(default_factory = lambda: int(time.time()))
|
||||||
|
model: str = "default"
|
||||||
|
output: list[ResponsesOutputMessage]
|
||||||
|
usage: ResponsesUsage = Field(default_factory = ResponsesUsage)
|
||||||
|
|
|
||||||
|
|
@ -15,3 +15,4 @@ huggingface-hub==0.36.2
|
||||||
structlog>=24.1.0
|
structlog>=24.1.0
|
||||||
diceware
|
diceware
|
||||||
ddgs
|
ddgs
|
||||||
|
cryptography>=42.0.0
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ Inference API routes for model loading and text generation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import secrets as _secrets
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
|
@ -83,8 +84,15 @@ from models.inference import (
|
||||||
CompletionUsage,
|
CompletionUsage,
|
||||||
ValidateModelRequest,
|
ValidateModelRequest,
|
||||||
ValidateModelResponse,
|
ValidateModelResponse,
|
||||||
|
ResponsesRequest,
|
||||||
|
ResponsesResponse,
|
||||||
|
ResponsesOutputMessage,
|
||||||
|
ResponsesOutputContent,
|
||||||
|
ResponsesUsage,
|
||||||
|
ResponsesInputMessage,
|
||||||
|
ResponsesContentPart,
|
||||||
)
|
)
|
||||||
from auth.authentication import get_current_subject
|
from auth.authentication import get_current_subject, get_current_subject_or_api_key, get_current_subject_or_api_key_anthropic
|
||||||
|
|
||||||
import io
|
import io
|
||||||
import wave
|
import wave
|
||||||
|
|
@ -121,6 +129,7 @@ def get_llama_cpp_backend() -> LlamaCppBackend:
|
||||||
@router.post("/load", response_model = LoadResponse)
|
@router.post("/load", response_model = LoadResponse)
|
||||||
async def load_model(
|
async def load_model(
|
||||||
request: LoadRequest,
|
request: LoadRequest,
|
||||||
|
fastapi_request: Request,
|
||||||
current_subject: str = Depends(get_current_subject),
|
current_subject: str = Depends(get_current_subject),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
|
|
@ -523,6 +532,7 @@ async def validate_model(
|
||||||
@router.post("/unload", response_model = UnloadResponse)
|
@router.post("/unload", response_model = UnloadResponse)
|
||||||
async def unload_model(
|
async def unload_model(
|
||||||
request: UnloadRequest,
|
request: UnloadRequest,
|
||||||
|
fastapi_request: Request,
|
||||||
current_subject: str = Depends(get_current_subject),
|
current_subject: str = Depends(get_current_subject),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
|
|
@ -883,11 +893,233 @@ def _extract_content_parts(
|
||||||
return system_prompt, chat_messages, first_image_b64
|
return system_prompt, chat_messages, first_image_b64
|
||||||
|
|
||||||
|
|
||||||
|
# ── Access Endpoint (external OpenAI-compatible access) ──────────
|
||||||
|
|
||||||
|
|
||||||
|
def _build_endpoint_urls(request: Request):
|
||||||
|
"""Return (local_url, external_url) for the Access Endpoint dialog."""
|
||||||
|
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"
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
return local_url, external_url
|
||||||
|
|
||||||
|
|
||||||
|
def _get_loaded_model_name():
|
||||||
|
"""Return the currently loaded model identifier."""
|
||||||
|
llama_backend = get_llama_cpp_backend()
|
||||||
|
if llama_backend.is_loaded:
|
||||||
|
return llama_backend.model_identifier
|
||||||
|
backend = get_inference_backend()
|
||||||
|
if backend.models:
|
||||||
|
return list(backend.models.keys())[0]
|
||||||
|
return "default"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_endpoint_state() -> dict | None:
|
||||||
|
"""Return {'api_key': str, 'enabled': bool} or None if file is missing/corrupt."""
|
||||||
|
from utils.paths.storage_roots import access_endpoint_state_path
|
||||||
|
|
||||||
|
path = access_endpoint_state_path()
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text())
|
||||||
|
if isinstance(data, dict) and isinstance(data.get("api_key"), str):
|
||||||
|
return {
|
||||||
|
"api_key": data["api_key"],
|
||||||
|
"enabled": bool(data.get("enabled", False)),
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to read access_endpoint.json: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _write_endpoint_state(api_key: str, enabled: bool) -> None:
|
||||||
|
"""Atomically persist the endpoint state with 0600 perms."""
|
||||||
|
from utils.paths.storage_roots import access_endpoint_state_path, ensure_dir
|
||||||
|
|
||||||
|
path = access_endpoint_state_path()
|
||||||
|
ensure_dir(path.parent)
|
||||||
|
tmp = path.with_suffix(".json.tmp")
|
||||||
|
tmp.write_text(json.dumps({"api_key": api_key, "enabled": enabled}))
|
||||||
|
os.chmod(tmp, 0o600)
|
||||||
|
os.replace(tmp, path) # atomic on POSIX
|
||||||
|
|
||||||
|
|
||||||
|
def _mint_api_key() -> str:
|
||||||
|
return f"sk-unsloth-{_secrets.token_urlsafe(32)}"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/access-endpoint")
|
||||||
|
async def get_access_endpoint(
|
||||||
|
request: Request,
|
||||||
|
current_subject: str = Depends(get_current_subject),
|
||||||
|
):
|
||||||
|
"""Return endpoint state, URLs, and model info.
|
||||||
|
|
||||||
|
The API key itself is NOT returned here — the frontend must fetch it via
|
||||||
|
the encrypted POST /access-endpoint/reveal flow so it never travels as
|
||||||
|
plaintext over the wire.
|
||||||
|
"""
|
||||||
|
api_key = getattr(request.app.state, "external_api_key", None)
|
||||||
|
local_url, external_url = _build_endpoint_urls(request)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"enabled": api_key is not None,
|
||||||
|
"local_url": local_url,
|
||||||
|
"external_url": external_url,
|
||||||
|
"model": _get_loaded_model_name(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/access-endpoint/enable")
|
||||||
|
async def enable_access_endpoint(
|
||||||
|
request: Request,
|
||||||
|
current_subject: str = Depends(get_current_subject),
|
||||||
|
):
|
||||||
|
"""Enable external API access.
|
||||||
|
|
||||||
|
Reuses the persisted API key if one already exists on disk; only mints a
|
||||||
|
new one on the very first enable or after the file has been removed.
|
||||||
|
llama-server is already running with --parallel 4, so concurrent Studio
|
||||||
|
chat + external API is supported without a restart.
|
||||||
|
"""
|
||||||
|
llama_backend = get_llama_cpp_backend()
|
||||||
|
if not llama_backend.is_loaded:
|
||||||
|
raise HTTPException(status_code = 400, detail = "No GGUF model loaded")
|
||||||
|
|
||||||
|
state = _read_endpoint_state()
|
||||||
|
api_key = state["api_key"] if state else _mint_api_key()
|
||||||
|
_write_endpoint_state(api_key, enabled = True)
|
||||||
|
request.app.state.external_api_key = api_key
|
||||||
|
|
||||||
|
local_url, external_url = _build_endpoint_urls(request)
|
||||||
|
return {
|
||||||
|
"enabled": True,
|
||||||
|
"local_url": local_url,
|
||||||
|
"external_url": external_url,
|
||||||
|
"model": _get_loaded_model_name(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/access-endpoint/disable")
|
||||||
|
async def disable_access_endpoint(
|
||||||
|
request: Request,
|
||||||
|
current_subject: str = Depends(get_current_subject),
|
||||||
|
):
|
||||||
|
"""Disable external API access.
|
||||||
|
|
||||||
|
Preserves the key on disk (so the next Enable brings the same key back)
|
||||||
|
but clears it from in-memory app.state, which causes the dual-auth check
|
||||||
|
to reject the key on subsequent requests.
|
||||||
|
"""
|
||||||
|
state = _read_endpoint_state()
|
||||||
|
if state:
|
||||||
|
_write_endpoint_state(state["api_key"], enabled = False)
|
||||||
|
request.app.state.external_api_key = None
|
||||||
|
return {"enabled": False}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/access-endpoint/regenerate")
|
||||||
|
async def regenerate_access_endpoint(
|
||||||
|
request: Request,
|
||||||
|
current_subject: str = Depends(get_current_subject),
|
||||||
|
):
|
||||||
|
"""Mint a fresh API key, invalidating the previous one.
|
||||||
|
|
||||||
|
JWT-only (deliberately not dual-auth) so a leaked external API key
|
||||||
|
cannot rotate itself. Only legal while the endpoint is currently enabled
|
||||||
|
and a GGUF model is loaded.
|
||||||
|
"""
|
||||||
|
if getattr(request.app.state, "external_api_key", None) is None:
|
||||||
|
raise HTTPException(status_code = 400, detail = "Endpoint is not enabled")
|
||||||
|
|
||||||
|
llama_backend = get_llama_cpp_backend()
|
||||||
|
if not llama_backend.is_loaded:
|
||||||
|
raise HTTPException(status_code = 400, detail = "No GGUF model loaded")
|
||||||
|
|
||||||
|
api_key = _mint_api_key()
|
||||||
|
_write_endpoint_state(api_key, enabled = True)
|
||||||
|
request.app.state.external_api_key = api_key
|
||||||
|
|
||||||
|
local_url, external_url = _build_endpoint_urls(request)
|
||||||
|
return {
|
||||||
|
"enabled": True,
|
||||||
|
"local_url": local_url,
|
||||||
|
"external_url": external_url,
|
||||||
|
"model": _get_loaded_model_name(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/access-endpoint/public-key")
|
||||||
|
async def access_endpoint_public_key(
|
||||||
|
current_subject: str = Depends(get_current_subject),
|
||||||
|
):
|
||||||
|
"""Return the server's RSA public key (PEM) for the reveal handshake.
|
||||||
|
|
||||||
|
JWT-protected so only authenticated Studio sessions can start the
|
||||||
|
reveal flow.
|
||||||
|
"""
|
||||||
|
from core.inference.key_exchange import get_public_key_pem
|
||||||
|
|
||||||
|
return {"public_key": get_public_key_pem()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/access-endpoint/reveal")
|
||||||
|
async def access_endpoint_reveal(
|
||||||
|
request: Request,
|
||||||
|
current_subject: str = Depends(get_current_subject),
|
||||||
|
):
|
||||||
|
"""Return the live API key, AES-GCM encrypted with a client-supplied session key.
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
{ "encrypted_session_key": "<base64 RSA-OAEP-SHA256 ciphertext>" }
|
||||||
|
|
||||||
|
Response body:
|
||||||
|
{ "iv": "<base64>", "ciphertext": "<base64 AES-GCM ciphertext+tag>" }
|
||||||
|
|
||||||
|
The plaintext inside the ciphertext is the API key string. If the endpoint
|
||||||
|
is disabled, returns 404 so the UI can hide the field rather than reveal
|
||||||
|
an inactive key.
|
||||||
|
"""
|
||||||
|
from core.inference.key_exchange import encrypt_payload
|
||||||
|
|
||||||
|
api_key = getattr(request.app.state, "external_api_key", None)
|
||||||
|
if not api_key:
|
||||||
|
raise HTTPException(status_code = 404, detail = "Endpoint is not enabled")
|
||||||
|
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code = 400, detail = "Invalid JSON body")
|
||||||
|
|
||||||
|
encrypted_session_key = body.get("encrypted_session_key") if isinstance(body, dict) else None
|
||||||
|
if not isinstance(encrypted_session_key, str) or not encrypted_session_key:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code = 400, detail = "Missing encrypted_session_key"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return encrypt_payload(encrypted_session_key, api_key)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Access endpoint reveal failed: %s", exc)
|
||||||
|
raise HTTPException(status_code = 400, detail = "Failed to decrypt session key")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/chat/completions")
|
@router.post("/chat/completions")
|
||||||
async def openai_chat_completions(
|
async def openai_chat_completions(
|
||||||
payload: ChatCompletionRequest,
|
payload: ChatCompletionRequest,
|
||||||
request: Request,
|
request: Request,
|
||||||
current_subject: str = Depends(get_current_subject),
|
current_subject: str = Depends(get_current_subject_or_api_key),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
OpenAI-compatible chat completions endpoint.
|
OpenAI-compatible chat completions endpoint.
|
||||||
|
|
@ -1483,11 +1715,22 @@ async def openai_chat_completions(
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
full_text = ""
|
full_text = ""
|
||||||
|
_ns_usage = None
|
||||||
for token in gguf_generate():
|
for token in gguf_generate():
|
||||||
if isinstance(token, dict):
|
if isinstance(token, dict):
|
||||||
continue # skip metadata dict in non-streaming path
|
if token.get("type") == "metadata":
|
||||||
|
_ns_usage = token.get("usage")
|
||||||
|
continue
|
||||||
full_text = token
|
full_text = token
|
||||||
|
|
||||||
|
usage_obj = None
|
||||||
|
if _ns_usage:
|
||||||
|
usage_obj = CompletionUsage(
|
||||||
|
prompt_tokens = _ns_usage.get("prompt_tokens", 0),
|
||||||
|
completion_tokens = _ns_usage.get("completion_tokens", 0),
|
||||||
|
total_tokens = _ns_usage.get("total_tokens", 0),
|
||||||
|
)
|
||||||
|
|
||||||
response = ChatCompletion(
|
response = ChatCompletion(
|
||||||
id = completion_id,
|
id = completion_id,
|
||||||
created = created,
|
created = created,
|
||||||
|
|
@ -1498,6 +1741,7 @@ async def openai_chat_completions(
|
||||||
finish_reason = "stop",
|
finish_reason = "stop",
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
usage = usage_obj,
|
||||||
)
|
)
|
||||||
return JSONResponse(content = response.model_dump())
|
return JSONResponse(content = response.model_dump())
|
||||||
|
|
||||||
|
|
@ -1777,13 +2021,304 @@ async def serve_sandbox_file(
|
||||||
|
|
||||||
|
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
|
# =====================================================================
|
||||||
|
# OpenAI Responses API (/responses → /v1/responses)
|
||||||
|
# =====================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _responses_input_to_messages(
|
||||||
|
input_data: str | list[ResponsesInputMessage],
|
||||||
|
) -> tuple[list[dict], str | None]:
|
||||||
|
"""Convert Responses API ``input`` to OpenAI-style message dicts.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
messages: List of ``{"role": ..., "content": ...}`` dicts.
|
||||||
|
image_b64: Base64 data of the first ``input_image`` found, or ``None``.
|
||||||
|
"""
|
||||||
|
if isinstance(input_data, str):
|
||||||
|
return [{"role": "user", "content": input_data}], None
|
||||||
|
|
||||||
|
messages: list[dict] = []
|
||||||
|
first_image_b64: str | None = None
|
||||||
|
for item in input_data:
|
||||||
|
if isinstance(item.content, str):
|
||||||
|
messages.append({"role": item.role, "content": item.content})
|
||||||
|
elif isinstance(item.content, list):
|
||||||
|
text_parts: list[str] = []
|
||||||
|
for part in item.content:
|
||||||
|
if part.type == "input_text" and part.text:
|
||||||
|
text_parts.append(part.text)
|
||||||
|
elif part.type == "input_image" and part.image_url and first_image_b64 is None:
|
||||||
|
url = part.image_url
|
||||||
|
if url.startswith("data:"):
|
||||||
|
first_image_b64 = url.split(",", 1)[1] if "," in url else None
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Remote image URLs not yet supported in /responses: %s...",
|
||||||
|
url[:80],
|
||||||
|
)
|
||||||
|
combined_text = "\n".join(text_parts) if text_parts else ""
|
||||||
|
messages.append({"role": item.role, "content": combined_text})
|
||||||
|
return messages, first_image_b64
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/responses")
|
||||||
|
async def openai_responses(
|
||||||
|
payload: ResponsesRequest,
|
||||||
|
request: Request,
|
||||||
|
current_subject: str = Depends(get_current_subject_or_api_key),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
OpenAI Responses API endpoint.
|
||||||
|
|
||||||
|
Accepts the Responses API ``input`` format and returns a Response object.
|
||||||
|
Translates internally to the GGUF chat completion path. Only GGUF models
|
||||||
|
are supported (same as ``/chat/completions`` for external API access).
|
||||||
|
"""
|
||||||
|
llama_backend = get_llama_cpp_backend()
|
||||||
|
if not llama_backend.is_loaded:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code = 400,
|
||||||
|
detail = "No GGUF model loaded. The Responses API requires a GGUF model.",
|
||||||
|
)
|
||||||
|
|
||||||
|
model_name = llama_backend.model_identifier or payload.model
|
||||||
|
messages, image_b64 = _responses_input_to_messages(payload.input)
|
||||||
|
cancel_event = threading.Event()
|
||||||
|
response_id = f"resp_{uuid.uuid4().hex[:12]}"
|
||||||
|
created_at = int(time.time())
|
||||||
|
|
||||||
|
def responses_generate():
|
||||||
|
return llama_backend.generate_chat_completion(
|
||||||
|
messages = messages,
|
||||||
|
image_b64 = image_b64,
|
||||||
|
temperature = payload.temperature,
|
||||||
|
top_p = payload.top_p,
|
||||||
|
top_k = 40,
|
||||||
|
min_p = 0.0,
|
||||||
|
max_tokens = payload.max_output_tokens,
|
||||||
|
repetition_penalty = 1.0,
|
||||||
|
presence_penalty = 0.0,
|
||||||
|
cancel_event = cancel_event,
|
||||||
|
enable_thinking = False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if payload.stream:
|
||||||
|
|
||||||
|
async def responses_stream_events():
|
||||||
|
try:
|
||||||
|
# response.created event
|
||||||
|
created_event = {
|
||||||
|
"type": "response.created",
|
||||||
|
"response": {
|
||||||
|
"id": response_id,
|
||||||
|
"object": "response",
|
||||||
|
"created_at": created_at,
|
||||||
|
"model": model_name,
|
||||||
|
"output": [],
|
||||||
|
"usage": None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
yield f"event: response.created\ndata: {json.dumps(created_event)}\n\n"
|
||||||
|
|
||||||
|
_sentinel = object()
|
||||||
|
gen = responses_generate()
|
||||||
|
prev_text = ""
|
||||||
|
_usage = None
|
||||||
|
output_index = 0
|
||||||
|
content_index = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if await request.is_disconnected():
|
||||||
|
cancel_event.set()
|
||||||
|
return
|
||||||
|
cumulative = await asyncio.to_thread(next, gen, _sentinel)
|
||||||
|
if cumulative is _sentinel:
|
||||||
|
break
|
||||||
|
if isinstance(cumulative, dict):
|
||||||
|
if cumulative.get("type") == "metadata":
|
||||||
|
_usage = cumulative.get("usage")
|
||||||
|
continue
|
||||||
|
new_text = cumulative[len(prev_text):]
|
||||||
|
prev_text = cumulative
|
||||||
|
if not new_text:
|
||||||
|
continue
|
||||||
|
delta_event = {
|
||||||
|
"type": "response.output_text.delta",
|
||||||
|
"output_index": output_index,
|
||||||
|
"content_index": content_index,
|
||||||
|
"delta": new_text,
|
||||||
|
}
|
||||||
|
yield f"event: response.output_text.delta\ndata: {json.dumps(delta_event)}\n\n"
|
||||||
|
|
||||||
|
# response.completed event
|
||||||
|
usage_data = None
|
||||||
|
if _usage:
|
||||||
|
usage_data = {
|
||||||
|
"input_tokens": _usage.get("prompt_tokens", 0),
|
||||||
|
"output_tokens": _usage.get("completion_tokens", 0),
|
||||||
|
"total_tokens": _usage.get("total_tokens", 0),
|
||||||
|
}
|
||||||
|
completed_event = {
|
||||||
|
"type": "response.completed",
|
||||||
|
"response": {
|
||||||
|
"id": response_id,
|
||||||
|
"object": "response",
|
||||||
|
"created_at": created_at,
|
||||||
|
"model": model_name,
|
||||||
|
"output": [
|
||||||
|
{
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [{"type": "output_text", "text": prev_text}],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"usage": usage_data,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
yield f"event: response.completed\ndata: {json.dumps(completed_event)}\n\n"
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
cancel_event.set()
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error during Responses API streaming: {e}", exc_info = True)
|
||||||
|
error_event = {
|
||||||
|
"type": "error",
|
||||||
|
"error": {"message": _friendly_error(e), "type": "server_error"},
|
||||||
|
}
|
||||||
|
yield f"event: error\ndata: {json.dumps(error_event)}\n\n"
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
responses_stream_events(),
|
||||||
|
media_type = "text/event-stream",
|
||||||
|
headers = {
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
full_text = ""
|
||||||
|
_usage = None
|
||||||
|
for token in responses_generate():
|
||||||
|
if isinstance(token, dict):
|
||||||
|
if token.get("type") == "metadata":
|
||||||
|
_usage = token.get("usage")
|
||||||
|
continue
|
||||||
|
full_text = token
|
||||||
|
|
||||||
|
usage_obj = ResponsesUsage()
|
||||||
|
if _usage:
|
||||||
|
usage_obj = ResponsesUsage(
|
||||||
|
input_tokens = _usage.get("prompt_tokens", 0),
|
||||||
|
output_tokens = _usage.get("completion_tokens", 0),
|
||||||
|
total_tokens = _usage.get("total_tokens", 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = ResponsesResponse(
|
||||||
|
id = response_id,
|
||||||
|
created_at = created_at,
|
||||||
|
model = model_name,
|
||||||
|
output = [
|
||||||
|
ResponsesOutputMessage(
|
||||||
|
content = [ResponsesOutputContent(text = full_text)]
|
||||||
|
)
|
||||||
|
],
|
||||||
|
usage = usage_obj,
|
||||||
|
)
|
||||||
|
return JSONResponse(content = response.model_dump())
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error during Responses API completion: {e}", exc_info = True)
|
||||||
|
raise HTTPException(status_code = 500, detail = _friendly_error(e))
|
||||||
|
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# Anthropic Messages API (/messages → /v1/messages)
|
||||||
|
# =====================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/messages")
|
||||||
|
async def anthropic_messages(
|
||||||
|
request: Request,
|
||||||
|
current_subject: str = Depends(get_current_subject_or_api_key_anthropic),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Anthropic-compatible Messages API endpoint.
|
||||||
|
|
||||||
|
Proxies the request directly to llama-server's native ``/v1/messages``
|
||||||
|
handler, which supports the full Anthropic Messages format including
|
||||||
|
streaming (``message_start``, ``content_block_delta``, etc.).
|
||||||
|
|
||||||
|
Accepts auth via ``x-api-key`` header (Anthropic SDK) or
|
||||||
|
``Authorization: Bearer`` (OpenAI SDK / general).
|
||||||
|
"""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
llama_backend = get_llama_cpp_backend()
|
||||||
|
if not llama_backend.is_loaded:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code = 400,
|
||||||
|
detail = "No GGUF model loaded. The Messages API requires a GGUF model.",
|
||||||
|
)
|
||||||
|
|
||||||
|
base_url = llama_backend.base_url
|
||||||
|
if not base_url:
|
||||||
|
raise HTTPException(status_code = 503, detail = "llama-server is not running")
|
||||||
|
|
||||||
|
body = await request.body()
|
||||||
|
# Detect streaming from the JSON body
|
||||||
|
try:
|
||||||
|
payload = json.loads(body)
|
||||||
|
is_stream = payload.get("stream", False)
|
||||||
|
except Exception:
|
||||||
|
is_stream = False
|
||||||
|
|
||||||
|
target_url = f"{base_url}/v1/messages"
|
||||||
|
|
||||||
|
if is_stream:
|
||||||
|
|
||||||
|
async def proxy_stream():
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
async with client.stream(
|
||||||
|
"POST",
|
||||||
|
target_url,
|
||||||
|
content = body,
|
||||||
|
headers = {"Content-Type": "application/json"},
|
||||||
|
timeout = 300.0,
|
||||||
|
) as resp:
|
||||||
|
async for chunk in resp.aiter_bytes():
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
proxy_stream(),
|
||||||
|
media_type = "text/event-stream",
|
||||||
|
headers = {
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
target_url,
|
||||||
|
content = body,
|
||||||
|
headers = {"Content-Type": "application/json"},
|
||||||
|
timeout = 300.0,
|
||||||
|
)
|
||||||
|
return JSONResponse(content = resp.json(), status_code = resp.status_code)
|
||||||
|
|
||||||
|
|
||||||
# OpenAI-Compatible Models Listing (/models → /v1/models)
|
# OpenAI-Compatible Models Listing (/models → /v1/models)
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
|
|
||||||
|
|
||||||
@router.get("/models")
|
@router.get("/models")
|
||||||
async def openai_list_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.
|
OpenAI-compatible model listing endpoint.
|
||||||
|
|
|
||||||
|
|
@ -331,6 +331,7 @@ def run_server(
|
||||||
# binds (port==0) leave it unset and let request handlers fall back
|
# binds (port==0) leave it unset and let request handlers fall back
|
||||||
# to the ASGI request scope or request.base_url.
|
# to the ASGI request scope or request.base_url.
|
||||||
app.state.server_port = port if port and port > 0 else None
|
app.state.server_port = port if port and port > 0 else None
|
||||||
|
app.state.bind_host = host
|
||||||
|
|
||||||
# Run server in a daemon thread
|
# Run server in a daemon thread
|
||||||
def _run():
|
def _run():
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,10 @@ def auth_db_path() -> Path:
|
||||||
return auth_root() / "auth.db"
|
return auth_root() / "auth.db"
|
||||||
|
|
||||||
|
|
||||||
|
def access_endpoint_state_path() -> Path:
|
||||||
|
return auth_root() / "access_endpoint.json"
|
||||||
|
|
||||||
|
|
||||||
def studio_db_path() -> Path:
|
def studio_db_path() -> Path:
|
||||||
return studio_root() / "studio.db"
|
return studio_root() / "studio.db"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@
|
||||||
"motion": "^12.34.0",
|
"motion": "^12.34.0",
|
||||||
"next": "^16.1.6",
|
"next": "^16.1.6",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"node-forge": "^1.4.0",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-day-picker": "^9.13.2",
|
"react-day-picker": "^9.13.2",
|
||||||
|
|
@ -80,6 +81,7 @@
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@types/js-yaml": "^4.0.9",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/node-forge": "^1.3.14",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.5",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
|
|
||||||
143
studio/frontend/src/features/chat/api/access-endpoint-crypto.ts
Normal file
143
studio/frontend/src/features/chat/api/access-endpoint-crypto.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypted reveal flow for the Access Endpoint API key.
|
||||||
|
*
|
||||||
|
* Studio is often served over plain HTTP, which rules out WebCrypto
|
||||||
|
* (`crypto.subtle` is HTTPS-only outside of localhost). We therefore use
|
||||||
|
* node-forge for both RSA-OAEP (session key wrap) and AES-256-GCM (payload
|
||||||
|
* decrypt).
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. Fetch the server's RSA public key (PEM) — cached for the session.
|
||||||
|
* 2. Generate a random 32-byte AES-256 session key in the browser.
|
||||||
|
* 3. RSA-OAEP-SHA256 encrypt the session key with the server public key.
|
||||||
|
* 4. POST the encrypted session key to /api/inference/access-endpoint/reveal.
|
||||||
|
* 5. Server decrypts the session key, AES-GCM encrypts the API key and
|
||||||
|
* returns {iv, ciphertext}.
|
||||||
|
* 6. Decrypt ciphertext locally using the session key we generated.
|
||||||
|
*
|
||||||
|
* The session key never leaves the browser in plaintext, and the API key
|
||||||
|
* never leaves the server in plaintext.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import forge from "node-forge";
|
||||||
|
|
||||||
|
import { authFetch } from "@/features/auth";
|
||||||
|
|
||||||
|
let cachedPublicKeyPem: string | null = null;
|
||||||
|
let cachedForgeKey: forge.pki.rsa.PublicKey | null = null;
|
||||||
|
|
||||||
|
export function clearAccessEndpointPublicKeyCache(): void {
|
||||||
|
cachedPublicKeyPem = null;
|
||||||
|
cachedForgeKey = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAccessEndpointPublicKey(
|
||||||
|
forceRefresh = false,
|
||||||
|
): Promise<forge.pki.rsa.PublicKey> {
|
||||||
|
if (!forceRefresh && cachedForgeKey) {
|
||||||
|
return cachedForgeKey;
|
||||||
|
}
|
||||||
|
const response = await authFetch("/api/inference/access-endpoint/public-key");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch public key: ${response.status}`);
|
||||||
|
}
|
||||||
|
const body = (await response.json()) as { public_key?: string };
|
||||||
|
const publicKeyPem = body.public_key?.trim();
|
||||||
|
if (!publicKeyPem) {
|
||||||
|
throw new Error("Public key missing from response");
|
||||||
|
}
|
||||||
|
if (!forceRefresh && cachedPublicKeyPem === publicKeyPem && cachedForgeKey) {
|
||||||
|
return cachedForgeKey;
|
||||||
|
}
|
||||||
|
const forgeKey = forge.pki.publicKeyFromPem(publicKeyPem);
|
||||||
|
cachedPublicKeyPem = publicKeyPem;
|
||||||
|
cachedForgeKey = forgeKey;
|
||||||
|
return forgeKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform the encrypted reveal handshake and return the plaintext API key.
|
||||||
|
* Returns `null` if the endpoint is disabled (HTTP 404 from reveal).
|
||||||
|
*/
|
||||||
|
export async function revealAccessEndpointKey(): Promise<string | null> {
|
||||||
|
// Step 1: fetch server public key (cached per session)
|
||||||
|
let publicKey: forge.pki.rsa.PublicKey;
|
||||||
|
try {
|
||||||
|
publicKey = await fetchAccessEndpointPublicKey();
|
||||||
|
} catch {
|
||||||
|
// Refresh cache once on failure (server may have restarted with new keypair)
|
||||||
|
publicKey = await fetchAccessEndpointPublicKey(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: generate AES-256 session key
|
||||||
|
const sessionKeyBytes = forge.random.getBytesSync(32);
|
||||||
|
|
||||||
|
// Step 3: RSA-OAEP-SHA256 wrap the session key
|
||||||
|
const encryptedSessionKeyBytes = publicKey.encrypt(
|
||||||
|
sessionKeyBytes,
|
||||||
|
"RSA-OAEP",
|
||||||
|
{
|
||||||
|
md: forge.md.sha256.create(),
|
||||||
|
mgf1: { md: forge.md.sha256.create() },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const encryptedSessionKeyB64 = forge.util.encode64(encryptedSessionKeyBytes);
|
||||||
|
|
||||||
|
// Step 4: POST to reveal endpoint
|
||||||
|
const response = await authFetch(
|
||||||
|
"/api/inference/access-endpoint/reveal",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ encrypted_session_key: encryptedSessionKeyB64 }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
return null; // Endpoint not enabled
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
// A restart can invalidate the cached public key — retry once with a
|
||||||
|
// fresh key before surrendering.
|
||||||
|
clearAccessEndpointPublicKeyCache();
|
||||||
|
throw new Error(`Reveal failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await response.json()) as { iv?: string; ciphertext?: string };
|
||||||
|
if (!body.iv || !body.ciphertext) {
|
||||||
|
throw new Error("Reveal response missing iv/ciphertext");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: AES-GCM decrypt
|
||||||
|
const ivBytes = forge.util.decode64(body.iv);
|
||||||
|
const ciphertextWithTag = forge.util.decode64(body.ciphertext);
|
||||||
|
|
||||||
|
// forge's GCM API wants ciphertext and tag separated (tag is the last 16 bytes)
|
||||||
|
const tagLength = 16;
|
||||||
|
if (ciphertextWithTag.length < tagLength) {
|
||||||
|
throw new Error("Ciphertext too short for GCM tag");
|
||||||
|
}
|
||||||
|
const ctBytes = ciphertextWithTag.slice(
|
||||||
|
0,
|
||||||
|
ciphertextWithTag.length - tagLength,
|
||||||
|
);
|
||||||
|
const tagBytes = ciphertextWithTag.slice(
|
||||||
|
ciphertextWithTag.length - tagLength,
|
||||||
|
);
|
||||||
|
|
||||||
|
const decipher = forge.cipher.createDecipher("AES-GCM", sessionKeyBytes);
|
||||||
|
decipher.start({
|
||||||
|
iv: ivBytes,
|
||||||
|
tagLength: tagLength * 8,
|
||||||
|
tag: forge.util.createBuffer(tagBytes),
|
||||||
|
});
|
||||||
|
decipher.update(forge.util.createBuffer(ctBytes));
|
||||||
|
const ok = decipher.finish();
|
||||||
|
if (!ok) {
|
||||||
|
throw new Error("AES-GCM authentication failed");
|
||||||
|
}
|
||||||
|
return decipher.output.toString();
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,14 @@ import {
|
||||||
} from "@/components/assistant-ui/model-selector";
|
} from "@/components/assistant-ui/model-selector";
|
||||||
import { Thread } from "@/components/assistant-ui/thread";
|
import { Thread } from "@/components/assistant-ui/thread";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
|
|
@ -33,6 +41,7 @@ import {
|
||||||
Settings04Icon,
|
Settings04Icon,
|
||||||
} from "@hugeicons/core-free-icons";
|
} from "@hugeicons/core-free-icons";
|
||||||
import { HugeiconsIcon } from "@hugeicons/react";
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
|
import { code as codePlugin } from "@streamdown/code";
|
||||||
import {
|
import {
|
||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
type ReactElement,
|
type ReactElement,
|
||||||
|
|
@ -44,7 +53,10 @@ import {
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { Streamdown } from "streamdown";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { authFetch } from "@/features/auth";
|
||||||
|
import { revealAccessEndpointKey } from "./api/access-endpoint-crypto";
|
||||||
import { listLocalModels } from "./api/chat-api";
|
import { listLocalModels } from "./api/chat-api";
|
||||||
import { ChatSettingsPanel } from "./chat-settings-sheet";
|
import { ChatSettingsPanel } from "./chat-settings-sheet";
|
||||||
import { ContextUsageBar } from "./components/context-usage-bar";
|
import { ContextUsageBar } from "./components/context-usage-bar";
|
||||||
|
|
@ -73,6 +85,81 @@ type LoraCandidate = {
|
||||||
updatedAt?: number;
|
updatedAt?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SHIKI_THEME = ["github-light", "github-dark"] as ["github-light", "github-dark"];
|
||||||
|
|
||||||
|
function HighlightedSnippet({
|
||||||
|
language,
|
||||||
|
source,
|
||||||
|
}: {
|
||||||
|
language: "python" | "bash";
|
||||||
|
source: string;
|
||||||
|
}) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const markdown = useMemo(
|
||||||
|
() => `\`\`\`${language}\n${source}\n\`\`\``,
|
||||||
|
[language, source],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCopy = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// Always use the textarea/execCommand approach — it works on plain HTTP
|
||||||
|
// inside a synchronous user-gesture handler. navigator.clipboard is
|
||||||
|
// HTTPS-only on non-localhost origins and silently fails.
|
||||||
|
const ta = document.createElement("textarea");
|
||||||
|
ta.value = source;
|
||||||
|
// Must be visible (off-screen but not display:none) for execCommand to work
|
||||||
|
ta.setAttribute("readonly", "");
|
||||||
|
ta.style.position = "fixed";
|
||||||
|
ta.style.left = "-9999px";
|
||||||
|
ta.style.top = "-9999px";
|
||||||
|
ta.style.opacity = "0";
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.focus();
|
||||||
|
ta.select();
|
||||||
|
// setSelectionRange needed for iOS
|
||||||
|
ta.setSelectionRange(0, ta.value.length);
|
||||||
|
const ok = document.execCommand("copy");
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[source],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Streamdown memoizes/diffs markdown blocks and can hold onto previously
|
||||||
|
// highlighted content when only an inline value inside the code fence
|
||||||
|
// changes (e.g. the API key). Forcing a remount keyed on the source string
|
||||||
|
// guarantees the displayed snippet always matches the latest props.
|
||||||
|
return (
|
||||||
|
<div className="relative mt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="absolute top-1.5 right-1.5 z-10 rounded bg-muted-foreground/10 px-1.5 py-0.5 text-[10px] text-muted-foreground hover:bg-muted-foreground/20 hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{copied ? "Copied!" : "Copy"}
|
||||||
|
</button>
|
||||||
|
<div className="overflow-x-auto rounded bg-muted p-2 text-[11px] leading-relaxed [&_pre]:!m-0 [&_pre]:!bg-transparent [&_pre]:!p-0 [&_pre]:!overflow-x-auto [&_code]:!whitespace-pre-wrap [&_code]:!break-all [&_[data-streamdown=code-block]]:!my-0 [&_[data-streamdown=code-block]]:!border-0 [&_[data-streamdown=code-block]]:!p-0">
|
||||||
|
<Streamdown
|
||||||
|
key={markdown}
|
||||||
|
mode="static"
|
||||||
|
plugins={{ code: codePlugin }}
|
||||||
|
controls={{ code: false }}
|
||||||
|
shikiTheme={SHIKI_THEME}
|
||||||
|
>
|
||||||
|
{markdown}
|
||||||
|
</Streamdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeModelRef(value: string | null | undefined): string {
|
function normalizeModelRef(value: string | null | undefined): string {
|
||||||
return value?.trim().toLowerCase() ?? "";
|
return value?.trim().toLowerCase() ?? "";
|
||||||
}
|
}
|
||||||
|
|
@ -497,6 +584,13 @@ export function ChatPage(): ReactElement {
|
||||||
// explicitly sets a nonce in handleNewThread.
|
// explicitly sets a nonce in handleNewThread.
|
||||||
const [view, setView] = useState<ChatView>(getInitialSingleChatView);
|
const [view, setView] = useState<ChatView>(getInitialSingleChatView);
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
const [endpointDialogOpen, setEndpointDialogOpen] = useState(false);
|
||||||
|
const [endpointEnabled, setEndpointEnabled] = useState(false);
|
||||||
|
const [endpointLoading, setEndpointLoading] = useState(false);
|
||||||
|
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 [modelSelectorOpen, setModelSelectorOpen] = useState(false);
|
||||||
const [modelSelectorLocked, setModelSelectorLocked] = useState(false);
|
const [modelSelectorLocked, setModelSelectorLocked] = useState(false);
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
|
|
@ -537,6 +631,214 @@ export function ChatPage(): ReactElement {
|
||||||
const canCompare = useMemo(() => {
|
const canCompare = useMemo(() => {
|
||||||
return Boolean(inferenceParams.checkpoint);
|
return Boolean(inferenceParams.checkpoint);
|
||||||
}, [inferenceParams.checkpoint]);
|
}, [inferenceParams.checkpoint]);
|
||||||
|
const modelAlias = inferenceParams.checkpoint || "unsloth/your-model-alias";
|
||||||
|
const endpointPythonSnippet = useMemo(
|
||||||
|
() => `from openai import OpenAI
|
||||||
|
|
||||||
|
client = OpenAI(
|
||||||
|
base_url="${endpointBaseUrl}",
|
||||||
|
api_key="${endpointApiKey}",
|
||||||
|
)
|
||||||
|
|
||||||
|
completion = client.chat.completions.create(
|
||||||
|
model="${modelAlias}",
|
||||||
|
messages=[{"role": "user", "content": "What is 2+2?"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
print(completion.choices[0].message.content)`,
|
||||||
|
[endpointApiKey, endpointBaseUrl, modelAlias],
|
||||||
|
);
|
||||||
|
const endpointCurlSnippet = useMemo(
|
||||||
|
() => `curl ${endpointBaseUrl}/chat/completions \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-H "Authorization: Bearer ${endpointApiKey}" \\
|
||||||
|
-d '{
|
||||||
|
"model": "${modelAlias}",
|
||||||
|
"messages": [{"role": "user", "content": "What is 2+2?"}]
|
||||||
|
}'`,
|
||||||
|
[endpointApiKey, endpointBaseUrl, modelAlias],
|
||||||
|
);
|
||||||
|
const responsesPythonSnippet = useMemo(
|
||||||
|
() => `from openai import OpenAI
|
||||||
|
|
||||||
|
client = OpenAI(
|
||||||
|
base_url="${endpointBaseUrl}",
|
||||||
|
api_key="${endpointApiKey}",
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.responses.create(
|
||||||
|
model="${modelAlias}",
|
||||||
|
input=[{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{"type": "input_text", "text": "What is 2+2?"}],
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
|
||||||
|
print(response.output[0].content[0].text)`,
|
||||||
|
[endpointApiKey, endpointBaseUrl, modelAlias],
|
||||||
|
);
|
||||||
|
const responsesCurlSnippet = useMemo(
|
||||||
|
() => `curl ${endpointBaseUrl}/responses \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-H "Authorization: Bearer ${endpointApiKey}" \\
|
||||||
|
-d '{
|
||||||
|
"model": "${modelAlias}",
|
||||||
|
"input": [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [{"type": "input_text", "text": "What is 2+2?"}]
|
||||||
|
}]
|
||||||
|
}'`,
|
||||||
|
[endpointApiKey, endpointBaseUrl, modelAlias],
|
||||||
|
);
|
||||||
|
const anthropicPythonSnippet = useMemo(
|
||||||
|
() => `from anthropic import Anthropic
|
||||||
|
|
||||||
|
client = Anthropic(
|
||||||
|
base_url="${endpointBaseUrl}",
|
||||||
|
api_key="${endpointApiKey}",
|
||||||
|
)
|
||||||
|
|
||||||
|
message = client.messages.create(
|
||||||
|
model="${modelAlias}",
|
||||||
|
max_tokens=1024,
|
||||||
|
messages=[{"role": "user", "content": "What is 2+2?"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
print(message.content[0].text)`,
|
||||||
|
[endpointApiKey, endpointBaseUrl, modelAlias],
|
||||||
|
);
|
||||||
|
const anthropicCurlSnippet = useMemo(
|
||||||
|
() => `curl ${endpointBaseUrl}/messages \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-H "x-api-key: ${endpointApiKey}" \\
|
||||||
|
-d '{
|
||||||
|
"model": "${modelAlias}",
|
||||||
|
"max_tokens": 1024,
|
||||||
|
"messages": [{"role": "user", "content": "What is 2+2?"}]
|
||||||
|
}'`,
|
||||||
|
[endpointApiKey, endpointBaseUrl, modelAlias],
|
||||||
|
);
|
||||||
|
const [endpointApiTab, setEndpointApiTab] = useState<"completions" | "responses" | "messages">("completions");
|
||||||
|
|
||||||
|
const applyEndpointData = useCallback(
|
||||||
|
(data: {
|
||||||
|
enabled: boolean;
|
||||||
|
local_url: string;
|
||||||
|
external_url: string | null;
|
||||||
|
model: string;
|
||||||
|
}) => {
|
||||||
|
setEndpointEnabled(data.enabled);
|
||||||
|
if (!data.enabled) {
|
||||||
|
setEndpointApiKey("");
|
||||||
|
}
|
||||||
|
setEndpointLocalUrl(data.local_url);
|
||||||
|
setEndpointExternalUrl(data.external_url ?? null);
|
||||||
|
// Default to external URL when accessing Studio remotely
|
||||||
|
const isRemote =
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
!["localhost", "127.0.0.1", "[::1]"].includes(window.location.hostname);
|
||||||
|
setEndpointBaseUrl(
|
||||||
|
isRemote && data.external_url ? data.external_url : data.local_url,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch the API key via the encrypted reveal handshake. Called after any
|
||||||
|
// endpoint state change that leaves the endpoint enabled (open dialog,
|
||||||
|
// enable, regenerate). The plaintext key only lives in React state.
|
||||||
|
const revealAndSetApiKey = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const key = await revealAccessEndpointKey();
|
||||||
|
setEndpointApiKey(key ?? "");
|
||||||
|
} catch {
|
||||||
|
setEndpointApiKey("");
|
||||||
|
toast.error("Failed to fetch API key");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Keep endpoint state in sync whenever the dialog opens or the loaded
|
||||||
|
// GGUF model changes, so the status indicator in the header button
|
||||||
|
// reflects reality without the user having to open the dialog first.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!inferenceParams.checkpoint || !activeGgufVariant) {
|
||||||
|
setEndpointEnabled(false);
|
||||||
|
setEndpointApiKey("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
authFetch("/api/inference/access-endpoint")
|
||||||
|
.then((res) => (res.ok ? res.json() : null))
|
||||||
|
.then(async (data) => {
|
||||||
|
if (cancelled || !data) return;
|
||||||
|
applyEndpointData(data);
|
||||||
|
if (data.enabled) {
|
||||||
|
const key = await revealAccessEndpointKey().catch(() => null);
|
||||||
|
if (!cancelled) setEndpointApiKey(key ?? "");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
endpointDialogOpen,
|
||||||
|
inferenceParams.checkpoint,
|
||||||
|
activeGgufVariant,
|
||||||
|
applyEndpointData,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleToggleEndpoint = useCallback(async () => {
|
||||||
|
setEndpointLoading(true);
|
||||||
|
try {
|
||||||
|
const action = endpointEnabled ? "disable" : "enable";
|
||||||
|
const res = await authFetch(`/api/inference/access-endpoint/${action}`, {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => null);
|
||||||
|
toast.error(err?.detail ?? "Failed to toggle endpoint");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
applyEndpointData(data);
|
||||||
|
if (data.enabled) {
|
||||||
|
await revealAndSetApiKey();
|
||||||
|
}
|
||||||
|
toast.success(
|
||||||
|
data.enabled
|
||||||
|
? "API endpoint enabled"
|
||||||
|
: "API endpoint disabled",
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to toggle endpoint");
|
||||||
|
} finally {
|
||||||
|
setEndpointLoading(false);
|
||||||
|
}
|
||||||
|
}, [endpointEnabled, applyEndpointData, revealAndSetApiKey]);
|
||||||
|
|
||||||
|
const handleRegenerateKey = useCallback(async () => {
|
||||||
|
setEndpointLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await authFetch(
|
||||||
|
"/api/inference/access-endpoint/regenerate",
|
||||||
|
{ method: "POST" },
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => null);
|
||||||
|
toast.error(err?.detail ?? "Failed to regenerate API key");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
applyEndpointData(data);
|
||||||
|
await revealAndSetApiKey();
|
||||||
|
toast.success("API key regenerated");
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to regenerate API key");
|
||||||
|
} finally {
|
||||||
|
setEndpointLoading(false);
|
||||||
|
}
|
||||||
|
}, [applyEndpointData, revealAndSetApiKey]);
|
||||||
|
|
||||||
const handleCheckpointChange = useCallback(
|
const handleCheckpointChange = useCallback(
|
||||||
(
|
(
|
||||||
|
|
@ -641,6 +943,13 @@ export function ChatPage(): ReactElement {
|
||||||
const openSettings = useCallback(() => setSettingsOpen(true), []);
|
const openSettings = useCallback(() => setSettingsOpen(true), []);
|
||||||
const closeSettings = useCallback(() => setSettingsOpen(false), []);
|
const closeSettings = useCallback(() => setSettingsOpen(false), []);
|
||||||
const openSidebar = useCallback(() => setSidebarOpen(true), []);
|
const openSidebar = useCallback(() => setSidebarOpen(true), []);
|
||||||
|
const handleOpenEndpointDialog = useCallback(() => {
|
||||||
|
if (!inferenceParams.checkpoint) {
|
||||||
|
toast.message("Load a model first to access endpoint details.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEndpointDialogOpen(true);
|
||||||
|
}, [inferenceParams.checkpoint]);
|
||||||
|
|
||||||
const enterCompare = useCallback(() => {
|
const enterCompare = useCallback(() => {
|
||||||
setViewBeforeCompare((prev) => prev ?? view);
|
setViewBeforeCompare((prev) => prev ?? view);
|
||||||
|
|
@ -932,6 +1241,27 @@ export function ChatPage(): ReactElement {
|
||||||
completionTokens={contextUsage.completionTokens}
|
completionTokens={contextUsage.completionTokens}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{inferenceParams.checkpoint && activeGgufVariant ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="mr-2 h-8 gap-1.5 px-2 text-xs"
|
||||||
|
onClick={handleOpenEndpointDialog}
|
||||||
|
title={
|
||||||
|
endpointEnabled
|
||||||
|
? "API endpoint is running"
|
||||||
|
: "API endpoint is off"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-block h-2 w-2 rounded-full",
|
||||||
|
endpointEnabled ? "bg-green-500" : "bg-red-500",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
Access Endpoint
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSettingsOpen((o) => !o)}
|
onClick={() => setSettingsOpen((o) => !o)}
|
||||||
|
|
@ -980,6 +1310,220 @@ export function ChatPage(): ReactElement {
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Dialog open={endpointDialogOpen} onOpenChange={setEndpointDialogOpen}>
|
||||||
|
<DialogContent className="corner-squircle sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Access Endpoint</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Serve the active model as an OpenAI-compatible API endpoint
|
||||||
|
for use from your own code, scripts, or other applications.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex items-center justify-between rounded-md border p-3">
|
||||||
|
<div className="text-sm">
|
||||||
|
{endpointEnabled ? (
|
||||||
|
<span className="font-medium text-green-600 dark:text-green-400">
|
||||||
|
Endpoint is running
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Endpoint is off
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant={endpointEnabled ? "outline" : "default"}
|
||||||
|
size="sm"
|
||||||
|
disabled={endpointLoading}
|
||||||
|
onClick={handleToggleEndpoint}
|
||||||
|
>
|
||||||
|
{endpointLoading
|
||||||
|
? endpointEnabled
|
||||||
|
? "Stopping..."
|
||||||
|
: "Starting..."
|
||||||
|
: endpointEnabled
|
||||||
|
? "Disable"
|
||||||
|
: "Enable"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{endpointEnabled && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<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
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
id="endpoint-api-key"
|
||||||
|
value={endpointApiKey}
|
||||||
|
readOnly
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 shrink-0 text-xs"
|
||||||
|
onClick={handleRegenerateKey}
|
||||||
|
disabled={endpointLoading}
|
||||||
|
title="Generate a new API key. The previous key will stop working."
|
||||||
|
>
|
||||||
|
Regenerate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
<label htmlFor="endpoint-model-alias" className="text-xs font-medium">
|
||||||
|
Model
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="endpoint-model-alias"
|
||||||
|
value={modelAlias}
|
||||||
|
readOnly
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex gap-1 rounded-md bg-muted p-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEndpointApiTab("completions")}
|
||||||
|
className={`flex-1 rounded px-2 py-1 text-xs font-medium transition-colors ${
|
||||||
|
endpointApiTab === "completions"
|
||||||
|
? "bg-background text-foreground shadow-sm"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Chat Completions
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEndpointApiTab("responses")}
|
||||||
|
className={`flex-1 rounded px-2 py-1 text-xs font-medium transition-colors ${
|
||||||
|
endpointApiTab === "responses"
|
||||||
|
? "bg-background text-foreground shadow-sm"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Responses
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEndpointApiTab("messages")}
|
||||||
|
className={`flex-1 rounded px-2 py-1 text-xs font-medium transition-colors ${
|
||||||
|
endpointApiTab === "messages"
|
||||||
|
? "bg-background text-foreground shadow-sm"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Messages
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{endpointApiTab === "completions" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<details className="rounded-md border p-2" open>
|
||||||
|
<summary className="cursor-pointer text-xs font-medium">
|
||||||
|
Python (OpenAI SDK)
|
||||||
|
</summary>
|
||||||
|
<HighlightedSnippet
|
||||||
|
language="python"
|
||||||
|
source={endpointPythonSnippet}
|
||||||
|
/>
|
||||||
|
</details>
|
||||||
|
<details className="rounded-md border p-2">
|
||||||
|
<summary className="cursor-pointer text-xs font-medium">
|
||||||
|
cURL
|
||||||
|
</summary>
|
||||||
|
<HighlightedSnippet language="bash" source={endpointCurlSnippet} />
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{endpointApiTab === "responses" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<details className="rounded-md border p-2" open>
|
||||||
|
<summary className="cursor-pointer text-xs font-medium">
|
||||||
|
Python (OpenAI SDK)
|
||||||
|
</summary>
|
||||||
|
<HighlightedSnippet
|
||||||
|
language="python"
|
||||||
|
source={responsesPythonSnippet}
|
||||||
|
/>
|
||||||
|
</details>
|
||||||
|
<details className="rounded-md border p-2">
|
||||||
|
<summary className="cursor-pointer text-xs font-medium">
|
||||||
|
cURL
|
||||||
|
</summary>
|
||||||
|
<HighlightedSnippet language="bash" source={responsesCurlSnippet} />
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{endpointApiTab === "messages" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<details className="rounded-md border p-2" open>
|
||||||
|
<summary className="cursor-pointer text-xs font-medium">
|
||||||
|
Python (Anthropic SDK)
|
||||||
|
</summary>
|
||||||
|
<HighlightedSnippet
|
||||||
|
language="python"
|
||||||
|
source={anthropicPythonSnippet}
|
||||||
|
/>
|
||||||
|
</details>
|
||||||
|
<details className="rounded-md border p-2">
|
||||||
|
<summary className="cursor-pointer text-xs font-medium">
|
||||||
|
cURL
|
||||||
|
</summary>
|
||||||
|
<HighlightedSnippet language="bash" source={anthropicCurlSnippet} />
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue