mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-10 10:12:20 +02:00
fix(security): close bearer alias boundaries
This commit is contained in:
@@ -652,6 +652,50 @@ def _normalize_model_id_from_cache(sess) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _validate_bearer_session_model(sess, owner: str | None = None) -> Optional[str]:
|
||||
"""Enforce endpoint-picker authority for a bearer session model.
|
||||
|
||||
Direct API-key sessions intentionally have no ``ModelEndpoint`` row and
|
||||
retain their documented compatibility behavior. Registered endpoint
|
||||
sessions, including provider-auth-backed rows, must use the visible
|
||||
server-owned inventory and never trigger a provider lookup here.
|
||||
"""
|
||||
endpoint_url = (getattr(sess, "endpoint_url", "") or "").strip()
|
||||
requested = (getattr(sess, "model", "") or "").strip()
|
||||
if not endpoint_url or not requested:
|
||||
return None
|
||||
try:
|
||||
session_base = normalize_base(endpoint_url)
|
||||
except Exception:
|
||||
session_base = endpoint_url.rstrip("/")
|
||||
if not session_base:
|
||||
return None
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
||||
if owner:
|
||||
from src.auth_helpers import owner_filter
|
||||
|
||||
q = owner_filter(q, ModelEndpoint, owner)
|
||||
for ep in q.all():
|
||||
try:
|
||||
if normalize_base(getattr(ep, "base_url", "") or "") != session_base:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
from routes.model_routes import _validate_bearer_model_selection
|
||||
|
||||
sess.model = _validate_bearer_model_selection(ep, requested)
|
||||
return sess.model
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# No registered endpoint means this is the documented direct API-key
|
||||
# compatibility path, not an endpoint-picker selection.
|
||||
return None
|
||||
|
||||
|
||||
def _session_is_research_spinoff(sess) -> bool:
|
||||
"""True if this session was created via research "Discuss" spin-off.
|
||||
|
||||
|
||||
+16
-1
@@ -63,6 +63,7 @@ from routes.model_routes import (
|
||||
from routes.chat_helpers import (
|
||||
resolve_session_auth,
|
||||
build_chat_context,
|
||||
_validate_bearer_session_model,
|
||||
save_assistant_response,
|
||||
run_post_response_tasks,
|
||||
accumulate_token_usage,
|
||||
@@ -847,6 +848,8 @@ def setup_chat_routes(
|
||||
)
|
||||
if not (getattr(sess, "endpoint_url", "") or "").strip():
|
||||
raise HTTPException(400, "Selected model endpoint is not configured")
|
||||
if request_capability.is_bearer:
|
||||
_validate_bearer_session_model(sess, owner=owner)
|
||||
|
||||
# Same allowed_models + daily-cap gate as chat_stream (mirror so the
|
||||
# non-streaming path can't be used to bypass).
|
||||
@@ -869,6 +872,7 @@ def setup_chat_routes(
|
||||
foreground_policy = resolve_foreground_model_policy(
|
||||
owner=owner,
|
||||
allowed_models=_allowed_models_for_request(request),
|
||||
allow_live_probes=request_capability.allow_live_probes,
|
||||
)
|
||||
|
||||
# Build shared context (preset, preprocess, preface, compact)
|
||||
@@ -913,6 +917,7 @@ def setup_chat_routes(
|
||||
sess.headers,
|
||||
owner=owner,
|
||||
policy=foreground_policy,
|
||||
allow_live_probes=request_capability.allow_live_probes,
|
||||
)
|
||||
route_descriptors = build_foreground_route_descriptors(
|
||||
sess.endpoint_url,
|
||||
@@ -921,6 +926,7 @@ def setup_chat_routes(
|
||||
owner=owner,
|
||||
policy=foreground_policy,
|
||||
selected_endpoint_id=chat_request.selected_endpoint_id,
|
||||
allow_live_probes=request_capability.allow_live_probes,
|
||||
)
|
||||
candidate_request_factory = None
|
||||
selected_context_length = getattr(ctx, "context_length", 0)
|
||||
@@ -1151,7 +1157,11 @@ def setup_chat_routes(
|
||||
# its way through a plain chat request (and fail, especially with the
|
||||
# shell disabled).
|
||||
auto_escalated = False
|
||||
_tool_intent = _classify_tool_intent(message) if isinstance(message, str) else None
|
||||
_tool_intent = (
|
||||
_classify_tool_intent(message)
|
||||
if not api_token_request and isinstance(message, str)
|
||||
else None
|
||||
)
|
||||
_workspace_agent_intent = False
|
||||
if not api_token_request and chat_mode == "chat" and _tool_intent and _tool_intent.needs_tools:
|
||||
chat_mode = "agent"
|
||||
@@ -1388,6 +1398,8 @@ def setup_chat_routes(
|
||||
)
|
||||
if not (getattr(sess, "endpoint_url", "") or "").strip():
|
||||
raise HTTPException(400, "Selected model endpoint is not configured")
|
||||
if request_capability.is_bearer:
|
||||
_validate_bearer_session_model(sess, owner=owner)
|
||||
if (
|
||||
not api_token_request
|
||||
and chat_mode == "chat"
|
||||
@@ -1501,6 +1513,7 @@ def setup_chat_routes(
|
||||
foreground_policy = resolve_foreground_model_policy(
|
||||
owner=owner,
|
||||
allowed_models=_allowed_models_for_request(request),
|
||||
allow_live_probes=request_capability.allow_live_probes,
|
||||
)
|
||||
|
||||
# Build shared context (stream path uses enhanced_message for context preface)
|
||||
@@ -1966,6 +1979,7 @@ def setup_chat_routes(
|
||||
sess.headers,
|
||||
owner=_user,
|
||||
policy=_foreground_policy,
|
||||
allow_live_probes=request_capability.allow_live_probes,
|
||||
)
|
||||
_foreground_route_descriptors = build_foreground_route_descriptors(
|
||||
sess.endpoint_url,
|
||||
@@ -1974,6 +1988,7 @@ def setup_chat_routes(
|
||||
owner=_user,
|
||||
policy=_foreground_policy,
|
||||
selected_endpoint_id=selected_endpoint_id,
|
||||
allow_live_probes=request_capability.allow_live_probes,
|
||||
)
|
||||
_chat_request_factory = None
|
||||
_selected_context_length = getattr(ctx, "context_length", 0)
|
||||
|
||||
+40
-1
@@ -67,9 +67,40 @@ async def _as_owner(request: Request, owner: str, fn, *args, **kwargs):
|
||||
"""Run an existing route handler with request.state.current_user temporarily
|
||||
set to ``owner`` so its internal get_current_user/require_user calls see
|
||||
the scope-gated owner (not the "api" pseudo-user the bearer middleware sets).
|
||||
Restores the original value when done. Works for sync and async handlers."""
|
||||
Temporarily hide the bearer header as well: nested legacy handlers classify
|
||||
the raw header independently of ``request.state.api_token``. Restore every
|
||||
request value when done. Works for sync and async handlers."""
|
||||
orig = getattr(request.state, "current_user", None)
|
||||
orig_api_token = getattr(request.state, "api_token", None)
|
||||
missing = object()
|
||||
scope = getattr(request, "scope", None)
|
||||
original_scope_headers = missing
|
||||
original_cached_headers = missing
|
||||
original_mapping_headers = missing
|
||||
|
||||
if isinstance(scope, dict) and "headers" in scope:
|
||||
original_scope_headers = scope["headers"]
|
||||
scope["headers"] = [
|
||||
(name, value)
|
||||
for name, value in (original_scope_headers or [])
|
||||
if not (
|
||||
(isinstance(name, bytes) and name.lower() == b"authorization")
|
||||
or (isinstance(name, str) and name.casefold() == "authorization")
|
||||
)
|
||||
]
|
||||
request_dict = getattr(request, "__dict__", {})
|
||||
if "_headers" in request_dict:
|
||||
original_cached_headers = request_dict["_headers"]
|
||||
request_dict.pop("_headers", None)
|
||||
else:
|
||||
current_headers = getattr(request, "headers", missing)
|
||||
if isinstance(current_headers, dict):
|
||||
original_mapping_headers = current_headers
|
||||
request.headers = {
|
||||
name: value
|
||||
for name, value in current_headers.items()
|
||||
if str(name).casefold() != "authorization"
|
||||
}
|
||||
request.state.current_user = owner
|
||||
request.state.api_token = False
|
||||
try:
|
||||
@@ -86,6 +117,14 @@ async def _as_owner(request: Request, owner: str, fn, *args, **kwargs):
|
||||
pass
|
||||
else:
|
||||
request.state.api_token = orig_api_token
|
||||
if original_scope_headers is not missing:
|
||||
scope["headers"] = original_scope_headers
|
||||
request_dict = getattr(request, "__dict__", {})
|
||||
request_dict.pop("_headers", None)
|
||||
if original_cached_headers is not missing:
|
||||
request_dict["_headers"] = original_cached_headers
|
||||
if original_mapping_headers is not missing:
|
||||
request.headers = original_mapping_headers
|
||||
|
||||
|
||||
def _scope_owner(request: Request, allowed: set[str]) -> str:
|
||||
|
||||
@@ -4,19 +4,23 @@ import json
|
||||
import uuid
|
||||
import random
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Form, HTTPException, Request
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
from typing import List
|
||||
from pydantic import BaseModel
|
||||
import logging
|
||||
|
||||
from core.database import Comparison, SessionLocal
|
||||
from core.session_manager import SessionManager
|
||||
from src.auth_helpers import get_current_user
|
||||
from src.auth_helpers import effective_user, is_bearer_principal, require_chat_scope
|
||||
from routes.session_routes import _reject_raw_endpoint_url_for_non_admin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/compare", tags=["compare"])
|
||||
router = APIRouter(
|
||||
prefix="/api/compare",
|
||||
tags=["compare"],
|
||||
dependencies=[Depends(require_chat_scope)],
|
||||
)
|
||||
|
||||
|
||||
def _owned_endpoint_by_url(db, base_url, owner):
|
||||
@@ -64,6 +68,37 @@ class RecordVoteRequest(BaseModel):
|
||||
is_blind: bool = True
|
||||
|
||||
|
||||
def _validate_bearer_compare_models(models, owner: str) -> list[str]:
|
||||
"""Validate record-only comparison models against visible endpoint caches."""
|
||||
from core.database import ModelEndpoint
|
||||
from src.auth_helpers import owner_filter
|
||||
from routes.model_routes import _validate_bearer_model_selection
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
||||
q = owner_filter(q, ModelEndpoint, owner)
|
||||
endpoints = q.all()
|
||||
selected = []
|
||||
for requested in models:
|
||||
matches = []
|
||||
for ep in endpoints:
|
||||
try:
|
||||
matches.append(_validate_bearer_model_selection(ep, requested))
|
||||
except HTTPException:
|
||||
continue
|
||||
unique = list(dict.fromkeys(matches))
|
||||
if len(unique) != 1:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"Model is not permitted by a visible server endpoint: {requested}",
|
||||
)
|
||||
selected.append(unique[0])
|
||||
return selected
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def setup_compare_routes(session_manager: SessionManager):
|
||||
"""Setup comparison routes."""
|
||||
|
||||
@@ -84,7 +119,9 @@ def setup_compare_routes(session_manager: SessionManager):
|
||||
Returns the comparison ID and the two session IDs so the client
|
||||
can fire two independent SSE streams to /api/chat_stream.
|
||||
"""
|
||||
user = getattr(request.state, 'current_user', None)
|
||||
require_chat_scope(request)
|
||||
user = effective_user(request)
|
||||
bearer = is_bearer_principal(request)
|
||||
comp_id = str(uuid.uuid4())
|
||||
sid_a = str(uuid.uuid4())
|
||||
sid_b = str(uuid.uuid4())
|
||||
@@ -160,6 +197,13 @@ def setup_compare_routes(session_manager: SessionManager):
|
||||
_reject_raw_endpoint_url_for_non_admin(
|
||||
request, user, str(ep.id) if ep is not None else None, endpoint
|
||||
)
|
||||
selected_model = model
|
||||
if bearer:
|
||||
if ep is None:
|
||||
raise HTTPException(403, "Choose a registered model endpoint")
|
||||
from routes.model_routes import _validate_bearer_model_selection
|
||||
|
||||
selected_model = _validate_bearer_model_selection(ep, model)
|
||||
# Bind the [CMP] session to the RESOLVED endpoint, not the raw
|
||||
# caller-supplied string. When the URL matches a registered
|
||||
# endpoint visible to the caller, use that row's own normalized
|
||||
@@ -176,7 +220,7 @@ def setup_compare_routes(session_manager: SessionManager):
|
||||
# `ep` is None (raw admin URL or no match), so a comparison can
|
||||
# never inherit another user's key/headers.
|
||||
headers = build_headers(ep.api_key, ep.base_url) if (ep and ep.api_key) else None
|
||||
resolved.append((sid, model, session_endpoint_url, headers))
|
||||
resolved.append((sid, selected_model, session_endpoint_url, headers))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -203,8 +247,8 @@ def setup_compare_routes(session_manager: SessionManager):
|
||||
comp = Comparison(
|
||||
id=comp_id,
|
||||
prompt=prompt,
|
||||
model_a=model_a,
|
||||
model_b=model_b,
|
||||
model_a=resolved[0][1],
|
||||
model_b=resolved[1][1],
|
||||
# Record the URL the session actually dials. For URL callers this
|
||||
# is their raw input; for id-only callers (empty endpoint_a/_b)
|
||||
# fall back to the resolved endpoint URL so the column stays
|
||||
@@ -241,7 +285,8 @@ def setup_compare_routes(session_manager: SessionManager):
|
||||
winner: str = Form(...), # "left", "right", or "tie"
|
||||
):
|
||||
"""Record the user's vote and reveal model names if blind."""
|
||||
user = get_current_user(request)
|
||||
require_chat_scope(request)
|
||||
user = effective_user(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
comp = db.query(Comparison).filter(Comparison.id == comp_id).first()
|
||||
@@ -283,15 +328,20 @@ def setup_compare_routes(session_manager: SessionManager):
|
||||
@router.post("/record")
|
||||
def record_comparison(request: Request, body: RecordVoteRequest):
|
||||
"""Lightweight endpoint to record a comparison vote from the frontend."""
|
||||
user = get_current_user(request)
|
||||
require_chat_scope(request)
|
||||
user = effective_user(request)
|
||||
comp_id = str(uuid.uuid4())
|
||||
|
||||
model_a = body.models[0] if len(body.models) > 0 else ""
|
||||
model_b = body.models[1] if len(body.models) > 1 else ""
|
||||
models = list(body.models or [])
|
||||
if is_bearer_principal(request):
|
||||
models = _validate_bearer_compare_models(models, user)
|
||||
|
||||
model_a = models[0] if len(models) > 0 else ""
|
||||
model_b = models[1] if len(models) > 1 else ""
|
||||
|
||||
# For N>2 models, store the full list as JSON in blind_mapping
|
||||
if len(body.models) > 2:
|
||||
blind_mapping = json.dumps({"models": body.models})
|
||||
if len(models) > 2:
|
||||
blind_mapping = json.dumps({"models": models})
|
||||
else:
|
||||
blind_mapping = None
|
||||
|
||||
@@ -320,7 +370,8 @@ def setup_compare_routes(session_manager: SessionManager):
|
||||
@router.get("/history")
|
||||
def list_comparisons(request: Request):
|
||||
"""List past comparisons."""
|
||||
user = get_current_user(request)
|
||||
require_chat_scope(request)
|
||||
user = effective_user(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(Comparison)
|
||||
@@ -346,7 +397,8 @@ def setup_compare_routes(session_manager: SessionManager):
|
||||
@router.delete("/{comp_id}")
|
||||
def delete_comparison(request: Request, comp_id: str):
|
||||
"""Delete a comparison and its ephemeral sessions."""
|
||||
user = get_current_user(request)
|
||||
require_chat_scope(request)
|
||||
user = effective_user(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
comp = db.query(Comparison).filter(Comparison.id == comp_id).first()
|
||||
|
||||
@@ -10,11 +10,19 @@ import uuid
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
|
||||
from core.database import SessionLocal, GalleryImage, GalleryAlbum, ModelEndpoint
|
||||
from core.database import Session as DbSession
|
||||
from src.auth_helpers import get_current_user, owner_filter, require_privilege
|
||||
from src.auth_helpers import (
|
||||
effective_user,
|
||||
get_current_user,
|
||||
is_bearer_principal,
|
||||
owner_filter,
|
||||
require_chat_scope,
|
||||
require_non_bearer_request,
|
||||
require_privilege,
|
||||
)
|
||||
from src.upload_limits import (
|
||||
read_upload_limited,
|
||||
GALLERY_UPLOAD_MAX_BYTES,
|
||||
@@ -33,6 +41,13 @@ _SAM_STATE: Dict[str, Any] = {}
|
||||
_GROUNDING_STATE: Dict[str, Any] = {}
|
||||
|
||||
|
||||
def _gallery_owner(request: Request) -> Optional[str]:
|
||||
"""Use the token owner for bearer calls and preserve the legacy seam otherwise."""
|
||||
if is_bearer_principal(request):
|
||||
return effective_user(request)
|
||||
return get_current_user(request)
|
||||
|
||||
|
||||
def _b64_to_pil_image(image_b64: str, *, mode: str = "RGBA"):
|
||||
if not image_b64:
|
||||
raise HTTPException(400, "Missing image")
|
||||
@@ -346,7 +361,10 @@ async def _fetch_result_image_b64(url: str) -> Optional[str]:
|
||||
|
||||
|
||||
def setup_gallery_routes() -> APIRouter:
|
||||
router = APIRouter(tags=["gallery"])
|
||||
router = APIRouter(
|
||||
tags=["gallery"],
|
||||
dependencies=[Depends(require_chat_scope)],
|
||||
)
|
||||
|
||||
# ---- POST /api/gallery/upload ----
|
||||
@router.post("/api/gallery/upload")
|
||||
@@ -360,7 +378,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
if not file or not hasattr(file, 'filename'):
|
||||
raise HTTPException(400, "No file provided")
|
||||
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
album_id = form.get("album_id") or None
|
||||
content = await read_upload_limited(file, GALLERY_UPLOAD_MAX_BYTES, "Gallery upload")
|
||||
|
||||
@@ -434,7 +452,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
@router.post("/api/gallery/{image_id}/replace")
|
||||
async def gallery_replace(request: Request, image_id: str):
|
||||
"""Replace an existing gallery image file with a new one."""
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
img = db.query(GalleryImage).filter(GalleryImage.id == image_id).first()
|
||||
@@ -479,7 +497,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
"""Rename a gallery photo. Stores the new name in the `prompt`
|
||||
column (which serves as the user-facing label for uploaded
|
||||
photos that have no AI prompt)."""
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
data = await request.json()
|
||||
new_name = (data.get("name") or "").strip()
|
||||
if not new_name:
|
||||
@@ -516,7 +534,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
if angle not in (90, -90, 180, 270):
|
||||
raise HTTPException(400, "Angle must be 90, -90, 180, or 270")
|
||||
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
img = db.query(GalleryImage).filter(GalleryImage.id == image_id).first()
|
||||
@@ -557,7 +575,10 @@ def setup_gallery_routes() -> APIRouter:
|
||||
db.close()
|
||||
|
||||
# ---- POST /api/gallery/ai-upscale ----
|
||||
@router.post("/api/gallery/ai-upscale")
|
||||
@router.post(
|
||||
"/api/gallery/ai-upscale",
|
||||
dependencies=[Depends(require_non_bearer_request)],
|
||||
)
|
||||
async def gallery_ai_upscale(request: Request):
|
||||
"""AI upscale using img2img with the diffusion server."""
|
||||
import base64, httpx
|
||||
@@ -601,7 +622,10 @@ def setup_gallery_routes() -> APIRouter:
|
||||
return {"error": "Upscale request failed"}
|
||||
|
||||
# ---- POST /api/gallery/style-transfer ----
|
||||
@router.post("/api/gallery/style-transfer")
|
||||
@router.post(
|
||||
"/api/gallery/style-transfer",
|
||||
dependencies=[Depends(require_non_bearer_request)],
|
||||
)
|
||||
async def gallery_style_transfer(request: Request):
|
||||
"""Style transfer using img2img with the diffusion server."""
|
||||
import base64, httpx
|
||||
@@ -651,7 +675,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
@router.get("/api/gallery/tags")
|
||||
async def gallery_tags(request: Request) -> Dict[str, Any]:
|
||||
"""Return distinct tags across all active gallery images."""
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(GalleryImage.tags).filter(
|
||||
@@ -683,7 +707,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
offset: int = Query(0, ge=0),
|
||||
limit: int = Query(24, ge=1, le=100),
|
||||
) -> Dict[str, Any]:
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Distinct tags for filter UI
|
||||
@@ -811,7 +835,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
|
||||
@router.get("/api/gallery/albums")
|
||||
async def list_albums(request: Request):
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(GalleryAlbum)
|
||||
@@ -850,7 +874,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
@router.post("/api/gallery/albums")
|
||||
async def create_album(request: Request):
|
||||
import uuid
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
data = await request.json()
|
||||
name = (data.get("name") or "").strip()
|
||||
if not name:
|
||||
@@ -870,7 +894,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
|
||||
@router.get("/api/gallery/stats")
|
||||
async def gallery_stats(request: Request):
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
from sqlalchemy import func
|
||||
@@ -894,13 +918,16 @@ def setup_gallery_routes() -> APIRouter:
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@router.post("/api/gallery/ai-tag-batch")
|
||||
@router.post(
|
||||
"/api/gallery/ai-tag-batch",
|
||||
dependencies=[Depends(require_non_bearer_request)],
|
||||
)
|
||||
async def ai_tag_batch(
|
||||
request: Request,
|
||||
album_id: Optional[str] = Query(None),
|
||||
limit: int = Query(200),
|
||||
):
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(GalleryImage).filter(
|
||||
@@ -919,7 +946,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
# ---- GET /api/gallery/{image_id} ----
|
||||
@router.get("/api/gallery/{image_id}")
|
||||
async def get_gallery_image(request: Request, image_id: str) -> Dict[str, Any]:
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
row = (
|
||||
@@ -940,7 +967,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
# ---- PATCH /api/gallery/{image_id} ----
|
||||
@router.patch("/api/gallery/{image_id}")
|
||||
async def patch_gallery_image(request: Request, image_id: str, req: GalleryPatch) -> Dict[str, Any]:
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
img = db.query(GalleryImage).filter(GalleryImage.id == image_id).first()
|
||||
@@ -992,7 +1019,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
# of a flood of individual downloads).
|
||||
@router.post("/api/gallery/download-zip")
|
||||
async def gallery_download_zip(request: Request):
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
if not user:
|
||||
raise HTTPException(401, "Not authenticated")
|
||||
try:
|
||||
@@ -1047,7 +1074,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
# AI-suggested values you never added.
|
||||
@router.post("/api/gallery/clear-user-tags")
|
||||
async def clear_gallery_user_tags(request: Request) -> Dict[str, Any]:
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(GalleryImage).filter(GalleryImage.is_active == True)
|
||||
@@ -1072,7 +1099,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
# "woman" have leaked into the gallery and you want them gone.
|
||||
@router.post("/api/gallery/clear-ai-tags")
|
||||
async def clear_gallery_ai_tags(request: Request, image_id: Optional[str] = Query(None)) -> Dict[str, Any]:
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(GalleryImage).filter(GalleryImage.is_active == True)
|
||||
@@ -1099,7 +1126,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
# Returns how many rows were touched + how many tags removed.
|
||||
@router.post("/api/gallery/dedupe-tags")
|
||||
async def dedupe_gallery_tags(request: Request) -> Dict[str, Any]:
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(GalleryImage).filter(GalleryImage.is_active == True)
|
||||
@@ -1135,7 +1162,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
# ---- DELETE /api/gallery/{image_id} ----
|
||||
@router.delete("/api/gallery/{image_id}")
|
||||
async def delete_gallery_image(request: Request, image_id: str) -> Dict[str, str]:
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
img = db.query(GalleryImage).filter(GalleryImage.id == image_id).first()
|
||||
@@ -1254,7 +1281,10 @@ def setup_gallery_routes() -> APIRouter:
|
||||
db.close()
|
||||
|
||||
# ---- POST /api/image/inpaint — proxy to diffusion server OR OpenAI ----
|
||||
@router.post("/api/image/inpaint")
|
||||
@router.post(
|
||||
"/api/image/inpaint",
|
||||
dependencies=[Depends(require_non_bearer_request)],
|
||||
)
|
||||
async def inpaint_proxy(request: Request):
|
||||
"""Forward inpaint request. If the selected endpoint is OpenAI, re-shape
|
||||
the request for /v1/images/edits (multipart, inverted mask). Otherwise
|
||||
@@ -1512,7 +1542,10 @@ def setup_gallery_routes() -> APIRouter:
|
||||
# scratch using the prompt", ignoring the source. Real img2img sends
|
||||
# the image alongside a `strength` (denoising strength) and the model
|
||||
# mixes that fraction of new noise into the existing pixels.
|
||||
@router.post("/api/image/harmonize")
|
||||
@router.post(
|
||||
"/api/image/harmonize",
|
||||
dependencies=[Depends(require_non_bearer_request)],
|
||||
)
|
||||
async def harmonize_image(request: Request):
|
||||
"""Harmonize = img2img. The model preserves (1 - strength) of the
|
||||
original and regenerates `strength` fraction. With strength ~0.4
|
||||
@@ -1712,7 +1745,10 @@ def setup_gallery_routes() -> APIRouter:
|
||||
"/v1/images/harmonize, /v1/images/img2img, /v1/images/variations, /sdapi/v1/img2img.")
|
||||
|
||||
# ---- POST /api/image/sharpen ----
|
||||
@router.post("/api/image/sharpen")
|
||||
@router.post(
|
||||
"/api/image/sharpen",
|
||||
dependencies=[Depends(require_non_bearer_request)],
|
||||
)
|
||||
async def sharpen_image(request: Request):
|
||||
"""Apply unsharp-mask sharpening to an image."""
|
||||
require_privilege(request, "can_generate_images")
|
||||
@@ -1737,7 +1773,10 @@ def setup_gallery_routes() -> APIRouter:
|
||||
# AI denoise via Real-ESRGAN with the realesr-general-x4v3 weights at
|
||||
# outscale=1 + denoise_strength. Falls back to a "package missing"
|
||||
# error so the client can prompt the user to install via Cookbook.
|
||||
@router.post("/api/image/denoise")
|
||||
@router.post(
|
||||
"/api/image/denoise",
|
||||
dependencies=[Depends(require_non_bearer_request)],
|
||||
)
|
||||
async def denoise_image(request: Request):
|
||||
require_privilege(request, "can_generate_images")
|
||||
body = await request.json()
|
||||
@@ -1788,7 +1827,10 @@ def setup_gallery_routes() -> APIRouter:
|
||||
# ---- POST /api/image/upscale-local ----
|
||||
# Local Real-ESRGAN upscale (2× or 4×). Self-contained — no diffusion
|
||||
# server required. Used by the editor's AI Upscale button.
|
||||
@router.post("/api/image/upscale-local")
|
||||
@router.post(
|
||||
"/api/image/upscale-local",
|
||||
dependencies=[Depends(require_non_bearer_request)],
|
||||
)
|
||||
async def upscale_image_local(request: Request):
|
||||
require_privilege(request, "can_generate_images")
|
||||
body = await request.json()
|
||||
@@ -1834,7 +1876,10 @@ def setup_gallery_routes() -> APIRouter:
|
||||
return {"error": "AI upscale failed"}
|
||||
|
||||
# ---- POST /api/image/remove-bg ----
|
||||
@router.post("/api/image/mask")
|
||||
@router.post(
|
||||
"/api/image/mask",
|
||||
dependencies=[Depends(require_non_bearer_request)],
|
||||
)
|
||||
async def smart_mask(request: Request):
|
||||
"""Create a neutral segmentation mask from user-provided points or a box.
|
||||
|
||||
@@ -1960,7 +2005,10 @@ def setup_gallery_routes() -> APIRouter:
|
||||
logger.exception("smart_mask failed")
|
||||
raise HTTPException(500, f"SAM mask failed: {exc}") from exc
|
||||
|
||||
@router.post("/api/image/remove-bg")
|
||||
@router.post(
|
||||
"/api/image/remove-bg",
|
||||
dependencies=[Depends(require_non_bearer_request)],
|
||||
)
|
||||
async def remove_background(request: Request):
|
||||
"""Remove background from an image. If the client passes a `hint_mask`
|
||||
(white-where-the-user-wants-the-subject PNG, same dims as the
|
||||
@@ -2053,7 +2101,10 @@ def setup_gallery_routes() -> APIRouter:
|
||||
return {"image": base64.b64encode(buf.getvalue()).decode()}
|
||||
|
||||
# ---- POST /api/image/enhance-face ----
|
||||
@router.post("/api/image/enhance-face")
|
||||
@router.post(
|
||||
"/api/image/enhance-face",
|
||||
dependencies=[Depends(require_non_bearer_request)],
|
||||
)
|
||||
async def enhance_face(request: Request):
|
||||
"""Face/portrait enhancement. Uses GFPGAN if available, falls back to PIL."""
|
||||
require_privilege(request, "can_generate_images")
|
||||
@@ -2139,7 +2190,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
|
||||
@router.put("/api/gallery/albums/{album_id}")
|
||||
async def update_album(request: Request, album_id: str):
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
data = await request.json()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
@@ -2160,7 +2211,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
|
||||
@router.delete("/api/gallery/albums/{album_id}")
|
||||
async def delete_album(request: Request, album_id: str):
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
album = _get_or_404_album(db, album_id, user)
|
||||
@@ -2176,7 +2227,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
|
||||
@router.post("/api/gallery/albums/{album_id}/add")
|
||||
async def add_to_album(request: Request, album_id: str):
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
data = await request.json()
|
||||
ids = data.get("image_ids", [])
|
||||
db = SessionLocal()
|
||||
@@ -2194,7 +2245,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
|
||||
@router.post("/api/gallery/albums/{album_id}/remove")
|
||||
async def remove_from_album(request: Request, album_id: str):
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
data = await request.json()
|
||||
ids = data.get("image_ids", [])
|
||||
db = SessionLocal()
|
||||
@@ -2215,7 +2266,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
|
||||
@router.post("/api/gallery/{image_id}/favorite")
|
||||
async def toggle_favorite(request: Request, image_id: str):
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
img = _get_or_404_image(db, image_id, user)
|
||||
@@ -2227,13 +2278,16 @@ def setup_gallery_routes() -> APIRouter:
|
||||
|
||||
# ---- AI auto-tag ----
|
||||
|
||||
@router.post("/api/gallery/{image_id}/ai-tag")
|
||||
@router.post(
|
||||
"/api/gallery/{image_id}/ai-tag",
|
||||
dependencies=[Depends(require_non_bearer_request)],
|
||||
)
|
||||
async def ai_tag_image(request: Request, image_id: str):
|
||||
"""Send image to vision model for auto-tagging."""
|
||||
import base64, httpx
|
||||
from pathlib import Path
|
||||
|
||||
user = get_current_user(request)
|
||||
user = _gallery_owner(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
img = _get_or_404_image(db, image_id, user)
|
||||
|
||||
+69
-1
@@ -1372,6 +1372,68 @@ def _picker_models_for_endpoint(ep, base_url: str, kind: str):
|
||||
), pinned
|
||||
|
||||
|
||||
def _validate_bearer_model_selection(
|
||||
ep,
|
||||
requested_model: Optional[str],
|
||||
*,
|
||||
allow_empty: bool = False,
|
||||
) -> str:
|
||||
"""Validate a bearer-selected model against the server-owned picker.
|
||||
|
||||
Bearer requests cannot perform a provider model probe. Their model choice
|
||||
must therefore come from the same endpoint-local cache/pin inventory that
|
||||
the server exposes to the model picker. ``allow_empty`` is used only by
|
||||
default-chat, where an explicitly empty inventory has a deterministic empty
|
||||
result rather than an implicit provider alias.
|
||||
"""
|
||||
if ep is None:
|
||||
if allow_empty:
|
||||
return ""
|
||||
raise HTTPException(400, "A registered model endpoint is required")
|
||||
|
||||
base_url = _normalize_base(getattr(ep, "base_url", "") or "")
|
||||
kind = _effective_endpoint_kind(ep, base_url)
|
||||
models, _ = _picker_models_for_endpoint(ep, base_url, kind)
|
||||
models = [model for model in models if isinstance(model, str) and model.strip()]
|
||||
requested = str(requested_model or "").strip()
|
||||
if not requested:
|
||||
if models:
|
||||
return models[0]
|
||||
if allow_empty:
|
||||
return ""
|
||||
raise HTTPException(400, "No permitted model is configured for this endpoint")
|
||||
# A registered local endpoint may intentionally have no persisted
|
||||
# catalog: local models are operator-controlled and bearer requests
|
||||
# must not discover them live. Preserve that documented compatibility
|
||||
# path, while still enforcing any inventory that the server does own
|
||||
# and rejecting explicitly hidden entries below.
|
||||
raw_inventory = _merge_model_ids(
|
||||
_normalize_model_ids(getattr(ep, "cached_models", None)),
|
||||
_normalize_model_ids(getattr(ep, "pinned_models", None)),
|
||||
)
|
||||
hidden = set(_normalize_model_ids(getattr(ep, "hidden_models", None)))
|
||||
if (
|
||||
requested
|
||||
and not raw_inventory
|
||||
and _classify_endpoint(base_url, kind) == "local"
|
||||
and requested not in hidden
|
||||
):
|
||||
return requested
|
||||
if requested in models:
|
||||
return requested
|
||||
|
||||
requested_base = os.path.basename(requested.rstrip("/"))
|
||||
matches = [
|
||||
model for model in models
|
||||
if os.path.basename(model.rstrip("/")) == requested_base
|
||||
]
|
||||
if len(matches) == 1:
|
||||
return matches[0]
|
||||
if len(matches) > 1:
|
||||
raise HTTPException(400, "Model selection is ambiguous for this endpoint")
|
||||
raise HTTPException(400, f"Model is not permitted for this endpoint: {requested}")
|
||||
|
||||
|
||||
def _api_key_fingerprint(api_key: Optional[str]) -> str:
|
||||
"""Stable, non-secret label for distinguishing same-URL credentials."""
|
||||
key = (api_key or "").strip()
|
||||
@@ -2501,7 +2563,13 @@ def setup_model_routes(model_discovery):
|
||||
return {"endpoint_id": "", "endpoint_url": "", "model": ""}
|
||||
base = _normalize_base(ep.base_url)
|
||||
chat_url = build_chat_url(base)
|
||||
if not model and (getattr(ep, "cached_models", None) or getattr(ep, "pinned_models", None)):
|
||||
if is_bearer_principal(request):
|
||||
model = _validate_bearer_model_selection(
|
||||
ep,
|
||||
model,
|
||||
allow_empty=True,
|
||||
)
|
||||
elif not model and (getattr(ep, "cached_models", None) or getattr(ep, "pinned_models", None)):
|
||||
try:
|
||||
visible = _visible_models(ep.cached_models, getattr(ep, "hidden_models", None), getattr(ep, "pinned_models", None))
|
||||
if visible:
|
||||
|
||||
@@ -370,6 +370,7 @@ def setup_session_routes(
|
||||
user = effective_user(request)
|
||||
endpoint_api_key = ""
|
||||
endpoint_base_url = ""
|
||||
endpoint_row = None
|
||||
_reject_raw_endpoint_url_for_non_admin(request, user, endpoint_id, endpoint_url)
|
||||
if endpoint_id and endpoint_id.strip():
|
||||
from core.database import ModelEndpoint
|
||||
@@ -403,7 +404,14 @@ def setup_session_routes(
|
||||
from src.endpoint_resolver import build_headers
|
||||
validation_headers = build_headers(effective_api_key, endpoint_base_url or endpoint_url)
|
||||
|
||||
if skip_val:
|
||||
if is_bearer_principal(request) and endpoint_row is not None:
|
||||
# Bearer requests are cache-only, but cache-only does not mean
|
||||
# caller-authorized. Validate explicit selections and choose an
|
||||
# empty selection deterministically from the server-owned picker.
|
||||
from routes.model_routes import _validate_bearer_model_selection
|
||||
|
||||
model_to_use = _validate_bearer_model_selection(endpoint_row, model_to_use)
|
||||
elif skip_val:
|
||||
# skip_validation = trust the caller and do NOT probe /v1/models.
|
||||
# Used for custom endpoints AND for bare placeholder sessions with no
|
||||
# model at all (e.g. an email reply draft just needs a session to live
|
||||
|
||||
@@ -124,6 +124,33 @@ def _cached_endpoint_model_ids(endpoint) -> list[str]:
|
||||
return ids
|
||||
|
||||
|
||||
def _validate_bearer_sync_model(endpoint, requested_model: str) -> str:
|
||||
"""Validate a configured sync model without probing its provider."""
|
||||
try:
|
||||
from routes.model_routes import _validate_bearer_model_selection
|
||||
|
||||
return _validate_bearer_model_selection(endpoint, requested_model)
|
||||
except ImportError:
|
||||
# Keep the lightweight webhook test/import seam usable when optional
|
||||
# route modules are deliberately stubbed. Production uses the
|
||||
# central picker validator above; this fallback remains cache-only.
|
||||
models = _cached_endpoint_model_ids(endpoint)
|
||||
requested = str(requested_model or "").strip()
|
||||
if requested and requested in models:
|
||||
return requested
|
||||
if not requested and models:
|
||||
return models[0]
|
||||
if (
|
||||
requested
|
||||
and not models
|
||||
and not getattr(endpoint, "cached_models", None)
|
||||
and not getattr(endpoint, "pinned_models", None)
|
||||
and "localhost" in str(getattr(endpoint, "base_url", "")).lower()
|
||||
):
|
||||
return requested
|
||||
raise HTTPException(400, "Model is not permitted for this endpoint")
|
||||
|
||||
|
||||
def setup_webhook_routes(
|
||||
webhook_manager: WebhookManager,
|
||||
auth_manager,
|
||||
@@ -389,23 +416,27 @@ def setup_webhook_routes(
|
||||
|
||||
base_url = normalize_base(ep.base_url)
|
||||
endpoint_url = build_chat_url(base_url)
|
||||
model = body.model or "auto"
|
||||
model = body.model or ""
|
||||
api_key = ep.api_key
|
||||
if getattr(ep, "provider_auth_id", None):
|
||||
try:
|
||||
from src.endpoint_resolver import resolve_endpoint_runtime
|
||||
base_url, api_key = resolve_endpoint_runtime(ep, owner=token_owner)
|
||||
runtime_kwargs = {}
|
||||
if not capability.allow_live_probes:
|
||||
runtime_kwargs["allow_live_probes"] = False
|
||||
base_url, api_key = resolve_endpoint_runtime(
|
||||
ep,
|
||||
owner=token_owner,
|
||||
**runtime_kwargs,
|
||||
)
|
||||
endpoint_url = build_chat_url(base_url)
|
||||
except Exception:
|
||||
raise HTTPException(500, "Could not resolve endpoint credentials")
|
||||
|
||||
if model == "auto":
|
||||
# This route is bearer-only. Resolve auto from the endpoint's
|
||||
# already persisted catalog and leave the provider alias in
|
||||
# place when no cache exists; neither choice needs a new
|
||||
# /models or /tags request during ordinary chat.
|
||||
ids = _cached_endpoint_model_ids(ep)
|
||||
model = ids[0] if ids else "auto"
|
||||
# This route is bearer-only. Explicit and empty selections both
|
||||
# use the same server-owned, cache-only picker inventory; an empty
|
||||
# inventory is an error rather than an implicit provider alias.
|
||||
model = _validate_bearer_sync_model(ep, model)
|
||||
|
||||
if not session_manager:
|
||||
raise HTTPException(500, "Session manager not available")
|
||||
|
||||
@@ -251,7 +251,17 @@ def access_token_is_expiring(access_token: str, skew_seconds: int = CHATGPT_ACCE
|
||||
return exp <= int(time.time()) + int(skew_seconds)
|
||||
|
||||
|
||||
def resolve_runtime_credentials(auth_id: str, owner: Optional[str] = None, *, force_refresh: bool = False) -> Dict[str, Any]:
|
||||
def resolve_runtime_credentials(
|
||||
auth_id: str,
|
||||
owner: Optional[str] = None,
|
||||
*,
|
||||
force_refresh: bool = False,
|
||||
allow_live_probes: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
if not allow_live_probes:
|
||||
raise ChatGPTSubscriptionReauthRequired(
|
||||
"ChatGPT Subscription credentials are unavailable when live probes are disabled."
|
||||
)
|
||||
ProviderAuthSession, SessionLocal, utcnow_naive = _database_handles()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
|
||||
+96
-24
@@ -143,7 +143,12 @@ def _endpoint_enabled_models(ep) -> list:
|
||||
return [m for m in merged if m not in hidden]
|
||||
|
||||
|
||||
def resolve_endpoint_runtime(ep, owner: Optional[str] = None) -> Tuple[str, Optional[str]]:
|
||||
def resolve_endpoint_runtime(
|
||||
ep,
|
||||
owner: Optional[str] = None,
|
||||
*,
|
||||
allow_live_probes: bool = True,
|
||||
) -> Tuple[str, Optional[str]]:
|
||||
"""Resolve a ModelEndpoint row to its runtime base URL and bearer/API key.
|
||||
|
||||
Static-key providers use ``ModelEndpoint.api_key``. Session-backed providers
|
||||
@@ -153,7 +158,7 @@ def resolve_endpoint_runtime(ep, owner: Optional[str] = None) -> Tuple[str, Opti
|
||||
base = normalize_base(getattr(ep, "base_url", "") or "")
|
||||
api_key = getattr(ep, "api_key", None)
|
||||
auth_id = getattr(ep, "provider_auth_id", None)
|
||||
if auth_id:
|
||||
if auth_id and allow_live_probes:
|
||||
from src.chatgpt_subscription import resolve_runtime_credentials
|
||||
|
||||
creds = resolve_runtime_credentials(auth_id, owner=owner)
|
||||
@@ -346,6 +351,8 @@ def resolve_endpoint(
|
||||
fallback_model: Optional[str] = None,
|
||||
fallback_headers: Optional[Dict] = None,
|
||||
owner: Optional[str] = None,
|
||||
*,
|
||||
allow_live_probes: bool = True,
|
||||
) -> Tuple[Optional[str], Optional[str], Optional[Dict]]:
|
||||
"""Resolve an endpoint/model from settings, with fallback.
|
||||
|
||||
@@ -407,7 +414,14 @@ def resolve_endpoint(
|
||||
return fallback_url, fallback_model, fallback_headers
|
||||
|
||||
try:
|
||||
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
||||
runtime_kwargs = {}
|
||||
if not allow_live_probes:
|
||||
runtime_kwargs["allow_live_probes"] = False
|
||||
base, api_key = resolve_endpoint_runtime(
|
||||
ep,
|
||||
owner=owner,
|
||||
**runtime_kwargs,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Could not resolve endpoint runtime credentials: %s", e)
|
||||
return fallback_url, fallback_model, fallback_headers
|
||||
@@ -440,6 +454,7 @@ def _resolve_endpoint_by_id_with_descriptor(
|
||||
owner: Optional[str] = None,
|
||||
*,
|
||||
require_exact_model: bool = False,
|
||||
allow_live_probes: bool = True,
|
||||
) -> Optional[Tuple[Tuple[str, str, Dict], dict]]:
|
||||
"""Resolve a concrete endpoint/model plus its non-secret descriptor.
|
||||
|
||||
@@ -461,7 +476,14 @@ def _resolve_endpoint_by_id_with_descriptor(
|
||||
if not ep:
|
||||
return None
|
||||
try:
|
||||
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
|
||||
runtime_kwargs = {}
|
||||
if not allow_live_probes:
|
||||
runtime_kwargs["allow_live_probes"] = False
|
||||
base, api_key = resolve_endpoint_runtime(
|
||||
ep,
|
||||
owner=owner,
|
||||
**runtime_kwargs,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Could not resolve endpoint runtime credentials: %s", e)
|
||||
return None
|
||||
@@ -509,15 +531,17 @@ def resolve_endpoint_by_id(
|
||||
owner: Optional[str] = None,
|
||||
*,
|
||||
require_exact_model: bool = False,
|
||||
allow_live_probes: bool = True,
|
||||
) -> Optional[Tuple[str, str, Dict]]:
|
||||
"""Resolve a specific endpoint id (+ optional model) to its runtime route."""
|
||||
|
||||
resolved = _resolve_endpoint_by_id_with_descriptor(
|
||||
ep_id,
|
||||
model,
|
||||
owner=owner,
|
||||
require_exact_model=require_exact_model,
|
||||
)
|
||||
descriptor_kwargs = {
|
||||
"owner": owner,
|
||||
"require_exact_model": require_exact_model,
|
||||
}
|
||||
if not allow_live_probes:
|
||||
descriptor_kwargs["allow_live_probes"] = False
|
||||
resolved = _resolve_endpoint_by_id_with_descriptor(ep_id, model, **descriptor_kwargs)
|
||||
return resolved[0] if resolved else None
|
||||
|
||||
|
||||
@@ -526,6 +550,8 @@ def resolve_route_descriptor(
|
||||
model: str,
|
||||
headers: Optional[Dict] = None,
|
||||
owner: Optional[str] = None,
|
||||
*,
|
||||
allow_live_probes: bool = True,
|
||||
) -> dict:
|
||||
"""Return the visible endpoint identity for an already-resolved route.
|
||||
|
||||
@@ -548,11 +574,16 @@ def resolve_route_descriptor(
|
||||
q = owner_filter(q, ModelEndpoint, owner)
|
||||
expected = (endpoint_url.rstrip("/"), model, headers or {})
|
||||
for ep in q.all():
|
||||
descriptor_kwargs = {
|
||||
"owner": owner,
|
||||
"require_exact_model": True,
|
||||
}
|
||||
if not allow_live_probes:
|
||||
descriptor_kwargs["allow_live_probes"] = False
|
||||
resolved = _resolve_endpoint_by_id_with_descriptor(
|
||||
ep.id,
|
||||
model,
|
||||
owner=owner,
|
||||
require_exact_model=True,
|
||||
**descriptor_kwargs,
|
||||
)
|
||||
if not resolved:
|
||||
continue
|
||||
@@ -577,6 +608,8 @@ def resolve_route_descriptor_by_id(
|
||||
model: str,
|
||||
headers: Optional[Dict] = None,
|
||||
owner: Optional[str] = None,
|
||||
*,
|
||||
allow_live_probes: bool = True,
|
||||
) -> Optional[dict]:
|
||||
"""Resolve a selected route's identity without relying on row order.
|
||||
|
||||
@@ -586,11 +619,16 @@ def resolve_route_descriptor_by_id(
|
||||
identical.
|
||||
"""
|
||||
|
||||
descriptor_kwargs = {
|
||||
"owner": owner,
|
||||
"require_exact_model": True,
|
||||
}
|
||||
if not allow_live_probes:
|
||||
descriptor_kwargs["allow_live_probes"] = False
|
||||
resolved = _resolve_endpoint_by_id_with_descriptor(
|
||||
endpoint_id,
|
||||
model,
|
||||
owner=owner,
|
||||
require_exact_model=True,
|
||||
**descriptor_kwargs,
|
||||
)
|
||||
if not resolved:
|
||||
return None
|
||||
@@ -600,24 +638,46 @@ def resolve_route_descriptor_by_id(
|
||||
return descriptor if actual == expected else None
|
||||
|
||||
|
||||
def resolve_utility_fallback_candidates(owner: Optional[str] = None) -> list:
|
||||
def resolve_utility_fallback_candidates(
|
||||
owner: Optional[str] = None,
|
||||
*,
|
||||
allow_live_probes: bool = True,
|
||||
) -> list:
|
||||
"""Configured fallback chain for the Utility model (`utility_model_fallbacks`)."""
|
||||
return _resolve_fallback_candidates("utility_model_fallbacks", owner=owner)
|
||||
fallback_kwargs = {"owner": owner}
|
||||
if not allow_live_probes:
|
||||
fallback_kwargs["allow_live_probes"] = False
|
||||
return _resolve_fallback_candidates("utility_model_fallbacks", **fallback_kwargs)
|
||||
|
||||
|
||||
def resolve_vision_fallback_candidates(owner: Optional[str] = None) -> list:
|
||||
def resolve_vision_fallback_candidates(
|
||||
owner: Optional[str] = None,
|
||||
*,
|
||||
allow_live_probes: bool = True,
|
||||
) -> list:
|
||||
"""Configured fallback chain for the Vision model (`vision_model_fallbacks`)."""
|
||||
return _resolve_fallback_candidates("vision_model_fallbacks", owner=owner)
|
||||
fallback_kwargs = {"owner": owner}
|
||||
if not allow_live_probes:
|
||||
fallback_kwargs["allow_live_probes"] = False
|
||||
return _resolve_fallback_candidates("vision_model_fallbacks", **fallback_kwargs)
|
||||
|
||||
|
||||
def _resolve_fallback_candidates(setting_key: str, owner: Optional[str] = None) -> list:
|
||||
def _resolve_fallback_candidates(
|
||||
setting_key: str,
|
||||
owner: Optional[str] = None,
|
||||
*,
|
||||
allow_live_probes: bool = True,
|
||||
) -> list:
|
||||
try:
|
||||
from src.settings import get_user_setting, load_settings
|
||||
settings = load_settings()
|
||||
chain = get_user_setting(setting_key, owner or "", settings.get(setting_key) or []) or []
|
||||
except Exception:
|
||||
return []
|
||||
return resolve_fallback_entries(chain, owner=owner)
|
||||
resolver_kwargs = {"owner": owner}
|
||||
if not allow_live_probes:
|
||||
resolver_kwargs["allow_live_probes"] = False
|
||||
return resolve_fallback_entries(chain, **resolver_kwargs)
|
||||
|
||||
|
||||
def resolve_fallback_entries(
|
||||
@@ -625,6 +685,7 @@ def resolve_fallback_entries(
|
||||
owner: Optional[str] = None,
|
||||
*,
|
||||
require_exact_model: bool = False,
|
||||
allow_live_probes: bool = True,
|
||||
) -> list:
|
||||
"""Resolve ordered endpoint/model entries within the caller's owner scope."""
|
||||
|
||||
@@ -632,11 +693,16 @@ def resolve_fallback_entries(
|
||||
for entry in entries or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
resolver_kwargs = {
|
||||
"owner": owner,
|
||||
"require_exact_model": require_exact_model,
|
||||
}
|
||||
if not allow_live_probes:
|
||||
resolver_kwargs["allow_live_probes"] = False
|
||||
resolved = resolve_endpoint_by_id(
|
||||
entry.get("endpoint_id", ""),
|
||||
entry.get("model", ""),
|
||||
owner=owner,
|
||||
require_exact_model=require_exact_model,
|
||||
**resolver_kwargs,
|
||||
)
|
||||
if resolved and resolved not in out:
|
||||
out.append(resolved)
|
||||
@@ -648,6 +714,7 @@ def resolve_fallback_entries_with_descriptors(
|
||||
owner: Optional[str] = None,
|
||||
*,
|
||||
require_exact_model: bool = False,
|
||||
allow_live_probes: bool = True,
|
||||
) -> list:
|
||||
"""Resolve ordered entries while retaining safe endpoint provenance."""
|
||||
|
||||
@@ -656,11 +723,16 @@ def resolve_fallback_entries_with_descriptors(
|
||||
for entry in entries or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
descriptor_kwargs = {
|
||||
"owner": owner,
|
||||
"require_exact_model": require_exact_model,
|
||||
}
|
||||
if not allow_live_probes:
|
||||
descriptor_kwargs["allow_live_probes"] = False
|
||||
resolved = _resolve_endpoint_by_id_with_descriptor(
|
||||
entry.get("endpoint_id", ""),
|
||||
entry.get("model", ""),
|
||||
owner=owner,
|
||||
require_exact_model=require_exact_model,
|
||||
**descriptor_kwargs,
|
||||
)
|
||||
if not resolved:
|
||||
continue
|
||||
|
||||
@@ -59,6 +59,8 @@ def _load_policy_preferences(owner: Optional[str]) -> dict:
|
||||
def resolve_foreground_model_policy(
|
||||
owner: Optional[str] = None,
|
||||
allowed_models: Optional[Collection[str]] = None,
|
||||
*,
|
||||
allow_live_probes: bool = True,
|
||||
) -> ForegroundModelPolicy:
|
||||
"""Resolve an explicit owner-scoped policy, failing closed to strict mode.
|
||||
|
||||
@@ -95,11 +97,13 @@ def resolve_foreground_model_policy(
|
||||
if resolve_fallback_entries is not _DEFAULT_FALLBACK_ENTRY_RESOLVER:
|
||||
# Preserve the long-standing resolver seam used by downstream tests and
|
||||
# integrations. Production uses the descriptor-aware resolver below.
|
||||
compatibility_candidates = resolve_fallback_entries(
|
||||
entries,
|
||||
owner=owner,
|
||||
require_exact_model=True,
|
||||
)
|
||||
resolver_kwargs = {
|
||||
"owner": owner,
|
||||
"require_exact_model": True,
|
||||
}
|
||||
if not allow_live_probes:
|
||||
resolver_kwargs["allow_live_probes"] = False
|
||||
compatibility_candidates = resolve_fallback_entries(entries, **resolver_kwargs)
|
||||
# Known limitation of this test-only seam: alignment matches on model
|
||||
# alone, so when two entries share a model and the resolver skips the
|
||||
# first, the surviving candidate inherits the skipped entry's
|
||||
@@ -128,11 +132,13 @@ def resolve_foreground_model_policy(
|
||||
}
|
||||
resolved_routes.append((candidate, descriptor))
|
||||
else:
|
||||
resolved_routes = resolve_fallback_entries_with_descriptors(
|
||||
entries,
|
||||
owner=owner,
|
||||
require_exact_model=True,
|
||||
)
|
||||
resolver_kwargs = {
|
||||
"owner": owner,
|
||||
"require_exact_model": True,
|
||||
}
|
||||
if not allow_live_probes:
|
||||
resolver_kwargs["allow_live_probes"] = False
|
||||
resolved_routes = resolve_fallback_entries_with_descriptors(entries, **resolver_kwargs)
|
||||
candidates = [candidate for candidate, _descriptor in resolved_routes]
|
||||
if not candidates:
|
||||
return ForegroundModelPolicy()
|
||||
@@ -146,10 +152,19 @@ def resolve_foreground_model_policy(
|
||||
)
|
||||
|
||||
|
||||
def resolve_foreground_fallback_candidates(owner: Optional[str] = None) -> list:
|
||||
def resolve_foreground_fallback_candidates(
|
||||
owner: Optional[str] = None,
|
||||
*,
|
||||
allow_live_probes: bool = True,
|
||||
) -> list:
|
||||
"""Return only candidates explicitly enabled by the current user."""
|
||||
|
||||
return list(resolve_foreground_model_policy(owner).fallback_candidates)
|
||||
return list(
|
||||
resolve_foreground_model_policy(
|
||||
owner,
|
||||
allow_live_probes=allow_live_probes,
|
||||
).fallback_candidates
|
||||
)
|
||||
|
||||
|
||||
def build_foreground_model_candidates(
|
||||
@@ -158,10 +173,16 @@ def build_foreground_model_candidates(
|
||||
headers: Optional[Dict[str, Any]] = None,
|
||||
owner: Optional[str] = None,
|
||||
policy: Optional[ForegroundModelPolicy] = None,
|
||||
*,
|
||||
allow_live_probes: bool = True,
|
||||
) -> list:
|
||||
"""Build the ordered candidate list for a foreground request."""
|
||||
|
||||
policy = policy or resolve_foreground_model_policy(owner)
|
||||
if policy is None:
|
||||
policy_kwargs = {}
|
||||
if not allow_live_probes:
|
||||
policy_kwargs["allow_live_probes"] = False
|
||||
policy = resolve_foreground_model_policy(owner, **policy_kwargs)
|
||||
primary = (endpoint_url, model, headers or {})
|
||||
candidates = [primary]
|
||||
for candidate in policy.fallback_candidates:
|
||||
@@ -177,21 +198,38 @@ def build_foreground_route_descriptors(
|
||||
owner: Optional[str] = None,
|
||||
policy: Optional[ForegroundModelPolicy] = None,
|
||||
selected_endpoint_id: Optional[str] = None,
|
||||
*,
|
||||
allow_live_probes: bool = True,
|
||||
) -> list:
|
||||
"""Build safe route metadata parallel to foreground candidates."""
|
||||
|
||||
policy = policy or resolve_foreground_model_policy(owner)
|
||||
if policy is None:
|
||||
policy_kwargs = {}
|
||||
if not allow_live_probes:
|
||||
policy_kwargs["allow_live_probes"] = False
|
||||
policy = resolve_foreground_model_policy(owner, **policy_kwargs)
|
||||
selected = None
|
||||
if selected_endpoint_id:
|
||||
descriptor_kwargs = {"owner": owner}
|
||||
if not allow_live_probes:
|
||||
descriptor_kwargs["allow_live_probes"] = False
|
||||
selected = resolve_route_descriptor_by_id(
|
||||
selected_endpoint_id,
|
||||
endpoint_url,
|
||||
model,
|
||||
headers or {},
|
||||
owner=owner,
|
||||
**descriptor_kwargs,
|
||||
)
|
||||
if selected is None:
|
||||
selected = resolve_route_descriptor(endpoint_url, model, headers or {}, owner=owner)
|
||||
descriptor_kwargs = {"owner": owner}
|
||||
if not allow_live_probes:
|
||||
descriptor_kwargs["allow_live_probes"] = False
|
||||
selected = resolve_route_descriptor(
|
||||
endpoint_url,
|
||||
model,
|
||||
headers or {},
|
||||
**descriptor_kwargs,
|
||||
)
|
||||
primary = (endpoint_url, model, headers or {})
|
||||
candidates = [primary]
|
||||
descriptors = [selected]
|
||||
|
||||
@@ -0,0 +1,658 @@
|
||||
"""Regression coverage for the cycle-6 API-token repair boundaries."""
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import APIRouter, FastAPI
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
import core.database as cdb
|
||||
|
||||
|
||||
class _Request:
|
||||
def __init__(self, *, scopes=("chat",), bearer=True, owner="alice"):
|
||||
self.state = SimpleNamespace(
|
||||
api_token=bearer,
|
||||
api_token_owner=owner if bearer else None,
|
||||
api_token_scopes=list(scopes),
|
||||
current_user="api" if bearer else owner,
|
||||
)
|
||||
self.app = SimpleNamespace(state=SimpleNamespace(auth_manager=None))
|
||||
self.headers = {"authorization": "Bearer ody_test"} if bearer else {}
|
||||
self.client = SimpleNamespace(host="127.0.0.1")
|
||||
|
||||
|
||||
class _EndpointDb:
|
||||
def __init__(self, endpoint):
|
||||
self.endpoint = endpoint
|
||||
|
||||
def query(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def filter(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def order_by(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return self.endpoint
|
||||
|
||||
def all(self):
|
||||
return [self.endpoint] if self.endpoint is not None else []
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
|
||||
class _StateInjector:
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] == "http":
|
||||
headers = dict(scope.get("headers") or [])
|
||||
if headers.get(b"x-api-token") == b"1":
|
||||
scope["state"] = {
|
||||
"api_token": True,
|
||||
"api_token_owner": headers.get(b"x-api-owner", b"").decode() or None,
|
||||
"api_token_scopes": [
|
||||
value for value in headers.get(b"x-api-scopes", b"").decode().split(",")
|
||||
if value
|
||||
],
|
||||
"current_user": "api",
|
||||
}
|
||||
else:
|
||||
scope["state"] = {
|
||||
"api_token": False,
|
||||
"current_user": headers.get(b"x-user", b"").decode() or None,
|
||||
}
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
|
||||
def _client(app):
|
||||
return httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app),
|
||||
base_url="http://cycle6.test",
|
||||
)
|
||||
|
||||
|
||||
def _endpoint(router, path, method):
|
||||
for route in reversed(router.routes):
|
||||
if route.path == path and method in route.methods:
|
||||
return route.endpoint
|
||||
raise AssertionError(f"route not found: {method} {path}")
|
||||
|
||||
|
||||
def _isolated_db(tmp_path):
|
||||
engine = create_engine(
|
||||
f"sqlite:///{tmp_path / 'cycle6-repair.db'}",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=NullPool,
|
||||
)
|
||||
cdb.Base.metadata.create_all(engine)
|
||||
return sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gallery_asgi_scope_and_non_bearer_boundaries(monkeypatch, tmp_path):
|
||||
from routes.gallery import gallery_routes
|
||||
from core.database import GalleryImage
|
||||
|
||||
db_session = _isolated_db(tmp_path)
|
||||
image_dir = tmp_path / "generated-images"
|
||||
monkeypatch.setattr(gallery_routes, "SessionLocal", db_session)
|
||||
monkeypatch.setattr(gallery_routes, "GENERATED_IMAGES_DIR", image_dir)
|
||||
monkeypatch.setattr(gallery_routes, "GALLERY_IMAGE_DIR", image_dir)
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(gallery_routes.setup_gallery_routes())
|
||||
client = _client(_StateInjector(app))
|
||||
|
||||
async with client:
|
||||
response = await client.post(
|
||||
"/api/gallery/upload",
|
||||
files={"file": ("photo.png", b"not-a-real-image", "image/png")},
|
||||
headers={
|
||||
"x-api-token": "1",
|
||||
"x-api-owner": "alice",
|
||||
"x-api-scopes": "todos:read",
|
||||
"authorization": "Bearer ody_test",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 403
|
||||
|
||||
response = await client.post(
|
||||
"/api/gallery/upload",
|
||||
files={"file": ("photo.png", b"not-a-real-image", "image/png")},
|
||||
headers={
|
||||
"x-api-token": "1",
|
||||
"x-api-owner": "alice",
|
||||
"x-api-scopes": "chat",
|
||||
"authorization": "Bearer ody_test",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
for path, method in (
|
||||
("/api/gallery/ai-tag-batch", "post"),
|
||||
("/api/gallery/unknown/ai-tag", "post"),
|
||||
("/api/image/inpaint", "post"),
|
||||
):
|
||||
response = await getattr(client, method)(
|
||||
path,
|
||||
headers={
|
||||
"x-api-token": "1",
|
||||
"x-api-owner": "alice",
|
||||
"x-api-scopes": "chat",
|
||||
"authorization": "Bearer ody_test",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 403, (path, response.text)
|
||||
|
||||
db = db_session()
|
||||
try:
|
||||
row = db.query(GalleryImage).first()
|
||||
assert row is not None
|
||||
assert row.owner == "alice"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gallery_cookie_ai_tag_uses_fake_provider_and_bearer_never_reaches_it(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
):
|
||||
from routes.gallery import gallery_routes
|
||||
from core.database import GalleryImage
|
||||
|
||||
db_session = _isolated_db(tmp_path)
|
||||
image_dir = tmp_path / "gallery"
|
||||
image_dir.mkdir()
|
||||
(image_dir / "image.png").write_bytes(b"fake-image")
|
||||
db = db_session()
|
||||
try:
|
||||
db.add(GalleryImage(
|
||||
id="image-1",
|
||||
filename="image.png",
|
||||
prompt="photo",
|
||||
model="imported",
|
||||
owner="alice",
|
||||
file_hash="hash",
|
||||
file_size=10,
|
||||
))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
monkeypatch.setattr(gallery_routes, "SessionLocal", db_session)
|
||||
monkeypatch.setattr(gallery_routes, "GALLERY_IMAGE_DIR", image_dir)
|
||||
monkeypatch.setattr(
|
||||
"src.document_processor._load_vl_settings",
|
||||
lambda: {"vision_enabled": True, "vision_model": "vision-model"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"src.document_processor._resolve_vl_model",
|
||||
lambda configured, owner=None: (
|
||||
"https://vision.example/v1/chat/completions",
|
||||
configured,
|
||||
{},
|
||||
),
|
||||
)
|
||||
|
||||
provider_calls = []
|
||||
|
||||
class _Response:
|
||||
status_code = 200
|
||||
text = ""
|
||||
|
||||
def json(self):
|
||||
return {"choices": [{"message": {"content": "photo, test"}}]}
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
return None
|
||||
|
||||
async def post(self, *args, **kwargs):
|
||||
provider_calls.append((args, kwargs))
|
||||
return _Response()
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(gallery_routes.setup_gallery_routes())
|
||||
client = _client(_StateInjector(app))
|
||||
monkeypatch.setattr(httpx, "AsyncClient", _FakeClient)
|
||||
|
||||
async with client:
|
||||
cookie_response = await client.post(
|
||||
"/api/gallery/image-1/ai-tag",
|
||||
headers={"x-user": "alice"},
|
||||
)
|
||||
assert cookie_response.status_code == 200, cookie_response.text
|
||||
assert provider_calls
|
||||
before_bearer = len(provider_calls)
|
||||
|
||||
bearer_response = await client.post(
|
||||
"/api/gallery/image-1/ai-tag",
|
||||
headers={
|
||||
"x-api-token": "1",
|
||||
"x-api-owner": "alice",
|
||||
"x-api-scopes": "chat",
|
||||
"authorization": "Bearer ody_test",
|
||||
},
|
||||
)
|
||||
assert bearer_response.status_code == 403
|
||||
assert len(provider_calls) == before_bearer
|
||||
|
||||
|
||||
def test_bearer_model_selection_rejects_hidden_unlisted_and_empty_inventory():
|
||||
from routes.model_routes import _validate_bearer_model_selection
|
||||
|
||||
endpoint = SimpleNamespace(
|
||||
base_url="https://api.example.test/v1",
|
||||
endpoint_kind="api",
|
||||
cached_models=json.dumps(["cached-model", "hidden-model"]),
|
||||
pinned_models=json.dumps(["allowed-model"]),
|
||||
hidden_models=json.dumps(["hidden-model"]),
|
||||
)
|
||||
assert _validate_bearer_model_selection(endpoint, "allowed-model") == "allowed-model"
|
||||
for model in ("cached-model", "hidden-model", "missing-model"):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_validate_bearer_model_selection(endpoint, model)
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
endpoint.pinned_models = "[]"
|
||||
assert _validate_bearer_model_selection(endpoint, "", allow_empty=True) == ""
|
||||
with pytest.raises(HTTPException):
|
||||
_validate_bearer_model_selection(endpoint, "cached-model")
|
||||
|
||||
|
||||
def test_bearer_default_chat_empty_pin_does_not_fall_back_to_cache(monkeypatch):
|
||||
from routes import model_routes
|
||||
from routes import prefs_routes
|
||||
|
||||
endpoint = SimpleNamespace(
|
||||
id="ep",
|
||||
base_url="https://api.example.test/v1",
|
||||
endpoint_kind="api",
|
||||
is_enabled=True,
|
||||
cached_models=json.dumps(["cached-model"]),
|
||||
pinned_models="[]",
|
||||
hidden_models=None,
|
||||
)
|
||||
db = _EndpointDb(endpoint)
|
||||
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(model_routes, "_load_settings", lambda: {
|
||||
"default_endpoint_id": "ep",
|
||||
"default_model": "",
|
||||
"share_defaults_with_users": False,
|
||||
})
|
||||
monkeypatch.setattr(prefs_routes, "_load_for_user", lambda owner: {})
|
||||
route = _endpoint(model_routes.setup_model_routes(None), "/api/default-chat", "GET")
|
||||
|
||||
result = route(_Request())
|
||||
assert result == {
|
||||
"endpoint_id": "ep",
|
||||
"endpoint_url": "https://api.example.test/v1/chat/completions",
|
||||
"model": "",
|
||||
}
|
||||
|
||||
|
||||
def test_bearer_session_model_is_checked_against_endpoint_inventory(monkeypatch):
|
||||
from routes import session_routes
|
||||
|
||||
endpoint = SimpleNamespace(
|
||||
id="ep",
|
||||
is_enabled=True,
|
||||
base_url="https://api.example.test/v1",
|
||||
api_key=None,
|
||||
endpoint_kind="api",
|
||||
cached_models=json.dumps(["provider-model"]),
|
||||
pinned_models=json.dumps(["allowed-model"]),
|
||||
hidden_models=None,
|
||||
)
|
||||
db = _EndpointDb(endpoint)
|
||||
monkeypatch.setattr(session_routes, "SessionLocal", lambda: db)
|
||||
manager = SimpleNamespace(create_session=lambda **kwargs: pytest.fail("session was created"))
|
||||
route = _endpoint(
|
||||
session_routes.setup_session_routes(manager, {}),
|
||||
"/api/session",
|
||||
"POST",
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
route(
|
||||
request=_Request(),
|
||||
name="chat",
|
||||
endpoint_url="",
|
||||
model="provider-model",
|
||||
rag=None,
|
||||
skip_validation="true",
|
||||
api_key="",
|
||||
endpoint_id="ep",
|
||||
)
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_chat_uses_cached_model_and_skips_provider_runtime_resolution(monkeypatch):
|
||||
from routes import webhook_routes
|
||||
from src import chatgpt_subscription, llm_core
|
||||
|
||||
endpoint = SimpleNamespace(
|
||||
owner="alice",
|
||||
is_enabled=True,
|
||||
created_at=1,
|
||||
base_url="https://chatgpt.com/backend-api/codex",
|
||||
api_key=None,
|
||||
provider_auth_id="provider-auth",
|
||||
endpoint_kind="api",
|
||||
cached_models=json.dumps(["cached-model"]),
|
||||
pinned_models=json.dumps(["allowed-model"]),
|
||||
hidden_models=None,
|
||||
)
|
||||
monkeypatch.setattr(webhook_routes, "SessionLocal", lambda: _EndpointDb(endpoint))
|
||||
runtime_calls = []
|
||||
monkeypatch.setattr(
|
||||
chatgpt_subscription,
|
||||
"resolve_runtime_credentials",
|
||||
lambda *args, **kwargs: runtime_calls.append((args, kwargs)) or pytest.fail(
|
||||
"bearer sync resolved provider credentials"
|
||||
),
|
||||
)
|
||||
llm_calls = []
|
||||
|
||||
async def fake_llm(*args, **kwargs):
|
||||
llm_calls.append(kwargs)
|
||||
return "reply"
|
||||
|
||||
monkeypatch.setattr(llm_core, "llm_call_async", fake_llm)
|
||||
|
||||
class _Session:
|
||||
def __init__(self, **kwargs):
|
||||
self.endpoint_url = kwargs["endpoint_url"]
|
||||
self.model = kwargs["model"]
|
||||
self.headers = {}
|
||||
self.history = []
|
||||
|
||||
def add_message(self, message):
|
||||
self.history.append(message)
|
||||
|
||||
manager = SimpleNamespace(
|
||||
create_session=lambda **kwargs: _Session(**kwargs),
|
||||
save_sessions=lambda: None,
|
||||
)
|
||||
router = webhook_routes.setup_webhook_routes(
|
||||
SimpleNamespace(fire_and_forget=lambda *args, **kwargs: None),
|
||||
None,
|
||||
session_manager=manager,
|
||||
)
|
||||
route = _endpoint(router, "/api/v1/chat", "POST")
|
||||
body = SimpleNamespace(
|
||||
message="hello",
|
||||
model=None,
|
||||
session=None,
|
||||
api_key=None,
|
||||
base_url=None,
|
||||
provider=None,
|
||||
)
|
||||
|
||||
result = await route(request=_Request(), body=body)
|
||||
assert result["model"] == "allowed-model"
|
||||
assert runtime_calls == []
|
||||
assert llm_calls[0]["allow_live_probes"] is False
|
||||
|
||||
|
||||
def test_provider_runtime_guard_is_no_live_without_opening_credentials_db(monkeypatch):
|
||||
from src import chatgpt_subscription
|
||||
|
||||
monkeypatch.setattr(
|
||||
chatgpt_subscription,
|
||||
"_database_handles",
|
||||
lambda: pytest.fail("no-live provider guard opened the credentials database"),
|
||||
)
|
||||
with pytest.raises(chatgpt_subscription.ChatGPTSubscriptionReauthRequired):
|
||||
chatgpt_subscription.resolve_runtime_credentials(
|
||||
"provider-auth",
|
||||
owner="alice",
|
||||
allow_live_probes=False,
|
||||
)
|
||||
|
||||
|
||||
def test_foreground_descriptors_propagate_no_live_to_provider_endpoint_resolution(monkeypatch):
|
||||
from src import chatgpt_subscription, endpoint_resolver, foreground_model_routing
|
||||
|
||||
endpoint = SimpleNamespace(
|
||||
id="ep",
|
||||
name="Subscription",
|
||||
is_enabled=True,
|
||||
base_url="https://chatgpt.com/backend-api/codex",
|
||||
api_key=None,
|
||||
provider_auth_id="provider-auth",
|
||||
endpoint_kind="api",
|
||||
cached_models=json.dumps(["allowed-model"]),
|
||||
pinned_models=json.dumps(["allowed-model"]),
|
||||
hidden_models=None,
|
||||
)
|
||||
monkeypatch.setattr(endpoint_resolver, "SessionLocal", lambda: _EndpointDb(endpoint))
|
||||
monkeypatch.setattr(
|
||||
chatgpt_subscription,
|
||||
"resolve_runtime_credentials",
|
||||
lambda *args, **kwargs: pytest.fail("foreground descriptor resolved provider credentials"),
|
||||
)
|
||||
|
||||
descriptors = foreground_model_routing.build_foreground_route_descriptors(
|
||||
"https://chatgpt.com/backend-api/codex/responses",
|
||||
"allowed-model",
|
||||
{},
|
||||
owner="alice",
|
||||
policy=foreground_model_routing.ForegroundModelPolicy(),
|
||||
allow_live_probes=False,
|
||||
)
|
||||
assert descriptors[0]["endpoint_label"] in {"Subscription", "Selected route"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_codex_owner_bridge_is_asgi_compatible_with_real_bearer_header():
|
||||
from routes import codex_routes
|
||||
from src.auth_helpers import is_bearer_principal, require_user
|
||||
|
||||
memory_router = APIRouter(prefix="/api/memory")
|
||||
|
||||
@memory_router.get("")
|
||||
async def memory_list(request):
|
||||
return {
|
||||
"owner": require_user(request),
|
||||
"bearer": is_bearer_principal(request),
|
||||
"authorization": request.headers.get("authorization"),
|
||||
}
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(codex_routes.setup_codex_routes(memory_router=memory_router))
|
||||
async with _client(_StateInjector(app)) as client:
|
||||
response = await client.get(
|
||||
"/api/codex/memory",
|
||||
headers={
|
||||
"x-api-token": "1",
|
||||
"x-api-owner": "alice",
|
||||
"x-api-scopes": "memory:read",
|
||||
"authorization": "Bearer ody_test",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json() == {
|
||||
"owner": "alice",
|
||||
"bearer": False,
|
||||
"authorization": None,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_codex_owner_bridge_directly_restores_scope_headers():
|
||||
from starlette.requests import Request
|
||||
|
||||
from routes.codex_routes import _as_owner
|
||||
from src.auth_helpers import is_bearer_principal, require_user
|
||||
|
||||
original_headers = [
|
||||
(b"authorization", b"Bearer ody_test"),
|
||||
(b"x-test", b"1"),
|
||||
]
|
||||
scope = {"type": "http", "headers": original_headers, "state": {
|
||||
"api_token": True,
|
||||
"api_token_owner": "alice",
|
||||
"api_token_scopes": ["memory:read"],
|
||||
"current_user": "api",
|
||||
}}
|
||||
|
||||
async def receive():
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
request = Request(scope, receive)
|
||||
assert request.headers.get("authorization") == "Bearer ody_test"
|
||||
|
||||
async def nested(req):
|
||||
assert not is_bearer_principal(req)
|
||||
assert req.headers.get("authorization") is None
|
||||
assert require_user(req) == "alice"
|
||||
return "ok"
|
||||
|
||||
assert await _as_owner(request, "alice", nested, request) == "ok"
|
||||
assert scope["headers"] == original_headers
|
||||
assert request.headers.get("authorization") == "Bearer ody_test"
|
||||
assert is_bearer_principal(request)
|
||||
assert request.state.api_token is True
|
||||
assert request.state.current_user == "api"
|
||||
|
||||
|
||||
def test_compare_direct_model_gate_rejects_unlisted_bearer_models(monkeypatch):
|
||||
from routes import compare_routes
|
||||
|
||||
endpoint = SimpleNamespace(
|
||||
id="ep",
|
||||
base_url="https://api.example.test/v1",
|
||||
api_key=None,
|
||||
endpoint_kind="api",
|
||||
cached_models=json.dumps(["cached-model"]),
|
||||
pinned_models=json.dumps(["allowed-model"]),
|
||||
hidden_models=None,
|
||||
is_enabled=True,
|
||||
)
|
||||
monkeypatch.setattr(compare_routes, "SessionLocal", lambda: _EndpointDb(endpoint))
|
||||
manager = SimpleNamespace(
|
||||
create_session=lambda **kwargs: pytest.fail("comparison session was created"),
|
||||
)
|
||||
route = _endpoint(compare_routes.setup_compare_routes(manager), "/api/compare/start", "POST")
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
route(
|
||||
request=_Request(),
|
||||
prompt="compare",
|
||||
model_a="cached-model",
|
||||
model_b="allowed-model",
|
||||
endpoint_a="",
|
||||
endpoint_b="",
|
||||
endpoint_a_id="ep",
|
||||
endpoint_b_id="ep",
|
||||
is_blind="true",
|
||||
)
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compare_aliases_run_chat_scope_dependency(monkeypatch):
|
||||
from routes import compare_routes
|
||||
|
||||
router = compare_routes.setup_compare_routes(SimpleNamespace())
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
headers = {
|
||||
"x-api-token": "1",
|
||||
"x-api-owner": "alice",
|
||||
"x-api-scopes": "todos:read",
|
||||
"authorization": "Bearer ody_test",
|
||||
}
|
||||
async with _client(_StateInjector(app)) as client:
|
||||
requests = [
|
||||
client.post("/api/compare/start", data={"prompt": "x", "model_a": "a", "model_b": "b", "endpoint_a": "https://a.example", "endpoint_b": "https://b.example"}, headers=headers),
|
||||
client.post("/api/compare/record", json={"prompt": "x", "models": ["a", "b"], "winner": "tie"}, headers=headers),
|
||||
client.get("/api/compare/history", headers=headers),
|
||||
client.post("/api/compare/abc/vote", data={"winner": "tie"}, headers=headers),
|
||||
client.delete("/api/compare/abc", headers=headers),
|
||||
]
|
||||
responses = await __import__("asyncio").gather(*requests)
|
||||
assert all(response.status_code == 403 for response in responses)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bearer_stream_skips_intent_classifier_and_tool_preprocessing(monkeypatch):
|
||||
from routes import chat_helpers, chat_routes
|
||||
from tests.test_foreground_model_routing import _chat_stream_endpoint
|
||||
|
||||
calls = []
|
||||
captured = {}
|
||||
endpoint = _chat_stream_endpoint(
|
||||
monkeypatch,
|
||||
"chat",
|
||||
captured,
|
||||
capture_completion=True,
|
||||
capture_context=True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
chat_routes,
|
||||
"_classify_tool_intent",
|
||||
lambda message: calls.append(message) or pytest.fail("bearer intent classifier ran"),
|
||||
)
|
||||
|
||||
class _EmptyDb:
|
||||
def query(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def filter(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return []
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(chat_helpers, "SessionLocal", _EmptyDb)
|
||||
request = SimpleNamespace(
|
||||
headers={"authorization": "Bearer ody_test"},
|
||||
app=SimpleNamespace(state=SimpleNamespace(auth_manager=None)),
|
||||
state=SimpleNamespace(
|
||||
api_token=True,
|
||||
api_token_owner="alice",
|
||||
api_token_scopes=["chat"],
|
||||
current_user="api",
|
||||
),
|
||||
_form={
|
||||
"message": "create a todo and use tools",
|
||||
"session": "session-1",
|
||||
"mode": "chat",
|
||||
},
|
||||
)
|
||||
|
||||
async def form():
|
||||
return request._form
|
||||
|
||||
request.form = form
|
||||
response = await endpoint(request)
|
||||
async for _ in response.body_iterator:
|
||||
pass
|
||||
|
||||
assert calls == []
|
||||
assert "chat" in captured
|
||||
assert captured["build_context"]["allow_tool_preprocessing"] is False
|
||||
@@ -400,6 +400,10 @@ def test_explicit_bearer_model_does_not_require_live_setup_probe(monkeypatch):
|
||||
is_enabled=True,
|
||||
base_url="https://api.example.test/v1",
|
||||
api_key=None,
|
||||
endpoint_kind="api",
|
||||
cached_models=json.dumps(["provider-model"]),
|
||||
pinned_models=json.dumps(["explicit-model"]),
|
||||
hidden_models=None,
|
||||
)
|
||||
monkeypatch.setattr(sr, "SessionLocal", lambda: _EndpointDb(endpoint))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user