fix(security): close bearer alias boundaries

This commit is contained in:
RaresKeY
2026-08-30 17:52:35 +00:00
parent 0b32ecea50
commit a09b0b5722
13 changed files with 1200 additions and 107 deletions
+44
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+67 -15
View File
@@ -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()
+92 -38
View File
@@ -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
View File
@@ -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:
+9 -1
View File
@@ -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
+40 -9
View File
@@ -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")
+11 -1
View File
@@ -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
View File
@@ -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
+54 -16
View File
@@ -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]
+658
View File
@@ -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
+4
View File
@@ -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))