mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-07 21:52:20 +01:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68eeb7841c | ||
|
|
4dc11cfe6b | ||
|
|
a2e691da2b | ||
|
|
eee2167502 | ||
|
|
09fe308720 | ||
|
|
666babfd58 | ||
|
|
1c43daa564 | ||
|
|
3d8c364689 | ||
|
|
0f7ea7a936 | ||
|
|
bd4067cf83 | ||
|
|
49c14af5c7 | ||
|
|
e340674c12 | ||
|
|
ceb62385f1 | ||
|
|
a38df08a31 | ||
|
|
37e1d401cf | ||
|
|
03dbf976a5 | ||
|
|
5043b2924c | ||
|
|
be8f1fac85 | ||
|
|
eac354629a | ||
|
|
2efebcc278 | ||
|
|
82fcec6bb6 | ||
|
|
6e66e69451 |
@@ -95,11 +95,20 @@ body:
|
|||||||
description: "e.g. Ollama + llama3.2:latest, vLLM + mistral-7b, OpenAI API, Anthropic API"
|
description: "e.g. Ollama + llama3.2:latest, vLLM + mistral-7b, OpenAI API, Anthropic API"
|
||||||
placeholder: "Ollama + llama3.2:latest"
|
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
|
- type: textarea
|
||||||
id: additional-info
|
id: additional-info
|
||||||
attributes:
|
attributes:
|
||||||
label: Additional Information
|
label: Additional Information
|
||||||
description: Anything else that might help — browser console errors, related issues, things you already tried, or environment quirks.
|
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.
|
// '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 ────────────────────────────────────────────────────────────────
|
// ── Labels ────────────────────────────────────────────────────────────────
|
||||||
// These labels are expected to already exist in the repo — managing the
|
// 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
|
// repo's label set is the maintainer's job, not this workflow's. We check a
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ module.exports = async ({ github, context, core }) => {
|
|||||||
// keyword + #NNN, or a full issue URL (e.g. .../issues/123) — the strict
|
// keyword + #NNN, or a full issue URL (e.g. .../issues/123) — the strict
|
||||||
// keyword-prefixed form previously false-flagged correctly-linked PRs.
|
// keyword-prefixed form previously false-flagged correctly-linked PRs.
|
||||||
const linkedSection = section('Linked Issue');
|
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) {
|
if (!linkedSection || !hasIssueRef) {
|
||||||
problems.push('**Linked Issue** — add a reference like `Fixes #NNN`, a bare `#NNN`, or a link to the issue.');
|
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.');
|
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');
|
const howTo = section('How to Test');
|
||||||
if (!howTo || !/\d+\.\s*\S/.test(howTo)) {
|
if (howTo.length < 30) {
|
||||||
problems.push('**How to Test** — add at least one numbered step a reviewer can follow to verify this works.');
|
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 ──────────────────────────────────────────────────────────────
|
// ── Comment ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -241,6 +241,18 @@ class AuthManager:
|
|||||||
revoked += 1
|
revoked += 1
|
||||||
if revoked:
|
if revoked:
|
||||||
self._save_sessions()
|
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)")
|
logger.info(f"Deleted user '{username}' (by {requesting_user}); revoked {revoked} active session(s)")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
+52
-14
@@ -1,7 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
import sqlite3
|
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 import event, create_engine, Column, String, Text, Boolean, DateTime, Integer, ForeignKey, JSON, Index, func, text
|
||||||
from sqlalchemy.engine import Engine
|
from sqlalchemy.engine import Engine
|
||||||
from sqlalchemy.types import TypeDecorator
|
from sqlalchemy.types import TypeDecorator
|
||||||
@@ -13,15 +13,21 @@ logger = logging.getLogger(__name__)
|
|||||||
# Create base class for declarative models
|
# Create base class for declarative models
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow_naive() -> datetime:
|
||||||
|
"""Return naive UTC for existing DateTime columns."""
|
||||||
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
class TimestampMixin:
|
class TimestampMixin:
|
||||||
"""Mixin that adds timestamp fields to models"""
|
"""Mixin that adds timestamp fields to models"""
|
||||||
@declared_attr
|
@declared_attr
|
||||||
def created_at(cls):
|
def created_at(cls):
|
||||||
return Column(DateTime, default=datetime.utcnow, nullable=False)
|
return Column(DateTime, default=utcnow_naive, nullable=False)
|
||||||
|
|
||||||
@declared_attr
|
@declared_attr
|
||||||
def updated_at(cls):
|
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
|
# Get database URL from environment, default to SQLite
|
||||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./data/app.db")
|
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.
|
meta_data = Column("metadata", Text, nullable=True) # JSON string for metrics etc.
|
||||||
|
|
||||||
# Timestamp
|
# Timestamp
|
||||||
timestamp = Column(DateTime, default=datetime.utcnow)
|
timestamp = Column(DateTime, default=utcnow_naive)
|
||||||
|
|
||||||
# Relationship to Session
|
# Relationship to Session
|
||||||
session = relationship("Session", back_populates="messages")
|
session = relationship("Session", back_populates="messages")
|
||||||
@@ -224,7 +230,7 @@ class DocumentVersion(Base):
|
|||||||
content = Column(Text, nullable=False)
|
content = Column(Text, nullable=False)
|
||||||
summary = Column(String, nullable=True) # Edit description
|
summary = Column(String, nullable=True) # Edit description
|
||||||
source = Column(String, default="ai") # "ai" or "user"
|
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")
|
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)
|
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)
|
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"
|
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
|
# Whether models on this endpoint accept OpenAI-style function
|
||||||
# schemas + emit `tool_calls`. Auto-detected at Cookbook auto-
|
# schemas + emit `tool_calls`. Auto-detected at Cookbook auto-
|
||||||
# register time from `--enable-auto-tool-choice` in the serve cmd;
|
# 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)
|
tool_id = Column(String, ForeignKey("user_tools.id", ondelete="CASCADE"), nullable=False)
|
||||||
key = Column(String, nullable=False)
|
key = Column(String, nullable=False)
|
||||||
value = Column(Text, nullable=True)
|
value = Column(Text, nullable=True)
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=utcnow_naive)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, default=utcnow_naive, onupdate=utcnow_naive)
|
||||||
|
|
||||||
tool = relationship("UserTool", backref=backref("data_entries", cascade="all, delete-orphan"))
|
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)
|
id = Column(String, primary_key=True, index=True)
|
||||||
task_id = Column(String, ForeignKey("scheduled_tasks.id", ondelete="CASCADE"), nullable=False)
|
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)
|
finished_at = Column(DateTime, nullable=True)
|
||||||
status = Column(String, default="running") # "running", "success", "error"
|
status = Column(String, default="running") # "running", "success", "error"
|
||||||
result = Column(Text, nullable=True)
|
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)
|
session_id = Column(String, ForeignKey("sessions.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
|
||||||
# Timestamp as Unix timestamp
|
# 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
|
# Relationship to Session
|
||||||
session = relationship("Session", backref="memories")
|
session = relationship("Session", backref="memories")
|
||||||
@@ -803,6 +817,29 @@ def _migrate_add_model_type_column():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.getLogger(__name__).warning(f"model_type migration failed: {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():
|
def _migrate_add_task_run_model_column():
|
||||||
"""Add model column to task_runs if it doesn't exist (records which model ran)."""
|
"""Add model column to task_runs if it doesn't exist (records which model ran)."""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
@@ -1480,7 +1517,7 @@ def _migrate_seed_email_account():
|
|||||||
if not imap_host and not smtp_host:
|
if not imap_host and not smtp_host:
|
||||||
return # nothing to migrate
|
return # nothing to migrate
|
||||||
|
|
||||||
now = datetime.utcnow()
|
now = utcnow_naive()
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
conn.execute(text("""
|
conn.execute(text("""
|
||||||
INSERT INTO email_accounts
|
INSERT INTO email_accounts
|
||||||
@@ -1533,6 +1570,7 @@ def init_db():
|
|||||||
_migrate_add_pinned_models_column()
|
_migrate_add_pinned_models_column()
|
||||||
_migrate_add_notes_sort_order()
|
_migrate_add_notes_sort_order()
|
||||||
_migrate_add_model_type_column()
|
_migrate_add_model_type_column()
|
||||||
|
_migrate_add_model_endpoint_refresh_columns()
|
||||||
_migrate_add_model_endpoint_owner_column()
|
_migrate_add_model_endpoint_owner_column()
|
||||||
_migrate_add_supports_tools_column()
|
_migrate_add_supports_tools_column()
|
||||||
_migrate_add_task_run_model_column()
|
_migrate_add_task_run_model_column()
|
||||||
@@ -1760,7 +1798,7 @@ def bulk_insert_messages(session_id: str, messages: list):
|
|||||||
'session_id': session_id,
|
'session_id': session_id,
|
||||||
'role': msg['role'],
|
'role': msg['role'],
|
||||||
'content': msg['content'],
|
'content': msg['content'],
|
||||||
'timestamp': datetime.utcnow()
|
'timestamp': utcnow_naive()
|
||||||
}
|
}
|
||||||
for msg in messages
|
for msg in messages
|
||||||
]
|
]
|
||||||
@@ -1771,7 +1809,7 @@ def cleanup_old_sessions(days: int = 30):
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
with get_db_session() as db:
|
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(
|
deleted_count = db.query(Session).filter(
|
||||||
Session.archived == True,
|
Session.archived == True,
|
||||||
@@ -1816,7 +1854,7 @@ def update_session_last_accessed(session_id: str):
|
|||||||
with get_db_session() as db:
|
with get_db_session() as db:
|
||||||
db_session = db.query(Session).filter(Session.id == session_id).first()
|
db_session = db.query(Session).filter(Session.id == session_id).first()
|
||||||
if db_session:
|
if db_session:
|
||||||
db_session.last_accessed = datetime.utcnow()
|
db_session.last_accessed = utcnow_naive()
|
||||||
db.commit()
|
db.commit()
|
||||||
return True
|
return True
|
||||||
return False
|
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
|
The autonomous email->calendar pass relies on this to avoid disclosing (and
|
||||||
acting on) other users' calendars."""
|
acting on) other users' calendars."""
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
now = datetime.utcnow()
|
now = utcnow_naive()
|
||||||
with get_db_session() as db:
|
with get_db_session() as db:
|
||||||
q = db.query(CalendarEvent).join(CalendarCal).filter(
|
q = db.query(CalendarEvent).join(CalendarCal).filter(
|
||||||
CalendarEvent.dtstart >= now,
|
CalendarEvent.dtstart >= now,
|
||||||
|
|||||||
@@ -171,6 +171,15 @@ def _windows_bash_fallbacks() -> List[str]:
|
|||||||
return paths
|
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]:
|
def find_bash() -> Optional[str]:
|
||||||
"""Locate a real ``bash`` interpreter, or None.
|
"""Locate a real ``bash`` interpreter, or None.
|
||||||
|
|
||||||
@@ -184,6 +193,8 @@ def find_bash() -> Optional[str]:
|
|||||||
return _BASH_CACHE
|
return _BASH_CACHE
|
||||||
_BASH_PROBED = True
|
_BASH_PROBED = True
|
||||||
found = which_tool("bash")
|
found = which_tool("bash")
|
||||||
|
if found and IS_WINDOWS and _is_windows_bash_stub(found):
|
||||||
|
found = None
|
||||||
if not found and IS_WINDOWS:
|
if not found and IS_WINDOWS:
|
||||||
for cand in _windows_bash_fallbacks():
|
for cand in _windows_bash_fallbacks():
|
||||||
if os.path.exists(cand):
|
if os.path.exists(cand):
|
||||||
|
|||||||
@@ -666,7 +666,7 @@ def _read_email(uid=None, message_id=None, folder="INBOX", account=None):
|
|||||||
conn.logout()
|
conn.logout()
|
||||||
return {"error": "No UID or Message-ID provided"}
|
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":
|
if status != "OK":
|
||||||
conn.logout()
|
conn.logout()
|
||||||
return {"error": f"Failed to fetch email UID {uid}"}
|
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."""
|
"""Reply to an existing email by UID. Threads via In-Reply-To/References."""
|
||||||
conn = _imap_connect(account)
|
conn = _imap_connect(account)
|
||||||
conn.select(folder, readonly=True)
|
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()
|
conn.logout()
|
||||||
if status != "OK" or not msg_data or not msg_data[0]:
|
if status != "OK" or not msg_data or not msg_data[0]:
|
||||||
return {"error": f"Failed to fetch email UID {uid}"}
|
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."""
|
"""Extract a specific attachment to disk and return its local path."""
|
||||||
conn = _imap_connect(account)
|
conn = _imap_connect(account)
|
||||||
conn.select(folder, readonly=True)
|
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()
|
conn.logout()
|
||||||
if status != "OK":
|
if status != "OK":
|
||||||
return {"error": f"Failed to fetch email UID {uid}"}
|
return {"error": f"Failed to fetch email UID {uid}"}
|
||||||
|
|||||||
@@ -375,6 +375,17 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
ok = auth_manager.delete_user(body.username, user)
|
ok = auth_manager.delete_user(body.username, user)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(400, "Cannot delete user")
|
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}
|
return {"ok": True}
|
||||||
|
|
||||||
# ---- Feature visibility (admin-managed) ----
|
# ---- Feature visibility (admin-managed) ----
|
||||||
|
|||||||
+29
-18
@@ -66,13 +66,33 @@ def setup_compare_routes(session_manager: SessionManager):
|
|||||||
comp_id = str(uuid.uuid4())
|
comp_id = str(uuid.uuid4())
|
||||||
sid_a = str(uuid.uuid4())
|
sid_a = str(uuid.uuid4())
|
||||||
sid_b = 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])
|
# Create ephemeral sessions (prefixed [CMP])
|
||||||
for sid, model, endpoint in [(sid_a, model_a, endpoint_a), (sid_b, model_b, endpoint_b)]:
|
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_manager.create_session(
|
||||||
session_id=sid,
|
session_id=sid,
|
||||||
name=f"[CMP] {model.split('/')[-1]}",
|
name=name,
|
||||||
endpoint_url=endpoint,
|
endpoint_url=endpoint,
|
||||||
model=model,
|
model=model,
|
||||||
rag=False,
|
rag=False,
|
||||||
@@ -93,15 +113,6 @@ def setup_compare_routes(session_manager: SessionManager):
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
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
|
# Store comparison record
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@@ -121,18 +132,18 @@ def setup_compare_routes(session_manager: SessionManager):
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
# Map session IDs to left/right based on blind mapping
|
# In blind mode, withhold the model identities AND the left/right
|
||||||
session_left = sid_a if mapping["left"] == "a" else sid_b
|
# mapping from the response. The client already knows model_a/model_b
|
||||||
session_right = sid_a if mapping["right"] == "a" else sid_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 {
|
return {
|
||||||
"id": comp_id,
|
"id": comp_id,
|
||||||
"session_left": session_left,
|
"session_left": session_left,
|
||||||
"session_right": session_right,
|
"session_right": session_right,
|
||||||
"model_left": model_a if mapping["left"] == "a" else model_b,
|
"model_left": None if blind else (model_a if mapping["left"] == "a" else model_b),
|
||||||
"model_right": model_a if mapping["right"] == "a" else model_b,
|
"model_right": None if blind else (model_a if mapping["right"] == "a" else model_b),
|
||||||
"is_blind": blind,
|
"is_blind": blind,
|
||||||
"mapping": mapping,
|
"mapping": None if blind else mapping,
|
||||||
}
|
}
|
||||||
|
|
||||||
@router.post("/{comp_id}/vote")
|
@router.post("/{comp_id}/vote")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
Extracted from cookbook_routes.py; the routes module imports the symbols it needs."""
|
Extracted from cookbook_routes.py; the routes module imports the symbols it needs."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import ntpath
|
||||||
import os
|
import os
|
||||||
import posixpath
|
import posixpath
|
||||||
import re
|
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
|
# only (no quotes, shell metacharacters, or spaces) since it lands in a shell
|
||||||
# command. A leading ~ is expanded to $HOME at command-build time.
|
# command. A leading ~ is expanded to $HOME at command-build time.
|
||||||
_LOCAL_DIR_RE = re.compile(r"^~?/[A-Za-z0-9._/-]*$|^~$")
|
_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:
|
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\\...".
|
# os.path.abspath("/opt/...") would incorrectly turn it into "D:\\opt\\...".
|
||||||
if executable.startswith("/"):
|
if executable.startswith("/"):
|
||||||
bin_dir = posixpath.dirname(executable)
|
bin_dir = posixpath.dirname(executable)
|
||||||
|
elif _WINDOWS_DRIVE_PATH_RE.match(executable):
|
||||||
|
bin_dir = ntpath.dirname(executable)
|
||||||
else:
|
else:
|
||||||
bin_dir = os.path.dirname(os.path.abspath(executable))
|
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
|
# Escape for a double-quoted context: $PATH must still expand, but spaces
|
||||||
# and shell metacharacters in the path must be preserved literally.
|
# and shell metacharacters in the path must be preserved literally.
|
||||||
esc = (
|
esc = (
|
||||||
@@ -250,6 +263,17 @@ def _venv_safe_local_pip_install_cmd(cmd: str, *, local: bool, in_venv: bool) ->
|
|||||||
return shlex.join(stripped)
|
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:
|
def _cached_model_scan_script(model_dirs: list[str] | None = None) -> str:
|
||||||
"""Build the standalone Python scanner used by /api/model/cached."""
|
"""Build the standalone Python scanner used by /api/model/cached."""
|
||||||
lines = [
|
lines = [
|
||||||
@@ -528,9 +552,16 @@ def _append_serve_preflight_exit_lines(runner_lines: list[str], *, keep_shell_op
|
|||||||
runner_lines.append('fi')
|
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."""
|
"""Append serve-runner lines that preserve and report the command exit code."""
|
||||||
runner_lines.append('ODYSSEUS_CMD_EXIT=$?')
|
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:
|
if keep_shell_open:
|
||||||
runner_lines.append('echo ""; echo "=== Process exited with code $ODYSSEUS_CMD_EXIT ==="; exec "${SHELL:-/bin/bash}"')
|
runner_lines.append('echo ""; echo "=== Process exited with code $ODYSSEUS_CMD_EXIT ==="; exec "${SHELL:-/bin/bash}"')
|
||||||
else:
|
else:
|
||||||
|
|||||||
+16
-14
@@ -38,7 +38,8 @@ from routes.cookbook_helpers import (
|
|||||||
_ps_squote, _bash_squote, _validate_serve_cmd, _parse_serve_phase,
|
_ps_squote, _bash_squote, _validate_serve_cmd, _parse_serve_phase,
|
||||||
_safe_env_prefix, _local_tooling_path_export, _append_serve_preflight_exit_lines,
|
_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,
|
_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,
|
ModelDownloadRequest, ServeRequest,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -294,15 +295,6 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
safe_chmod(key_path.with_suffix(".pub"), 0o644)
|
safe_chmod(key_path.with_suffix(".pub"), 0o644)
|
||||||
return {"ok": True, "public_key": _read_cookbook_public_key()}
|
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:
|
def _needs_binary(cmd: str, binary: str) -> bool:
|
||||||
return bool(re.search(rf"(^|[\s;&|()]){re.escape(binary)}($|[\s;&|()])", cmd or ""))
|
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"')
|
lines.append('export PATH="$HOME/.local/bin:$PATH"')
|
||||||
# When Odysseus runs from a venv (e.g. native macOS install), put its bin
|
# 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
|
# on PATH so the tmux shell finds the bundled `hf`/`python3` without an
|
||||||
# activated venv. Local bash runs only — meaningless over SSH/Windows.
|
# activated venv. Local bash runs only — meaningless over SSH.
|
||||||
if not req.remote_host and req.platform != "windows":
|
if not req.remote_host:
|
||||||
lines.append(_local_tooling_path_export(sys.executable))
|
lines.append(_local_tooling_path_export(sys.executable))
|
||||||
# Best-effort install hf CLI (always). hf_transfer (Rust parallel downloader)
|
# 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
|
# 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('Write-Host "ERROR: vLLM is not supported on Windows. Use Ollama or llama.cpp instead."')
|
||||||
ps_lines.append('exit 1')
|
ps_lines.append('exit 1')
|
||||||
ps_lines.append(req.cmd)
|
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 ""')
|
||||||
ps_lines.append('Write-Host "=== Process exited with code $LASTEXITCODE ==="')
|
ps_lines.append('Write-Host "=== Process exited with code $LASTEXITCODE ==="')
|
||||||
runner_path = TMUX_LOG_DIR / f"{session_id}_run.ps1"
|
runner_path = TMUX_LOG_DIR / f"{session_id}_run.ps1"
|
||||||
@@ -1167,10 +1161,18 @@ def setup_cookbook_routes() -> APIRouter:
|
|||||||
if local_windows:
|
if local_windows:
|
||||||
# Detached background process — no interactive shell to keep open.
|
# Detached background process — no interactive shell to keep open.
|
||||||
# Print the exit marker the status poller looks for, then stop.
|
# 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:
|
else:
|
||||||
# Keep shell open after exit so user can see errors
|
# 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 = TMUX_LOG_DIR / f"{session_id}_run.sh"
|
||||||
runner_path.write_text("\n".join(runner_lines) + "\n", encoding="utf-8")
|
runner_path.write_text("\n".join(runner_lines) + "\n", encoding="utf-8")
|
||||||
|
|||||||
@@ -886,7 +886,7 @@ async def _auto_summarize_pass_single(days_back: int = 1, account_id: str | None
|
|||||||
if is_spam and auto_spam and spam_folder:
|
if is_spam and auto_spam and spam_folder:
|
||||||
if _imap_move(uid, spam_folder, account_id=account_id, owner=account_owner):
|
if _imap_move(uid, spam_folder, account_id=account_id, owner=account_owner):
|
||||||
moved_to = spam_folder
|
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 = _sql3.connect(SCHEDULED_DB)
|
||||||
_c.execute("""
|
_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,
|
(message_id, owner, uid, folder, subject, sender, tags, spam_verdict,
|
||||||
spam_reason, moved_to, model_used, created_at)
|
spam_reason, moved_to, model_used, created_at)
|
||||||
VALUES (?, ?, ?, 'INBOX', ?, ?, ?, ?, ?, ?, ?, ?)
|
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,
|
json.dumps(tags), 1 if is_spam else 0,
|
||||||
spam_reason, moved_to, model, datetime.utcnow().isoformat()))
|
spam_reason, moved_to, model, datetime.utcnow().isoformat()))
|
||||||
_c.commit()
|
_c.commit()
|
||||||
|
|||||||
+443
-164
@@ -11,7 +11,7 @@ import httpx
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
from urllib.parse import urlparse, urlunparse
|
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 pydantic import BaseModel
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from core.database import SessionLocal, ModelEndpoint, Session as DbSession
|
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")
|
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
|
# Prefixes/substrings for models that are NOT chat-completions-capable
|
||||||
_NON_CHAT_PREFIXES = (
|
_NON_CHAT_PREFIXES = (
|
||||||
"dall-e", "tts-", "whisper", "text-embedding", "embedding",
|
"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])\.")
|
_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'.
|
"""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
|
Includes the Tailscale CGNAT range (100.64.0.0/10) so tailnet-hosted
|
||||||
servers (e.g. Cookbook serve endpoints) get reachability-probed too."""
|
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:
|
try:
|
||||||
host = urlparse(base_url).hostname or ""
|
host = urlparse(base_url).hostname or ""
|
||||||
if host in _LOCAL_HOSTS or host.startswith(_PRIVATE_PREFIXES):
|
if host in _LOCAL_HOSTS or host.startswith(_PRIVATE_PREFIXES):
|
||||||
@@ -456,6 +596,21 @@ def _classify_endpoint(base_url: str) -> str:
|
|||||||
return "api"
|
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]:
|
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.
|
"""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."""
|
"""Reachability probe that does not require installed/listed models."""
|
||||||
from src.endpoint_resolver import resolve_url
|
from src.endpoint_resolver import resolve_url
|
||||||
base = resolve_url(_normalize_base(base_url))
|
base = resolve_url(_normalize_base(base_url))
|
||||||
headers = {}
|
headers = build_headers(api_key, base)
|
||||||
if api_key:
|
|
||||||
headers["Authorization"] = f"Bearer {api_key}"
|
|
||||||
|
|
||||||
# Ollama exposes /v1/models (OpenAI-compatible) AND native /api/version,
|
# Ollama exposes /v1/models (OpenAI-compatible) AND native /api/version,
|
||||||
# /api/tags. The OpenAI-style GET base + "/models" returns 404 when the
|
# /api/tags. Probe native paths for Ollama-style endpoints, but avoid using
|
||||||
# base is the host root or the native /api root (e.g. http://localhost:11434,
|
# /models as a generic health check because large proxy catalogs can be slow.
|
||||||
# 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.
|
|
||||||
parsed_base = urlparse(base)
|
parsed_base = urlparse(base)
|
||||||
looks_like_ollama = (
|
looks_like_ollama = (
|
||||||
parsed_base.port == 11434
|
parsed_base.port == 11434
|
||||||
or "ollama" in (parsed_base.hostname or "").lower()
|
or "ollama" in (parsed_base.hostname or "").lower()
|
||||||
)
|
)
|
||||||
|
|
||||||
url = base + "/models"
|
def _result_from_response(r) -> Dict[str, Any]:
|
||||||
last_error: Optional[str] = None
|
|
||||||
try:
|
|
||||||
r = httpx.get(url, headers=headers, timeout=timeout)
|
|
||||||
if 300 <= r.status_code < 400:
|
if 300 <= r.status_code < 400:
|
||||||
loc = r.headers.get("location", "")
|
loc = r.headers.get("location", "")
|
||||||
if loc.startswith("/login") or "/login" in loc:
|
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.",
|
"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"}
|
return {"reachable": False, "status_code": r.status_code, "error": f"HTTP {r.status_code} redirect"}
|
||||||
if r.status_code < 400:
|
if 200 <= r.status_code < 300:
|
||||||
return {"reachable": True, "status_code": r.status_code, "error": None}
|
return {
|
||||||
if r.status_code < 500 and not looks_like_ollama:
|
"reachable": True,
|
||||||
return {"reachable": False, "status_code": r.status_code, "error": f"HTTP {r.status_code}"}
|
"status_code": r.status_code,
|
||||||
last_error = f"HTTP {r.status_code}"
|
"error": None,
|
||||||
except Exception as e:
|
}
|
||||||
last_error = str(e)[:120]
|
return {"reachable": False, "status_code": r.status_code, "error": f"HTTP {r.status_code}"}
|
||||||
|
|
||||||
|
last_error: Optional[str] = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if looks_like_ollama:
|
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"):
|
for path in ("/api/version", "/api/tags"):
|
||||||
try:
|
try:
|
||||||
r = httpx.get(root + path, timeout=timeout)
|
r = httpx.get(root + path, timeout=timeout)
|
||||||
if r.status_code < 400:
|
result = _result_from_response(r)
|
||||||
return {"reachable": True, "status_code": r.status_code, "error": None}
|
if result["reachable"]:
|
||||||
last_error = f"HTTP {r.status_code}"
|
return result
|
||||||
|
last_error = result.get("error")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
last_error = str(e)[:120]
|
last_error = str(e)[:120]
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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}
|
return {"reachable": False, "status_code": None, "error": last_error}
|
||||||
|
|
||||||
|
|
||||||
@@ -715,17 +867,71 @@ def setup_model_routes(model_discovery):
|
|||||||
flip)."""
|
flip)."""
|
||||||
_models_cache.clear()
|
_models_cache.clear()
|
||||||
|
|
||||||
# Track endpoints that have failed recently so we back off probing dead ones.
|
# Track model-list refreshes by URL+key. This prevents repeated picker/API
|
||||||
_probe_failures = {} # ep_id → (last_fail_ts, consecutive_fails)
|
# 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_inflight = {"v": False} # coarse single-flight guard
|
||||||
|
_REFRESH_FAILURE_BASE = 300.0
|
||||||
|
_REFRESH_FAILURE_MAX = 3600.0
|
||||||
|
|
||||||
def _refresh_caches_bg():
|
def _refresh_key(base: str, api_key: Optional[str]) -> str:
|
||||||
"""Background thread: re-probe all endpoints in PARALLEL with a tight
|
return f"{base.rstrip('/')}\x00{api_key or ''}"
|
||||||
timeout, skipping endpoints that have been failing repeatedly.
|
|
||||||
|
|
||||||
Was the cause of gradual server degradation: sequential 3s-timeout
|
def _ts(value: Any) -> float:
|
||||||
probes against many endpoints (some offline) tied up the threadpool
|
try:
|
||||||
for 15-30s every cache cycle, eventually exhausting it."""
|
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
|
import threading
|
||||||
if _refresh_inflight["v"]:
|
if _refresh_inflight["v"]:
|
||||||
return # already running
|
return # already running
|
||||||
@@ -735,44 +941,63 @@ def setup_model_routes(model_discovery):
|
|||||||
try:
|
try:
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
|
changed = False
|
||||||
try:
|
try:
|
||||||
endpoints = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all()
|
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()
|
now = _time.time()
|
||||||
to_probe = []
|
groups: Dict[str, Dict[str, Any]] = {}
|
||||||
for ep in endpoints:
|
for ep in endpoints:
|
||||||
ts, fails = _probe_failures.get(ep.id, (0, 0))
|
ok, info = _should_refresh_endpoint(ep, now, force=force)
|
||||||
if fails >= 3 and (now - ts) < 300:
|
if not ok:
|
||||||
continue
|
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):
|
for key in groups:
|
||||||
base = _normalize_base(ep.base_url)
|
st = _refresh_state.setdefault(key, {})
|
||||||
|
st["inflight"] = True
|
||||||
|
st["last_attempt"] = now
|
||||||
|
|
||||||
|
def _probe_one(key: str, data: Dict[str, Any]):
|
||||||
try:
|
try:
|
||||||
ids = _probe_endpoint(base, ep.api_key, timeout=2)
|
ids = _probe_endpoint(data["base"], data.get("api_key"), timeout=data.get("timeout") or 2)
|
||||||
return ep, ids, None
|
return key, data["endpoint_ids"], ids, None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ep, None, e
|
return key, data["endpoint_ids"], None, e
|
||||||
|
|
||||||
if to_probe:
|
if groups:
|
||||||
# Bounded parallelism — 8 concurrent probes is plenty
|
with ThreadPoolExecutor(max_workers=min(4, len(groups))) as pool:
|
||||||
with ThreadPoolExecutor(max_workers=min(8, len(to_probe))) as pool:
|
futures = [pool.submit(_probe_one, key, data) for key, data in groups.items()]
|
||||||
futures = [pool.submit(_probe_one, ep) for ep in to_probe]
|
|
||||||
for fut in as_completed(futures):
|
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:
|
if ids:
|
||||||
ep.cached_models = json.dumps(ids)
|
for ep_id in endpoint_ids:
|
||||||
_probe_failures.pop(ep.id, None)
|
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:
|
else:
|
||||||
prev = _probe_failures.get(ep.id, (0, 0))
|
st["last_failure"] = _time.time()
|
||||||
_probe_failures[ep.id] = (_time.time(), prev[1] + 1)
|
st["fail_count"] = int(st.get("fail_count") or 0) + 1
|
||||||
|
st["inflight"] = False
|
||||||
db.commit()
|
db.commit()
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
_invalidate_models_cache()
|
if changed:
|
||||||
|
_invalidate_models_cache()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
|
for st in _refresh_state.values():
|
||||||
|
st["inflight"] = False
|
||||||
_refresh_inflight["v"] = False
|
_refresh_inflight["v"] = False
|
||||||
threading.Thread(target=_do, daemon=True).start()
|
threading.Thread(target=_do, daemon=True).start()
|
||||||
|
|
||||||
@@ -804,24 +1029,15 @@ def setup_model_routes(model_discovery):
|
|||||||
base = _normalize_base(ep.base_url)
|
base = _normalize_base(ep.base_url)
|
||||||
provider = _detect_provider(base)
|
provider = _detect_provider(base)
|
||||||
# Use cached models — background refresh keeps them updated
|
# Use cached models — background refresh keeps them updated
|
||||||
model_ids = []
|
model_ids = _cached_model_ids(ep)
|
||||||
if ep.cached_models:
|
|
||||||
try:
|
|
||||||
model_ids = json.loads(ep.cached_models)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
ep_model_type = getattr(ep, "model_type", None) or "llm"
|
ep_model_type = getattr(ep, "model_type", None) or "llm"
|
||||||
# Filter out hidden (probe-failed) models
|
# Filter out hidden (probe-failed) models
|
||||||
hidden = set()
|
hidden = _hidden_model_ids(ep)
|
||||||
if ep.hidden_models:
|
|
||||||
try:
|
|
||||||
hidden = set(json.loads(ep.hidden_models))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
model_ids = [m for m in model_ids if m not in hidden]
|
model_ids = [m for m in model_ids if m not in hidden]
|
||||||
# Build correct URL based on provider
|
# Build correct URL based on provider
|
||||||
chat_url = build_chat_url(base)
|
chat_url = build_chat_url(base)
|
||||||
category = _classify_endpoint(base)
|
kind = _effective_endpoint_kind(ep, base)
|
||||||
|
category = _classify_endpoint(base, kind)
|
||||||
|
|
||||||
if model_ids:
|
if model_ids:
|
||||||
curated_key = _match_provider_curated(base, None)
|
curated_key = _match_provider_curated(base, None)
|
||||||
@@ -837,6 +1053,7 @@ def setup_model_routes(model_discovery):
|
|||||||
"endpoint_id": ep.id,
|
"endpoint_id": ep.id,
|
||||||
"endpoint_name": ep.name,
|
"endpoint_name": ep.name,
|
||||||
"category": category,
|
"category": category,
|
||||||
|
"endpoint_kind": kind,
|
||||||
"model_type": ep_model_type,
|
"model_type": ep_model_type,
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
@@ -852,6 +1069,7 @@ def setup_model_routes(model_discovery):
|
|||||||
"endpoint_id": ep.id,
|
"endpoint_id": ep.id,
|
||||||
"endpoint_name": ep.name,
|
"endpoint_name": ep.name,
|
||||||
"category": category,
|
"category": category,
|
||||||
|
"endpoint_kind": kind,
|
||||||
"model_type": ep_model_type,
|
"model_type": ep_model_type,
|
||||||
"offline": True,
|
"offline": True,
|
||||||
})
|
})
|
||||||
@@ -898,11 +1116,11 @@ def setup_model_routes(model_discovery):
|
|||||||
result = _fetch_models(owner=owner, is_admin=_is_admin)
|
result = _fetch_models(owner=owner, is_admin=_is_admin)
|
||||||
_models_cache[_cache_key] = {"data": result, "time": now}
|
_models_cache[_cache_key] = {"data": result, "time": now}
|
||||||
# Kick off background refresh to update caches from live endpoints
|
# Kick off background refresh to update caches from live endpoints
|
||||||
_refresh_caches_bg()
|
_refresh_caches_bg(force=refresh)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Brief cache for local-probe results so picker-open doesn't hammer
|
# 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
|
# short enough that a freshly-killed local server shows as offline
|
||||||
# within ~8s of the user noticing.
|
# within ~8s of the user noticing.
|
||||||
_LOCAL_PROBE_TTL = 8.0
|
_LOCAL_PROBE_TTL = 8.0
|
||||||
@@ -912,7 +1130,7 @@ def setup_model_routes(model_discovery):
|
|||||||
async def probe_local_endpoints(request: Request):
|
async def probe_local_endpoints(request: Request):
|
||||||
"""Fast parallel reachability check for LOCAL endpoints only.
|
"""Fast parallel reachability check for LOCAL endpoints only.
|
||||||
Cloud endpoints (api.openai.com, api.anthropic.com, etc.) are
|
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
|
can dim stale entries pointing at dead vLLM servers. Returns
|
||||||
{ep_id: {alive, latency_ms, error}}."""
|
{ep_id: {alive, latency_ms, error}}."""
|
||||||
require_admin(request)
|
require_admin(request)
|
||||||
@@ -924,36 +1142,44 @@ def setup_model_routes(model_discovery):
|
|||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
endpoints = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all()
|
endpoints = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all()
|
||||||
local_eps = [
|
local_eps = []
|
||||||
(ep.id, _normalize_base(ep.base_url), ep.api_key)
|
for ep in endpoints:
|
||||||
for ep in endpoints
|
base = _normalize_base(ep.base_url)
|
||||||
if _classify_endpoint(_normalize_base(ep.base_url)) == "local"
|
kind = _effective_endpoint_kind(ep, base)
|
||||||
]
|
if _classify_endpoint(base, kind) == "local":
|
||||||
|
local_eps.append((ep.id, base, ep.api_key))
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
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()
|
t0 = _time.time()
|
||||||
try:
|
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)
|
lat = round((_time.time() - t0) * 1000)
|
||||||
return {
|
return {
|
||||||
"alive": bool(models),
|
"alive": bool(ping.get("reachable")),
|
||||||
"latency_ms": lat,
|
"latency_ms": lat,
|
||||||
"status_code": 200 if models else None,
|
"status_code": ping.get("status_code"),
|
||||||
"error": None if models else "No models found",
|
"error": ping.get("error"),
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"alive": False, "latency_ms": None, "status_code": None, "error": str(e)[:120]}
|
return {"alive": False, "latency_ms": None, "status_code": None, "error": str(e)[:120]}
|
||||||
|
|
||||||
import asyncio as _asyncio
|
import asyncio as _asyncio
|
||||||
results_list = await _asyncio.gather(
|
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,
|
return_exceptions=False,
|
||||||
)
|
)
|
||||||
results: Dict[str, Any] = {}
|
results: Dict[str, Any] = {}
|
||||||
for (eid, _, _), r in zip(local_eps, results_list):
|
for data, r in zip(grouped.values(), results_list):
|
||||||
results[eid] = r
|
for eid in data["endpoint_ids"]:
|
||||||
|
results[eid] = r
|
||||||
|
|
||||||
_local_probe_cache["data"] = results
|
_local_probe_cache["data"] = results
|
||||||
_local_probe_cache["time"] = now
|
_local_probe_cache["time"] = now
|
||||||
@@ -973,50 +1199,28 @@ def setup_model_routes(model_discovery):
|
|||||||
for ep in endpoints:
|
for ep in endpoints:
|
||||||
base = _normalize_base(ep.base_url)
|
base = _normalize_base(ep.base_url)
|
||||||
provider = _detect_provider(base)
|
provider = _detect_provider(base)
|
||||||
|
kind = _effective_endpoint_kind(ep, base)
|
||||||
|
cached_count = len(_cached_model_ids(ep))
|
||||||
entry = {
|
entry = {
|
||||||
"id": ep.id,
|
"id": ep.id,
|
||||||
"name": ep.name,
|
"name": ep.name,
|
||||||
"base_url": base,
|
"base_url": base,
|
||||||
"provider": provider,
|
"provider": provider,
|
||||||
"category": _classify_endpoint(base),
|
"category": _classify_endpoint(base, kind),
|
||||||
|
"endpoint_kind": kind,
|
||||||
}
|
}
|
||||||
if provider == "anthropic":
|
try:
|
||||||
# Anthropic has no /models endpoint; just check connectivity
|
t0 = _time.time()
|
||||||
try:
|
ping = _ping_endpoint(base, ep.api_key, timeout=1.5)
|
||||||
t0 = _time.time()
|
entry["latency_ms"] = round((_time.time() - t0) * 1000)
|
||||||
r = httpx.get(base.rstrip("/"), timeout=5)
|
entry["status"] = "online" if ping.get("reachable") or cached_count else "offline"
|
||||||
entry["latency_ms"] = round((_time.time() - t0) * 1000)
|
entry["error"] = ping.get("error")
|
||||||
entry["status"] = "online"
|
entry["model_count"] = cached_count or (len(ANTHROPIC_MODELS) if provider == "anthropic" else 0)
|
||||||
entry["model_count"] = len(ANTHROPIC_MODELS)
|
except Exception as e:
|
||||||
except Exception as e:
|
entry["latency_ms"] = None
|
||||||
entry["latency_ms"] = None
|
entry["status"] = "online" if cached_count else "offline"
|
||||||
entry["status"] = "offline"
|
entry["error"] = str(e)
|
||||||
entry["error"] = str(e)
|
entry["model_count"] = cached_count
|
||||||
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
|
|
||||||
results.append(entry)
|
results.append(entry)
|
||||||
|
|
||||||
return {"endpoints": results}
|
return {"endpoints": results}
|
||||||
@@ -1165,19 +1369,8 @@ def setup_model_routes(model_discovery):
|
|||||||
rows = db.query(ModelEndpoint).order_by(ModelEndpoint.created_at).all()
|
rows = db.query(ModelEndpoint).order_by(ModelEndpoint.created_at).all()
|
||||||
results = []
|
results = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
# Use cached model list to avoid slow probe on every load
|
all_models = _cached_model_ids(r)
|
||||||
all_models = []
|
hidden = _hidden_model_ids(r)
|
||||||
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
|
|
||||||
pinned = _normalize_model_ids(getattr(r, "pinned_models", None))
|
pinned = _normalize_model_ids(getattr(r, "pinned_models", None))
|
||||||
visible = _visible_models(all_models, r.hidden_models, pinned)
|
visible = _visible_models(all_models, r.hidden_models, pinned)
|
||||||
# Endpoint counts as reachable if it has any model — including
|
# 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)
|
ping = _ping_endpoint(r.base_url, r.api_key, timeout=1.0)
|
||||||
if ping.get("reachable"):
|
if ping.get("reachable"):
|
||||||
status = "empty"
|
status = "empty"
|
||||||
|
base = _normalize_base(r.base_url)
|
||||||
|
kind = _effective_endpoint_kind(r, base)
|
||||||
results.append({
|
results.append({
|
||||||
"id": r.id,
|
"id": r.id,
|
||||||
"name": r.name,
|
"name": r.name,
|
||||||
@@ -1202,6 +1397,11 @@ def setup_model_routes(model_discovery):
|
|||||||
"ping_error": (ping or {}).get("error") if ping else None,
|
"ping_error": (ping or {}).get("error") if ping else None,
|
||||||
"model_type": getattr(r, "model_type", None) or "llm",
|
"model_type": getattr(r, "model_type", None) or "llm",
|
||||||
"supports_tools": getattr(r, "supports_tools", None),
|
"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
|
return results
|
||||||
finally:
|
finally:
|
||||||
@@ -1216,6 +1416,10 @@ def setup_model_routes(model_discovery):
|
|||||||
skip_probe: str = Form("false"),
|
skip_probe: str = Form("false"),
|
||||||
require_models: str = Form("false"),
|
require_models: str = Form("false"),
|
||||||
model_type: str = Form("llm"),
|
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)
|
supports_tools: str = Form(""), # "true"/"false"/"" (unknown)
|
||||||
pinned_models: str = Form(""), # admin-pinned IDs: list/JSON/comma/newline
|
pinned_models: str = Form(""), # admin-pinned IDs: list/JSON/comma/newline
|
||||||
container_local: str = Form("false"),
|
container_local: str = Form("false"),
|
||||||
@@ -1240,8 +1444,15 @@ def setup_model_routes(model_discovery):
|
|||||||
if not name.strip():
|
if not name.strip():
|
||||||
name = base_url.replace("http://", "").replace("https://", "").split("/")[0]
|
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)
|
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
|
# Dedupe: if an endpoint with the same base_url already exists and
|
||||||
# is reachable by the caller (shared or owned by them), return it
|
# is reachable by the caller (shared or owned by them), return it
|
||||||
@@ -1259,6 +1470,7 @@ def setup_model_routes(model_discovery):
|
|||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if existing:
|
if existing:
|
||||||
|
changed = False
|
||||||
# Persist any incoming pinned IDs onto the existing row. An
|
# Persist any incoming pinned IDs onto the existing row. An
|
||||||
# empty/omitted form field must not wipe previously pinned IDs.
|
# empty/omitted form field must not wipe previously pinned IDs.
|
||||||
_incoming_pinned = _normalize_model_ids(pinned_models)
|
_incoming_pinned = _normalize_model_ids(pinned_models)
|
||||||
@@ -1268,15 +1480,45 @@ def setup_model_routes(model_discovery):
|
|||||||
_incoming_pinned,
|
_incoming_pinned,
|
||||||
)
|
)
|
||||||
existing.pinned_models = json.dumps(_merged_pinned) if _merged_pinned else None
|
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()
|
_db_dedup.commit()
|
||||||
_invalidate_models_cache()
|
_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_pinned = _normalize_model_ids(getattr(existing, "pinned_models", None))
|
||||||
|
existing_kind = _effective_endpoint_kind(existing, existing.base_url)
|
||||||
return {
|
return {
|
||||||
"id": existing.id,
|
"id": existing.id,
|
||||||
"name": existing.name,
|
"name": existing.name,
|
||||||
"base_url": existing.base_url,
|
"base_url": existing.base_url,
|
||||||
"models": _visible_models(
|
"models": _visible_models(
|
||||||
getattr(existing, "cached_models", None),
|
existing_models,
|
||||||
getattr(existing, "hidden_models", None),
|
getattr(existing, "hidden_models", None),
|
||||||
existing.pinned_models,
|
existing.pinned_models,
|
||||||
),
|
),
|
||||||
@@ -1284,16 +1526,16 @@ def setup_model_routes(model_discovery):
|
|||||||
"online": True,
|
"online": True,
|
||||||
"status": "online",
|
"status": "online",
|
||||||
"existing": True,
|
"existing": True,
|
||||||
|
"endpoint_kind": existing_kind,
|
||||||
|
"category": _classify_endpoint(existing.base_url, existing_kind),
|
||||||
}
|
}
|
||||||
finally:
|
finally:
|
||||||
_db_dedup.close()
|
_db_dedup.close()
|
||||||
|
|
||||||
# Quick model list fetch (1s timeout — if endpoint is slow, it'll update on next refresh)
|
model_ids = _probe_endpoint(base_url, api_key.strip() or None, timeout=explicit_timeout) if should_probe else []
|
||||||
_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 []
|
|
||||||
ping = {"reachable": False, "error": None}
|
ping = {"reachable": False, "error": None}
|
||||||
if should_probe and not model_ids:
|
if (should_probe or requested_kind in ("api", "proxy")) and not model_ids:
|
||||||
ping = _ping_endpoint(base_url, api_key.strip() or None, timeout=_probe_timeout)
|
ping = _ping_endpoint(base_url, api_key.strip() or None, timeout=min(explicit_timeout, 2.0))
|
||||||
if require_model_list and not model_ids:
|
if require_model_list and not model_ids:
|
||||||
raise HTTPException(400, _model_endpoint_error_message(base_url, ping))
|
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,
|
api_key=api_key.strip() or None,
|
||||||
is_enabled=True,
|
is_enabled=True,
|
||||||
model_type=model_type.strip() if model_type else "llm",
|
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,
|
cached_models=json.dumps(model_ids) if model_ids else None,
|
||||||
pinned_models=json.dumps(_pinned) if _pinned else None,
|
pinned_models=json.dumps(_pinned) if _pinned else None,
|
||||||
supports_tools=_st,
|
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")),
|
"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"),
|
"status": "online" if (model_ids or _pinned) else ("empty" if ping.get("reachable") else "offline"),
|
||||||
"ping_error": ping.get("error") if ping else None,
|
"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")
|
@router.post("/model-endpoints/test")
|
||||||
@@ -1356,6 +1604,8 @@ def setup_model_routes(model_discovery):
|
|||||||
request: Request,
|
request: Request,
|
||||||
base_url: str = Form(...),
|
base_url: str = Form(...),
|
||||||
api_key: str = Form(""),
|
api_key: str = Form(""),
|
||||||
|
endpoint_kind: str = Form("auto"),
|
||||||
|
model_refresh_timeout: str = Form(""),
|
||||||
):
|
):
|
||||||
require_admin(request)
|
require_admin(request)
|
||||||
base_url = _normalize_base(base_url)
|
base_url = _normalize_base(base_url)
|
||||||
@@ -1364,9 +1614,11 @@ def setup_model_routes(model_discovery):
|
|||||||
from src.endpoint_resolver import resolve_url
|
from src.endpoint_resolver import resolve_url
|
||||||
base_url = resolve_url(base_url)
|
base_url = resolve_url(base_url)
|
||||||
base_url = _rewrite_loopback_for_docker(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)
|
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 {
|
return {
|
||||||
"base_url": base_url,
|
"base_url": base_url,
|
||||||
"online": bool(models) or bool(ping.get("reachable")),
|
"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,
|
"ping_error": ping.get("error") if ping else None,
|
||||||
"models": models,
|
"models": models,
|
||||||
"count": len(models),
|
"count": len(models),
|
||||||
|
"endpoint_kind": requested_kind,
|
||||||
|
"category": _classify_endpoint(base_url, requested_kind),
|
||||||
}
|
}
|
||||||
|
|
||||||
@router.get("/model-endpoints/{ep_id}/probe")
|
@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()
|
ep_obj = db2.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
|
||||||
if ep_obj:
|
if ep_obj:
|
||||||
ep_obj.hidden_models = json.dumps(failed) if failed else None
|
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()
|
db2.commit()
|
||||||
finally:
|
finally:
|
||||||
db2.close()
|
db2.close()
|
||||||
@@ -1426,7 +1681,13 @@ def setup_model_routes(model_discovery):
|
|||||||
return StreamingResponse(_stream(), media_type="text/event-stream")
|
return StreamingResponse(_stream(), media_type="text/event-stream")
|
||||||
|
|
||||||
@router.get("/model-endpoints/{ep_id}/models")
|
@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."""
|
"""List all discovered models for an endpoint with hidden/visible state."""
|
||||||
require_admin(request)
|
require_admin(request)
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
@@ -1434,23 +1695,28 @@ def setup_model_routes(model_discovery):
|
|||||||
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
|
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
|
||||||
if not ep:
|
if not ep:
|
||||||
raise HTTPException(404, "Endpoint not found")
|
raise HTTPException(404, "Endpoint not found")
|
||||||
hidden = set()
|
hidden = _hidden_model_ids(ep)
|
||||||
if ep.hidden_models:
|
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:
|
try:
|
||||||
hidden = set(json.loads(ep.hidden_models))
|
probed = _probe_endpoint(base, ep.api_key, timeout=timeout)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
pass
|
logger.warning("Manual model refresh failed for endpoint %s at %s: %s", ep_id, base, exc)
|
||||||
# Try live probe, fall back to cached. Pinned IDs are admin-entered
|
probed = []
|
||||||
# and persist regardless of probe results — never overwritten here.
|
if probed:
|
||||||
all_models = _probe_endpoint(ep.base_url, ep.api_key, timeout=3)
|
all_models = probed
|
||||||
if all_models:
|
ep.cached_models = json.dumps(all_models)
|
||||||
ep.cached_models = json.dumps(all_models)
|
db.commit()
|
||||||
db.commit()
|
_invalidate_models_cache()
|
||||||
elif ep.cached_models:
|
response.headers["X-Model-Refresh-Status"] = "refreshed"
|
||||||
try:
|
response.headers["X-Model-Refresh-Count"] = str(len(probed))
|
||||||
all_models = json.loads(ep.cached_models)
|
else:
|
||||||
except Exception:
|
response.headers["X-Model-Refresh-Status"] = "failed"
|
||||||
pass
|
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 = _normalize_model_ids(getattr(ep, "pinned_models", None))
|
||||||
pinned_set = set(pinned)
|
pinned_set = set(pinned)
|
||||||
return [
|
return [
|
||||||
@@ -1502,7 +1768,6 @@ def setup_model_routes(model_discovery):
|
|||||||
|
|
||||||
@router.get("/default-chat")
|
@router.get("/default-chat")
|
||||||
def get_default_chat(request: Request):
|
def get_default_chat(request: Request):
|
||||||
import json as _json
|
|
||||||
# SECURITY: resolve the default endpoint + model from the CALLER's
|
# SECURITY: resolve the default endpoint + model from the CALLER's
|
||||||
# per-user prefs ONLY. We deliberately do NOT fall back to the
|
# per-user prefs ONLY. We deliberately do NOT fall back to the
|
||||||
# global `default_model` / `default_endpoint_id` in settings.json
|
# global `default_model` / `default_endpoint_id` in settings.json
|
||||||
@@ -1635,6 +1900,16 @@ def setup_model_routes(model_discovery):
|
|||||||
if "pinned_models" in body:
|
if "pinned_models" in body:
|
||||||
_pinned = _normalize_model_ids(body["pinned_models"])
|
_pinned = _normalize_model_ids(body["pinned_models"])
|
||||||
ep.pinned_models = json.dumps(_pinned) if _pinned else None
|
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
|
# Rotating an API key used to require DELETE+POST, which wiped
|
||||||
# endpoint_url/model from every session referencing the old base
|
# endpoint_url/model from every session referencing the old base
|
||||||
# URL. Allow in-place updates so the admin can change the key
|
# 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,
|
"model_type": ep.model_type,
|
||||||
"base_url": ep.base_url,
|
"base_url": ep.base_url,
|
||||||
"pinned_models": _normalize_model_ids(getattr(ep, "pinned_models", None)),
|
"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:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
@@ -19,9 +19,13 @@ def _load():
|
|||||||
|
|
||||||
|
|
||||||
def _save(prefs):
|
def _save(prefs):
|
||||||
os.makedirs(os.path.dirname(PREFS_FILE), exist_ok=True)
|
os.makedirs(os.path.dirname(PREFS_FILE) or ".", exist_ok=True)
|
||||||
with open(PREFS_FILE, "w", encoding="utf-8") as f:
|
tmp = f"{PREFS_FILE}.tmp.{os.getpid()}"
|
||||||
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
json.dump(prefs, f, indent=2)
|
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:
|
def _load_for_user(user: Optional[str] = None) -> dict:
|
||||||
|
|||||||
@@ -21,6 +21,22 @@ def _sanitize_export_filename(name: str) -> str:
|
|||||||
return name[:128]
|
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):
|
def _verify_session_owner(request: Request, session_id: str, session_manager=None):
|
||||||
"""Verify the current user owns the session. Raises 404 if not.
|
"""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:
|
finally:
|
||||||
db.close()
|
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,
|
"endpoint_url": s.endpoint_url, "rag": s.rag,
|
||||||
"archived": s.archived, "folder": folder_map.get(s.id),
|
"archived": s.archived, "folder": folder_map.get(s.id),
|
||||||
"total_tokens": token_map.get(s.id, 0),
|
"total_tokens": token_map.get(s.id, 0),
|
||||||
|
|||||||
@@ -280,13 +280,15 @@ def rank_image_models(system, search=None, sort="fit"):
|
|||||||
|
|
||||||
Returns list of models with fit info (vram needed, fits, recommended quant).
|
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
|
gpu_vram = system.get("gpu_vram_gb", 0) or 0
|
||||||
has_gpu = system.get("has_gpu", False)
|
has_gpu = system.get("has_gpu", False)
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
for model in IMAGE_MODEL_REGISTRY:
|
for model in IMAGE_MODEL_REGISTRY:
|
||||||
# Filter by search
|
# Filter by search
|
||||||
if search:
|
if isinstance(search, str) and search:
|
||||||
s = search.lower()
|
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():
|
if s not in model["name"].lower() and s not in model["id"].lower() and s not in model.get("description", "").lower():
|
||||||
continue
|
continue
|
||||||
|
|||||||
+7
-361
@@ -1,364 +1,10 @@
|
|||||||
|
"""Compatibility import for the canonical memory manager.
|
||||||
|
|
||||||
import json
|
Historically this package carried a second copy of ``MemoryManager``. The
|
||||||
import logging
|
application runtime instantiates ``src.memory.MemoryManager``, so keeping a
|
||||||
import os
|
parallel implementation here risks silent drift between import paths.
|
||||||
import time
|
"""
|
||||||
import uuid
|
|
||||||
import re
|
|
||||||
from typing import List, Dict, Tuple
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
from src.memory import MemoryManager, get_text_similarity, tokenize
|
||||||
|
|
||||||
def tokenize(text: str) -> List[str]:
|
__all__ = ["MemoryManager", "get_text_similarity", "tokenize"]
|
||||||
"""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]]
|
|
||||||
|
|||||||
@@ -1,175 +1,5 @@
|
|||||||
"""
|
"""Compatibility import for the canonical memory vector store."""
|
||||||
memory_vector.py
|
|
||||||
|
|
||||||
ChromaDB-backed vector store for memory entries.
|
from src.memory_vector import MemoryVectorStore
|
||||||
Shares the EmbeddingClient with RAG to save memory.
|
|
||||||
Stores pre-computed embeddings (ChromaDB does not manage embedding).
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
__all__ = ["MemoryVectorStore"]
|
||||||
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")
|
|
||||||
|
|||||||
+42
-55
@@ -43,6 +43,16 @@ class MemoryService:
|
|||||||
os.path.join(data_dir, "memory_vectors")
|
os.path.join(data_dir, "memory_vectors")
|
||||||
) else None
|
) 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:
|
async def remember(self, text: str, session_id: Optional[str] = None) -> Memory:
|
||||||
"""
|
"""
|
||||||
Store a new memory.
|
Store a new memory.
|
||||||
@@ -54,31 +64,19 @@ class MemoryService:
|
|||||||
Returns:
|
Returns:
|
||||||
Created Memory object
|
Created Memory object
|
||||||
"""
|
"""
|
||||||
import uuid
|
entry = self.manager.add_entry(text)
|
||||||
import time
|
if session_id:
|
||||||
|
entry["session_id"] = session_id
|
||||||
|
|
||||||
memory_id = str(uuid.uuid4())[:8]
|
memories = self.manager.load_all()
|
||||||
timestamp = int(time.time())
|
memories.append(entry)
|
||||||
|
self.manager.save(memories)
|
||||||
entry = {
|
|
||||||
"id": memory_id,
|
|
||||||
"text": text,
|
|
||||||
"timestamp": timestamp,
|
|
||||||
"session_id": session_id,
|
|
||||||
}
|
|
||||||
|
|
||||||
self.manager.add_memory(entry)
|
|
||||||
|
|
||||||
# Also add to vector store if available
|
# Also add to vector store if available
|
||||||
if self.vector_store:
|
if self.vector_store and self.vector_store.healthy:
|
||||||
self.vector_store.add(text, {"id": memory_id, "session_id": session_id})
|
self.vector_store.add(entry["id"], entry["text"])
|
||||||
|
|
||||||
return Memory(
|
return self._to_memory(entry)
|
||||||
id=memory_id,
|
|
||||||
text=text,
|
|
||||||
timestamp=timestamp,
|
|
||||||
session_id=session_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def recall(self, query: str, top_k: int = 5) -> MemorySearchResult:
|
async def recall(self, query: str, top_k: int = 5) -> MemorySearchResult:
|
||||||
"""
|
"""
|
||||||
@@ -92,47 +90,36 @@ class MemoryService:
|
|||||||
MemorySearchResult with matching memories
|
MemorySearchResult with matching memories
|
||||||
"""
|
"""
|
||||||
# Try vector search first
|
# 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)
|
results = self.vector_store.search(query, k=top_k)
|
||||||
memories = [
|
found = []
|
||||||
Memory(
|
for result in results:
|
||||||
id=r.get("id", ""),
|
entry = by_id.get(result.get("memory_id"))
|
||||||
text=r.get("text", ""),
|
if entry:
|
||||||
timestamp=r.get("timestamp", 0),
|
found.append(self._to_memory(entry, metadata={"score": result.get("score")}))
|
||||||
session_id=r.get("session_id"),
|
if found:
|
||||||
metadata=r.get("metadata", {}),
|
return MemorySearchResult(memories=found, query=query, total=len(found))
|
||||||
)
|
|
||||||
for r in results
|
|
||||||
if isinstance(r, dict)
|
|
||||||
]
|
|
||||||
return MemorySearchResult(memories=memories, query=query, total=len(memories))
|
|
||||||
|
|
||||||
# Fallback to keyword search
|
# Fallback to keyword search
|
||||||
results = self.manager.search_memories(query, limit=top_k)
|
results = self.manager.get_relevant_memories(query, all_memories, max_items=top_k)
|
||||||
memories = [
|
memories = [self._to_memory(m) for m in results]
|
||||||
Memory(
|
|
||||||
id=m.get("id", ""),
|
|
||||||
text=m.get("text", ""),
|
|
||||||
timestamp=m.get("timestamp", 0),
|
|
||||||
session_id=m.get("session_id"),
|
|
||||||
)
|
|
||||||
for m in results
|
|
||||||
]
|
|
||||||
return MemorySearchResult(memories=memories, query=query, total=len(memories))
|
return MemorySearchResult(memories=memories, query=query, total=len(memories))
|
||||||
|
|
||||||
def get_all(self, limit: int = 100) -> List[Memory]:
|
def get_all(self, limit: int = 100) -> List[Memory]:
|
||||||
"""Get all memories."""
|
"""Get all memories."""
|
||||||
memories = self.manager.get_memories(limit=limit)
|
memories = self.manager.load_all()[:limit]
|
||||||
return [
|
return [self._to_memory(m) for m in memories]
|
||||||
Memory(
|
|
||||||
id=m.get("id", ""),
|
|
||||||
text=m.get("text", ""),
|
|
||||||
timestamp=m.get("timestamp", 0),
|
|
||||||
session_id=m.get("session_id"),
|
|
||||||
)
|
|
||||||
for m in memories
|
|
||||||
]
|
|
||||||
|
|
||||||
def delete(self, memory_id: str) -> bool:
|
def delete(self, memory_id: str) -> bool:
|
||||||
"""Delete a memory by ID."""
|
"""Delete a memory by ID."""
|
||||||
return self.manager.delete_memory(memory_id)
|
memories = self.manager.load_all()
|
||||||
|
remaining = [m for m in memories if m.get("id") != memory_id]
|
||||||
|
if len(remaining) == len(memories):
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.manager.save(remaining)
|
||||||
|
if self.vector_store and self.vector_store.healthy:
|
||||||
|
self.vector_store.remove(memory_id)
|
||||||
|
return True
|
||||||
|
|||||||
+23
-9
@@ -37,15 +37,12 @@ class APIKeyManager:
|
|||||||
f = Fernet(self.get_or_create_key())
|
f = Fernet(self.get_or_create_key())
|
||||||
return f.decrypt(encrypted_key.encode()).decode()
|
return f.decrypt(encrypted_key.encode()).decode()
|
||||||
|
|
||||||
def save(self, provider: str, api_key: str):
|
def _load_raw(self) -> Dict[str, str]:
|
||||||
"""Save encrypted API key to file"""
|
"""Load the raw, still-encrypted keys dict from disk.
|
||||||
keys = self.load()
|
|
||||||
keys[provider] = self.encrypt_api_key(api_key)
|
Tolerates a missing/corrupt/wrong-shaped file by returning {} — the
|
||||||
with open(self.api_keys_file, 'w', encoding="utf-8") as f:
|
same robustness load() relies on at startup.
|
||||||
json.dump(keys, f)
|
"""
|
||||||
|
|
||||||
def load(self) -> Dict[str, str]:
|
|
||||||
"""Load and decrypt API keys"""
|
|
||||||
if not os.path.exists(self.api_keys_file):
|
if not os.path.exists(self.api_keys_file):
|
||||||
return {}
|
return {}
|
||||||
try:
|
try:
|
||||||
@@ -60,7 +57,24 @@ class APIKeyManager:
|
|||||||
# Legacy/wrong shape (e.g. a list) — .items() would raise. Ignore it.
|
# 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__)
|
logger.warning("API keys file has unexpected shape (%s); ignoring", type(encrypted_keys).__name__)
|
||||||
return {}
|
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 = {}
|
decrypted = {}
|
||||||
for provider, key in encrypted_keys.items():
|
for provider, key in encrypted_keys.items():
|
||||||
try:
|
try:
|
||||||
|
|||||||
+20
-3
@@ -105,6 +105,25 @@ def _to_utc_naive(dt):
|
|||||||
return datetime(dt.year, dt.month, dt.day), True
|
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:
|
def _sync_blocking(owner: str, url: str, username: str, password: str) -> dict:
|
||||||
"""The actual sync — synchronous, intended to run in a threadpool.
|
"""The actual sync — synchronous, intended to run in a threadpool.
|
||||||
Returns counts: {calendars, events, deleted, errors}."""
|
Returns counts: {calendars, events, deleted, errors}."""
|
||||||
@@ -235,9 +254,7 @@ def _sync_blocking(owner: str, url: str, username: str, password: str) -> dict:
|
|||||||
else ""
|
else ""
|
||||||
)
|
)
|
||||||
|
|
||||||
existing = pending.get(uid_val) or db.query(CalendarEvent).filter(
|
existing = _find_existing_event(db, pending, uid_val, local_cal.id)
|
||||||
CalendarEvent.uid == uid_val,
|
|
||||||
).first()
|
|
||||||
if existing:
|
if existing:
|
||||||
existing.calendar_id = local_cal.id
|
existing.calendar_id = local_cal.id
|
||||||
existing.summary = summary
|
existing.summary = summary
|
||||||
|
|||||||
@@ -743,8 +743,74 @@ def _normalize_anthropic_url(url: str) -> str:
|
|||||||
return url + "/messages"
|
return url + "/messages"
|
||||||
return url + "/v1/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]:
|
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."""
|
"""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)
|
provider = _detect_provider(base_chat_url)
|
||||||
if provider == "anthropic":
|
if provider == "anthropic":
|
||||||
return list(ANTHROPIC_MODELS)
|
return list(ANTHROPIC_MODELS)
|
||||||
|
|||||||
@@ -132,6 +132,20 @@ class MemoryManager:
|
|||||||
if owner is None:
|
if owner is None:
|
||||||
return entries
|
return entries
|
||||||
return [e for e in entries if e.get("owner") == owner]
|
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]:
|
def _validate_entries(self, entries: List[Dict]) -> List[Dict]:
|
||||||
"""Ensure all entries have required fields."""
|
"""Ensure all entries have required fields."""
|
||||||
|
|||||||
+60
-1
@@ -6,6 +6,7 @@ Provides token estimation for context usage tracking.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import sys
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from urllib.parse import urlparse
|
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.")
|
"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:
|
def _is_local_endpoint(url: str) -> bool:
|
||||||
"""Check if URL points to a local/private/tailscale address."""
|
"""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:
|
try:
|
||||||
host = urlparse(url).hostname or ""
|
host = urlparse(url).hostname or ""
|
||||||
return host in _LOCAL_HOSTS or host.startswith(_PRIVATE_PREFIXES)
|
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.
|
or context_window fields. Caches result per model ID.
|
||||||
Falls back to DEFAULT_CONTEXT if unavailable.
|
Falls back to DEFAULT_CONTEXT if unavailable.
|
||||||
"""
|
"""
|
||||||
|
configured_kind = _configured_endpoint_kind(endpoint_url)
|
||||||
is_local = _is_local_endpoint(endpoint_url)
|
is_local = _is_local_endpoint(endpoint_url)
|
||||||
if not is_local and model in _context_cache:
|
if not is_local and model in _context_cache:
|
||||||
return _context_cache[model]
|
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.
|
# Only cache non-default values to allow retry on next request.
|
||||||
# Local endpoints can restart with a different --max-model-len while keeping
|
# 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.
|
# 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
|
_context_cache[model] = ctx
|
||||||
logger.info(f"Context length for {model}: {ctx}")
|
logger.info(f"Context length for {model}: {ctx}")
|
||||||
return ctx
|
return ctx
|
||||||
@@ -207,6 +256,16 @@ def _query_context_length(endpoint_url: str, model: str) -> int:
|
|||||||
"""Query the model API for context length."""
|
"""Query the model API for context length."""
|
||||||
known = _lookup_known(model)
|
known = _lookup_known(model)
|
||||||
api_ctx = None
|
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
|
# Try llama.cpp /slots endpoint first — reports actual serving context
|
||||||
if _is_local_endpoint(endpoint_url):
|
if _is_local_endpoint(endpoint_url):
|
||||||
|
|||||||
@@ -2079,6 +2079,10 @@
|
|||||||
</select>
|
</select>
|
||||||
<div class="admin-model-form-row">
|
<div class="admin-model-form-row">
|
||||||
<input id="adm-epApiKey" type="password" placeholder="API key">
|
<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;">
|
<select id="adm-epType" style="padding:5px;width:80px;">
|
||||||
<option value="llm">LLM</option>
|
<option value="llm">LLM</option>
|
||||||
<option value="image">Image</option>
|
<option value="image">Image</option>
|
||||||
|
|||||||
+64
-9
@@ -371,7 +371,7 @@ async function loadEndpoints() {
|
|||||||
const listLegacy = el('adm-epList');
|
const listLegacy = el('adm-epList');
|
||||||
// Refresh model picker so new endpoints show up in chat
|
// Refresh model picker so new endpoints show up in chat
|
||||||
if (window.modelsModule && window.modelsModule.refreshModels) {
|
if (window.modelsModule && window.modelsModule.refreshModels) {
|
||||||
window.modelsModule.refreshModels(true);
|
window.modelsModule.refreshModels();
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (window.sessionModule && window.sessionModule.updateModelPicker) {
|
if (window.sessionModule && window.sessionModule.updateModelPicker) {
|
||||||
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">${visibleCount}/${totalCount} models enabled</span>`
|
||||||
: '<span class="admin-badge admin-badge-off">offline</span>';
|
: '<span class="admin-badge admin-badge-off">offline</span>';
|
||||||
const justAddedClass = (_recentlyAddedEpId && String(ep.id) === _recentlyAddedEpId) ? ' adm-ep-just-added' : '';
|
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 `
|
return `
|
||||||
<div class="admin-user-row${ep.is_enabled ? '' : ' admin-ep-disabled'}${justAddedClass}" data-adm-ep-id="${ep.id}">
|
<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 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;">
|
<div class="admin-user-info" style="flex:1;flex-wrap:wrap;gap:0.3rem;">
|
||||||
<span class="admin-user-name">${esc(ep.name)}</span>
|
<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>' : ''}
|
${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}
|
${statusBadge}
|
||||||
${ep.is_enabled ? '' : '<span class="admin-badge admin-badge-off">disabled</span>'}
|
${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>' : ''}
|
${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>' : ''}
|
${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>
|
</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>` : ''}
|
${hasModels ? `<div class="mcp-tools-panel hidden" data-adm-ep-models-panel="${ep.id}"></div>` : ''}
|
||||||
</div>`;
|
</div>`;
|
||||||
});
|
});
|
||||||
@@ -446,7 +449,7 @@ async function loadEndpoints() {
|
|||||||
container.innerHTML = indices.map(i => rowHtml[i]).join('');
|
container.innerHTML = indices.map(i => rowHtml[i]).join('');
|
||||||
};
|
};
|
||||||
const localIdx = [], apiIdx = [];
|
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.
|
// Sort each section: enabled endpoints first, disabled at the bottom.
|
||||||
// Preserve original order within each group via stable sort.
|
// Preserve original order within each group via stable sort.
|
||||||
const _sortByEnabled = (a, b) => Number(!!data[b].is_enabled) - Number(!!data[a].is_enabled);
|
const _sortByEnabled = (a, b) => Number(!!data[b].is_enabled) - Number(!!data[a].is_enabled);
|
||||||
@@ -552,22 +555,48 @@ async function loadEndpoints() {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
panel.appendChild(_ld);
|
panel.appendChild(_ld);
|
||||||
const _stopSpin = () => { try { _modelsSpin && _modelsSpin.stop(); } catch (_) {} };
|
const _stopSpin = () => { try { _modelsSpin && _modelsSpin.stop(); } catch (_) {} };
|
||||||
try {
|
const _loadingHtml = (label) => `<span style="opacity:0.55;font-size:11px;display:inline-flex;align-items:center;gap:8px;">${esc(label)}</span>`;
|
||||||
const res = await fetch(`/api/model-endpoints/${epId}/models`, { credentials: 'same-origin' });
|
const renderModels = (models, warning = '') => {
|
||||||
const models = await res.json();
|
|
||||||
_stopSpin();
|
|
||||||
const sortedModels = sortModelObjects(models);
|
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 hiddenSet = new Set(sortedModels.filter(m => m.is_hidden).map(m => m.id));
|
||||||
const showSearch = sortedModels.length >= 8;
|
const showSearch = sortedModels.length >= 8;
|
||||||
panel.innerHTML = `<div class="mcp-tools-header">
|
panel.innerHTML = `<div class="mcp-tools-header">
|
||||||
<span>Models</span>
|
<span>Models</span>
|
||||||
<span style="display:flex;gap:8px;align-items:center;">
|
<span style="display:flex;gap:8px;align-items:center;">
|
||||||
<span class="mcp-tools-count">${sortedModels.length - hiddenSet.size}/${sortedModels.length} enabled</span>
|
<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-all="${epId}">All</a>
|
||||||
<a href="#" data-ep-select-none="${epId}">None</a>
|
<a href="#" data-ep-select-none="${epId}">None</a>
|
||||||
</span>
|
</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">
|
`<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' : ''}>
|
<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>
|
<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';
|
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-search="${epId}"]`)?.addEventListener('input', (e) => filterRows(e.target.value));
|
||||||
panel.querySelector(`[data-ep-select-all="${epId}"]`)?.addEventListener('click', (e) => {
|
panel.querySelector(`[data-ep-select-all="${epId}"]`)?.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -598,6 +628,13 @@ async function loadEndpoints() {
|
|||||||
panel.querySelectorAll('input[type=checkbox]').forEach(cb => {
|
panel.querySelectorAll('input[type=checkbox]').forEach(cb => {
|
||||||
cb.addEventListener('change', () => _saveEpModelState(epId, panel));
|
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>'; }
|
} 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() {
|
function initEndpointForm() {
|
||||||
const provider = el('adm-epProvider');
|
const provider = el('adm-epProvider');
|
||||||
const urlInput = el('adm-epUrl');
|
const urlInput = el('adm-epUrl');
|
||||||
|
const kindSel = el('adm-epKind');
|
||||||
|
|
||||||
// Custom provider picker — mirrors the (now hidden) <select id="adm-epProvider">
|
// Custom provider picker — mirrors the (now hidden) <select id="adm-epProvider">
|
||||||
// so the rest of this function (which reads provider.value and dispatches
|
// so the rest of this function (which reads provider.value and dispatches
|
||||||
@@ -688,14 +726,20 @@ function initEndpointForm() {
|
|||||||
provider.addEventListener('change', () => {
|
provider.addEventListener('change', () => {
|
||||||
if (provider.value) urlInput.value = provider.value;
|
if (provider.value) urlInput.value = provider.value;
|
||||||
else urlInput.value = '';
|
else urlInput.value = '';
|
||||||
|
if (kindSel) kindSel.value = provider.value ? 'api' : 'proxy';
|
||||||
});
|
});
|
||||||
urlInput.addEventListener('input', () => {
|
urlInput.addEventListener('input', () => {
|
||||||
if (provider.value && urlInput.value.trim() !== provider.value) {
|
if (provider.value && urlInput.value.trim() !== provider.value) {
|
||||||
provider.value = '';
|
provider.value = '';
|
||||||
|
if (kindSel) kindSel.value = 'proxy';
|
||||||
_renderPickerMenu();
|
_renderPickerMenu();
|
||||||
_syncPickerCurrent();
|
_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) {
|
function _normalizeBaseUrl(raw) {
|
||||||
let u = raw.trim();
|
let u = raw.trim();
|
||||||
// Fix common protocol typos
|
// Fix common protocol typos
|
||||||
@@ -784,6 +828,8 @@ function initEndpointForm() {
|
|||||||
try {
|
try {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('base_url', url);
|
fd.append('base_url', url);
|
||||||
|
fd.append('endpoint_kind', _apiEndpointKind());
|
||||||
|
fd.append('model_refresh_timeout', '30');
|
||||||
if (apiKey) fd.append('api_key', apiKey);
|
if (apiKey) fd.append('api_key', apiKey);
|
||||||
const res = await fetch('/api/model-endpoints/test', {
|
const res = await fetch('/api/model-endpoints/test', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -828,6 +874,10 @@ function initEndpointForm() {
|
|||||||
try {
|
try {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('base_url', url);
|
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 (apiKey) fd.append('api_key', apiKey);
|
||||||
if (provider.value && provider.selectedOptions && provider.selectedOptions[0]) {
|
if (provider.value && provider.selectedOptions && provider.selectedOptions[0]) {
|
||||||
fd.append('name', provider.selectedOptions[0].textContent.trim());
|
fd.append('name', provider.selectedOptions[0].textContent.trim());
|
||||||
@@ -842,6 +892,7 @@ function initEndpointForm() {
|
|||||||
const count = d.models ? d.models.length : 0;
|
const count = d.models ? d.models.length : 0;
|
||||||
urlInput.value = ''; urlInput.style.display = '';
|
urlInput.value = ''; urlInput.style.display = '';
|
||||||
el('adm-epApiKey').value = ''; provider.value = '';
|
el('adm-epApiKey').value = ''; provider.value = '';
|
||||||
|
if (kindSel) kindSel.value = 'proxy';
|
||||||
if (epType) epType.value = 'llm';
|
if (epType) epType.value = 'llm';
|
||||||
if (d.id) _recentlyAddedEpId = String(d.id);
|
if (d.id) _recentlyAddedEpId = String(d.id);
|
||||||
await loadEndpoints();
|
await loadEndpoints();
|
||||||
@@ -904,6 +955,8 @@ function initEndpointForm() {
|
|||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('base_url', url);
|
fd.append('base_url', url);
|
||||||
if (apiKey) fd.append('api_key', apiKey);
|
if (apiKey) fd.append('api_key', apiKey);
|
||||||
|
fd.append('endpoint_kind', 'local');
|
||||||
|
fd.append('model_refresh_mode', 'auto');
|
||||||
const lt = el('adm-epLocalType');
|
const lt = el('adm-epLocalType');
|
||||||
if (lt) fd.append('model_type', lt.value);
|
if (lt) fd.append('model_type', lt.value);
|
||||||
fd.append('skip_probe', 'false');
|
fd.append('skip_probe', 'false');
|
||||||
@@ -986,6 +1039,8 @@ function initEndpointForm() {
|
|||||||
const base = item.url.replace('/chat/completions', '').replace(/\/$/, '');
|
const base = item.url.replace('/chat/completions', '').replace(/\/$/, '');
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('base_url', base);
|
fd.append('base_url', base);
|
||||||
|
fd.append('endpoint_kind', 'local');
|
||||||
|
fd.append('model_refresh_mode', 'auto');
|
||||||
fd.append('skip_probe', 'false');
|
fd.append('skip_probe', 'false');
|
||||||
const r = await fetch('/api/model-endpoints', { method: 'POST', body: fd });
|
const r = await fetch('/api/model-endpoints', { method: 'POST', body: fd });
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { providerLogo } from './providers.js';
|
|||||||
import settingsModule from './settings.js';
|
import settingsModule from './settings.js';
|
||||||
import spinnerModule from './spinner.js';
|
import spinnerModule from './spinner.js';
|
||||||
import { bindMenuDismiss } from './escMenuStack.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 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>';
|
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 */
|
/** Look up model info (pricing + context) by substring match */
|
||||||
export function getModelInfo(modelName) {
|
export function getModelInfo(modelName) {
|
||||||
if (!modelName) return null;
|
if (!modelName) return null;
|
||||||
const name = modelName.toLowerCase();
|
const key = matchModelKey(modelName, Object.keys(MODEL_INFO));
|
||||||
for (const [key, info] of Object.entries(MODEL_INFO)) {
|
return key ? { key, ...MODEL_INFO[key] } : null;
|
||||||
if (name.includes(key)) return { key, ...info };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function _fmtCtx(n) {
|
function _fmtCtx(n) {
|
||||||
@@ -634,13 +632,10 @@ export function applyModelColor(roleEl, modelName) {
|
|||||||
|
|
||||||
export function getModelCost(modelName, inputTokens, outputTokens) {
|
export function getModelCost(modelName, inputTokens, outputTokens) {
|
||||||
if (!modelName) return null;
|
if (!modelName) return null;
|
||||||
const name = modelName.toLowerCase();
|
const key = matchModelKey(modelName, Object.keys(MODEL_PRICING));
|
||||||
for (const [key, price] of Object.entries(MODEL_PRICING)) {
|
if (!key) return null;
|
||||||
if (name.includes(key)) {
|
const price = MODEL_PRICING[key];
|
||||||
return (inputTokens * price.input + outputTokens * price.output) / 1_000_000;
|
return (inputTokens * price.input + outputTokens * price.output) / 1_000_000;
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -210,7 +210,9 @@ async function _buildCompareUI() {
|
|||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
const m = state._selectedModels[i];
|
const m = state._selectedModels[i];
|
||||||
const fd = new FormData();
|
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('endpoint_url', m.endpoint || '');
|
||||||
fd.append('model', m.model || '');
|
fd.append('model', m.model || '');
|
||||||
if (m.endpointId) {
|
if (m.endpointId) {
|
||||||
|
|||||||
@@ -382,7 +382,8 @@ async function _createAndAppendPane(m) {
|
|||||||
|
|
||||||
// Create session
|
// Create session
|
||||||
const fd = new FormData();
|
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('endpoint_url', m.url || '');
|
||||||
fd.append('model', m.id || '');
|
fd.append('model', m.id || '');
|
||||||
if (m.endpointId) {
|
if (m.endpointId) {
|
||||||
@@ -584,7 +585,8 @@ function _showModelSwapDropdown(paneIdx, titleBtn) {
|
|||||||
fetch(`${state.API_BASE}/api/session/${oldSid}`, { method: 'DELETE' }).catch(() => {});
|
fetch(`${state.API_BASE}/api/session/${oldSid}`, { method: 'DELETE' }).catch(() => {});
|
||||||
}
|
}
|
||||||
const fd = new FormData();
|
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('endpoint_url', m.url || '');
|
||||||
fd.append('model', m.id || '');
|
fd.append('model', m.id || '');
|
||||||
if (m.endpointId) {
|
if (m.endpointId) {
|
||||||
|
|||||||
@@ -728,37 +728,48 @@ export function _tmuxCmd(task, tmuxArgs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _winSessionCmd(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 sid = task.sessionId;
|
||||||
const pf = _sshPrefix(_getPort(task));
|
const pf = _sshPrefix(_getPort(task));
|
||||||
const host = task.remoteHost;
|
|
||||||
if (tmuxArgs.includes('capture-pane')) {
|
if (tmuxArgs.includes('capture-pane')) {
|
||||||
const lines = tmuxArgs.match(/-S\s*-?(\d+)/)?.[1] || '200';
|
const lines = tmuxArgs.match(/-S\s*-?(\d+)/)?.[1] || '200';
|
||||||
const ps = `Get-Content '${sd}\\${sid}.log' -Tail ${lines} -ErrorAction SilentlyContinue`;
|
const ps = host
|
||||||
return `ssh ${pf}${host} "powershell -Command \\"${ps}\\""`;
|
? `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')) {
|
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 }`;
|
const ps = host
|
||||||
return `ssh ${pf}${host} "powershell -Command \\"${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 }`
|
||||||
|
: `$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')) {
|
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`;
|
const ps = host
|
||||||
return `ssh ${pf}${host} "powershell -Command \\"${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`
|
||||||
|
: `$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')) {
|
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 }`;
|
const ps = host
|
||||||
return `ssh ${pf}${host} "powershell -Command \\"${ps}\\""`;
|
? `$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) {
|
function _tmuxGracefulKill(task) {
|
||||||
if (_isWindows(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 sid = task.sessionId;
|
||||||
const pf = _sshPrefix(_getPort(task));
|
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`;
|
const ps = host
|
||||||
return `ssh ${pf}${task.remoteHost} "powershell -Command \\"${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`
|
||||||
|
: `$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) {
|
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'`;
|
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)) {
|
if (_isWindows(task)) {
|
||||||
const sd = '$env:TEMP\\odysseus-sessions';
|
const host = task.remoteHost;
|
||||||
const logCmd = `ssh ${_sshPrefix(_getPort(task))}${task.remoteHost} "powershell -Command \\"Get-Content '${sd}\\${task.sessionId}.log' -Wait\\""`;
|
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: () => {
|
items.push({ label: 'Copy log cmd', action: 'copy-tmux', custom: () => {
|
||||||
_copyText(logCmd);
|
_copyText(logCmd);
|
||||||
}});
|
}});
|
||||||
|
|||||||
@@ -391,6 +391,27 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function libraryRemoveDocumentFromState(docId) {
|
||||||
|
const removed = _libraryDocs.find(d => String(d.id) === String(docId));
|
||||||
|
_libraryDocs = _libraryDocs.filter(d => String(d.id) !== String(docId));
|
||||||
|
_librarySelectedIds.delete(docId);
|
||||||
|
_libraryTotal = Math.max(0, _libraryTotal - 1);
|
||||||
|
|
||||||
|
const lang = removed && (removed.language || 'text');
|
||||||
|
if (lang && Object.prototype.hasOwnProperty.call(_libraryLanguages, lang)) {
|
||||||
|
const next = Math.max(0, Number(_libraryLanguages[lang] || 0) - 1);
|
||||||
|
if (next > 0) {
|
||||||
|
_libraryLanguages[lang] = next;
|
||||||
|
} else {
|
||||||
|
delete _libraryLanguages[lang];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
libraryRenderStats();
|
||||||
|
libraryRenderLangChips();
|
||||||
|
libraryUpdateBulkCount();
|
||||||
|
}
|
||||||
|
|
||||||
function libraryRenderGrid() {
|
function libraryRenderGrid() {
|
||||||
const grid = document.getElementById('doclib-grid');
|
const grid = document.getElementById('doclib-grid');
|
||||||
if (!grid) return;
|
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' });
|
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');
|
if (!res.ok) throw new Error('failed');
|
||||||
// Drop it from the current view (it no longer belongs here) and refresh.
|
// Drop it from the current view (it no longer belongs here) and refresh.
|
||||||
_libraryDocs = _libraryDocs.filter(d => d.id !== doc.id);
|
libraryRemoveDocumentFromState(doc.id);
|
||||||
_libraryTotal = Math.max(0, _libraryTotal - 1);
|
|
||||||
libraryRenderGrid();
|
libraryRenderGrid();
|
||||||
if (uiModule) uiModule.showToast(toArchived ? 'Archived' : 'Restored');
|
if (uiModule) uiModule.showToast(toArchived ? 'Archived' : 'Restored');
|
||||||
} catch { if (uiModule) uiModule.showError('Failed to ' + (toArchived ? 'archive' : 'restore')); }
|
} catch { if (uiModule) uiModule.showError('Failed to ' + (toArchived ? 'archive' : 'restore')); }
|
||||||
@@ -802,8 +822,7 @@ let _libraryArchivedView = false; // Documents tab showing archived docs?
|
|||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/api/document/${doc.id}/archive?archived=${toArchived}`, { method: 'POST', credentials: 'same-origin' });
|
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');
|
if (!res.ok) throw new Error('failed');
|
||||||
_libraryDocs = _libraryDocs.filter(d => d.id !== doc.id);
|
libraryRemoveDocumentFromState(doc.id);
|
||||||
_libraryTotal = Math.max(0, _libraryTotal - 1);
|
|
||||||
libraryRenderGrid();
|
libraryRenderGrid();
|
||||||
if (uiModule) uiModule.showToast(toArchived ? 'Archived' : 'Restored');
|
if (uiModule) uiModule.showToast(toArchived ? 'Archived' : 'Restored');
|
||||||
} catch { if (uiModule) uiModule.showError('Failed to ' + (toArchived ? 'archive' : 'restore')); }
|
} 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 });
|
card.addEventListener('transitionend', () => card.remove(), { once: true });
|
||||||
setTimeout(() => { if (card.parentElement) card.remove(); }, 400);
|
setTimeout(() => { if (card.parentElement) card.remove(); }, 400);
|
||||||
}
|
}
|
||||||
_libraryDocs = _libraryDocs.filter(d => d.id !== docId);
|
libraryRemoveDocumentFromState(docId);
|
||||||
_libraryTotal = Math.max(0, _libraryTotal - 1);
|
|
||||||
libraryRenderStats();
|
|
||||||
if (uiModule) uiModule.showToast('Document deleted');
|
if (uiModule) uiModule.showToast('Document deleted');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (uiModule) uiModule.showError(`Failed to delete document: ${e.message || e}`);
|
if (uiModule) uiModule.showError(`Failed to delete document: ${e.message || e}`);
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// static/js/model/matchKey.js
|
||||||
|
//
|
||||||
|
// Pure helper for matching a model name against a set of known keys. No DOM —
|
||||||
|
// safe to import anywhere and to unit-test under node.
|
||||||
|
|
||||||
|
// Return the most specific (longest) key that is a substring of `name`, or null.
|
||||||
|
// Returning the first match instead made "gpt-4o-mini" match the shorter
|
||||||
|
// "gpt-4o" key — billing it at gpt-4o rates (~16x) and showing the wrong
|
||||||
|
// context window.
|
||||||
|
export function matchModelKey(name, keys) {
|
||||||
|
const n = (name || '').toLowerCase();
|
||||||
|
let best = null;
|
||||||
|
for (const key of keys) {
|
||||||
|
if (n.includes(key) && (best === null || key.length > best.length)) {
|
||||||
|
best = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
@@ -561,7 +561,7 @@ function _initModelPickerDropdown() {
|
|||||||
menu.classList.remove('closing', 'hidden');
|
menu.classList.remove('closing', 'hidden');
|
||||||
_populate('');
|
_populate('');
|
||||||
if (window.modelsModule && window.modelsModule.refreshModels) {
|
if (window.modelsModule && window.modelsModule.refreshModels) {
|
||||||
window.modelsModule.refreshModels(true).then(() => {
|
window.modelsModule.refreshModels().then(() => {
|
||||||
if (!menu.classList.contains('hidden')) _populate(search.value || '');
|
if (!menu.classList.contains('hidden')) _populate(search.value || '');
|
||||||
updateModelPicker();
|
updateModelPicker();
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|||||||
+10
-2
@@ -16,6 +16,7 @@ import { sortModelIds } from './modelSort.js';
|
|||||||
let API_BASE = '';
|
let API_BASE = '';
|
||||||
let _cachedItems = []; // cached /api/models items for model-switch dropdown
|
let _cachedItems = []; // cached /api/models items for model-switch dropdown
|
||||||
let _lastFetchTime = 0;
|
let _lastFetchTime = 0;
|
||||||
|
let _fetchInflight = null;
|
||||||
const _FETCH_CACHE_TTL = 30000; // 30s client-side cache for /api/models
|
const _FETCH_CACHE_TTL = 30000; // 30s client-side cache for /api/models
|
||||||
const COLLAPSE_KEY = 'odysseus-models-collapsed';
|
const COLLAPSE_KEY = 'odysseus-models-collapsed';
|
||||||
const FAVORITES_KEY = 'odysseus-model-favorites';
|
const FAVORITES_KEY = 'odysseus-model-favorites';
|
||||||
@@ -176,8 +177,15 @@ export async function refreshModels(force = false) {
|
|||||||
box.appendChild(_loadingSpinner.createElement());
|
box.appendChild(_loadingSpinner.createElement());
|
||||||
_loadingSpinner.start();
|
_loadingSpinner.start();
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/api/models`);
|
if (!_fetchInflight) {
|
||||||
const data = await res.json();
|
_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();
|
_lastFetchTime = Date.now();
|
||||||
_cachedItems = data.items || [];
|
_cachedItems = data.items || [];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
+45
-10
@@ -4,22 +4,57 @@ and _append_tool_results. Uses mock imports to avoid loading the full app stack.
|
|||||||
import sys
|
import sys
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
# Mock heavy dependencies before importing
|
_MOCKED_IMPORTS = [
|
||||||
for mod in [
|
|
||||||
'sqlalchemy', 'sqlalchemy.orm', 'sqlalchemy.ext', 'sqlalchemy.ext.declarative',
|
'sqlalchemy', 'sqlalchemy.orm', 'sqlalchemy.ext', 'sqlalchemy.ext.declarative',
|
||||||
'sqlalchemy.ext.hybrid', 'sqlalchemy.sql', 'sqlalchemy.sql.expression',
|
'sqlalchemy.ext.hybrid', 'sqlalchemy.sql', 'sqlalchemy.sql.expression',
|
||||||
'src.database',
|
'src.database',
|
||||||
'src.agent_tools',
|
'src.agent_tools',
|
||||||
'core.models', 'core.database',
|
'core.models', 'core.database',
|
||||||
]:
|
]
|
||||||
if mod not in sys.modules:
|
_INJECTED_IMPORT_STUBS = {}
|
||||||
sys.modules[mod] = MagicMock()
|
_PREEXISTING_AGENT_LOOP = sys.modules.get("src.agent_loop")
|
||||||
|
|
||||||
from src.agent_loop import (
|
|
||||||
_detect_admin_intent,
|
def _drop_module_if_same(name, expected):
|
||||||
_compute_final_metrics,
|
if sys.modules.get(name) is expected:
|
||||||
_append_tool_results,
|
sys.modules.pop(name, None)
|
||||||
)
|
parent_name, _, attr = name.rpartition(".")
|
||||||
|
parent = sys.modules.get(parent_name)
|
||||||
|
if parent is not None and getattr(parent, "__dict__", {}).get(attr) is expected:
|
||||||
|
delattr(parent, attr)
|
||||||
|
|
||||||
|
|
||||||
|
# Mock heavy dependencies before importing. Only clean up stubs this file
|
||||||
|
# created so pre-existing conftest/pytest modules keep their intended state.
|
||||||
|
for mod in _MOCKED_IMPORTS:
|
||||||
|
if mod not in sys.modules:
|
||||||
|
stub = MagicMock()
|
||||||
|
sys.modules[mod] = stub
|
||||||
|
_INJECTED_IMPORT_STUBS[mod] = stub
|
||||||
|
|
||||||
|
_IMPORTED_AGENT_LOOP = None
|
||||||
|
try:
|
||||||
|
from src.agent_loop import (
|
||||||
|
_detect_admin_intent,
|
||||||
|
_compute_final_metrics,
|
||||||
|
_append_tool_results,
|
||||||
|
)
|
||||||
|
_IMPORTED_AGENT_LOOP = sys.modules.get("src.agent_loop")
|
||||||
|
finally:
|
||||||
|
if _PREEXISTING_AGENT_LOOP is None and _IMPORTED_AGENT_LOOP is not None:
|
||||||
|
_drop_module_if_same("src.agent_loop", _IMPORTED_AGENT_LOOP)
|
||||||
|
for _mod, _stub in _INJECTED_IMPORT_STUBS.items():
|
||||||
|
_drop_module_if_same(_mod, _stub)
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_stubs_do_not_leak_into_later_tests():
|
||||||
|
leaked = [
|
||||||
|
mod for mod, stub in _INJECTED_IMPORT_STUBS.items()
|
||||||
|
if sys.modules.get(mod) is stub
|
||||||
|
]
|
||||||
|
assert leaked == []
|
||||||
|
if _PREEXISTING_AGENT_LOOP is None:
|
||||||
|
assert sys.modules.get("src.agent_loop") is not _IMPORTED_AGENT_LOOP
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ def _seed(owner, *models):
|
|||||||
db.query(DbSession).delete()
|
db.query(DbSession).delete()
|
||||||
for m in models:
|
for m in models:
|
||||||
db.add(DbSession(id=str(uuid.uuid4()), owner=owner, name=f"chat {m}",
|
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()
|
db.commit()
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""Regression tests for issue #1285 — blind Compare must not leak model
|
||||||
|
identities through helper-session names or GET /api/sessions.
|
||||||
|
|
||||||
|
Two guards are pinned here:
|
||||||
|
|
||||||
|
1. Backend: ``routes.session_routes._public_model`` blanks the ``model`` field
|
||||||
|
of any ``[CMP] …`` helper session in the session list, so the sidebar /
|
||||||
|
``/api/sessions`` can't be used to map a neutral pane label ("Model A")
|
||||||
|
back to its real model.
|
||||||
|
2. Frontend: every ``[CMP]`` session name built in ``static/js/compare/`` is
|
||||||
|
guarded by ``state._blindMode`` so blind sessions are named by slot rather
|
||||||
|
than by the real model.
|
||||||
|
|
||||||
|
The backend import mirrors tests/test_session_ghost_delete.py: stub the heavy
|
||||||
|
ORM modules so the real route module imports under conftest's MagicMock
|
||||||
|
sqlalchemy stub, then restore sys.modules so the stubs don't leak into sibling
|
||||||
|
test modules.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import importlib
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
_REPO = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
# Mirror tests/test_session_ghost_delete.py exactly: stub only the ORM *class*
|
||||||
|
# modules and import the REAL core.session_manager + src.auth_helpers. pytest
|
||||||
|
# caches routes.session_routes after the first import, so stubbing auth_helpers /
|
||||||
|
# session_manager here would poison the shared module for the sibling session
|
||||||
|
# tests (whichever file is collected first wins). Matching their stub set keeps
|
||||||
|
# the cached module identical regardless of collection order.
|
||||||
|
_ABSENT = object()
|
||||||
|
_TEMP_STUBS = ("core.database", "core.models", "src.request_models")
|
||||||
|
_saved = {name: sys.modules.get(name, _ABSENT) for name in _TEMP_STUBS}
|
||||||
|
_saved["core.session_manager"] = sys.modules.get("core.session_manager", _ABSENT)
|
||||||
|
try:
|
||||||
|
for _name in _TEMP_STUBS:
|
||||||
|
sys.modules[_name] = MagicMock(name=_name)
|
||||||
|
if isinstance(sys.modules.get("core.session_manager"), MagicMock):
|
||||||
|
del sys.modules["core.session_manager"]
|
||||||
|
importlib.import_module("core.session_manager")
|
||||||
|
import routes.session_routes as SR # noqa: E402
|
||||||
|
finally:
|
||||||
|
for _name, _val in _saved.items():
|
||||||
|
if _val is _ABSENT:
|
||||||
|
sys.modules.pop(_name, None)
|
||||||
|
else:
|
||||||
|
sys.modules[_name] = _val
|
||||||
|
|
||||||
|
|
||||||
|
# ── backend: GET /api/sessions model redaction ─────────────────────────────
|
||||||
|
|
||||||
|
def test_public_model_blanks_blind_compare_sessions():
|
||||||
|
"""A blind-compare helper session ("[CMP] Model A") must not expose its
|
||||||
|
real model in the session list — that is the de-anonymization vector."""
|
||||||
|
assert SR._public_model("[CMP] Model A", "gpt-4o") == ""
|
||||||
|
assert SR._public_model("[CMP] Model B", "llama-3.1-70b") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_model_blanks_any_cmp_prefixed_session():
|
||||||
|
"""Defense in depth: even a non-blind [CMP] session (named after the real
|
||||||
|
model) gets its model field blanked. The name already carries whatever the
|
||||||
|
user chose to reveal, and the session list never needs the raw model."""
|
||||||
|
assert SR._public_model("[CMP] gpt-4o", "gpt-4o") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_model_preserves_normal_sessions():
|
||||||
|
"""Ordinary chats are untouched — only the [CMP] prefix triggers redaction.
|
||||||
|
The post-vote "Compare: a vs b" folder is a normal session, not a helper."""
|
||||||
|
assert SR._public_model("My research chat", "gpt-4o") == "gpt-4o"
|
||||||
|
assert SR._public_model("", "claude-sonnet") == "claude-sonnet"
|
||||||
|
assert SR._public_model("Compare: gpt-4o vs llama", "gpt-4o") == "gpt-4o"
|
||||||
|
|
||||||
|
|
||||||
|
def test_compare_prefix_constant_matches_frontend():
|
||||||
|
"""The redaction prefix must match what the frontend prepends, or the
|
||||||
|
guard silently stops matching new sessions."""
|
||||||
|
assert SR.COMPARE_SESSION_PREFIX == "[CMP] "
|
||||||
|
|
||||||
|
|
||||||
|
# ── frontend: every [CMP] session name is blind-guarded ────────────────────
|
||||||
|
|
||||||
|
def test_compare_session_names_are_blind_guarded():
|
||||||
|
"""Every line in static/js/compare/ that builds a '[CMP]' session name
|
||||||
|
must branch on state._blindMode, so a blind comparison is never named
|
||||||
|
after its real model. Pins the #1285 fix against regressions."""
|
||||||
|
compare_dir = _REPO / "static" / "js" / "compare"
|
||||||
|
assert compare_dir.is_dir(), f"missing {compare_dir}"
|
||||||
|
offenders = []
|
||||||
|
for path in sorted(compare_dir.glob("*.js")):
|
||||||
|
for lineno, line in enumerate(
|
||||||
|
path.read_text(encoding="utf-8").splitlines(), 1
|
||||||
|
):
|
||||||
|
if "'[CMP] '" in line and "_blindMode" not in line:
|
||||||
|
offenders.append(f"{path.name}:{lineno}: {line.strip()}")
|
||||||
|
assert not offenders, (
|
||||||
|
"Compare session names must be blind-guarded (issue #1285):\n"
|
||||||
|
+ "\n".join(offenders)
|
||||||
|
)
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""CalDAV sync must not hijack another user's event via a shared VEVENT uid.
|
||||||
|
|
||||||
|
CalendarEvent.uid is the global primary key. _sync_blocking looked up the
|
||||||
|
existing event by uid with NO calendar scope, so when user B synced a uid
|
||||||
|
that user A's calendar already held, the query returned A's row and the sync
|
||||||
|
reassigned its calendar_id to B's calendar — stealing A's event. The lookup
|
||||||
|
must be scoped to the calendar being synced.
|
||||||
|
"""
|
||||||
|
import tempfile
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.pool import NullPool
|
||||||
|
|
||||||
|
import core.database as cdb
|
||||||
|
from core.database import CalendarEvent, CalendarCal
|
||||||
|
from src.caldav_sync import _find_existing_event
|
||||||
|
|
||||||
|
_TMPDB = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||||
|
_ENGINE = create_engine(f"sqlite:///{_TMPDB.name}", connect_args={"check_same_thread": False}, poolclass=NullPool)
|
||||||
|
cdb.Base.metadata.create_all(_ENGINE)
|
||||||
|
_TS = sessionmaker(bind=_ENGINE, autoflush=False, autocommit=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _setup():
|
||||||
|
db = _TS()
|
||||||
|
try:
|
||||||
|
db.query(CalendarEvent).delete(); db.query(CalendarCal).delete()
|
||||||
|
db.add(CalendarCal(id="calA", owner="alice", name="A"))
|
||||||
|
db.add(CalendarCal(id="calB", owner="bob", name="B"))
|
||||||
|
# dtstart/dtend are NOT NULL in the schema, so seed valid values.
|
||||||
|
db.add(CalendarEvent(
|
||||||
|
uid="shared@svc", calendar_id="calA", summary="Alice event",
|
||||||
|
dtstart=datetime(2026, 6, 4, 9, 0), dtend=datetime(2026, 6, 4, 10, 0),
|
||||||
|
))
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_lookup_for_other_calendar_does_not_find_a_users_event():
|
||||||
|
_setup()
|
||||||
|
db = _TS()
|
||||||
|
try:
|
||||||
|
# Bob's calendar syncing the same uid must NOT resolve Alice's row.
|
||||||
|
assert _find_existing_event(db, {}, "shared@svc", "calB") is None
|
||||||
|
# Same calendar still resolves its own event (normal update path).
|
||||||
|
own = _find_existing_event(db, {}, "shared@svc", "calA")
|
||||||
|
assert own is not None and own.calendar_id == "calA"
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_alice_event_is_not_moved():
|
||||||
|
_setup()
|
||||||
|
db = _TS()
|
||||||
|
try:
|
||||||
|
# Simulate the (fixed) sync deciding there is no existing row for calB.
|
||||||
|
assert _find_existing_event(db, {}, "shared@svc", "calB") is None
|
||||||
|
ev = db.query(CalendarEvent).filter(CalendarEvent.uid == "shared@svc").first()
|
||||||
|
assert ev.calendar_id == "calA" # unchanged — not hijacked
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_pending_takes_precedence():
|
||||||
|
_setup()
|
||||||
|
db = _TS()
|
||||||
|
try:
|
||||||
|
sentinel = object()
|
||||||
|
assert _find_existing_event(db, {"shared@svc": sentinel}, "shared@svc", "calB") is sentinel
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
@@ -28,6 +28,15 @@ def test_background_status_poll_reconciles_into_local_tasks():
|
|||||||
assert "completedDeps.forEach(t => _refreshDepsAfterInstall(t));" in source
|
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():
|
def test_dependency_install_payload_keeps_env_path_for_refresh():
|
||||||
source = _read("static/js/cookbook.js")
|
source = _read("static/js/cookbook.js")
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from routes.cookbook_helpers import (
|
|||||||
_pip_install_fallback_chain,
|
_pip_install_fallback_chain,
|
||||||
_ollama_bind_from_cmd,
|
_ollama_bind_from_cmd,
|
||||||
_safe_env_prefix,
|
_safe_env_prefix,
|
||||||
|
_user_shell_path_bootstrap,
|
||||||
_venv_safe_local_pip_install_cmd,
|
_venv_safe_local_pip_install_cmd,
|
||||||
_validate_gpus,
|
_validate_gpus,
|
||||||
_validate_repo_id,
|
_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
|
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():
|
def test_serve_preflight_failure_keeps_tmux_pane_visible():
|
||||||
"""Dependency preflight failures should remain visible in tmux output.
|
"""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
|
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():
|
def test_validate_serve_cmd_accepts_vllm_kv_cache_dtype():
|
||||||
cmd = (
|
cmd = (
|
||||||
"CUDA_VISIBLE_DEVICES=0,1 vllm serve nvidia/Qwen3.6-35B-A3B-NVFP4 "
|
"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)
|
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():
|
def test_llama_cpp_linux_bootstrap_keeps_cpu_fallback_when_no_gpu_toolchain():
|
||||||
runner_lines = []
|
runner_lines = []
|
||||||
_append_llama_cpp_linux_accel_build_lines(runner_lines)
|
_append_llama_cpp_linux_accel_build_lines(runner_lines)
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import types
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sqlalchemy = pytest.importorskip("sqlalchemy")
|
||||||
|
if not isinstance(sqlalchemy, types.ModuleType):
|
||||||
|
pytest.skip("sqlalchemy is stubbed in this environment", allow_module_level=True)
|
||||||
|
|
||||||
|
from core.database import ChatMessage, DocumentVersion, Session, TaskRun, UserToolData, utcnow_naive
|
||||||
|
|
||||||
|
|
||||||
|
def test_utcnow_naive_returns_naive_utc_datetime():
|
||||||
|
now = utcnow_naive()
|
||||||
|
|
||||||
|
assert now.tzinfo is None
|
||||||
|
assert abs((now - utcnow_naive()).total_seconds()) < 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_database_timestamp_defaults_use_utcnow_naive():
|
||||||
|
defaults = (
|
||||||
|
Session.created_at.default.arg,
|
||||||
|
Session.updated_at.default.arg,
|
||||||
|
Session.updated_at.onupdate.arg,
|
||||||
|
ChatMessage.timestamp.default.arg,
|
||||||
|
DocumentVersion.created_at.default.arg,
|
||||||
|
UserToolData.created_at.default.arg,
|
||||||
|
UserToolData.updated_at.default.arg,
|
||||||
|
UserToolData.updated_at.onupdate.arg,
|
||||||
|
TaskRun.started_at.default.arg,
|
||||||
|
)
|
||||||
|
|
||||||
|
for fn in defaults:
|
||||||
|
assert fn.__name__ == "utcnow_naive"
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Deleting a user must invalidate the bearer-token cache.
|
||||||
|
|
||||||
|
delete_user removes the user's ApiToken rows from the DB, but the bearer-auth
|
||||||
|
middleware in app.py serves from an in-memory prefix->token cache that only
|
||||||
|
rebuilds when flagged dirty (app.state.invalidate_token_cache). If the admin
|
||||||
|
delete route does not flag it, a deleted user's already-cached token keeps
|
||||||
|
authenticating until some unrelated token op or a process restart clears the
|
||||||
|
cache. The DELETE /api/auth/users handler now calls the invalidator on a
|
||||||
|
successful delete (and only then), so the next bearer request rebuilds the
|
||||||
|
cache from the DB, where the rows are already gone, and the token is rejected.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import types
|
||||||
|
|
||||||
|
from routes.auth_routes import setup_auth_routes, DeleteUserRequest
|
||||||
|
|
||||||
|
|
||||||
|
def _handler(router):
|
||||||
|
for route in router.routes:
|
||||||
|
if getattr(route, "path", "") == "/api/auth/users" and "DELETE" in getattr(route, "methods", set()):
|
||||||
|
return route.endpoint
|
||||||
|
raise AssertionError("DELETE /api/auth/users handler not found")
|
||||||
|
|
||||||
|
|
||||||
|
def _fake_request(invalidations):
|
||||||
|
state = types.SimpleNamespace(invalidate_token_cache=lambda: invalidations.append(True))
|
||||||
|
app = types.SimpleNamespace(state=state)
|
||||||
|
return types.SimpleNamespace(cookies={"_dummy": "x"}, app=app)
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_manager(delete_result):
|
||||||
|
return types.SimpleNamespace(
|
||||||
|
get_username_for_token=lambda token: "admin",
|
||||||
|
is_admin=lambda user: True,
|
||||||
|
delete_user=lambda username, requesting_user: delete_result,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_successful_delete_invalidates_cache():
|
||||||
|
invalidations = []
|
||||||
|
router = setup_auth_routes(_auth_manager(delete_result=True))
|
||||||
|
handler = _handler(router)
|
||||||
|
result = asyncio.run(handler(DeleteUserRequest(username="bob"), _fake_request(invalidations)))
|
||||||
|
assert result == {"ok": True}
|
||||||
|
assert invalidations == [True], "successful delete must flag the token cache stale"
|
||||||
|
|
||||||
|
|
||||||
|
def test_refused_delete_does_not_invalidate_cache():
|
||||||
|
invalidations = []
|
||||||
|
router = setup_auth_routes(_auth_manager(delete_result=False))
|
||||||
|
handler = _handler(router)
|
||||||
|
try:
|
||||||
|
asyncio.run(handler(DeleteUserRequest(username="admin"), _fake_request(invalidations)))
|
||||||
|
raised = False
|
||||||
|
except Exception:
|
||||||
|
raised = True
|
||||||
|
assert raised, "a refused delete should raise (HTTP 400)"
|
||||||
|
assert invalidations == [], "a refused delete must not touch the token cache"
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""Deleting a user must also revoke their API bearer tokens.
|
||||||
|
|
||||||
|
Regression test: delete_user purged cookie sessions but left ApiToken
|
||||||
|
rows behind, so a deleted user could keep authenticating with an
|
||||||
|
"ody_..." bearer token forever.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def _real_core_package():
|
||||||
|
root = Path(__file__).resolve().parent.parent
|
||||||
|
core_path = str(root / "core")
|
||||||
|
core = sys.modules.get("core")
|
||||||
|
if core is None:
|
||||||
|
core = types.ModuleType("core")
|
||||||
|
sys.modules["core"] = core
|
||||||
|
core.__path__ = [core_path]
|
||||||
|
if hasattr(core, "auth"):
|
||||||
|
delattr(core, "auth")
|
||||||
|
sys.modules.pop("core.auth", None)
|
||||||
|
return core
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_module():
|
||||||
|
_real_core_package()
|
||||||
|
return importlib.import_module("core.auth")
|
||||||
|
|
||||||
|
|
||||||
|
class _OwnerColumn:
|
||||||
|
"""Mimics a SQLAlchemy column: ApiToken.owner == x yields a marker."""
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return ("owner ==", other)
|
||||||
|
|
||||||
|
def __hash__(self):
|
||||||
|
return id(self)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeApiToken:
|
||||||
|
owner = _OwnerColumn()
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeQuery:
|
||||||
|
def __init__(self, recorder):
|
||||||
|
self._recorder = recorder
|
||||||
|
self._conds = []
|
||||||
|
|
||||||
|
def filter(self, *conds):
|
||||||
|
self._conds.extend(conds)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def delete(self, *args, **kwargs):
|
||||||
|
self._recorder.append(list(self._conds))
|
||||||
|
return len(self._conds)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSession:
|
||||||
|
def __init__(self, recorder):
|
||||||
|
self._recorder = recorder
|
||||||
|
|
||||||
|
def query(self, model):
|
||||||
|
assert model is _FakeApiToken
|
||||||
|
return _FakeQuery(self._recorder)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def manager(tmp_path, monkeypatch):
|
||||||
|
auth_mod = _auth_module()
|
||||||
|
monkeypatch.setattr(auth_mod, "_hash_password", lambda password: f"hash:{password}")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
auth_mod, "_verify_password", lambda password, hashed: hashed == f"hash:{password}"
|
||||||
|
)
|
||||||
|
mgr = auth_mod.AuthManager(str(tmp_path / "auth.json"))
|
||||||
|
assert mgr.create_user("admin", "secret-admin-pw", is_admin=True)
|
||||||
|
assert mgr.create_user("bob", "secret-bob-pw", is_admin=False)
|
||||||
|
return mgr
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db_calls(monkeypatch):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def _fake_db_session():
|
||||||
|
yield _FakeSession(calls)
|
||||||
|
|
||||||
|
db_stub = types.ModuleType("core.database")
|
||||||
|
db_stub.get_db_session = _fake_db_session
|
||||||
|
db_stub.ApiToken = _FakeApiToken
|
||||||
|
monkeypatch.setitem(sys.modules, "core.database", db_stub)
|
||||||
|
return calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_user_revokes_api_tokens(manager, db_calls):
|
||||||
|
assert manager.delete_user("bob", "admin") is True
|
||||||
|
assert "bob" not in manager.users
|
||||||
|
assert db_calls, "delete_user never purged ApiToken rows for the deleted user"
|
||||||
|
assert [("owner ==", "bob")] in db_calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_refused_delete_leaves_tokens_alone(manager, db_calls):
|
||||||
|
assert manager.delete_user("admin", "bob") is False
|
||||||
|
assert "admin" in manager.users
|
||||||
|
assert db_calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_user_leaves_tokens_alone(manager, db_calls):
|
||||||
|
assert manager.delete_user("ghost", "admin") is False
|
||||||
|
assert db_calls == []
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Regression for #1809: document library counters must update after delete.
|
||||||
|
|
||||||
|
documentLibrary.js is a browser module with several DOM-only imports, so this
|
||||||
|
guards the relevant wiring at the source level. A single-card delete used to
|
||||||
|
remove the card and decrement `_libraryTotal`, but the header/chips render from
|
||||||
|
`_libraryLanguages`, which stayed stale until a full library refetch.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
SRC = Path(__file__).resolve().parent.parent / "static/js/documentLibrary.js"
|
||||||
|
|
||||||
|
|
||||||
|
def _src() -> str:
|
||||||
|
return SRC.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _between(text: str, start: str, end: str) -> str:
|
||||||
|
begin = text.index(start)
|
||||||
|
finish = text.index(end, begin)
|
||||||
|
return text[begin:finish]
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_delete_updates_language_counters_and_chips():
|
||||||
|
text = _src()
|
||||||
|
|
||||||
|
helper = _between(
|
||||||
|
text,
|
||||||
|
"function libraryRemoveDocumentFromState(docId)",
|
||||||
|
"function libraryRenderGrid()",
|
||||||
|
)
|
||||||
|
assert "_libraryLanguages[lang]" in helper
|
||||||
|
assert "delete _libraryLanguages[lang]" in helper
|
||||||
|
assert "libraryRenderStats();" in helper
|
||||||
|
assert "libraryRenderLangChips();" in helper
|
||||||
|
|
||||||
|
delete_body = _between(
|
||||||
|
text,
|
||||||
|
"async function libraryDeleteSingle(docId, card)",
|
||||||
|
"async function libraryBulkDelete()",
|
||||||
|
)
|
||||||
|
assert "libraryRemoveDocumentFromState(docId);" in delete_body
|
||||||
@@ -100,8 +100,8 @@ async def test_auto_summarize_pass_logs_out_imap_on_select_failure(monkeypatch):
|
|||||||
assert captured.get("connect_called") is True, (
|
assert captured.get("connect_called") is True, (
|
||||||
"test setup: _imap_connect must be reached for the leak to apply"
|
"test setup: _imap_connect must be reached for the leak to apply"
|
||||||
)
|
)
|
||||||
assert captured.get("logout_calls", 0) == 1, (
|
assert captured.get("logout_calls", 0) >= 1, (
|
||||||
f"conn.logout() must be called exactly once on the error path "
|
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"(IMAP leak fix). Got logout_calls={captured.get('logout_calls')}, "
|
||||||
f"select_calls={captured.get('select_calls')}. Pre-fix the "
|
f"select_calls={captured.get('select_calls')}. Pre-fix the "
|
||||||
f"outer `except` returned without logging out the IMAP socket."
|
f"outer `except` returned without logging out the IMAP socket."
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Regression for issue #1961 — read_email (and reply_to_email,
|
||||||
|
download_attachment) failed on iCloud IMAP accounts.
|
||||||
|
|
||||||
|
iCloud's IMAP server silently ignores the legacy bare `RFC822` fetch item: a
|
||||||
|
`UID FETCH <uid> (RFC822)` returns status OK but only `(UID <uid>)` with no body
|
||||||
|
tuple, so the parse treats the message as "not found" — even though list_emails
|
||||||
|
works (it uses `RFC822.HEADER`, which iCloud honours). The modern
|
||||||
|
`BODY.PEEK[]` item is honoured by iCloud and Gmail alike and doesn't set \\Seen.
|
||||||
|
|
||||||
|
The fix is an IMAP-protocol-string change exercised only against a live server,
|
||||||
|
so it's guarded at the source here (per CONTRIBUTING's "guard at source" note):
|
||||||
|
the three full-message fetches must use BODY.PEEK[], and no bare (RFC822) full
|
||||||
|
fetch may remain. The header/uid fetches must be left untouched so listing keeps
|
||||||
|
working.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SRC = (Path(__file__).resolve().parent.parent / "mcp_servers/email_server.py").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _full_fetches():
|
||||||
|
# every conn.uid("FETCH", ..., "<item>") call's fetch item
|
||||||
|
return re.findall(r'conn\.uid\(\s*"FETCH"\s*,[^,]+,\s*"([^"]+)"\s*\)', SRC)
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_message_fetches_use_body_peek_not_bare_rfc822():
|
||||||
|
items = _full_fetches()
|
||||||
|
assert items, "no conn.uid FETCH calls found — test anchor stale"
|
||||||
|
# No bare (RFC822) full-message fetch may remain (it breaks iCloud).
|
||||||
|
assert "(RFC822)" not in items, f"a bare (RFC822) full fetch remains: {items}"
|
||||||
|
# The full-message reads now use BODY.PEEK[] — at least the 3 known sites.
|
||||||
|
assert items.count("(BODY.PEEK[])") >= 3, f"expected >=3 BODY.PEEK[] fetches: {items}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_header_and_uid_fetches_preserved():
|
||||||
|
items = _full_fetches()
|
||||||
|
# Listing relies on RFC822.HEADER (iCloud honours it) — must stay.
|
||||||
|
assert "(RFC822.HEADER)" in items, "RFC822.HEADER fetch (used by listing) must be preserved"
|
||||||
|
# UID-only probes must stay as-is.
|
||||||
|
assert "(UID)" in items, "(UID) probe fetch must be preserved"
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from services.hwfit.image_models import rank_image_models, IMAGE_MODEL_REGISTRY
|
||||||
|
|
||||||
|
|
||||||
|
def test_rank_image_models_handles_non_dict_system():
|
||||||
|
# `system` is the detected-hardware dict; if detection failed and returned
|
||||||
|
# None (or a non-dict), system.get(...) raised AttributeError. Treat a
|
||||||
|
# non-dict system as "unknown hardware" (no GPU) rather than crashing.
|
||||||
|
assert len(rank_image_models(None)) == len(IMAGE_MODEL_REGISTRY)
|
||||||
|
assert len(rank_image_models(123)) == len(IMAGE_MODEL_REGISTRY)
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from services.hwfit.image_models import rank_image_models, IMAGE_MODEL_REGISTRY
|
||||||
|
|
||||||
|
SYS = {"gpu_vram_gb": 0, "has_gpu": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_rank_image_models_handles_non_string_search():
|
||||||
|
# search is a CLI/API filter arg; a non-string made search.lower() raise
|
||||||
|
# AttributeError. A non-string search should behave as "no filter".
|
||||||
|
out = rank_image_models(SYS, search=123)
|
||||||
|
assert len(out) == len(IMAGE_MODEL_REGISTRY)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rank_image_models_string_filter_still_applies():
|
||||||
|
out = rank_image_models(SYS, search="zzzznotarealmodelzzz")
|
||||||
|
assert out == []
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Pin matchModelKey (static/js/model/matchKey.js).
|
||||||
|
|
||||||
|
Driven through `node --input-type=module` (same approach as test_compare_js.py);
|
||||||
|
skips when `node` is not installed.
|
||||||
|
|
||||||
|
Regression: model name -> info/pricing lookups returned the FIRST substring
|
||||||
|
match, so "gpt-4o-mini" matched the shorter "gpt-4o" key and was billed at
|
||||||
|
gpt-4o rates (~16x) with the wrong context window.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_REPO = Path(__file__).resolve().parent.parent
|
||||||
|
_HELPER = _REPO / "static" / "js" / "model" / "matchKey.js"
|
||||||
|
_HAS_NODE = shutil.which("node") is not None
|
||||||
|
|
||||||
|
_KEYS = ["gpt-4o", "gpt-4o-mini", "gpt-4", "o1", "o1-mini", "o1-pro", "o3", "o3-mini"]
|
||||||
|
|
||||||
|
|
||||||
|
def _match(name):
|
||||||
|
js = (
|
||||||
|
f"import {{ matchModelKey }} from '{_HELPER.as_posix()}';"
|
||||||
|
f"console.log(JSON.stringify(matchModelKey({json.dumps(name)}, {json.dumps(_KEYS)})));"
|
||||||
|
)
|
||||||
|
proc = subprocess.run(
|
||||||
|
["node", "--input-type=module"],
|
||||||
|
input=js, capture_output=True, text=True, cwd=str(_REPO), timeout=30,
|
||||||
|
)
|
||||||
|
assert proc.returncode == 0, proc.stderr
|
||||||
|
return json.loads(proc.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||||
|
def test_prefers_longest_specific_key():
|
||||||
|
assert _match("gpt-4o-mini") == "gpt-4o-mini"
|
||||||
|
assert _match("o1-mini") == "o1-mini"
|
||||||
|
assert _match("o1-pro") == "o1-pro"
|
||||||
|
assert _match("o3-mini") == "o3-mini"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
|
||||||
|
def test_base_model_and_unknown():
|
||||||
|
assert _match("gpt-4o-2024-08-06") == "gpt-4o"
|
||||||
|
assert _match("some-unknown-model") is None
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Regression tests for memory import-path compatibility."""
|
||||||
|
|
||||||
|
|
||||||
|
def test_services_memory_manager_is_canonical_src_class():
|
||||||
|
from services.memory import MemoryManager as package_manager
|
||||||
|
from services.memory.memory import MemoryManager as module_manager
|
||||||
|
from src.memory import MemoryManager as canonical_manager
|
||||||
|
|
||||||
|
assert module_manager is canonical_manager
|
||||||
|
assert package_manager is canonical_manager
|
||||||
|
assert hasattr(package_manager, "increment_uses")
|
||||||
|
assert hasattr(package_manager, "claim_ownerless")
|
||||||
|
|
||||||
|
|
||||||
|
def test_services_memory_vector_is_canonical_src_class():
|
||||||
|
from services.memory import MemoryVectorStore as package_vector_store
|
||||||
|
from services.memory.memory_vector import MemoryVectorStore as module_vector_store
|
||||||
|
from src.memory_vector import MemoryVectorStore as canonical_vector_store
|
||||||
|
|
||||||
|
assert module_vector_store is canonical_vector_store
|
||||||
|
assert package_vector_store is canonical_vector_store
|
||||||
|
|
||||||
|
|
||||||
|
def test_memory_service_uses_canonical_manager_api(tmp_path):
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from services.memory import MemoryService
|
||||||
|
|
||||||
|
service = MemoryService(str(tmp_path))
|
||||||
|
|
||||||
|
remembered = asyncio.run(service.remember("User prefers dark mode", session_id="sess-1"))
|
||||||
|
assert remembered.text == "User prefers dark mode"
|
||||||
|
assert remembered.session_id == "sess-1"
|
||||||
|
|
||||||
|
all_memories = service.get_all()
|
||||||
|
assert [m.id for m in all_memories] == [remembered.id]
|
||||||
|
|
||||||
|
recalled = asyncio.run(service.recall("dark mode", top_k=5))
|
||||||
|
assert [m.id for m in recalled.memories] == [remembered.id]
|
||||||
|
|
||||||
|
assert service.delete(remembered.id) is True
|
||||||
|
assert service.delete(remembered.id) is False
|
||||||
|
assert service.get_all() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_canonical_manager_keeps_ownerless_claim_helper(tmp_path):
|
||||||
|
from src.memory import MemoryManager
|
||||||
|
|
||||||
|
manager = MemoryManager(str(tmp_path))
|
||||||
|
entry = manager.add_entry("User likes compact code reviews")
|
||||||
|
manager.save([entry])
|
||||||
|
|
||||||
|
manager.claim_ownerless("alice")
|
||||||
|
|
||||||
|
memories = manager.load_all()
|
||||||
|
assert memories[0]["owner"] == "alice"
|
||||||
@@ -1,11 +1,59 @@
|
|||||||
"""Tests for model_context.py — local endpoint detection, token estimation, known model lookup."""
|
"""Tests for model_context.py — local endpoint detection, token estimation, known model lookup."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import src.model_context as model_context
|
import src.model_context as model_context
|
||||||
from src.model_context import _is_local_endpoint, estimate_tokens, _lookup_known
|
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:
|
class TestIsLocalEndpoint:
|
||||||
def test_localhost(self):
|
def test_localhost(self):
|
||||||
assert _is_local_endpoint("http://localhost:5000/v1/chat/completions") is True
|
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.
|
# 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
|
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):
|
def test_openai_is_remote(self):
|
||||||
assert _is_local_endpoint("https://api.openai.com/v1/chat/completions") is False
|
assert _is_local_endpoint("https://api.openai.com/v1/chat/completions") is False
|
||||||
|
|
||||||
@@ -164,3 +224,28 @@ class TestGetContextLength:
|
|||||||
assert first == 200000
|
assert first == 200000
|
||||||
assert second == 200000
|
assert second == 200000
|
||||||
assert len(calls) == 1
|
assert len(calls) == 1
|
||||||
|
|
||||||
|
def test_configured_proxy_uses_default_without_model_listing(self, monkeypatch):
|
||||||
|
_install_endpoint_db(monkeypatch, [
|
||||||
|
types.SimpleNamespace(
|
||||||
|
base_url="http://100.117.136.97:34521/v1",
|
||||||
|
endpoint_kind="proxy",
|
||||||
|
api_key="fake-key",
|
||||||
|
is_enabled=True,
|
||||||
|
)
|
||||||
|
])
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_get(*args, **kwargs):
|
||||||
|
calls.append(args)
|
||||||
|
raise AssertionError("/models should not be queried for configured proxy context")
|
||||||
|
|
||||||
|
monkeypatch.setattr(model_context.httpx, "get", fake_get)
|
||||||
|
|
||||||
|
endpoint = "http://100.117.136.97:34521/v1/chat/completions"
|
||||||
|
first = model_context.get_context_length(endpoint, "unknown-proxy-model")
|
||||||
|
second = model_context.get_context_length(endpoint, "unknown-proxy-model")
|
||||||
|
|
||||||
|
assert first == model_context.DEFAULT_CONTEXT
|
||||||
|
assert second == model_context.DEFAULT_CONTEXT
|
||||||
|
assert calls == []
|
||||||
|
|||||||
+558
-54
@@ -2,9 +2,11 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
import types
|
import types
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
@@ -28,7 +30,9 @@ if "core.database" not in sys.modules:
|
|||||||
sys.modules["core.database"] = _core_db
|
sys.modules["core.database"] = _core_db
|
||||||
|
|
||||||
import routes.model_routes as model_routes
|
import routes.model_routes as model_routes
|
||||||
|
import src.database as src_database
|
||||||
import src.endpoint_resolver as endpoint_resolver
|
import src.endpoint_resolver as endpoint_resolver
|
||||||
|
import src.llm_core as llm_core
|
||||||
from routes.model_routes import (
|
from routes.model_routes import (
|
||||||
_match_provider_curated,
|
_match_provider_curated,
|
||||||
_curate_models,
|
_curate_models,
|
||||||
@@ -36,7 +40,11 @@ from routes.model_routes import (
|
|||||||
_normalize_model_ids,
|
_normalize_model_ids,
|
||||||
_is_chat_model,
|
_is_chat_model,
|
||||||
_classify_endpoint,
|
_classify_endpoint,
|
||||||
|
_effective_endpoint_kind,
|
||||||
_probe_endpoint,
|
_probe_endpoint,
|
||||||
|
_ping_endpoint,
|
||||||
|
_parse_model_list,
|
||||||
|
_normalize_refresh_mode,
|
||||||
_truthy,
|
_truthy,
|
||||||
_speech_settings_using_endpoint,
|
_speech_settings_using_endpoint,
|
||||||
_clear_speech_settings_for_endpoint,
|
_clear_speech_settings_for_endpoint,
|
||||||
@@ -304,6 +312,54 @@ class TestClassifyEndpoint:
|
|||||||
def test_malformed_url(self):
|
def test_malformed_url(self):
|
||||||
assert _classify_endpoint("not-a-url") == "api"
|
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 ──
|
# ── setup probing ──
|
||||||
|
|
||||||
@@ -534,77 +590,51 @@ if "python_multipart" not in sys.modules:
|
|||||||
sys.modules["python_multipart"] = _mp_stub
|
sys.modules["python_multipart"] = _mp_stub
|
||||||
|
|
||||||
|
|
||||||
class _PinnedFakeQuery:
|
class _RouteCondition:
|
||||||
def __init__(self, rows):
|
def __init__(self, op, field, value):
|
||||||
self.rows = list(rows)
|
self.op = op
|
||||||
|
self.field = field
|
||||||
def filter(self, *conditions):
|
self.value = value
|
||||||
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
|
|
||||||
|
|
||||||
def __or__(self, other):
|
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):
|
def desc(self):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
class _RecordingEndpoint:
|
class _RouteModelEndpoint:
|
||||||
"""ModelEndpoint stand-in that stores constructor kwargs as attributes.
|
"""ModelEndpoint stand-in that stores constructor kwargs as attributes.
|
||||||
|
|
||||||
Class-level fake columns let it double as the query class in the dedupe
|
Class-level fake columns let it double as the query class in the dedupe
|
||||||
lookup; instance attributes (set in __init__) shadow them per-row.
|
lookup; instance attributes (set in __init__) shadow them per-row.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
id = _FakeCol()
|
id = _RouteColumn("id")
|
||||||
base_url = _FakeCol()
|
base_url = _RouteColumn("base_url")
|
||||||
owner = _FakeCol()
|
is_enabled = _RouteColumn("is_enabled")
|
||||||
|
owner = _RouteColumn("owner")
|
||||||
|
created_at = _RouteColumn("created_at")
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs):
|
||||||
for key, value in kwargs.items():
|
for key, value in kwargs.items():
|
||||||
setattr(self, key, value)
|
setattr(self, key, value)
|
||||||
|
|
||||||
|
|
||||||
|
_RecordingEndpoint = _RouteModelEndpoint
|
||||||
|
|
||||||
|
|
||||||
class _PinnedFakeRequest:
|
class _PinnedFakeRequest:
|
||||||
def __init__(self, body=None, headers=None):
|
def __init__(self, body=None, headers=None):
|
||||||
self._body = body if body is not None else {}
|
self._body = body if body is not None else {}
|
||||||
@@ -635,6 +665,13 @@ def _make_endpoint(**kwargs):
|
|||||||
pinned_models=None,
|
pinned_models=None,
|
||||||
model_type="llm",
|
model_type="llm",
|
||||||
supports_tools=None,
|
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)
|
base.update(kwargs)
|
||||||
return SimpleNamespace(**base)
|
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: [])
|
monkeypatch.setattr(model_routes, "_probe_endpoint", lambda *a, **k: [])
|
||||||
endpoint = _get_route("/api/model-endpoints/{ep_id}/models", "GET")
|
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]
|
ids = [row["id"] for row in result]
|
||||||
assert ids == ["deploy-1"]
|
assert ids == ["deploy-1"]
|
||||||
@@ -730,6 +767,10 @@ def _create_form_kwargs(**overrides):
|
|||||||
skip_probe="true", # avoid any network probe in unit tests
|
skip_probe="true", # avoid any network probe in unit tests
|
||||||
require_models="false",
|
require_models="false",
|
||||||
model_type="llm",
|
model_type="llm",
|
||||||
|
endpoint_kind="auto",
|
||||||
|
model_refresh_mode="",
|
||||||
|
model_refresh_interval="",
|
||||||
|
model_refresh_timeout="",
|
||||||
supports_tools="",
|
supports_tools="",
|
||||||
pinned_models="",
|
pinned_models="",
|
||||||
container_local="false",
|
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):
|
def test_post_dedupe_existing_merges_and_returns_pinned(monkeypatch):
|
||||||
existing = _make_endpoint(
|
existing = _make_endpoint(
|
||||||
|
base_url="http://host:1234/v1",
|
||||||
cached_models=json.dumps(["m1"]),
|
cached_models=json.dumps(["m1"]),
|
||||||
hidden_models=None,
|
hidden_models=None,
|
||||||
pinned_models=json.dumps(["old-pin"]),
|
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):
|
def test_post_dedupe_existing_does_not_clobber_pinned_when_omitted(monkeypatch):
|
||||||
existing = _make_endpoint(
|
existing = _make_endpoint(
|
||||||
|
base_url="http://host:1234/v1",
|
||||||
cached_models=json.dumps(["m1"]),
|
cached_models=json.dumps(["m1"]),
|
||||||
pinned_models=json.dumps(["keep-me"]),
|
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 json.loads(existing.pinned_models) == ["keep-me"]
|
||||||
assert result["pinned_models"] == ["keep-me"]
|
assert result["pinned_models"] == ["keep-me"]
|
||||||
assert db.committed == 0 # nothing to persist
|
assert db.committed == 0 # nothing to persist
|
||||||
|
class _RouteQuery:
|
||||||
|
def __init__(self, rows):
|
||||||
|
self.rows = list(rows)
|
||||||
|
|
||||||
|
def filter(self, *conditions):
|
||||||
|
for condition in conditions:
|
||||||
|
if isinstance(condition, _RouteCondition) and condition.op == "eq":
|
||||||
|
self.rows = [row for row in self.rows if getattr(row, condition.field, None) == condition.value]
|
||||||
|
elif isinstance(condition, tuple) and condition and condition[0] == "or":
|
||||||
|
keep = []
|
||||||
|
for row in self.rows:
|
||||||
|
matched = False
|
||||||
|
for part in condition[1:]:
|
||||||
|
if isinstance(part, _RouteCondition) and part.op == "eq":
|
||||||
|
matched = matched or (getattr(row, part.field, None) == part.value)
|
||||||
|
if matched:
|
||||||
|
keep.append(row)
|
||||||
|
self.rows = keep
|
||||||
|
return self
|
||||||
|
|
||||||
|
def order_by(self, *args, **kwargs):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def all(self):
|
||||||
|
return list(self.rows)
|
||||||
|
|
||||||
|
def first(self):
|
||||||
|
return self.rows[0] if self.rows else None
|
||||||
|
|
||||||
|
|
||||||
|
class _RouteDb:
|
||||||
|
def __init__(self, rows):
|
||||||
|
self.rows = rows
|
||||||
|
self.added = []
|
||||||
|
self.committed = 0
|
||||||
|
self.commits = 0
|
||||||
|
self.closed = False
|
||||||
|
|
||||||
|
def query(self, model):
|
||||||
|
return _RouteQuery(self.rows)
|
||||||
|
|
||||||
|
def commit(self):
|
||||||
|
self.committed += 1
|
||||||
|
self.commits += 1
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
def add(self, row):
|
||||||
|
self.rows.append(row)
|
||||||
|
self.added.append(row)
|
||||||
|
|
||||||
|
|
||||||
|
_PinnedFakeDb = _RouteDb
|
||||||
|
|
||||||
|
|
||||||
|
class _ImmediateThread:
|
||||||
|
def __init__(self, target, daemon=None):
|
||||||
|
self.target = target
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self.target()
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for(predicate, timeout=2.0):
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
if predicate():
|
||||||
|
return True
|
||||||
|
time.sleep(0.01)
|
||||||
|
return bool(predicate())
|
||||||
|
|
||||||
|
|
||||||
|
def _route_endpoint(router, path, method="GET"):
|
||||||
|
for route in router.routes:
|
||||||
|
if getattr(route, "path", "") == path and method in getattr(route, "methods", set()):
|
||||||
|
return route.endpoint
|
||||||
|
raise AssertionError(f"{method} {path} route not found")
|
||||||
|
|
||||||
|
|
||||||
|
def _route_ep(
|
||||||
|
id,
|
||||||
|
base_url,
|
||||||
|
*,
|
||||||
|
cached_models=None,
|
||||||
|
endpoint_kind="auto",
|
||||||
|
api_key=None,
|
||||||
|
name=None,
|
||||||
|
pinned_models=None,
|
||||||
|
refresh_mode="auto",
|
||||||
|
refresh_timeout=None,
|
||||||
|
):
|
||||||
|
return SimpleNamespace(
|
||||||
|
id=id,
|
||||||
|
name=name or id,
|
||||||
|
base_url=base_url,
|
||||||
|
api_key=api_key,
|
||||||
|
is_enabled=True,
|
||||||
|
cached_models=json.dumps(cached_models) if cached_models is not None else None,
|
||||||
|
hidden_models=None,
|
||||||
|
pinned_models=json.dumps(pinned_models) if pinned_models is not None else None,
|
||||||
|
model_type="llm",
|
||||||
|
endpoint_kind=endpoint_kind,
|
||||||
|
model_refresh_mode=refresh_mode,
|
||||||
|
model_refresh_interval=None,
|
||||||
|
model_refresh_timeout=refresh_timeout,
|
||||||
|
supports_tools=None,
|
||||||
|
owner=None,
|
||||||
|
created_at=None,
|
||||||
|
updated_at=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _route_request():
|
||||||
|
return SimpleNamespace(
|
||||||
|
state=SimpleNamespace(current_user=None),
|
||||||
|
app=SimpleNamespace(state=SimpleNamespace(auth_manager=None)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_models_returns_cached_proxy_models_without_refresh_probe(monkeypatch):
|
||||||
|
row = _route_ep(
|
||||||
|
"proxy",
|
||||||
|
"http://100.117.136.97:34521/v1",
|
||||||
|
cached_models=["cached-model"],
|
||||||
|
endpoint_kind="proxy",
|
||||||
|
api_key="fake-key",
|
||||||
|
refresh_mode="manual",
|
||||||
|
)
|
||||||
|
db = _RouteDb([row])
|
||||||
|
router = model_routes.setup_model_routes(model_discovery=None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(model_routes, "ModelEndpoint", _RouteModelEndpoint)
|
||||||
|
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||||
|
monkeypatch.setattr(model_routes, "_auth_disabled", lambda: True)
|
||||||
|
monkeypatch.setattr(model_routes, "build_chat_url", lambda base: f"{base}/chat/completions")
|
||||||
|
|
||||||
|
def fail_probe(*args, **kwargs):
|
||||||
|
raise AssertionError("/models probe should not run for cached manual proxy")
|
||||||
|
|
||||||
|
monkeypatch.setattr(model_routes, "_probe_endpoint", fail_probe)
|
||||||
|
monkeypatch.setattr(threading, "Thread", _ImmediateThread)
|
||||||
|
|
||||||
|
result = _route_endpoint(router, "/api/models")(_route_request())
|
||||||
|
|
||||||
|
assert result["items"][0]["models"] == ["cached-model"]
|
||||||
|
assert result["items"][0]["category"] == "api"
|
||||||
|
assert result["items"][0]["endpoint_kind"] == "proxy"
|
||||||
|
assert "offline" not in result["items"][0]
|
||||||
|
assert json.loads(row.cached_models) == ["cached-model"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_probe_local_skips_tailscale_proxy_endpoint(monkeypatch):
|
||||||
|
proxy = _route_ep(
|
||||||
|
"proxy",
|
||||||
|
"http://100.117.136.97:34521/v1",
|
||||||
|
cached_models=["cached-model"],
|
||||||
|
endpoint_kind="proxy",
|
||||||
|
api_key="fake-key",
|
||||||
|
)
|
||||||
|
local = _route_ep("local", "http://127.0.0.1:8000/v1", endpoint_kind="local")
|
||||||
|
db = _RouteDb([proxy, local])
|
||||||
|
router = model_routes.setup_model_routes(model_discovery=None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(model_routes, "ModelEndpoint", _RouteModelEndpoint)
|
||||||
|
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||||
|
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
|
||||||
|
monkeypatch.setattr(model_routes, "_probe_endpoint", lambda *a, **k: (_ for _ in ()).throw(AssertionError("full probe should not run")))
|
||||||
|
|
||||||
|
pinged = []
|
||||||
|
|
||||||
|
def fake_ping(base_url, api_key=None, timeout=1.5):
|
||||||
|
pinged.append(base_url)
|
||||||
|
return {"reachable": True, "status_code": 404, "error": "HTTP 404"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(model_routes, "_ping_endpoint", fake_ping)
|
||||||
|
|
||||||
|
result = await _route_endpoint(router, "/api/model-endpoints/probe-local")(_route_request())
|
||||||
|
|
||||||
|
assert set(result) == {"local"}
|
||||||
|
assert pinged == ["http://127.0.0.1:8000/v1"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_background_refresh_deduplicates_same_base_url(monkeypatch):
|
||||||
|
ep1 = _route_ep("a", "http://127.0.0.1:8000/v1", endpoint_kind="local")
|
||||||
|
ep2 = _route_ep("b", "http://127.0.0.1:8000/v1", endpoint_kind="local")
|
||||||
|
db = _RouteDb([ep1, ep2])
|
||||||
|
router = model_routes.setup_model_routes(model_discovery=None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(model_routes, "ModelEndpoint", _RouteModelEndpoint)
|
||||||
|
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||||
|
monkeypatch.setattr(model_routes, "_auth_disabled", lambda: True)
|
||||||
|
monkeypatch.setattr(model_routes, "build_chat_url", lambda base: f"{base}/chat/completions")
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
probe_done = threading.Event()
|
||||||
|
|
||||||
|
def fake_probe(base_url, api_key=None, timeout=2):
|
||||||
|
calls.append(base_url)
|
||||||
|
probe_done.set()
|
||||||
|
return ["live-model"]
|
||||||
|
|
||||||
|
monkeypatch.setattr(model_routes, "_probe_endpoint", fake_probe)
|
||||||
|
|
||||||
|
_route_endpoint(router, "/api/models")(_route_request(), refresh=True)
|
||||||
|
|
||||||
|
assert probe_done.wait(2)
|
||||||
|
assert _wait_for(lambda: ep1.cached_models and ep2.cached_models)
|
||||||
|
assert calls == ["http://127.0.0.1:8000/v1"]
|
||||||
|
assert json.loads(ep1.cached_models) == ["live-model"]
|
||||||
|
assert json.loads(ep2.cached_models) == ["live-model"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_background_refresh_failure_keeps_existing_cached_models(monkeypatch):
|
||||||
|
ep = _route_ep(
|
||||||
|
"local",
|
||||||
|
"http://127.0.0.1:8000/v1",
|
||||||
|
cached_models=["cached-model"],
|
||||||
|
endpoint_kind="local",
|
||||||
|
)
|
||||||
|
db = _RouteDb([ep])
|
||||||
|
router = model_routes.setup_model_routes(model_discovery=None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(model_routes, "ModelEndpoint", _RouteModelEndpoint)
|
||||||
|
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||||
|
monkeypatch.setattr(model_routes, "_auth_disabled", lambda: True)
|
||||||
|
monkeypatch.setattr(model_routes, "build_chat_url", lambda base: f"{base}/chat/completions")
|
||||||
|
probe_done = threading.Event()
|
||||||
|
|
||||||
|
def fake_probe(*args, **kwargs):
|
||||||
|
probe_done.set()
|
||||||
|
return []
|
||||||
|
|
||||||
|
monkeypatch.setattr(model_routes, "_probe_endpoint", fake_probe)
|
||||||
|
|
||||||
|
result = _route_endpoint(router, "/api/models")(_route_request(), refresh=True)
|
||||||
|
|
||||||
|
assert probe_done.wait(2)
|
||||||
|
assert _wait_for(lambda: db.commits > 0)
|
||||||
|
assert result["items"][0]["models"] == ["cached-model"]
|
||||||
|
assert json.loads(ep.cached_models) == ["cached-model"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_core_list_model_ids_uses_cached_configured_proxy(monkeypatch):
|
||||||
|
ep = _route_ep(
|
||||||
|
"proxy",
|
||||||
|
"http://100.117.136.97:34521/v1",
|
||||||
|
cached_models=["cached-model", "hidden-model"],
|
||||||
|
endpoint_kind="proxy",
|
||||||
|
)
|
||||||
|
ep.hidden_models = json.dumps(["hidden-model"])
|
||||||
|
db = _RouteDb([ep])
|
||||||
|
|
||||||
|
monkeypatch.setattr(src_database, "ModelEndpoint", _RouteModelEndpoint)
|
||||||
|
monkeypatch.setattr(src_database, "SessionLocal", lambda: db)
|
||||||
|
monkeypatch.setattr(llm_core.httpx, "get", lambda *a, **k: (_ for _ in ()).throw(AssertionError("/models should not be fetched")))
|
||||||
|
|
||||||
|
assert llm_core.list_model_ids("http://100.117.136.97:34521/v1/chat/completions", timeout=1) == ["cached-model"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_proxy_test_fetches_models_with_long_timeout(monkeypatch):
|
||||||
|
router = model_routes.setup_model_routes(model_discovery=None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
|
||||||
|
monkeypatch.setattr(model_routes, "_ping_endpoint", lambda *a, **k: (_ for _ in ()).throw(AssertionError("ping should not run when model listing succeeds")))
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
returned = ["NVIDIA NIM/openai/gpt-oss-120b", "mistral/mistral-small-2603"]
|
||||||
|
|
||||||
|
def fake_probe(base_url, api_key=None, timeout=2):
|
||||||
|
calls.append({"base_url": base_url, "api_key": api_key, "timeout": timeout})
|
||||||
|
return returned
|
||||||
|
|
||||||
|
monkeypatch.setattr(model_routes, "_probe_endpoint", fake_probe)
|
||||||
|
|
||||||
|
result = _route_endpoint(router, "/api/model-endpoints/test", "POST")(
|
||||||
|
_route_request(),
|
||||||
|
base_url="http://100.117.136.97:34521/v1",
|
||||||
|
api_key="fake-key",
|
||||||
|
endpoint_kind="proxy",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["online"] is True
|
||||||
|
assert result["status"] == "online"
|
||||||
|
assert result["models"] == returned
|
||||||
|
assert calls == [{
|
||||||
|
"base_url": "http://100.117.136.97:34521/v1",
|
||||||
|
"api_key": "fake-key",
|
||||||
|
"timeout": 30.0,
|
||||||
|
}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_explicit_proxy_add_fetches_and_caches_models_with_long_timeout(monkeypatch):
|
||||||
|
db = _RouteDb([])
|
||||||
|
router = model_routes.setup_model_routes(model_discovery=None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(model_routes, "ModelEndpoint", _RouteModelEndpoint)
|
||||||
|
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||||
|
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
|
||||||
|
monkeypatch.setattr(model_routes, "_load_settings", lambda: {})
|
||||||
|
monkeypatch.setattr(model_routes, "_save_settings", lambda settings: None)
|
||||||
|
monkeypatch.setattr("src.auth_helpers.get_current_user", lambda request: None)
|
||||||
|
monkeypatch.setattr(model_routes, "_ping_endpoint", lambda *a, **k: (_ for _ in ()).throw(AssertionError("ping should not run when model listing succeeds")))
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
returned = ["NVIDIA NIM/openai/gpt-oss-120b", "mistral/mistral-small-2603"]
|
||||||
|
|
||||||
|
def fake_probe(base_url, api_key=None, timeout=2):
|
||||||
|
calls.append({"base_url": base_url, "api_key": api_key, "timeout": timeout})
|
||||||
|
return returned
|
||||||
|
|
||||||
|
monkeypatch.setattr(model_routes, "_probe_endpoint", fake_probe)
|
||||||
|
|
||||||
|
result = _route_endpoint(router, "/api/model-endpoints", "POST")(
|
||||||
|
_route_request(),
|
||||||
|
name="Bifrost",
|
||||||
|
base_url="http://100.117.136.97:34521/v1",
|
||||||
|
api_key="fake-key",
|
||||||
|
skip_probe="true",
|
||||||
|
require_models="false",
|
||||||
|
model_type="llm",
|
||||||
|
endpoint_kind="proxy",
|
||||||
|
model_refresh_mode="manual",
|
||||||
|
model_refresh_interval="",
|
||||||
|
model_refresh_timeout="",
|
||||||
|
supports_tools="",
|
||||||
|
container_local="false",
|
||||||
|
shared="true",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["online"] is True
|
||||||
|
assert result["status"] == "online"
|
||||||
|
assert result["models"] == returned
|
||||||
|
assert calls == [{
|
||||||
|
"base_url": "http://100.117.136.97:34521/v1",
|
||||||
|
"api_key": "fake-key",
|
||||||
|
"timeout": 30.0,
|
||||||
|
}]
|
||||||
|
assert len(db.rows) == 1
|
||||||
|
assert json.loads(db.rows[0].cached_models) == returned
|
||||||
|
assert db.rows[0].endpoint_kind == "proxy"
|
||||||
|
assert db.rows[0].model_refresh_mode == "manual"
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_refresh_uses_long_timeout_and_saves_full_model_list(monkeypatch):
|
||||||
|
ep = _route_ep(
|
||||||
|
"proxy",
|
||||||
|
"http://100.117.136.97:34521/v1",
|
||||||
|
cached_models=["cached-model"],
|
||||||
|
endpoint_kind="proxy",
|
||||||
|
api_key="fake-key",
|
||||||
|
refresh_mode="manual",
|
||||||
|
)
|
||||||
|
db = _RouteDb([ep])
|
||||||
|
router = model_routes.setup_model_routes(model_discovery=None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(model_routes, "ModelEndpoint", _RouteModelEndpoint)
|
||||||
|
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||||
|
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
refreshed = ["cached-model", "mistral/mistral-small-2603", "provider/nested/model/id"]
|
||||||
|
|
||||||
|
def fake_probe(base_url, api_key=None, timeout=2):
|
||||||
|
calls.append({"base_url": base_url, "api_key": api_key, "timeout": timeout})
|
||||||
|
return refreshed
|
||||||
|
|
||||||
|
monkeypatch.setattr(model_routes, "_probe_endpoint", fake_probe)
|
||||||
|
|
||||||
|
response = SimpleNamespace(headers={})
|
||||||
|
result = _route_endpoint(router, "/api/model-endpoints/{ep_id}/models")(
|
||||||
|
"proxy",
|
||||||
|
_route_request(),
|
||||||
|
response,
|
||||||
|
refresh=True,
|
||||||
|
refresh_timeout=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [m["id"] for m in result] == refreshed
|
||||||
|
assert calls == [{
|
||||||
|
"base_url": "http://100.117.136.97:34521/v1",
|
||||||
|
"api_key": "fake-key",
|
||||||
|
"timeout": 60.0,
|
||||||
|
}]
|
||||||
|
assert json.loads(ep.cached_models) == refreshed
|
||||||
|
assert db.commits == 1
|
||||||
|
assert response.headers["X-Model-Refresh-Status"] == "refreshed"
|
||||||
|
assert response.headers["X-Model-Refresh-Count"] == "3"
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_refresh_defaults_to_proxy_long_timeout(monkeypatch):
|
||||||
|
ep = _route_ep(
|
||||||
|
"proxy",
|
||||||
|
"https://proxy.example.test/v1",
|
||||||
|
cached_models=["cached-model"],
|
||||||
|
endpoint_kind="proxy",
|
||||||
|
refresh_mode="manual",
|
||||||
|
)
|
||||||
|
db = _RouteDb([ep])
|
||||||
|
router = model_routes.setup_model_routes(model_discovery=None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(model_routes, "ModelEndpoint", _RouteModelEndpoint)
|
||||||
|
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||||
|
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
|
||||||
|
|
||||||
|
timeouts = []
|
||||||
|
|
||||||
|
def fake_probe(base_url, api_key=None, timeout=2):
|
||||||
|
timeouts.append(timeout)
|
||||||
|
return ["cached-model", "new-model"]
|
||||||
|
|
||||||
|
monkeypatch.setattr(model_routes, "_probe_endpoint", fake_probe)
|
||||||
|
|
||||||
|
response = SimpleNamespace(headers={})
|
||||||
|
_route_endpoint(router, "/api/model-endpoints/{ep_id}/models")(
|
||||||
|
"proxy",
|
||||||
|
_route_request(),
|
||||||
|
response,
|
||||||
|
refresh=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert timeouts == [30.0]
|
||||||
|
assert json.loads(ep.cached_models) == ["cached-model", "new-model"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_manual_refresh_timeout_keeps_cached_models_and_warns(monkeypatch):
|
||||||
|
ep = _route_ep(
|
||||||
|
"proxy",
|
||||||
|
"http://100.117.136.97:34521/v1",
|
||||||
|
cached_models=["cached-model"],
|
||||||
|
endpoint_kind="proxy",
|
||||||
|
api_key="fake-key",
|
||||||
|
refresh_mode="manual",
|
||||||
|
)
|
||||||
|
db = _RouteDb([ep])
|
||||||
|
router = model_routes.setup_model_routes(model_discovery=None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(model_routes, "ModelEndpoint", _RouteModelEndpoint)
|
||||||
|
monkeypatch.setattr(model_routes, "SessionLocal", lambda: db)
|
||||||
|
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
|
||||||
|
|
||||||
|
def fake_probe(base_url, api_key=None, timeout=2):
|
||||||
|
raise httpx.TimeoutException("timed out")
|
||||||
|
|
||||||
|
monkeypatch.setattr(model_routes, "_probe_endpoint", fake_probe)
|
||||||
|
|
||||||
|
response = SimpleNamespace(headers={})
|
||||||
|
result = _route_endpoint(router, "/api/model-endpoints/{ep_id}/models")(
|
||||||
|
"proxy",
|
||||||
|
_route_request(),
|
||||||
|
response,
|
||||||
|
refresh=True,
|
||||||
|
refresh_timeout=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [m["id"] for m in result] == ["cached-model"]
|
||||||
|
assert json.loads(ep.cached_models) == ["cached-model"]
|
||||||
|
assert db.commits == 0
|
||||||
|
assert response.headers["X-Model-Refresh-Status"] == "failed"
|
||||||
|
assert "kept cached models" in response.headers["X-Model-Refresh-Warning"]
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
"""Regression tests for cross-platform helper behavior."""
|
"""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):
|
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)
|
monkeypatch.setattr(platform_compat.os.path, "exists", lambda path: path == expected)
|
||||||
|
|
||||||
assert platform_compat.find_bash() == expected
|
assert platform_compat.find_bash() == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_bash_skips_windows_wsl_stub(monkeypatch):
|
||||||
|
_reset_bash_cache(monkeypatch)
|
||||||
|
monkeypatch.setattr(platform_compat, "IS_WINDOWS", True)
|
||||||
|
|
||||||
|
stub = r"C:\WINDOWS\system32\bash.exe"
|
||||||
|
expected = r"C:\Program Files\Git\bin\bash.exe"
|
||||||
|
monkeypatch.setattr(
|
||||||
|
platform_compat.shutil,
|
||||||
|
"which",
|
||||||
|
lambda name: stub if name == "bash" else None,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(platform_compat.os.path, "exists", lambda path: path == expected)
|
||||||
|
|
||||||
|
assert platform_compat.find_bash() == expected
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
import routes.prefs_routes as prefs_routes
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_replaces_prefs_file_atomically(monkeypatch, tmp_path):
|
||||||
|
calls = []
|
||||||
|
real_replace = prefs_routes.os.replace
|
||||||
|
|
||||||
|
def fake_replace(src, dst):
|
||||||
|
calls.append((src, dst))
|
||||||
|
real_replace(src, dst)
|
||||||
|
|
||||||
|
prefs_file = tmp_path / "data" / "user_prefs.json"
|
||||||
|
monkeypatch.setattr(prefs_routes, "PREFS_FILE", str(prefs_file))
|
||||||
|
monkeypatch.setattr(prefs_routes.os, "replace", fake_replace)
|
||||||
|
|
||||||
|
prefs_routes._save({"theme": "dark"})
|
||||||
|
|
||||||
|
assert len(calls) == 1
|
||||||
|
src, dst = calls[0]
|
||||||
|
assert dst == str(prefs_file)
|
||||||
|
assert src.startswith(str(prefs_file) + ".tmp.")
|
||||||
|
assert json.loads(prefs_file.read_text(encoding="utf-8")) == {"theme": "dark"}
|
||||||
|
assert not list(prefs_file.parent.glob("*.tmp.*"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_for_user_preserves_scoped_user_prefs(monkeypatch, tmp_path):
|
||||||
|
prefs_file = tmp_path / "data" / "user_prefs.json"
|
||||||
|
monkeypatch.setattr(prefs_routes, "PREFS_FILE", str(prefs_file))
|
||||||
|
|
||||||
|
prefs_routes._save_for_user("alice", {"theme": "dark"})
|
||||||
|
|
||||||
|
data = json.loads(prefs_file.read_text(encoding="utf-8"))
|
||||||
|
assert data == {"_users": {"alice": {"theme": "dark"}}}
|
||||||
|
assert prefs_routes._load_for_user("alice") == {"theme": "dark"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_for_user_preserves_flat_prefs_when_auth_disabled(monkeypatch, tmp_path):
|
||||||
|
prefs_file = tmp_path / "data" / "user_prefs.json"
|
||||||
|
monkeypatch.setattr(prefs_routes, "PREFS_FILE", str(prefs_file))
|
||||||
|
|
||||||
|
prefs_routes._save_for_user(None, {"theme": "dark"})
|
||||||
|
|
||||||
|
data = json.loads(prefs_file.read_text(encoding="utf-8"))
|
||||||
|
assert data == {"theme": "dark"}
|
||||||
|
assert prefs_routes._load_for_user(None) == {"theme": "dark"}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import pytest
|
import sys
|
||||||
|
|
||||||
def test_rag_id_stability_across_processes():
|
def test_rag_id_stability_across_processes():
|
||||||
# Run helper in subprocesses with different PYTHONHASHSEED values to ensure cross-process stability
|
# 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 = os.environ.copy()
|
||||||
env0["PYTHONHASHSEED"] = "0"
|
env0["PYTHONHASHSEED"] = "0"
|
||||||
@@ -23,6 +23,6 @@ def test_rag_id_stability_across_processes():
|
|||||||
assert id0 == id_rand
|
assert id0 == id_rand
|
||||||
|
|
||||||
# Assert different inputs produce different IDs
|
# 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()
|
id_diff = subprocess.check_output(cmd_diff, env=env0).decode().strip()
|
||||||
assert id0 != id_diff
|
assert id0 != id_diff
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ def _make_session(sid, owner="alice"):
|
|||||||
db = _TS()
|
db = _TS()
|
||||||
try:
|
try:
|
||||||
db.add(DbSession(id=sid, owner=owner, name="chat", model="gpt-4o",
|
db.add(DbSession(id=sid, owner=owner, name="chat", model="gpt-4o",
|
||||||
|
endpoint_url="http://localhost:11434",
|
||||||
archived=False, message_count=1))
|
archived=False, message_count=1))
|
||||||
db.commit()
|
db.commit()
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -36,7 +36,14 @@ def test_rewrite_query_selects_and_updates_latest_assistant_message():
|
|||||||
base = datetime(2026, 6, 3, 12, 0, 0)
|
base = datetime(2026, 6, 3, 12, 0, 0)
|
||||||
db = TS()
|
db = TS()
|
||||||
try:
|
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="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.add(DBChatMessage(id="m2", session_id=sid, role="assistant", content="old latest", timestamp=base + timedelta(minutes=1)))
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
Reference in New Issue
Block a user