mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-22 14:52:24 +02:00
Compare commits
22
Commits
5f58f9a45f
...
68eeb7841c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68eeb7841c | ||
|
|
4dc11cfe6b | ||
|
|
a2e691da2b | ||
|
|
eee2167502 | ||
|
|
09fe308720 | ||
|
|
666babfd58 | ||
|
|
1c43daa564 | ||
|
|
3d8c364689 | ||
|
|
0f7ea7a936 | ||
|
|
bd4067cf83 | ||
|
|
49c14af5c7 | ||
|
|
e340674c12 | ||
|
|
ceb62385f1 | ||
|
|
a38df08a31 | ||
|
|
37e1d401cf | ||
|
|
03dbf976a5 | ||
|
|
5043b2924c | ||
|
|
be8f1fac85 | ||
|
|
eac354629a | ||
|
|
2efebcc278 | ||
|
|
82fcec6bb6 | ||
|
|
6e66e69451 |
@@ -95,11 +95,20 @@ body:
|
||||
description: "e.g. Ollama + llama3.2:latest, vLLM + mistral-7b, OpenAI API, Anthropic API"
|
||||
placeholder: "Ollama + llama3.2:latest"
|
||||
|
||||
- type: dropdown
|
||||
id: willing_to_fix
|
||||
attributes:
|
||||
label: Are you willing to submit a fix?
|
||||
options:
|
||||
- "-- Please Select --"
|
||||
- "Yes — I can open a PR"
|
||||
- "Partially — I can help but need guidance"
|
||||
- "No — I am only filing the report"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional-info
|
||||
attributes:
|
||||
label: Additional Information
|
||||
description: Anything else that might help — browser console errors, related issues, things you already tried, or environment quirks.
|
||||
placeholder: |
|
||||
- Any other context goes here.
|
||||
- If you are willing to submit a PR that fixes this, mention it here.
|
||||
|
||||
@@ -91,6 +91,37 @@ module.exports = async ({ github, context, core }) => {
|
||||
// 'untyped' → only the common body-length check applies.
|
||||
}
|
||||
|
||||
// ── Unfilled dropdowns ────────────────────────────────────────────────────
|
||||
// #2068 added a "-- Please Select --" default to every template dropdown, so
|
||||
// a contributor who never opens the dropdown submits with that literal string
|
||||
// as the section value. The per-section checks above only verify presence, so
|
||||
// a placeholder value passes. Scan every section and flag the ones still
|
||||
// showing the placeholder, as a single comma-separated line item.
|
||||
const PLACEHOLDER = '-- Please Select --';
|
||||
const headingRe = /^#+\s+(.+?)\s*$/gm;
|
||||
const headings = [];
|
||||
let headingMatch;
|
||||
while ((headingMatch = headingRe.exec(body)) !== null) {
|
||||
headings.push({
|
||||
name: headingMatch[1].trim(),
|
||||
headStart: headingMatch.index,
|
||||
contentStart: headingMatch.index + headingMatch[0].length,
|
||||
});
|
||||
}
|
||||
const unfilled = [];
|
||||
for (let i = 0; i < headings.length; i++) {
|
||||
const end = i + 1 < headings.length ? headings[i + 1].headStart : body.length;
|
||||
if (body.slice(headings[i].contentStart, end).includes(PLACEHOLDER)) {
|
||||
unfilled.push(headings[i].name);
|
||||
}
|
||||
}
|
||||
if (unfilled.length > 0) {
|
||||
failures.push(
|
||||
`**Unfilled dropdowns** — please choose a value; these sections still show ` +
|
||||
`the \`${PLACEHOLDER}\` placeholder: ${unfilled.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Labels ────────────────────────────────────────────────────────────────
|
||||
// These labels are expected to already exist in the repo — managing the
|
||||
// repo's label set is the maintainer's job, not this workflow's. We check a
|
||||
|
||||
@@ -32,7 +32,7 @@ module.exports = async ({ github, context, core }) => {
|
||||
// keyword + #NNN, or a full issue URL (e.g. .../issues/123) — the strict
|
||||
// keyword-prefixed form previously false-flagged correctly-linked PRs.
|
||||
const linkedSection = section('Linked Issue');
|
||||
const hasIssueRef = /#\d+/.test(linkedSection) || /\/issues\/\d+/.test(linkedSection);
|
||||
const hasIssueRef = /#\d+\b/.test(linkedSection) || /\/issues\/\d+/.test(linkedSection);
|
||||
if (!linkedSection || !hasIssueRef) {
|
||||
problems.push('**Linked Issue** — add a reference like `Fixes #NNN`, a bare `#NNN`, or a link to the issue.');
|
||||
}
|
||||
@@ -48,10 +48,12 @@ module.exports = async ({ github, context, core }) => {
|
||||
problems.push('**Checklist** — check the duplicate-search box to confirm you searched existing issues and PRs.');
|
||||
}
|
||||
|
||||
// 5. How to Test must have at least one numbered step.
|
||||
// 5. How to Test must contain enough real detail for a reviewer to act on.
|
||||
// Any format is fine — numbered steps, prose, the commands you ran, or a
|
||||
// code block — so we only require non-trivial content, not a specific shape.
|
||||
const howTo = section('How to Test');
|
||||
if (!howTo || !/\d+\.\s*\S/.test(howTo)) {
|
||||
problems.push('**How to Test** — add at least one numbered step a reviewer can follow to verify this works.');
|
||||
if (howTo.length < 30) {
|
||||
problems.push('**How to Test** — explain how a reviewer can verify this change. Numbered steps, the commands you ran, or a short code block all work — give a sentence or two of real detail (not just "tested locally").');
|
||||
}
|
||||
|
||||
// ── Comment ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -241,6 +241,18 @@ class AuthManager:
|
||||
revoked += 1
|
||||
if revoked:
|
||||
self._save_sessions()
|
||||
# Also revoke API bearer tokens owned by this user. The bearer auth
|
||||
# path authenticates straight against ApiToken rows and never
|
||||
# re-checks that the owner still exists, so leaving the rows behind
|
||||
# would let a deleted user keep full API access indefinitely.
|
||||
try:
|
||||
from core.database import get_db_session, ApiToken
|
||||
with get_db_session() as db:
|
||||
removed = db.query(ApiToken).filter(ApiToken.owner == username).delete()
|
||||
if removed:
|
||||
logger.info(f"Revoked {removed} API token(s) owned by deleted user '{username}'")
|
||||
except Exception:
|
||||
logger.warning(f"Failed to revoke API tokens for deleted user '{username}'")
|
||||
logger.info(f"Deleted user '{username}' (by {requesting_user}); revoked {revoked} active session(s)")
|
||||
return True
|
||||
|
||||
|
||||
+52
-14
@@ -1,7 +1,7 @@
|
||||
import os
|
||||
import logging
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import event, create_engine, Column, String, Text, Boolean, DateTime, Integer, ForeignKey, JSON, Index, func, text
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.types import TypeDecorator
|
||||
@@ -13,15 +13,21 @@ logger = logging.getLogger(__name__)
|
||||
# Create base class for declarative models
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def utcnow_naive() -> datetime:
|
||||
"""Return naive UTC for existing DateTime columns."""
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
"""Mixin that adds timestamp fields to models"""
|
||||
@declared_attr
|
||||
def created_at(cls):
|
||||
return Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
return Column(DateTime, default=utcnow_naive, nullable=False)
|
||||
|
||||
@declared_attr
|
||||
def updated_at(cls):
|
||||
return Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
return Column(DateTime, default=utcnow_naive, onupdate=utcnow_naive, nullable=False)
|
||||
|
||||
# Get database URL from environment, default to SQLite
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./data/app.db")
|
||||
@@ -171,7 +177,7 @@ class ChatMessage(Base):
|
||||
meta_data = Column("metadata", Text, nullable=True) # JSON string for metrics etc.
|
||||
|
||||
# Timestamp
|
||||
timestamp = Column(DateTime, default=datetime.utcnow)
|
||||
timestamp = Column(DateTime, default=utcnow_naive)
|
||||
|
||||
# Relationship to Session
|
||||
session = relationship("Session", back_populates="messages")
|
||||
@@ -224,7 +230,7 @@ class DocumentVersion(Base):
|
||||
content = Column(Text, nullable=False)
|
||||
summary = Column(String, nullable=True) # Edit description
|
||||
source = Column(String, default="ai") # "ai" or "user"
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
created_at = Column(DateTime, default=utcnow_naive)
|
||||
|
||||
document = relationship("Document", back_populates="versions")
|
||||
|
||||
@@ -336,6 +342,14 @@ class ModelEndpoint(TimestampMixin, Base):
|
||||
cached_models = Column(Text, nullable=True) # JSON list of last-known model IDs (avoids probe on list)
|
||||
pinned_models = Column(Text, nullable=True) # JSON list of admin-pinned model IDs (manual, may not appear in /v1/models)
|
||||
model_type = Column(String, nullable=True, default="llm") # "llm" or "image"
|
||||
# auto = classify by URL; local = self-hosted server; api/proxy = external
|
||||
# OpenAI-compatible API even when reachable through a private/tailnet IP.
|
||||
endpoint_kind = Column(String, nullable=True, default="auto")
|
||||
# auto = background refresh with TTL/backoff; manual/disabled = cached-first
|
||||
# only unless an explicit endpoint probe is requested.
|
||||
model_refresh_mode = Column(String, nullable=True, default="auto")
|
||||
model_refresh_interval = Column(Integer, nullable=True, default=None)
|
||||
model_refresh_timeout = Column(Integer, nullable=True, default=None)
|
||||
# Whether models on this endpoint accept OpenAI-style function
|
||||
# schemas + emit `tool_calls`. Auto-detected at Cookbook auto-
|
||||
# register time from `--enable-auto-tool-choice` in the serve cmd;
|
||||
@@ -472,8 +486,8 @@ class UserToolData(Base):
|
||||
tool_id = Column(String, ForeignKey("user_tools.id", ondelete="CASCADE"), nullable=False)
|
||||
key = Column(String, nullable=False)
|
||||
value = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
created_at = Column(DateTime, default=utcnow_naive)
|
||||
updated_at = Column(DateTime, default=utcnow_naive, onupdate=utcnow_naive)
|
||||
|
||||
tool = relationship("UserTool", backref=backref("data_entries", cascade="all, delete-orphan"))
|
||||
|
||||
@@ -592,7 +606,7 @@ class TaskRun(Base):
|
||||
|
||||
id = Column(String, primary_key=True, index=True)
|
||||
task_id = Column(String, ForeignKey("scheduled_tasks.id", ondelete="CASCADE"), nullable=False)
|
||||
started_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||
started_at = Column(DateTime, nullable=False, default=utcnow_naive)
|
||||
finished_at = Column(DateTime, nullable=True)
|
||||
status = Column(String, default="running") # "running", "success", "error"
|
||||
result = Column(Text, nullable=True)
|
||||
@@ -633,7 +647,7 @@ class Memory(Base):
|
||||
session_id = Column(String, ForeignKey("sessions.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
# Timestamp as Unix timestamp
|
||||
timestamp = Column(Integer, default=lambda: int(datetime.utcnow().timestamp()))
|
||||
timestamp = Column(Integer, default=lambda: int(utcnow_naive().timestamp()))
|
||||
|
||||
# Relationship to Session
|
||||
session = relationship("Session", backref="memories")
|
||||
@@ -803,6 +817,29 @@ def _migrate_add_model_type_column():
|
||||
except Exception as e:
|
||||
logging.getLogger(__name__).warning(f"model_type migration failed: {e}")
|
||||
|
||||
def _migrate_add_model_endpoint_refresh_columns():
|
||||
"""Add endpoint classification / refresh policy columns if missing."""
|
||||
import sqlite3
|
||||
db_path = DATABASE_URL.replace("sqlite:///", "")
|
||||
if not os.path.exists(db_path):
|
||||
return
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.execute("PRAGMA table_info(model_endpoints)")
|
||||
columns = [row[1] for row in cursor.fetchall()]
|
||||
if columns and "endpoint_kind" not in columns:
|
||||
conn.execute("ALTER TABLE model_endpoints ADD COLUMN endpoint_kind TEXT DEFAULT 'auto'")
|
||||
if columns and "model_refresh_mode" not in columns:
|
||||
conn.execute("ALTER TABLE model_endpoints ADD COLUMN model_refresh_mode TEXT DEFAULT 'auto'")
|
||||
if columns and "model_refresh_interval" not in columns:
|
||||
conn.execute("ALTER TABLE model_endpoints ADD COLUMN model_refresh_interval INTEGER")
|
||||
if columns and "model_refresh_timeout" not in columns:
|
||||
conn.execute("ALTER TABLE model_endpoints ADD COLUMN model_refresh_timeout INTEGER")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logging.getLogger(__name__).warning(f"model_endpoints refresh-policy migration failed: {e}")
|
||||
|
||||
def _migrate_add_task_run_model_column():
|
||||
"""Add model column to task_runs if it doesn't exist (records which model ran)."""
|
||||
import sqlite3
|
||||
@@ -1480,7 +1517,7 @@ def _migrate_seed_email_account():
|
||||
if not imap_host and not smtp_host:
|
||||
return # nothing to migrate
|
||||
|
||||
now = datetime.utcnow()
|
||||
now = utcnow_naive()
|
||||
with engine.begin() as conn:
|
||||
conn.execute(text("""
|
||||
INSERT INTO email_accounts
|
||||
@@ -1533,6 +1570,7 @@ def init_db():
|
||||
_migrate_add_pinned_models_column()
|
||||
_migrate_add_notes_sort_order()
|
||||
_migrate_add_model_type_column()
|
||||
_migrate_add_model_endpoint_refresh_columns()
|
||||
_migrate_add_model_endpoint_owner_column()
|
||||
_migrate_add_supports_tools_column()
|
||||
_migrate_add_task_run_model_column()
|
||||
@@ -1760,7 +1798,7 @@ def bulk_insert_messages(session_id: str, messages: list):
|
||||
'session_id': session_id,
|
||||
'role': msg['role'],
|
||||
'content': msg['content'],
|
||||
'timestamp': datetime.utcnow()
|
||||
'timestamp': utcnow_naive()
|
||||
}
|
||||
for msg in messages
|
||||
]
|
||||
@@ -1771,7 +1809,7 @@ def cleanup_old_sessions(days: int = 30):
|
||||
from datetime import timedelta
|
||||
|
||||
with get_db_session() as db:
|
||||
cutoff_date = datetime.utcnow() - timedelta(days=days)
|
||||
cutoff_date = utcnow_naive() - timedelta(days=days)
|
||||
|
||||
deleted_count = db.query(Session).filter(
|
||||
Session.archived == True,
|
||||
@@ -1816,7 +1854,7 @@ def update_session_last_accessed(session_id: str):
|
||||
with get_db_session() as db:
|
||||
db_session = db.query(Session).filter(Session.id == session_id).first()
|
||||
if db_session:
|
||||
db_session.last_accessed = datetime.utcnow()
|
||||
db_session.last_accessed = utcnow_naive()
|
||||
db.commit()
|
||||
return True
|
||||
return False
|
||||
@@ -1861,7 +1899,7 @@ def get_upcoming_events(owner, horizon_days: int = 60, limit: int = 40):
|
||||
The autonomous email->calendar pass relies on this to avoid disclosing (and
|
||||
acting on) other users' calendars."""
|
||||
from datetime import timedelta
|
||||
now = datetime.utcnow()
|
||||
now = utcnow_naive()
|
||||
with get_db_session() as db:
|
||||
q = db.query(CalendarEvent).join(CalendarCal).filter(
|
||||
CalendarEvent.dtstart >= now,
|
||||
|
||||
@@ -171,6 +171,15 @@ def _windows_bash_fallbacks() -> List[str]:
|
||||
return paths
|
||||
|
||||
|
||||
def _is_windows_bash_stub(path: str) -> bool:
|
||||
lowered = path.lower()
|
||||
return (
|
||||
"system32\\bash.exe" in lowered
|
||||
or "sysnative\\bash.exe" in lowered
|
||||
or "windowsapps\\bash.exe" in lowered
|
||||
)
|
||||
|
||||
|
||||
def find_bash() -> Optional[str]:
|
||||
"""Locate a real ``bash`` interpreter, or None.
|
||||
|
||||
@@ -184,6 +193,8 @@ def find_bash() -> Optional[str]:
|
||||
return _BASH_CACHE
|
||||
_BASH_PROBED = True
|
||||
found = which_tool("bash")
|
||||
if found and IS_WINDOWS and _is_windows_bash_stub(found):
|
||||
found = None
|
||||
if not found and IS_WINDOWS:
|
||||
for cand in _windows_bash_fallbacks():
|
||||
if os.path.exists(cand):
|
||||
|
||||
@@ -666,7 +666,7 @@ def _read_email(uid=None, message_id=None, folder="INBOX", account=None):
|
||||
conn.logout()
|
||||
return {"error": "No UID or Message-ID provided"}
|
||||
|
||||
status, msg_data = conn.uid("FETCH", _b(uid), "(RFC822)")
|
||||
status, msg_data = conn.uid("FETCH", _b(uid), "(BODY.PEEK[])")
|
||||
if status != "OK":
|
||||
conn.logout()
|
||||
return {"error": f"Failed to fetch email UID {uid}"}
|
||||
@@ -855,7 +855,7 @@ def _reply_to_email(uid, body, folder="INBOX", reply_all=False, account=None):
|
||||
"""Reply to an existing email by UID. Threads via In-Reply-To/References."""
|
||||
conn = _imap_connect(account)
|
||||
conn.select(folder, readonly=True)
|
||||
status, msg_data = conn.uid("FETCH", _b(uid), "(RFC822)")
|
||||
status, msg_data = conn.uid("FETCH", _b(uid), "(BODY.PEEK[])")
|
||||
conn.logout()
|
||||
if status != "OK" or not msg_data or not msg_data[0]:
|
||||
return {"error": f"Failed to fetch email UID {uid}"}
|
||||
@@ -1033,7 +1033,7 @@ def _download_attachment(uid, index, folder="INBOX", account=None):
|
||||
"""Extract a specific attachment to disk and return its local path."""
|
||||
conn = _imap_connect(account)
|
||||
conn.select(folder, readonly=True)
|
||||
status, msg_data = conn.uid("FETCH", _b(uid), "(RFC822)")
|
||||
status, msg_data = conn.uid("FETCH", _b(uid), "(BODY.PEEK[])")
|
||||
conn.logout()
|
||||
if status != "OK":
|
||||
return {"error": f"Failed to fetch email UID {uid}"}
|
||||
|
||||
@@ -375,6 +375,17 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
ok = auth_manager.delete_user(body.username, user)
|
||||
if not ok:
|
||||
raise HTTPException(400, "Cannot delete user")
|
||||
# delete_user removes the user's ApiToken rows, but the bearer-auth
|
||||
# middleware serves from an in-memory prefix->token cache that only
|
||||
# rebuilds when flagged dirty. Without this, a deleted user's already
|
||||
# cached token keeps authenticating until some other token op or a
|
||||
# restart clears the cache. Mirror what the token routes do.
|
||||
try:
|
||||
invalidator = getattr(request.app.state, "invalidate_token_cache", None)
|
||||
if invalidator:
|
||||
invalidator()
|
||||
except Exception:
|
||||
pass
|
||||
return {"ok": True}
|
||||
|
||||
# ---- Feature visibility (admin-managed) ----
|
||||
|
||||
+29
-18
@@ -66,13 +66,33 @@ def setup_compare_routes(session_manager: SessionManager):
|
||||
comp_id = str(uuid.uuid4())
|
||||
sid_a = str(uuid.uuid4())
|
||||
sid_b = str(uuid.uuid4())
|
||||
user = getattr(request.state, 'current_user', None)
|
||||
|
||||
# Blind mapping: randomly assign left/right
|
||||
blind = str(is_blind).lower() == "true"
|
||||
if blind:
|
||||
mapping = {"left": "a", "right": "b"}
|
||||
if random.random() > 0.5:
|
||||
mapping = {"left": "b", "right": "a"}
|
||||
else:
|
||||
mapping = {"left": "a", "right": "b"}
|
||||
|
||||
# Map session IDs to left/right based on blind mapping
|
||||
session_left = sid_a if mapping["left"] == "a" else sid_b
|
||||
session_right = sid_a if mapping["right"] == "a" else sid_b
|
||||
|
||||
# In blind mode, name the helper sessions by their neutral slot
|
||||
# ("Model A" / "Model B") instead of the real model. Otherwise the
|
||||
# session name leaks the model in the sidebar and GET /api/sessions,
|
||||
# de-anonymizing the comparison before the user votes (issue #1285).
|
||||
slot_name = {session_left: "Model A", session_right: "Model B"}
|
||||
|
||||
# Create ephemeral sessions (prefixed [CMP])
|
||||
for sid, model, endpoint in [(sid_a, model_a, endpoint_a), (sid_b, model_b, endpoint_b)]:
|
||||
user = getattr(request.state, 'current_user', None)
|
||||
name = f"[CMP] {slot_name[sid]}" if blind else f"[CMP] {model.split('/')[-1]}"
|
||||
session_manager.create_session(
|
||||
session_id=sid,
|
||||
name=f"[CMP] {model.split('/')[-1]}",
|
||||
name=name,
|
||||
endpoint_url=endpoint,
|
||||
model=model,
|
||||
rag=False,
|
||||
@@ -93,15 +113,6 @@ def setup_compare_routes(session_manager: SessionManager):
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Blind mapping: randomly assign left/right
|
||||
blind = str(is_blind).lower() == "true"
|
||||
if blind:
|
||||
mapping = {"left": "a", "right": "b"}
|
||||
if random.random() > 0.5:
|
||||
mapping = {"left": "b", "right": "a"}
|
||||
else:
|
||||
mapping = {"left": "a", "right": "b"}
|
||||
|
||||
# Store comparison record
|
||||
db = SessionLocal()
|
||||
try:
|
||||
@@ -121,18 +132,18 @@ def setup_compare_routes(session_manager: SessionManager):
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Map session IDs to left/right based on blind mapping
|
||||
session_left = sid_a if mapping["left"] == "a" else sid_b
|
||||
session_right = sid_a if mapping["right"] == "a" else sid_b
|
||||
|
||||
# In blind mode, withhold the model identities AND the left/right
|
||||
# mapping from the response. The client already knows model_a/model_b
|
||||
# (it sent them), so returning either would defeat blind mode. They are
|
||||
# revealed by POST /api/compare/{id}/vote once the user has voted (#1285).
|
||||
return {
|
||||
"id": comp_id,
|
||||
"session_left": session_left,
|
||||
"session_right": session_right,
|
||||
"model_left": model_a if mapping["left"] == "a" else model_b,
|
||||
"model_right": model_a if mapping["right"] == "a" else model_b,
|
||||
"model_left": None if blind else (model_a if mapping["left"] == "a" else model_b),
|
||||
"model_right": None if blind else (model_a if mapping["right"] == "a" else model_b),
|
||||
"is_blind": blind,
|
||||
"mapping": mapping,
|
||||
"mapping": None if blind else mapping,
|
||||
}
|
||||
|
||||
@router.post("/{comp_id}/vote")
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
Extracted from cookbook_routes.py; the routes module imports the symbols it needs."""
|
||||
|
||||
import logging
|
||||
import ntpath
|
||||
import os
|
||||
import posixpath
|
||||
import re
|
||||
@@ -41,6 +42,15 @@ _GPU_LIST_RE = re.compile(r"^\d+(?:,\d+)*$")
|
||||
# only (no quotes, shell metacharacters, or spaces) since it lands in a shell
|
||||
# command. A leading ~ is expanded to $HOME at command-build time.
|
||||
_LOCAL_DIR_RE = re.compile(r"^~?/[A-Za-z0-9._/-]*$|^~$")
|
||||
_WINDOWS_DRIVE_PATH_RE = re.compile(r"^[A-Za-z]:[\\/]")
|
||||
|
||||
|
||||
def _git_bash_path(path: str) -> str:
|
||||
m = re.match(r"^([A-Za-z]):[\\/](.*)$", path)
|
||||
if not m:
|
||||
return path
|
||||
drive, rest = m.groups()
|
||||
return f"/{drive.lower()}/{rest.replace(chr(92), '/')}"
|
||||
|
||||
|
||||
def _validate_repo_id(v: str | None) -> str:
|
||||
@@ -135,8 +145,11 @@ def _local_tooling_path_export(executable: str) -> str:
|
||||
# os.path.abspath("/opt/...") would incorrectly turn it into "D:\\opt\\...".
|
||||
if executable.startswith("/"):
|
||||
bin_dir = posixpath.dirname(executable)
|
||||
elif _WINDOWS_DRIVE_PATH_RE.match(executable):
|
||||
bin_dir = ntpath.dirname(executable)
|
||||
else:
|
||||
bin_dir = os.path.dirname(os.path.abspath(executable))
|
||||
bin_dir = _git_bash_path(bin_dir)
|
||||
# Escape for a double-quoted context: $PATH must still expand, but spaces
|
||||
# and shell metacharacters in the path must be preserved literally.
|
||||
esc = (
|
||||
@@ -250,6 +263,17 @@ def _venv_safe_local_pip_install_cmd(cmd: str, *, local: bool, in_venv: bool) ->
|
||||
return shlex.join(stripped)
|
||||
|
||||
|
||||
def _user_shell_path_bootstrap() -> list[str]:
|
||||
return [
|
||||
'ODYSSEUS_USER_SHELL="${SHELL:-}"',
|
||||
'if [ -n "$ODYSSEUS_USER_SHELL" ] && [ -x "$ODYSSEUS_USER_SHELL" ]; then',
|
||||
' ODYSSEUS_USER_PATH="$("$ODYSSEUS_USER_SHELL" -ic \'printf "__ODYSSEUS_PATH__%s\\n" "$PATH"\' 2>/dev/null | sed -n \'s/^__ODYSSEUS_PATH__//p\' | tail -n 1 || true)"',
|
||||
' if [ -n "$ODYSSEUS_USER_PATH" ]; then export PATH="$ODYSSEUS_USER_PATH:$PATH"; fi',
|
||||
'fi',
|
||||
'command -v python3 >/dev/null 2>&1 || python3() { python "$@"; }',
|
||||
]
|
||||
|
||||
|
||||
def _cached_model_scan_script(model_dirs: list[str] | None = None) -> str:
|
||||
"""Build the standalone Python scanner used by /api/model/cached."""
|
||||
lines = [
|
||||
@@ -528,9 +552,16 @@ def _append_serve_preflight_exit_lines(runner_lines: list[str], *, keep_shell_op
|
||||
runner_lines.append('fi')
|
||||
|
||||
|
||||
def _append_serve_exit_code_lines(runner_lines: list[str], *, keep_shell_open: bool) -> None:
|
||||
def _append_serve_exit_code_lines(
|
||||
runner_lines: list[str],
|
||||
*,
|
||||
keep_shell_open: bool,
|
||||
is_pip_install: bool = False,
|
||||
) -> None:
|
||||
"""Append serve-runner lines that preserve and report the command exit code."""
|
||||
runner_lines.append('ODYSSEUS_CMD_EXIT=$?')
|
||||
if is_pip_install:
|
||||
runner_lines.append('if [ $ODYSSEUS_CMD_EXIT -eq 0 ]; then echo ""; echo "DOWNLOAD_OK"; fi')
|
||||
if keep_shell_open:
|
||||
runner_lines.append('echo ""; echo "=== Process exited with code $ODYSSEUS_CMD_EXIT ==="; exec "${SHELL:-/bin/bash}"')
|
||||
else:
|
||||
|
||||
+16
-14
@@ -38,7 +38,8 @@ from routes.cookbook_helpers import (
|
||||
_ps_squote, _bash_squote, _validate_serve_cmd, _parse_serve_phase,
|
||||
_safe_env_prefix, _local_tooling_path_export, _append_serve_preflight_exit_lines,
|
||||
_append_serve_exit_code_lines, _append_llama_cpp_linux_accel_build_lines, _cached_model_scan_script,
|
||||
_ollama_bind_from_cmd, _pip_install_fallback_chain, _pip_install_no_cache, _venv_safe_local_pip_install_cmd,
|
||||
_ollama_bind_from_cmd, _pip_install_fallback_chain, _pip_install_no_cache,
|
||||
_user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd,
|
||||
ModelDownloadRequest, ServeRequest,
|
||||
)
|
||||
|
||||
@@ -294,15 +295,6 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
safe_chmod(key_path.with_suffix(".pub"), 0o644)
|
||||
return {"ok": True, "public_key": _read_cookbook_public_key()}
|
||||
|
||||
def _user_shell_path_bootstrap() -> list[str]:
|
||||
return [
|
||||
'ODYSSEUS_USER_SHELL="${SHELL:-}"',
|
||||
'if [ -n "$ODYSSEUS_USER_SHELL" ] && [ -x "$ODYSSEUS_USER_SHELL" ]; then',
|
||||
' ODYSSEUS_USER_PATH="$("$ODYSSEUS_USER_SHELL" -ic \'printf "__ODYSSEUS_PATH__%s\\n" "$PATH"\' 2>/dev/null | sed -n \'s/^__ODYSSEUS_PATH__//p\' | tail -n 1 || true)"',
|
||||
' if [ -n "$ODYSSEUS_USER_PATH" ]; then export PATH="$ODYSSEUS_USER_PATH:$PATH"; fi',
|
||||
'fi',
|
||||
]
|
||||
|
||||
def _needs_binary(cmd: str, binary: str) -> bool:
|
||||
return bool(re.search(rf"(^|[\s;&|()]){re.escape(binary)}($|[\s;&|()])", cmd or ""))
|
||||
|
||||
@@ -443,8 +435,8 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
lines.append('export PATH="$HOME/.local/bin:$PATH"')
|
||||
# When Odysseus runs from a venv (e.g. native macOS install), put its bin
|
||||
# on PATH so the tmux shell finds the bundled `hf`/`python3` without an
|
||||
# activated venv. Local bash runs only — meaningless over SSH/Windows.
|
||||
if not req.remote_host and req.platform != "windows":
|
||||
# activated venv. Local bash runs only — meaningless over SSH.
|
||||
if not req.remote_host:
|
||||
lines.append(_local_tooling_path_export(sys.executable))
|
||||
# Best-effort install hf CLI (always). hf_transfer (Rust parallel downloader)
|
||||
# is fast but flaky on large files — it tends to crash near the end at high
|
||||
@@ -982,6 +974,8 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
ps_lines.append('Write-Host "ERROR: vLLM is not supported on Windows. Use Ollama or llama.cpp instead."')
|
||||
ps_lines.append('exit 1')
|
||||
ps_lines.append(req.cmd)
|
||||
if is_pip_install:
|
||||
ps_lines.append('if ($LASTEXITCODE -eq 0) { Write-Host ""; Write-Host "DOWNLOAD_OK" }')
|
||||
ps_lines.append('Write-Host ""')
|
||||
ps_lines.append('Write-Host "=== Process exited with code $LASTEXITCODE ==="')
|
||||
runner_path = TMUX_LOG_DIR / f"{session_id}_run.ps1"
|
||||
@@ -1167,10 +1161,18 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
if local_windows:
|
||||
# Detached background process — no interactive shell to keep open.
|
||||
# Print the exit marker the status poller looks for, then stop.
|
||||
_append_serve_exit_code_lines(runner_lines, keep_shell_open=False)
|
||||
_append_serve_exit_code_lines(
|
||||
runner_lines,
|
||||
keep_shell_open=False,
|
||||
is_pip_install=is_pip_install,
|
||||
)
|
||||
else:
|
||||
# Keep shell open after exit so user can see errors
|
||||
_append_serve_exit_code_lines(runner_lines, keep_shell_open=True)
|
||||
_append_serve_exit_code_lines(
|
||||
runner_lines,
|
||||
keep_shell_open=True,
|
||||
is_pip_install=is_pip_install,
|
||||
)
|
||||
|
||||
runner_path = TMUX_LOG_DIR / f"{session_id}_run.sh"
|
||||
runner_path.write_text("\n".join(runner_lines) + "\n", encoding="utf-8")
|
||||
|
||||
@@ -886,7 +886,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
||||
if is_spam and auto_spam and spam_folder:
|
||||
if _imap_move(uid, spam_folder, account_id=account_id, owner=account_owner):
|
||||
moved_to = spam_folder
|
||||
logger.info(f"Auto-spam moved uid={uid.decode()} to {spam_folder}: {spam_reason}")
|
||||
logger.info(f"Auto-spam moved uid={uid.decode() if isinstance(uid, bytes) else str(uid)} to {spam_folder}: {spam_reason}")
|
||||
|
||||
_c = _sql3.connect(SCHEDULED_DB)
|
||||
_c.execute("""
|
||||
@@ -894,7 +894,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
||||
(message_id, owner, uid, folder, subject, sender, tags, spam_verdict,
|
||||
spam_reason, moved_to, model_used, created_at)
|
||||
VALUES (?, ?, ?, 'INBOX', ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (message_id, account_owner or "", uid.decode(), subject, sender,
|
||||
""", (message_id, account_owner or "", uid.decode() if isinstance(uid, bytes) else str(uid), subject, sender,
|
||||
json.dumps(tags), 1 if is_spam else 0,
|
||||
spam_reason, moved_to, model, datetime.utcnow().isoformat()))
|
||||
_c.commit()
|
||||
|
||||
+443
-164
@@ -11,7 +11,7 @@ import httpx
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Optional
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
from fastapi import APIRouter, HTTPException, Form, Query, Body, Request
|
||||
from fastapi import APIRouter, HTTPException, Form, Query, Body, Request, Response
|
||||
from pydantic import BaseModel
|
||||
from fastapi.responses import StreamingResponse
|
||||
from core.database import SessionLocal, ModelEndpoint, Session as DbSession
|
||||
@@ -335,6 +335,141 @@ def _truthy(value: str | None) -> bool:
|
||||
return (value or "").strip().lower() in ("true", "1", "yes", "on")
|
||||
|
||||
|
||||
_ENDPOINT_KINDS = {"auto", "local", "api", "proxy"}
|
||||
_REFRESH_MODES = {"auto", "manual", "disabled"}
|
||||
|
||||
|
||||
def _normalize_endpoint_kind(value: Any) -> str:
|
||||
kind = str(value or "auto").strip().lower()
|
||||
return kind if kind in _ENDPOINT_KINDS else "auto"
|
||||
|
||||
|
||||
def _normalize_refresh_mode(value: Any, endpoint_kind: str = "auto") -> str:
|
||||
mode = str(value or "").strip().lower()
|
||||
kind = _normalize_endpoint_kind(endpoint_kind)
|
||||
if mode in ("manual", "disabled"):
|
||||
return mode
|
||||
if mode == "auto" and kind != "proxy":
|
||||
return "auto"
|
||||
# Proxies default to manual cached-first behavior. Normal local/API
|
||||
# endpoints keep automatic bounded refreshes.
|
||||
return "manual" if kind == "proxy" else "auto"
|
||||
|
||||
|
||||
def _endpoint_kind(ep: Any) -> str:
|
||||
return _normalize_endpoint_kind(getattr(ep, "endpoint_kind", None))
|
||||
|
||||
|
||||
def _endpoint_refresh_mode(ep: Any, endpoint_kind: str | None = None) -> str:
|
||||
return _normalize_refresh_mode(getattr(ep, "model_refresh_mode", None), endpoint_kind or _endpoint_kind(ep))
|
||||
|
||||
|
||||
def _endpoint_refresh_interval(ep: Any, category: str) -> float:
|
||||
raw = getattr(ep, "model_refresh_interval", None)
|
||||
try:
|
||||
val = int(raw) if raw is not None else 0
|
||||
except Exception:
|
||||
val = 0
|
||||
if val > 0:
|
||||
return float(max(30, val))
|
||||
return 60.0 if category == "local" else 3600.0
|
||||
|
||||
|
||||
def _endpoint_refresh_timeout(ep: Any, category: str) -> float:
|
||||
raw = getattr(ep, "model_refresh_timeout", None)
|
||||
try:
|
||||
val = int(raw) if raw is not None else 0
|
||||
except Exception:
|
||||
val = 0
|
||||
if val > 0:
|
||||
return float(max(1, min(30, val)))
|
||||
return 2.5 if category == "local" else 2.0
|
||||
|
||||
|
||||
def _manual_refresh_timeout(ep: Any, category: str, requested: Any = None) -> float:
|
||||
"""Timeout for explicit user-triggered model-list refreshes.
|
||||
|
||||
Background refreshes stay short. A manual refresh is the one path where a
|
||||
large proxy may legitimately need 15-30s to aggregate its catalog.
|
||||
"""
|
||||
requested_val = _parse_positive_int(requested, minimum=1, maximum=60)
|
||||
if requested_val is not None:
|
||||
return float(requested_val)
|
||||
stored = _parse_positive_int(getattr(ep, "model_refresh_timeout", None), minimum=1, maximum=60)
|
||||
if category == "local":
|
||||
return float(stored) if stored is not None else _endpoint_refresh_timeout(ep, category)
|
||||
return float(max(stored or 30, 30))
|
||||
|
||||
|
||||
def _parse_model_list(raw: Any) -> List[str]:
|
||||
"""Return a sanitized list of model ids from JSON/list/comma text."""
|
||||
if raw is None:
|
||||
return []
|
||||
value = raw
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return []
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
if isinstance(parsed, list):
|
||||
value = parsed
|
||||
else:
|
||||
value = re.split(r"[\n,]+", text)
|
||||
except Exception:
|
||||
value = re.split(r"[\n,]+", text)
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
out = []
|
||||
seen = set()
|
||||
for item in value:
|
||||
mid = str(item or "").strip()
|
||||
if not mid or mid in seen:
|
||||
continue
|
||||
seen.add(mid)
|
||||
out.append(mid)
|
||||
return out
|
||||
|
||||
|
||||
def _parse_positive_int(raw: Any, *, minimum: int = 1, maximum: int = 86400) -> Optional[int]:
|
||||
try:
|
||||
val = int(str(raw).strip())
|
||||
except Exception:
|
||||
return None
|
||||
if val < minimum:
|
||||
return None
|
||||
return min(val, maximum)
|
||||
|
||||
|
||||
def _explicit_model_list_timeout(base_url: str, endpoint_kind: str = "auto", requested: Any = None) -> float:
|
||||
"""Timeout for explicit user-triggered model-list fetches during setup."""
|
||||
requested_val = _parse_positive_int(requested, minimum=1, maximum=60)
|
||||
if requested_val is not None:
|
||||
return float(requested_val)
|
||||
kind = _normalize_endpoint_kind(endpoint_kind)
|
||||
category = _classify_endpoint(base_url, kind)
|
||||
if kind in ("api", "proxy") or category == "api":
|
||||
return 30.0
|
||||
return 3.0 if _is_ollama_base(base_url) else 2.0
|
||||
|
||||
|
||||
def _cached_model_ids(ep: Any) -> List[str]:
|
||||
return _parse_model_list(getattr(ep, "cached_models", None))
|
||||
|
||||
|
||||
def _hidden_model_ids(ep: Any) -> set:
|
||||
return set(_parse_model_list(getattr(ep, "hidden_models", None)))
|
||||
|
||||
|
||||
def _is_ollama_base(base_url: str) -> bool:
|
||||
try:
|
||||
parsed = urlparse(base_url)
|
||||
host = (parsed.hostname or "").lower()
|
||||
return parsed.port == 11434 or "ollama" in host
|
||||
except Exception:
|
||||
return "ollama" in (base_url or "").lower()
|
||||
|
||||
|
||||
# Prefixes/substrings for models that are NOT chat-completions-capable
|
||||
_NON_CHAT_PREFIXES = (
|
||||
"dall-e", "tts-", "whisper", "text-embedding", "embedding",
|
||||
@@ -441,10 +576,15 @@ _PRIVATE_PREFIXES = ("10.", "172.16.", "172.17.", "172.18.", "172.19.",
|
||||
_TAILSCALE_RE = re.compile(r"^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.")
|
||||
|
||||
|
||||
def _classify_endpoint(base_url: str) -> str:
|
||||
def _classify_endpoint(base_url: str, endpoint_kind: str = "auto") -> str:
|
||||
"""Return 'local' if the endpoint URL points to a private/local address, else 'api'.
|
||||
Includes the Tailscale CGNAT range (100.64.0.0/10) so tailnet-hosted
|
||||
servers (e.g. Cookbook serve endpoints) get reachability-probed too."""
|
||||
kind = _normalize_endpoint_kind(endpoint_kind)
|
||||
if kind == "local":
|
||||
return "local"
|
||||
if kind in ("api", "proxy"):
|
||||
return "api"
|
||||
try:
|
||||
host = urlparse(base_url).hostname or ""
|
||||
if host in _LOCAL_HOSTS or host.startswith(_PRIVATE_PREFIXES):
|
||||
@@ -456,6 +596,21 @@ def _classify_endpoint(base_url: str) -> str:
|
||||
return "api"
|
||||
|
||||
|
||||
def _effective_endpoint_kind(ep: Any, base_url: str) -> str:
|
||||
"""Return explicit kind, with a legacy proxy heuristic for keyed /v1 URLs."""
|
||||
kind = _endpoint_kind(ep)
|
||||
if kind != "auto":
|
||||
return kind
|
||||
if getattr(ep, "api_key", None) and not _is_ollama_base(base_url):
|
||||
try:
|
||||
path = (urlparse(base_url).path or "").rstrip("/")
|
||||
if path.endswith("/v1") or "/openai" in path:
|
||||
return "proxy"
|
||||
except Exception:
|
||||
pass
|
||||
return "auto"
|
||||
|
||||
|
||||
|
||||
def _probe_endpoint(base_url: str, api_key: str = None, timeout: int = 5) -> List[str]:
|
||||
"""Probe a base URL's /models endpoint and return list of model IDs.
|
||||
@@ -546,30 +701,18 @@ def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) ->
|
||||
"""Reachability probe that does not require installed/listed models."""
|
||||
from src.endpoint_resolver import resolve_url
|
||||
base = resolve_url(_normalize_base(base_url))
|
||||
headers = {}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
headers = build_headers(api_key, base)
|
||||
|
||||
# Ollama exposes /v1/models (OpenAI-compatible) AND native /api/version,
|
||||
# /api/tags. The OpenAI-style GET base + "/models" returns 404 when the
|
||||
# base is the host root or the native /api root (e.g. http://localhost:11434,
|
||||
# http://localhost:11434/api) because /models lives under /v1 there. Treat
|
||||
# 4xx on a port-11434 / Ollama-named base as "try the native paths" rather
|
||||
# than as a definitive offline verdict — Ollama is reachable, it just
|
||||
# doesn't speak OpenAI on that prefix. Without this gate the quickstart
|
||||
# marks an alive Ollama as offline whenever cached_models is empty (issue
|
||||
# #1025): _probe_endpoint() falls through to /api/tags on the same 404, but
|
||||
# _ping_endpoint() was returning before that fallback could run.
|
||||
# /api/tags. Probe native paths for Ollama-style endpoints, but avoid using
|
||||
# /models as a generic health check because large proxy catalogs can be slow.
|
||||
parsed_base = urlparse(base)
|
||||
looks_like_ollama = (
|
||||
parsed_base.port == 11434
|
||||
or "ollama" in (parsed_base.hostname or "").lower()
|
||||
)
|
||||
|
||||
url = base + "/models"
|
||||
last_error: Optional[str] = None
|
||||
try:
|
||||
r = httpx.get(url, headers=headers, timeout=timeout)
|
||||
def _result_from_response(r) -> Dict[str, Any]:
|
||||
if 300 <= r.status_code < 400:
|
||||
loc = r.headers.get("location", "")
|
||||
if loc.startswith("/login") or "/login" in loc:
|
||||
@@ -579,13 +722,15 @@ def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) ->
|
||||
"error": "That is Odysseus, not a model server. Use the Ollama URL, usually http://host.docker.internal:11434/v1 in Docker.",
|
||||
}
|
||||
return {"reachable": False, "status_code": r.status_code, "error": f"HTTP {r.status_code} redirect"}
|
||||
if r.status_code < 400:
|
||||
return {"reachable": True, "status_code": r.status_code, "error": None}
|
||||
if r.status_code < 500 and not looks_like_ollama:
|
||||
return {"reachable": False, "status_code": r.status_code, "error": f"HTTP {r.status_code}"}
|
||||
last_error = f"HTTP {r.status_code}"
|
||||
except Exception as e:
|
||||
last_error = str(e)[:120]
|
||||
if 200 <= r.status_code < 300:
|
||||
return {
|
||||
"reachable": True,
|
||||
"status_code": r.status_code,
|
||||
"error": None,
|
||||
}
|
||||
return {"reachable": False, "status_code": r.status_code, "error": f"HTTP {r.status_code}"}
|
||||
|
||||
last_error: Optional[str] = None
|
||||
|
||||
try:
|
||||
if looks_like_ollama:
|
||||
@@ -597,14 +742,21 @@ def _ping_endpoint(base_url: str, api_key: str = None, timeout: float = 1.5) ->
|
||||
for path in ("/api/version", "/api/tags"):
|
||||
try:
|
||||
r = httpx.get(root + path, timeout=timeout)
|
||||
if r.status_code < 400:
|
||||
return {"reachable": True, "status_code": r.status_code, "error": None}
|
||||
last_error = f"HTTP {r.status_code}"
|
||||
result = _result_from_response(r)
|
||||
if result["reachable"]:
|
||||
return result
|
||||
last_error = result.get("error")
|
||||
except Exception as e:
|
||||
last_error = str(e)[:120]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
r = httpx.get(base, headers=headers, timeout=timeout)
|
||||
return _result_from_response(r)
|
||||
except Exception as e:
|
||||
last_error = str(e)[:120]
|
||||
|
||||
return {"reachable": False, "status_code": None, "error": last_error}
|
||||
|
||||
|
||||
@@ -715,17 +867,71 @@ def setup_model_routes(model_discovery):
|
||||
flip)."""
|
||||
_models_cache.clear()
|
||||
|
||||
# Track endpoints that have failed recently so we back off probing dead ones.
|
||||
_probe_failures = {} # ep_id → (last_fail_ts, consecutive_fails)
|
||||
# Track model-list refreshes by URL+key. This prevents repeated picker/API
|
||||
# opens from starting duplicate /models probes, and gives slow/offline
|
||||
# providers a cooldown after failures.
|
||||
_refresh_state: Dict[str, Dict[str, Any]] = {}
|
||||
_refresh_inflight = {"v": False} # coarse single-flight guard
|
||||
_REFRESH_FAILURE_BASE = 300.0
|
||||
_REFRESH_FAILURE_MAX = 3600.0
|
||||
|
||||
def _refresh_caches_bg():
|
||||
"""Background thread: re-probe all endpoints in PARALLEL with a tight
|
||||
timeout, skipping endpoints that have been failing repeatedly.
|
||||
def _refresh_key(base: str, api_key: Optional[str]) -> str:
|
||||
return f"{base.rstrip('/')}\x00{api_key or ''}"
|
||||
|
||||
Was the cause of gradual server degradation: sequential 3s-timeout
|
||||
probes against many endpoints (some offline) tied up the threadpool
|
||||
for 15-30s every cache cycle, eventually exhausting it."""
|
||||
def _ts(value: Any) -> float:
|
||||
try:
|
||||
return float(value.timestamp()) if value else 0.0
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
def _failure_delay(fails: int) -> float:
|
||||
if fails <= 0:
|
||||
return 0.0
|
||||
return min(_REFRESH_FAILURE_BASE * (2 ** max(0, fails - 1)), _REFRESH_FAILURE_MAX)
|
||||
|
||||
def _should_refresh_endpoint(ep: Any, now: float, force: bool = False) -> tuple[bool, Dict[str, Any]]:
|
||||
base = _normalize_base(getattr(ep, "base_url", "") or "")
|
||||
kind = _effective_endpoint_kind(ep, base)
|
||||
category = _classify_endpoint(base, kind)
|
||||
mode = _endpoint_refresh_mode(ep, kind)
|
||||
cached = _cached_model_ids(ep)
|
||||
key = _refresh_key(base, getattr(ep, "api_key", None))
|
||||
state = _refresh_state.get(key, {})
|
||||
|
||||
info = {
|
||||
"id": getattr(ep, "id", ""),
|
||||
"base": base,
|
||||
"api_key": getattr(ep, "api_key", None),
|
||||
"kind": kind,
|
||||
"category": category,
|
||||
"mode": mode,
|
||||
"key": key,
|
||||
"timeout": _endpoint_refresh_timeout(ep, category),
|
||||
}
|
||||
if not base:
|
||||
return False, info
|
||||
if state.get("inflight"):
|
||||
return False, info
|
||||
if mode in ("manual", "disabled") and not force:
|
||||
return False, info
|
||||
fails = int(state.get("fail_count") or 0)
|
||||
if fails and not force:
|
||||
last_failure = float(state.get("last_failure") or 0.0)
|
||||
if now - last_failure < _failure_delay(fails):
|
||||
return False, info
|
||||
if cached and not force:
|
||||
interval = _endpoint_refresh_interval(ep, category)
|
||||
last_good = float(state.get("last_success") or 0.0) or _ts(getattr(ep, "updated_at", None)) or _ts(getattr(ep, "created_at", None))
|
||||
if last_good and now - last_good < interval:
|
||||
return False, info
|
||||
return True, info
|
||||
|
||||
def _refresh_caches_bg(force: bool = False):
|
||||
"""Background thread: safely refresh model caches with per-base single-flight.
|
||||
|
||||
The public /api/models path stays cached-first. This refresh never clears
|
||||
a non-empty cached model list on timeout/failure, and proxy/manual
|
||||
endpoints are skipped unless explicitly forced."""
|
||||
import threading
|
||||
if _refresh_inflight["v"]:
|
||||
return # already running
|
||||
@@ -735,44 +941,63 @@ def setup_model_routes(model_discovery):
|
||||
try:
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
db = SessionLocal()
|
||||
changed = False
|
||||
try:
|
||||
endpoints = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all()
|
||||
# Skip endpoints that have failed 3+ times in a row in the last 5 min
|
||||
now = _time.time()
|
||||
to_probe = []
|
||||
groups: Dict[str, Dict[str, Any]] = {}
|
||||
for ep in endpoints:
|
||||
ts, fails = _probe_failures.get(ep.id, (0, 0))
|
||||
if fails >= 3 and (now - ts) < 300:
|
||||
ok, info = _should_refresh_endpoint(ep, now, force=force)
|
||||
if not ok:
|
||||
continue
|
||||
to_probe.append(ep)
|
||||
groups.setdefault(info["key"], {
|
||||
"base": info["base"],
|
||||
"api_key": info["api_key"],
|
||||
"timeout": info["timeout"],
|
||||
"endpoint_ids": [],
|
||||
})["endpoint_ids"].append(info["id"])
|
||||
|
||||
def _probe_one(ep):
|
||||
base = _normalize_base(ep.base_url)
|
||||
for key in groups:
|
||||
st = _refresh_state.setdefault(key, {})
|
||||
st["inflight"] = True
|
||||
st["last_attempt"] = now
|
||||
|
||||
def _probe_one(key: str, data: Dict[str, Any]):
|
||||
try:
|
||||
ids = _probe_endpoint(base, ep.api_key, timeout=2)
|
||||
return ep, ids, None
|
||||
ids = _probe_endpoint(data["base"], data.get("api_key"), timeout=data.get("timeout") or 2)
|
||||
return key, data["endpoint_ids"], ids, None
|
||||
except Exception as e:
|
||||
return ep, None, e
|
||||
return key, data["endpoint_ids"], None, e
|
||||
|
||||
if to_probe:
|
||||
# Bounded parallelism — 8 concurrent probes is plenty
|
||||
with ThreadPoolExecutor(max_workers=min(8, len(to_probe))) as pool:
|
||||
futures = [pool.submit(_probe_one, ep) for ep in to_probe]
|
||||
if groups:
|
||||
with ThreadPoolExecutor(max_workers=min(4, len(groups))) as pool:
|
||||
futures = [pool.submit(_probe_one, key, data) for key, data in groups.items()]
|
||||
for fut in as_completed(futures):
|
||||
ep, ids, err = fut.result()
|
||||
key, endpoint_ids, ids, err = fut.result()
|
||||
st = _refresh_state.setdefault(key, {})
|
||||
if ids:
|
||||
ep.cached_models = json.dumps(ids)
|
||||
_probe_failures.pop(ep.id, None)
|
||||
for ep_id in endpoint_ids:
|
||||
ep_obj = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
|
||||
if ep_obj:
|
||||
ep_obj.cached_models = json.dumps(ids)
|
||||
changed = True
|
||||
st["last_success"] = _time.time()
|
||||
st["fail_count"] = 0
|
||||
st.pop("last_failure", None)
|
||||
else:
|
||||
prev = _probe_failures.get(ep.id, (0, 0))
|
||||
_probe_failures[ep.id] = (_time.time(), prev[1] + 1)
|
||||
st["last_failure"] = _time.time()
|
||||
st["fail_count"] = int(st.get("fail_count") or 0) + 1
|
||||
st["inflight"] = False
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
_invalidate_models_cache()
|
||||
if changed:
|
||||
_invalidate_models_cache()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
for st in _refresh_state.values():
|
||||
st["inflight"] = False
|
||||
_refresh_inflight["v"] = False
|
||||
threading.Thread(target=_do, daemon=True).start()
|
||||
|
||||
@@ -804,24 +1029,15 @@ def setup_model_routes(model_discovery):
|
||||
base = _normalize_base(ep.base_url)
|
||||
provider = _detect_provider(base)
|
||||
# Use cached models — background refresh keeps them updated
|
||||
model_ids = []
|
||||
if ep.cached_models:
|
||||
try:
|
||||
model_ids = json.loads(ep.cached_models)
|
||||
except Exception:
|
||||
pass
|
||||
model_ids = _cached_model_ids(ep)
|
||||
ep_model_type = getattr(ep, "model_type", None) or "llm"
|
||||
# Filter out hidden (probe-failed) models
|
||||
hidden = set()
|
||||
if ep.hidden_models:
|
||||
try:
|
||||
hidden = set(json.loads(ep.hidden_models))
|
||||
except Exception:
|
||||
pass
|
||||
hidden = _hidden_model_ids(ep)
|
||||
model_ids = [m for m in model_ids if m not in hidden]
|
||||
# Build correct URL based on provider
|
||||
chat_url = build_chat_url(base)
|
||||
category = _classify_endpoint(base)
|
||||
kind = _effective_endpoint_kind(ep, base)
|
||||
category = _classify_endpoint(base, kind)
|
||||
|
||||
if model_ids:
|
||||
curated_key = _match_provider_curated(base, None)
|
||||
@@ -837,6 +1053,7 @@ def setup_model_routes(model_discovery):
|
||||
"endpoint_id": ep.id,
|
||||
"endpoint_name": ep.name,
|
||||
"category": category,
|
||||
"endpoint_kind": kind,
|
||||
"model_type": ep_model_type,
|
||||
})
|
||||
else:
|
||||
@@ -852,6 +1069,7 @@ def setup_model_routes(model_discovery):
|
||||
"endpoint_id": ep.id,
|
||||
"endpoint_name": ep.name,
|
||||
"category": category,
|
||||
"endpoint_kind": kind,
|
||||
"model_type": ep_model_type,
|
||||
"offline": True,
|
||||
})
|
||||
@@ -898,11 +1116,11 @@ def setup_model_routes(model_discovery):
|
||||
result = _fetch_models(owner=owner, is_admin=_is_admin)
|
||||
_models_cache[_cache_key] = {"data": result, "time": now}
|
||||
# Kick off background refresh to update caches from live endpoints
|
||||
_refresh_caches_bg()
|
||||
_refresh_caches_bg(force=refresh)
|
||||
return result
|
||||
|
||||
# Brief cache for local-probe results so picker-open doesn't hammer
|
||||
# /v1/models every time. 8s TTL — long enough to amortize cost,
|
||||
# endpoint health checks every time. 8s TTL — long enough to amortize cost,
|
||||
# short enough that a freshly-killed local server shows as offline
|
||||
# within ~8s of the user noticing.
|
||||
_LOCAL_PROBE_TTL = 8.0
|
||||
@@ -912,7 +1130,7 @@ def setup_model_routes(model_discovery):
|
||||
async def probe_local_endpoints(request: Request):
|
||||
"""Fast parallel reachability check for LOCAL endpoints only.
|
||||
Cloud endpoints (api.openai.com, api.anthropic.com, etc.) are
|
||||
assumed up. Local endpoints get a 1.5s /models probe so the UI
|
||||
assumed up. Local endpoints get a 1.5s cheap reachability probe so the UI
|
||||
can dim stale entries pointing at dead vLLM servers. Returns
|
||||
{ep_id: {alive, latency_ms, error}}."""
|
||||
require_admin(request)
|
||||
@@ -924,36 +1142,44 @@ def setup_model_routes(model_discovery):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
endpoints = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all()
|
||||
local_eps = [
|
||||
(ep.id, _normalize_base(ep.base_url), ep.api_key)
|
||||
for ep in endpoints
|
||||
if _classify_endpoint(_normalize_base(ep.base_url)) == "local"
|
||||
]
|
||||
local_eps = []
|
||||
for ep in endpoints:
|
||||
base = _normalize_base(ep.base_url)
|
||||
kind = _effective_endpoint_kind(ep, base)
|
||||
if _classify_endpoint(base, kind) == "local":
|
||||
local_eps.append((ep.id, base, ep.api_key))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
async def _probe_one(ep_id: str, base: str, api_key: Optional[str]) -> Dict[str, Any]:
|
||||
grouped: Dict[str, Dict[str, Any]] = {}
|
||||
for ep_id, base, api_key in local_eps:
|
||||
key = _refresh_key(base, api_key)
|
||||
grouped.setdefault(key, {"base": base, "api_key": api_key, "endpoint_ids": []})["endpoint_ids"].append(ep_id)
|
||||
|
||||
async def _probe_one(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
t0 = _time.time()
|
||||
try:
|
||||
models = _probe_endpoint(base, api_key, timeout=2.5)
|
||||
import asyncio as _asyncio
|
||||
ping = await _asyncio.to_thread(_ping_endpoint, data["base"], data.get("api_key"), 1.5)
|
||||
lat = round((_time.time() - t0) * 1000)
|
||||
return {
|
||||
"alive": bool(models),
|
||||
"alive": bool(ping.get("reachable")),
|
||||
"latency_ms": lat,
|
||||
"status_code": 200 if models else None,
|
||||
"error": None if models else "No models found",
|
||||
"status_code": ping.get("status_code"),
|
||||
"error": ping.get("error"),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"alive": False, "latency_ms": None, "status_code": None, "error": str(e)[:120]}
|
||||
|
||||
import asyncio as _asyncio
|
||||
results_list = await _asyncio.gather(
|
||||
*[_probe_one(eid, base, key) for eid, base, key in local_eps],
|
||||
*[_probe_one(data) for data in grouped.values()],
|
||||
return_exceptions=False,
|
||||
)
|
||||
results: Dict[str, Any] = {}
|
||||
for (eid, _, _), r in zip(local_eps, results_list):
|
||||
results[eid] = r
|
||||
for data, r in zip(grouped.values(), results_list):
|
||||
for eid in data["endpoint_ids"]:
|
||||
results[eid] = r
|
||||
|
||||
_local_probe_cache["data"] = results
|
||||
_local_probe_cache["time"] = now
|
||||
@@ -973,50 +1199,28 @@ def setup_model_routes(model_discovery):
|
||||
for ep in endpoints:
|
||||
base = _normalize_base(ep.base_url)
|
||||
provider = _detect_provider(base)
|
||||
kind = _effective_endpoint_kind(ep, base)
|
||||
cached_count = len(_cached_model_ids(ep))
|
||||
entry = {
|
||||
"id": ep.id,
|
||||
"name": ep.name,
|
||||
"base_url": base,
|
||||
"provider": provider,
|
||||
"category": _classify_endpoint(base),
|
||||
"category": _classify_endpoint(base, kind),
|
||||
"endpoint_kind": kind,
|
||||
}
|
||||
if provider == "anthropic":
|
||||
# Anthropic has no /models endpoint; just check connectivity
|
||||
try:
|
||||
t0 = _time.time()
|
||||
r = httpx.get(base.rstrip("/"), timeout=5)
|
||||
entry["latency_ms"] = round((_time.time() - t0) * 1000)
|
||||
entry["status"] = "online"
|
||||
entry["model_count"] = len(ANTHROPIC_MODELS)
|
||||
except Exception as e:
|
||||
entry["latency_ms"] = None
|
||||
entry["status"] = "offline"
|
||||
entry["error"] = str(e)
|
||||
entry["model_count"] = 0
|
||||
else:
|
||||
url = build_models_url(base)
|
||||
headers = build_headers(ep.api_key, base)
|
||||
try:
|
||||
t0 = _time.time()
|
||||
r = httpx.get(url, headers=headers, timeout=5)
|
||||
entry["latency_ms"] = round((_time.time() - t0) * 1000)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
models = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
|
||||
if not models:
|
||||
models = [
|
||||
m.get("name") or m.get("model")
|
||||
for m in (data.get("models") or [])
|
||||
if m.get("name") or m.get("model")
|
||||
]
|
||||
entry["status"] = "online"
|
||||
entry["model_count"] = len(models)
|
||||
except Exception as e:
|
||||
if "latency_ms" not in entry:
|
||||
entry["latency_ms"] = None
|
||||
entry["status"] = "offline"
|
||||
entry["error"] = str(e)
|
||||
entry["model_count"] = 0
|
||||
try:
|
||||
t0 = _time.time()
|
||||
ping = _ping_endpoint(base, ep.api_key, timeout=1.5)
|
||||
entry["latency_ms"] = round((_time.time() - t0) * 1000)
|
||||
entry["status"] = "online" if ping.get("reachable") or cached_count else "offline"
|
||||
entry["error"] = ping.get("error")
|
||||
entry["model_count"] = cached_count or (len(ANTHROPIC_MODELS) if provider == "anthropic" else 0)
|
||||
except Exception as e:
|
||||
entry["latency_ms"] = None
|
||||
entry["status"] = "online" if cached_count else "offline"
|
||||
entry["error"] = str(e)
|
||||
entry["model_count"] = cached_count
|
||||
results.append(entry)
|
||||
|
||||
return {"endpoints": results}
|
||||
@@ -1165,19 +1369,8 @@ def setup_model_routes(model_discovery):
|
||||
rows = db.query(ModelEndpoint).order_by(ModelEndpoint.created_at).all()
|
||||
results = []
|
||||
for r in rows:
|
||||
# Use cached model list to avoid slow probe on every load
|
||||
all_models = []
|
||||
if r.cached_models:
|
||||
try:
|
||||
all_models = json.loads(r.cached_models)
|
||||
except Exception:
|
||||
pass
|
||||
hidden = set()
|
||||
if r.hidden_models:
|
||||
try:
|
||||
hidden = set(json.loads(r.hidden_models))
|
||||
except Exception:
|
||||
pass
|
||||
all_models = _cached_model_ids(r)
|
||||
hidden = _hidden_model_ids(r)
|
||||
pinned = _normalize_model_ids(getattr(r, "pinned_models", None))
|
||||
visible = _visible_models(all_models, r.hidden_models, pinned)
|
||||
# Endpoint counts as reachable if it has any model — including
|
||||
@@ -1188,6 +1381,8 @@ def setup_model_routes(model_discovery):
|
||||
ping = _ping_endpoint(r.base_url, r.api_key, timeout=1.0)
|
||||
if ping.get("reachable"):
|
||||
status = "empty"
|
||||
base = _normalize_base(r.base_url)
|
||||
kind = _effective_endpoint_kind(r, base)
|
||||
results.append({
|
||||
"id": r.id,
|
||||
"name": r.name,
|
||||
@@ -1202,6 +1397,11 @@ def setup_model_routes(model_discovery):
|
||||
"ping_error": (ping or {}).get("error") if ping else None,
|
||||
"model_type": getattr(r, "model_type", None) or "llm",
|
||||
"supports_tools": getattr(r, "supports_tools", None),
|
||||
"endpoint_kind": kind,
|
||||
"category": _classify_endpoint(base, kind),
|
||||
"model_refresh_mode": _endpoint_refresh_mode(r, kind),
|
||||
"model_refresh_interval": getattr(r, "model_refresh_interval", None),
|
||||
"model_refresh_timeout": getattr(r, "model_refresh_timeout", None),
|
||||
})
|
||||
return results
|
||||
finally:
|
||||
@@ -1216,6 +1416,10 @@ def setup_model_routes(model_discovery):
|
||||
skip_probe: str = Form("false"),
|
||||
require_models: str = Form("false"),
|
||||
model_type: str = Form("llm"),
|
||||
endpoint_kind: str = Form("auto"),
|
||||
model_refresh_mode: str = Form(""),
|
||||
model_refresh_interval: str = Form(""),
|
||||
model_refresh_timeout: str = Form(""),
|
||||
supports_tools: str = Form(""), # "true"/"false"/"" (unknown)
|
||||
pinned_models: str = Form(""), # admin-pinned IDs: list/JSON/comma/newline
|
||||
container_local: str = Form("false"),
|
||||
@@ -1240,8 +1444,15 @@ def setup_model_routes(model_discovery):
|
||||
if not name.strip():
|
||||
name = base_url.replace("http://", "").replace("https://", "").split("/")[0]
|
||||
|
||||
requested_kind = _normalize_endpoint_kind(endpoint_kind)
|
||||
refresh_mode = _normalize_refresh_mode(model_refresh_mode, requested_kind)
|
||||
refresh_interval = _parse_positive_int(model_refresh_interval, minimum=30, maximum=86400)
|
||||
refresh_timeout = _parse_positive_int(model_refresh_timeout, minimum=1, maximum=60)
|
||||
require_model_list = _truthy(require_models)
|
||||
should_probe = require_model_list or not _truthy(skip_probe)
|
||||
should_probe = (
|
||||
require_model_list or requested_kind in ("api", "proxy") or not _truthy(skip_probe)
|
||||
)
|
||||
explicit_timeout = _explicit_model_list_timeout(base_url, requested_kind, refresh_timeout)
|
||||
|
||||
# Dedupe: if an endpoint with the same base_url already exists and
|
||||
# is reachable by the caller (shared or owned by them), return it
|
||||
@@ -1259,6 +1470,7 @@ def setup_model_routes(model_discovery):
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
changed = False
|
||||
# Persist any incoming pinned IDs onto the existing row. An
|
||||
# empty/omitted form field must not wipe previously pinned IDs.
|
||||
_incoming_pinned = _normalize_model_ids(pinned_models)
|
||||
@@ -1268,15 +1480,45 @@ def setup_model_routes(model_discovery):
|
||||
_incoming_pinned,
|
||||
)
|
||||
existing.pinned_models = json.dumps(_merged_pinned) if _merged_pinned else None
|
||||
changed = True
|
||||
existing_kind_for_probe = requested_kind if requested_kind != "auto" else _effective_endpoint_kind(existing, base_url)
|
||||
if requested_kind != "auto" and _endpoint_kind(existing) == "auto":
|
||||
existing.endpoint_kind = requested_kind
|
||||
changed = True
|
||||
if model_refresh_mode or (requested_kind == "proxy" and _endpoint_refresh_mode(existing, requested_kind) != refresh_mode):
|
||||
existing.model_refresh_mode = refresh_mode
|
||||
changed = True
|
||||
if refresh_interval is not None:
|
||||
existing.model_refresh_interval = refresh_interval
|
||||
changed = True
|
||||
if refresh_timeout is not None:
|
||||
existing.model_refresh_timeout = refresh_timeout
|
||||
changed = True
|
||||
if api_key.strip() and not existing.api_key:
|
||||
existing.api_key = api_key.strip()
|
||||
changed = True
|
||||
if should_probe:
|
||||
probed_models = _probe_endpoint(
|
||||
base_url,
|
||||
(api_key.strip() or existing.api_key or None),
|
||||
timeout=_explicit_model_list_timeout(base_url, existing_kind_for_probe, refresh_timeout),
|
||||
)
|
||||
if probed_models:
|
||||
existing.cached_models = json.dumps(probed_models)
|
||||
changed = True
|
||||
if changed:
|
||||
_db_dedup.commit()
|
||||
_invalidate_models_cache()
|
||||
_local_probe_cache["data"] = None
|
||||
existing_models = _cached_model_ids(existing)
|
||||
_existing_pinned = _normalize_model_ids(getattr(existing, "pinned_models", None))
|
||||
existing_kind = _effective_endpoint_kind(existing, existing.base_url)
|
||||
return {
|
||||
"id": existing.id,
|
||||
"name": existing.name,
|
||||
"base_url": existing.base_url,
|
||||
"models": _visible_models(
|
||||
getattr(existing, "cached_models", None),
|
||||
existing_models,
|
||||
getattr(existing, "hidden_models", None),
|
||||
existing.pinned_models,
|
||||
),
|
||||
@@ -1284,16 +1526,16 @@ def setup_model_routes(model_discovery):
|
||||
"online": True,
|
||||
"status": "online",
|
||||
"existing": True,
|
||||
"endpoint_kind": existing_kind,
|
||||
"category": _classify_endpoint(existing.base_url, existing_kind),
|
||||
}
|
||||
finally:
|
||||
_db_dedup.close()
|
||||
|
||||
# Quick model list fetch (1s timeout — if endpoint is slow, it'll update on next refresh)
|
||||
_probe_timeout = 3 if (":11434" in base_url or "ollama" in base_url.lower()) else 1
|
||||
model_ids = _probe_endpoint(base_url, api_key.strip() or None, timeout=_probe_timeout) if should_probe else []
|
||||
model_ids = _probe_endpoint(base_url, api_key.strip() or None, timeout=explicit_timeout) if should_probe else []
|
||||
ping = {"reachable": False, "error": None}
|
||||
if should_probe and not model_ids:
|
||||
ping = _ping_endpoint(base_url, api_key.strip() or None, timeout=_probe_timeout)
|
||||
if (should_probe or requested_kind in ("api", "proxy")) and not model_ids:
|
||||
ping = _ping_endpoint(base_url, api_key.strip() or None, timeout=min(explicit_timeout, 2.0))
|
||||
if require_model_list and not model_ids:
|
||||
raise HTTPException(400, _model_endpoint_error_message(base_url, ping))
|
||||
|
||||
@@ -1317,6 +1559,10 @@ def setup_model_routes(model_discovery):
|
||||
api_key=api_key.strip() or None,
|
||||
is_enabled=True,
|
||||
model_type=model_type.strip() if model_type else "llm",
|
||||
endpoint_kind=requested_kind,
|
||||
model_refresh_mode=refresh_mode,
|
||||
model_refresh_interval=refresh_interval,
|
||||
model_refresh_timeout=refresh_timeout,
|
||||
cached_models=json.dumps(model_ids) if model_ids else None,
|
||||
pinned_models=json.dumps(_pinned) if _pinned else None,
|
||||
supports_tools=_st,
|
||||
@@ -1349,6 +1595,8 @@ def setup_model_routes(model_discovery):
|
||||
"online": bool(model_ids) or bool(_pinned) or bool(ping.get("reachable")),
|
||||
"status": "online" if (model_ids or _pinned) else ("empty" if ping.get("reachable") else "offline"),
|
||||
"ping_error": ping.get("error") if ping else None,
|
||||
"endpoint_kind": requested_kind,
|
||||
"category": _classify_endpoint(base_url, requested_kind),
|
||||
}
|
||||
|
||||
@router.post("/model-endpoints/test")
|
||||
@@ -1356,6 +1604,8 @@ def setup_model_routes(model_discovery):
|
||||
request: Request,
|
||||
base_url: str = Form(...),
|
||||
api_key: str = Form(""),
|
||||
endpoint_kind: str = Form("auto"),
|
||||
model_refresh_timeout: str = Form(""),
|
||||
):
|
||||
require_admin(request)
|
||||
base_url = _normalize_base(base_url)
|
||||
@@ -1364,9 +1614,11 @@ def setup_model_routes(model_discovery):
|
||||
from src.endpoint_resolver import resolve_url
|
||||
base_url = resolve_url(base_url)
|
||||
base_url = _rewrite_loopback_for_docker(base_url)
|
||||
probe_timeout = 3 if (":11434" in base_url or "ollama" in base_url.lower()) else 2
|
||||
requested_kind = _normalize_endpoint_kind(endpoint_kind)
|
||||
configured_timeout = _parse_positive_int(model_refresh_timeout, minimum=1, maximum=60)
|
||||
probe_timeout = _explicit_model_list_timeout(base_url, requested_kind, configured_timeout)
|
||||
models = _probe_endpoint(base_url, api_key.strip() or None, timeout=probe_timeout)
|
||||
ping = {"reachable": True, "error": None} if models else _ping_endpoint(base_url, api_key.strip() or None, timeout=probe_timeout)
|
||||
ping = {"reachable": True, "error": None} if models else _ping_endpoint(base_url, api_key.strip() or None, timeout=min(probe_timeout, 2.0))
|
||||
return {
|
||||
"base_url": base_url,
|
||||
"online": bool(models) or bool(ping.get("reachable")),
|
||||
@@ -1374,6 +1626,8 @@ def setup_model_routes(model_discovery):
|
||||
"ping_error": ping.get("error") if ping else None,
|
||||
"models": models,
|
||||
"count": len(models),
|
||||
"endpoint_kind": requested_kind,
|
||||
"category": _classify_endpoint(base_url, requested_kind),
|
||||
}
|
||||
|
||||
@router.get("/model-endpoints/{ep_id}/probe")
|
||||
@@ -1415,7 +1669,8 @@ def setup_model_routes(model_discovery):
|
||||
ep_obj = db2.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
|
||||
if ep_obj:
|
||||
ep_obj.hidden_models = json.dumps(failed) if failed else None
|
||||
ep_obj.cached_models = json.dumps(all_models) if all_models else None
|
||||
if all_models:
|
||||
ep_obj.cached_models = json.dumps(all_models)
|
||||
db2.commit()
|
||||
finally:
|
||||
db2.close()
|
||||
@@ -1426,7 +1681,13 @@ def setup_model_routes(model_discovery):
|
||||
return StreamingResponse(_stream(), media_type="text/event-stream")
|
||||
|
||||
@router.get("/model-endpoints/{ep_id}/models")
|
||||
def list_endpoint_models(ep_id: str, request: Request):
|
||||
def list_endpoint_models(
|
||||
ep_id: str,
|
||||
request: Request,
|
||||
response: Response,
|
||||
refresh: bool = False,
|
||||
refresh_timeout: Optional[int] = Query(None, ge=1, le=60),
|
||||
):
|
||||
"""List all discovered models for an endpoint with hidden/visible state."""
|
||||
require_admin(request)
|
||||
db = SessionLocal()
|
||||
@@ -1434,23 +1695,28 @@ def setup_model_routes(model_discovery):
|
||||
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
|
||||
if not ep:
|
||||
raise HTTPException(404, "Endpoint not found")
|
||||
hidden = set()
|
||||
if ep.hidden_models:
|
||||
hidden = _hidden_model_ids(ep)
|
||||
all_models = _cached_model_ids(ep)
|
||||
if refresh:
|
||||
base = _normalize_base(ep.base_url)
|
||||
kind = _effective_endpoint_kind(ep, base)
|
||||
category = _classify_endpoint(base, kind)
|
||||
timeout = _manual_refresh_timeout(ep, category, refresh_timeout)
|
||||
try:
|
||||
hidden = set(json.loads(ep.hidden_models))
|
||||
except Exception:
|
||||
pass
|
||||
# Try live probe, fall back to cached. Pinned IDs are admin-entered
|
||||
# and persist regardless of probe results — never overwritten here.
|
||||
all_models = _probe_endpoint(ep.base_url, ep.api_key, timeout=3)
|
||||
if all_models:
|
||||
ep.cached_models = json.dumps(all_models)
|
||||
db.commit()
|
||||
elif ep.cached_models:
|
||||
try:
|
||||
all_models = json.loads(ep.cached_models)
|
||||
except Exception:
|
||||
pass
|
||||
probed = _probe_endpoint(base, ep.api_key, timeout=timeout)
|
||||
except Exception as exc:
|
||||
logger.warning("Manual model refresh failed for endpoint %s at %s: %s", ep_id, base, exc)
|
||||
probed = []
|
||||
if probed:
|
||||
all_models = probed
|
||||
ep.cached_models = json.dumps(all_models)
|
||||
db.commit()
|
||||
_invalidate_models_cache()
|
||||
response.headers["X-Model-Refresh-Status"] = "refreshed"
|
||||
response.headers["X-Model-Refresh-Count"] = str(len(probed))
|
||||
else:
|
||||
response.headers["X-Model-Refresh-Status"] = "failed"
|
||||
response.headers["X-Model-Refresh-Warning"] = "Model refresh failed or returned no models; kept cached models."
|
||||
pinned = _normalize_model_ids(getattr(ep, "pinned_models", None))
|
||||
pinned_set = set(pinned)
|
||||
return [
|
||||
@@ -1502,7 +1768,6 @@ def setup_model_routes(model_discovery):
|
||||
|
||||
@router.get("/default-chat")
|
||||
def get_default_chat(request: Request):
|
||||
import json as _json
|
||||
# SECURITY: resolve the default endpoint + model from the CALLER's
|
||||
# per-user prefs ONLY. We deliberately do NOT fall back to the
|
||||
# global `default_model` / `default_endpoint_id` in settings.json
|
||||
@@ -1635,6 +1900,16 @@ def setup_model_routes(model_discovery):
|
||||
if "pinned_models" in body:
|
||||
_pinned = _normalize_model_ids(body["pinned_models"])
|
||||
ep.pinned_models = json.dumps(_pinned) if _pinned else None
|
||||
if "endpoint_kind" in body:
|
||||
ep.endpoint_kind = _normalize_endpoint_kind(body.get("endpoint_kind"))
|
||||
if "model_refresh_mode" in body:
|
||||
ep.model_refresh_mode = _normalize_refresh_mode(body.get("model_refresh_mode"), _endpoint_kind(ep))
|
||||
if "model_refresh_interval" in body:
|
||||
interval = _parse_positive_int(body.get("model_refresh_interval"), minimum=30, maximum=86400)
|
||||
ep.model_refresh_interval = interval
|
||||
if "model_refresh_timeout" in body:
|
||||
timeout = _parse_positive_int(body.get("model_refresh_timeout"), minimum=1, maximum=60)
|
||||
ep.model_refresh_timeout = timeout
|
||||
# Rotating an API key used to require DELETE+POST, which wiped
|
||||
# endpoint_url/model from every session referencing the old base
|
||||
# URL. Allow in-place updates so the admin can change the key
|
||||
@@ -1664,6 +1939,10 @@ def setup_model_routes(model_discovery):
|
||||
"model_type": ep.model_type,
|
||||
"base_url": ep.base_url,
|
||||
"pinned_models": _normalize_model_ids(getattr(ep, "pinned_models", None)),
|
||||
"endpoint_kind": getattr(ep, "endpoint_kind", None) or "auto",
|
||||
"model_refresh_mode": getattr(ep, "model_refresh_mode", None) or "auto",
|
||||
"model_refresh_interval": getattr(ep, "model_refresh_interval", None),
|
||||
"model_refresh_timeout": getattr(ep, "model_refresh_timeout", None),
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -19,9 +19,13 @@ def _load():
|
||||
|
||||
|
||||
def _save(prefs):
|
||||
os.makedirs(os.path.dirname(PREFS_FILE), exist_ok=True)
|
||||
with open(PREFS_FILE, "w", encoding="utf-8") as f:
|
||||
os.makedirs(os.path.dirname(PREFS_FILE) or ".", exist_ok=True)
|
||||
tmp = f"{PREFS_FILE}.tmp.{os.getpid()}"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(prefs, f, indent=2)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.replace(tmp, PREFS_FILE)
|
||||
|
||||
|
||||
def _load_for_user(user: Optional[str] = None) -> dict:
|
||||
|
||||
@@ -21,6 +21,22 @@ def _sanitize_export_filename(name: str) -> str:
|
||||
return name[:128]
|
||||
|
||||
|
||||
# Blind-compare helper sessions are created with this name prefix. Their real
|
||||
# model must never surface in the session list / sidebar — otherwise a blind
|
||||
# comparison can be de-anonymized before the user votes (issue #1285).
|
||||
COMPARE_SESSION_PREFIX = "[CMP] "
|
||||
|
||||
|
||||
def _public_model(name: str, model: str) -> str:
|
||||
"""Blank out the real model of blind-compare helper sessions so the
|
||||
session list can't be used to map a neutral pane label ("Model A") back
|
||||
to its model. The Compare UI tracks models client-side, so hiding it here
|
||||
costs the sidebar nothing. See issue #1285."""
|
||||
if (name or "").startswith(COMPARE_SESSION_PREFIX):
|
||||
return ""
|
||||
return model
|
||||
|
||||
|
||||
def _verify_session_owner(request: Request, session_id: str, session_manager=None):
|
||||
"""Verify the current user owns the session. Raises 404 if not.
|
||||
|
||||
@@ -215,7 +231,7 @@ def setup_session_routes(session_manager: SessionManager, config: dict, webhook_
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
sessions = [{"id": s.id, "name": s.name, "model": s.model,
|
||||
sessions = [{"id": s.id, "name": s.name, "model": _public_model(s.name, s.model),
|
||||
"endpoint_url": s.endpoint_url, "rag": s.rag,
|
||||
"archived": s.archived, "folder": folder_map.get(s.id),
|
||||
"total_tokens": token_map.get(s.id, 0),
|
||||
|
||||
@@ -280,13 +280,15 @@ def rank_image_models(system, search=None, sort="fit"):
|
||||
|
||||
Returns list of models with fit info (vram needed, fits, recommended quant).
|
||||
"""
|
||||
if not isinstance(system, dict):
|
||||
system = {}
|
||||
gpu_vram = system.get("gpu_vram_gb", 0) or 0
|
||||
has_gpu = system.get("has_gpu", False)
|
||||
results = []
|
||||
|
||||
for model in IMAGE_MODEL_REGISTRY:
|
||||
# Filter by search
|
||||
if search:
|
||||
if isinstance(search, str) and search:
|
||||
s = search.lower()
|
||||
if s not in model["name"].lower() and s not in model["id"].lower() and s not in model.get("description", "").lower():
|
||||
continue
|
||||
|
||||
+7
-361
@@ -1,364 +1,10 @@
|
||||
"""Compatibility import for the canonical memory manager.
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
import re
|
||||
from typing import List, Dict, Tuple
|
||||
from datetime import datetime
|
||||
Historically this package carried a second copy of ``MemoryManager``. The
|
||||
application runtime instantiates ``src.memory.MemoryManager``, so keeping a
|
||||
parallel implementation here risks silent drift between import paths.
|
||||
"""
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from src.memory import MemoryManager, get_text_similarity, tokenize
|
||||
|
||||
def tokenize(text: str) -> List[str]:
|
||||
"""Simple tokenizer that splits on whitespace and removes punctuation."""
|
||||
return [cleaned for word in text.split() if (cleaned := word.strip('.,!?";'))]
|
||||
|
||||
def get_text_similarity(text1: str, text2: str) -> float:
|
||||
"""Calculate Jaccard similarity between two texts."""
|
||||
if not text1 or not text2:
|
||||
return 0.0
|
||||
|
||||
tokens1 = set(tokenize(text1.lower()))
|
||||
tokens2 = set(tokenize(text2.lower()))
|
||||
|
||||
if not tokens1 and not tokens2:
|
||||
return 1.0
|
||||
if not tokens1 or not tokens2:
|
||||
return 0.0
|
||||
|
||||
intersection = tokens1.intersection(tokens2)
|
||||
union = tokens1.union(tokens2)
|
||||
|
||||
return len(intersection) / len(union)
|
||||
|
||||
class MemoryManager:
|
||||
def __init__(self, data_dir: str):
|
||||
self.memory_file = os.path.join(data_dir, "memory.json")
|
||||
self.ensure_file_exists()
|
||||
|
||||
def extract_memory_from_chat(self, chat_history: List[Dict], session_id: str = None) -> List[Dict]:
|
||||
"""
|
||||
Extract memory entries from chat history as a fallback when LLM fails.
|
||||
|
||||
Args:
|
||||
chat_history: List of chat messages with 'role' and 'content' keys
|
||||
session_id: Optional session ID to associate with extracted memories
|
||||
|
||||
Returns:
|
||||
List of memory entries with text, timestamp, and optional session_id
|
||||
"""
|
||||
memories = []
|
||||
|
||||
for msg in chat_history:
|
||||
if msg.get("role") == "assistant":
|
||||
content = str(msg.get("content", ""))
|
||||
lines = content.split('\n')
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
# Look for bullet points or numbered lists that might contain memories
|
||||
if re.match(r'^[-*•]|\d+\.', line):
|
||||
# Extract the text after the bullet/number. Group both
|
||||
# markers so the capture applies to either. The previous
|
||||
# `^[-*•]|\d+\.\s*(.*)` put the group on the numbered
|
||||
# branch only, so a bullet line matched with group(1)=None
|
||||
# and crashed on .strip().
|
||||
text_match = re.match(r'^(?:[-*•]|\d+\.)\s*(.*)', line)
|
||||
if text_match:
|
||||
text = text_match.group(1).strip()
|
||||
if text:
|
||||
memories.append({
|
||||
"text": text,
|
||||
"timestamp": int(time.time()),
|
||||
"session_id": session_id
|
||||
})
|
||||
# If we see a heading that suggests memories
|
||||
elif re.search(r'memory|fact|note|remember', line, re.I):
|
||||
pass
|
||||
# If we see a clear separator or end
|
||||
elif re.match(r'^={3,}|-{3,}|_{3,}', line):
|
||||
pass
|
||||
|
||||
return memories
|
||||
|
||||
def process_inline_memory_command(self, message: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Check if a message is an inline memory command (e.g. "remember: X").
|
||||
|
||||
Args:
|
||||
message: The user message to check
|
||||
|
||||
Returns:
|
||||
Tuple of (is_command, extracted_text) where is_command is True if
|
||||
the message matches the memory command pattern
|
||||
"""
|
||||
# Pattern for memory commands: "remember: X", "memorize: X", "save: X", etc.
|
||||
pattern = r'^(?:remember|memorize|save|note|store)[:\-]?\s+(.+)$'
|
||||
match = re.match(pattern, message.strip(), re.IGNORECASE)
|
||||
|
||||
if match:
|
||||
return True, match.group(1).strip()
|
||||
else:
|
||||
return False, ""
|
||||
|
||||
def ensure_file_exists(self):
|
||||
"""Create memory file if it doesn't exist."""
|
||||
if not os.path.exists(self.memory_file):
|
||||
os.makedirs(os.path.dirname(self.memory_file), exist_ok=True)
|
||||
with open(self.memory_file, 'w', encoding='utf-8') as f:
|
||||
json.dump([], f, ensure_ascii=False, indent=2)
|
||||
|
||||
def load_all(self) -> List[Dict]:
|
||||
"""Load all memory entries from JSON file (unfiltered)."""
|
||||
if not os.path.exists(self.memory_file):
|
||||
return []
|
||||
|
||||
try:
|
||||
with open(self.memory_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if isinstance(data, list):
|
||||
return self._validate_entries(data)
|
||||
except (json.JSONDecodeError, PermissionError) as e:
|
||||
logger.error("Error loading memory.json: %s", e)
|
||||
return self._migrate_from_legacy()
|
||||
|
||||
return []
|
||||
|
||||
def load(self, owner: str = None) -> List[Dict]:
|
||||
"""Load memory entries, filtered by owner."""
|
||||
entries = self.load_all()
|
||||
if owner is None:
|
||||
return entries
|
||||
return [e for e in entries if e.get("owner") == owner]
|
||||
|
||||
def claim_ownerless(self, owner: str):
|
||||
"""Assign all ownerless memory entries to the given owner. Run once to migrate."""
|
||||
entries = self.load_all()
|
||||
changed = False
|
||||
for e in entries:
|
||||
if not e.get("owner"):
|
||||
e["owner"] = owner
|
||||
changed = True
|
||||
if changed:
|
||||
self.save(entries)
|
||||
logger.info("Claimed %d ownerless memories for %s", sum(1 for e in entries if e.get("owner") == owner), owner)
|
||||
|
||||
def _validate_entries(self, entries: List[Dict]) -> List[Dict]:
|
||||
"""Ensure all entries have required fields."""
|
||||
validated = []
|
||||
for entry in entries:
|
||||
if "id" not in entry:
|
||||
entry["id"] = str(uuid.uuid4())
|
||||
if "timestamp" not in entry:
|
||||
entry["timestamp"] = int(time.time())
|
||||
if "source" not in entry:
|
||||
entry["source"] = "unknown"
|
||||
if "category" not in entry:
|
||||
entry["category"] = "fact"
|
||||
validated.append(entry)
|
||||
return validated
|
||||
|
||||
def _migrate_from_legacy(self) -> List[Dict]:
|
||||
"""Migrate from old text format to JSON if needed."""
|
||||
legacy_path = os.path.join(os.path.dirname(self.memory_file), "memory.txt")
|
||||
if not os.path.exists(legacy_path):
|
||||
return []
|
||||
|
||||
logger.info("Converting legacy memory.txt to new JSON format")
|
||||
try:
|
||||
with open(legacy_path, "r", encoding="utf-8") as f:
|
||||
lines = [ln.strip() for ln in f.readlines() if ln.strip()]
|
||||
|
||||
entries = []
|
||||
for line in lines:
|
||||
entries.append({
|
||||
"id": str(uuid.uuid4()),
|
||||
"text": line,
|
||||
"timestamp": int(time.time()),
|
||||
"source": "user",
|
||||
"category": "fact"
|
||||
})
|
||||
|
||||
self.save(entries)
|
||||
return entries
|
||||
except Exception as e:
|
||||
logger.error("Failed to convert legacy memory: %s", e)
|
||||
return []
|
||||
|
||||
def save(self, entries: List[Dict]):
|
||||
"""Save memory entries to JSON file."""
|
||||
# Validate entries before saving
|
||||
for entry in entries:
|
||||
if "id" not in entry:
|
||||
entry["id"] = str(uuid.uuid4())
|
||||
if "timestamp" not in entry:
|
||||
entry["timestamp"] = int(time.time())
|
||||
if "source" not in entry:
|
||||
entry["source"] = "user"
|
||||
if "category" not in entry:
|
||||
entry["category"] = "fact"
|
||||
|
||||
# Use atomic write
|
||||
tmp_file = self.memory_file + ".tmp"
|
||||
with open(tmp_file, "w", encoding="utf-8") as f:
|
||||
json.dump(entries, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp_file, self.memory_file)
|
||||
|
||||
def add_entry(self, text: str, source: str = "user", category: str = "fact", owner: str = None) -> Dict:
|
||||
"""Add a new memory entry."""
|
||||
if not text.strip():
|
||||
raise ValueError("Memory text cannot be empty")
|
||||
|
||||
entry = {
|
||||
"id": str(uuid.uuid4()),
|
||||
"text": text.strip(),
|
||||
"timestamp": int(time.time()),
|
||||
"source": source,
|
||||
"category": category
|
||||
}
|
||||
if owner:
|
||||
entry["owner"] = owner
|
||||
return entry
|
||||
|
||||
def find_duplicates(self, text: str, entries: List[Dict] = None) -> List[Dict]:
|
||||
"""Find duplicate memory entries based on text content."""
|
||||
if entries is None:
|
||||
entries = self.load()
|
||||
|
||||
text_lower = text.strip().lower()
|
||||
return [entry for entry in entries if entry["text"].lower() == text_lower]
|
||||
|
||||
def categorize_memory_by_relevance(self, message: str, memories: list):
|
||||
"""Categorize memories by type and relevance"""
|
||||
categories = {
|
||||
"contacts": [],
|
||||
"preferences": [],
|
||||
"facts": [],
|
||||
"tasks": []
|
||||
}
|
||||
|
||||
msg_lower = message.lower()
|
||||
|
||||
for mem in memories:
|
||||
text_lower = mem["text"].lower()
|
||||
|
||||
# Contact info
|
||||
if any(word in text_lower for word in ["phone", "email", "address", "lives", "works"]):
|
||||
if any(word in msg_lower for word in ["contact", "phone", "address", "email"]):
|
||||
categories["contacts"].append(mem)
|
||||
|
||||
# Personal preferences
|
||||
elif any(word in text_lower for word in ["likes", "dislikes", "prefers", "favorite"]):
|
||||
if any(word in msg_lower for word in ["like", "prefer", "favorite", "want"]):
|
||||
categories["preferences"].append(mem)
|
||||
|
||||
# Tasks and todos
|
||||
elif any(word in text_lower for word in ["todo", "task", "remind", "meeting"]):
|
||||
if any(word in msg_lower for word in ["todo", "task", "schedule", "remind"]):
|
||||
categories["tasks"].append(mem)
|
||||
|
||||
# General facts - only if very relevant
|
||||
else:
|
||||
if get_text_similarity(message, mem["text"]) > 0.4:
|
||||
categories["facts"].append(mem)
|
||||
|
||||
return categories
|
||||
|
||||
def get_relevant_memories(self, query: str, memories: list, threshold: float = 0.05, max_items: int = 8):
|
||||
"""Get memories that are relevant to the query based on text similarity and semantic keyword matching."""
|
||||
if not memories or not query.strip():
|
||||
return []
|
||||
|
||||
# Define keyword categories for semantic matching
|
||||
identity_words = ["name", "who", "i", "am", "called", "identity", "myself", "me", "my"]
|
||||
contact_words = ["phone", "email", "address", "contact", "number", "where", "located", "reach"]
|
||||
preference_words = ["like", "prefer", "favorite", "want", "love", "hate", "dislike", "enjoy", "interested"]
|
||||
task_words = ["todo", "task", "remind", "meeting", "appointment", "schedule", "deadline"]
|
||||
fact_words = ["what", "when", "where", "how", "why", "explain", "describe", "information", "know"]
|
||||
|
||||
query_lower = query.lower()
|
||||
|
||||
# Determine query type based on keywords
|
||||
query_type = None
|
||||
if any(word in query_lower for word in identity_words):
|
||||
query_type = "identity"
|
||||
elif any(word in query_lower for word in contact_words):
|
||||
query_type = "contact"
|
||||
elif any(word in query_lower for word in preference_words):
|
||||
query_type = "preference"
|
||||
elif any(word in query_lower for word in task_words):
|
||||
query_type = "task"
|
||||
elif any(word in query_lower for word in fact_words):
|
||||
query_type = "fact"
|
||||
|
||||
relevant = []
|
||||
identity_memories = []
|
||||
other_memories = []
|
||||
|
||||
# Separate identity memories from others
|
||||
for memory in memories:
|
||||
memory_text = memory["text"].lower()
|
||||
# Check if this is an identity memory (contains name patterns or identity indicators)
|
||||
is_identity = any([
|
||||
re.search(r'\b[A-Z][a-z]+ [A-Z][a-z]+\b', memory["text"]),
|
||||
any(word in memory_text for word in ["name is", "i'm", "i am", "called", "my name", "named", "call me"])
|
||||
])
|
||||
if is_identity:
|
||||
identity_memories.append(memory)
|
||||
else:
|
||||
other_memories.append(memory)
|
||||
|
||||
# For identity queries, include all identity memories regardless of similarity
|
||||
if query_type == "identity" and identity_memories:
|
||||
# Give them high scores to ensure they're included first
|
||||
for memory in identity_memories:
|
||||
relevant.append((0.9, memory)) # High score for identity memories in identity queries
|
||||
|
||||
# Process other memories with similarity scoring
|
||||
for memory in other_memories:
|
||||
memory_text = memory["text"].lower()
|
||||
memory_tokens = set(tokenize(memory_text))
|
||||
query_tokens = set(tokenize(query_lower))
|
||||
|
||||
# Calculate base Jaccard similarity
|
||||
if not query_tokens or not memory_tokens:
|
||||
continue
|
||||
|
||||
base_similarity = len(query_tokens & memory_tokens) / len(query_tokens | memory_tokens)
|
||||
final_score = base_similarity
|
||||
|
||||
# Apply boosts based on semantic matching
|
||||
if query_type == "contact":
|
||||
# Boost memories with contact information
|
||||
has_contact_info = any(word in memory_text for word in ["@gmail.com", "@", ".com",
|
||||
"phone", "number", "address",
|
||||
"http", "www", "tel:"])
|
||||
if has_contact_info:
|
||||
final_score *= 1.4 # 40% boost for contact-related memories
|
||||
|
||||
elif query_type == "preference":
|
||||
# Boost memories with preference indicators
|
||||
has_preference = any(word in memory_text for word in ["like", "love", "hate", "dislike",
|
||||
"prefer", "favorite", "enjoy", "interested"])
|
||||
if has_preference:
|
||||
final_score *= 1.3 # 30% boost for preference-related memories
|
||||
|
||||
elif query_type == "task":
|
||||
# Boost memories with task indicators
|
||||
has_task = any(word in memory_text for word in ["todo", "task", "remind", "meeting",
|
||||
"appointment", "schedule", "deadline", "need to"])
|
||||
if has_task:
|
||||
final_score *= 1.3 # 30% boost for task-related memories
|
||||
|
||||
# Always consider exact phrase matches as highly relevant
|
||||
if query.lower() in memory["text"].lower():
|
||||
final_score = max(final_score, 0.8) # Ensure high relevance for exact matches
|
||||
|
||||
# Include memory if it meets threshold after boosts
|
||||
if final_score >= threshold:
|
||||
relevant.append((final_score, memory))
|
||||
|
||||
# Sort by final score (descending) and return top matches
|
||||
relevant.sort(key=lambda x: x[0], reverse=True)
|
||||
return [mem for _, mem in relevant[:max_items]]
|
||||
__all__ = ["MemoryManager", "get_text_similarity", "tokenize"]
|
||||
|
||||
@@ -1,175 +1,5 @@
|
||||
"""
|
||||
memory_vector.py
|
||||
"""Compatibility import for the canonical memory vector store."""
|
||||
|
||||
ChromaDB-backed vector store for memory entries.
|
||||
Shares the EmbeddingClient with RAG to save memory.
|
||||
Stores pre-computed embeddings (ChromaDB does not manage embedding).
|
||||
"""
|
||||
from src.memory_vector import MemoryVectorStore
|
||||
|
||||
import logging
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MemoryVectorStore:
|
||||
"""Vector index over memory entries for semantic retrieval."""
|
||||
|
||||
COLLECTION_NAME = "odysseus_memories"
|
||||
|
||||
def __init__(self, data_dir: str, embedding_model=None):
|
||||
self._model = embedding_model
|
||||
self._collection = None
|
||||
self._healthy = False
|
||||
|
||||
self._initialize()
|
||||
|
||||
def _initialize(self):
|
||||
try:
|
||||
from src.chroma_client import get_chroma_client
|
||||
|
||||
if self._model is None:
|
||||
from src.embeddings import get_embedding_client
|
||||
self._model = get_embedding_client()
|
||||
if self._model is None:
|
||||
raise RuntimeError("No embedding backend available")
|
||||
logger.info(f"MemoryVectorStore using embeddings: {self._model.url}")
|
||||
|
||||
client = get_chroma_client()
|
||||
self._collection = client.get_or_create_collection(
|
||||
name=self.COLLECTION_NAME,
|
||||
metadata={"hnsw:space": "cosine"},
|
||||
)
|
||||
|
||||
self._healthy = True
|
||||
count = self._collection.count()
|
||||
logger.info(f"MemoryVectorStore ready (entries={count})")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"MemoryVectorStore init failed: {e}")
|
||||
|
||||
@property
|
||||
def healthy(self) -> bool:
|
||||
return self._healthy
|
||||
|
||||
def _embed(self, texts: List[str]) -> List[List[float]]:
|
||||
vecs = self._model.encode(texts, normalize_embeddings=True)
|
||||
return vecs.tolist()
|
||||
|
||||
def count(self) -> int:
|
||||
"""Return the number of stored vectors."""
|
||||
if not self._healthy:
|
||||
return 0
|
||||
return self._collection.count()
|
||||
|
||||
def add(self, memory_id: str, text: str):
|
||||
"""Add a single memory entry to the vector index."""
|
||||
if not self._healthy:
|
||||
return
|
||||
# Skip if already exists
|
||||
existing = self._collection.get(ids=[memory_id])
|
||||
if existing["ids"]:
|
||||
return
|
||||
embeddings = self._embed([text])
|
||||
self._collection.add(
|
||||
ids=[memory_id],
|
||||
embeddings=embeddings,
|
||||
documents=[text],
|
||||
metadatas=[{"source": "memory"}],
|
||||
)
|
||||
|
||||
def remove(self, memory_id: str):
|
||||
"""Remove a memory entry. O(1) — no rebuild needed."""
|
||||
if not self._healthy:
|
||||
return
|
||||
try:
|
||||
self._collection.delete(ids=[memory_id])
|
||||
except Exception as e:
|
||||
logger.warning(f"memory remove {memory_id}: {e}")
|
||||
|
||||
def search(self, query: str, k: int = 8) -> List[Dict]:
|
||||
"""Search for the most relevant memory IDs by semantic similarity.
|
||||
Returns list of {"memory_id": str, "score": float}.
|
||||
|
||||
ChromaDB cosine distance = 1 - cosine_similarity.
|
||||
We convert back: similarity = 1.0 - distance.
|
||||
"""
|
||||
if not self._healthy or self._collection.count() == 0:
|
||||
return []
|
||||
|
||||
embeddings = self._embed([query])
|
||||
actual_k = min(k, self._collection.count())
|
||||
results = self._collection.query(
|
||||
query_embeddings=embeddings,
|
||||
n_results=actual_k,
|
||||
)
|
||||
|
||||
out = []
|
||||
for idx, mid in enumerate(results["ids"][0]):
|
||||
distance = results["distances"][0][idx]
|
||||
out.append({
|
||||
"memory_id": mid,
|
||||
"score": round(1.0 - distance, 4),
|
||||
})
|
||||
return out
|
||||
|
||||
def find_similar(self, text: str, threshold: float = 0.92) -> Optional[str]:
|
||||
"""Check if a near-duplicate exists. Returns memory_id if found, else None."""
|
||||
if not self._healthy or self._collection.count() == 0:
|
||||
return None
|
||||
|
||||
embeddings = self._embed([text])
|
||||
results = self._collection.query(
|
||||
query_embeddings=embeddings,
|
||||
n_results=1,
|
||||
)
|
||||
|
||||
if results["ids"][0]:
|
||||
distance = results["distances"][0][0]
|
||||
similarity = 1.0 - distance
|
||||
if similarity >= threshold:
|
||||
return results["ids"][0][0]
|
||||
return None
|
||||
|
||||
def rebuild(self, memories: List[Dict]):
|
||||
"""Rebuild the entire index from a list of memory entries.
|
||||
Each entry must have 'id' and 'text' keys."""
|
||||
if not self._healthy:
|
||||
return
|
||||
|
||||
from src.chroma_client import get_chroma_client
|
||||
|
||||
# Delete and recreate collection for a clean rebuild
|
||||
client = get_chroma_client()
|
||||
try:
|
||||
client.delete_collection(self.COLLECTION_NAME)
|
||||
except Exception:
|
||||
pass
|
||||
self._collection = client.get_or_create_collection(
|
||||
name=self.COLLECTION_NAME,
|
||||
metadata={"hnsw:space": "cosine"},
|
||||
)
|
||||
|
||||
texts = []
|
||||
ids = []
|
||||
for mem in memories:
|
||||
text = mem.get("text", "").strip()
|
||||
mid = mem.get("id", "")
|
||||
if text and mid:
|
||||
texts.append(text)
|
||||
ids.append(mid)
|
||||
|
||||
if texts:
|
||||
# Batch in chunks of 100 to avoid oversized requests
|
||||
for i in range(0, len(texts), 100):
|
||||
batch_texts = texts[i:i + 100]
|
||||
batch_ids = ids[i:i + 100]
|
||||
embeddings = self._embed(batch_texts)
|
||||
self._collection.add(
|
||||
ids=batch_ids,
|
||||
embeddings=embeddings,
|
||||
documents=batch_texts,
|
||||
metadatas=[{"source": "memory"}] * len(batch_ids),
|
||||
)
|
||||
|
||||
logger.info(f"MemoryVectorStore rebuilt with {len(ids)} entries")
|
||||
__all__ = ["MemoryVectorStore"]
|
||||
|
||||
+42
-55
@@ -43,6 +43,16 @@ class MemoryService:
|
||||
os.path.join(data_dir, "memory_vectors")
|
||||
) else None
|
||||
|
||||
@staticmethod
|
||||
def _to_memory(entry: Dict[str, Any], metadata: Optional[Dict[str, Any]] = None) -> Memory:
|
||||
return Memory(
|
||||
id=entry.get("id", ""),
|
||||
text=entry.get("text", ""),
|
||||
timestamp=entry.get("timestamp", 0),
|
||||
session_id=entry.get("session_id"),
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
async def remember(self, text: str, session_id: Optional[str] = None) -> Memory:
|
||||
"""
|
||||
Store a new memory.
|
||||
@@ -54,31 +64,19 @@ class MemoryService:
|
||||
Returns:
|
||||
Created Memory object
|
||||
"""
|
||||
import uuid
|
||||
import time
|
||||
entry = self.manager.add_entry(text)
|
||||
if session_id:
|
||||
entry["session_id"] = session_id
|
||||
|
||||
memory_id = str(uuid.uuid4())[:8]
|
||||
timestamp = int(time.time())
|
||||
|
||||
entry = {
|
||||
"id": memory_id,
|
||||
"text": text,
|
||||
"timestamp": timestamp,
|
||||
"session_id": session_id,
|
||||
}
|
||||
|
||||
self.manager.add_memory(entry)
|
||||
memories = self.manager.load_all()
|
||||
memories.append(entry)
|
||||
self.manager.save(memories)
|
||||
|
||||
# Also add to vector store if available
|
||||
if self.vector_store:
|
||||
self.vector_store.add(text, {"id": memory_id, "session_id": session_id})
|
||||
if self.vector_store and self.vector_store.healthy:
|
||||
self.vector_store.add(entry["id"], entry["text"])
|
||||
|
||||
return Memory(
|
||||
id=memory_id,
|
||||
text=text,
|
||||
timestamp=timestamp,
|
||||
session_id=session_id,
|
||||
)
|
||||
return self._to_memory(entry)
|
||||
|
||||
async def recall(self, query: str, top_k: int = 5) -> MemorySearchResult:
|
||||
"""
|
||||
@@ -92,47 +90,36 @@ class MemoryService:
|
||||
MemorySearchResult with matching memories
|
||||
"""
|
||||
# Try vector search first
|
||||
if self.vector_store:
|
||||
all_memories = self.manager.load_all()
|
||||
by_id = {m.get("id"): m for m in all_memories}
|
||||
if self.vector_store and self.vector_store.healthy:
|
||||
results = self.vector_store.search(query, k=top_k)
|
||||
memories = [
|
||||
Memory(
|
||||
id=r.get("id", ""),
|
||||
text=r.get("text", ""),
|
||||
timestamp=r.get("timestamp", 0),
|
||||
session_id=r.get("session_id"),
|
||||
metadata=r.get("metadata", {}),
|
||||
)
|
||||
for r in results
|
||||
if isinstance(r, dict)
|
||||
]
|
||||
return MemorySearchResult(memories=memories, query=query, total=len(memories))
|
||||
found = []
|
||||
for result in results:
|
||||
entry = by_id.get(result.get("memory_id"))
|
||||
if entry:
|
||||
found.append(self._to_memory(entry, metadata={"score": result.get("score")}))
|
||||
if found:
|
||||
return MemorySearchResult(memories=found, query=query, total=len(found))
|
||||
|
||||
# Fallback to keyword search
|
||||
results = self.manager.search_memories(query, limit=top_k)
|
||||
memories = [
|
||||
Memory(
|
||||
id=m.get("id", ""),
|
||||
text=m.get("text", ""),
|
||||
timestamp=m.get("timestamp", 0),
|
||||
session_id=m.get("session_id"),
|
||||
)
|
||||
for m in results
|
||||
]
|
||||
results = self.manager.get_relevant_memories(query, all_memories, max_items=top_k)
|
||||
memories = [self._to_memory(m) for m in results]
|
||||
return MemorySearchResult(memories=memories, query=query, total=len(memories))
|
||||
|
||||
def get_all(self, limit: int = 100) -> List[Memory]:
|
||||
"""Get all memories."""
|
||||
memories = self.manager.get_memories(limit=limit)
|
||||
return [
|
||||
Memory(
|
||||
id=m.get("id", ""),
|
||||
text=m.get("text", ""),
|
||||
timestamp=m.get("timestamp", 0),
|
||||
session_id=m.get("session_id"),
|
||||
)
|
||||
for m in memories
|
||||
]
|
||||
memories = self.manager.load_all()[:limit]
|
||||
return [self._to_memory(m) for m in memories]
|
||||
|
||||
def delete(self, memory_id: str) -> bool:
|
||||
"""Delete a memory by ID."""
|
||||
return self.manager.delete_memory(memory_id)
|
||||
memories = self.manager.load_all()
|
||||
remaining = [m for m in memories if m.get("id") != memory_id]
|
||||
if len(remaining) == len(memories):
|
||||
return False
|
||||
|
||||
self.manager.save(remaining)
|
||||
if self.vector_store and self.vector_store.healthy:
|
||||
self.vector_store.remove(memory_id)
|
||||
return True
|
||||
|
||||
+23
-9
@@ -37,15 +37,12 @@ class APIKeyManager:
|
||||
f = Fernet(self.get_or_create_key())
|
||||
return f.decrypt(encrypted_key.encode()).decode()
|
||||
|
||||
def save(self, provider: str, api_key: str):
|
||||
"""Save encrypted API key to file"""
|
||||
keys = self.load()
|
||||
keys[provider] = self.encrypt_api_key(api_key)
|
||||
with open(self.api_keys_file, 'w', encoding="utf-8") as f:
|
||||
json.dump(keys, f)
|
||||
|
||||
def load(self) -> Dict[str, str]:
|
||||
"""Load and decrypt API keys"""
|
||||
def _load_raw(self) -> Dict[str, str]:
|
||||
"""Load the raw, still-encrypted keys dict from disk.
|
||||
|
||||
Tolerates a missing/corrupt/wrong-shaped file by returning {} — the
|
||||
same robustness load() relies on at startup.
|
||||
"""
|
||||
if not os.path.exists(self.api_keys_file):
|
||||
return {}
|
||||
try:
|
||||
@@ -60,7 +57,24 @@ class APIKeyManager:
|
||||
# Legacy/wrong shape (e.g. a list) — .items() would raise. Ignore it.
|
||||
logger.warning("API keys file has unexpected shape (%s); ignoring", type(encrypted_keys).__name__)
|
||||
return {}
|
||||
return encrypted_keys
|
||||
|
||||
def save(self, provider: str, api_key: str):
|
||||
"""Save encrypted API key to file.
|
||||
|
||||
Operates on the raw (still-encrypted) on-disk dict so other providers'
|
||||
keys stay encrypted. Loading via load() first would decrypt them and
|
||||
write them back as plaintext, which then fails to decrypt on the next
|
||||
load() and silently drops those providers.
|
||||
"""
|
||||
keys = self._load_raw()
|
||||
keys[provider] = self.encrypt_api_key(api_key)
|
||||
with open(self.api_keys_file, 'w', encoding="utf-8") as f:
|
||||
json.dump(keys, f)
|
||||
|
||||
def load(self) -> Dict[str, str]:
|
||||
"""Load and decrypt API keys"""
|
||||
encrypted_keys = self._load_raw()
|
||||
decrypted = {}
|
||||
for provider, key in encrypted_keys.items():
|
||||
try:
|
||||
|
||||
+20
-3
@@ -105,6 +105,25 @@ def _to_utc_naive(dt):
|
||||
return datetime(dt.year, dt.month, dt.day), True
|
||||
|
||||
|
||||
def _find_existing_event(db, pending, uid_val, calendar_id):
|
||||
"""Find the event to update for THIS calendar.
|
||||
|
||||
CalendarEvent.uid is the global primary key, so an unscoped lookup by uid
|
||||
returns whatever row holds that VEVENT uid — including another owner's.
|
||||
The old code then reassigned that row's calendar_id, moving (stealing)
|
||||
another user's event into the syncing calendar whenever the two share a
|
||||
uid (shared/subscribed/public calendars, or two accounts on one server).
|
||||
Scope the lookup to the calendar being synced; a genuine cross-user uid
|
||||
collision then fails the PK insert inside the per-calendar try/except
|
||||
instead of hijacking the row. (import_ics was already fixed this way.)
|
||||
"""
|
||||
from core.database import CalendarEvent
|
||||
return pending.get(uid_val) or db.query(CalendarEvent).filter(
|
||||
CalendarEvent.uid == uid_val,
|
||||
CalendarEvent.calendar_id == calendar_id,
|
||||
).first()
|
||||
|
||||
|
||||
def _sync_blocking(owner: str, url: str, username: str, password: str) -> dict:
|
||||
"""The actual sync — synchronous, intended to run in a threadpool.
|
||||
Returns counts: {calendars, events, deleted, errors}."""
|
||||
@@ -235,9 +254,7 @@ def _sync_blocking(owner: str, url: str, username: str, password: str) -> dict:
|
||||
else ""
|
||||
)
|
||||
|
||||
existing = pending.get(uid_val) or db.query(CalendarEvent).filter(
|
||||
CalendarEvent.uid == uid_val,
|
||||
).first()
|
||||
existing = _find_existing_event(db, pending, uid_val, local_cal.id)
|
||||
if existing:
|
||||
existing.calendar_id = local_cal.id
|
||||
existing.summary = summary
|
||||
|
||||
@@ -743,8 +743,74 @@ def _normalize_anthropic_url(url: str) -> str:
|
||||
return url + "/messages"
|
||||
return url + "/v1/messages"
|
||||
|
||||
|
||||
def _model_list_base(url: str) -> str:
|
||||
"""Normalize model/chat URLs to the configured endpoint base."""
|
||||
base = (url or "").strip().rstrip("/")
|
||||
for suffix in ("/models", "/chat/completions", "/completions", "/v1/messages"):
|
||||
if base.endswith(suffix):
|
||||
base = base[: -len(suffix)].rstrip("/")
|
||||
for suffix in ("/chat", "/tags", "/generate"):
|
||||
if base.endswith("/api" + suffix):
|
||||
base = base[: -len(suffix)].rstrip("/")
|
||||
return base
|
||||
|
||||
|
||||
def _parse_model_cache(raw) -> List[str]:
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
models = json.loads(raw) if isinstance(raw, str) else raw
|
||||
except Exception:
|
||||
return []
|
||||
if not isinstance(models, list):
|
||||
return []
|
||||
out = []
|
||||
seen = set()
|
||||
for item in models:
|
||||
mid = str(item or "").strip()
|
||||
if not mid or mid in seen:
|
||||
continue
|
||||
out.append(mid)
|
||||
seen.add(mid)
|
||||
return out
|
||||
|
||||
|
||||
def _configured_cached_model_ids(endpoint_url: str) -> List[str]:
|
||||
"""Return cached models for a configured endpoint matching endpoint_url."""
|
||||
target = _model_list_base(endpoint_url)
|
||||
if not target:
|
||||
return []
|
||||
try:
|
||||
from src.database import SessionLocal, ModelEndpoint
|
||||
except Exception:
|
||||
return []
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rows = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all()
|
||||
for ep in rows:
|
||||
if _model_list_base(getattr(ep, "base_url", "")) != target:
|
||||
continue
|
||||
models = _parse_model_cache(getattr(ep, "cached_models", None) or getattr(ep, "models", None))
|
||||
if not models:
|
||||
continue
|
||||
hidden = set(_parse_model_cache(getattr(ep, "hidden_models", None)))
|
||||
return [m for m in models if m not in hidden]
|
||||
except Exception:
|
||||
return []
|
||||
finally:
|
||||
try:
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
def list_model_ids(base_chat_url: str, timeout: int = LLMConfig.DEFAULT_TIMEOUT, headers: Optional[Dict] = None) -> List[str]:
|
||||
"""List available model IDs from an endpoint."""
|
||||
cached = _configured_cached_model_ids(base_chat_url)
|
||||
if cached:
|
||||
return cached
|
||||
provider = _detect_provider(base_chat_url)
|
||||
if provider == "anthropic":
|
||||
return list(ANTHROPIC_MODELS)
|
||||
|
||||
@@ -132,6 +132,20 @@ class MemoryManager:
|
||||
if owner is None:
|
||||
return entries
|
||||
return [e for e in entries if e.get("owner") == owner]
|
||||
|
||||
def claim_ownerless(self, owner: str):
|
||||
"""Assign all ownerless memory entries to the given owner."""
|
||||
entries = self.load_all()
|
||||
changed = False
|
||||
claimed = 0
|
||||
for entry in entries:
|
||||
if not entry.get("owner"):
|
||||
entry["owner"] = owner
|
||||
changed = True
|
||||
claimed += 1
|
||||
if changed:
|
||||
self.save(entries)
|
||||
logger.info("Claimed %d ownerless memories for %s", claimed, owner)
|
||||
|
||||
def _validate_entries(self, entries: List[Dict]) -> List[Dict]:
|
||||
"""Ensure all entries have required fields."""
|
||||
|
||||
+60
-1
@@ -6,6 +6,7 @@ Provides token estimation for context usage tracking.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from urllib.parse import urlparse
|
||||
@@ -21,8 +22,55 @@ _PRIVATE_PREFIXES = ("10.", "172.16.", "172.17.", "172.18.", "172.19.",
|
||||
"172.30.", "172.31.", "192.168.", "100.")
|
||||
|
||||
|
||||
def _normalize_base_for_compare(url: str) -> str:
|
||||
url = (url or "").strip().rstrip("/")
|
||||
for suffix in ("/chat/completions", "/models", "/completions", "/v1/messages"):
|
||||
if url.endswith(suffix):
|
||||
url = url[: -len(suffix)].rstrip("/")
|
||||
return url
|
||||
|
||||
|
||||
def _configured_endpoint_kind(url: str) -> Optional[str]:
|
||||
"""Return configured endpoint kind for a chat/base URL when available."""
|
||||
target = _normalize_base_for_compare(url)
|
||||
if not target:
|
||||
return None
|
||||
if "core.database" not in sys.modules:
|
||||
return None
|
||||
try:
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
db = SessionLocal()
|
||||
try:
|
||||
rows = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all()
|
||||
for ep in rows:
|
||||
base = _normalize_base_for_compare(getattr(ep, "base_url", "") or "")
|
||||
if not base:
|
||||
continue
|
||||
if target != base and not target.startswith(base + "/"):
|
||||
continue
|
||||
kind = (getattr(ep, "endpoint_kind", None) or "auto").strip().lower()
|
||||
if kind in ("local", "api", "proxy"):
|
||||
return kind
|
||||
if getattr(ep, "api_key", None):
|
||||
parsed = urlparse(base)
|
||||
host = (parsed.hostname or "").lower()
|
||||
path = (parsed.path or "").rstrip("/")
|
||||
if parsed.port != 11434 and "ollama" not in host and (path.endswith("/v1") or "/openai" in path):
|
||||
return "proxy"
|
||||
return "auto"
|
||||
finally:
|
||||
db.close()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _is_local_endpoint(url: str) -> bool:
|
||||
"""Check if URL points to a local/private/tailscale address."""
|
||||
kind = _configured_endpoint_kind(url)
|
||||
if kind in ("api", "proxy"):
|
||||
return False
|
||||
if kind == "local":
|
||||
return True
|
||||
try:
|
||||
host = urlparse(url).hostname or ""
|
||||
return host in _LOCAL_HOSTS or host.startswith(_PRIVATE_PREFIXES)
|
||||
@@ -170,6 +218,7 @@ def get_context_length(endpoint_url: str, model: str) -> int:
|
||||
or context_window fields. Caches result per model ID.
|
||||
Falls back to DEFAULT_CONTEXT if unavailable.
|
||||
"""
|
||||
configured_kind = _configured_endpoint_kind(endpoint_url)
|
||||
is_local = _is_local_endpoint(endpoint_url)
|
||||
if not is_local and model in _context_cache:
|
||||
return _context_cache[model]
|
||||
@@ -178,7 +227,7 @@ def get_context_length(endpoint_url: str, model: str) -> int:
|
||||
# Only cache non-default values to allow retry on next request.
|
||||
# Local endpoints can restart with a different --max-model-len while keeping
|
||||
# the same model id, so always re-query them instead of serving stale cache.
|
||||
if not is_local and ctx != DEFAULT_CONTEXT:
|
||||
if not is_local and (ctx != DEFAULT_CONTEXT or configured_kind in ("api", "proxy")):
|
||||
_context_cache[model] = ctx
|
||||
logger.info(f"Context length for {model}: {ctx}")
|
||||
return ctx
|
||||
@@ -207,6 +256,16 @@ def _query_context_length(endpoint_url: str, model: str) -> int:
|
||||
"""Query the model API for context length."""
|
||||
known = _lookup_known(model)
|
||||
api_ctx = None
|
||||
configured_kind = _configured_endpoint_kind(endpoint_url)
|
||||
|
||||
# Large OpenAI-compatible proxies can make /models expensive. If the
|
||||
# endpoint is explicitly configured as API/proxy, prefer known context
|
||||
# metadata (or the default) over downloading the full catalog.
|
||||
if configured_kind in ("api", "proxy"):
|
||||
if known:
|
||||
logger.info(f"Using known context window for {model}: {known}")
|
||||
return known
|
||||
return DEFAULT_CONTEXT
|
||||
|
||||
# Try llama.cpp /slots endpoint first — reports actual serving context
|
||||
if _is_local_endpoint(endpoint_url):
|
||||
|
||||
@@ -2079,6 +2079,10 @@
|
||||
</select>
|
||||
<div class="admin-model-form-row">
|
||||
<input id="adm-epApiKey" type="password" placeholder="API key">
|
||||
<select id="adm-epKind" style="padding:5px;width:82px;">
|
||||
<option value="proxy">Proxy</option>
|
||||
<option value="api">API</option>
|
||||
</select>
|
||||
<select id="adm-epType" style="padding:5px;width:80px;">
|
||||
<option value="llm">LLM</option>
|
||||
<option value="image">Image</option>
|
||||
|
||||
+64
-9
@@ -371,7 +371,7 @@ async function loadEndpoints() {
|
||||
const listLegacy = el('adm-epList');
|
||||
// Refresh model picker so new endpoints show up in chat
|
||||
if (window.modelsModule && window.modelsModule.refreshModels) {
|
||||
window.modelsModule.refreshModels(true);
|
||||
window.modelsModule.refreshModels();
|
||||
setTimeout(() => {
|
||||
if (window.sessionModule && window.sessionModule.updateModelPicker) {
|
||||
window.sessionModule.updateModelPicker();
|
||||
@@ -411,12 +411,15 @@ async function loadEndpoints() {
|
||||
? `<span class="admin-badge">${visibleCount}/${totalCount} models enabled</span>`
|
||||
: '<span class="admin-badge admin-badge-off">offline</span>';
|
||||
const justAddedClass = (_recentlyAddedEpId && String(ep.id) === _recentlyAddedEpId) ? ' adm-ep-just-added' : '';
|
||||
const category = ep.category || (_isLocalEndpoint(ep.base_url) ? 'local' : 'api');
|
||||
const kindLabel = ep.endpoint_kind && ep.endpoint_kind !== 'auto' ? ep.endpoint_kind.toUpperCase() : '';
|
||||
return `
|
||||
<div class="admin-user-row${ep.is_enabled ? '' : ' admin-ep-disabled'}${justAddedClass}" data-adm-ep-id="${ep.id}">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;${hasModels ? 'cursor:pointer;' : ''}padding:4px 0;" data-adm-ep-header="${ep.id}">
|
||||
<div class="admin-user-info" style="flex:1;flex-wrap:wrap;gap:0.3rem;">
|
||||
<span class="admin-user-name">${esc(ep.name)}</span>
|
||||
${ep.model_type === 'image' ? '<span class="admin-badge" style="background:color-mix(in srgb, var(--accent) 20%, transparent);color:var(--accent);">Image</span>' : ''}
|
||||
${kindLabel ? `<span class="admin-badge">${esc(kindLabel)}</span>` : ''}
|
||||
${statusBadge}
|
||||
${ep.is_enabled ? '' : '<span class="admin-badge admin-badge-off">disabled</span>'}
|
||||
${hasModels ? '<span style="font-size:10px;opacity:0.4;">Click to manage models</span>' : ''}
|
||||
@@ -427,7 +430,7 @@ async function loadEndpoints() {
|
||||
${hasModels ? '<svg class="admin-user-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.3;transition:transform 0.2s,opacity 0.2s;"><polyline points="6 9 12 15 18 9"/></svg>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-ep-detail">${esc(ep.base_url)}${_isLocalEndpoint(ep.base_url) ? `<button type="button" class="admin-ep-copy-btn" data-adm-copy-url="${esc(ep.base_url)}" title="Copy URL" aria-label="Copy URL" style="background:none;border:none;padding:0 2px;margin-left:6px;cursor:pointer;color:inherit;opacity:0.45;vertical-align:-2px;line-height:1;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>` : ''}${ep.has_key ? ' (key set)' : ''}</div>
|
||||
<div class="admin-ep-detail">${esc(ep.base_url)}${category === 'local' ? `<button type="button" class="admin-ep-copy-btn" data-adm-copy-url="${esc(ep.base_url)}" title="Copy URL" aria-label="Copy URL" style="background:none;border:none;padding:0 2px;margin-left:6px;cursor:pointer;color:inherit;opacity:0.45;vertical-align:-2px;line-height:1;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>` : ''}${ep.has_key ? ' (key set)' : ''}</div>
|
||||
${hasModels ? `<div class="mcp-tools-panel hidden" data-adm-ep-models-panel="${ep.id}"></div>` : ''}
|
||||
</div>`;
|
||||
});
|
||||
@@ -446,7 +449,7 @@ async function loadEndpoints() {
|
||||
container.innerHTML = indices.map(i => rowHtml[i]).join('');
|
||||
};
|
||||
const localIdx = [], apiIdx = [];
|
||||
data.forEach((ep, i) => (_isLocalEndpoint(ep.base_url) ? localIdx : apiIdx).push(i));
|
||||
data.forEach((ep, i) => ((ep.category || (_isLocalEndpoint(ep.base_url) ? 'local' : 'api')) === 'local' ? localIdx : apiIdx).push(i));
|
||||
// Sort each section: enabled endpoints first, disabled at the bottom.
|
||||
// Preserve original order within each group via stable sort.
|
||||
const _sortByEnabled = (a, b) => Number(!!data[b].is_enabled) - Number(!!data[a].is_enabled);
|
||||
@@ -552,22 +555,48 @@ async function loadEndpoints() {
|
||||
} catch (_) {}
|
||||
panel.appendChild(_ld);
|
||||
const _stopSpin = () => { try { _modelsSpin && _modelsSpin.stop(); } catch (_) {} };
|
||||
try {
|
||||
const res = await fetch(`/api/model-endpoints/${epId}/models`, { credentials: 'same-origin' });
|
||||
const models = await res.json();
|
||||
_stopSpin();
|
||||
const _loadingHtml = (label) => `<span style="opacity:0.55;font-size:11px;display:inline-flex;align-items:center;gap:8px;">${esc(label)}</span>`;
|
||||
const renderModels = (models, warning = '') => {
|
||||
const sortedModels = sortModelObjects(models);
|
||||
if (!sortedModels.length) { panel.innerHTML = '<span style="opacity:0.5;font-size:11px;">No models</span>'; return; }
|
||||
const warningHtml = warning ? `<div class="admin-error" style="font-size:11px;margin:6px 0;">${esc(warning)}</div>` : '';
|
||||
const attachRefresh = () => {
|
||||
panel.querySelector(`[data-ep-refresh-models="${epId}"]`)?.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
panel.innerHTML = _loadingHtml('Refreshing models...');
|
||||
try {
|
||||
const res = await fetch(`/api/model-endpoints/${epId}/models?refresh=true&refresh_timeout=60`, { credentials: 'same-origin' });
|
||||
const refreshWarning = res.headers.get('X-Model-Refresh-Warning') || '';
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const refreshedModels = await res.json();
|
||||
renderModels(refreshedModels, refreshWarning);
|
||||
if (refreshWarning && uiModule?.showToast) uiModule.showToast(refreshWarning, 6000);
|
||||
} catch (_) {
|
||||
renderModels(sortedModels, 'Model refresh failed; kept cached models.');
|
||||
}
|
||||
});
|
||||
};
|
||||
if (!sortedModels.length) {
|
||||
panel.innerHTML = `<div class="mcp-tools-header">
|
||||
<span>Models</span>
|
||||
<span style="display:flex;gap:8px;align-items:center;">
|
||||
<span class="mcp-tools-count">0/0 enabled</span>
|
||||
<a href="#" data-ep-refresh-models="${epId}">Refresh</a>
|
||||
</span>
|
||||
</div>${warningHtml}<span style="opacity:0.5;font-size:11px;">No models</span>`;
|
||||
attachRefresh();
|
||||
return;
|
||||
}
|
||||
const hiddenSet = new Set(sortedModels.filter(m => m.is_hidden).map(m => m.id));
|
||||
const showSearch = sortedModels.length >= 8;
|
||||
panel.innerHTML = `<div class="mcp-tools-header">
|
||||
<span>Models</span>
|
||||
<span style="display:flex;gap:8px;align-items:center;">
|
||||
<span class="mcp-tools-count">${sortedModels.length - hiddenSet.size}/${sortedModels.length} enabled</span>
|
||||
<a href="#" data-ep-refresh-models="${epId}">Refresh</a>
|
||||
<a href="#" data-ep-select-all="${epId}">All</a>
|
||||
<a href="#" data-ep-select-none="${epId}">None</a>
|
||||
</span>
|
||||
</div>${showSearch ? `<input type="search" class="mcp-tools-search" placeholder="Search ${sortedModels.length} models..." data-ep-search="${epId}">` : ''}<div class="mcp-tools-list">` + sortedModels.map(m =>
|
||||
</div>${warningHtml}${showSearch ? `<input type="search" class="mcp-tools-search" placeholder="Search ${sortedModels.length} models..." data-ep-search="${epId}">` : ''}<div class="mcp-tools-list">` + sortedModels.map(m =>
|
||||
`<label title="${esc(m.id)}" data-ep-model-row data-search="${esc((m.display + ' ' + m.id).toLowerCase())}" class="adm-model-row">
|
||||
<input type="checkbox" class="adm-cb-hidden" data-ep-model-id="${esc(m.id)}" ${!m.is_hidden ? 'checked' : ''}>
|
||||
<span class="adm-check-dot" aria-hidden="true"></span>
|
||||
@@ -580,6 +609,7 @@ async function loadEndpoints() {
|
||||
row.style.display = (!needle || row.dataset.search.includes(needle)) ? '' : 'none';
|
||||
});
|
||||
};
|
||||
attachRefresh();
|
||||
panel.querySelector(`[data-ep-search="${epId}"]`)?.addEventListener('input', (e) => filterRows(e.target.value));
|
||||
panel.querySelector(`[data-ep-select-all="${epId}"]`)?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
@@ -598,6 +628,13 @@ async function loadEndpoints() {
|
||||
panel.querySelectorAll('input[type=checkbox]').forEach(cb => {
|
||||
cb.addEventListener('change', () => _saveEpModelState(epId, panel));
|
||||
});
|
||||
};
|
||||
try {
|
||||
const res = await fetch(`/api/model-endpoints/${epId}/models`, { credentials: 'same-origin' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const models = await res.json();
|
||||
_stopSpin();
|
||||
renderModels(models);
|
||||
} catch (e) { _stopSpin(); panel.innerHTML = '<span class="admin-error" style="font-size:11px;">Failed to load models</span>'; }
|
||||
}
|
||||
});
|
||||
@@ -637,6 +674,7 @@ async function _saveEpModelState(epId, panel) {
|
||||
function initEndpointForm() {
|
||||
const provider = el('adm-epProvider');
|
||||
const urlInput = el('adm-epUrl');
|
||||
const kindSel = el('adm-epKind');
|
||||
|
||||
// Custom provider picker — mirrors the (now hidden) <select id="adm-epProvider">
|
||||
// so the rest of this function (which reads provider.value and dispatches
|
||||
@@ -688,14 +726,20 @@ function initEndpointForm() {
|
||||
provider.addEventListener('change', () => {
|
||||
if (provider.value) urlInput.value = provider.value;
|
||||
else urlInput.value = '';
|
||||
if (kindSel) kindSel.value = provider.value ? 'api' : 'proxy';
|
||||
});
|
||||
urlInput.addEventListener('input', () => {
|
||||
if (provider.value && urlInput.value.trim() !== provider.value) {
|
||||
provider.value = '';
|
||||
if (kindSel) kindSel.value = 'proxy';
|
||||
_renderPickerMenu();
|
||||
_syncPickerCurrent();
|
||||
}
|
||||
});
|
||||
if (kindSel) kindSel.value = provider.value ? 'api' : (kindSel.value || 'proxy');
|
||||
function _apiEndpointKind() {
|
||||
return (kindSel && kindSel.value) ? kindSel.value : (provider.value ? 'api' : 'proxy');
|
||||
}
|
||||
function _normalizeBaseUrl(raw) {
|
||||
let u = raw.trim();
|
||||
// Fix common protocol typos
|
||||
@@ -784,6 +828,8 @@ function initEndpointForm() {
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('base_url', url);
|
||||
fd.append('endpoint_kind', _apiEndpointKind());
|
||||
fd.append('model_refresh_timeout', '30');
|
||||
if (apiKey) fd.append('api_key', apiKey);
|
||||
const res = await fetch('/api/model-endpoints/test', {
|
||||
method: 'POST',
|
||||
@@ -828,6 +874,10 @@ function initEndpointForm() {
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('base_url', url);
|
||||
const endpointKind = _apiEndpointKind();
|
||||
fd.append('endpoint_kind', endpointKind);
|
||||
fd.append('model_refresh_mode', endpointKind === 'proxy' ? 'manual' : 'auto');
|
||||
fd.append('model_refresh_timeout', '30');
|
||||
if (apiKey) fd.append('api_key', apiKey);
|
||||
if (provider.value && provider.selectedOptions && provider.selectedOptions[0]) {
|
||||
fd.append('name', provider.selectedOptions[0].textContent.trim());
|
||||
@@ -842,6 +892,7 @@ function initEndpointForm() {
|
||||
const count = d.models ? d.models.length : 0;
|
||||
urlInput.value = ''; urlInput.style.display = '';
|
||||
el('adm-epApiKey').value = ''; provider.value = '';
|
||||
if (kindSel) kindSel.value = 'proxy';
|
||||
if (epType) epType.value = 'llm';
|
||||
if (d.id) _recentlyAddedEpId = String(d.id);
|
||||
await loadEndpoints();
|
||||
@@ -904,6 +955,8 @@ function initEndpointForm() {
|
||||
const fd = new FormData();
|
||||
fd.append('base_url', url);
|
||||
if (apiKey) fd.append('api_key', apiKey);
|
||||
fd.append('endpoint_kind', 'local');
|
||||
fd.append('model_refresh_mode', 'auto');
|
||||
const lt = el('adm-epLocalType');
|
||||
if (lt) fd.append('model_type', lt.value);
|
||||
fd.append('skip_probe', 'false');
|
||||
@@ -986,6 +1039,8 @@ function initEndpointForm() {
|
||||
const base = item.url.replace('/chat/completions', '').replace(/\/$/, '');
|
||||
const fd = new FormData();
|
||||
fd.append('base_url', base);
|
||||
fd.append('endpoint_kind', 'local');
|
||||
fd.append('model_refresh_mode', 'auto');
|
||||
fd.append('skip_probe', 'false');
|
||||
const r = await fetch('/api/model-endpoints', { method: 'POST', body: fd });
|
||||
if (r.ok) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { providerLogo } from './providers.js';
|
||||
import settingsModule from './settings.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import { bindMenuDismiss } from './escMenuStack.js';
|
||||
import { matchModelKey } from './model/matchKey.js';
|
||||
|
||||
const SEARCH_ICON = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>';
|
||||
const REPORT_ICON = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>';
|
||||
@@ -532,11 +533,8 @@ export function modelColor(name) {
|
||||
/** Look up model info (pricing + context) by substring match */
|
||||
export function getModelInfo(modelName) {
|
||||
if (!modelName) return null;
|
||||
const name = modelName.toLowerCase();
|
||||
for (const [key, info] of Object.entries(MODEL_INFO)) {
|
||||
if (name.includes(key)) return { key, ...info };
|
||||
}
|
||||
return null;
|
||||
const key = matchModelKey(modelName, Object.keys(MODEL_INFO));
|
||||
return key ? { key, ...MODEL_INFO[key] } : null;
|
||||
}
|
||||
|
||||
function _fmtCtx(n) {
|
||||
@@ -634,13 +632,10 @@ export function applyModelColor(roleEl, modelName) {
|
||||
|
||||
export function getModelCost(modelName, inputTokens, outputTokens) {
|
||||
if (!modelName) return null;
|
||||
const name = modelName.toLowerCase();
|
||||
for (const [key, price] of Object.entries(MODEL_PRICING)) {
|
||||
if (name.includes(key)) {
|
||||
return (inputTokens * price.input + outputTokens * price.output) / 1_000_000;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
const key = matchModelKey(modelName, Object.keys(MODEL_PRICING));
|
||||
if (!key) return null;
|
||||
const price = MODEL_PRICING[key];
|
||||
return (inputTokens * price.input + outputTokens * price.output) / 1_000_000;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -210,7 +210,9 @@ async function _buildCompareUI() {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const m = state._selectedModels[i];
|
||||
const fd = new FormData();
|
||||
fd.append('name', '[CMP] ' + modelShorts[i]);
|
||||
// Blind mode: name the session by its neutral slot so the sidebar /
|
||||
// GET /api/sessions can't de-anonymize the comparison (issue #1285).
|
||||
fd.append('name', '[CMP] ' + (state._blindMode ? 'Model ' + _slotChar(i) : modelShorts[i]));
|
||||
fd.append('endpoint_url', m.endpoint || '');
|
||||
fd.append('model', m.model || '');
|
||||
if (m.endpointId) {
|
||||
|
||||
@@ -382,7 +382,8 @@ async function _createAndAppendPane(m) {
|
||||
|
||||
// Create session
|
||||
const fd = new FormData();
|
||||
fd.append('name', '[CMP] ' + m.name);
|
||||
// Blind mode: neutral slot name only — never leak the model (issue #1285).
|
||||
fd.append('name', '[CMP] ' + (state._blindMode ? 'Model ' + _slotChar(i) : m.name));
|
||||
fd.append('endpoint_url', m.url || '');
|
||||
fd.append('model', m.id || '');
|
||||
if (m.endpointId) {
|
||||
@@ -584,7 +585,8 @@ function _showModelSwapDropdown(paneIdx, titleBtn) {
|
||||
fetch(`${state.API_BASE}/api/session/${oldSid}`, { method: 'DELETE' }).catch(() => {});
|
||||
}
|
||||
const fd = new FormData();
|
||||
fd.append('name', '[CMP] ' + m.name);
|
||||
// Blind mode: neutral slot name only — never leak the model (issue #1285).
|
||||
fd.append('name', '[CMP] ' + (state._blindMode ? 'Model ' + _slotChar(paneIdx) : m.name));
|
||||
fd.append('endpoint_url', m.url || '');
|
||||
fd.append('model', m.id || '');
|
||||
if (m.endpointId) {
|
||||
|
||||
@@ -728,37 +728,48 @@ export function _tmuxCmd(task, tmuxArgs) {
|
||||
}
|
||||
|
||||
function _winSessionCmd(task, tmuxArgs) {
|
||||
const sd = '$env:TEMP\\odysseus-sessions';
|
||||
const host = task.remoteHost;
|
||||
const sd = host ? '$env:TEMP\\odysseus-sessions' : '$env:TEMP\\odysseus-tmux';
|
||||
const sid = task.sessionId;
|
||||
const pf = _sshPrefix(_getPort(task));
|
||||
const host = task.remoteHost;
|
||||
if (tmuxArgs.includes('capture-pane')) {
|
||||
const lines = tmuxArgs.match(/-S\s*-?(\d+)/)?.[1] || '200';
|
||||
const ps = `Get-Content '${sd}\\${sid}.log' -Tail ${lines} -ErrorAction SilentlyContinue`;
|
||||
return `ssh ${pf}${host} "powershell -Command \\"${ps}\\""`;
|
||||
const ps = host
|
||||
? `Get-Content '${sd}\\${sid}.log' -Tail ${lines} -ErrorAction SilentlyContinue`
|
||||
: `Get-Content (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.log') -Tail ${lines} -ErrorAction SilentlyContinue`;
|
||||
return host ? `ssh ${pf}${host} "powershell -Command \\"${ps}\\""` : `powershell -Command "${ps}"`;
|
||||
}
|
||||
if (tmuxArgs.includes('has-session')) {
|
||||
const ps = `$p = Get-Content '${sd}\\${sid}.pid' -ErrorAction SilentlyContinue; if ($p) { Get-Process -Id $p -ErrorAction SilentlyContinue | Out-Null; if ($?) { exit 0 } else { exit 1 } } else { exit 1 }`;
|
||||
return `ssh ${pf}${host} "powershell -Command \\"${ps}\\""`;
|
||||
const ps = host
|
||||
? `$p = Get-Content '${sd}\\${sid}.pid' -ErrorAction SilentlyContinue; if ($p) { Get-Process -Id $p -ErrorAction SilentlyContinue | Out-Null; if ($?) { exit 0 } else { exit 1 } } else { exit 1 }`
|
||||
: `$p = Get-Content (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.pid') -ErrorAction SilentlyContinue; if ($p) { Get-Process -Id $p -ErrorAction SilentlyContinue | Out-Null; if ($?) { exit 0 } else { exit 1 } } else { exit 1 }`;
|
||||
return host ? `ssh ${pf}${host} "powershell -Command \\"${ps}\\""` : `powershell -Command "${ps}"`;
|
||||
}
|
||||
if (tmuxArgs.includes('kill-session')) {
|
||||
const ps = `$p = Get-Content '${sd}\\${sid}.pid' -ErrorAction SilentlyContinue; if ($p) { Stop-Process -Id $p -Force -ErrorAction SilentlyContinue }; Remove-Item '${sd}\\${sid}.*' -Force -ErrorAction SilentlyContinue`;
|
||||
return `ssh ${pf}${host} "powershell -Command \\"${ps}\\""`;
|
||||
const ps = host
|
||||
? `$p = Get-Content '${sd}\\${sid}.pid' -ErrorAction SilentlyContinue; if ($p) { Stop-Process -Id $p -Force -ErrorAction SilentlyContinue }; Remove-Item '${sd}\\${sid}.*' -Force -ErrorAction SilentlyContinue`
|
||||
: `$p = Get-Content (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.pid') -ErrorAction SilentlyContinue; if ($p) { Stop-Process -Id $p -Force -ErrorAction SilentlyContinue }; Remove-Item (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.*') -Force -ErrorAction SilentlyContinue`;
|
||||
return host ? `ssh ${pf}${host} "powershell -Command \\"${ps}\\""` : `powershell -Command "${ps}"`;
|
||||
}
|
||||
if (tmuxArgs.includes('send-keys') && tmuxArgs.includes('C-c')) {
|
||||
const ps = `$p = Get-Content '${sd}\\${sid}.pid' -ErrorAction SilentlyContinue; if ($p) { Stop-Process -Id $p -ErrorAction SilentlyContinue }`;
|
||||
return `ssh ${pf}${host} "powershell -Command \\"${ps}\\""`;
|
||||
const ps = host
|
||||
? `$p = Get-Content '${sd}\\${sid}.pid' -ErrorAction SilentlyContinue; if ($p) { Stop-Process -Id $p -ErrorAction SilentlyContinue }`
|
||||
: `$p = Get-Content (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.pid') -ErrorAction SilentlyContinue; if ($p) { Stop-Process -Id $p -ErrorAction SilentlyContinue }`;
|
||||
return host ? `ssh ${pf}${host} "powershell -Command \\"${ps}\\""` : `powershell -Command "${ps}"`;
|
||||
}
|
||||
return `ssh ${pf}${host} 'tmux ${tmuxArgs}' 2>/dev/null`;
|
||||
return host ? `ssh ${pf}${host} 'tmux ${tmuxArgs}' 2>/dev/null` : `tmux ${tmuxArgs} 2>/dev/null`;
|
||||
}
|
||||
|
||||
function _tmuxGracefulKill(task) {
|
||||
if (_isWindows(task)) {
|
||||
const sd = '$env:TEMP\\odysseus-sessions';
|
||||
const host = task.remoteHost;
|
||||
const sd = host ? '$env:TEMP\\odysseus-sessions' : '$env:TEMP\\odysseus-tmux';
|
||||
const sid = task.sessionId;
|
||||
const pf = _sshPrefix(_getPort(task));
|
||||
const ps = `$p = Get-Content '${sd}\\${sid}.pid' -ErrorAction SilentlyContinue; if ($p) { Stop-Process -Id $p -Force -ErrorAction SilentlyContinue }; Remove-Item '${sd}\\${sid}.*' -Force -ErrorAction SilentlyContinue`;
|
||||
return `ssh ${pf}${task.remoteHost} "powershell -Command \\"${ps}\\""`;
|
||||
const ps = host
|
||||
? `$p = Get-Content '${sd}\\${sid}.pid' -ErrorAction SilentlyContinue; if ($p) { Stop-Process -Id $p -Force -ErrorAction SilentlyContinue }; Remove-Item '${sd}\\${sid}.*' -Force -ErrorAction SilentlyContinue`
|
||||
: `$p = Get-Content (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.pid') -ErrorAction SilentlyContinue; if ($p) { Stop-Process -Id $p -Force -ErrorAction SilentlyContinue }; Remove-Item (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.*') -Force -ErrorAction SilentlyContinue`;
|
||||
return host ? `ssh ${pf}${host} "powershell -Command \\"${ps}\\""` : `powershell -Command "${ps}"`;
|
||||
}
|
||||
if (task.remoteHost) {
|
||||
return `ssh ${_sshPrefix(_getPort(task))}${task.remoteHost} 'tmux send-keys -t ${task.sessionId} C-c 2>/dev/null; sleep 2; tmux kill-session -t ${task.sessionId} 2>/dev/null'`;
|
||||
@@ -2116,8 +2127,11 @@ export function _renderRunningTab() {
|
||||
}});
|
||||
}
|
||||
if (_isWindows(task)) {
|
||||
const sd = '$env:TEMP\\odysseus-sessions';
|
||||
const logCmd = `ssh ${_sshPrefix(_getPort(task))}${task.remoteHost} "powershell -Command \\"Get-Content '${sd}\\${task.sessionId}.log' -Wait\\""`;
|
||||
const host = task.remoteHost;
|
||||
const sd = host ? '$env:TEMP\\odysseus-sessions' : '$env:TEMP\\odysseus-tmux';
|
||||
const logCmd = host
|
||||
? `ssh ${_sshPrefix(_getPort(task))}${host} "powershell -Command \\"Get-Content '${sd}\\${task.sessionId}.log' -Wait\\""`
|
||||
: `powershell -Command "Get-Content (Join-Path $env:TEMP 'odysseus-tmux\\${task.sessionId}.log') -Wait"`;
|
||||
items.push({ label: 'Copy log cmd', action: 'copy-tmux', custom: () => {
|
||||
_copyText(logCmd);
|
||||
}});
|
||||
|
||||
@@ -391,6 +391,27 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
}
|
||||
}
|
||||
|
||||
function libraryRemoveDocumentFromState(docId) {
|
||||
const removed = _libraryDocs.find(d => String(d.id) === String(docId));
|
||||
_libraryDocs = _libraryDocs.filter(d => String(d.id) !== String(docId));
|
||||
_librarySelectedIds.delete(docId);
|
||||
_libraryTotal = Math.max(0, _libraryTotal - 1);
|
||||
|
||||
const lang = removed && (removed.language || 'text');
|
||||
if (lang && Object.prototype.hasOwnProperty.call(_libraryLanguages, lang)) {
|
||||
const next = Math.max(0, Number(_libraryLanguages[lang] || 0) - 1);
|
||||
if (next > 0) {
|
||||
_libraryLanguages[lang] = next;
|
||||
} else {
|
||||
delete _libraryLanguages[lang];
|
||||
}
|
||||
}
|
||||
|
||||
libraryRenderStats();
|
||||
libraryRenderLangChips();
|
||||
libraryUpdateBulkCount();
|
||||
}
|
||||
|
||||
function libraryRenderGrid() {
|
||||
const grid = document.getElementById('doclib-grid');
|
||||
if (!grid) return;
|
||||
@@ -709,8 +730,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
const res = await fetch(`${API_BASE}/api/document/${doc.id}/archive?archived=${toArchived}`, { method: 'POST', credentials: 'same-origin' });
|
||||
if (!res.ok) throw new Error('failed');
|
||||
// Drop it from the current view (it no longer belongs here) and refresh.
|
||||
_libraryDocs = _libraryDocs.filter(d => d.id !== doc.id);
|
||||
_libraryTotal = Math.max(0, _libraryTotal - 1);
|
||||
libraryRemoveDocumentFromState(doc.id);
|
||||
libraryRenderGrid();
|
||||
if (uiModule) uiModule.showToast(toArchived ? 'Archived' : 'Restored');
|
||||
} catch { if (uiModule) uiModule.showError('Failed to ' + (toArchived ? 'archive' : 'restore')); }
|
||||
@@ -802,8 +822,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/document/${doc.id}/archive?archived=${toArchived}`, { method: 'POST', credentials: 'same-origin' });
|
||||
if (!res.ok) throw new Error('failed');
|
||||
_libraryDocs = _libraryDocs.filter(d => d.id !== doc.id);
|
||||
_libraryTotal = Math.max(0, _libraryTotal - 1);
|
||||
libraryRemoveDocumentFromState(doc.id);
|
||||
libraryRenderGrid();
|
||||
if (uiModule) uiModule.showToast(toArchived ? 'Archived' : 'Restored');
|
||||
} catch { if (uiModule) uiModule.showError('Failed to ' + (toArchived ? 'archive' : 'restore')); }
|
||||
@@ -1170,9 +1189,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
||||
card.addEventListener('transitionend', () => card.remove(), { once: true });
|
||||
setTimeout(() => { if (card.parentElement) card.remove(); }, 400);
|
||||
}
|
||||
_libraryDocs = _libraryDocs.filter(d => d.id !== docId);
|
||||
_libraryTotal = Math.max(0, _libraryTotal - 1);
|
||||
libraryRenderStats();
|
||||
libraryRemoveDocumentFromState(docId);
|
||||
if (uiModule) uiModule.showToast('Document deleted');
|
||||
} catch (e) {
|
||||
if (uiModule) uiModule.showError(`Failed to delete document: ${e.message || e}`);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// static/js/model/matchKey.js
|
||||
//
|
||||
// Pure helper for matching a model name against a set of known keys. No DOM —
|
||||
// safe to import anywhere and to unit-test under node.
|
||||
|
||||
// Return the most specific (longest) key that is a substring of `name`, or null.
|
||||
// Returning the first match instead made "gpt-4o-mini" match the shorter
|
||||
// "gpt-4o" key — billing it at gpt-4o rates (~16x) and showing the wrong
|
||||
// context window.
|
||||
export function matchModelKey(name, keys) {
|
||||
const n = (name || '').toLowerCase();
|
||||
let best = null;
|
||||
for (const key of keys) {
|
||||
if (n.includes(key) && (best === null || key.length > best.length)) {
|
||||
best = key;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
@@ -561,7 +561,7 @@ function _initModelPickerDropdown() {
|
||||
menu.classList.remove('closing', 'hidden');
|
||||
_populate('');
|
||||
if (window.modelsModule && window.modelsModule.refreshModels) {
|
||||
window.modelsModule.refreshModels(true).then(() => {
|
||||
window.modelsModule.refreshModels().then(() => {
|
||||
if (!menu.classList.contains('hidden')) _populate(search.value || '');
|
||||
updateModelPicker();
|
||||
}).catch(() => {});
|
||||
|
||||
+10
-2
@@ -16,6 +16,7 @@ import { sortModelIds } from './modelSort.js';
|
||||
let API_BASE = '';
|
||||
let _cachedItems = []; // cached /api/models items for model-switch dropdown
|
||||
let _lastFetchTime = 0;
|
||||
let _fetchInflight = null;
|
||||
const _FETCH_CACHE_TTL = 30000; // 30s client-side cache for /api/models
|
||||
const COLLAPSE_KEY = 'odysseus-models-collapsed';
|
||||
const FAVORITES_KEY = 'odysseus-model-favorites';
|
||||
@@ -176,8 +177,15 @@ export async function refreshModels(force = false) {
|
||||
box.appendChild(_loadingSpinner.createElement());
|
||||
_loadingSpinner.start();
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/models`);
|
||||
const data = await res.json();
|
||||
if (!_fetchInflight) {
|
||||
_fetchInflight = fetch(`${API_BASE}/api/models`, { credentials: 'same-origin' })
|
||||
.then(async (res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
})
|
||||
.finally(() => { _fetchInflight = null; });
|
||||
}
|
||||
const data = await _fetchInflight;
|
||||
_lastFetchTime = Date.now();
|
||||
_cachedItems = data.items || [];
|
||||
} catch (e) {
|
||||
|
||||
+45
-10
@@ -4,22 +4,57 @@ and _append_tool_results. Uses mock imports to avoid loading the full app stack.
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# Mock heavy dependencies before importing
|
||||
for mod in [
|
||||
_MOCKED_IMPORTS = [
|
||||
'sqlalchemy', 'sqlalchemy.orm', 'sqlalchemy.ext', 'sqlalchemy.ext.declarative',
|
||||
'sqlalchemy.ext.hybrid', 'sqlalchemy.sql', 'sqlalchemy.sql.expression',
|
||||
'src.database',
|
||||
'src.agent_tools',
|
||||
'core.models', 'core.database',
|
||||
]:
|
||||
if mod not in sys.modules:
|
||||
sys.modules[mod] = MagicMock()
|
||||
]
|
||||
_INJECTED_IMPORT_STUBS = {}
|
||||
_PREEXISTING_AGENT_LOOP = sys.modules.get("src.agent_loop")
|
||||
|
||||
from src.agent_loop import (
|
||||
_detect_admin_intent,
|
||||
_compute_final_metrics,
|
||||
_append_tool_results,
|
||||
)
|
||||
|
||||
def _drop_module_if_same(name, expected):
|
||||
if sys.modules.get(name) is expected:
|
||||
sys.modules.pop(name, None)
|
||||
parent_name, _, attr = name.rpartition(".")
|
||||
parent = sys.modules.get(parent_name)
|
||||
if parent is not None and getattr(parent, "__dict__", {}).get(attr) is expected:
|
||||
delattr(parent, attr)
|
||||
|
||||
|
||||
# Mock heavy dependencies before importing. Only clean up stubs this file
|
||||
# created so pre-existing conftest/pytest modules keep their intended state.
|
||||
for mod in _MOCKED_IMPORTS:
|
||||
if mod not in sys.modules:
|
||||
stub = MagicMock()
|
||||
sys.modules[mod] = stub
|
||||
_INJECTED_IMPORT_STUBS[mod] = stub
|
||||
|
||||
_IMPORTED_AGENT_LOOP = None
|
||||
try:
|
||||
from src.agent_loop import (
|
||||
_detect_admin_intent,
|
||||
_compute_final_metrics,
|
||||
_append_tool_results,
|
||||
)
|
||||
_IMPORTED_AGENT_LOOP = sys.modules.get("src.agent_loop")
|
||||
finally:
|
||||
if _PREEXISTING_AGENT_LOOP is None and _IMPORTED_AGENT_LOOP is not None:
|
||||
_drop_module_if_same("src.agent_loop", _IMPORTED_AGENT_LOOP)
|
||||
for _mod, _stub in _INJECTED_IMPORT_STUBS.items():
|
||||
_drop_module_if_same(_mod, _stub)
|
||||
|
||||
|
||||
def test_import_stubs_do_not_leak_into_later_tests():
|
||||
leaked = [
|
||||
mod for mod, stub in _INJECTED_IMPORT_STUBS.items()
|
||||
if sys.modules.get(mod) is stub
|
||||
]
|
||||
assert leaked == []
|
||||
if _PREEXISTING_AGENT_LOOP is None:
|
||||
assert sys.modules.get("src.agent_loop") is not _IMPORTED_AGENT_LOOP
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -51,7 +51,7 @@ def _seed(owner, *models):
|
||||
db.query(DbSession).delete()
|
||||
for m in models:
|
||||
db.add(DbSession(id=str(uuid.uuid4()), owner=owner, name=f"chat {m}",
|
||||
model=m, archived=True))
|
||||
endpoint_url="http://localhost", model=m, archived=True))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Regression tests for issue #1285 — blind Compare must not leak model
|
||||
identities through helper-session names or GET /api/sessions.
|
||||
|
||||
Two guards are pinned here:
|
||||
|
||||
1. Backend: ``routes.session_routes._public_model`` blanks the ``model`` field
|
||||
of any ``[CMP] …`` helper session in the session list, so the sidebar /
|
||||
``/api/sessions`` can't be used to map a neutral pane label ("Model A")
|
||||
back to its real model.
|
||||
2. Frontend: every ``[CMP]`` session name built in ``static/js/compare/`` is
|
||||
guarded by ``state._blindMode`` so blind sessions are named by slot rather
|
||||
than by the real model.
|
||||
|
||||
The backend import mirrors tests/test_session_ghost_delete.py: stub the heavy
|
||||
ORM modules so the real route module imports under conftest's MagicMock
|
||||
sqlalchemy stub, then restore sys.modules so the stubs don't leak into sibling
|
||||
test modules.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
_REPO = Path(__file__).resolve().parent.parent
|
||||
|
||||
# Mirror tests/test_session_ghost_delete.py exactly: stub only the ORM *class*
|
||||
# modules and import the REAL core.session_manager + src.auth_helpers. pytest
|
||||
# caches routes.session_routes after the first import, so stubbing auth_helpers /
|
||||
# session_manager here would poison the shared module for the sibling session
|
||||
# tests (whichever file is collected first wins). Matching their stub set keeps
|
||||
# the cached module identical regardless of collection order.
|
||||
_ABSENT = object()
|
||||
_TEMP_STUBS = ("core.database", "core.models", "src.request_models")
|
||||
_saved = {name: sys.modules.get(name, _ABSENT) for name in _TEMP_STUBS}
|
||||
_saved["core.session_manager"] = sys.modules.get("core.session_manager", _ABSENT)
|
||||
try:
|
||||
for _name in _TEMP_STUBS:
|
||||
sys.modules[_name] = MagicMock(name=_name)
|
||||
if isinstance(sys.modules.get("core.session_manager"), MagicMock):
|
||||
del sys.modules["core.session_manager"]
|
||||
importlib.import_module("core.session_manager")
|
||||
import routes.session_routes as SR # noqa: E402
|
||||
finally:
|
||||
for _name, _val in _saved.items():
|
||||
if _val is _ABSENT:
|
||||
sys.modules.pop(_name, None)
|
||||
else:
|
||||
sys.modules[_name] = _val
|
||||
|
||||
|
||||
# ── backend: GET /api/sessions model redaction ─────────────────────────────
|
||||
|
||||
def test_public_model_blanks_blind_compare_sessions():
|
||||
"""A blind-compare helper session ("[CMP] Model A") must not expose its
|
||||
real model in the session list — that is the de-anonymization vector."""
|
||||
assert SR._public_model("[CMP] Model A", "gpt-4o") == ""
|
||||
assert SR._public_model("[CMP] Model B", "llama-3.1-70b") == ""
|
||||
|
||||
|
||||
def test_public_model_blanks_any_cmp_prefixed_session():
|
||||
"""Defense in depth: even a non-blind [CMP] session (named after the real
|
||||
model) gets its model field blanked. The name already carries whatever the
|
||||
user chose to reveal, and the session list never needs the raw model."""
|
||||
assert SR._public_model("[CMP] gpt-4o", "gpt-4o") == ""
|
||||
|
||||
|
||||
def test_public_model_preserves_normal_sessions():
|
||||
"""Ordinary chats are untouched — only the [CMP] prefix triggers redaction.
|
||||
The post-vote "Compare: a vs b" folder is a normal session, not a helper."""
|
||||
assert SR._public_model("My research chat", "gpt-4o") == "gpt-4o"
|
||||
assert SR._public_model("", "claude-sonnet") == "claude-sonnet"
|
||||
assert SR._public_model("Compare: gpt-4o vs llama", "gpt-4o") == "gpt-4o"
|
||||
|
||||
|
||||
def test_compare_prefix_constant_matches_frontend():
|
||||
"""The redaction prefix must match what the frontend prepends, or the
|
||||
guard silently stops matching new sessions."""
|
||||
assert SR.COMPARE_SESSION_PREFIX == "[CMP] "
|
||||
|
||||
|
||||
# ── frontend: every [CMP] session name is blind-guarded ────────────────────
|
||||
|
||||
def test_compare_session_names_are_blind_guarded():
|
||||
"""Every line in static/js/compare/ that builds a '[CMP]' session name
|
||||
must branch on state._blindMode, so a blind comparison is never named
|
||||
after its real model. Pins the #1285 fix against regressions."""
|
||||
compare_dir = _REPO / "static" / "js" / "compare"
|
||||
assert compare_dir.is_dir(), f"missing {compare_dir}"
|
||||
offenders = []
|
||||
for path in sorted(compare_dir.glob("*.js")):
|
||||
for lineno, line in enumerate(
|
||||
path.read_text(encoding="utf-8").splitlines(), 1
|
||||
):
|
||||
if "'[CMP] '" in line and "_blindMode" not in line:
|
||||
offenders.append(f"{path.name}:{lineno}: {line.strip()}")
|
||||
assert not offenders, (
|
||||
"Compare session names must be blind-guarded (issue #1285):\n"
|
||||
+ "\n".join(offenders)
|
||||
)
|
||||
@@ -0,0 +1,76 @@
|
||||
"""CalDAV sync must not hijack another user's event via a shared VEVENT uid.
|
||||
|
||||
CalendarEvent.uid is the global primary key. _sync_blocking looked up the
|
||||
existing event by uid with NO calendar scope, so when user B synced a uid
|
||||
that user A's calendar already held, the query returned A's row and the sync
|
||||
reassigned its calendar_id to B's calendar — stealing A's event. The lookup
|
||||
must be scoped to the calendar being synced.
|
||||
"""
|
||||
import tempfile
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
import core.database as cdb
|
||||
from core.database import CalendarEvent, CalendarCal
|
||||
from src.caldav_sync import _find_existing_event
|
||||
|
||||
_TMPDB = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
_ENGINE = create_engine(f"sqlite:///{_TMPDB.name}", connect_args={"check_same_thread": False}, poolclass=NullPool)
|
||||
cdb.Base.metadata.create_all(_ENGINE)
|
||||
_TS = sessionmaker(bind=_ENGINE, autoflush=False, autocommit=False)
|
||||
|
||||
|
||||
def _setup():
|
||||
db = _TS()
|
||||
try:
|
||||
db.query(CalendarEvent).delete(); db.query(CalendarCal).delete()
|
||||
db.add(CalendarCal(id="calA", owner="alice", name="A"))
|
||||
db.add(CalendarCal(id="calB", owner="bob", name="B"))
|
||||
# dtstart/dtend are NOT NULL in the schema, so seed valid values.
|
||||
db.add(CalendarEvent(
|
||||
uid="shared@svc", calendar_id="calA", summary="Alice event",
|
||||
dtstart=datetime(2026, 6, 4, 9, 0), dtend=datetime(2026, 6, 4, 10, 0),
|
||||
))
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_lookup_for_other_calendar_does_not_find_a_users_event():
|
||||
_setup()
|
||||
db = _TS()
|
||||
try:
|
||||
# Bob's calendar syncing the same uid must NOT resolve Alice's row.
|
||||
assert _find_existing_event(db, {}, "shared@svc", "calB") is None
|
||||
# Same calendar still resolves its own event (normal update path).
|
||||
own = _find_existing_event(db, {}, "shared@svc", "calA")
|
||||
assert own is not None and own.calendar_id == "calA"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_alice_event_is_not_moved():
|
||||
_setup()
|
||||
db = _TS()
|
||||
try:
|
||||
# Simulate the (fixed) sync deciding there is no existing row for calB.
|
||||
assert _find_existing_event(db, {}, "shared@svc", "calB") is None
|
||||
ev = db.query(CalendarEvent).filter(CalendarEvent.uid == "shared@svc").first()
|
||||
assert ev.calendar_id == "calA" # unchanged — not hijacked
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_pending_takes_precedence():
|
||||
_setup()
|
||||
db = _TS()
|
||||
try:
|
||||
sentinel = object()
|
||||
assert _find_existing_event(db, {"shared@svc": sentinel}, "shared@svc", "calB") is sentinel
|
||||
finally:
|
||||
db.close()
|
||||
@@ -28,6 +28,15 @@ def test_background_status_poll_reconciles_into_local_tasks():
|
||||
assert "completedDeps.forEach(t => _refreshDepsAfterInstall(t));" in source
|
||||
|
||||
|
||||
def test_local_windows_session_commands_use_local_powershell_log_dir():
|
||||
source = _read("static/js/cookbookRunning.js")
|
||||
|
||||
assert "const host = task.remoteHost;" in source
|
||||
assert "host ? '$env:TEMP\\\\odysseus-sessions' : '$env:TEMP\\\\odysseus-tmux'" in source
|
||||
assert "return host ? `ssh ${pf}${host}" in source
|
||||
assert ": `powershell -Command \"${ps}\"`;" in source
|
||||
|
||||
|
||||
def test_dependency_install_payload_keeps_env_path_for_refresh():
|
||||
source = _read("static/js/cookbook.js")
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from routes.cookbook_helpers import (
|
||||
_pip_install_fallback_chain,
|
||||
_ollama_bind_from_cmd,
|
||||
_safe_env_prefix,
|
||||
_user_shell_path_bootstrap,
|
||||
_venv_safe_local_pip_install_cmd,
|
||||
_validate_gpus,
|
||||
_validate_repo_id,
|
||||
@@ -258,6 +259,17 @@ def test_pip_install_attempt_surfaces_stderr_on_failure():
|
||||
assert "nonexistent" in combined.lower() or result.returncode != 0
|
||||
|
||||
|
||||
def test_local_tooling_path_export_converts_windows_paths_for_bash():
|
||||
line = _local_tooling_path_export(r"C:\Users\Jane Dev\.venv\Scripts\python.exe")
|
||||
assert line == 'export PATH="/c/Users/Jane Dev/.venv/Scripts:$PATH"'
|
||||
assert "C:" not in line
|
||||
|
||||
|
||||
def test_user_shell_path_bootstrap_falls_back_to_python_on_windows_bash():
|
||||
script = "\n".join(_user_shell_path_bootstrap())
|
||||
assert 'command -v python3 >/dev/null 2>&1 || python3() { python "$@"; }' in script
|
||||
|
||||
|
||||
def test_serve_preflight_failure_keeps_tmux_pane_visible():
|
||||
"""Dependency preflight failures should remain visible in tmux output.
|
||||
|
||||
@@ -290,6 +302,17 @@ def test_serve_runner_preserves_command_exit_code():
|
||||
assert 'echo "=== Process exited with code $? ==="' not in script
|
||||
|
||||
|
||||
def test_pip_serve_runner_emits_download_ok_before_exit_marker():
|
||||
"""Dependency installs run through the serve wrapper need the download marker."""
|
||||
runner_lines = ["python3 -m pip install llama-cpp-python"]
|
||||
_append_serve_exit_code_lines(runner_lines, keep_shell_open=False, is_pip_install=True)
|
||||
script = "\n".join(runner_lines)
|
||||
|
||||
assert 'echo "DOWNLOAD_OK"' in script
|
||||
assert script.index('echo "DOWNLOAD_OK"') < script.index("=== Process exited with code")
|
||||
assert 'exit "$ODYSSEUS_CMD_EXIT"' in script
|
||||
|
||||
|
||||
def test_validate_serve_cmd_accepts_vllm_kv_cache_dtype():
|
||||
cmd = (
|
||||
"CUDA_VISIBLE_DEVICES=0,1 vllm serve nvidia/Qwen3.6-35B-A3B-NVFP4 "
|
||||
@@ -410,6 +433,13 @@ def test_llama_cpp_linux_bootstrap_nvcc_without_cudart_warns_and_falls_back():
|
||||
assert script.index(cpu_cmake) < script.index(no_toolchain_warn)
|
||||
|
||||
|
||||
def test_llama_cpp_linux_bootstrap_uses_single_shell_continuations():
|
||||
runner_lines = []
|
||||
_append_llama_cpp_linux_accel_build_lines(runner_lines)
|
||||
|
||||
assert not any(line.endswith("\\\\") for line in runner_lines)
|
||||
|
||||
|
||||
def test_llama_cpp_linux_bootstrap_keeps_cpu_fallback_when_no_gpu_toolchain():
|
||||
runner_lines = []
|
||||
_append_llama_cpp_linux_accel_build_lines(runner_lines)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
sqlalchemy = pytest.importorskip("sqlalchemy")
|
||||
if not isinstance(sqlalchemy, types.ModuleType):
|
||||
pytest.skip("sqlalchemy is stubbed in this environment", allow_module_level=True)
|
||||
|
||||
from core.database import ChatMessage, DocumentVersion, Session, TaskRun, UserToolData, utcnow_naive
|
||||
|
||||
|
||||
def test_utcnow_naive_returns_naive_utc_datetime():
|
||||
now = utcnow_naive()
|
||||
|
||||
assert now.tzinfo is None
|
||||
assert abs((now - utcnow_naive()).total_seconds()) < 2
|
||||
|
||||
|
||||
def test_database_timestamp_defaults_use_utcnow_naive():
|
||||
defaults = (
|
||||
Session.created_at.default.arg,
|
||||
Session.updated_at.default.arg,
|
||||
Session.updated_at.onupdate.arg,
|
||||
ChatMessage.timestamp.default.arg,
|
||||
DocumentVersion.created_at.default.arg,
|
||||
UserToolData.created_at.default.arg,
|
||||
UserToolData.updated_at.default.arg,
|
||||
UserToolData.updated_at.onupdate.arg,
|
||||
TaskRun.started_at.default.arg,
|
||||
)
|
||||
|
||||
for fn in defaults:
|
||||
assert fn.__name__ == "utcnow_naive"
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Deleting a user must invalidate the bearer-token cache.
|
||||
|
||||
delete_user removes the user's ApiToken rows from the DB, but the bearer-auth
|
||||
middleware in app.py serves from an in-memory prefix->token cache that only
|
||||
rebuilds when flagged dirty (app.state.invalidate_token_cache). If the admin
|
||||
delete route does not flag it, a deleted user's already-cached token keeps
|
||||
authenticating until some unrelated token op or a process restart clears the
|
||||
cache. The DELETE /api/auth/users handler now calls the invalidator on a
|
||||
successful delete (and only then), so the next bearer request rebuilds the
|
||||
cache from the DB, where the rows are already gone, and the token is rejected.
|
||||
"""
|
||||
import asyncio
|
||||
import types
|
||||
|
||||
from routes.auth_routes import setup_auth_routes, DeleteUserRequest
|
||||
|
||||
|
||||
def _handler(router):
|
||||
for route in router.routes:
|
||||
if getattr(route, "path", "") == "/api/auth/users" and "DELETE" in getattr(route, "methods", set()):
|
||||
return route.endpoint
|
||||
raise AssertionError("DELETE /api/auth/users handler not found")
|
||||
|
||||
|
||||
def _fake_request(invalidations):
|
||||
state = types.SimpleNamespace(invalidate_token_cache=lambda: invalidations.append(True))
|
||||
app = types.SimpleNamespace(state=state)
|
||||
return types.SimpleNamespace(cookies={"_dummy": "x"}, app=app)
|
||||
|
||||
|
||||
def _auth_manager(delete_result):
|
||||
return types.SimpleNamespace(
|
||||
get_username_for_token=lambda token: "admin",
|
||||
is_admin=lambda user: True,
|
||||
delete_user=lambda username, requesting_user: delete_result,
|
||||
)
|
||||
|
||||
|
||||
def test_successful_delete_invalidates_cache():
|
||||
invalidations = []
|
||||
router = setup_auth_routes(_auth_manager(delete_result=True))
|
||||
handler = _handler(router)
|
||||
result = asyncio.run(handler(DeleteUserRequest(username="bob"), _fake_request(invalidations)))
|
||||
assert result == {"ok": True}
|
||||
assert invalidations == [True], "successful delete must flag the token cache stale"
|
||||
|
||||
|
||||
def test_refused_delete_does_not_invalidate_cache():
|
||||
invalidations = []
|
||||
router = setup_auth_routes(_auth_manager(delete_result=False))
|
||||
handler = _handler(router)
|
||||
try:
|
||||
asyncio.run(handler(DeleteUserRequest(username="admin"), _fake_request(invalidations)))
|
||||
raised = False
|
||||
except Exception:
|
||||
raised = True
|
||||
assert raised, "a refused delete should raise (HTTP 400)"
|
||||
assert invalidations == [], "a refused delete must not touch the token cache"
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Deleting a user must also revoke their API bearer tokens.
|
||||
|
||||
Regression test: delete_user purged cookie sessions but left ApiToken
|
||||
rows behind, so a deleted user could keep authenticating with an
|
||||
"ody_..." bearer token forever.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import importlib
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _real_core_package():
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
core_path = str(root / "core")
|
||||
core = sys.modules.get("core")
|
||||
if core is None:
|
||||
core = types.ModuleType("core")
|
||||
sys.modules["core"] = core
|
||||
core.__path__ = [core_path]
|
||||
if hasattr(core, "auth"):
|
||||
delattr(core, "auth")
|
||||
sys.modules.pop("core.auth", None)
|
||||
return core
|
||||
|
||||
|
||||
def _auth_module():
|
||||
_real_core_package()
|
||||
return importlib.import_module("core.auth")
|
||||
|
||||
|
||||
class _OwnerColumn:
|
||||
"""Mimics a SQLAlchemy column: ApiToken.owner == x yields a marker."""
|
||||
|
||||
def __eq__(self, other):
|
||||
return ("owner ==", other)
|
||||
|
||||
def __hash__(self):
|
||||
return id(self)
|
||||
|
||||
|
||||
class _FakeApiToken:
|
||||
owner = _OwnerColumn()
|
||||
|
||||
|
||||
class _FakeQuery:
|
||||
def __init__(self, recorder):
|
||||
self._recorder = recorder
|
||||
self._conds = []
|
||||
|
||||
def filter(self, *conds):
|
||||
self._conds.extend(conds)
|
||||
return self
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
self._recorder.append(list(self._conds))
|
||||
return len(self._conds)
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self, recorder):
|
||||
self._recorder = recorder
|
||||
|
||||
def query(self, model):
|
||||
assert model is _FakeApiToken
|
||||
return _FakeQuery(self._recorder)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager(tmp_path, monkeypatch):
|
||||
auth_mod = _auth_module()
|
||||
monkeypatch.setattr(auth_mod, "_hash_password", lambda password: f"hash:{password}")
|
||||
monkeypatch.setattr(
|
||||
auth_mod, "_verify_password", lambda password, hashed: hashed == f"hash:{password}"
|
||||
)
|
||||
mgr = auth_mod.AuthManager(str(tmp_path / "auth.json"))
|
||||
assert mgr.create_user("admin", "secret-admin-pw", is_admin=True)
|
||||
assert mgr.create_user("bob", "secret-bob-pw", is_admin=False)
|
||||
return mgr
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_calls(monkeypatch):
|
||||
calls = []
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _fake_db_session():
|
||||
yield _FakeSession(calls)
|
||||
|
||||
db_stub = types.ModuleType("core.database")
|
||||
db_stub.get_db_session = _fake_db_session
|
||||
db_stub.ApiToken = _FakeApiToken
|
||||
monkeypatch.setitem(sys.modules, "core.database", db_stub)
|
||||
return calls
|
||||
|
||||
|
||||
def test_delete_user_revokes_api_tokens(manager, db_calls):
|
||||
assert manager.delete_user("bob", "admin") is True
|
||||
assert "bob" not in manager.users
|
||||
assert db_calls, "delete_user never purged ApiToken rows for the deleted user"
|
||||
assert [("owner ==", "bob")] in db_calls
|
||||
|
||||
|
||||
def test_refused_delete_leaves_tokens_alone(manager, db_calls):
|
||||
assert manager.delete_user("admin", "bob") is False
|
||||
assert "admin" in manager.users
|
||||
assert db_calls == []
|
||||
|
||||
|
||||
def test_unknown_user_leaves_tokens_alone(manager, db_calls):
|
||||
assert manager.delete_user("ghost", "admin") is False
|
||||
assert db_calls == []
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Regression for #1809: document library counters must update after delete.
|
||||
|
||||
documentLibrary.js is a browser module with several DOM-only imports, so this
|
||||
guards the relevant wiring at the source level. A single-card delete used to
|
||||
remove the card and decrement `_libraryTotal`, but the header/chips render from
|
||||
`_libraryLanguages`, which stayed stale until a full library refetch.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SRC = Path(__file__).resolve().parent.parent / "static/js/documentLibrary.js"
|
||||
|
||||
|
||||
def _src() -> str:
|
||||
return SRC.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _between(text: str, start: str, end: str) -> str:
|
||||
begin = text.index(start)
|
||||
finish = text.index(end, begin)
|
||||
return text[begin:finish]
|
||||
|
||||
|
||||
def test_single_delete_updates_language_counters_and_chips():
|
||||
text = _src()
|
||||
|
||||
helper = _between(
|
||||
text,
|
||||
"function libraryRemoveDocumentFromState(docId)",
|
||||
"function libraryRenderGrid()",
|
||||
)
|
||||
assert "_libraryLanguages[lang]" in helper
|
||||
assert "delete _libraryLanguages[lang]" in helper
|
||||
assert "libraryRenderStats();" in helper
|
||||
assert "libraryRenderLangChips();" in helper
|
||||
|
||||
delete_body = _between(
|
||||
text,
|
||||
"async function libraryDeleteSingle(docId, card)",
|
||||
"async function libraryBulkDelete()",
|
||||
)
|
||||
assert "libraryRemoveDocumentFromState(docId);" in delete_body
|
||||
@@ -100,8 +100,8 @@ async def test_auto_summarize_pass_logs_out_imap_on_select_failure(monkeypatch):
|
||||
assert captured.get("connect_called") is True, (
|
||||
"test setup: _imap_connect must be reached for the leak to apply"
|
||||
)
|
||||
assert captured.get("logout_calls", 0) == 1, (
|
||||
f"conn.logout() must be called exactly once on the error path "
|
||||
assert captured.get("logout_calls", 0) >= 1, (
|
||||
f"conn.logout() must be called at least once on the error path "
|
||||
f"(IMAP leak fix). Got logout_calls={captured.get('logout_calls')}, "
|
||||
f"select_calls={captured.get('select_calls')}. Pre-fix the "
|
||||
f"outer `except` returned without logging out the IMAP socket."
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Regression for issue #1961 — read_email (and reply_to_email,
|
||||
download_attachment) failed on iCloud IMAP accounts.
|
||||
|
||||
iCloud's IMAP server silently ignores the legacy bare `RFC822` fetch item: a
|
||||
`UID FETCH <uid> (RFC822)` returns status OK but only `(UID <uid>)` with no body
|
||||
tuple, so the parse treats the message as "not found" — even though list_emails
|
||||
works (it uses `RFC822.HEADER`, which iCloud honours). The modern
|
||||
`BODY.PEEK[]` item is honoured by iCloud and Gmail alike and doesn't set \\Seen.
|
||||
|
||||
The fix is an IMAP-protocol-string change exercised only against a live server,
|
||||
so it's guarded at the source here (per CONTRIBUTING's "guard at source" note):
|
||||
the three full-message fetches must use BODY.PEEK[], and no bare (RFC822) full
|
||||
fetch may remain. The header/uid fetches must be left untouched so listing keeps
|
||||
working.
|
||||
"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
SRC = (Path(__file__).resolve().parent.parent / "mcp_servers/email_server.py").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _full_fetches():
|
||||
# every conn.uid("FETCH", ..., "<item>") call's fetch item
|
||||
return re.findall(r'conn\.uid\(\s*"FETCH"\s*,[^,]+,\s*"([^"]+)"\s*\)', SRC)
|
||||
|
||||
|
||||
def test_full_message_fetches_use_body_peek_not_bare_rfc822():
|
||||
items = _full_fetches()
|
||||
assert items, "no conn.uid FETCH calls found — test anchor stale"
|
||||
# No bare (RFC822) full-message fetch may remain (it breaks iCloud).
|
||||
assert "(RFC822)" not in items, f"a bare (RFC822) full fetch remains: {items}"
|
||||
# The full-message reads now use BODY.PEEK[] — at least the 3 known sites.
|
||||
assert items.count("(BODY.PEEK[])") >= 3, f"expected >=3 BODY.PEEK[] fetches: {items}"
|
||||
|
||||
|
||||
def test_header_and_uid_fetches_preserved():
|
||||
items = _full_fetches()
|
||||
# Listing relies on RFC822.HEADER (iCloud honours it) — must stay.
|
||||
assert "(RFC822.HEADER)" in items, "RFC822.HEADER fetch (used by listing) must be preserved"
|
||||
# UID-only probes must stay as-is.
|
||||
assert "(UID)" in items, "(UID) probe fetch must be preserved"
|
||||
@@ -0,0 +1,9 @@
|
||||
from services.hwfit.image_models import rank_image_models, IMAGE_MODEL_REGISTRY
|
||||
|
||||
|
||||
def test_rank_image_models_handles_non_dict_system():
|
||||
# `system` is the detected-hardware dict; if detection failed and returned
|
||||
# None (or a non-dict), system.get(...) raised AttributeError. Treat a
|
||||
# non-dict system as "unknown hardware" (no GPU) rather than crashing.
|
||||
assert len(rank_image_models(None)) == len(IMAGE_MODEL_REGISTRY)
|
||||
assert len(rank_image_models(123)) == len(IMAGE_MODEL_REGISTRY)
|
||||
@@ -0,0 +1,15 @@
|
||||
from services.hwfit.image_models import rank_image_models, IMAGE_MODEL_REGISTRY
|
||||
|
||||
SYS = {"gpu_vram_gb": 0, "has_gpu": False}
|
||||
|
||||
|
||||
def test_rank_image_models_handles_non_string_search():
|
||||
# search is a CLI/API filter arg; a non-string made search.lower() raise
|
||||
# AttributeError. A non-string search should behave as "no filter".
|
||||
out = rank_image_models(SYS, search=123)
|
||||
assert len(out) == len(IMAGE_MODEL_REGISTRY)
|
||||
|
||||
|
||||
def test_rank_image_models_string_filter_still_applies():
|
||||
out = rank_image_models(SYS, search="zzzznotarealmodelzzz")
|
||||
assert out == []
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Pin matchModelKey (static/js/model/matchKey.js).
|
||||
|
||||
Driven through `node --input-type=module` (same approach as test_compare_js.py);
|
||||
skips when `node` is not installed.
|
||||
|
||||
Regression: model name -> info/pricing lookups returned the FIRST substring
|
||||
match, so "gpt-4o-mini" matched the shorter "gpt-4o" key and was billed at
|
||||
gpt-4o rates (~16x) with the wrong context window.
|
||||
"""
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_REPO = Path(__file__).resolve().parent.parent
|
||||
_HELPER = _REPO / "static" / "js" / "model" / "matchKey.js"
|
||||
_HAS_NODE = shutil.which("node") is not None
|
||||
|
||||
_KEYS = ["gpt-4o", "gpt-4o-mini", "gpt-4", "o1", "o1-mini", "o1-pro", "o3", "o3-mini"]
|
||||
|
||||
|
||||
def _match(name):
|
||||
js = (
|
||||
f"import {{ matchModelKey }} from '{_HELPER.as_posix()}';"
|
||||
f"console.log(JSON.stringify(matchModelKey({json.dumps(name)}, {json.dumps(_KEYS)})));"
|
||||
)
|
||||
proc = subprocess.run(
|
||||
["node", "--input-type=module"],
|
||||
input=js, capture_output=True, text=True, cwd=str(_REPO), timeout=30,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout.strip())
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_prefers_longest_specific_key():
|
||||
assert _match("gpt-4o-mini") == "gpt-4o-mini"
|
||||
assert _match("o1-mini") == "o1-mini"
|
||||
assert _match("o1-pro") == "o1-pro"
|
||||
assert _match("o3-mini") == "o3-mini"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||
def test_base_model_and_unknown():
|
||||
assert _match("gpt-4o-2024-08-06") == "gpt-4o"
|
||||
assert _match("some-unknown-model") is None
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Regression tests for memory import-path compatibility."""
|
||||
|
||||
|
||||
def test_services_memory_manager_is_canonical_src_class():
|
||||
from services.memory import MemoryManager as package_manager
|
||||
from services.memory.memory import MemoryManager as module_manager
|
||||
from src.memory import MemoryManager as canonical_manager
|
||||
|
||||
assert module_manager is canonical_manager
|
||||
assert package_manager is canonical_manager
|
||||
assert hasattr(package_manager, "increment_uses")
|
||||
assert hasattr(package_manager, "claim_ownerless")
|
||||
|
||||
|
||||
def test_services_memory_vector_is_canonical_src_class():
|
||||
from services.memory import MemoryVectorStore as package_vector_store
|
||||
from services.memory.memory_vector import MemoryVectorStore as module_vector_store
|
||||
from src.memory_vector import MemoryVectorStore as canonical_vector_store
|
||||
|
||||
assert module_vector_store is canonical_vector_store
|
||||
assert package_vector_store is canonical_vector_store
|
||||
|
||||
|
||||
def test_memory_service_uses_canonical_manager_api(tmp_path):
|
||||
import asyncio
|
||||
|
||||
from services.memory import MemoryService
|
||||
|
||||
service = MemoryService(str(tmp_path))
|
||||
|
||||
remembered = asyncio.run(service.remember("User prefers dark mode", session_id="sess-1"))
|
||||
assert remembered.text == "User prefers dark mode"
|
||||
assert remembered.session_id == "sess-1"
|
||||
|
||||
all_memories = service.get_all()
|
||||
assert [m.id for m in all_memories] == [remembered.id]
|
||||
|
||||
recalled = asyncio.run(service.recall("dark mode", top_k=5))
|
||||
assert [m.id for m in recalled.memories] == [remembered.id]
|
||||
|
||||
assert service.delete(remembered.id) is True
|
||||
assert service.delete(remembered.id) is False
|
||||
assert service.get_all() == []
|
||||
|
||||
|
||||
def test_canonical_manager_keeps_ownerless_claim_helper(tmp_path):
|
||||
from src.memory import MemoryManager
|
||||
|
||||
manager = MemoryManager(str(tmp_path))
|
||||
entry = manager.add_entry("User likes compact code reviews")
|
||||
manager.save([entry])
|
||||
|
||||
manager.claim_ownerless("alice")
|
||||
|
||||
memories = manager.load_all()
|
||||
assert memories[0]["owner"] == "alice"
|
||||
@@ -1,11 +1,59 @@
|
||||
"""Tests for model_context.py — local endpoint detection, token estimation, known model lookup."""
|
||||
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
import src.model_context as model_context
|
||||
from src.model_context import _is_local_endpoint, estimate_tokens, _lookup_known
|
||||
|
||||
|
||||
class _Column:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
def __eq__(self, value):
|
||||
return ("eq", self.name, value)
|
||||
|
||||
|
||||
class _ModelEndpoint:
|
||||
is_enabled = _Column("is_enabled")
|
||||
|
||||
|
||||
class _Query:
|
||||
def __init__(self, rows):
|
||||
self.rows = list(rows)
|
||||
|
||||
def filter(self, *conditions):
|
||||
for condition in conditions:
|
||||
if isinstance(condition, tuple) and condition[0] == "eq":
|
||||
_, field, value = condition
|
||||
self.rows = [row for row in self.rows if getattr(row, field) == value]
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return list(self.rows)
|
||||
|
||||
|
||||
class _Db:
|
||||
def __init__(self, rows):
|
||||
self.rows = rows
|
||||
|
||||
def query(self, model):
|
||||
return _Query(self.rows)
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
def _install_endpoint_db(monkeypatch, rows):
|
||||
mod = types.ModuleType("core.database")
|
||||
mod.ModelEndpoint = _ModelEndpoint
|
||||
mod.SessionLocal = lambda: _Db(rows)
|
||||
monkeypatch.setitem(sys.modules, "core.database", mod)
|
||||
|
||||
|
||||
class TestIsLocalEndpoint:
|
||||
def test_localhost(self):
|
||||
assert _is_local_endpoint("http://localhost:5000/v1/chat/completions") is True
|
||||
@@ -23,6 +71,18 @@ class TestIsLocalEndpoint:
|
||||
# 100.64.0.0/10 is the CGNAT range Tailscale uses.
|
||||
assert _is_local_endpoint("http://100.64.0.1:5000/v1/chat/completions") is True
|
||||
|
||||
def test_configured_tailscale_proxy_is_remote(self, monkeypatch):
|
||||
_install_endpoint_db(monkeypatch, [
|
||||
types.SimpleNamespace(
|
||||
base_url="http://100.117.136.97:34521/v1",
|
||||
endpoint_kind="proxy",
|
||||
api_key="fake-key",
|
||||
is_enabled=True,
|
||||
)
|
||||
])
|
||||
|
||||
assert _is_local_endpoint("http://100.117.136.97:34521/v1/chat/completions") is False
|
||||
|
||||
def test_openai_is_remote(self):
|
||||
assert _is_local_endpoint("https://api.openai.com/v1/chat/completions") is False
|
||||
|
||||
@@ -164,3 +224,28 @@ class TestGetContextLength:
|
||||
assert first == 200000
|
||||
assert second == 200000
|
||||
assert len(calls) == 1
|
||||
|
||||
def test_configured_proxy_uses_default_without_model_listing(self, monkeypatch):
|
||||
_install_endpoint_db(monkeypatch, [
|
||||
types.SimpleNamespace(
|
||||
base_url="http://100.117.136.97:34521/v1",
|
||||
endpoint_kind="proxy",
|
||||
api_key="fake-key",
|
||||
is_enabled=True,
|
||||
)
|
||||
])
|
||||
calls = []
|
||||
|
||||
def fake_get(*args, **kwargs):
|
||||
calls.append(args)
|
||||
raise AssertionError("/models should not be queried for configured proxy context")
|
||||
|
||||
monkeypatch.setattr(model_context.httpx, "get", fake_get)
|
||||
|
||||
endpoint = "http://100.117.136.97:34521/v1/chat/completions"
|
||||
first = model_context.get_context_length(endpoint, "unknown-proxy-model")
|
||||
second = model_context.get_context_length(endpoint, "unknown-proxy-model")
|
||||
|
||||
assert first == model_context.DEFAULT_CONTEXT
|
||||
assert second == model_context.DEFAULT_CONTEXT
|
||||
assert calls == []
|
||||
|
||||
+558
-54
@@ -2,9 +2,11 @@
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -28,7 +30,9 @@ if "core.database" not in sys.modules:
|
||||
sys.modules["core.database"] = _core_db
|
||||
|
||||
import routes.model_routes as model_routes
|
||||
import src.database as src_database
|
||||
import src.endpoint_resolver as endpoint_resolver
|
||||
import src.llm_core as llm_core
|
||||
from routes.model_routes import (
|
||||
_match_provider_curated,
|
||||
_curate_models,
|
||||
@@ -36,7 +40,11 @@ from routes.model_routes import (
|
||||
_normalize_model_ids,
|
||||
_is_chat_model,
|
||||
_classify_endpoint,
|
||||
_effective_endpoint_kind,
|
||||
_probe_endpoint,
|
||||
_ping_endpoint,
|
||||
_parse_model_list,
|
||||
_normalize_refresh_mode,
|
||||
_truthy,
|
||||
_speech_settings_using_endpoint,
|
||||
_clear_speech_settings_for_endpoint,
|
||||
@@ -304,6 +312,54 @@ class TestClassifyEndpoint:
|
||||
def test_malformed_url(self):
|
||||
assert _classify_endpoint("not-a-url") == "api"
|
||||
|
||||
def test_tailscale_auto_is_local(self):
|
||||
assert _classify_endpoint("http://100.117.136.97:34521/v1") == "local"
|
||||
|
||||
def test_tailscale_proxy_override_is_api(self):
|
||||
assert _classify_endpoint("http://100.117.136.97:34521/v1", "proxy") == "api"
|
||||
|
||||
def test_tailscale_api_override_is_api(self):
|
||||
assert _classify_endpoint("http://100.117.136.97:34521/v1", "api") == "api"
|
||||
|
||||
def test_public_local_override_is_local(self):
|
||||
assert _classify_endpoint("https://api.openai.com/v1", "local") == "local"
|
||||
|
||||
def test_keyed_legacy_v1_endpoint_is_effective_proxy(self):
|
||||
ep = SimpleNamespace(endpoint_kind="auto", api_key="fake-key")
|
||||
assert _effective_endpoint_kind(ep, "http://100.117.136.97:34521/v1") == "proxy"
|
||||
|
||||
def test_proxy_refresh_mode_defaults_manual(self):
|
||||
assert _normalize_refresh_mode("", "proxy") == "manual"
|
||||
assert _normalize_refresh_mode("auto", "proxy") == "manual"
|
||||
assert _normalize_refresh_mode("manual", "proxy") == "manual"
|
||||
assert _normalize_refresh_mode("auto", "api") == "auto"
|
||||
|
||||
def test_parse_model_list_accepts_json_and_text(self):
|
||||
assert _parse_model_list('["a", "b", "a"]') == ["a", "b"]
|
||||
assert _parse_model_list("a, b\nc") == ["a", "b", "c"]
|
||||
|
||||
def test_ping_endpoint_does_not_request_models_for_openai_style_proxy(self, monkeypatch):
|
||||
monkeypatch.setattr(endpoint_resolver, "resolve_url", lambda url: url, raising=False)
|
||||
seen = []
|
||||
|
||||
def fake_head(*args, **kwargs):
|
||||
raise AssertionError("generic proxy health check should not use HEAD")
|
||||
|
||||
def fake_get(url, headers=None, timeout=None):
|
||||
seen.append(("GET", url))
|
||||
request = httpx.Request("GET", url)
|
||||
return httpx.Response(200, request=request)
|
||||
|
||||
monkeypatch.setattr(model_routes.httpx, "head", fake_head)
|
||||
monkeypatch.setattr(model_routes.httpx, "get", fake_get)
|
||||
|
||||
result = _ping_endpoint("http://100.117.136.97:34521/v1", "fake-key", timeout=1)
|
||||
|
||||
assert result["reachable"] is True
|
||||
assert result["status_code"] == 200
|
||||
assert seen == [("GET", "http://100.117.136.97:34521/v1")]
|
||||
assert all(not url.endswith("/models") for _, url in seen)
|
||||
|
||||
|
||||
# ── setup probing ──
|
||||
|
||||
@@ -534,77 +590,51 @@ if "python_multipart" not in sys.modules:
|
||||
sys.modules["python_multipart"] = _mp_stub
|
||||
|
||||
|
||||
class _PinnedFakeQuery:
|
||||
def __init__(self, rows):
|
||||
self.rows = list(rows)
|
||||
|
||||
def filter(self, *conditions):
|
||||
return self
|
||||
|
||||
def order_by(self, *args):
|
||||
return self
|
||||
|
||||
def first(self):
|
||||
return self.rows[0] if self.rows else None
|
||||
|
||||
def all(self):
|
||||
return list(self.rows)
|
||||
|
||||
|
||||
class _PinnedFakeDb:
|
||||
def __init__(self, rows):
|
||||
self.rows = rows
|
||||
self.added = []
|
||||
self.committed = 0
|
||||
|
||||
def query(self, model):
|
||||
return _PinnedFakeQuery(self.rows)
|
||||
|
||||
def add(self, row):
|
||||
self.added.append(row)
|
||||
|
||||
def commit(self):
|
||||
self.committed += 1
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class _FakeCol:
|
||||
"""Column stand-in: every comparison/operator just returns itself so the
|
||||
dedupe query expressions evaluate without a real SQLAlchemy column."""
|
||||
|
||||
__hash__ = None
|
||||
|
||||
def __eq__(self, other):
|
||||
return self
|
||||
|
||||
def is_(self, other):
|
||||
return self
|
||||
class _RouteCondition:
|
||||
def __init__(self, op, field, value):
|
||||
self.op = op
|
||||
self.field = field
|
||||
self.value = value
|
||||
|
||||
def __or__(self, other):
|
||||
return self
|
||||
return ("or", self, other)
|
||||
|
||||
|
||||
class _RouteColumn:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
|
||||
def __eq__(self, value):
|
||||
return _RouteCondition("eq", self.name, value)
|
||||
|
||||
def is_(self, value):
|
||||
return _RouteCondition("eq", self.name, value)
|
||||
|
||||
def desc(self):
|
||||
return self
|
||||
|
||||
|
||||
class _RecordingEndpoint:
|
||||
class _RouteModelEndpoint:
|
||||
"""ModelEndpoint stand-in that stores constructor kwargs as attributes.
|
||||
|
||||
Class-level fake columns let it double as the query class in the dedupe
|
||||
lookup; instance attributes (set in __init__) shadow them per-row.
|
||||
"""
|
||||
|
||||
id = _FakeCol()
|
||||
base_url = _FakeCol()
|
||||
owner = _FakeCol()
|
||||
id = _RouteColumn("id")
|
||||
base_url = _RouteColumn("base_url")
|
||||
is_enabled = _RouteColumn("is_enabled")
|
||||
owner = _RouteColumn("owner")
|
||||
created_at = _RouteColumn("created_at")
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
for key, value in kwargs.items():
|
||||
setattr(self, key, value)
|
||||
|
||||
|
||||
_RecordingEndpoint = _RouteModelEndpoint
|
||||
|
||||
|
||||
class _PinnedFakeRequest:
|
||||
def __init__(self, body=None, headers=None):
|
||||
self._body = body if body is not None else {}
|
||||
@@ -635,6 +665,13 @@ def _make_endpoint(**kwargs):
|
||||
pinned_models=None,
|
||||
model_type="llm",
|
||||
supports_tools=None,
|
||||
endpoint_kind="auto",
|
||||
model_refresh_mode="auto",
|
||||
model_refresh_interval=None,
|
||||
model_refresh_timeout=None,
|
||||
owner=None,
|
||||
created_at=None,
|
||||
updated_at=None,
|
||||
)
|
||||
base.update(kwargs)
|
||||
return SimpleNamespace(**base)
|
||||
@@ -676,7 +713,7 @@ def test_get_models_returns_pinned_when_probe_empty(monkeypatch):
|
||||
monkeypatch.setattr(model_routes, "_probe_endpoint", lambda *a, **k: [])
|
||||
endpoint = _get_route("/api/model-endpoints/{ep_id}/models", "GET")
|
||||
|
||||
result = endpoint("ep1", _PinnedFakeRequest())
|
||||
result = endpoint("ep1", _PinnedFakeRequest(), SimpleNamespace(headers={}))
|
||||
|
||||
ids = [row["id"] for row in result]
|
||||
assert ids == ["deploy-1"]
|
||||
@@ -730,6 +767,10 @@ def _create_form_kwargs(**overrides):
|
||||
skip_probe="true", # avoid any network probe in unit tests
|
||||
require_models="false",
|
||||
model_type="llm",
|
||||
endpoint_kind="auto",
|
||||
model_refresh_mode="",
|
||||
model_refresh_interval="",
|
||||
model_refresh_timeout="",
|
||||
supports_tools="",
|
||||
pinned_models="",
|
||||
container_local="false",
|
||||
@@ -772,6 +813,7 @@ def test_post_creates_endpoint_with_pinned_models(monkeypatch):
|
||||
|
||||
def test_post_dedupe_existing_merges_and_returns_pinned(monkeypatch):
|
||||
existing = _make_endpoint(
|
||||
base_url="http://host:1234/v1",
|
||||
cached_models=json.dumps(["m1"]),
|
||||
hidden_models=None,
|
||||
pinned_models=json.dumps(["old-pin"]),
|
||||
@@ -798,6 +840,7 @@ def test_post_dedupe_existing_merges_and_returns_pinned(monkeypatch):
|
||||
|
||||
def test_post_dedupe_existing_does_not_clobber_pinned_when_omitted(monkeypatch):
|
||||
existing = _make_endpoint(
|
||||
base_url="http://host:1234/v1",
|
||||
cached_models=json.dumps(["m1"]),
|
||||
pinned_models=json.dumps(["keep-me"]),
|
||||
)
|
||||
@@ -814,3 +857,464 @@ def test_post_dedupe_existing_does_not_clobber_pinned_when_omitted(monkeypatch):
|
||||
assert json.loads(existing.pinned_models) == ["keep-me"]
|
||||
assert result["pinned_models"] == ["keep-me"]
|
||||
assert db.committed == 0 # nothing to persist
|
||||
class _RouteQuery:
|
||||
def __init__(self, rows):
|
||||
self.rows = list(rows)
|
||||
|
||||
def filter(self, *conditions):
|
||||
for condition in conditions:
|
||||
if isinstance(condition, _RouteCondition) and condition.op == "eq":
|
||||
self.rows = [row for row in self.rows if getattr(row, condition.field, None) == condition.value]
|
||||
elif isinstance(condition, tuple) and condition and condition[0] == "or":
|
||||
keep = []
|
||||
for row in self.rows:
|
||||
matched = False
|
||||
for part in condition[1:]:
|
||||
if isinstance(part, _RouteCondition) and part.op == "eq":
|
||||
matched = matched or (getattr(row, part.field, None) == part.value)
|
||||
if matched:
|
||||
keep.append(row)
|
||||
self.rows = keep
|
||||
return self
|
||||
|
||||
def order_by(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return list(self.rows)
|
||||
|
||||
def first(self):
|
||||
return self.rows[0] if self.rows else None
|
||||
|
||||
|
||||
class _RouteDb:
|
||||
def __init__(self, rows):
|
||||
self.rows = rows
|
||||
self.added = []
|
||||
self.committed = 0
|
||||
self.commits = 0
|
||||
self.closed = False
|
||||
|
||||
def query(self, model):
|
||||
return _RouteQuery(self.rows)
|
||||
|
||||
def commit(self):
|
||||
self.committed += 1
|
||||
self.commits += 1
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
def add(self, row):
|
||||
self.rows.append(row)
|
||||
self.added.append(row)
|
||||
|
||||
|
||||
_PinnedFakeDb = _RouteDb
|
||||
|
||||
|
||||
class _ImmediateThread:
|
||||
def __init__(self, target, daemon=None):
|
||||
self.target = target
|
||||
|
||||
def start(self):
|
||||
self.target()
|
||||
|
||||
|
||||
def _wait_for(predicate, timeout=2.0):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.01)
|
||||
return bool(predicate())
|
||||
|
||||
|
||||
def _route_endpoint(router, path, method="GET"):
|
||||
for route in router.routes:
|
||||
if getattr(route, "path", "") == path and method in getattr(route, "methods", set()):
|
||||
return route.endpoint
|
||||
raise AssertionError(f"{method} {path} route not found")
|
||||
|
||||
|
||||
def _route_ep(
|
||||
id,
|
||||
base_url,
|
||||
*,
|
||||
cached_models=None,
|
||||
endpoint_kind="auto",
|
||||
api_key=None,
|
||||
name=None,
|
||||
pinned_models=None,
|
||||
refresh_mode="auto",
|
||||
refresh_timeout=None,
|
||||
):
|
||||
return SimpleNamespace(
|
||||
id=id,
|
||||
name=name or id,
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
is_enabled=True,
|
||||
cached_models=json.dumps(cached_models) if cached_models is not None else None,
|
||||
hidden_models=None,
|
||||
pinned_models=json.dumps(pinned_models) if pinned_models is not None else None,
|
||||
model_type="llm",
|
||||
endpoint_kind=endpoint_kind,
|
||||
model_refresh_mode=refresh_mode,
|
||||
model_refresh_interval=None,
|
||||
model_refresh_timeout=refresh_timeout,
|
||||
supports_tools=None,
|
||||
owner=None,
|
||||
created_at=None,
|
||||
updated_at=None,
|
||||
)
|
||||
|
||||
|
||||
def _route_request():
|
||||
return SimpleNamespace(
|
||||
state=SimpleNamespace(current_user=None),
|
||||
app=SimpleNamespace(state=SimpleNamespace(auth_manager=None)),
|
||||
)
|
||||
|
||||
|
||||
def test_api_models_returns_cached_proxy_models_without_refresh_probe(monkeypatch):
|
||||
row = _route_ep(
|
||||
"proxy",
|
||||
"http://100.117.136.97:34521/v1",
|
||||
cached_models=["cached-model"],
|
||||
endpoint_kind="proxy",
|
||||
api_key="fake-key",
|
||||
refresh_mode="manual",
|
||||
)
|
||||
db = _RouteDb([row])
|
||||
router = model_routes.setup_model_routes(model_discovery=None)
|
||||
|
||||
monkeypatch.setattr(model_routes, "ModelEndpoint", _RouteModelEndpoint)
|
||||
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(model_routes, "_auth_disabled", lambda: True)
|
||||
monkeypatch.setattr(model_routes, "build_chat_url", lambda base: f"{base}/chat/completions")
|
||||
|
||||
def fail_probe(*args, **kwargs):
|
||||
raise AssertionError("/models probe should not run for cached manual proxy")
|
||||
|
||||
monkeypatch.setattr(model_routes, "_probe_endpoint", fail_probe)
|
||||
monkeypatch.setattr(threading, "Thread", _ImmediateThread)
|
||||
|
||||
result = _route_endpoint(router, "/api/models")(_route_request())
|
||||
|
||||
assert result["items"][0]["models"] == ["cached-model"]
|
||||
assert result["items"][0]["category"] == "api"
|
||||
assert result["items"][0]["endpoint_kind"] == "proxy"
|
||||
assert "offline" not in result["items"][0]
|
||||
assert json.loads(row.cached_models) == ["cached-model"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_local_skips_tailscale_proxy_endpoint(monkeypatch):
|
||||
proxy = _route_ep(
|
||||
"proxy",
|
||||
"http://100.117.136.97:34521/v1",
|
||||
cached_models=["cached-model"],
|
||||
endpoint_kind="proxy",
|
||||
api_key="fake-key",
|
||||
)
|
||||
local = _route_ep("local", "http://127.0.0.1:8000/v1", endpoint_kind="local")
|
||||
db = _RouteDb([proxy, local])
|
||||
router = model_routes.setup_model_routes(model_discovery=None)
|
||||
|
||||
monkeypatch.setattr(model_routes, "ModelEndpoint", _RouteModelEndpoint)
|
||||
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
|
||||
monkeypatch.setattr(model_routes, "_probe_endpoint", lambda *a, **k: (_ for _ in ()).throw(AssertionError("full probe should not run")))
|
||||
|
||||
pinged = []
|
||||
|
||||
def fake_ping(base_url, api_key=None, timeout=1.5):
|
||||
pinged.append(base_url)
|
||||
return {"reachable": True, "status_code": 404, "error": "HTTP 404"}
|
||||
|
||||
monkeypatch.setattr(model_routes, "_ping_endpoint", fake_ping)
|
||||
|
||||
result = await _route_endpoint(router, "/api/model-endpoints/probe-local")(_route_request())
|
||||
|
||||
assert set(result) == {"local"}
|
||||
assert pinged == ["http://127.0.0.1:8000/v1"]
|
||||
|
||||
|
||||
def test_background_refresh_deduplicates_same_base_url(monkeypatch):
|
||||
ep1 = _route_ep("a", "http://127.0.0.1:8000/v1", endpoint_kind="local")
|
||||
ep2 = _route_ep("b", "http://127.0.0.1:8000/v1", endpoint_kind="local")
|
||||
db = _RouteDb([ep1, ep2])
|
||||
router = model_routes.setup_model_routes(model_discovery=None)
|
||||
|
||||
monkeypatch.setattr(model_routes, "ModelEndpoint", _RouteModelEndpoint)
|
||||
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(model_routes, "_auth_disabled", lambda: True)
|
||||
monkeypatch.setattr(model_routes, "build_chat_url", lambda base: f"{base}/chat/completions")
|
||||
|
||||
calls = []
|
||||
probe_done = threading.Event()
|
||||
|
||||
def fake_probe(base_url, api_key=None, timeout=2):
|
||||
calls.append(base_url)
|
||||
probe_done.set()
|
||||
return ["live-model"]
|
||||
|
||||
monkeypatch.setattr(model_routes, "_probe_endpoint", fake_probe)
|
||||
|
||||
_route_endpoint(router, "/api/models")(_route_request(), refresh=True)
|
||||
|
||||
assert probe_done.wait(2)
|
||||
assert _wait_for(lambda: ep1.cached_models and ep2.cached_models)
|
||||
assert calls == ["http://127.0.0.1:8000/v1"]
|
||||
assert json.loads(ep1.cached_models) == ["live-model"]
|
||||
assert json.loads(ep2.cached_models) == ["live-model"]
|
||||
|
||||
|
||||
def test_background_refresh_failure_keeps_existing_cached_models(monkeypatch):
|
||||
ep = _route_ep(
|
||||
"local",
|
||||
"http://127.0.0.1:8000/v1",
|
||||
cached_models=["cached-model"],
|
||||
endpoint_kind="local",
|
||||
)
|
||||
db = _RouteDb([ep])
|
||||
router = model_routes.setup_model_routes(model_discovery=None)
|
||||
|
||||
monkeypatch.setattr(model_routes, "ModelEndpoint", _RouteModelEndpoint)
|
||||
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(model_routes, "_auth_disabled", lambda: True)
|
||||
monkeypatch.setattr(model_routes, "build_chat_url", lambda base: f"{base}/chat/completions")
|
||||
probe_done = threading.Event()
|
||||
|
||||
def fake_probe(*args, **kwargs):
|
||||
probe_done.set()
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(model_routes, "_probe_endpoint", fake_probe)
|
||||
|
||||
result = _route_endpoint(router, "/api/models")(_route_request(), refresh=True)
|
||||
|
||||
assert probe_done.wait(2)
|
||||
assert _wait_for(lambda: db.commits > 0)
|
||||
assert result["items"][0]["models"] == ["cached-model"]
|
||||
assert json.loads(ep.cached_models) == ["cached-model"]
|
||||
|
||||
|
||||
def test_llm_core_list_model_ids_uses_cached_configured_proxy(monkeypatch):
|
||||
ep = _route_ep(
|
||||
"proxy",
|
||||
"http://100.117.136.97:34521/v1",
|
||||
cached_models=["cached-model", "hidden-model"],
|
||||
endpoint_kind="proxy",
|
||||
)
|
||||
ep.hidden_models = json.dumps(["hidden-model"])
|
||||
db = _RouteDb([ep])
|
||||
|
||||
monkeypatch.setattr(src_database, "ModelEndpoint", _RouteModelEndpoint)
|
||||
monkeypatch.setattr(src_database, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(llm_core.httpx, "get", lambda *a, **k: (_ for _ in ()).throw(AssertionError("/models should not be fetched")))
|
||||
|
||||
assert llm_core.list_model_ids("http://100.117.136.97:34521/v1/chat/completions", timeout=1) == ["cached-model"]
|
||||
|
||||
|
||||
def test_explicit_proxy_test_fetches_models_with_long_timeout(monkeypatch):
|
||||
router = model_routes.setup_model_routes(model_discovery=None)
|
||||
|
||||
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
|
||||
monkeypatch.setattr(model_routes, "_ping_endpoint", lambda *a, **k: (_ for _ in ()).throw(AssertionError("ping should not run when model listing succeeds")))
|
||||
|
||||
calls = []
|
||||
returned = ["NVIDIA NIM/openai/gpt-oss-120b", "mistral/mistral-small-2603"]
|
||||
|
||||
def fake_probe(base_url, api_key=None, timeout=2):
|
||||
calls.append({"base_url": base_url, "api_key": api_key, "timeout": timeout})
|
||||
return returned
|
||||
|
||||
monkeypatch.setattr(model_routes, "_probe_endpoint", fake_probe)
|
||||
|
||||
result = _route_endpoint(router, "/api/model-endpoints/test", "POST")(
|
||||
_route_request(),
|
||||
base_url="http://100.117.136.97:34521/v1",
|
||||
api_key="fake-key",
|
||||
endpoint_kind="proxy",
|
||||
)
|
||||
|
||||
assert result["online"] is True
|
||||
assert result["status"] == "online"
|
||||
assert result["models"] == returned
|
||||
assert calls == [{
|
||||
"base_url": "http://100.117.136.97:34521/v1",
|
||||
"api_key": "fake-key",
|
||||
"timeout": 30.0,
|
||||
}]
|
||||
|
||||
|
||||
def test_explicit_proxy_add_fetches_and_caches_models_with_long_timeout(monkeypatch):
|
||||
db = _RouteDb([])
|
||||
router = model_routes.setup_model_routes(model_discovery=None)
|
||||
|
||||
monkeypatch.setattr(model_routes, "ModelEndpoint", _RouteModelEndpoint)
|
||||
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
|
||||
monkeypatch.setattr(model_routes, "_load_settings", lambda: {})
|
||||
monkeypatch.setattr(model_routes, "_save_settings", lambda settings: None)
|
||||
monkeypatch.setattr("src.auth_helpers.get_current_user", lambda request: None)
|
||||
monkeypatch.setattr(model_routes, "_ping_endpoint", lambda *a, **k: (_ for _ in ()).throw(AssertionError("ping should not run when model listing succeeds")))
|
||||
|
||||
calls = []
|
||||
returned = ["NVIDIA NIM/openai/gpt-oss-120b", "mistral/mistral-small-2603"]
|
||||
|
||||
def fake_probe(base_url, api_key=None, timeout=2):
|
||||
calls.append({"base_url": base_url, "api_key": api_key, "timeout": timeout})
|
||||
return returned
|
||||
|
||||
monkeypatch.setattr(model_routes, "_probe_endpoint", fake_probe)
|
||||
|
||||
result = _route_endpoint(router, "/api/model-endpoints", "POST")(
|
||||
_route_request(),
|
||||
name="Bifrost",
|
||||
base_url="http://100.117.136.97:34521/v1",
|
||||
api_key="fake-key",
|
||||
skip_probe="true",
|
||||
require_models="false",
|
||||
model_type="llm",
|
||||
endpoint_kind="proxy",
|
||||
model_refresh_mode="manual",
|
||||
model_refresh_interval="",
|
||||
model_refresh_timeout="",
|
||||
supports_tools="",
|
||||
container_local="false",
|
||||
shared="true",
|
||||
)
|
||||
|
||||
assert result["online"] is True
|
||||
assert result["status"] == "online"
|
||||
assert result["models"] == returned
|
||||
assert calls == [{
|
||||
"base_url": "http://100.117.136.97:34521/v1",
|
||||
"api_key": "fake-key",
|
||||
"timeout": 30.0,
|
||||
}]
|
||||
assert len(db.rows) == 1
|
||||
assert json.loads(db.rows[0].cached_models) == returned
|
||||
assert db.rows[0].endpoint_kind == "proxy"
|
||||
assert db.rows[0].model_refresh_mode == "manual"
|
||||
|
||||
|
||||
def test_manual_refresh_uses_long_timeout_and_saves_full_model_list(monkeypatch):
|
||||
ep = _route_ep(
|
||||
"proxy",
|
||||
"http://100.117.136.97:34521/v1",
|
||||
cached_models=["cached-model"],
|
||||
endpoint_kind="proxy",
|
||||
api_key="fake-key",
|
||||
refresh_mode="manual",
|
||||
)
|
||||
db = _RouteDb([ep])
|
||||
router = model_routes.setup_model_routes(model_discovery=None)
|
||||
|
||||
monkeypatch.setattr(model_routes, "ModelEndpoint", _RouteModelEndpoint)
|
||||
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
|
||||
|
||||
calls = []
|
||||
refreshed = ["cached-model", "mistral/mistral-small-2603", "provider/nested/model/id"]
|
||||
|
||||
def fake_probe(base_url, api_key=None, timeout=2):
|
||||
calls.append({"base_url": base_url, "api_key": api_key, "timeout": timeout})
|
||||
return refreshed
|
||||
|
||||
monkeypatch.setattr(model_routes, "_probe_endpoint", fake_probe)
|
||||
|
||||
response = SimpleNamespace(headers={})
|
||||
result = _route_endpoint(router, "/api/model-endpoints/{ep_id}/models")(
|
||||
"proxy",
|
||||
_route_request(),
|
||||
response,
|
||||
refresh=True,
|
||||
refresh_timeout=60,
|
||||
)
|
||||
|
||||
assert [m["id"] for m in result] == refreshed
|
||||
assert calls == [{
|
||||
"base_url": "http://100.117.136.97:34521/v1",
|
||||
"api_key": "fake-key",
|
||||
"timeout": 60.0,
|
||||
}]
|
||||
assert json.loads(ep.cached_models) == refreshed
|
||||
assert db.commits == 1
|
||||
assert response.headers["X-Model-Refresh-Status"] == "refreshed"
|
||||
assert response.headers["X-Model-Refresh-Count"] == "3"
|
||||
|
||||
|
||||
def test_manual_refresh_defaults_to_proxy_long_timeout(monkeypatch):
|
||||
ep = _route_ep(
|
||||
"proxy",
|
||||
"https://proxy.example.test/v1",
|
||||
cached_models=["cached-model"],
|
||||
endpoint_kind="proxy",
|
||||
refresh_mode="manual",
|
||||
)
|
||||
db = _RouteDb([ep])
|
||||
router = model_routes.setup_model_routes(model_discovery=None)
|
||||
|
||||
monkeypatch.setattr(model_routes, "ModelEndpoint", _RouteModelEndpoint)
|
||||
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
|
||||
|
||||
timeouts = []
|
||||
|
||||
def fake_probe(base_url, api_key=None, timeout=2):
|
||||
timeouts.append(timeout)
|
||||
return ["cached-model", "new-model"]
|
||||
|
||||
monkeypatch.setattr(model_routes, "_probe_endpoint", fake_probe)
|
||||
|
||||
response = SimpleNamespace(headers={})
|
||||
_route_endpoint(router, "/api/model-endpoints/{ep_id}/models")(
|
||||
"proxy",
|
||||
_route_request(),
|
||||
response,
|
||||
refresh=True,
|
||||
)
|
||||
|
||||
assert timeouts == [30.0]
|
||||
assert json.loads(ep.cached_models) == ["cached-model", "new-model"]
|
||||
|
||||
|
||||
def test_manual_refresh_timeout_keeps_cached_models_and_warns(monkeypatch):
|
||||
ep = _route_ep(
|
||||
"proxy",
|
||||
"http://100.117.136.97:34521/v1",
|
||||
cached_models=["cached-model"],
|
||||
endpoint_kind="proxy",
|
||||
api_key="fake-key",
|
||||
refresh_mode="manual",
|
||||
)
|
||||
db = _RouteDb([ep])
|
||||
router = model_routes.setup_model_routes(model_discovery=None)
|
||||
|
||||
monkeypatch.setattr(model_routes, "ModelEndpoint", _RouteModelEndpoint)
|
||||
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
|
||||
|
||||
def fake_probe(base_url, api_key=None, timeout=2):
|
||||
raise httpx.TimeoutException("timed out")
|
||||
|
||||
monkeypatch.setattr(model_routes, "_probe_endpoint", fake_probe)
|
||||
|
||||
response = SimpleNamespace(headers={})
|
||||
result = _route_endpoint(router, "/api/model-endpoints/{ep_id}/models")(
|
||||
"proxy",
|
||||
_route_request(),
|
||||
response,
|
||||
refresh=True,
|
||||
refresh_timeout=60,
|
||||
)
|
||||
|
||||
assert [m["id"] for m in result] == ["cached-model"]
|
||||
assert json.loads(ep.cached_models) == ["cached-model"]
|
||||
assert db.commits == 0
|
||||
assert response.headers["X-Model-Refresh-Status"] == "failed"
|
||||
assert "kept cached models" in response.headers["X-Model-Refresh-Warning"]
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
"""Regression tests for cross-platform helper behavior."""
|
||||
|
||||
from core import platform_compat
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_MODULE_PATH = Path(__file__).resolve().parents[1] / "core" / "platform_compat.py"
|
||||
_SPEC = importlib.util.spec_from_file_location("platform_compat_under_test", _MODULE_PATH)
|
||||
platform_compat = importlib.util.module_from_spec(_SPEC)
|
||||
assert _SPEC and _SPEC.loader
|
||||
_SPEC.loader.exec_module(platform_compat)
|
||||
|
||||
|
||||
def _reset_bash_cache(monkeypatch):
|
||||
@@ -35,3 +43,19 @@ def test_find_bash_checks_local_app_data_git_install(monkeypatch):
|
||||
monkeypatch.setattr(platform_compat.os.path, "exists", lambda path: path == expected)
|
||||
|
||||
assert platform_compat.find_bash() == expected
|
||||
|
||||
|
||||
def test_find_bash_skips_windows_wsl_stub(monkeypatch):
|
||||
_reset_bash_cache(monkeypatch)
|
||||
monkeypatch.setattr(platform_compat, "IS_WINDOWS", True)
|
||||
|
||||
stub = r"C:\WINDOWS\system32\bash.exe"
|
||||
expected = r"C:\Program Files\Git\bin\bash.exe"
|
||||
monkeypatch.setattr(
|
||||
platform_compat.shutil,
|
||||
"which",
|
||||
lambda name: stub if name == "bash" else None,
|
||||
)
|
||||
monkeypatch.setattr(platform_compat.os.path, "exists", lambda path: path == expected)
|
||||
|
||||
assert platform_compat.find_bash() == expected
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import json
|
||||
|
||||
import routes.prefs_routes as prefs_routes
|
||||
|
||||
|
||||
def test_save_replaces_prefs_file_atomically(monkeypatch, tmp_path):
|
||||
calls = []
|
||||
real_replace = prefs_routes.os.replace
|
||||
|
||||
def fake_replace(src, dst):
|
||||
calls.append((src, dst))
|
||||
real_replace(src, dst)
|
||||
|
||||
prefs_file = tmp_path / "data" / "user_prefs.json"
|
||||
monkeypatch.setattr(prefs_routes, "PREFS_FILE", str(prefs_file))
|
||||
monkeypatch.setattr(prefs_routes.os, "replace", fake_replace)
|
||||
|
||||
prefs_routes._save({"theme": "dark"})
|
||||
|
||||
assert len(calls) == 1
|
||||
src, dst = calls[0]
|
||||
assert dst == str(prefs_file)
|
||||
assert src.startswith(str(prefs_file) + ".tmp.")
|
||||
assert json.loads(prefs_file.read_text(encoding="utf-8")) == {"theme": "dark"}
|
||||
assert not list(prefs_file.parent.glob("*.tmp.*"))
|
||||
|
||||
|
||||
def test_save_for_user_preserves_scoped_user_prefs(monkeypatch, tmp_path):
|
||||
prefs_file = tmp_path / "data" / "user_prefs.json"
|
||||
monkeypatch.setattr(prefs_routes, "PREFS_FILE", str(prefs_file))
|
||||
|
||||
prefs_routes._save_for_user("alice", {"theme": "dark"})
|
||||
|
||||
data = json.loads(prefs_file.read_text(encoding="utf-8"))
|
||||
assert data == {"_users": {"alice": {"theme": "dark"}}}
|
||||
assert prefs_routes._load_for_user("alice") == {"theme": "dark"}
|
||||
|
||||
|
||||
def test_save_for_user_preserves_flat_prefs_when_auth_disabled(monkeypatch, tmp_path):
|
||||
prefs_file = tmp_path / "data" / "user_prefs.json"
|
||||
monkeypatch.setattr(prefs_routes, "PREFS_FILE", str(prefs_file))
|
||||
|
||||
prefs_routes._save_for_user(None, {"theme": "dark"})
|
||||
|
||||
data = json.loads(prefs_file.read_text(encoding="utf-8"))
|
||||
assert data == {"theme": "dark"}
|
||||
assert prefs_routes._load_for_user(None) == {"theme": "dark"}
|
||||
@@ -1,10 +1,10 @@
|
||||
import os
|
||||
import subprocess
|
||||
import pytest
|
||||
import sys
|
||||
|
||||
def test_rag_id_stability_across_processes():
|
||||
# Run helper in subprocesses with different PYTHONHASHSEED values to ensure cross-process stability
|
||||
cmd = ["./venv/bin/python", "-c", "from src.rag_vector import _generate_doc_id; print(_generate_doc_id('test_text_hash'))"]
|
||||
cmd = [sys.executable, "-c", "from src.rag_vector import _generate_doc_id; print(_generate_doc_id('test_text_hash'))"]
|
||||
|
||||
env0 = os.environ.copy()
|
||||
env0["PYTHONHASHSEED"] = "0"
|
||||
@@ -23,6 +23,6 @@ def test_rag_id_stability_across_processes():
|
||||
assert id0 == id_rand
|
||||
|
||||
# Assert different inputs produce different IDs
|
||||
cmd_diff = ["./venv/bin/python", "-c", "from src.rag_vector import _generate_doc_id; print(_generate_doc_id('different_text_hash'))"]
|
||||
cmd_diff = [sys.executable, "-c", "from src.rag_vector import _generate_doc_id; print(_generate_doc_id('different_text_hash'))"]
|
||||
id_diff = subprocess.check_output(cmd_diff, env=env0).decode().strip()
|
||||
assert id0 != id_diff
|
||||
|
||||
@@ -45,6 +45,7 @@ def _make_session(sid, owner="alice"):
|
||||
db = _TS()
|
||||
try:
|
||||
db.add(DbSession(id=sid, owner=owner, name="chat", model="gpt-4o",
|
||||
endpoint_url="http://localhost:11434",
|
||||
archived=False, message_count=1))
|
||||
db.commit()
|
||||
finally:
|
||||
|
||||
@@ -36,7 +36,14 @@ def test_rewrite_query_selects_and_updates_latest_assistant_message():
|
||||
base = datetime(2026, 6, 3, 12, 0, 0)
|
||||
db = TS()
|
||||
try:
|
||||
db.add(DbSession(id=sid, owner="alice", name="c", model="m", archived=False))
|
||||
db.add(DbSession(
|
||||
id=sid,
|
||||
owner="alice",
|
||||
name="c",
|
||||
model="m",
|
||||
endpoint_url="http://localhost:11434",
|
||||
archived=False,
|
||||
))
|
||||
db.add(DBChatMessage(id="m1", session_id=sid, role="assistant", content="old first", timestamp=base))
|
||||
db.add(DBChatMessage(id="m2", session_id=sid, role="assistant", content="old latest", timestamp=base + timedelta(minutes=1)))
|
||||
db.commit()
|
||||
|
||||
Reference in New Issue
Block a user