Compare commits

...
22 Commits
Author SHA1 Message Date
68eeb7841c ci: harden description checks — unfilled dropdowns, gameable test plans, non-issue links (#2099)
* ci: harden description checks (dropdown placeholder, how-to-test, link \b)

- issue: flag sections still showing the "-- Please Select --" dropdown
  placeholder (added in #2068) as a single comma-separated line item;
  presence-only checks previously let an un-chosen dropdown pass.
- PR: replace the numbered-step "How to Test" rule with a non-trivial
  content requirement (>=30 chars). The old /\d+\.\s*\S/ rule both
  false-failed prose/code-block test plans and was gamed by an empty
  "1. 2. 3." shell; the message now explains what detail to provide.
- PR: tighten the linked-issue regex to /#\d+\b/ so a hex colour like
  #1a2b3c no longer counts as an issue reference.

---------

Co-authored-by: Povilas Kirna <povilas.kirna@pebble.net>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 08:16:36 +02:00
Nicholai 4dc11cfe6b refactor(memory): canonicalize memory imports (#50) 2026-06-04 05:31:15 +01:00
Yuri a2e691da2b fix(models): stabilize proxy endpoint refresh behavior
* fix: support large proxy model endpoint refresh

Large OpenAI-compatible proxy endpoints can expose hundreds of models and make /v1/models slow. Treating those endpoints like local model servers caused model picker opens and background probes to repeatedly hit /models, producing timeouts and making otherwise usable endpoints appear offline.

Make model endpoint discovery cached-first for normal UI usage, add explicit proxy/API classification and refresh policy fields, exclude proxy/API endpoints from aggressive local probing, and preserve cached models when refresh fails.

Manual Test/Add/Refresh actions still fetch the full model list with longer timeouts so users can intentionally import large proxy model lists without blocking normal model picker usage.

* fix: preserve endpoint ping status semantics
2026-06-04 04:56:11 +01:00
Sushanth ReddyandEkaTantra Dev eee2167502 Stop API key save() from writing other providers' keys as plaintext (#1944)
save() called load(), which DECRYPTS every stored key, then re-encrypted
only the key being saved and wrote the whole dict back. The other
providers' keys were thus persisted in plaintext; on the next load()
Fernet raised InvalidToken on them and they were silently dropped.

Add _load_raw() that returns the still-encrypted on-disk dict (reusing the
existing missing/corrupt-file guards) and have save() build on that, so
untouched providers keep their ciphertext. load() now also goes through
_load_raw(), keeping its behavior identical.

Fixes #1914

Co-authored-by: EkaTantra Dev <dev@ekatantra.com>
2026-06-04 04:47:13 +01:00
Afonso Coutinho 09fe308720 fix(auth): revoke API tokens when deleting users
* fix: revoke API bearer tokens when their owner is deleted

* Re-run CI

* Invalidate bearer-token cache on user delete so warmed cached tokens stop working
2026-06-04 04:44:34 +01:00
Marius Popa 666babfd58 fix(documents): refresh library counters after removal (#1924) 2026-06-04 04:42:23 +01:00
Rudy WolfandClaude Opus 4.8 1c43daa564 fix(compare): stop blind mode leaking model identities via session names (#1318)
Blind Compare anonymized the pane headers, but each pane still created a helper chat session named "[CMP] <real-model>" and GET /api/sessions returned the session's model field. So the sidebar and the session-list API let a user map "Model A" back to its real model before voting, defeating the blind test.

- Frontend (static/js/compare/index.js, panes.js): in blind mode, name helper sessions by their neutral slot ("[CMP] Model A") instead of the model, matching the existing blind pane labels.
- Backend GET /api/sessions (routes/session_routes.py): blank the model field for [CMP]-prefixed helper sessions via a new _public_model helper.
- Backend /api/compare/start (routes/compare_routes.py): name blind sessions by slot and withhold model_left/model_right/mapping from the blind response (revealed at /vote).
- Tests: tests/test_blind_compare_redaction.py.

Fixes #1285.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 04:39:01 +01:00
hawktuahs 3d8c364689 [Bash] Fix Windows cookbook background tasks (#676)
* Fix Windows cookbook background tasks

* Add Windows Cookbook reliability follow-ups
2026-06-04 04:30:01 +01:00
Wes HuberandClaude Opus 4.6 0f7ea7a936 fix: add 'willing to fix' dropdown to bug report issue template (#2063)
* fix: add 'willing to fix' dropdown to bug report issue template

The feature request template has an 'Are you willing to implement
this?' dropdown but the bug report template was missing it, leaving
a plain textarea with a placeholder hint instead. Add a matching
dropdown for consistency.

Fixes #2059

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add '-- Please Select --' default option to match feature_request template

Rebased on #2068 and added the placeholder option for consistency.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 04:25:04 +01:00
Paulo Victor Cordeiro bd4067cf83 fix: guard remaining uid.decode() calls in auto-classify spam path (#1860)
Two more bare uid.decode() calls at lines 889 and 897 crash with
AttributeError when uid is already a string. Applies the same
isinstance guard used everywhere else in this function.
2026-06-04 04:06:10 +01:00
Afonso Coutinho 49c14af5c7 fix(calendar): scope CalDAV event lookup by calendar
* fix: CalDAV sync hijacks another user's event sharing a VEVENT uid

* Seed schema-valid dtstart/dtend in caldav uid-scope test fixture
2026-06-04 04:01:21 +01:00
.bulat e340674c12 Persist user prefs atomically (#1840) 2026-06-04 03:55:22 +01:00
lekt8andClaude Opus 4.8 ceb62385f1 Fetch full messages with BODY.PEEK[] so read_email works on iCloud IMAP (#1961) (#1963)
read_email, reply_to_email and download_attachment fetched the full message with
the legacy bare RFC822 item (UID FETCH <uid> (RFC822)). iCloud's IMAP server
silently ignores it — the fetch returns status OK but only (UID <uid>) with no
body tuple, so the parse reports 'Email not found with UID' even though the
message exists and list_emails (which uses RFC822.HEADER) shows it. Gmail honours
(RFC822), which is why it only reproduced on iCloud.

Switch the three full-message fetches to (BODY.PEEK[]), which iCloud and Gmail
both honour and which doesn't set \Seen. Response shape is unchanged (raw bytes
still at msg_data[0][1]), so parsing is unaffected; the RFC822.HEADER (listing)
and (UID) probe fetches are left as-is.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 03:53:14 +01:00
Ocean Bennett a38df08a31 fix(tests): use current python for rag id stability (#1817) 2026-06-04 03:49:59 +01:00
nubs 37e1d401cf fix(tests): clean agent loop import stubs 2026-06-04 03:44:49 +01:00
Afonso Coutinho 03dbf976a5 fix: image model ranking crashes on a non-string search filter (#1898) 2026-06-04 03:26:35 +01:00
Afonso Coutinho 5043b2924c fix: image model ranking crashes when system is not a dict (#1900) 2026-06-04 03:23:59 +01:00
Alexandre Teixeira be8f1fac85 fix(tests): add endpoint URLs to remaining session fixtures 2026-06-04 03:14:43 +01:00
Afonso Coutinho eac354629a fix: model cost/info matches first substring key (gpt-4o-mini billed as gpt-4o) (#1439)
* fix: match model name to the longest known key, not the first substring

* test: model key matching prefers the longest specific key
2026-06-04 03:05:37 +01:00
raf 2efebcc278 fix(tests): allow multiple logout calls when IMAP fallback reconnects (#1976)
_latest_inbox_fallback_uids logs out the broken connection before
reconnecting. The outer finally then logs out the new
connection. Both logouts are correct, the test assertion of == 1
was written before the reconnect logic existed. Changed to >= 1.
2026-06-04 02:56:05 +01:00
ghreprimandandghreprimand 82fcec6bb6 Replace core database utcnow defaults (#1457)
Co-authored-by: ghreprimand <203024559+ghreprimand@users.noreply.github.com>
2026-06-04 02:50:19 +01:00
Wes HuberandClaude Opus 4.6 6e66e69451 fix(tests): add endpoint URL to archived session seeds
The sessions table now enforces NOT NULL on endpoint_url, but the
test fixture omitted it when seeding archived sessions, causing
IntegrityError on all three test cases.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-04 02:32:54 +01:00
57 changed files with 2496 additions and 951 deletions
+12 -3
View File
@@ -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
+6 -4
View File
@@ -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 ──────────────────────────────────────────────────────────────
+12
View File
@@ -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
View File
@@ -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,
+11
View File
@@ -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):
+3 -3
View File
@@ -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}"}
+11
View File
@@ -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
View File
@@ -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")
+32 -1
View File
@@ -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
View File
@@ -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")
+2 -2
View File
@@ -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
View File
@@ -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()
+6 -2
View File
@@ -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:
+17 -1
View File
@@ -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),
+3 -1
View File
@@ -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
View File
@@ -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"]
+3 -173
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+66
View File
@@ -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)
+14
View File
@@ -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
View File
@@ -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):
+4
View File
@@ -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
View File
@@ -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) {
+7 -12
View File
@@ -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;
}
/**
+3 -1
View File
@@ -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) {
+4 -2
View File
@@ -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) {
+30 -16
View File
@@ -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);
}});
+24 -7
View File
@@ -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}`);
+19
View File
@@ -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;
}
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
# ---------------------------------------------------------------------------
+1 -1
View File
@@ -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()
+100
View File
@@ -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)
)
+76
View File
@@ -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")
+30
View File
@@ -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)
+33
View File
@@ -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
+2 -2
View File
@@ -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."
+41
View File
@@ -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 == []
+48
View File
@@ -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
+56
View File
@@ -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"
+85
View File
@@ -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
View File
@@ -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"]
+25 -1
View File
@@ -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
+47
View File
@@ -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"}
+3 -3
View File
@@ -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:
+8 -1
View File
@@ -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()