mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-03 11:42:23 +01:00
fix(security): close bearer side-effect boundaries
This commit is contained in:
+35
-7
@@ -19,6 +19,7 @@ from src.model_context import estimate_tokens, get_context_length
|
|||||||
from src.auth_helpers import (
|
from src.auth_helpers import (
|
||||||
RequestCapability,
|
RequestCapability,
|
||||||
effective_user,
|
effective_user,
|
||||||
|
is_bearer_principal,
|
||||||
request_capability as build_request_capability,
|
request_capability as build_request_capability,
|
||||||
)
|
)
|
||||||
from src.tool_approval_scopes import CHAT_SESSION_APPROVAL_CONTEXT_MARKER
|
from src.tool_approval_scopes import CHAT_SESSION_APPROVAL_CONTEXT_MARKER
|
||||||
@@ -204,6 +205,11 @@ def _allowed_models_from_privileges(privs: dict) -> Optional[frozenset[str]]:
|
|||||||
def _allowed_models_for_request(request) -> Optional[frozenset[str]]:
|
def _allowed_models_for_request(request) -> Optional[frozenset[str]]:
|
||||||
"""Return the caller's model allowlist, or ``None`` when unrestricted."""
|
"""Return the caller's model allowlist, or ``None`` when unrestricted."""
|
||||||
|
|
||||||
|
# ``effective_user`` is an attribution/storage identity for bearers, not a
|
||||||
|
# browser privilege principal. In particular, an admin-owned token must
|
||||||
|
# not inherit the owner's ADMIN_PRIVILEGES map through this lookup.
|
||||||
|
if is_bearer_principal(request):
|
||||||
|
return None
|
||||||
try:
|
try:
|
||||||
user = effective_user(request)
|
user = effective_user(request)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -226,6 +232,12 @@ def _enforce_chat_privileges(request, sess) -> None:
|
|||||||
(single-user mode). Admins receive ADMIN_PRIVILEGES from get_privileges,
|
(single-user mode). Admins receive ADMIN_PRIVILEGES from get_privileges,
|
||||||
which means unrestricted allowed_models / zero cap -> no-op for them.
|
which means unrestricted allowed_models / zero cap -> no-op for them.
|
||||||
"""
|
"""
|
||||||
|
# Bearer authority is defined by the token scope at the route boundary.
|
||||||
|
# Do not turn its owner attribution back into a browser privilege lookup;
|
||||||
|
# that would make an admin-owned token inherit the admin model/cap policy.
|
||||||
|
if is_bearer_principal(request):
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user = effective_user(request)
|
user = effective_user(request)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -817,11 +829,16 @@ async def build_chat_context(
|
|||||||
|
|
||||||
# Normalize model ID. Prefer cached endpoint models so group chat does not
|
# Normalize model ID. Prefer cached endpoint models so group chat does not
|
||||||
# re-hit slow local /models endpoints on every participant turn.
|
# re-hit slow local /models endpoints on every participant turn.
|
||||||
norm = _normalize_model_id_from_cache(sess) or normalize_model_id(
|
norm = _normalize_model_id_from_cache(sess)
|
||||||
sess.endpoint_url,
|
# Model normalization falls back to a live /models or /tags request on a
|
||||||
sess.model,
|
# cache miss. A bearer chat request may use the stored model as-is, but it
|
||||||
owner=getattr(sess, "owner", None),
|
# must not implicitly refresh an endpoint catalogue while building context.
|
||||||
)
|
if norm is None and capability.allow_live_probes:
|
||||||
|
norm = normalize_model_id(
|
||||||
|
sess.endpoint_url,
|
||||||
|
sess.model,
|
||||||
|
owner=getattr(sess, "owner", None),
|
||||||
|
)
|
||||||
if norm:
|
if norm:
|
||||||
sess.model = norm
|
sess.model = norm
|
||||||
|
|
||||||
@@ -860,11 +877,22 @@ async def build_chat_context(
|
|||||||
# session history before we know which route can answer and would make a
|
# session history before we know which route can answer and would make a
|
||||||
# later larger-context candidate unable to recover discarded history.
|
# later larger-context candidate unable to recover discarded history.
|
||||||
if defer_context_shaping:
|
if defer_context_shaping:
|
||||||
context_length = get_context_length(sess.endpoint_url, sess.model)
|
context_kwargs = {}
|
||||||
|
if not capability.allow_live_probes:
|
||||||
|
context_kwargs["allow_live_probes"] = False
|
||||||
|
context_length = get_context_length(sess.endpoint_url, sess.model, **context_kwargs)
|
||||||
was_compacted = False
|
was_compacted = False
|
||||||
else:
|
else:
|
||||||
|
compact_kwargs = {"owner": user}
|
||||||
|
if not capability.allow_live_probes:
|
||||||
|
compact_kwargs["allow_live_probes"] = False
|
||||||
messages, context_length, was_compacted = await maybe_compact(
|
messages, context_length, was_compacted = await maybe_compact(
|
||||||
sess, sess.endpoint_url, sess.model, messages, sess.headers, owner=user,
|
sess,
|
||||||
|
sess.endpoint_url,
|
||||||
|
sess.model,
|
||||||
|
messages,
|
||||||
|
sess.headers,
|
||||||
|
**compact_kwargs,
|
||||||
)
|
)
|
||||||
_before_trim_messages = len(messages)
|
_before_trim_messages = len(messages)
|
||||||
_before_trim_tokens = estimate_tokens(messages)
|
_before_trim_tokens = estimate_tokens(messages)
|
||||||
|
|||||||
+45
-22
@@ -167,6 +167,7 @@ def _chat_candidate_request_factory(
|
|||||||
*,
|
*,
|
||||||
session=None,
|
session=None,
|
||||||
owner: Optional[str] = None,
|
owner: Optional[str] = None,
|
||||||
|
allow_live_probes: bool = True,
|
||||||
):
|
):
|
||||||
"""Shape one route-neutral Chat prompt for each candidate window."""
|
"""Shape one route-neutral Chat prompt for each candidate window."""
|
||||||
|
|
||||||
@@ -180,15 +181,20 @@ def _chat_candidate_request_factory(
|
|||||||
|
|
||||||
async def factory(index, candidate_url, candidate_model, candidate_headers):
|
async def factory(index, candidate_url, candidate_model, candidate_headers):
|
||||||
compaction_state = {}
|
compaction_state = {}
|
||||||
|
compact_kwargs = {
|
||||||
|
"owner": owner,
|
||||||
|
"persist": False,
|
||||||
|
"compaction_state": compaction_state,
|
||||||
|
}
|
||||||
|
if not allow_live_probes:
|
||||||
|
compact_kwargs["allow_live_probes"] = False
|
||||||
candidate_messages, context_length, was_compacted = await maybe_compact(
|
candidate_messages, context_length, was_compacted = await maybe_compact(
|
||||||
session,
|
session,
|
||||||
candidate_url,
|
candidate_url,
|
||||||
candidate_model,
|
candidate_model,
|
||||||
list(messages),
|
list(messages),
|
||||||
candidate_headers,
|
candidate_headers,
|
||||||
owner=owner,
|
**compact_kwargs,
|
||||||
persist=False,
|
|
||||||
compaction_state=compaction_state,
|
|
||||||
)
|
)
|
||||||
if not context_length:
|
if not context_length:
|
||||||
context_length = fallback_context_length
|
context_length = fallback_context_length
|
||||||
@@ -878,17 +884,23 @@ def setup_chat_routes(
|
|||||||
selected_context_length,
|
selected_context_length,
|
||||||
session=sess,
|
session=sess,
|
||||||
owner=owner,
|
owner=owner,
|
||||||
|
allow_live_probes=request_capability.allow_live_probes,
|
||||||
)
|
)
|
||||||
requested_model = sess.model
|
requested_model = sess.model
|
||||||
|
llm_kwargs = {
|
||||||
|
"fallback_statuses": foreground_policy.eligible_statuses,
|
||||||
|
"candidate_request_factory": candidate_request_factory,
|
||||||
|
"temperature": ctx.preset.temperature,
|
||||||
|
"max_tokens": ctx.preset.max_tokens,
|
||||||
|
"prompt_type": preset_id,
|
||||||
|
"session_id": session,
|
||||||
|
}
|
||||||
|
if not request_capability.allow_live_probes:
|
||||||
|
llm_kwargs["allow_live_probes"] = False
|
||||||
reply, actual_candidate, actual_model = await llm_call_async_with_route_fallback(
|
reply, actual_candidate, actual_model = await llm_call_async_with_route_fallback(
|
||||||
foreground_candidates,
|
foreground_candidates,
|
||||||
request_messages,
|
request_messages,
|
||||||
fallback_statuses=foreground_policy.eligible_statuses,
|
**llm_kwargs,
|
||||||
candidate_request_factory=candidate_request_factory,
|
|
||||||
temperature=ctx.preset.temperature,
|
|
||||||
max_tokens=ctx.preset.max_tokens,
|
|
||||||
prompt_type=preset_id,
|
|
||||||
session_id=session,
|
|
||||||
)
|
)
|
||||||
actual_index = _candidate_index(foreground_candidates, actual_candidate)
|
actual_index = _candidate_index(foreground_candidates, actual_candidate)
|
||||||
apply_compaction_state(
|
apply_compaction_state(
|
||||||
@@ -1605,7 +1617,12 @@ def setup_chat_routes(
|
|||||||
# Enforce per-user privileges
|
# Enforce per-user privileges
|
||||||
_privs = {}
|
_privs = {}
|
||||||
_user = ctx.user
|
_user = ctx.user
|
||||||
if _user and hasattr(request.app.state, 'auth_manager') and request.app.state.auth_manager:
|
if (
|
||||||
|
not api_token_request
|
||||||
|
and _user
|
||||||
|
and hasattr(request.app.state, 'auth_manager')
|
||||||
|
and request.app.state.auth_manager
|
||||||
|
):
|
||||||
_privs = request.app.state.auth_manager.get_privileges(_user)
|
_privs = request.app.state.auth_manager.get_privileges(_user)
|
||||||
if _privs:
|
if _privs:
|
||||||
if not _privs.get("can_use_bash", True):
|
if not _privs.get("can_use_bash", True):
|
||||||
@@ -1897,6 +1914,7 @@ def setup_chat_routes(
|
|||||||
_selected_context_length,
|
_selected_context_length,
|
||||||
session=sess,
|
session=sess,
|
||||||
owner=_user,
|
owner=_user,
|
||||||
|
allow_live_probes=request_capability.allow_live_probes,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Send model name early so the frontend can show it during streaming
|
# Send model name early so the frontend can show it during streaming
|
||||||
@@ -2030,23 +2048,28 @@ def setup_chat_routes(
|
|||||||
|
|
||||||
# ── Chat mode: call stream_llm directly, NO tools, NO document access ──
|
# ── Chat mode: call stream_llm directly, NO tools, NO document access ──
|
||||||
try:
|
try:
|
||||||
async for chunk in stream_llm_with_fallback(
|
stream_kwargs = {
|
||||||
_foreground_candidates,
|
"temperature": ctx.preset.temperature,
|
||||||
messages,
|
|
||||||
temperature=ctx.preset.temperature,
|
|
||||||
# Respect the preset; 0/unset = let the server decide (no
|
# Respect the preset; 0/unset = let the server decide (no
|
||||||
# cap), matching agent mode. The old hard 4096 fallback
|
# cap), matching agent mode. The old hard 4096 fallback
|
||||||
# truncated reasoning models mid-<think> — they'd burn the
|
# truncated reasoning models mid-<think> — they'd burn the
|
||||||
# whole budget thinking and never emit the answer (seen in
|
# whole budget thinking and never emit the answer (seen in
|
||||||
# Compare on heavy generation prompts).
|
# Compare on heavy generation prompts).
|
||||||
max_tokens=ctx.preset.max_tokens,
|
"max_tokens": ctx.preset.max_tokens,
|
||||||
prompt_type=preset_id,
|
"prompt_type": preset_id,
|
||||||
tools=None,
|
"tools": None,
|
||||||
session_id=session,
|
"session_id": session,
|
||||||
fallback_statuses=_foreground_policy.eligible_statuses,
|
"fallback_statuses": _foreground_policy.eligible_statuses,
|
||||||
fallback_on_empty=_foreground_policy.fallback_on_empty,
|
"fallback_on_empty": _foreground_policy.fallback_on_empty,
|
||||||
candidate_request_factory=_chat_request_factory,
|
"candidate_request_factory": _chat_request_factory,
|
||||||
candidate_route_descriptors=_foreground_route_descriptors,
|
"candidate_route_descriptors": _foreground_route_descriptors,
|
||||||
|
}
|
||||||
|
if not request_capability.allow_live_probes:
|
||||||
|
stream_kwargs["allow_live_probes"] = False
|
||||||
|
async for chunk in stream_llm_with_fallback(
|
||||||
|
_foreground_candidates,
|
||||||
|
messages,
|
||||||
|
**stream_kwargs,
|
||||||
):
|
):
|
||||||
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
|
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
|
||||||
try:
|
try:
|
||||||
|
|||||||
+10
-1
@@ -34,7 +34,7 @@ from fastapi import Query, HTTPException, Request
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
||||||
from src.auth_helpers import _auth_disabled, get_current_user
|
from src.auth_helpers import _auth_disabled, get_current_user, is_bearer_principal
|
||||||
from src.secret_storage import decrypt as _decrypt
|
from src.secret_storage import decrypt as _decrypt
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -420,6 +420,15 @@ def _require_auth(request: Request) -> str:
|
|||||||
unconfigured mode are only honoured if they're coming from
|
unconfigured mode are only honoured if they're coming from
|
||||||
localhost; everyone else gets 401.
|
localhost; everyone else gets 401.
|
||||||
"""
|
"""
|
||||||
|
# The legacy email router uses one generic dependency for mailbox reads,
|
||||||
|
# drafts, AI helpers, and SMTP send. It has no per-route token-scope
|
||||||
|
# contract, so a bearer must not be allowed to enter it as the ``api``
|
||||||
|
# pseudo-user. Otherwise owner-scoped lookup can miss the token owner and
|
||||||
|
# fall through to process-wide legacy settings credentials below.
|
||||||
|
# Scope-aware integrations must use their dedicated route boundary.
|
||||||
|
if is_bearer_principal(request):
|
||||||
|
raise HTTPException(403, "API tokens must use a scope-aware email route")
|
||||||
|
|
||||||
u = get_current_user(request)
|
u = get_current_user(request)
|
||||||
if u:
|
if u:
|
||||||
return u
|
return u
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ from fastapi import APIRouter, Depends, Request, HTTPException
|
|||||||
|
|
||||||
from core.models import ChatMessage
|
from core.models import ChatMessage
|
||||||
from core.database import SessionLocal, ChatMessage as DbChatMessage, Session as DbSession
|
from core.database import SessionLocal, ChatMessage as DbChatMessage, Session as DbSession
|
||||||
from src.auth_helpers import effective_user, require_chat_scope
|
from src.auth_helpers import effective_user, is_bearer_principal, require_chat_scope
|
||||||
from src.message_metadata import sanitize_client_message_metadata
|
from src.message_metadata import (
|
||||||
|
sanitize_client_message_metadata,
|
||||||
|
sanitize_projected_message_metadata,
|
||||||
|
)
|
||||||
from src.topic_analyzer import analyze_topics
|
from src.topic_analyzer import analyze_topics
|
||||||
from src.upload_handler import reserve_message_upload_references
|
from src.upload_handler import reserve_message_upload_references
|
||||||
from routes.session_routes import (
|
from routes.session_routes import (
|
||||||
@@ -142,11 +145,19 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
f"Referenced upload is no longer available: {missing_id}",
|
f"Referenced upload is no longer available: {missing_id}",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _db_history_entry(m: DbChatMessage) -> Dict[str, Any]:
|
def _display_metadata(value: Any, *, sanitize: bool) -> dict:
|
||||||
|
meta = _metadata_dict(value)
|
||||||
|
if sanitize:
|
||||||
|
return sanitize_projected_message_metadata(meta) or {}
|
||||||
|
return dict(meta)
|
||||||
|
|
||||||
|
def _db_history_entry(
|
||||||
|
m: DbChatMessage,
|
||||||
|
*,
|
||||||
|
sanitize: bool = False,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
entry = {"role": m.role, "content": _history_display_content(m.content)}
|
entry = {"role": m.role, "content": _history_display_content(m.content)}
|
||||||
meta = _metadata_dict(m.meta_data)
|
meta = _display_metadata(m.meta_data, sanitize=sanitize)
|
||||||
if meta:
|
|
||||||
meta = dict(meta)
|
|
||||||
if m.timestamp and "timestamp" not in meta:
|
if m.timestamp and "timestamp" not in meta:
|
||||||
meta["timestamp"] = m.timestamp.isoformat() + "Z"
|
meta["timestamp"] = m.timestamp.isoformat() + "Z"
|
||||||
if meta:
|
if meta:
|
||||||
@@ -161,6 +172,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
offset: Optional[int] = None,
|
offset: Optional[int] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
require_chat_scope(request)
|
require_chat_scope(request)
|
||||||
|
sanitize_history = is_bearer_principal(request)
|
||||||
_verify_session_owner(request, session_id)
|
_verify_session_owner(request, session_id)
|
||||||
if limit is not None:
|
if limit is not None:
|
||||||
page_limit = max(1, min(int(limit), 100))
|
page_limit = max(1, min(int(limit), 100))
|
||||||
@@ -188,7 +200,11 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
history_dict = [
|
history_dict = [
|
||||||
entry for entry in (_db_history_entry(m) for m in rows)
|
entry
|
||||||
|
for entry in (
|
||||||
|
_db_history_entry(m, sanitize=sanitize_history)
|
||||||
|
for m in rows
|
||||||
|
)
|
||||||
if not (entry.get("metadata") or {}).get("hidden")
|
if not (entry.get("metadata") or {}).get("hidden")
|
||||||
]
|
]
|
||||||
return {
|
return {
|
||||||
@@ -214,7 +230,10 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
for msg in session.history:
|
for msg in session.history:
|
||||||
if isinstance(msg, ChatMessage):
|
if isinstance(msg, ChatMessage):
|
||||||
# Skip hidden messages (e.g. compaction summaries for AI context)
|
# Skip hidden messages (e.g. compaction summaries for AI context)
|
||||||
msg_meta = _metadata_dict(msg.metadata)
|
msg_meta = _display_metadata(
|
||||||
|
msg.metadata,
|
||||||
|
sanitize=sanitize_history,
|
||||||
|
)
|
||||||
if msg_meta.get("hidden"):
|
if msg_meta.get("hidden"):
|
||||||
continue
|
continue
|
||||||
entry = {"role": msg.role, "content": _history_display_content(msg.content)}
|
entry = {"role": msg.role, "content": _history_display_content(msg.content)}
|
||||||
@@ -222,7 +241,10 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
entry["metadata"] = msg_meta
|
entry["metadata"] = msg_meta
|
||||||
history_dict.append(entry)
|
history_dict.append(entry)
|
||||||
elif isinstance(msg, dict):
|
elif isinstance(msg, dict):
|
||||||
msg_meta = _metadata_dict(msg.get("metadata"))
|
msg_meta = _display_metadata(
|
||||||
|
msg.get("metadata"),
|
||||||
|
sanitize=sanitize_history,
|
||||||
|
)
|
||||||
if msg_meta.get("hidden"):
|
if msg_meta.get("hidden"):
|
||||||
continue
|
continue
|
||||||
entry = {
|
entry = {
|
||||||
@@ -248,7 +270,11 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
)
|
)
|
||||||
# Response excludes hidden messages, matching the in-memory path.
|
# Response excludes hidden messages, matching the in-memory path.
|
||||||
history_dict = [
|
history_dict = [
|
||||||
entry for entry in (_db_history_entry(m) for m in db_messages)
|
entry
|
||||||
|
for entry in (
|
||||||
|
_db_history_entry(m, sanitize=sanitize_history)
|
||||||
|
for m in db_messages
|
||||||
|
)
|
||||||
if not (entry.get("metadata") or {}).get("hidden")
|
if not (entry.get("metadata") or {}).get("hidden")
|
||||||
]
|
]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -653,12 +679,15 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
|||||||
# in-memory messages, corrupting their _db_id and breaking
|
# in-memory messages, corrupting their _db_id and breaking
|
||||||
# edit/delete-by-id on the original conversation.
|
# edit/delete-by-id on the original conversation.
|
||||||
meta = dict(msg.metadata) if isinstance(msg.metadata, dict) else None
|
meta = dict(msg.metadata) if isinstance(msg.metadata, dict) else None
|
||||||
|
if is_bearer_principal(request):
|
||||||
|
meta = sanitize_projected_message_metadata(meta)
|
||||||
new_session.add_message(ChatMessage(msg.role, msg.content, meta))
|
new_session.add_message(ChatMessage(msg.role, msg.content, meta))
|
||||||
try:
|
if not is_bearer_principal(request):
|
||||||
from src.event_bus import fire_event
|
try:
|
||||||
fire_event("session_created", getattr(source, 'owner', None))
|
from src.event_bus import fire_event
|
||||||
except Exception:
|
fire_event("session_created", getattr(source, 'owner', None))
|
||||||
logger.debug("session_created event dispatch failed", exc_info=True)
|
except Exception:
|
||||||
|
logger.debug("session_created event dispatch failed", exc_info=True)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
|
|||||||
+25
-12
@@ -11,7 +11,13 @@ from core.session_manager import SessionManager
|
|||||||
from core.models import ChatMessage
|
from core.models import ChatMessage
|
||||||
from src.request_models import SessionResponse
|
from src.request_models import SessionResponse
|
||||||
from core.database import Session as DbSession, SessionLocal, Document, GalleryImage, utcnow_naive
|
from core.database import Session as DbSession, SessionLocal, Document, GalleryImage, utcnow_naive
|
||||||
from src.auth_helpers import effective_user, _auth_disabled, owner_filter, require_chat_scope
|
from src.auth_helpers import (
|
||||||
|
effective_user,
|
||||||
|
_auth_disabled,
|
||||||
|
is_bearer_principal,
|
||||||
|
owner_filter,
|
||||||
|
require_chat_scope,
|
||||||
|
)
|
||||||
from src.message_metadata import sanitize_client_message_metadata
|
from src.message_metadata import sanitize_client_message_metadata
|
||||||
from src.session_image_cleanup import _generated_image_path_for_cleanup, session_image_refs
|
from src.session_image_cleanup import _generated_image_path_for_cleanup, session_image_refs
|
||||||
from src.session_actions import is_session_recently_active
|
from src.session_actions import is_session_recently_active
|
||||||
@@ -158,7 +164,10 @@ def _reject_raw_endpoint_url_for_non_admin(
|
|||||||
# Raw URLs make the server dial whatever host the request supplies. For
|
# Raw URLs make the server dial whatever host the request supplies. For
|
||||||
# non-admin users, require a saved endpoint row so normal owner scoping and
|
# non-admin users, require a saved endpoint row so normal owner scoping and
|
||||||
# endpoint validation have already happened.
|
# endpoint validation have already happened.
|
||||||
if user and not _current_user_is_admin(request, user):
|
# A bearer may be attributed to an admin owner for storage and endpoint
|
||||||
|
# visibility, but it is still not an interactive admin principal. Raw
|
||||||
|
# endpoint URLs therefore remain unavailable to every bearer request.
|
||||||
|
if is_bearer_principal(request) or (user and not _current_user_is_admin(request, user)):
|
||||||
raise HTTPException(403, "Choose a registered model endpoint")
|
raise HTTPException(403, "Choose a registered model endpoint")
|
||||||
|
|
||||||
|
|
||||||
@@ -450,14 +459,17 @@ def setup_session_routes(
|
|||||||
from src.endpoint_resolver import build_headers
|
from src.endpoint_resolver import build_headers
|
||||||
session.headers = build_headers(resolved_key, resolved_base)
|
session.headers = build_headers(resolved_key, resolved_base)
|
||||||
_persist_session_headers(sid, session.headers)
|
_persist_session_headers(sid, session.headers)
|
||||||
# Fire webhook (sync-safe)
|
# A bearer can create owner-attributed chat data, but must not cause
|
||||||
if webhook_manager:
|
# owner lifecycle automation or webhook delivery as a side effect.
|
||||||
webhook_manager.fire_and_forget("session.created", {
|
if not is_bearer_principal(request):
|
||||||
"session_id": sid, "name": session.name, "model": model_to_use,
|
# Fire webhook (sync-safe)
|
||||||
})
|
if webhook_manager:
|
||||||
# Fire event for automation tasks
|
webhook_manager.fire_and_forget("session.created", {
|
||||||
from src.event_bus import fire_event
|
"session_id": sid, "name": session.name, "model": model_to_use,
|
||||||
fire_event("session_created", user)
|
})
|
||||||
|
# Fire event for automation tasks
|
||||||
|
from src.event_bus import fire_event
|
||||||
|
fire_event("session_created", user)
|
||||||
return SessionResponse(
|
return SessionResponse(
|
||||||
id=sid,
|
id=sid,
|
||||||
name=session.name,
|
name=session.name,
|
||||||
@@ -943,8 +955,9 @@ def setup_session_routes(
|
|||||||
)
|
)
|
||||||
session.headers = {"Authorization": f"Bearer {OPENAI_API_KEY}"}
|
session.headers = {"Authorization": f"Bearer {OPENAI_API_KEY}"}
|
||||||
session_manager.save_sessions()
|
session_manager.save_sessions()
|
||||||
from src.event_bus import fire_event
|
if not is_bearer_principal(request):
|
||||||
fire_event("session_created", user)
|
from src.event_bus import fire_event
|
||||||
|
fire_event("session_created", user)
|
||||||
return {"id": sid, "name": "", "model": model}
|
return {"id": sid, "name": "", "model": model}
|
||||||
|
|
||||||
@router.post("/session/{session_id}/important")
|
@router.post("/session/{session_id}/important")
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ from core.database import (
|
|||||||
Note,
|
Note,
|
||||||
Session as DbSession,
|
Session as DbSession,
|
||||||
)
|
)
|
||||||
from src.auth_helpers import effective_user, require_chat_scope
|
from src.auth_helpers import effective_user, require_chat_scope, require_non_bearer_request
|
||||||
from src.attachment_refs import attachment_refs_from_metadata
|
from src.attachment_refs import attachment_refs_from_metadata
|
||||||
from src.constants import GENERATED_IMAGES_DIR
|
from src.constants import GENERATED_IMAGES_DIR
|
||||||
from src.upload_handler import (
|
from src.upload_handler import (
|
||||||
@@ -462,6 +462,7 @@ def setup_upload_routes(upload_handler):
|
|||||||
Cached under UPLOAD_DIR/.vision/{file_id}.txt — first call computes,
|
Cached under UPLOAD_DIR/.vision/{file_id}.txt — first call computes,
|
||||||
subsequent loads are instant. Pass force=1 to recompute."""
|
subsequent loads are instant. Pass force=1 to recompute."""
|
||||||
require_chat_scope(request)
|
require_chat_scope(request)
|
||||||
|
require_non_bearer_request(request)
|
||||||
if not upload_handler.validate_upload_id(file_id):
|
if not upload_handler.validate_upload_id(file_id):
|
||||||
raise HTTPException(400, "Invalid file ID")
|
raise HTTPException(400, "Invalid file ID")
|
||||||
info = _load_upload_info(file_id)
|
info = _load_upload_info(file_id)
|
||||||
@@ -507,6 +508,7 @@ def setup_upload_routes(upload_handler):
|
|||||||
"""Persist a user-edited vision/OCR text for an attachment. Stored in
|
"""Persist a user-edited vision/OCR text for an attachment. Stored in
|
||||||
the same cache file so the chat send picks it up as the override."""
|
the same cache file so the chat send picks it up as the override."""
|
||||||
require_chat_scope(request)
|
require_chat_scope(request)
|
||||||
|
require_non_bearer_request(request)
|
||||||
if not upload_handler.validate_upload_id(file_id):
|
if not upload_handler.validate_upload_id(file_id):
|
||||||
raise HTTPException(400, "Invalid file ID")
|
raise HTTPException(400, "Invalid file ID")
|
||||||
info = _load_upload_info(file_id)
|
info = _load_upload_info(file_id)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from fastapi import APIRouter, HTTPException, Request, Form
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from core.database import SessionLocal, Webhook, ModelEndpoint
|
from core.database import SessionLocal, Webhook, ModelEndpoint
|
||||||
from src.auth_helpers import owner_filter, require_chat_scope
|
from src.auth_helpers import is_bearer_principal, owner_filter, require_chat_scope
|
||||||
from src.url_security import validate_public_http_url
|
from src.url_security import validate_public_http_url
|
||||||
from src.webhook_manager import WebhookManager, validate_webhook_url, validate_events
|
from src.webhook_manager import WebhookManager, validate_webhook_url, validate_events
|
||||||
|
|
||||||
@@ -385,10 +385,14 @@ def setup_webhook_routes(
|
|||||||
sess.add_message(ChatMessage("assistant", reply))
|
sess.add_message(ChatMessage("assistant", reply))
|
||||||
session_manager.save_sessions()
|
session_manager.save_sessions()
|
||||||
|
|
||||||
webhook_manager.fire_and_forget("chat.completed", {
|
# /api/v1/chat remains a synchronous bearer integration: the response
|
||||||
"session_id": session_id, "model": sess.model,
|
# is returned normally, but the token must not fan that content out to
|
||||||
"user_message": message[:2000], "response": reply[:2000],
|
# an owner-configured asynchronous callback after authorization ends.
|
||||||
})
|
if not is_bearer_principal(request):
|
||||||
|
webhook_manager.fire_and_forget("chat.completed", {
|
||||||
|
"session_id": session_id, "model": sess.model,
|
||||||
|
"user_message": message[:2000], "response": reply[:2000],
|
||||||
|
})
|
||||||
|
|
||||||
return {"response": reply, "session_id": session_id, "model": sess.model}
|
return {"response": reply, "session_id": session_id, "model": sess.model}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class RequestCapability:
|
|||||||
allow_detached_execution: bool
|
allow_detached_execution: bool
|
||||||
allow_message_events: bool
|
allow_message_events: bool
|
||||||
allow_auto_naming: bool
|
allow_auto_naming: bool
|
||||||
|
allow_live_probes: bool
|
||||||
|
|
||||||
|
|
||||||
def is_bearer_principal(request: Request) -> bool:
|
def is_bearer_principal(request: Request) -> bool:
|
||||||
@@ -101,6 +102,7 @@ def request_capability(request: Request) -> RequestCapability:
|
|||||||
allow_detached_execution=not bearer,
|
allow_detached_execution=not bearer,
|
||||||
allow_message_events=not bearer,
|
allow_message_events=not bearer,
|
||||||
allow_auto_naming=not bearer,
|
allow_auto_naming=not bearer,
|
||||||
|
allow_live_probes=not bearer,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -330,12 +330,16 @@ async def maybe_compact(
|
|||||||
*,
|
*,
|
||||||
persist: bool = True,
|
persist: bool = True,
|
||||||
compaction_state: Optional[Dict[str, Any]] = None,
|
compaction_state: Optional[Dict[str, Any]] = None,
|
||||||
|
allow_live_probes: bool = True,
|
||||||
) -> tuple:
|
) -> tuple:
|
||||||
"""Check context usage and compact if above threshold.
|
"""Check context usage and compact if above threshold.
|
||||||
|
|
||||||
Returns (messages, context_length, was_compacted).
|
Returns (messages, context_length, was_compacted).
|
||||||
"""
|
"""
|
||||||
context_length = get_context_length(endpoint_url, model)
|
context_kwargs = {}
|
||||||
|
if not allow_live_probes:
|
||||||
|
context_kwargs["allow_live_probes"] = False
|
||||||
|
context_length = get_context_length(endpoint_url, model, **context_kwargs)
|
||||||
used = estimate_tokens(messages)
|
used = estimate_tokens(messages)
|
||||||
pct = (used / context_length) * 100 if context_length else 0
|
pct = (used / context_length) * 100 if context_length else 0
|
||||||
|
|
||||||
@@ -392,6 +396,9 @@ async def maybe_compact(
|
|||||||
]
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
summary_kwargs = {}
|
||||||
|
if not allow_live_probes:
|
||||||
|
summary_kwargs["allow_live_probes"] = False
|
||||||
summary = await llm_call_async(
|
summary = await llm_call_async(
|
||||||
compact_url,
|
compact_url,
|
||||||
compact_model,
|
compact_model,
|
||||||
@@ -400,6 +407,7 @@ async def maybe_compact(
|
|||||||
max_tokens=SUMMARY_MAX_TOKENS,
|
max_tokens=SUMMARY_MAX_TOKENS,
|
||||||
headers=compact_headers,
|
headers=compact_headers,
|
||||||
timeout=30,
|
timeout=30,
|
||||||
|
**summary_kwargs,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Compaction summary failed: {e}")
|
logger.error(f"Compaction summary failed: {e}")
|
||||||
|
|||||||
+39
-8
@@ -1903,6 +1903,7 @@ def list_model_ids(
|
|||||||
*,
|
*,
|
||||||
owner: Optional[str] = None,
|
owner: Optional[str] = None,
|
||||||
endpoint_id: Optional[str] = None,
|
endpoint_id: Optional[str] = None,
|
||||||
|
allow_live_probes: bool = True,
|
||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
"""List available model IDs from an endpoint."""
|
"""List available model IDs from an endpoint."""
|
||||||
cached = _configured_cached_model_ids(base_chat_url, owner=owner, endpoint_id=endpoint_id)
|
cached = _configured_cached_model_ids(base_chat_url, owner=owner, endpoint_id=endpoint_id)
|
||||||
@@ -1911,6 +1912,8 @@ def list_model_ids(
|
|||||||
provider = _detect_provider(base_chat_url)
|
provider = _detect_provider(base_chat_url)
|
||||||
if provider == "anthropic":
|
if provider == "anthropic":
|
||||||
return list(ANTHROPIC_MODELS)
|
return list(ANTHROPIC_MODELS)
|
||||||
|
if not allow_live_probes:
|
||||||
|
return []
|
||||||
try:
|
try:
|
||||||
h = {}
|
h = {}
|
||||||
if headers:
|
if headers:
|
||||||
@@ -1952,9 +1955,16 @@ def normalize_model_id(
|
|||||||
*,
|
*,
|
||||||
owner: Optional[str] = None,
|
owner: Optional[str] = None,
|
||||||
endpoint_id: Optional[str] = None,
|
endpoint_id: Optional[str] = None,
|
||||||
|
allow_live_probes: bool = True,
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""Normalize a model ID to match available models."""
|
"""Normalize a model ID to match available models."""
|
||||||
avail = list_model_ids(endpoint_url, timeout, owner=owner, endpoint_id=endpoint_id)
|
avail = list_model_ids(
|
||||||
|
endpoint_url,
|
||||||
|
timeout,
|
||||||
|
owner=owner,
|
||||||
|
endpoint_id=endpoint_id,
|
||||||
|
allow_live_probes=allow_live_probes,
|
||||||
|
)
|
||||||
if not avail:
|
if not avail:
|
||||||
return None
|
return None
|
||||||
if requested in avail:
|
if requested in avail:
|
||||||
@@ -1968,7 +1978,8 @@ def normalize_model_id(
|
|||||||
|
|
||||||
def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LLMConfig.DEFAULT_TEMPERATURE,
|
def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LLMConfig.DEFAULT_TEMPERATURE,
|
||||||
max_tokens: int = LLMConfig.DEFAULT_MAX_TOKENS, headers: Optional[Dict] = None,
|
max_tokens: int = LLMConfig.DEFAULT_MAX_TOKENS, headers: Optional[Dict] = None,
|
||||||
timeout: int = LLMConfig.DEFAULT_TIMEOUT, prompt_type: Optional[str] = None) -> str:
|
timeout: int = LLMConfig.DEFAULT_TIMEOUT, prompt_type: Optional[str] = None,
|
||||||
|
allow_live_probes: bool = True) -> str:
|
||||||
"""Synchronous LLM call with optional prompt type enhancement."""
|
"""Synchronous LLM call with optional prompt type enhancement."""
|
||||||
h = _provider_headers(_detect_provider(url))
|
h = _provider_headers(_detect_provider(url))
|
||||||
# Tolerate headers that arrive as a JSON string (some sessions stored them
|
# Tolerate headers that arrive as a JSON string (some sessions stored them
|
||||||
@@ -2012,9 +2023,12 @@ def llm_call(url: str, model: str, messages: List[Dict], temperature: float = LL
|
|||||||
payload = _build_anthropic_payload(model, messages_copy, temperature, max_tokens)
|
payload = _build_anthropic_payload(model, messages_copy, temperature, max_tokens)
|
||||||
elif provider == "ollama":
|
elif provider == "ollama":
|
||||||
target_url = _normalize_ollama_url(url)
|
target_url = _normalize_ollama_url(url)
|
||||||
|
context_kwargs = {}
|
||||||
|
if not allow_live_probes:
|
||||||
|
context_kwargs["allow_live_probes"] = False
|
||||||
payload = _build_ollama_payload(
|
payload = _build_ollama_payload(
|
||||||
model, messages_copy, temperature, max_tokens,
|
model, messages_copy, temperature, max_tokens,
|
||||||
stream=False, num_ctx=get_context_length(url, model),
|
stream=False, num_ctx=get_context_length(url, model, **context_kwargs),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
target_url = _normalize_openai_chat_url(url)
|
target_url = _normalize_openai_chat_url(url)
|
||||||
@@ -2273,6 +2287,7 @@ async def llm_call_async(
|
|||||||
workload: str = "foreground",
|
workload: str = "foreground",
|
||||||
availability_only_transport: bool = False,
|
availability_only_transport: bool = False,
|
||||||
return_model_metadata: bool = False,
|
return_model_metadata: bool = False,
|
||||||
|
allow_live_probes: bool = True,
|
||||||
) -> str | tuple[str, str]:
|
) -> str | tuple[str, str]:
|
||||||
"""Asynchronous LLM call using httpx with connection pooling, timeout, retry logic, and performance logging."""
|
"""Asynchronous LLM call using httpx with connection pooling, timeout, retry logic, and performance logging."""
|
||||||
provider = _detect_provider(url)
|
provider = _detect_provider(url)
|
||||||
@@ -2307,6 +2322,9 @@ async def llm_call_async(
|
|||||||
# Reuse stream_llm's validated Codex SSE path and collect deltas.
|
# Reuse stream_llm's validated Codex SSE path and collect deltas.
|
||||||
parts: List[str] = []
|
parts: List[str] = []
|
||||||
actual_model = model
|
actual_model = model
|
||||||
|
stream_kwargs = {"workload": workload}
|
||||||
|
if not allow_live_probes:
|
||||||
|
stream_kwargs["allow_live_probes"] = False
|
||||||
async for chunk in stream_llm(
|
async for chunk in stream_llm(
|
||||||
url,
|
url,
|
||||||
model,
|
model,
|
||||||
@@ -2315,7 +2333,7 @@ async def llm_call_async(
|
|||||||
max_tokens=max_tokens,
|
max_tokens=max_tokens,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
workload=workload,
|
**stream_kwargs,
|
||||||
):
|
):
|
||||||
event_is_error = False
|
event_is_error = False
|
||||||
for line in str(chunk).splitlines():
|
for line in str(chunk).splitlines():
|
||||||
@@ -2372,9 +2390,12 @@ async def llm_call_async(
|
|||||||
h = {"Content-Type": "application/json"}
|
h = {"Content-Type": "application/json"}
|
||||||
if headers:
|
if headers:
|
||||||
h.update(headers)
|
h.update(headers)
|
||||||
|
context_kwargs = {}
|
||||||
|
if not allow_live_probes:
|
||||||
|
context_kwargs["allow_live_probes"] = False
|
||||||
payload = _build_ollama_payload(
|
payload = _build_ollama_payload(
|
||||||
model, messages_copy, temperature, max_tokens,
|
model, messages_copy, temperature, max_tokens,
|
||||||
stream=False, num_ctx=get_context_length(url, model),
|
stream=False, num_ctx=get_context_length(url, model, **context_kwargs),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
target_url = _normalize_openai_chat_url(url)
|
target_url = _normalize_openai_chat_url(url)
|
||||||
@@ -2560,9 +2581,13 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
|||||||
max_tokens: int = LLMConfig.DEFAULT_MAX_TOKENS, headers: Optional[Dict] = None,
|
max_tokens: int = LLMConfig.DEFAULT_MAX_TOKENS, headers: Optional[Dict] = None,
|
||||||
timeout: int = LLMConfig.STREAM_TIMEOUT, prompt_type: Optional[str] = None,
|
timeout: int = LLMConfig.STREAM_TIMEOUT, prompt_type: Optional[str] = None,
|
||||||
tools: Optional[List[Dict]] = None, session_id: Optional[str] = None,
|
tools: Optional[List[Dict]] = None, session_id: Optional[str] = None,
|
||||||
tool_choice_none: bool = False, workload: str = "foreground"):
|
tool_choice_none: bool = False, workload: str = "foreground",
|
||||||
|
allow_live_probes: bool = True):
|
||||||
target_url = _stream_target_url(url)
|
target_url = _stream_target_url(url)
|
||||||
async with _local_model_slot(target_url, model, workload):
|
async with _local_model_slot(target_url, model, workload):
|
||||||
|
inner_kwargs = {}
|
||||||
|
if not allow_live_probes:
|
||||||
|
inner_kwargs["allow_live_probes"] = False
|
||||||
async for chunk in _stream_llm_inner(
|
async for chunk in _stream_llm_inner(
|
||||||
url,
|
url,
|
||||||
model,
|
model,
|
||||||
@@ -2575,6 +2600,7 @@ async def stream_llm(url: str, model: str, messages: List[Dict], temperature: fl
|
|||||||
tools=tools,
|
tools=tools,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
tool_choice_none=tool_choice_none,
|
tool_choice_none=tool_choice_none,
|
||||||
|
**inner_kwargs,
|
||||||
):
|
):
|
||||||
yield chunk
|
yield chunk
|
||||||
|
|
||||||
@@ -2583,7 +2609,7 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat
|
|||||||
max_tokens: int = LLMConfig.DEFAULT_MAX_TOKENS, headers: Optional[Dict] = None,
|
max_tokens: int = LLMConfig.DEFAULT_MAX_TOKENS, headers: Optional[Dict] = None,
|
||||||
timeout: int = LLMConfig.STREAM_TIMEOUT, prompt_type: Optional[str] = None,
|
timeout: int = LLMConfig.STREAM_TIMEOUT, prompt_type: Optional[str] = None,
|
||||||
tools: Optional[List[Dict]] = None, session_id: Optional[str] = None,
|
tools: Optional[List[Dict]] = None, session_id: Optional[str] = None,
|
||||||
tool_choice_none: bool = False):
|
tool_choice_none: bool = False, allow_live_probes: bool = True):
|
||||||
"""Stream LLM responses with improved error handling.
|
"""Stream LLM responses with improved error handling.
|
||||||
|
|
||||||
Yields SSE chunks:
|
Yields SSE chunks:
|
||||||
@@ -2618,9 +2644,14 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat
|
|||||||
h = {"Content-Type": "application/json"}
|
h = {"Content-Type": "application/json"}
|
||||||
if headers:
|
if headers:
|
||||||
h.update(headers)
|
h.update(headers)
|
||||||
|
context_kwargs = {}
|
||||||
|
if not allow_live_probes:
|
||||||
|
context_kwargs["allow_live_probes"] = False
|
||||||
payload = _build_ollama_payload(
|
payload = _build_ollama_payload(
|
||||||
model, messages_copy, temperature, max_tokens,
|
model, messages_copy, temperature, max_tokens,
|
||||||
stream=True, tools=tools, num_ctx=get_context_length(url, model),
|
stream=True,
|
||||||
|
tools=tools,
|
||||||
|
num_ctx=get_context_length(url, model, **context_kwargs),
|
||||||
)
|
)
|
||||||
elif provider == "chatgpt-subscription":
|
elif provider == "chatgpt-subscription":
|
||||||
target_url = _normalize_chatgpt_subscription_url(url)
|
target_url = _normalize_chatgpt_subscription_url(url)
|
||||||
|
|||||||
+39
-6
@@ -238,16 +238,31 @@ KNOWN_CONTEXT_WINDOWS = {
|
|||||||
_context_cache: Dict[Tuple[str, str], Tuple[int, bool]] = {}
|
_context_cache: Dict[Tuple[str, str], Tuple[int, bool]] = {}
|
||||||
|
|
||||||
|
|
||||||
def _get_context_length_cached(endpoint_url: str, model: str) -> Tuple[int, bool]:
|
def _get_context_length_cached(
|
||||||
|
endpoint_url: str,
|
||||||
|
model: str,
|
||||||
|
*,
|
||||||
|
allow_live_probes: bool = True,
|
||||||
|
) -> Tuple[int, bool]:
|
||||||
"""Return (context_length, known). ``known`` is False only when the value is a
|
"""Return (context_length, known). ``known`` is False only when the value is a
|
||||||
bare DEFAULT_CONTEXT fallback (no endpoint report and not in the known table)."""
|
bare DEFAULT_CONTEXT fallback (no endpoint report and not in the known table)."""
|
||||||
|
cache_key = (endpoint_url, model)
|
||||||
|
if not allow_live_probes:
|
||||||
|
# A bearer may consume metadata already learned by an interactive or
|
||||||
|
# explicitly privileged refresh, but a context build must not cause a
|
||||||
|
# new /slots, /models, or catalog request or populate those caches.
|
||||||
|
cached = _context_cache.get(cache_key)
|
||||||
|
if cached:
|
||||||
|
return cached
|
||||||
|
known = _lookup_known(model)
|
||||||
|
return (known, True) if known else (DEFAULT_CONTEXT, False)
|
||||||
|
|
||||||
configured_kind = _configured_endpoint_kind(endpoint_url)
|
configured_kind = _configured_endpoint_kind(endpoint_url)
|
||||||
is_local = is_local_endpoint(endpoint_url)
|
is_local = is_local_endpoint(endpoint_url)
|
||||||
# Key on (endpoint_url, model): the same model id can be served by two
|
# Key on (endpoint_url, model): the same model id can be served by two
|
||||||
# different remote endpoints with different real context windows (e.g. a
|
# different remote endpoints with different real context windows (e.g. a
|
||||||
# capped proxy vs. the full provider), so caching by model id alone would
|
# capped proxy vs. the full provider), so caching by model id alone would
|
||||||
# serve one endpoint's window for the other (issue #2603).
|
# serve one endpoint's window for the other (issue #2603).
|
||||||
cache_key = (endpoint_url, model)
|
|
||||||
if not is_local and cache_key in _context_cache:
|
if not is_local and cache_key in _context_cache:
|
||||||
return _context_cache[cache_key]
|
return _context_cache[cache_key]
|
||||||
|
|
||||||
@@ -261,23 +276,41 @@ def _get_context_length_cached(endpoint_url: str, model: str) -> Tuple[int, bool
|
|||||||
return ctx, known
|
return ctx, known
|
||||||
|
|
||||||
|
|
||||||
def get_context_length(endpoint_url: str, model: str) -> int:
|
def get_context_length(
|
||||||
|
endpoint_url: str,
|
||||||
|
model: str,
|
||||||
|
*,
|
||||||
|
allow_live_probes: bool = True,
|
||||||
|
) -> int:
|
||||||
"""Get the context window size for a model.
|
"""Get the context window size for a model.
|
||||||
|
|
||||||
Queries /v1/models on the endpoint and looks for context_length
|
Queries /v1/models on the endpoint and looks for context_length
|
||||||
or context_window fields. Caches result per (endpoint, model).
|
or context_window fields. Caches result per (endpoint, model).
|
||||||
Falls back to DEFAULT_CONTEXT if unavailable.
|
Falls back to DEFAULT_CONTEXT if unavailable.
|
||||||
"""
|
"""
|
||||||
return _get_context_length_cached(endpoint_url, model)[0]
|
return _get_context_length_cached(
|
||||||
|
endpoint_url,
|
||||||
|
model,
|
||||||
|
allow_live_probes=allow_live_probes,
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
|
||||||
def get_context_length_known(endpoint_url: str, model: str) -> Tuple[int, bool]:
|
def get_context_length_known(
|
||||||
|
endpoint_url: str,
|
||||||
|
model: str,
|
||||||
|
*,
|
||||||
|
allow_live_probes: bool = True,
|
||||||
|
) -> Tuple[int, bool]:
|
||||||
"""Like ``get_context_length`` but also returns whether the window was actually
|
"""Like ``get_context_length`` but also returns whether the window was actually
|
||||||
discovered (endpoint-reported or in the known-models table) rather than the bare
|
discovered (endpoint-reported or in the known-models table) rather than the bare
|
||||||
DEFAULT_CONTEXT fallback. Callers that *scale* a budget off the window must not
|
DEFAULT_CONTEXT fallback. Callers that *scale* a budget off the window must not
|
||||||
trust an unknown value — a fallback 128K isn't proof the model holds 128K
|
trust an unknown value — a fallback 128K isn't proof the model holds 128K
|
||||||
(review on #4122)."""
|
(review on #4122)."""
|
||||||
return _get_context_length_cached(endpoint_url, model)
|
return _get_context_length_cached(
|
||||||
|
endpoint_url,
|
||||||
|
model,
|
||||||
|
allow_live_probes=allow_live_probes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def budget_context_for_model(endpoint_url: str, model: str, *, fallback: int = 0) -> int:
|
def budget_context_for_model(endpoint_url: str, model: str, *, fallback: int = 0) -> int:
|
||||||
|
|||||||
@@ -0,0 +1,558 @@
|
|||||||
|
"""Cycle-3 regressions for the bearer capability boundary.
|
||||||
|
|
||||||
|
The tests intentionally call route endpoints directly as well as exercising
|
||||||
|
the shared helpers. FastAPI dependency execution is not a substitute for the
|
||||||
|
handler's own authorization checks when an endpoint can be called by another
|
||||||
|
in-process route or test harness.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
|
||||||
|
def _request(*, bearer=True, owner="alice", scopes=("chat",), current_user="api", auth_manager=None):
|
||||||
|
return SimpleNamespace(
|
||||||
|
state=SimpleNamespace(
|
||||||
|
api_token=bearer,
|
||||||
|
api_token_owner=owner if bearer else None,
|
||||||
|
api_token_scopes=list(scopes),
|
||||||
|
current_user=current_user if bearer else current_user,
|
||||||
|
),
|
||||||
|
app=SimpleNamespace(state=SimpleNamespace(auth_manager=auth_manager)),
|
||||||
|
headers={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _JsonRequest:
|
||||||
|
def __init__(self, body=None, *, bearer=True, owner="alice", scopes=("chat",)):
|
||||||
|
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 = {}
|
||||||
|
self.body = body or {}
|
||||||
|
|
||||||
|
async def json(self):
|
||||||
|
return self.body
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_endpoint(router, path, method):
|
||||||
|
for route in reversed(router.routes):
|
||||||
|
if route.path == path and method in (route.methods or set()):
|
||||||
|
return route.endpoint
|
||||||
|
raise AssertionError(f"route not found: {method} {path}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_owned_bearer_does_not_inherit_raw_endpoint_or_model_privileges():
|
||||||
|
from routes.chat_helpers import _allowed_models_for_request, _enforce_chat_privileges
|
||||||
|
from routes.session_routes import _reject_raw_endpoint_url_for_non_admin
|
||||||
|
|
||||||
|
auth_manager = MagicMock()
|
||||||
|
auth_manager.is_admin.return_value = True
|
||||||
|
auth_manager.get_privileges.side_effect = AssertionError("bearer used owner privilege lookup")
|
||||||
|
request = _request(owner="admin", auth_manager=auth_manager)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
_reject_raw_endpoint_url_for_non_admin(
|
||||||
|
request,
|
||||||
|
"admin",
|
||||||
|
endpoint_id="",
|
||||||
|
endpoint_url="http://127.0.0.1:9/private",
|
||||||
|
)
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
assert _allowed_models_for_request(request) is None
|
||||||
|
_enforce_chat_privileges(request, SimpleNamespace(model="admin-only"))
|
||||||
|
auth_manager.get_privileges.assert_not_called()
|
||||||
|
|
||||||
|
cookie_request = _request(
|
||||||
|
bearer=False,
|
||||||
|
owner=None,
|
||||||
|
current_user="admin",
|
||||||
|
auth_manager=auth_manager,
|
||||||
|
)
|
||||||
|
auth_manager.get_privileges.side_effect = None
|
||||||
|
auth_manager.get_privileges.return_value = {
|
||||||
|
"allowed_models": ["admin-only"],
|
||||||
|
"allowed_models_restricted": True,
|
||||||
|
}
|
||||||
|
_reject_raw_endpoint_url_for_non_admin(
|
||||||
|
cookie_request,
|
||||||
|
"admin",
|
||||||
|
endpoint_id="",
|
||||||
|
endpoint_url="http://127.0.0.1:9/private",
|
||||||
|
)
|
||||||
|
assert _allowed_models_for_request(cookie_request) == frozenset({"admin-only"})
|
||||||
|
|
||||||
|
|
||||||
|
class _SessionManager:
|
||||||
|
def __init__(self):
|
||||||
|
self.sessions = {}
|
||||||
|
self.saved = 0
|
||||||
|
|
||||||
|
def create_session(self, **kwargs):
|
||||||
|
session = SimpleNamespace(
|
||||||
|
id=kwargs["session_id"],
|
||||||
|
name=kwargs.get("name", ""),
|
||||||
|
endpoint_url=kwargs.get("endpoint_url", ""),
|
||||||
|
model=kwargs.get("model", ""),
|
||||||
|
rag=kwargs.get("rag", False),
|
||||||
|
owner=kwargs.get("owner"),
|
||||||
|
headers={},
|
||||||
|
history=[],
|
||||||
|
)
|
||||||
|
self.sessions[session.id] = session
|
||||||
|
return session
|
||||||
|
|
||||||
|
def save_sessions(self):
|
||||||
|
self.saved += 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_bearer_session_lifecycle_routes_do_not_emit_webhook_or_event(monkeypatch):
|
||||||
|
import src.event_bus as event_bus
|
||||||
|
from routes import session_routes
|
||||||
|
|
||||||
|
events = []
|
||||||
|
monkeypatch.setattr(event_bus, "fire_event", lambda *args, **kwargs: events.append((args, kwargs)))
|
||||||
|
manager = _SessionManager()
|
||||||
|
webhook_manager = MagicMock()
|
||||||
|
router = session_routes.setup_session_routes(
|
||||||
|
manager,
|
||||||
|
{
|
||||||
|
"REQUEST_TIMEOUT": 1,
|
||||||
|
"OPENAI_API_KEY": "server-key",
|
||||||
|
"SESSIONS_FILE": "sessions.json",
|
||||||
|
},
|
||||||
|
webhook_manager=webhook_manager,
|
||||||
|
)
|
||||||
|
request = _request()
|
||||||
|
|
||||||
|
create = _latest_endpoint(router, "/api/session", "POST")
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
create(
|
||||||
|
request,
|
||||||
|
name="Private endpoint",
|
||||||
|
endpoint_url="http://127.0.0.1:9/private",
|
||||||
|
model="stored-model",
|
||||||
|
rag="false",
|
||||||
|
skip_validation="true",
|
||||||
|
api_key="",
|
||||||
|
endpoint_id="",
|
||||||
|
)
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
assert manager.sessions == {}
|
||||||
|
|
||||||
|
result = create(
|
||||||
|
request,
|
||||||
|
name="API chat",
|
||||||
|
endpoint_url="",
|
||||||
|
model="stored-model",
|
||||||
|
rag="false",
|
||||||
|
skip_validation="true",
|
||||||
|
api_key="",
|
||||||
|
endpoint_id="",
|
||||||
|
)
|
||||||
|
assert result.model == "stored-model"
|
||||||
|
|
||||||
|
create_openai = _latest_endpoint(router, "/api/session/openai", "POST")
|
||||||
|
openai_result = create_openai(request, name="OpenAI", model="gpt-4o", rag="false")
|
||||||
|
assert openai_result["model"] == "gpt-4o"
|
||||||
|
webhook_manager.fire_and_forget.assert_not_called()
|
||||||
|
assert events == []
|
||||||
|
|
||||||
|
cookie_request = _request(bearer=False, owner=None, current_user="alice", scopes=())
|
||||||
|
cookie_result = create(
|
||||||
|
cookie_request,
|
||||||
|
name="Browser chat",
|
||||||
|
endpoint_url="",
|
||||||
|
model="stored-model",
|
||||||
|
rag="false",
|
||||||
|
skip_validation="true",
|
||||||
|
api_key="",
|
||||||
|
endpoint_id="",
|
||||||
|
)
|
||||||
|
assert cookie_result.model == "stored-model"
|
||||||
|
assert events and events[-1][0] == ("session_created", "alice")
|
||||||
|
|
||||||
|
|
||||||
|
def test_generic_email_dependency_rejects_bearer_before_legacy_fallback(monkeypatch):
|
||||||
|
from routes import email_helpers
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
email_helpers._require_auth(_request(owner="alice", scopes=("chat",)))
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
|
||||||
|
assert email_helpers._require_auth(
|
||||||
|
_request(bearer=False, owner=None, current_user="alice", scopes=())
|
||||||
|
) == "alice"
|
||||||
|
monkeypatch.setattr(email_helpers, "_auth_disabled", lambda: True)
|
||||||
|
assert email_helpers._require_auth(
|
||||||
|
_request(bearer=False, owner=None, current_user=None, scopes=())
|
||||||
|
) == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bearer_context_builder_uses_no_live_model_or_context_probes(monkeypatch):
|
||||||
|
from routes import chat_helpers
|
||||||
|
from src.auth_helpers import request_capability
|
||||||
|
|
||||||
|
calls = {"normalize": 0, "compact": []}
|
||||||
|
|
||||||
|
class _ChatHandler:
|
||||||
|
def validate_and_extract_preset(self, _preset_id):
|
||||||
|
return 0.2, 64, None, None
|
||||||
|
|
||||||
|
async def preprocess_message(self, message, att_ids, sess, **kwargs):
|
||||||
|
assert kwargs["allow_tool_preprocessing"] is False
|
||||||
|
return message, message, message, [], []
|
||||||
|
|
||||||
|
class _ChatProcessor:
|
||||||
|
def build_context_preface(self, **kwargs):
|
||||||
|
return [], [], []
|
||||||
|
|
||||||
|
def fail_normalize(*args, **kwargs):
|
||||||
|
calls["normalize"] += 1
|
||||||
|
raise AssertionError("bearer context performed a live model probe")
|
||||||
|
|
||||||
|
async def fake_compact(*args, **kwargs):
|
||||||
|
calls["compact"].append(kwargs)
|
||||||
|
return args[3], 128000, False
|
||||||
|
|
||||||
|
monkeypatch.setattr(chat_helpers, "_normalize_model_id_from_cache", lambda _sess: None)
|
||||||
|
monkeypatch.setattr(chat_helpers, "normalize_model_id", fail_normalize)
|
||||||
|
monkeypatch.setattr(chat_helpers, "maybe_compact", fake_compact)
|
||||||
|
monkeypatch.setattr(chat_helpers, "load_prefs_for_user", lambda _owner: {})
|
||||||
|
|
||||||
|
session = SimpleNamespace(
|
||||||
|
endpoint_url="http://127.0.0.1:9999/v1/chat/completions",
|
||||||
|
model="uncached-model",
|
||||||
|
headers={},
|
||||||
|
owner="alice",
|
||||||
|
history=[],
|
||||||
|
get_context_messages=lambda: [],
|
||||||
|
add_message=lambda _message: None,
|
||||||
|
)
|
||||||
|
request = _request()
|
||||||
|
context = await chat_helpers.build_chat_context(
|
||||||
|
session,
|
||||||
|
request,
|
||||||
|
_ChatHandler(),
|
||||||
|
_ChatProcessor(),
|
||||||
|
message="hello",
|
||||||
|
session_id="session-1",
|
||||||
|
incognito=True,
|
||||||
|
allow_tool_preprocessing=False,
|
||||||
|
persist_user_message=False,
|
||||||
|
capability=request_capability(request),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert context.context_length == 128000
|
||||||
|
assert calls["normalize"] == 0
|
||||||
|
assert calls["compact"] == [{"owner": "alice", "allow_live_probes": False}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_and_context_no_live_probe_options_do_not_touch_endpoints(monkeypatch):
|
||||||
|
from src import llm_core, model_context
|
||||||
|
|
||||||
|
monkeypatch.setattr(llm_core, "_configured_cached_model_ids", lambda *args, **kwargs: [])
|
||||||
|
monkeypatch.setattr(
|
||||||
|
llm_core,
|
||||||
|
"httpx_get_kimi_aware",
|
||||||
|
lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("model endpoint touched")),
|
||||||
|
)
|
||||||
|
assert llm_core.list_model_ids(
|
||||||
|
"http://127.0.0.1:9999/v1",
|
||||||
|
allow_live_probes=False,
|
||||||
|
) == []
|
||||||
|
assert llm_core.normalize_model_id(
|
||||||
|
"http://127.0.0.1:9999/v1",
|
||||||
|
"uncached-model",
|
||||||
|
allow_live_probes=False,
|
||||||
|
) is None
|
||||||
|
|
||||||
|
monkeypatch.setattr(model_context, "_context_cache", {})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
model_context,
|
||||||
|
"httpx",
|
||||||
|
SimpleNamespace(
|
||||||
|
get=lambda *args, **kwargs: (_ for _ in ()).throw(
|
||||||
|
AssertionError("context endpoint touched")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert model_context.get_context_length(
|
||||||
|
"http://127.0.0.1:9999/v1",
|
||||||
|
"uncached-model",
|
||||||
|
allow_live_probes=False,
|
||||||
|
) == model_context.DEFAULT_CONTEXT
|
||||||
|
assert model_context.get_context_length(
|
||||||
|
"http://127.0.0.1:9999/v1",
|
||||||
|
"gpt-4o",
|
||||||
|
allow_live_probes=False,
|
||||||
|
) == 128000
|
||||||
|
assert model_context._context_cache == {}
|
||||||
|
|
||||||
|
|
||||||
|
def _forged_metadata():
|
||||||
|
return {
|
||||||
|
"safe": {"label": "retain"},
|
||||||
|
"_tool_approval_chat_session_granted": True,
|
||||||
|
"approval_id": "approval-1",
|
||||||
|
"approved_by_interactive_session": True,
|
||||||
|
"resolved": "approve",
|
||||||
|
"session_id": "session-1",
|
||||||
|
"tool_events": [
|
||||||
|
{
|
||||||
|
"kind": "tool_approval",
|
||||||
|
"approval_id": "nested-1",
|
||||||
|
"resolved": "approve",
|
||||||
|
"ask_user": {
|
||||||
|
"approval_id": "nested-2",
|
||||||
|
"approved_by_interactive_session": True,
|
||||||
|
"label": "display-only",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bearer_history_and_fork_scrub_legacy_approval_metadata(monkeypatch):
|
||||||
|
import src.event_bus as event_bus
|
||||||
|
from core.models import ChatMessage
|
||||||
|
from routes.history import history_routes
|
||||||
|
|
||||||
|
display_source = SimpleNamespace(
|
||||||
|
id="source",
|
||||||
|
name="Source",
|
||||||
|
owner="alice",
|
||||||
|
endpoint_url="https://example.test/v1",
|
||||||
|
model="model",
|
||||||
|
history=[
|
||||||
|
ChatMessage("user", "hello", _forged_metadata()),
|
||||||
|
{"role": "assistant", "content": "answer", "metadata": _forged_metadata()},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
fork_source = SimpleNamespace(
|
||||||
|
id="source",
|
||||||
|
name="Source",
|
||||||
|
owner="alice",
|
||||||
|
endpoint_url="https://example.test/v1",
|
||||||
|
model="model",
|
||||||
|
history=[ChatMessage("user", "hello", _forged_metadata())],
|
||||||
|
)
|
||||||
|
|
||||||
|
class _Forked:
|
||||||
|
def __init__(self):
|
||||||
|
self.history = []
|
||||||
|
|
||||||
|
def add_message(self, message):
|
||||||
|
self.history.append(message)
|
||||||
|
|
||||||
|
class _Manager:
|
||||||
|
def __init__(self, source):
|
||||||
|
self.source = source
|
||||||
|
self.created = None
|
||||||
|
|
||||||
|
def get_session(self, _session_id):
|
||||||
|
return self.source
|
||||||
|
|
||||||
|
def create_session(self, **kwargs):
|
||||||
|
self.created = _Forked()
|
||||||
|
return self.created
|
||||||
|
|
||||||
|
monkeypatch.setattr(history_routes, "_verify_session_owner", lambda *args, **kwargs: None)
|
||||||
|
events = []
|
||||||
|
monkeypatch.setattr(event_bus, "fire_event", lambda *args, **kwargs: events.append(args))
|
||||||
|
display_manager = _Manager(display_source)
|
||||||
|
display_router = history_routes.setup_history_routes(display_manager)
|
||||||
|
history_endpoint = _latest_endpoint(display_router, "/api/history/{session_id}", "GET")
|
||||||
|
request = _JsonRequest({"keep_count": 1})
|
||||||
|
|
||||||
|
displayed = await history_endpoint(request, "source")
|
||||||
|
assert len(displayed["history"]) == 2
|
||||||
|
for entry in displayed["history"]:
|
||||||
|
metadata = entry.get("metadata", {})
|
||||||
|
assert metadata.get("safe") == {"label": "retain"}
|
||||||
|
assert all(
|
||||||
|
field not in metadata
|
||||||
|
for field in (
|
||||||
|
"_tool_approval_chat_session_granted",
|
||||||
|
"approval_id",
|
||||||
|
"approved_by_interactive_session",
|
||||||
|
"resolved",
|
||||||
|
"session_id",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert metadata["tool_events"][0]["ask_user"] == {"label": "display-only"}
|
||||||
|
|
||||||
|
fork_manager = _Manager(fork_source)
|
||||||
|
fork_router = history_routes.setup_history_routes(fork_manager)
|
||||||
|
fork_endpoint = _latest_endpoint(fork_router, "/api/session/{session_id}/fork", "POST")
|
||||||
|
forked = await fork_endpoint(request, "source")
|
||||||
|
assert forked["status"] == "ok"
|
||||||
|
assert events == []
|
||||||
|
assert fork_manager.created.history[0].metadata["safe"] == {"label": "retain"}
|
||||||
|
assert "approval_id" not in fork_manager.created.history[0].metadata
|
||||||
|
|
||||||
|
|
||||||
|
class _ColumnQuery:
|
||||||
|
def __init__(self, result, *, count=0, rows=None):
|
||||||
|
self.result = result
|
||||||
|
self.count_value = count
|
||||||
|
self.rows = rows or []
|
||||||
|
|
||||||
|
def filter(self, *args):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def order_by(self, *args):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def offset(self, value):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def limit(self, value):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def first(self):
|
||||||
|
return self.result
|
||||||
|
|
||||||
|
def count(self):
|
||||||
|
return self.count_value
|
||||||
|
|
||||||
|
def all(self):
|
||||||
|
return self.rows
|
||||||
|
|
||||||
|
|
||||||
|
class _HistoryDb:
|
||||||
|
def __init__(self, history_routes, session_row, message_rows):
|
||||||
|
self.history_routes = history_routes
|
||||||
|
self.session_row = session_row
|
||||||
|
self.message_rows = message_rows
|
||||||
|
|
||||||
|
def query(self, model):
|
||||||
|
if model is self.history_routes.DbSession:
|
||||||
|
return _ColumnQuery(self.session_row)
|
||||||
|
return _ColumnQuery(None, count=len(self.message_rows), rows=self.message_rows)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bearer_paginated_history_scrubs_db_projection(monkeypatch):
|
||||||
|
from routes.history import history_routes
|
||||||
|
db_session = SimpleNamespace(model="model", endpoint_url="https://example.test/v1", name="Chat")
|
||||||
|
db_message = SimpleNamespace(
|
||||||
|
role="assistant",
|
||||||
|
content="answer",
|
||||||
|
meta_data=json.dumps(_forged_metadata()),
|
||||||
|
timestamp=datetime(2026, 1, 1, 0, 0, 0),
|
||||||
|
)
|
||||||
|
db = _HistoryDb(history_routes, db_session, [db_message])
|
||||||
|
monkeypatch.setattr(history_routes, "SessionLocal", lambda: db)
|
||||||
|
monkeypatch.setattr(history_routes, "_verify_session_owner", lambda *args, **kwargs: None)
|
||||||
|
router = history_routes.setup_history_routes(SimpleNamespace())
|
||||||
|
endpoint = _latest_endpoint(router, "/api/history/{session_id}", "GET")
|
||||||
|
|
||||||
|
payload = await endpoint(_JsonRequest(), "source", limit=10, offset=0)
|
||||||
|
metadata = payload["history"][0]["metadata"]
|
||||||
|
assert metadata["safe"] == {"label": "retain"}
|
||||||
|
assert "approval_id" not in metadata
|
||||||
|
assert "approved_by_interactive_session" not in metadata
|
||||||
|
assert metadata["tool_events"][0]["ask_user"] == {"label": "display-only"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sync_bearer_chat_keeps_response_but_suppresses_completion_webhook(monkeypatch):
|
||||||
|
from routes.webhook import webhook_routes
|
||||||
|
from src import llm_core
|
||||||
|
|
||||||
|
class _Session:
|
||||||
|
def __init__(self, kwargs):
|
||||||
|
self.endpoint_url = kwargs["endpoint_url"]
|
||||||
|
self.model = kwargs["model"]
|
||||||
|
self.owner = kwargs["owner"]
|
||||||
|
self.headers = {}
|
||||||
|
self.history = []
|
||||||
|
|
||||||
|
def add_message(self, message):
|
||||||
|
self.history.append(message)
|
||||||
|
|
||||||
|
class _Manager:
|
||||||
|
def __init__(self):
|
||||||
|
self.created = []
|
||||||
|
|
||||||
|
def create_session(self, **kwargs):
|
||||||
|
session = _Session(kwargs)
|
||||||
|
self.created.append(session)
|
||||||
|
return session
|
||||||
|
|
||||||
|
def save_sessions(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
class _Webhooks:
|
||||||
|
def __init__(self):
|
||||||
|
self.events = []
|
||||||
|
|
||||||
|
def fire_and_forget(self, event, payload):
|
||||||
|
self.events.append((event, payload))
|
||||||
|
|
||||||
|
async def fake_llm(*args, **kwargs):
|
||||||
|
return "answer"
|
||||||
|
|
||||||
|
monkeypatch.setattr(webhook_routes, "validate_public_http_url", lambda value: value)
|
||||||
|
monkeypatch.setattr(llm_core, "llm_call_async", fake_llm)
|
||||||
|
webhook_manager = _Webhooks()
|
||||||
|
router = webhook_routes.setup_webhook_routes(
|
||||||
|
webhook_manager,
|
||||||
|
auth_manager=None,
|
||||||
|
session_manager=_Manager(),
|
||||||
|
)
|
||||||
|
endpoint = _latest_endpoint(router, "/api/v1/chat", "POST")
|
||||||
|
body = SimpleNamespace(
|
||||||
|
message="hello",
|
||||||
|
model="gpt-4o",
|
||||||
|
session=None,
|
||||||
|
api_key="test-key",
|
||||||
|
base_url="https://api.example.com/v1",
|
||||||
|
provider=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await endpoint(_request(), body)
|
||||||
|
assert result["response"] == "answer"
|
||||||
|
assert webhook_manager.events == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_upload_vision_handlers_reject_bearer_before_ai_or_cache_work():
|
||||||
|
from routes import upload_routes
|
||||||
|
|
||||||
|
upload_handler = MagicMock()
|
||||||
|
router, _cleanup = upload_routes.setup_upload_routes(upload_handler)
|
||||||
|
get_vision = _latest_endpoint(router, "/api/upload/{file_id}/vision", "GET")
|
||||||
|
put_vision = _latest_endpoint(router, "/api/upload/{file_id}/vision", "PUT")
|
||||||
|
request = _request()
|
||||||
|
|
||||||
|
for endpoint, kwargs in ((get_vision, {"force": 0}), (put_vision, {})):
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
result = endpoint(request, "upload-1", **kwargs)
|
||||||
|
if hasattr(result, "__await__"):
|
||||||
|
await result
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
upload_handler.validate_upload_id.assert_not_called()
|
||||||
|
|
||||||
|
cookie_request = _request(bearer=False, owner=None, current_user="alice", scopes=())
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
result = get_vision(cookie_request, "upload-1", force=0)
|
||||||
|
if hasattr(result, "__await__"):
|
||||||
|
await result
|
||||||
|
assert exc.value.status_code == 404
|
||||||
|
upload_handler.validate_upload_id.assert_called_once_with("upload-1")
|
||||||
@@ -9,7 +9,9 @@ def test_chat_context_uses_cached_models_before_live_model_probe():
|
|||||||
|
|
||||||
assert "def _normalize_model_id_from_cache" in source
|
assert "def _normalize_model_id_from_cache" in source
|
||||||
assert "cached_models" in source
|
assert "cached_models" in source
|
||||||
assert "norm = _normalize_model_id_from_cache(sess) or normalize_model_id" in source
|
assert "norm = _normalize_model_id_from_cache(sess)" in source
|
||||||
|
assert "capability.allow_live_probes" in source
|
||||||
|
assert "normalize_model_id(" in source
|
||||||
|
|
||||||
|
|
||||||
def test_cached_model_match_keeps_basename_normalization():
|
def test_cached_model_match_keeps_basename_normalization():
|
||||||
|
|||||||
@@ -500,8 +500,9 @@ async def _build_context_owner_probe(monkeypatch, request_state):
|
|||||||
captured["preface_owner"] = kwargs["owner"]
|
captured["preface_owner"] = kwargs["owner"]
|
||||||
return [], [], []
|
return [], [], []
|
||||||
|
|
||||||
async def fake_maybe_compact(sess, endpoint_url, model, messages, headers, owner=None):
|
async def fake_maybe_compact(sess, endpoint_url, model, messages, headers, owner=None, **kwargs):
|
||||||
captured["compact_owner"] = owner
|
captured["compact_owner"] = owner
|
||||||
|
captured["allow_live_probes"] = kwargs.get("allow_live_probes", True)
|
||||||
return messages, 8192, False
|
return messages, 8192, False
|
||||||
|
|
||||||
monkeypatch.setattr(chat_helpers, "preprocess", fake_preprocess)
|
monkeypatch.setattr(chat_helpers, "preprocess", fake_preprocess)
|
||||||
@@ -557,10 +558,12 @@ async def test_build_chat_context_uses_api_token_owner_for_compaction_scope(monk
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert ctx.user == "alice"
|
assert ctx.user == "alice"
|
||||||
|
assert captured["allow_live_probes"] is False
|
||||||
assert captured == {
|
assert captured == {
|
||||||
"prefs_owner": "alice",
|
"prefs_owner": "alice",
|
||||||
"preface_owner": "alice",
|
"preface_owner": "alice",
|
||||||
"compact_owner": "alice",
|
"compact_owner": "alice",
|
||||||
|
"allow_live_probes": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -579,4 +582,5 @@ async def test_build_chat_context_keeps_cookie_user_owner_scope(monkeypatch):
|
|||||||
"prefs_owner": "bob",
|
"prefs_owner": "bob",
|
||||||
"preface_owner": "bob",
|
"preface_owner": "bob",
|
||||||
"compact_owner": "bob",
|
"compact_owner": "bob",
|
||||||
|
"allow_live_probes": True,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user