mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-04 20:22:21 +01:00
Compare commits
56
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aac59573b8 | ||
|
|
c21980f5a6 | ||
|
|
7fdc97880a | ||
|
|
9974841c49 | ||
|
|
631c05603c | ||
|
|
f814018098 | ||
|
|
d47d85d8a4 | ||
|
|
77964b0025 | ||
|
|
682662d36f | ||
|
|
b7ab0a37c1 | ||
|
|
3efbecd50a | ||
|
|
06c1e71fa0 | ||
|
|
1e34f938cc | ||
|
|
596e1421e4 | ||
|
|
cad14f81b9 | ||
|
|
8532f4606b | ||
|
|
5d4e8d57bb | ||
|
|
ed531cfc61 | ||
|
|
f00ae350cd | ||
|
|
c1f959ae61 | ||
|
|
2790708b19 | ||
|
|
4126bea113 | ||
|
|
f7f6abf82b | ||
|
|
2a413814c5 | ||
|
|
1de3c72140 | ||
|
|
8450793fe6 | ||
|
|
c4fdd7ad5b | ||
|
|
6fced41220 | ||
|
|
6f2b58c896 | ||
|
|
39303f19cd | ||
|
|
5353241743 | ||
|
|
285d05e72e | ||
|
|
dc0fbbf7aa | ||
|
|
12b9c5f2ad | ||
|
|
cb26ccd9a7 | ||
|
|
23a480571a | ||
|
|
6a43cf23ea | ||
|
|
64b457dc0a | ||
|
|
ef3467e8d4 | ||
|
|
72e8520fa3 | ||
|
|
9276db9c11 | ||
|
|
a797cee0b3 | ||
|
|
76061498c8 | ||
|
|
04f8546d9d | ||
|
|
9835c1a7f9 | ||
|
|
239f09cbc4 | ||
|
|
a1a7f8fc9a | ||
|
|
87193d23f5 | ||
|
|
516f046fef | ||
|
|
48c3a7fc36 | ||
|
|
70fc58225c | ||
|
|
9efedc8b07 | ||
|
|
1d251ef867 | ||
|
|
bf5bf57bc1 | ||
|
|
2019a97339 | ||
|
|
9a1fdb192e |
@@ -105,6 +105,17 @@ SEARXNG_INSTANCE=http://localhost:8080
|
||||
# trusted single-user setups that accept injected content reaching side-effect tools.
|
||||
# ODYSSEUS_TOOL_APPROVAL_GATE=1
|
||||
|
||||
# Comma-separated list of exact qualified MCP read and write tool names
|
||||
# on host-local servers. Only explicitly configured stdio MCP servers qualify.
|
||||
# Declarations do not override built-in tool effects. Declared reads remain
|
||||
# untrusted (EXTERNAL_UNTRUSTED) to prevent prompt injections from reaching shell
|
||||
# or network tools, but allow continuation to declared non-destructive write tools
|
||||
# on the exact same stdio server without repeated approvals. Stdio transport does
|
||||
# not guarantee network isolation. Authorization must still originate from the
|
||||
# trusted request. Automatic same-store writes carry residual prompt-injection risk.
|
||||
# ODYSSEUS_LOCAL_MCP_READ_TOOLS=mcp__notes__search_notes,mcp__notes__read_note
|
||||
# ODYSSEUS_LOCAL_MCP_WRITE_TOOLS=mcp__notes__create_note,mcp__notes__update_note
|
||||
|
||||
# Mark session cookies Secure. Left unset, this follows the request scheme:
|
||||
# an HTTPS login gets a Secure cookie, a plain-HTTP one does not. Set true to
|
||||
# force it on, or false to force it off while you still serve plain HTTP.
|
||||
|
||||
@@ -264,3 +264,20 @@ jobs:
|
||||
ODYSSEUS_TEST_PYTHON=python ODYSSEUS_LAYOUT_BROWSER=chromium npx --no-install playwright test \
|
||||
--config tests/e2e/ios-layout.config.cjs \
|
||||
--reporter=line
|
||||
|
||||
benchmark-gfm-security:
|
||||
name: Benchmark GFM rendering security
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
# Test-only renderer, pinned independently of application requirements.
|
||||
- name: Install GitHub Flavored Markdown renderer
|
||||
run: |
|
||||
python -m pip install --disable-pip-version-check --only-binary=:all: "cmarkgfm==2025.10.22"
|
||||
- name: Verify rendered benchmark report security
|
||||
run: python tests/test_audit_c5_benchmark_evidence.py --gfm
|
||||
|
||||
+3
-2
@@ -64,12 +64,13 @@ External content that reaches the LLM is treated as untrusted via `src/prompt_se
|
||||
|
||||
`src/tool_capabilities.py` carries a second layer: once untrusted content has entered a run, `ToolRunSecurityContext.decision_for()` blocks tools that execute code, mutate state, or cause external side effects until the user authorises the action separately.
|
||||
|
||||
It is on unless `ODYSSEUS_TOOL_APPROVAL_GATE` is set to a falsy value (`0`/`false`/`no`/`off`). Request authority narrows which tool families a turn may use, but within an admitted family it does not bind the exact action: a request to read email admits `send_email` and `delete_email`, and agent processes inherit the host network. Until those are covered by their own boundaries, this gate is what stops an injected instruction from reaching them. Turning it off lets a successful injection reach `bash`, `host_shell`, `send_email` and `delete_email` without a separate confirmation.
|
||||
It is on unless `ODYSSEUS_TOOL_APPROVAL_GATE` is set to a falsy value (`0`/`false`/`no`/`off`). The admin-only `tool_approval_mode` setting (Settings → Agent Tools, `off` = "Off (YOLO)") is the same knob exposed in the UI: with `off`, every run sets `approval_gate_bypassed` and this gate stops asking, exactly as if the env var were off. Request authority narrows which tool families a turn may use, but within an admitted family it does not bind the exact action: a request to read email admits `send_email` and `delete_email`, and agent processes inherit the host network. Until those are covered by their own boundaries, this gate is what stops an injected instruction from reaching them. Turning it off — by either knob — lets a successful injection reach `bash`, `host_shell`, `send_email` and `delete_email` without a separate confirmation.
|
||||
|
||||
Two exemptions apply even when the gate is on, both deliberate:
|
||||
Three exemptions apply even when the gate is on, all deliberate:
|
||||
|
||||
- Sources in `_CONTROL_PLANE_CONTEXT_SOURCES` (skills, runtime descriptors, the open editor document, the open email, uploaded files) are treated as control-plane metadata and still permit read-only tools.
|
||||
- A TUI run that advertises a host shell bridge and declares `unattended_mode` exempts the local execution set in `TUI_CLIENT_TOOL_NAMES`. Personal, network and deployment-local tools are never exempted.
|
||||
- Narrow continuation for local MCP tools declared via `ODYSSEUS_LOCAL_MCP_READ_TOOLS` and `ODYSSEUS_LOCAL_MCP_WRITE_TOOLS`: Only explicitly configured stdio MCP servers qualify; remote HTTP and SSE servers fail closed. Declarations never override built-in tool effects, so built-in email, browser, memory, image generation, and other built-in servers cannot receive continuation exceptions. Declared read tools remain untrusted (`EXTERNAL_UNTRUSTED`) to ensure that prompt injections in local data cannot reach shell, network, or unrelated tools without approval. Stdio transport does not guarantee network isolation, as local processes may still reach the host network. However, declared non-destructive write tools on the exact same stdio MCP server are permitted to continue without an approval prompt if the only untrusted context in the run was produced by declared local reads on that same server, without any explicit error or external taint. Genuinely external content (web search, web fetch, emails, external message context) blocks these writes. Authorization must still originate from the trusted request: request authority must permit the tool and retain the sealed backend resource grant. The residual same-store write risk (the agent writing to the same local store after reading a local prompt injection) remains subject to the user's initial request authority.
|
||||
|
||||
## Security Headers
|
||||
|
||||
|
||||
+39
-1
@@ -47,7 +47,7 @@ ADMIN_PRIVILEGES["allowed_models_restricted"] = False
|
||||
# backwards for this sentinel.
|
||||
ADMIN_PRIVILEGES["block_all_models"] = False
|
||||
|
||||
from src.constants import AUTH_FILE, PASSWORD_MIN_LENGTH
|
||||
from src.constants import AUTH_FILE, PASSWORD_MIN_LENGTH, USERNAME_MAX_LENGTH
|
||||
from src.owner_identity import RESERVED_AUTH_USERNAMES
|
||||
DEFAULT_AUTH_PATH = AUTH_FILE
|
||||
TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
|
||||
@@ -62,6 +62,24 @@ TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
|
||||
RESERVED_USERNAMES = frozenset(RESERVED_AUTH_USERNAMES)
|
||||
|
||||
|
||||
def username_error(username: str | None) -> Optional[str]:
|
||||
"""Why a new username is not acceptable, or None when it is.
|
||||
|
||||
Admin routes carry the name as a URL path segment, where "/" never
|
||||
reaches the handler, so such an account could not be managed afterwards.
|
||||
"""
|
||||
name = (username or "").strip()
|
||||
if not name:
|
||||
return "Username is required"
|
||||
if len(name) > USERNAME_MAX_LENGTH:
|
||||
return f"Username must be at most {USERNAME_MAX_LENGTH} characters"
|
||||
if "/" in name or "\\" in name or any(not c.isprintable() for c in name):
|
||||
return "Username must not contain slashes or control characters"
|
||||
if name in (".", ".."):
|
||||
return "Username is not allowed"
|
||||
return None
|
||||
|
||||
|
||||
def normalize_known_username(users: Dict[str, Any], username: str | None) -> Optional[str]:
|
||||
"""Return a normalized username only when it exists in the auth user map."""
|
||||
key = str(username or "").strip().lower()
|
||||
@@ -396,6 +414,15 @@ class AuthManager:
|
||||
current = self.get_privileges(username)
|
||||
for k, v in privileges.items():
|
||||
if k in DEFAULT_PRIVILEGES:
|
||||
default = DEFAULT_PRIVILEGES[k]
|
||||
if isinstance(default, bool):
|
||||
ok = isinstance(v, bool)
|
||||
elif isinstance(default, int):
|
||||
ok = isinstance(v, int) and not isinstance(v, bool)
|
||||
else:
|
||||
ok = isinstance(v, list)
|
||||
if not ok:
|
||||
raise ValueError(f"Invalid value for privilege '{k}'")
|
||||
current[k] = v
|
||||
self._config["users"][username]["privileges"] = current
|
||||
self._save()
|
||||
@@ -436,6 +463,17 @@ class AuthManager:
|
||||
admin_count = sum(1 for d in self.users.values() if d.get("is_admin"))
|
||||
if admin_count <= 1:
|
||||
return SetAdminResult.LAST_ADMIN
|
||||
# Token creation is admin-only and a demoted user can neither
|
||||
# list nor revoke their tokens, so purge them with the role.
|
||||
# Done before the flag flips: if the token store fails the
|
||||
# exception propagates and the user is still an admin.
|
||||
from core.database import get_db_session, ApiToken
|
||||
with get_db_session() as db:
|
||||
removed_tokens = db.query(ApiToken).filter(ApiToken.owner == username).delete()
|
||||
if removed_tokens:
|
||||
logger.info(
|
||||
f"Revoked {removed_tokens} API token(s) owned by demoted admin '{username}'"
|
||||
)
|
||||
# Write order matters for lock-free readers: get_privileges()
|
||||
# reads without _config_lock and trusts is_admin, so the admin
|
||||
# flag must be flipped while the stored map is safe to expose —
|
||||
|
||||
@@ -568,6 +568,7 @@ class ModelEndpoint(TimestampMixin, Base):
|
||||
hidden_models = Column(Text, nullable=True) # JSON list of model IDs that failed probing
|
||||
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)
|
||||
cookbook_serve_session_id = Column(String, nullable=True) # Current Cookbook serve identity
|
||||
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.
|
||||
@@ -1378,6 +1379,26 @@ def _migrate_add_pinned_models_column():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _migrate_add_cookbook_serve_session_id_column():
|
||||
"""Bind Cookbook cleanup to the serve session that owns the endpoint."""
|
||||
from sqlalchemy import inspect
|
||||
|
||||
with engine.begin() as conn:
|
||||
columns = {
|
||||
column["name"]
|
||||
for column in inspect(conn).get_columns("model_endpoints")
|
||||
}
|
||||
|
||||
if "cookbook_serve_session_id" not in columns:
|
||||
conn.execute(
|
||||
text(
|
||||
"ALTER TABLE model_endpoints "
|
||||
"ADD COLUMN cookbook_serve_session_id VARCHAR"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _migrate_add_notes_sort_order():
|
||||
"""Add sort_order, image_url, repeat columns to notes if they don't exist."""
|
||||
import sqlite3
|
||||
@@ -2390,6 +2411,7 @@ def init_db():
|
||||
_migrate_add_hidden_models_column()
|
||||
_migrate_add_cached_models_column()
|
||||
_migrate_add_pinned_models_column()
|
||||
_migrate_add_cookbook_serve_session_id_column()
|
||||
_migrate_add_notes_sort_order()
|
||||
_migrate_add_model_type_column()
|
||||
_migrate_add_model_endpoint_refresh_columns()
|
||||
|
||||
@@ -73,6 +73,8 @@ services:
|
||||
- ODYSSEUS_CAPTURE_MODEL_REQUESTS=${ODYSSEUS_CAPTURE_MODEL_REQUESTS:-0}
|
||||
- ODYSSEUS_MCP_EMAIL_OWNER=${ODYSSEUS_MCP_EMAIL_OWNER:-}
|
||||
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
|
||||
- ODYSSEUS_LOCAL_MCP_READ_TOOLS=${ODYSSEUS_LOCAL_MCP_READ_TOOLS:-}
|
||||
- ODYSSEUS_LOCAL_MCP_WRITE_TOOLS=${ODYSSEUS_LOCAL_MCP_WRITE_TOOLS:-}
|
||||
- ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760}
|
||||
- ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES:-104857600}
|
||||
- ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES:-26214400}
|
||||
@@ -82,13 +84,15 @@ services:
|
||||
- ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
|
||||
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
|
||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES:-524288000}
|
||||
# Host workspace translation is opt-in. Keep the public compose file
|
||||
# user-neutral; configure these in a local .env or use the host-workspace
|
||||
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
||||
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
||||
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
||||
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
|
||||
# Local first-token watchdog in seconds; 0 disables it. Empty keeps the code default (60s).
|
||||
- ODYSSEUS_FIRST_TOKEN_TIMEOUT=${ODYSSEUS_FIRST_TOKEN_TIMEOUT:-}
|
||||
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||
@@ -165,7 +169,7 @@ services:
|
||||
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
||||
exec /usr/local/searxng/entrypoint.sh
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
- "127.0.0.1:${SEARXNG_HOST_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- searxng-data:/etc/searxng
|
||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
||||
|
||||
@@ -72,6 +72,8 @@ services:
|
||||
- ODYSSEUS_CAPTURE_MODEL_REQUESTS=${ODYSSEUS_CAPTURE_MODEL_REQUESTS:-0}
|
||||
- ODYSSEUS_MCP_EMAIL_OWNER=${ODYSSEUS_MCP_EMAIL_OWNER:-}
|
||||
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
|
||||
- ODYSSEUS_LOCAL_MCP_READ_TOOLS=${ODYSSEUS_LOCAL_MCP_READ_TOOLS:-}
|
||||
- ODYSSEUS_LOCAL_MCP_WRITE_TOOLS=${ODYSSEUS_LOCAL_MCP_WRITE_TOOLS:-}
|
||||
- ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760}
|
||||
- ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES:-104857600}
|
||||
- ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES:-26214400}
|
||||
@@ -81,13 +83,15 @@ services:
|
||||
- ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
|
||||
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
|
||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES:-524288000}
|
||||
# Host workspace translation is opt-in. Keep the public compose file
|
||||
# user-neutral; configure these in a local .env or use the host-workspace
|
||||
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
||||
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
||||
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
||||
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
|
||||
# Local first-token watchdog in seconds; 0 disables it. Empty keeps the code default (60s).
|
||||
- ODYSSEUS_FIRST_TOKEN_TIMEOUT=${ODYSSEUS_FIRST_TOKEN_TIMEOUT:-}
|
||||
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||
@@ -168,7 +172,7 @@ services:
|
||||
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
||||
exec /usr/local/searxng/entrypoint.sh
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
- "127.0.0.1:${SEARXNG_HOST_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- searxng-data:/etc/searxng
|
||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
||||
|
||||
+6
-2
@@ -61,6 +61,8 @@ services:
|
||||
- ODYSSEUS_CAPTURE_MODEL_REQUESTS=${ODYSSEUS_CAPTURE_MODEL_REQUESTS:-0}
|
||||
- ODYSSEUS_MCP_EMAIL_OWNER=${ODYSSEUS_MCP_EMAIL_OWNER:-}
|
||||
- ODYSSEUS_SCRIPT_HOST=${ODYSSEUS_SCRIPT_HOST:-localhost}
|
||||
- ODYSSEUS_LOCAL_MCP_READ_TOOLS=${ODYSSEUS_LOCAL_MCP_READ_TOOLS:-}
|
||||
- ODYSSEUS_LOCAL_MCP_WRITE_TOOLS=${ODYSSEUS_LOCAL_MCP_WRITE_TOOLS:-}
|
||||
- ODYSSEUS_CHAT_UPLOAD_MAX_BYTES=${ODYSSEUS_CHAT_UPLOAD_MAX_BYTES:-10485760}
|
||||
- ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_UPLOAD_MAX_BYTES:-104857600}
|
||||
- ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES=${ODYSSEUS_GALLERY_TRANSFORM_UPLOAD_MAX_BYTES:-26214400}
|
||||
@@ -70,13 +72,15 @@ services:
|
||||
- ODYSSEUS_EDITOR_DRAFT_MAX_BYTES=${ODYSSEUS_EDITOR_DRAFT_MAX_BYTES:-268435456}
|
||||
- ODYSSEUS_STT_MAX_AUDIO_BYTES=${ODYSSEUS_STT_MAX_AUDIO_BYTES:-26214400}
|
||||
- ODYSSEUS_ICS_MAX_BYTES=${ODYSSEUS_ICS_MAX_BYTES:-10485760}
|
||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES}
|
||||
- ODYSSEUS_TTS_CACHE_MAX_BYTES=${ODYSSEUS_TTS_CACHE_MAX_BYTES:-524288000}
|
||||
# Host workspace translation is opt-in. Keep the public compose file
|
||||
# user-neutral; configure these in a local .env or use the host-workspace
|
||||
# overlay with ODYSSEUS_HOST_WORKSPACE_DIR.
|
||||
- ODYSSEUS_WORKSPACE_HOST_ROOT=${ODYSSEUS_WORKSPACE_HOST_ROOT:-}
|
||||
- ODYSSEUS_WORKSPACE_CONTAINER_ROOT=${ODYSSEUS_WORKSPACE_CONTAINER_ROOT:-/workspace}
|
||||
- ODYSSEUS_WORKSPACE_DEFAULT=${ODYSSEUS_WORKSPACE_DEFAULT:-}
|
||||
# Local first-token watchdog in seconds; 0 disables it. Empty keeps the code default (60s).
|
||||
- ODYSSEUS_FIRST_TOKEN_TIMEOUT=${ODYSSEUS_FIRST_TOKEN_TIMEOUT:-}
|
||||
- DATA_BRAVE_API_KEY=${DATA_BRAVE_API_KEY:-}
|
||||
- GOOGLE_API_KEY=${GOOGLE_API_KEY:-}
|
||||
- GOOGLE_PSE_CX=${GOOGLE_PSE_CX:-}
|
||||
@@ -146,7 +150,7 @@ services:
|
||||
/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py /etc/searxng/settings.yml || true
|
||||
exec /usr/local/searxng/entrypoint.sh
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
- "127.0.0.1:${SEARXNG_HOST_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- searxng-data:/etc/searxng
|
||||
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
|
||||
|
||||
@@ -31,21 +31,43 @@ for spec in $SPECS; do
|
||||
name="${spec%%==*}"
|
||||
ver="${spec##*==}"
|
||||
# pip download builds metadata (and trips the same bug), so fetch the raw
|
||||
# sdist URL from the PyPI JSON API instead.
|
||||
url="$(python - "$name" "$ver" <<'PY'
|
||||
import json, sys, urllib.request
|
||||
name, ver = sys.argv[1], sys.argv[2]
|
||||
data = json.load(urllib.request.urlopen(f"https://pypi.org/pypi/{name}/{ver}/json"))
|
||||
for f in data["urls"]:
|
||||
if f["packagetype"] == "sdist":
|
||||
print(f["url"]); break
|
||||
# sdist URL from the PyPI JSON API instead. Use curl with retries to
|
||||
# survive transient TLS / network failures that urllib cannot recover from.
|
||||
metadata="${name}-${ver}.json"
|
||||
curl --fail --silent --show-error --location \
|
||||
--retry 8 \
|
||||
--retry-delay 3 \
|
||||
--retry-max-time 120 \
|
||||
--retry-all-errors \
|
||||
"https://pypi.org/pypi/${name}/${ver}/json" \
|
||||
-o "$metadata"
|
||||
|
||||
url="$(python - "$metadata" <<'PY'
|
||||
import json, sys
|
||||
|
||||
with open(sys.argv[1], encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
for item in data["urls"]:
|
||||
if item["packagetype"] == "sdist":
|
||||
print(item["url"])
|
||||
break
|
||||
else:
|
||||
sys.exit(f"no sdist found for {name}=={ver}")
|
||||
raise SystemExit("no sdist found")
|
||||
PY
|
||||
)"
|
||||
)"
|
||||
rm -f "$metadata"
|
||||
|
||||
echo ">> fetching ${name} ${ver}: ${url}"
|
||||
curl -fsSL "$url" -o "${name}.tar.gz"
|
||||
curl --fail --silent --show-error --location \
|
||||
--retry 8 \
|
||||
--retry-delay 3 \
|
||||
--retry-max-time 120 \
|
||||
--retry-all-errors \
|
||||
"$url" \
|
||||
-o "${name}.tar.gz"
|
||||
tar xzf "${name}.tar.gz"
|
||||
rm -f "${name}.tar.gz"
|
||||
done
|
||||
|
||||
echo ">> patching get_version()"
|
||||
|
||||
+12
-15
@@ -33,6 +33,7 @@ from mcp.types import Tool, TextContent
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from src.email_mime import decode_text_part as _decode_text_part
|
||||
server = Server("email")
|
||||
EMAIL_SOCKET_TIMEOUT = float(os.environ.get("EMAIL_SOCKET_TIMEOUT", "20"))
|
||||
from src.constants import DATA_DIR as _DATA_DIR, APP_DB, EMAIL_CACHE_DB, SETTINGS_FILE as _SETTINGS_FILE, MAIL_ATTACHMENTS_DIR
|
||||
@@ -923,29 +924,25 @@ def _extract_text(msg):
|
||||
"""Extract plain text body from email message."""
|
||||
if msg.is_multipart():
|
||||
text_parts = []
|
||||
html_parts = []
|
||||
for part in msg.walk():
|
||||
ct = part.get_content_type()
|
||||
cd = str(part.get("Content-Disposition", ""))
|
||||
if ct == "text/plain" and "attachment" not in cd:
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload:
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
text_parts.append(payload.decode(charset, errors="replace"))
|
||||
elif ct == "text/html" and not text_parts and "attachment" not in cd:
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload:
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
raw_html = payload.decode(charset, errors="replace")
|
||||
text = _decode_text_part(part)
|
||||
if text:
|
||||
text_parts.append(text)
|
||||
elif ct == "text/html" and not html_parts and "attachment" not in cd:
|
||||
raw_html = _decode_text_part(part)
|
||||
if raw_html:
|
||||
text = re.sub(r"<br\s*/?>", "\n", raw_html, flags=re.I)
|
||||
text = re.sub(r"<[^>]+>", "", text)
|
||||
text = html.unescape(text)
|
||||
text_parts.append(text.strip())
|
||||
return "\n".join(text_parts)
|
||||
html_parts.append(text.strip())
|
||||
return "\n".join(text_parts or html_parts)
|
||||
else:
|
||||
payload = msg.get_payload(decode=True)
|
||||
if payload:
|
||||
charset = msg.get_content_charset() or "utf-8"
|
||||
text = payload.decode(charset, errors="replace")
|
||||
text = _decode_text_part(msg)
|
||||
if text:
|
||||
if msg.get_content_type() == "text/html":
|
||||
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.I)
|
||||
text = re.sub(r"</(?:p|div|li|tr|h[1-6])\s*>", "\n", text, flags=re.I)
|
||||
|
||||
+53
-10
@@ -13,11 +13,12 @@ import re
|
||||
from pathlib import Path
|
||||
|
||||
from core.atomic_io import atomic_write_json, atomic_write_text
|
||||
from core.auth import AuthManager, RESERVED_USERNAMES, SetAdminResult, TOKEN_TTL
|
||||
from core.auth import AuthManager, RESERVED_USERNAMES, SetAdminResult, TOKEN_TTL, username_error
|
||||
from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, PASSWORD_MIN_LENGTH, SKILLS_DIR
|
||||
from src.auth_helpers import _auth_disabled
|
||||
from src.rate_limiter import RateLimiter
|
||||
from src.settings_scrub import scrub_settings
|
||||
from src import tool_capabilities
|
||||
from src.settings import (
|
||||
load_settings as _load_settings,
|
||||
save_settings as _save_settings,
|
||||
@@ -154,8 +155,9 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
raise HTTPException(400, "Already configured")
|
||||
if len(body.password) < PASSWORD_MIN_LENGTH:
|
||||
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
|
||||
if len(body.username.strip()) < 1:
|
||||
raise HTTPException(400, "Username is required")
|
||||
name_error = username_error(body.username)
|
||||
if name_error:
|
||||
raise HTTPException(400, name_error)
|
||||
if body.username.lower() in RESERVED_USERNAMES:
|
||||
raise HTTPException(403, "Username is reserved")
|
||||
ok = await asyncio.to_thread(auth_manager.setup, body.username, body.password)
|
||||
@@ -174,8 +176,9 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
raise HTTPException(403, "Registration is disabled. Ask an admin for an account.")
|
||||
if len(body.password) < PASSWORD_MIN_LENGTH:
|
||||
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
|
||||
if len(body.username.strip()) < 1:
|
||||
raise HTTPException(400, "Username is required")
|
||||
name_error = username_error(body.username)
|
||||
if name_error:
|
||||
raise HTTPException(400, name_error)
|
||||
if body.username.lower() in RESERVED_USERNAMES:
|
||||
raise HTTPException(403, "Username is reserved")
|
||||
ok = await asyncio.to_thread(auth_manager.create_user, body.username, body.password, is_admin=False)
|
||||
@@ -333,8 +336,9 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
raise HTTPException(403, "Admin only")
|
||||
if len(body.password) < PASSWORD_MIN_LENGTH:
|
||||
raise HTTPException(400, f"Password must be at least {PASSWORD_MIN_LENGTH} characters")
|
||||
if len(body.username.strip()) < 1:
|
||||
raise HTTPException(400, "Username is required")
|
||||
name_error = username_error(body.username)
|
||||
if name_error:
|
||||
raise HTTPException(400, name_error)
|
||||
if body.username.lower() in RESERVED_USERNAMES:
|
||||
raise HTTPException(403, "Username is reserved")
|
||||
ok = auth_manager.create_user(body.username, body.password, body.is_admin)
|
||||
@@ -361,8 +365,16 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
user = _get_current_user(request)
|
||||
if not user or not auth_manager.is_admin(user):
|
||||
raise HTTPException(403, "Admin only")
|
||||
body = await request.json()
|
||||
ok = auth_manager.set_privileges(username, body)
|
||||
try:
|
||||
body = await request.json()
|
||||
except ValueError:
|
||||
raise HTTPException(400, "Request body must be a JSON object")
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(400, "Request body must be a JSON object")
|
||||
try:
|
||||
ok = auth_manager.set_privileges(username, body)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
if not ok:
|
||||
raise HTTPException(404, "User not found or is admin")
|
||||
return {"ok": True, "privileges": auth_manager.get_privileges(username)}
|
||||
@@ -376,6 +388,9 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
new_username = (body.username or "").strip().lower()
|
||||
if not new_username:
|
||||
raise HTTPException(400, "Username required")
|
||||
name_error = username_error(new_username)
|
||||
if name_error:
|
||||
raise HTTPException(400, name_error)
|
||||
if old_username == new_username:
|
||||
return {"ok": True, "username": new_username, "renamed_self": old_username == user}
|
||||
if old_username not in auth_manager.users:
|
||||
@@ -653,7 +668,18 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
user = _get_current_user(request)
|
||||
if not user or not auth_manager.is_admin(user):
|
||||
raise HTTPException(403, "Admin only")
|
||||
result = auth_manager.set_admin(username, body.is_admin, user)
|
||||
try:
|
||||
result = auth_manager.set_admin(username, body.is_admin, user)
|
||||
finally:
|
||||
# A demotion purges the user's ApiToken rows; dirty the bearer
|
||||
# cache so already-cached tokens stop authenticating.
|
||||
if not body.is_admin:
|
||||
invalidator = getattr(
|
||||
getattr(getattr(request, "app", None), "state", None),
|
||||
"invalidate_token_cache", None,
|
||||
)
|
||||
if callable(invalidator):
|
||||
invalidator()
|
||||
if result is SetAdminResult.USER_NOT_FOUND:
|
||||
raise HTTPException(404, "User not found")
|
||||
if result is SetAdminResult.NOT_AUTHORIZED:
|
||||
@@ -753,6 +779,11 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
user = _get_current_user(request)
|
||||
settings = without_retired_settings(_load_settings())
|
||||
if _auth_disabled() or (user and auth_manager.is_admin(user)):
|
||||
# Deployment-level truth for the Tool approval row: a falsy
|
||||
# ODYSSEUS_TOOL_APPROVAL_GATE disables the approval gate for every
|
||||
# run regardless of the stored tool_approval_mode, so surface it to
|
||||
# admins instead of letting the row mislead them the other way.
|
||||
settings["tool_approval_gate_env_disabled"] = not tool_capabilities.TOOL_APPROVAL_GATE_ENABLED
|
||||
return settings
|
||||
return scrub_settings(settings)
|
||||
|
||||
@@ -769,6 +800,9 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
"agent_max_tool_calls": (0, 1000), # 0 = unlimited
|
||||
"auto_compact_threshold_percent": (50, 95),
|
||||
}
|
||||
_VALID_VALUES = {
|
||||
"tool_approval_mode": {"ask", "off"},
|
||||
}
|
||||
for key in DEFAULT_SETTINGS:
|
||||
if key in RETIRED_SETTING_KEYS:
|
||||
continue
|
||||
@@ -782,6 +816,15 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, f"{key} must be an integer")
|
||||
val = max(lo, min(val, hi))
|
||||
|
||||
if key in _VALID_VALUES:
|
||||
allowed = _VALID_VALUES[key]
|
||||
if not isinstance(val, str) or val not in allowed:
|
||||
raise HTTPException(
|
||||
400,
|
||||
f"{key} must be one of {sorted(allowed)}",
|
||||
)
|
||||
|
||||
current[key] = val
|
||||
_save_settings(current)
|
||||
return without_retired_settings(current)
|
||||
|
||||
@@ -1276,6 +1276,7 @@ async def build_chat_context(
|
||||
message=_ctx_msg,
|
||||
session=sess,
|
||||
use_web=use_web_val and not skip_web,
|
||||
use_rag=use_rag_val,
|
||||
use_memory=mem_enabled,
|
||||
time_filter=time_filter,
|
||||
preset_system_prompt=preset.system_prompt,
|
||||
@@ -1286,8 +1287,6 @@ async def build_chat_context(
|
||||
incognito=incognito,
|
||||
use_skills=skills_enabled,
|
||||
)
|
||||
if use_rag is not None or is_research_spinoff or casual_low_signal:
|
||||
_preface_kwargs["use_rag"] = use_rag_val
|
||||
preface, rag_sources, web_sources = chat_processor.build_context_preface(**_preface_kwargs)
|
||||
|
||||
# Capture used memories immediately
|
||||
|
||||
+17
-1
@@ -1901,7 +1901,7 @@ def _ts_or_zero(value) -> float:
|
||||
|
||||
|
||||
def _recover_empty_session_model(sess, session_id: str, owner: str | None = None) -> bool:
|
||||
"""Re-populate sess.model from the matching endpoint's cached models.
|
||||
"""Recover sess.model from the endpoint's visible cached and pinned models.
|
||||
|
||||
Covers the window between endpoint setup and the first chat send: the
|
||||
picker showed a model in the dropdown but the session record never got
|
||||
@@ -1965,6 +1965,9 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None
|
||||
visible = _visible_models(cached, getattr(ep, "hidden_models", None))
|
||||
except Exception:
|
||||
visible = cached
|
||||
if not is_chatgpt_subscription:
|
||||
from src.endpoint_resolver import _endpoint_enabled_models
|
||||
visible = _endpoint_enabled_models(ep)
|
||||
if current_model and current_model in {str(item).strip() for item in visible}:
|
||||
return False
|
||||
if is_chatgpt_subscription:
|
||||
@@ -3923,6 +3926,19 @@ def setup_chat_routes(
|
||||
if ctx.preprocessed.attachment_meta:
|
||||
yield f"data: {json.dumps({'type': 'attachments', 'data': ctx.preprocessed.attachment_meta})}\n\n"
|
||||
|
||||
# Give the page the saved user message's id. Only the reply's id
|
||||
# was sent back, so a message sent in the open page had none and
|
||||
# a later regenerate/edit of it could not cut the chat by id.
|
||||
try:
|
||||
_last_user_msg = next((m for m in reversed(getattr(sess, "history", None) or [])
|
||||
if getattr(m, "role", None) == "user"), None)
|
||||
_user_db_id = ((getattr(_last_user_msg, "metadata", None) or {}).get("_db_id")
|
||||
if _last_user_msg is not None else None)
|
||||
if _user_db_id:
|
||||
yield f"data: {json.dumps({'type': 'user_message_saved', 'id': _user_db_id})}\n\n"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Announce any docs auto-created during preprocess (e.g. fillable
|
||||
# PDF → editable markdown) so the editor pane switches to them
|
||||
# before the model starts streaming.
|
||||
|
||||
@@ -728,8 +728,13 @@ def _check_serve_binary(seg: str) -> None:
|
||||
if not tokens:
|
||||
return
|
||||
env_re = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
||||
first = next((t for t in tokens if not env_re.match(t)), "")
|
||||
idx = next((i for i, t in enumerate(tokens) if not env_re.match(t)), len(tokens))
|
||||
first = tokens[idx] if idx < len(tokens) else ""
|
||||
base = os.path.basename(first)
|
||||
# The vLLM/SGLang Dependencies recipes install with `uv pip install …
|
||||
# --torch-backend auto`. Allow uv for that subcommand only, not `uv run`.
|
||||
if base == "uv" and tokens[idx + 1:idx + 3] == ["pip", "install"]:
|
||||
return
|
||||
if base not in _SERVE_CMD_ALLOWLIST:
|
||||
raise HTTPException(
|
||||
400,
|
||||
@@ -1491,6 +1496,20 @@ def _diagnose_serve_output(text: str) -> dict | None:
|
||||
"Port is already in use.",
|
||||
[{"label": "retry on port 8001", "op": "replace", "flag": "--port", "value": "8001"}],
|
||||
),
|
||||
(
|
||||
# PyPI torch/vLLM wheels now target CUDA 13, which needs driver 580+.
|
||||
# torch: "The NVIDIA driver on your system is too old (found version 12080)";
|
||||
# vLLM kernels: "CUDA driver version is insufficient for CUDA runtime version".
|
||||
r"NVIDIA driver on your system is too old|CUDA driver version is insufficient",
|
||||
"The installed PyTorch/vLLM build needs a newer NVIDIA driver than this server has.",
|
||||
[
|
||||
{
|
||||
"label": "install builds for the driver's CUDA version, e.g. vLLM's +cu129 wheel from its GitHub release with uv pip install … --torch-backend cu129",
|
||||
"op": "manual",
|
||||
},
|
||||
{"label": "or update the NVIDIA driver (CUDA 13 builds need driver 580 or newer)", "op": "manual"},
|
||||
],
|
||||
),
|
||||
(
|
||||
r"No CUDA GPUs are available|no GPU.*found|CUDA_VISIBLE_DEVICES.*invalid",
|
||||
"No GPUs are visible to the serve process.",
|
||||
|
||||
+100
-240
@@ -468,175 +468,6 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
task["payload"].pop("hf_token", None)
|
||||
return state
|
||||
|
||||
def _diagnose_serve_output(text: str) -> dict | None:
|
||||
"""Server-side mirror of the Cookbook UI's common serve diagnoses.
|
||||
|
||||
The browser uses cookbook-diagnosis.js for clickable fixes. This gives
|
||||
the agent/tool path the same structured signal so it can retry with an
|
||||
adjusted command instead of guessing from raw tmux output.
|
||||
"""
|
||||
if not text:
|
||||
return None
|
||||
tail = text[-6000:]
|
||||
patterns = [
|
||||
(
|
||||
r"No available memory for the cache blocks|Available KV cache memory:.*-",
|
||||
"No GPU memory left for KV cache after loading model.",
|
||||
[
|
||||
{"label": "retry with GPU memory utilization 0.95", "op": "replace", "flag": "--gpu-memory-utilization", "value": "0.95"},
|
||||
{"label": "retry with context 2048", "op": "replace", "flag": "--max-model-len", "value": "2048"},
|
||||
],
|
||||
),
|
||||
(
|
||||
r"CUDA out of memory|torch\.cuda\.OutOfMemoryError|CUDA error: out of memory|warming up sampler|max_num_seqs.*gpu_memory_utilization",
|
||||
"GPU ran out of memory during startup or warmup.",
|
||||
[
|
||||
{"label": "retry with context 4096", "op": "replace", "flag": "--max-model-len", "value": "4096"},
|
||||
{"label": "retry with GPU memory utilization 0.80", "op": "replace", "flag": "--gpu-memory-utilization", "value": "0.80"},
|
||||
{"label": "retry with --enforce-eager", "op": "append", "arg": "--enforce-eager"},
|
||||
],
|
||||
),
|
||||
(
|
||||
r"not divisib|must be divisible|attention heads.*divisible",
|
||||
"Tensor parallel size is incompatible with the model.",
|
||||
[
|
||||
{"label": "retry with tensor parallel size 1", "op": "replace", "flag": "--tensor-parallel-size", "value": "1"},
|
||||
{"label": "retry with tensor parallel size 2", "op": "replace", "flag": "--tensor-parallel-size", "value": "2"},
|
||||
],
|
||||
),
|
||||
(
|
||||
r"KV cache.*too (small|large)|max_model_len.*exceeds|maximum.*context",
|
||||
"Context length is too large for available GPU memory.",
|
||||
[
|
||||
{"label": "retry with context 8192", "op": "replace", "flag": "--max-model-len", "value": "8192"},
|
||||
{"label": "retry with context 4096", "op": "replace", "flag": "--max-model-len", "value": "4096"},
|
||||
],
|
||||
),
|
||||
(
|
||||
r"enable-auto-tool-choice requires --tool-call-parser",
|
||||
"Auto tool choice requires an explicit tool call parser.",
|
||||
[{"label": "retry with Hermes tool parser", "op": "append", "arg": "--tool-call-parser hermes"}],
|
||||
),
|
||||
(
|
||||
r"Please pass.*trust.remote.code=True|contains custom code which must be executed to correctly load|does not recognize this architecture|model type.*but Transformers does not",
|
||||
"Model requires custom code or newer model support.",
|
||||
[{"label": "retry with --trust-remote-code", "op": "append", "arg": "--trust-remote-code"}],
|
||||
),
|
||||
(
|
||||
r"Either a revision or a version must be specified|transformers\.integrations\.hub_kernels|kernels/layer",
|
||||
"vLLM/Transformers kernel package mismatch.",
|
||||
[{"label": "update vLLM, Transformers, and kernels on this server", "op": "dependency", "package": "vllm transformers kernels"}],
|
||||
),
|
||||
(
|
||||
r"Address already in use|bind.*address.*in use",
|
||||
"Port is already in use.",
|
||||
[{"label": "retry on port 8001", "op": "replace", "flag": "--port", "value": "8001"}],
|
||||
),
|
||||
(
|
||||
r"No CUDA GPUs are available|no GPU.*found|CUDA_VISIBLE_DEVICES.*invalid",
|
||||
"No GPUs are visible to the serve process.",
|
||||
[{"label": "clear Cookbook GPU selection or choose available GPUs", "op": "settings", "field": "gpus", "value": ""}],
|
||||
),
|
||||
(
|
||||
r"Failed to infer device type|NVML Shared Library Not Found|No module named 'amdsmi'|platform is not available",
|
||||
"vLLM could not find a supported GPU (CUDA or ROCm). "
|
||||
"This machine may have integrated or unsupported graphics only.",
|
||||
[
|
||||
{"label": "switch to llama.cpp (CPU/Metal, works without a discrete GPU)", "op": "manual"},
|
||||
{"label": "switch to Ollama (CPU/Metal, works without a discrete GPU)", "op": "manual"},
|
||||
],
|
||||
),
|
||||
(
|
||||
r"vllm.*command not found|No module named vllm|ERROR: vLLM is not installed",
|
||||
"vLLM is not installed or not in PATH on this server.",
|
||||
[{"label": "install vLLM in Cookbook Dependencies", "op": "dependency", "package": "vllm"}],
|
||||
),
|
||||
(
|
||||
r"sgl_kernel[\s\S]*(Python\.h|libnuma\.so\.1|common_ops|libnvrtc\.so)|"
|
||||
r"(Python\.h|libnuma\.so\.1|common_ops|libnvrtc\.so)[\s\S]*sgl_kernel|"
|
||||
r"Could not load any common_ops library|"
|
||||
r"Please ensure sgl_kernel is properly installed",
|
||||
"SGLang native kernel/runtime is missing or mismatched on this server.",
|
||||
[
|
||||
{"label": "repair sglang-kernel in this Python environment", "op": "dependency", "package": "sglang-kernel"},
|
||||
{"label": "if libnvrtc is still missing, install the matching CUDA/NVRTC runtime on this host", "op": "manual"},
|
||||
],
|
||||
),
|
||||
(
|
||||
r"sglang.*command not found|No module named sglang|SGLang is not installed",
|
||||
"SGLang is not installed or not in PATH on this server.",
|
||||
[{"label": "install SGLang in Cookbook Dependencies", "op": "dependency", "package": "sglang[all]"}],
|
||||
),
|
||||
(
|
||||
r"No module named ['\"]?mlx_lm|mlx_lm.*command not found|MLX is not installed|MLX LM is not installed",
|
||||
"MLX LM is not installed on this server.",
|
||||
[{"label": "install mlx-lm in Cookbook Dependencies", "op": "dependency", "package": "mlx-lm"}],
|
||||
),
|
||||
(
|
||||
r"OmniGen2Pipeline|module diffusers has no attribute .*Pipeline|custom_pipeline=.*failed",
|
||||
"This image model uses a custom Diffusers pipeline that the launch environment does not know yet.",
|
||||
[{"label": "update Diffusers image dependencies", "op": "dependency", "package": "diffusers transformers accelerate"}],
|
||||
),
|
||||
(
|
||||
r"Unable to quantize model of type <class ['\"]mlx_lm\.models\.switch_layers\.QuantizedSwitchLinear['\"]>|QuantizedSwitchLinear",
|
||||
"MLX-LM tried to quantize an already-quantized DeepSeek switch layer.",
|
||||
[
|
||||
{"label": "relaunch from the cached local Hugging Face snapshot path on this Mac", "op": "manual"},
|
||||
{"label": "Odysseus now rewrites MLX repo-id launches to a cached snapshot when one exists", "op": "manual"},
|
||||
],
|
||||
),
|
||||
# System build deps come BEFORE the generic llama.cpp catch-all
|
||||
# so cmake / build-essential / git missing → a specific OS-package
|
||||
# remediation instead of "install llama-cpp-python[server]" (which
|
||||
# itself fails to compile when cmake is absent).
|
||||
(
|
||||
r"cmake: command not found|cmake.*not found.*[Cc]ould not",
|
||||
"cmake is required to build llama.cpp from source but isn't installed on this server.",
|
||||
[{"label": "install build deps for llama.cpp (apt: cmake build-essential git / pacman: cmake base-devel git / dnf: cmake gcc-c++ make git / brew: cmake git)", "op": "dependency", "package": "llama-cpp-python[server]"}],
|
||||
),
|
||||
(
|
||||
r"^(make|g\+\+|gcc): command not found|Could not find C\+\+ compiler",
|
||||
"A C/C++ compiler (build-essential) is required to build llama.cpp from source.",
|
||||
[{"label": "install build deps for llama.cpp on this server", "op": "dependency", "package": "llama-cpp-python[server]"}],
|
||||
),
|
||||
(
|
||||
r"^git: command not found",
|
||||
"git is required to clone the llama.cpp source tree.",
|
||||
[{"label": "install build deps for llama.cpp on this server", "op": "dependency", "package": "llama-cpp-python[server]"}],
|
||||
),
|
||||
(
|
||||
r"llama-server.*command not found|llama\.cpp.*not found|No module named.*llama_cpp|No module named 'starlette_context'",
|
||||
"llama.cpp / llama-cpp-python dependencies are missing.",
|
||||
[{"label": "install llama.cpp dependencies or llama-cpp-python[server]", "op": "dependency", "package": "llama-cpp-python[server]"}],
|
||||
),
|
||||
(
|
||||
r"No GGUF found on this host|no \.gguf file|No GGUF file found",
|
||||
"No GGUF file found for this model on this host. The llama.cpp backend needs a .gguf file.",
|
||||
[{"label": "download a GGUF build of this model (repo name usually ends in -GGUF, file like Q4_K_M.gguf)", "op": "manual"}],
|
||||
),
|
||||
(
|
||||
r"No module named 'torch'|No module named torch|No module named 'torchvision'|No module named torchvision|No module named 'diffusers'|No module named diffusers|No module named 'scipy'|No module named scipy|install scipy if you want to use beta sigmas|requires the Torchvision library",
|
||||
"Diffusion serving requires PyTorch, Torchvision, Diffusers, Accelerate, and SciPy.",
|
||||
[{"label": "install Diffusers image deps in Cookbook Dependencies", "op": "dependency", "package": "diffusers[torch] torchvision accelerate scipy python-multipart"}],
|
||||
),
|
||||
(
|
||||
r"403 Forbidden|401 Unauthorized|Access to model.*is restricted|gated repo|not in the authorized list|awaiting a review",
|
||||
"Model access is gated or unauthorized.",
|
||||
[{"label": "set HF token and request model access on HuggingFace", "op": "manual"}],
|
||||
),
|
||||
]
|
||||
for pattern, message, suggestions in patterns:
|
||||
if re.search(pattern, tail, re.I):
|
||||
return {"message": message, "suggestions": suggestions}
|
||||
if re.search(r"Traceback \(most recent call last\)", tail, re.I) and not re.search(
|
||||
r"Application startup complete|GET /v1/|Uvicorn running on", tail, re.I
|
||||
):
|
||||
return {
|
||||
"message": "Python traceback detected during serve startup.",
|
||||
"suggestions": [{"label": "inspect traceback and retry with adjusted backend/settings", "op": "manual"}],
|
||||
}
|
||||
return None
|
||||
|
||||
def _state_for_client(state):
|
||||
"""Return cookbook state without raw secrets for browser clients."""
|
||||
_strip_task_secrets(state)
|
||||
@@ -1577,10 +1408,15 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
|
||||
return {"models": models, "host": host or "local"}
|
||||
|
||||
def _auto_register_image_endpoint(req: ServeRequest, remote: str | None) -> str | None:
|
||||
def _auto_register_image_endpoint(
|
||||
req: ServeRequest,
|
||||
remote: str | None,
|
||||
serve_session_id: str | None = None,
|
||||
) -> str | None:
|
||||
"""Register a diffusion model as an image endpoint so it appears in the model selector."""
|
||||
import re
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
from routes.model_routes import _normalize_model_ids
|
||||
from src.settings import load_settings, save_settings
|
||||
|
||||
# Parse port from command (--port NNNN), default 8100 for diffusion_server
|
||||
@@ -1608,9 +1444,13 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
if existing:
|
||||
existing.is_enabled = True
|
||||
existing.model_type = "image"
|
||||
existing.name = display_name
|
||||
# Follow the newly served model unless the user renamed the endpoint.
|
||||
pinned_names = {f"{m.split('/')[-1]} (image)" for m in _normalize_model_ids(existing.pinned_models)}
|
||||
if existing.name in pinned_names:
|
||||
existing.name = display_name
|
||||
existing.endpoint_kind = "local"
|
||||
existing.model_refresh_mode = "manual"
|
||||
existing.cookbook_serve_session_id = serve_session_id
|
||||
if pinned_models:
|
||||
existing.cached_models = json.dumps(pinned_models)
|
||||
existing.pinned_models = json.dumps(pinned_models)
|
||||
@@ -1634,6 +1474,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
model_refresh_mode="manual",
|
||||
cached_models=json.dumps(pinned_models) if pinned_models else None,
|
||||
pinned_models=json.dumps(pinned_models) if pinned_models else None,
|
||||
cookbook_serve_session_id=serve_session_id,
|
||||
)
|
||||
db.add(ep)
|
||||
db.commit()
|
||||
@@ -1779,43 +1620,71 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
# let the probe layer mark it offline if nothing's listening.
|
||||
logger.info(f"crash-watchdog: serve {session_id} exited cleanly (0); leaving endpoint {endpoint_id}")
|
||||
return
|
||||
# Non-zero exit — drop the endpoint.
|
||||
# A nonzero exit may delete only the endpoint owned by this serve.
|
||||
try:
|
||||
from core.database import SessionLocal as _SL, ModelEndpoint as _ME
|
||||
from core.constants import internal_api_base as _internal_api_base
|
||||
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
|
||||
from urllib.parse import quote
|
||||
import httpx
|
||||
|
||||
db = _SL()
|
||||
try:
|
||||
ep = db.query(_ME).filter(_ME.id == endpoint_id).first()
|
||||
if ep:
|
||||
# A scheduled serve can leave old non-zero exit markers
|
||||
# in tmux scrollback while the current OpenAI endpoint is
|
||||
# actually alive. Verify reachability before deleting the
|
||||
# endpoint row; otherwise chats fall back even though the
|
||||
# served model is ready.
|
||||
try:
|
||||
probe_url = ep.base_url.rstrip("/") + "/models"
|
||||
with urllib.request.urlopen(probe_url, timeout=3) as resp:
|
||||
if 200 <= getattr(resp, "status", 0) < 300:
|
||||
logger.info(
|
||||
f"crash-watchdog: serve {session_id} has exit marker {exit_code} "
|
||||
f"but endpoint {ep.id} is reachable; leaving it registered"
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
if not ep or ep.cookbook_serve_session_id != session_id:
|
||||
logger.info(
|
||||
f"crash-watchdog: dropping endpoint {endpoint_id} "
|
||||
f"({ep.name} @ {ep.base_url}) — serve exited {exit_code}"
|
||||
"crash-watchdog: endpoint %s no longer owned by %s",
|
||||
endpoint_id, session_id,
|
||||
)
|
||||
db.delete(ep)
|
||||
db.commit()
|
||||
return
|
||||
probe_url = ep.base_url.rstrip("/") + "/models"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Preserve an endpoint that is already healthy, including
|
||||
# after an old runner emits a stale failure marker.
|
||||
try:
|
||||
with urllib.request.urlopen(probe_url, timeout=3) as resp:
|
||||
if 200 <= getattr(resp, "status", 0) < 300:
|
||||
logger.info(
|
||||
"crash-watchdog: endpoint %s is reachable; keeping it",
|
||||
endpoint_id,
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async with httpx.AsyncClient(timeout=8) as client:
|
||||
response = await client.delete(
|
||||
f"{_internal_api_base()}/api/model-endpoints/{quote(endpoint_id, safe='')}",
|
||||
params={"expected_cookbook_serve_session_id": session_id},
|
||||
headers={INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN},
|
||||
)
|
||||
if response.status_code == 200:
|
||||
logger.info(
|
||||
"crash-watchdog: deleted endpoint %s after serve %s exited %s",
|
||||
endpoint_id, session_id, exit_code,
|
||||
)
|
||||
elif response.status_code in (404, 409):
|
||||
logger.info(
|
||||
"crash-watchdog: endpoint %s absent or reassigned; keeping it",
|
||||
endpoint_id,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"crash-watchdog: endpoint %s deletion returned HTTP %s",
|
||||
endpoint_id, response.status_code,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"crash-watchdog: endpoint cleanup failed: {e!r}")
|
||||
logger.warning("crash-watchdog: endpoint cleanup failed: %r", e)
|
||||
return
|
||||
logger.debug(f"crash-watchdog: no exit marker for {session_id} within window; leaving endpoint {endpoint_id}")
|
||||
|
||||
def _auto_register_llm_endpoint(req: ServeRequest, remote: str | None) -> str | None:
|
||||
def _auto_register_llm_endpoint(
|
||||
req: ServeRequest,
|
||||
remote: str | None,
|
||||
serve_session_id: str | None = None,
|
||||
) -> str | None:
|
||||
"""Register a freshly-served LLM as a model endpoint so it appears in the
|
||||
model picker without a manual /setup step — the text-model sibling of
|
||||
_auto_register_image_endpoint.
|
||||
@@ -1832,6 +1701,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
)
|
||||
import re
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
from routes.model_routes import _normalize_model_ids
|
||||
|
||||
# Port: ordered fallbacks so we match whatever the user actually
|
||||
# asked for, not a hardcoded default:
|
||||
@@ -1893,8 +1763,12 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
# deployment alias from /v1/models, and a stale server can answer on the
|
||||
# same port while the new launch failed. Keeping the requested model id
|
||||
# pinned makes the picker reflect the actual launch intent.
|
||||
pinned_models = [mlx_shim_model_id] if mlx_shim_model_id else ([req.repo_id] if req.repo_id else [])
|
||||
launched_model = mlx_shim_model_id or req.repo_id
|
||||
pinned_models = [launched_model] if launched_model else []
|
||||
|
||||
# Launch registration must not delete other endpoints.
|
||||
# Matching names, model pins, and failed probes do not prove that
|
||||
# another serve has stopped. Lifecycle cleanup owns termination.
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Reuse an endpoint already pointed at this URL instead of duplicating.
|
||||
@@ -1902,19 +1776,22 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
if existing:
|
||||
existing.is_enabled = True
|
||||
existing.model_type = "llm"
|
||||
existing.name = display_name
|
||||
# Follow the newly served model unless the user renamed the endpoint.
|
||||
pinned_names = {m.split("/")[-1] for m in _normalize_model_ids(existing.pinned_models)}
|
||||
if existing.name in pinned_names:
|
||||
existing.name = display_name
|
||||
existing.endpoint_kind = "local"
|
||||
existing.model_refresh_mode = "auto"
|
||||
if pinned_models:
|
||||
try:
|
||||
existing_pinned = json.loads(existing.pinned_models or "[]")
|
||||
except Exception:
|
||||
existing_pinned = []
|
||||
merged_pinned = []
|
||||
for mid in [*existing_pinned, *pinned_models]:
|
||||
if mid and mid not in merged_pinned:
|
||||
merged_pinned.append(mid)
|
||||
existing.pinned_models = json.dumps(merged_pinned) if merged_pinned else None
|
||||
# A reused endpoint belongs to the newest serve session.
|
||||
existing.cookbook_serve_session_id = serve_session_id
|
||||
if launched_model:
|
||||
# Pins represent model selection, not launch history.
|
||||
existing_pins = _normalize_model_ids(
|
||||
existing.pinned_models
|
||||
)
|
||||
if launched_model not in existing_pins:
|
||||
existing_pins.append(launched_model)
|
||||
existing.pinned_models = json.dumps(existing_pins)
|
||||
if is_ollama_endpoint:
|
||||
existing.endpoint_kind = "ollama"
|
||||
if pinned_models:
|
||||
@@ -1940,22 +1817,6 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
db.commit()
|
||||
except Exception as _pe:
|
||||
logger.warning(f"Re-probe failed for {base_url}: {_pe!r}")
|
||||
# Sweep stale dupes: other endpoints with the same display name
|
||||
# at DIFFERENT URLs (likely failed earlier-attempt ports) get
|
||||
# deleted so the picker doesn't show an offline ghost next to
|
||||
# the working one. Only sweeps endpoints whose id starts with
|
||||
# `local-` so we never touch a user's hand-added DeepSeek/OpenAI/
|
||||
# etc. entry with a coincidentally matching name.
|
||||
stale = (db.query(ModelEndpoint)
|
||||
.filter(ModelEndpoint.name == display_name)
|
||||
.filter(ModelEndpoint.base_url != base_url)
|
||||
.filter(ModelEndpoint.id.like("local-%"))
|
||||
.all())
|
||||
for s in stale:
|
||||
logger.info(f"Sweeping stale local endpoint {s.id} ({s.base_url})")
|
||||
db.delete(s)
|
||||
if stale:
|
||||
db.commit()
|
||||
return existing.id
|
||||
|
||||
ep_id = f"local-{uuid.uuid4().hex[:8]}"
|
||||
@@ -1971,22 +1832,11 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
cached_models=json.dumps(pinned_models) if pinned_models else None,
|
||||
pinned_models=json.dumps(pinned_models) if pinned_models else None,
|
||||
supports_tools=supports_tools,
|
||||
cookbook_serve_session_id=serve_session_id,
|
||||
)
|
||||
db.add(ep)
|
||||
db.commit()
|
||||
logger.info(f"Auto-registered local model endpoint: {display_name} @ {base_url}")
|
||||
# Same sweep on first-register path: drop any pre-existing local-*
|
||||
# endpoints with this display name pointed elsewhere.
|
||||
stale = (db.query(ModelEndpoint)
|
||||
.filter(ModelEndpoint.name == display_name)
|
||||
.filter(ModelEndpoint.id != ep_id)
|
||||
.filter(ModelEndpoint.id.like("local-%"))
|
||||
.all())
|
||||
for s in stale:
|
||||
logger.info(f"Sweeping stale local endpoint {s.id} ({s.base_url})")
|
||||
db.delete(s)
|
||||
if stale:
|
||||
db.commit()
|
||||
# Probe /v1/models NOW and write cached_models so the chat
|
||||
# picker actually shows the model on the next /api/models
|
||||
# call. Without this immediate probe, the endpoint has empty
|
||||
@@ -2808,9 +2658,17 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
endpoint_id = None
|
||||
is_image_endpoint = "diffusion_server.py" in req.cmd or "mlx_image_server.py" in req.cmd
|
||||
if is_image_endpoint:
|
||||
endpoint_id = _auto_register_image_endpoint(req, remote)
|
||||
endpoint_id = _auto_register_image_endpoint(
|
||||
req,
|
||||
remote,
|
||||
serve_session_id=session_id,
|
||||
)
|
||||
elif not is_pip_install:
|
||||
endpoint_id = _auto_register_llm_endpoint(req, remote)
|
||||
endpoint_id = _auto_register_llm_endpoint(
|
||||
req,
|
||||
remote,
|
||||
serve_session_id=session_id,
|
||||
)
|
||||
|
||||
# Crash watchdog: the auto-register above writes the endpoint row
|
||||
# IMMEDIATELY (before the server has even bound its port) so the
|
||||
@@ -3930,7 +3788,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
return {"ok": False, "files": [], "error": f"HF API HTTP {resp.status_code}"}
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
logger.exception("HF GGUF file scan failed for %s", repo)
|
||||
logger.exception("HF GGUF file scan failed for %s", repo_id)
|
||||
return {"ok": False, "files": [], "error": "HF API request failed"}
|
||||
files = [
|
||||
str(s.get("rfilename") or "")
|
||||
@@ -4446,7 +4304,9 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
# fills the visible tail. Without this, output_tail ends up
|
||||
# as just "Locale: C / Ubuntu_Odysseus ❯" and the agent
|
||||
# can't diagnose the actual error.
|
||||
capture_cmd = ssh_base + [remote, _remote_tmux_command("capture-pane", "-t", session_id, "-p", "-S", "-500")]
|
||||
# -J joins lines tmux wrapped at the pane width, so an error
|
||||
# split mid-word still matches the serve diagnosis patterns.
|
||||
capture_cmd = ssh_base + [remote, _remote_tmux_command("capture-pane", "-t", session_id, "-p", "-J", "-S", "-500")]
|
||||
elif IS_WINDOWS:
|
||||
# LOCAL Windows task: launched as a detached process (no tmux).
|
||||
# Liveness comes from the <session>.pid file, output from the
|
||||
@@ -4455,7 +4315,7 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
capture_cmd = None
|
||||
else:
|
||||
check_cmd = ["tmux", "has-session", "-t", session_id]
|
||||
capture_cmd = ["tmux", "capture-pane", "-t", session_id, "-p", "-S", "-500"]
|
||||
capture_cmd = ["tmux", "capture-pane", "-t", session_id, "-p", "-J", "-S", "-500"]
|
||||
|
||||
local_win_task = (not remote) and IS_WINDOWS
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from core.database import SessionLocal, Document, DocumentVersion
|
||||
from core.database import Session as DbSession
|
||||
from src.auth_helpers import get_current_user, _auth_disabled
|
||||
from src.constants import MAIL_ATTACHMENTS_DIR
|
||||
from src.session_search import _escape_like
|
||||
from src.upload_handler import reserve_upload_references
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -571,9 +572,10 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
# match docs containing both words regardless of position/order.
|
||||
if search:
|
||||
for tok in search.split():
|
||||
term = f"%{tok}%"
|
||||
term = f"%{_escape_like(tok)}%"
|
||||
q = q.filter(
|
||||
Document.title.ilike(term) | Document.current_content.ilike(term)
|
||||
Document.title.ilike(term, escape="\\")
|
||||
| Document.current_content.ilike(term, escape="\\")
|
||||
)
|
||||
|
||||
# Language filter. "pdf" is a display language derived from the
|
||||
@@ -812,10 +814,13 @@ def setup_document_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
ext = _ext.get(doc.language or "text", ".txt")
|
||||
base = (doc.title or "document").strip() or "document"
|
||||
base = re.sub(r"[^\w\-. ]+", "", base)[:60].strip() or doc.id
|
||||
name = base if "." in base else base + ext
|
||||
has_ext = base.lower().endswith(ext)
|
||||
stem = base[:-len(ext)] if has_ext else base
|
||||
suffix = base[-len(ext):] if has_ext else ext
|
||||
name = stem + suffix
|
||||
i = 1
|
||||
while name in used:
|
||||
name = f"{base}-{i}" + ("" if "." in base else ext)
|
||||
name = f"{stem}-{i}{suffix}"
|
||||
i += 1
|
||||
used.add(name)
|
||||
content = doc.current_content or ""
|
||||
|
||||
@@ -36,6 +36,7 @@ from fastapi import Query, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
|
||||
from src.email_mime import decode_text_part as _decode_text_part
|
||||
from src.auth_helpers import _auth_disabled, get_current_user
|
||||
from src.secret_storage import decrypt as _decrypt
|
||||
from src.url_safety import OutboundAddressBlocked, connect_outbound_tcp
|
||||
@@ -1776,44 +1777,38 @@ def _extract_html(msg):
|
||||
ct = part.get_content_type()
|
||||
cd = str(part.get("Content-Disposition", ""))
|
||||
if ct == "text/html" and "attachment" not in cd:
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload:
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
return payload.decode(charset, errors="replace")
|
||||
text = _decode_text_part(part)
|
||||
if text:
|
||||
return text
|
||||
elif msg.get_content_type() == "text/html":
|
||||
payload = msg.get_payload(decode=True)
|
||||
if payload:
|
||||
charset = msg.get_content_charset() or "utf-8"
|
||||
return payload.decode(charset, errors="replace")
|
||||
text = _decode_text_part(msg)
|
||||
if text:
|
||||
return text
|
||||
return None
|
||||
|
||||
|
||||
def _extract_text(msg):
|
||||
if msg.is_multipart():
|
||||
text_parts = []
|
||||
html_parts = []
|
||||
for part in msg.walk():
|
||||
ct = part.get_content_type()
|
||||
cd = str(part.get("Content-Disposition", ""))
|
||||
if ct == "text/plain" and "attachment" not in cd:
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload:
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
text_parts.append(payload.decode(charset, errors="replace"))
|
||||
elif ct == "text/html" and not text_parts and "attachment" not in cd:
|
||||
payload = part.get_payload(decode=True)
|
||||
if payload:
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
raw_html = payload.decode(charset, errors="replace")
|
||||
text = _decode_text_part(part)
|
||||
if text:
|
||||
text_parts.append(text)
|
||||
elif ct == "text/html" and not html_parts and "attachment" not in cd:
|
||||
raw_html = _decode_text_part(part)
|
||||
if raw_html:
|
||||
text = re.sub(r"<br\s*/?>", "\n", raw_html, flags=re.I)
|
||||
text = re.sub(r"<[^>]+>", "", text)
|
||||
text = html.unescape(text)
|
||||
text_parts.append(text.strip())
|
||||
return "\n".join(text_parts)
|
||||
html_parts.append(text.strip())
|
||||
return "\n".join(text_parts or html_parts)
|
||||
else:
|
||||
payload = msg.get_payload(decode=True)
|
||||
if payload:
|
||||
charset = msg.get_content_charset() or "utf-8"
|
||||
text = payload.decode(charset, errors="replace")
|
||||
text = _decode_text_part(msg)
|
||||
if text:
|
||||
if msg.get_content_type() == "text/html":
|
||||
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.I)
|
||||
text = re.sub(r"</(?:p|div|li|tr|h[1-6])\s*>", "\n", text, flags=re.I)
|
||||
|
||||
@@ -18,6 +18,7 @@ from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from core.database import SessionLocal, GalleryImage, GalleryAlbum, ModelEndpoint
|
||||
from core.database import Session as DbSession
|
||||
from src.auth_helpers import get_current_user, owner_filter, require_privilege
|
||||
from src.session_search import escape_like_pattern
|
||||
from src.upload_limits import (
|
||||
read_upload_limited,
|
||||
GALLERY_UPLOAD_MAX_BYTES,
|
||||
@@ -323,6 +324,23 @@ def _b64_bytes(value: str, *, label: str = "image") -> bytes:
|
||||
raise HTTPException(400, f"Invalid {label}") from exc
|
||||
|
||||
|
||||
async def _json_object(request: Request) -> dict:
|
||||
try:
|
||||
data = await request.json()
|
||||
except ValueError:
|
||||
raise HTTPException(400, "Invalid JSON body")
|
||||
if not isinstance(data, dict):
|
||||
raise HTTPException(400, "JSON object body required")
|
||||
return data
|
||||
|
||||
|
||||
async def _image_ids(request: Request) -> list:
|
||||
ids = (await _json_object(request)).get("image_ids", [])
|
||||
if not isinstance(ids, list) or not all(isinstance(i, str) for i in ids):
|
||||
raise HTTPException(400, "image_ids must be a list of image ids")
|
||||
return ids
|
||||
|
||||
|
||||
def _pil_image_to_b64(img, *, fmt: str = "PNG") -> str:
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format=fmt)
|
||||
@@ -1203,15 +1221,18 @@ def setup_gallery_routes() -> APIRouter:
|
||||
)
|
||||
q = _owner_filter(q, user)
|
||||
|
||||
# Search filter (prompt + tags + ai_tags)
|
||||
# Per-term AND search: a single `%foo bar%` LIKE only matched the
|
||||
# exact phrase, so "red car" missed "a car that is red".
|
||||
# LIKE wildcards are escaped per token.
|
||||
if search:
|
||||
term = f"%{search}%"
|
||||
from sqlalchemy import or_
|
||||
q = q.filter(or_(
|
||||
GalleryImage.prompt.ilike(term),
|
||||
GalleryImage.tags.ilike(term),
|
||||
GalleryImage.ai_tags.ilike(term),
|
||||
))
|
||||
for tok in search.split():
|
||||
term = f"%{escape_like_pattern(tok)}%"
|
||||
q = q.filter(or_(
|
||||
GalleryImage.prompt.ilike(term, escape="\\"),
|
||||
GalleryImage.tags.ilike(term, escape="\\"),
|
||||
GalleryImage.ai_tags.ilike(term, escape="\\"),
|
||||
))
|
||||
|
||||
# Tag filter. The UI stacks multiple tag pills by passing them
|
||||
# comma-separated — each tag adds a separate AND-filter so the
|
||||
@@ -1222,9 +1243,10 @@ def setup_gallery_routes() -> APIRouter:
|
||||
for one in (t.strip() for t in tag.split(",")):
|
||||
if not one:
|
||||
continue
|
||||
safe_tag = f"%{escape_like_pattern(one)}%"
|
||||
q = q.filter(_or(
|
||||
GalleryImage.tags.ilike(f"%{one}%"),
|
||||
GalleryImage.ai_tags.ilike(f"%{one}%"),
|
||||
GalleryImage.tags.ilike(safe_tag, escape="\\"),
|
||||
GalleryImage.ai_tags.ilike(safe_tag, escape="\\"),
|
||||
))
|
||||
|
||||
# Model filter
|
||||
@@ -2992,14 +3014,22 @@ def setup_gallery_routes() -> APIRouter:
|
||||
@router.put("/api/gallery/albums/{album_id}")
|
||||
async def update_album(request: Request, album_id: str):
|
||||
user = get_current_user(request)
|
||||
data = await request.json()
|
||||
data = await _json_object(request)
|
||||
name = data.get("name")
|
||||
if name is not None:
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
raise HTTPException(400, "Album name required")
|
||||
name = name.strip()
|
||||
description = data.get("description")
|
||||
if description is not None and not isinstance(description, str):
|
||||
raise HTTPException(400, "Album description must be a string")
|
||||
db = SessionLocal()
|
||||
try:
|
||||
album = _get_or_404_album(db, album_id, user)
|
||||
if data.get("name") is not None:
|
||||
album.name = data["name"]
|
||||
if data.get("description") is not None:
|
||||
album.description = data["description"]
|
||||
if name is not None:
|
||||
album.name = name
|
||||
if description is not None:
|
||||
album.description = description
|
||||
if data.get("cover_id") is not None:
|
||||
cover_id = data["cover_id"] or None
|
||||
if cover_id:
|
||||
@@ -3029,8 +3059,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
@router.post("/api/gallery/albums/{album_id}/add")
|
||||
async def add_to_album(request: Request, album_id: str):
|
||||
user = get_current_user(request)
|
||||
data = await request.json()
|
||||
ids = data.get("image_ids", [])
|
||||
ids = await _image_ids(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
_get_or_404_album(db, album_id, user)
|
||||
@@ -3047,8 +3076,7 @@ def setup_gallery_routes() -> APIRouter:
|
||||
@router.post("/api/gallery/albums/{album_id}/remove")
|
||||
async def remove_from_album(request: Request, album_id: str):
|
||||
user = get_current_user(request)
|
||||
data = await request.json()
|
||||
ids = data.get("image_ids", [])
|
||||
ids = await _image_ids(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
_get_or_404_album(db, album_id, user)
|
||||
|
||||
@@ -471,10 +471,16 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
_verify_session_owner(request, session_id)
|
||||
try:
|
||||
body = await request.json()
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(400, "Request body must be a JSON object")
|
||||
role = body.get("role", "assistant")
|
||||
content = body.get("content", "")
|
||||
if role not in ("user", "assistant"):
|
||||
raise HTTPException(400, "role must be 'user' or 'assistant'")
|
||||
if not content:
|
||||
raise HTTPException(400, "content is required")
|
||||
if not isinstance(content, str):
|
||||
raise HTTPException(400, "content must be a string")
|
||||
metadata = sanitize_client_message_metadata(body.get("metadata"))
|
||||
_reserve_message_uploads(request, content, metadata)
|
||||
msg = ChatMessage(role=role, content=content, metadata=metadata)
|
||||
@@ -825,6 +831,23 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
try:
|
||||
body = await request.json()
|
||||
keep_count = body.get("keep_count", 0)
|
||||
# A message id is exact; keep_count from the client counts only the
|
||||
# messages loaded in the page. before_msg_id forks before that
|
||||
# message, through_msg_id includes it.
|
||||
before_msg_id = str(body.get("before_msg_id") or "").strip()
|
||||
through_msg_id = str(body.get("through_msg_id") or "").strip()
|
||||
if before_msg_id or through_msg_id:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
all_db_messages = db.query(DbChatMessage).filter(
|
||||
DbChatMessage.session_id == session_id
|
||||
).order_by(DbChatMessage.timestamp).all()
|
||||
finally:
|
||||
db.close()
|
||||
resolved = _keep_count_before_message(all_db_messages, before_msg_id or through_msg_id)
|
||||
if resolved is None:
|
||||
raise HTTPException(404, "Message not found")
|
||||
keep_count = resolved + (0 if before_msg_id else 1)
|
||||
|
||||
# Get the source session. keep_count indexes into source.history,
|
||||
# so this must go through get_session — reading the cache directly
|
||||
|
||||
+30
-5
@@ -659,7 +659,7 @@ _NON_CHAT_CONTAINS = (
|
||||
"gliner", "riva", "-parse", "-embedqa", "-nemoretriever",
|
||||
"topic-control", "calibration",
|
||||
"ai-synthetic-video", "cosmos-reason2",
|
||||
"bge", "llama-guard",
|
||||
"bge", "llama-guard", "-embed", "rerank",
|
||||
)
|
||||
_NON_CHAT_EXACT_PREFIXES = (
|
||||
"gpt-audio", # gpt-audio, gpt-audio-mini etc. (not gpt-4o-audio-preview which is chat)
|
||||
@@ -2846,11 +2846,12 @@ def setup_model_routes(model_discovery):
|
||||
return {"endpoint_id": "", "endpoint_url": "", "model": ""}
|
||||
base = _normalize_base(ep.base_url)
|
||||
chat_url = build_chat_url(base)
|
||||
if model and not _is_chat_model(model):
|
||||
model = ""
|
||||
if not model and (getattr(ep, "cached_models", None) or getattr(ep, "pinned_models", None)):
|
||||
try:
|
||||
visible = _visible_models(ep.cached_models, getattr(ep, "hidden_models", None), getattr(ep, "pinned_models", None))
|
||||
if visible:
|
||||
model = visible[0]
|
||||
model = next((mid for mid in visible if _is_chat_model(mid)), "")
|
||||
except Exception:
|
||||
pass
|
||||
return {"endpoint_id": ep.id, "endpoint_url": chat_url, "model": model}
|
||||
@@ -3043,20 +3044,44 @@ def setup_model_routes(model_discovery):
|
||||
return {"dependents": _settings_using_endpoint(ep_id)}
|
||||
|
||||
@router.delete("/model-endpoints/{ep_id}")
|
||||
def delete_model_endpoint(ep_id: str, request: Request):
|
||||
def delete_model_endpoint(
|
||||
ep_id: str,
|
||||
request: Request,
|
||||
expected_cookbook_serve_session_id: Optional[str] = None,
|
||||
):
|
||||
require_admin(request)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = db.query(ModelEndpoint).filter(ModelEndpoint.id == ep_id).first()
|
||||
if not ep or not _chatgpt_endpoint_visible(ep, request):
|
||||
raise HTTPException(404, "Endpoint not found")
|
||||
if expected_cookbook_serve_session_id is not None:
|
||||
# The DELETE itself checks ownership. An old serve must never
|
||||
# mutate the settings of an endpoint reused by another serve.
|
||||
if (
|
||||
not ep_id.startswith(("local-", "img-"))
|
||||
or not expected_cookbook_serve_session_id
|
||||
):
|
||||
raise HTTPException(409, "Cookbook endpoint ownership mismatch")
|
||||
deleted = (
|
||||
db.query(ModelEndpoint)
|
||||
.filter(
|
||||
ModelEndpoint.id == ep_id,
|
||||
ModelEndpoint.cookbook_serve_session_id
|
||||
== expected_cookbook_serve_session_id,
|
||||
)
|
||||
.delete(synchronize_session=False)
|
||||
)
|
||||
if not deleted:
|
||||
raise HTTPException(409, "Cookbook endpoint ownership changed")
|
||||
# Clean up any settings that reference this endpoint
|
||||
cleared = _clear_settings_for_endpoint(ep_id)
|
||||
cleared_user_preferences = _clear_user_prefs_for_endpoint(ep_id)
|
||||
cleared_sessions = _clear_sessions_for_endpoint(db, ep.base_url, ep.id, ep.owner)
|
||||
cleared_loaded_sessions = _clear_loaded_sessions_for_endpoint(ep.base_url, ep.id, ep.owner)
|
||||
auth_id = getattr(ep, "provider_auth_id", None)
|
||||
db.delete(ep)
|
||||
if expected_cookbook_serve_session_id is None:
|
||||
db.delete(ep)
|
||||
cleared_provider_auth = _delete_orphaned_provider_auth(db, auth_id, exclude_ep_id=ep_id)
|
||||
db.commit()
|
||||
_invalidate_models_cache()
|
||||
|
||||
@@ -183,6 +183,7 @@ def _resolve_endpoint_runtime(ep, owner=None, model: Optional[str] = None):
|
||||
OAuth tokens in ProviderAuthSession, so ep.api_key is intentionally empty.
|
||||
"""
|
||||
from src.endpoint_resolver import (
|
||||
_endpoint_enabled_models,
|
||||
build_chat_url,
|
||||
build_headers,
|
||||
resolve_endpoint_runtime as resolve_model_endpoint_runtime,
|
||||
@@ -197,7 +198,7 @@ def _resolve_endpoint_runtime(ep, owner=None, model: Optional[str] = None):
|
||||
ep_model = (model or "").strip()
|
||||
if not ep_model:
|
||||
try:
|
||||
models = json.loads(ep.cached_models) if ep.cached_models else []
|
||||
models = _endpoint_enabled_models(ep)
|
||||
if models:
|
||||
ep_model = _first_chat_model(models)
|
||||
except Exception:
|
||||
@@ -824,23 +825,13 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
if not ep_url or not ep_model:
|
||||
# Last resort: this user's enabled endpoint, plus legacy shared rows.
|
||||
from src.database import SessionLocal
|
||||
from src.endpoint_resolver import normalize_base, build_chat_url, build_headers
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ep = _owned_enabled_endpoint(db, user)
|
||||
if ep:
|
||||
base = normalize_base(ep.base_url)
|
||||
fallback_url = build_chat_url(base)
|
||||
fallback_headers = build_headers(ep.api_key, base)
|
||||
fallback_model = ""
|
||||
if ep.cached_models:
|
||||
try:
|
||||
models = json.loads(ep.cached_models)
|
||||
if models:
|
||||
fallback_model = _first_chat_model(models)
|
||||
except Exception:
|
||||
pass
|
||||
_merge(fallback_url, fallback_model, fallback_headers)
|
||||
resolved = _resolve_endpoint_runtime(ep, owner=user)
|
||||
if resolved:
|
||||
_merge(*resolved)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import httpx
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from services.memory.skill_format import slugify
|
||||
from services.memory.skills import SkillsManager
|
||||
from src.auth_helpers import get_current_user
|
||||
from services.skills import evaluation as _evaluation
|
||||
@@ -836,6 +837,10 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
|
||||
updates = body.dict(exclude_none=True)
|
||||
if not updates:
|
||||
return {"ok": True}
|
||||
if "name" in updates:
|
||||
new_name = slugify(updates["name"] or match.get("name"))
|
||||
if new_name != match.get("name") and skills_manager.has_skill(new_name, owner=user):
|
||||
raise HTTPException(409, f"A skill named '{new_name}' already exists")
|
||||
ok = skills_manager.update_skill(match.get("name"), updates, owner=user)
|
||||
if not ok:
|
||||
raise HTTPException(404, "Skill not found")
|
||||
|
||||
@@ -289,15 +289,15 @@ def _resolve_run_endpoint(db, task: ScheduledTask, run: TaskRun) -> str:
|
||||
|
||||
try:
|
||||
from core.database import ModelEndpoint
|
||||
eps = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True).all()
|
||||
from src.auth_helpers import owner_filter
|
||||
from src.endpoint_resolver import _endpoint_enabled_models
|
||||
|
||||
eps = owner_filter(
|
||||
db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True),
|
||||
ModelEndpoint, getattr(task, "owner", None),
|
||||
).all()
|
||||
for ep in eps:
|
||||
cached = []
|
||||
if ep.cached_models:
|
||||
try:
|
||||
cached = json.loads(ep.cached_models) or []
|
||||
except Exception:
|
||||
cached = []
|
||||
if model in cached:
|
||||
if model in _endpoint_enabled_models(ep):
|
||||
return ep.base_url or ""
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -492,6 +492,25 @@ VARIABLE_NOTES: dict[str, tuple[str, str, str]] = {
|
||||
"tools that execute code, mutate state or cause external side effects need "
|
||||
"a separate approval. Set to 0 to opt out.",
|
||||
),
|
||||
"ODYSSEUS_LOCAL_MCP_READ_TOOLS": (
|
||||
"Agent loop and tool execution", USER,
|
||||
"Security-relevant. Comma-separated exact MCP read-tool names "
|
||||
"on configured stdio MCP servers. Only stdio servers qualify. "
|
||||
"Declarations do not override built-in tool effects. Output remains "
|
||||
"untrusted to prevent privilege escalation, but allows continuation to "
|
||||
"declared write tools on the same stdio server without external taint. "
|
||||
"Stdio transport does not guarantee network isolation. Automatic "
|
||||
"same-store writes carry residual prompt-injection risk.",
|
||||
),
|
||||
"ODYSSEUS_LOCAL_MCP_WRITE_TOOLS": (
|
||||
"Agent loop and tool execution", USER,
|
||||
"Security-relevant. Comma-separated exact MCP write-tool names "
|
||||
"on configured stdio MCP servers. Only stdio servers qualify. "
|
||||
"Declarations do not override built-in tool effects. Allowed to execute "
|
||||
"after declared local reads on the same stdio server, but blocked once "
|
||||
"external untrusted context enters the run. Authorization must still "
|
||||
"originate from the trusted request grant.",
|
||||
),
|
||||
"ODYSSEUS_MCP_ALLOWED_COMMANDS": (
|
||||
"Agent loop and tool execution", USER,
|
||||
"Security-relevant. Comma-separated allowlist of MCP launcher basenames the "
|
||||
|
||||
+12
-4
@@ -27,6 +27,7 @@ from pathlib import Path
|
||||
|
||||
try:
|
||||
from core.database import SessionLocal, Document, DocumentVersion
|
||||
from src.session_search import escape_like_pattern
|
||||
quiet_logs()
|
||||
except ModuleNotFoundError as e:
|
||||
sys.stderr.write(f"error: {e}\nhint: run from repo root with venv active.\n")
|
||||
@@ -139,10 +140,17 @@ def cmd_export(args):
|
||||
def cmd_search(args):
|
||||
db = SessionLocal()
|
||||
try:
|
||||
like = f"%{args.query}%"
|
||||
rows = db.query(Document).filter(
|
||||
(Document.title.ilike(like)) | (Document.current_content.ilike(like))
|
||||
).order_by(Document.updated_at.desc()).limit(args.limit).all()
|
||||
# Per-term AND search: a single `%foo bar%` LIKE only matched the
|
||||
# exact phrase, so "machine learning" missed docs containing both
|
||||
# words apart. LIKE wildcards are escaped per token.
|
||||
q = db.query(Document)
|
||||
for tok in args.query.split():
|
||||
term = f"%{escape_like_pattern(tok)}%"
|
||||
q = q.filter(
|
||||
Document.title.ilike(term, escape="\\")
|
||||
| Document.current_content.ilike(term, escape="\\")
|
||||
)
|
||||
rows = q.order_by(Document.updated_at.desc()).limit(args.limit).all()
|
||||
emit([_serialize(d) for d in rows], args)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -660,6 +660,8 @@ def _detect_windows():
|
||||
"gpu_vram_gb": d.get("gpu_vram_gb"),
|
||||
"gpu_count": _as_int(d.get("gpu_count"), 0),
|
||||
"backend": d.get("gpu_backend", "cpu_x86"),
|
||||
"gpus": [],
|
||||
"gpu_groups": [],
|
||||
"homogeneous": True,
|
||||
"gpu_error": None,
|
||||
"platform": "windows",
|
||||
@@ -822,12 +824,14 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
||||
if result:
|
||||
result = _attach_probe_context(result, host=host)
|
||||
_remote_host = None
|
||||
_remote_port = None
|
||||
_remote_platform = None
|
||||
_cache_by_host[cache_key] = (now, result)
|
||||
return result
|
||||
# SSH may work while the PowerShell hardware probe still fails.
|
||||
result = {"error": f"Windows hardware probe failed for {host}", "host": host}
|
||||
_remote_host = None
|
||||
_remote_port = None
|
||||
_remote_platform = None
|
||||
_cache_by_host[cache_key] = (now, result)
|
||||
return result
|
||||
@@ -852,6 +856,7 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
||||
result = {"error": f"Cannot connect to {host}", "host": host}
|
||||
_cache_by_host[cache_key] = (now, result)
|
||||
_remote_host = None
|
||||
_remote_port = None
|
||||
_remote_platform = None
|
||||
return result
|
||||
available_ram = round(_get_available_ram_gb(), 1)
|
||||
@@ -893,6 +898,9 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
||||
"gpu_name": None,
|
||||
"gpu_vram_gb": None,
|
||||
"gpu_count": 0,
|
||||
"gpus": [],
|
||||
"gpu_groups": [],
|
||||
"homogeneous": True,
|
||||
"backend": backend,
|
||||
# Set when nvidia-smi exists but failed (e.g. driver/library
|
||||
# version mismatch) — lets the UI say "GPU driver error" instead
|
||||
@@ -902,6 +910,7 @@ def detect_system(host="", ssh_port="", platform="", fresh=False):
|
||||
|
||||
result = _attach_probe_context(result, host=host)
|
||||
_remote_host = None
|
||||
_remote_port = None
|
||||
_remote_platform = None
|
||||
_cache_by_host[cache_key] = (now, result)
|
||||
return result
|
||||
|
||||
@@ -130,10 +130,15 @@ def _parse_scalar(raw: str, *, string_only: bool = False) -> Any:
|
||||
def _split_top_level(s: str, sep: str) -> List[str]:
|
||||
"""Split `s` on `sep` ignoring separators inside [] or quotes."""
|
||||
out, buf, depth, quote = [], [], 0, None
|
||||
escaped = False
|
||||
for ch in s:
|
||||
if quote:
|
||||
buf.append(ch)
|
||||
if ch == quote:
|
||||
if escaped:
|
||||
escaped = False
|
||||
elif ch == "\\" and quote == '"':
|
||||
escaped = True
|
||||
elif ch == quote:
|
||||
quote = None
|
||||
continue
|
||||
if ch in ("'", '"'):
|
||||
|
||||
@@ -500,6 +500,19 @@ class SkillsManager:
|
||||
sk.path = self._skill_file(cat, nm)
|
||||
return sk.to_dict()
|
||||
|
||||
def has_skill(self, name: str, owner: Optional[str] = None) -> bool:
|
||||
"""Whether `owner` already has a skill called `name`, in any category.
|
||||
|
||||
Update and delete find a skill by name within its owner, so two of
|
||||
one owner's skills sharing a name would make them act on whichever
|
||||
file is walked first.
|
||||
"""
|
||||
for path in self._iter_skill_files():
|
||||
sk = self._read_skill(path)
|
||||
if sk and sk.name == name and (sk.owner or "") == (owner or ""):
|
||||
return True
|
||||
return False
|
||||
|
||||
def update_skill(self, skill_id: str, updates: Dict, owner: Optional[str] = None) -> bool:
|
||||
"""`skill_id` is the slug name. Allows updating any field plus
|
||||
renames if `name` changes (file is moved on disk).
|
||||
@@ -551,6 +564,9 @@ class SkillsManager:
|
||||
# Rename
|
||||
new_name = slugify(updates.get("name") or sk.name)
|
||||
if new_name != sk.name:
|
||||
if self.has_skill(new_name, owner=owner):
|
||||
logger.warning(f"Skill rename target name in use: {new_name}")
|
||||
return False
|
||||
sk.name = new_name
|
||||
|
||||
# Write to potentially new path
|
||||
|
||||
+39
-15
@@ -14,12 +14,12 @@ import csv
|
||||
import difflib
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import time
|
||||
import logging
|
||||
import hashlib
|
||||
from src.web_recovery import WebRecoveryBudget
|
||||
from itertools import count
|
||||
@@ -16994,6 +16994,20 @@ def _detect_runaway_call(call_freq, threshold=15):
|
||||
sig = next((s for s, n in call_freq.items() if n >= threshold), None)
|
||||
return sig.split(":", 1)[0] if sig else None
|
||||
|
||||
def _tool_approval_gate_bypassed(
|
||||
exact_approval: Optional[ExactToolApproval] = None,
|
||||
) -> bool:
|
||||
"""Return whether the post-external-context tool approval gate is bypassed.
|
||||
|
||||
``tool_approval_mode=off`` (admin setting, Settings → Agent Tools) bypasses
|
||||
the gate on every run, the same effect as ``ODYSSEUS_TOOL_APPROVAL_GATE=0``.
|
||||
A chat-session grant bypasses it only within the scope of its own task.
|
||||
"""
|
||||
return (
|
||||
get_setting("tool_approval_mode", "ask") == "off"
|
||||
or bool(exact_approval and exact_approval.allow_remaining_actions)
|
||||
)
|
||||
|
||||
|
||||
def _tool_result_signature(tool_result_records: list[dict]) -> str:
|
||||
"""Return a bounded fingerprint of the observable result of a tool batch.
|
||||
@@ -21437,9 +21451,7 @@ async def stream_agent_loop(
|
||||
)
|
||||
or messages_contain_external_untrusted_context(messages)
|
||||
),
|
||||
approval_gate_bypassed=bool(
|
||||
exact_approval and exact_approval.allow_remaining_actions
|
||||
),
|
||||
approval_gate_bypassed=_tool_approval_gate_bypassed(exact_approval),
|
||||
delegated_credential=bool(delegated_credential),
|
||||
)
|
||||
_has_tui_host_bridge = _tui_host_bridge_is_usable(client_runtime_context)
|
||||
@@ -24193,11 +24205,14 @@ async def stream_agent_loop(
|
||||
budget_is_explicit,
|
||||
hard_max=hard_max,
|
||||
)
|
||||
if candidate_context <= 8192:
|
||||
if 0 < candidate_context <= 8192:
|
||||
# Small local servers often tokenize chat wrappers and tool
|
||||
# results much more generously than our rough estimator. Keep
|
||||
# substantial headroom for those wrappers and generation so a
|
||||
# follow-up tool round cannot exceed the server's n_ctx.
|
||||
# 0 means the window is unknown, which already gets the
|
||||
# conservative default budget; clamping it as well cut every
|
||||
# request to 1200 tokens and dropped open documents (#6678).
|
||||
effective_budget = min(
|
||||
effective_budget,
|
||||
max(1200, int(candidate_context * 0.40)),
|
||||
@@ -27833,16 +27848,25 @@ async def stream_agent_loop(
|
||||
if _streamed_tool_markup or _streamed_tool_markup_starts(_delta_text):
|
||||
_streamed_tool_markup += _delta_text
|
||||
if not _streamed_tool_markup_complete(_streamed_tool_markup):
|
||||
continue
|
||||
_visible_markup_tail = strip_tool_blocks(
|
||||
_streamed_tool_markup,
|
||||
skip_fenced=True,
|
||||
).strip()
|
||||
_streamed_tool_markup = ""
|
||||
if not _visible_markup_tail:
|
||||
continue
|
||||
_delta_text = _visible_markup_tail
|
||||
data["delta"] = _delta_text
|
||||
if _streamed_tool_markup_starts(_streamed_tool_markup):
|
||||
continue
|
||||
# False alarm: a bare "<" (e.g. ``x < 2``) looked like
|
||||
# a tag prefix, but the buffer can no longer become a
|
||||
# tool tag. Release it as prose instead of holding the
|
||||
# rest of the answer forever.
|
||||
_delta_text = _streamed_tool_markup
|
||||
_streamed_tool_markup = ""
|
||||
data["delta"] = _delta_text
|
||||
else:
|
||||
_visible_markup_tail = strip_tool_blocks(
|
||||
_streamed_tool_markup,
|
||||
skip_fenced=True,
|
||||
).strip()
|
||||
_streamed_tool_markup = ""
|
||||
if not _visible_markup_tail:
|
||||
continue
|
||||
_delta_text = _visible_markup_tail
|
||||
data["delta"] = _delta_text
|
||||
full_response += _delta_text
|
||||
if not _suppress_unavailable_web_delta:
|
||||
_qwen_buffered_route = bool(
|
||||
|
||||
@@ -371,6 +371,101 @@ def interpret_request(request_text, *, history=(), workspace=None, active_docume
|
||||
return SemanticIntent(families, selected, required_read_operation_for_request(request_text, history))
|
||||
|
||||
|
||||
|
||||
def _trusted_local_mcp_request_tools(request_text: str, intent: SemanticIntent) -> set[str]:
|
||||
"""Seal local MCP operations from operator declarations and trusted request intent.
|
||||
|
||||
Discovery validates an operator-declared tool but cannot grant it. For the
|
||||
notes family, only an explicit request for local notes admits matching
|
||||
read operations and explicitly requested non-destructive write verbs. Other
|
||||
MCP domains require the user to name the qualified operation explicitly.
|
||||
"""
|
||||
from src.tool_capabilities import (
|
||||
LOCAL_MCP_READ_TOOLS_ENV, LOCAL_MCP_WRITE_TOOLS_ENV,
|
||||
_local_mcp_tools, _is_declared_local_mcp_read_tool,
|
||||
_is_declared_local_mcp_write_tool, capabilities_for_tool, ToolEffect,
|
||||
)
|
||||
from src.tool_utils import get_mcp_manager
|
||||
|
||||
manager = get_mcp_manager()
|
||||
if manager is None:
|
||||
return set()
|
||||
|
||||
request = request_text.casefold()
|
||||
local_notes = (
|
||||
intent.selected_tools is None
|
||||
and intent.required_read is None
|
||||
and "notes" in intent.capabilities
|
||||
and re.search(r"\b(?:local|on my computer|on this machine)\b", request)
|
||||
)
|
||||
write_verbs = {
|
||||
"create": r"\b(?:create|add|make)\b",
|
||||
"add": r"\b(?:add|create)\b",
|
||||
"write": r"\bwrite\b",
|
||||
"save": r"\bsave\b",
|
||||
"update": r"\b(?:update|edit|modify|change)\b",
|
||||
"edit": r"\b(?:edit|update|modify|change)\b",
|
||||
"append": r"\bappend\b",
|
||||
"modify": r"\b(?:modify|edit|update|change)\b",
|
||||
}
|
||||
negative_write = re.search(
|
||||
r"\b(?:do not|don't|never|without|no)\s+(?:\w+\s+){0,2}"
|
||||
r"(?:create|add|make|write|save|update|edit|append|modify)\b",
|
||||
request,
|
||||
)
|
||||
declared = _local_mcp_tools(LOCAL_MCP_READ_TOOLS_ENV) | _local_mcp_tools(LOCAL_MCP_WRITE_TOOLS_ENV)
|
||||
admitted = set()
|
||||
for name in sorted(declared):
|
||||
parts = name.split("__", 2)
|
||||
if len(parts) != 3 or parts[0] != "mcp":
|
||||
continue
|
||||
_, server, bare = parts
|
||||
if manager.is_builtin(server):
|
||||
continue
|
||||
conn = manager.get_server_status(server)
|
||||
if conn.get("status") != "connected" or conn.get("transport") != "stdio":
|
||||
continue
|
||||
resource = manager.resource_identity(name)
|
||||
if resource is None or resource.namespace != "mcp" or resource.endpoint_id != f"stdio:{server}":
|
||||
continue
|
||||
rows = manager._tools.get(server, [])
|
||||
row = next((item for item in rows if item.get("name") == bare), None)
|
||||
if row is None:
|
||||
continue
|
||||
read_ok = _is_declared_local_mcp_read_tool(name)
|
||||
write_ok = _is_declared_local_mcp_write_tool(name)
|
||||
if not (read_ok or write_ok):
|
||||
continue
|
||||
caps = capabilities_for_tool(name)
|
||||
if not caps.known or caps.effects != frozenset({
|
||||
ToolEffect.READ_PRIVATE if read_ok else ToolEffect.WRITE_PRIVATE
|
||||
}):
|
||||
continue
|
||||
ann = row.get("annotations") or {}
|
||||
destructive = ann.get("destructiveHint") if isinstance(ann, dict) else getattr(ann, "destructiveHint", None)
|
||||
if destructive is True:
|
||||
continue
|
||||
if write_ok and re.match(r"^(?:delete|remove|erase|destroy|drop|purge)(?:_|$)", bare, re.I):
|
||||
continue
|
||||
|
||||
# Exact qualified names in the trusted request permit any configured
|
||||
# and validated local operation. Model/tool content is not an input.
|
||||
explicitly_named = bool(re.search(
|
||||
r"(?<![A-Za-z0-9_])" + re.escape(name.casefold()) + r"(?![A-Za-z0-9_])",
|
||||
request,
|
||||
))
|
||||
if not explicitly_named:
|
||||
if not local_notes or not re.search(r"(?:^|_)notes?(?:_|$)", bare, re.I):
|
||||
continue
|
||||
if write_ok:
|
||||
action = bare.split("_", 1)[0].casefold()
|
||||
if action not in write_verbs or not re.search(write_verbs[action], request):
|
||||
continue
|
||||
if negative_write:
|
||||
continue
|
||||
admitted.add(name)
|
||||
return admitted
|
||||
|
||||
def create_request_authority(request_text, *, owner=None, session_id=None, workspace=None,
|
||||
history=(), policy=None, active_document=False,
|
||||
image_attachment=False, capabilities=None, client_runtime_context=None):
|
||||
@@ -393,6 +488,7 @@ def create_request_authority(request_text, *, owner=None, session_id=None, works
|
||||
tools = {canonical_tool(operation.tool)}
|
||||
else:
|
||||
operation = None
|
||||
tools.update(_trusted_local_mcp_request_tools(request_text, intent))
|
||||
grants = []
|
||||
for name in sorted(tools | {"ask_user", "update_plan"}):
|
||||
name = canonical_tool(name)
|
||||
|
||||
@@ -47,6 +47,7 @@ EFFECTS_DIR = os.path.join(DATA_DIR, "effects")
|
||||
_RUN_ID = re.compile(r"[a-f0-9]{32}")
|
||||
_TYPES = {"claim": EffectClaim, "outcome": EffectOutcome, "observation": Observation}
|
||||
_VERSION = 1
|
||||
_O_BINARY = getattr(os, "O_BINARY", 0) # Windows: raw os.open() is text-mode without it
|
||||
|
||||
|
||||
def _fsync_directory(directory: str | os.PathLike) -> None:
|
||||
@@ -243,7 +244,7 @@ class EffectLog:
|
||||
"""Hold the file exclusively with every durable record merged."""
|
||||
assert self.path is not None
|
||||
_ensure_directory(self.path.parent)
|
||||
flags = os.O_RDWR | os.O_APPEND | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
|
||||
flags = os.O_RDWR | os.O_APPEND | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) | _O_BINARY
|
||||
descriptor = os.open(self.path, flags, 0o600)
|
||||
try:
|
||||
if fcntl is not None:
|
||||
@@ -358,7 +359,7 @@ class EffectLog:
|
||||
with self._lock:
|
||||
try:
|
||||
descriptor = os.open(self.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
||||
| getattr(os, "O_CLOEXEC", 0))
|
||||
| getattr(os, "O_CLOEXEC", 0) | _O_BINARY)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except OSError:
|
||||
@@ -394,7 +395,7 @@ class EffectLog:
|
||||
log = cls(run_id, directory=directory)
|
||||
assert log.path is not None
|
||||
try:
|
||||
descriptor = os.open(log.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0))
|
||||
descriptor = os.open(log.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) | _O_BINARY)
|
||||
except FileNotFoundError:
|
||||
return log
|
||||
except OSError as error:
|
||||
@@ -460,7 +461,7 @@ class EffectLog:
|
||||
temporary = target.with_suffix(".tmp")
|
||||
data = json.dumps({"run_id": self.run_id, "effect_id": effect_id}, sort_keys=True).encode()
|
||||
_ensure_directory(target.parent)
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) | _O_BINARY
|
||||
descriptor = os.open(temporary, flags, 0o600)
|
||||
try:
|
||||
view = memoryview(data)
|
||||
@@ -478,7 +479,7 @@ class EffectLog:
|
||||
return None
|
||||
base = Path(directory) if directory is not None else effects_dir()
|
||||
try:
|
||||
descriptor = os.open(base / f"launch-{generation}.json", os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
||||
descriptor = os.open(base / f"launch-{generation}.json", os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | _O_BINARY)
|
||||
with os.fdopen(descriptor, "rb") as stream:
|
||||
if os.fstat(stream.fileno()).st_nlink != 1:
|
||||
return None
|
||||
|
||||
@@ -4,7 +4,7 @@ import html
|
||||
import difflib
|
||||
import logging
|
||||
import re
|
||||
from src.constants import MAX_READ_CHARS
|
||||
from src.constants import MAX_OUTPUT_CHARS, MAX_READ_CHARS
|
||||
from src.tool_approvals import document_content_digest
|
||||
from src.tool_utils import _parse_tool_args, get_upload_handler
|
||||
from src.upload_handler import reserve_upload_references
|
||||
@@ -1175,14 +1175,23 @@ class ManageDocumentTool:
|
||||
except (TypeError, ValueError):
|
||||
offset = 0
|
||||
offset = min(offset, len(body))
|
||||
anchor = f"[{doc.title}](#document-{doc.id})"
|
||||
header = f"{anchor} — click to open in editor.\n\n```{doc.language or ''}\n"
|
||||
# Reserve the closing fence and the longest possible paging
|
||||
# notice before selecting the page. The common output cap must
|
||||
# never hide content that next_offset claims was delivered.
|
||||
notice = f"\n... (truncated, {len(body)} chars total; next_offset={len(body)})"
|
||||
page_budget = MAX_OUTPUT_CHARS - len(header) - len(notice) - len("\n```")
|
||||
if page_budget < 1:
|
||||
return {"error": "Document title or language is too long to display a read page", "exit_code": 1}
|
||||
preview_limit = min(preview_limit, page_budget)
|
||||
end = min(offset + preview_limit, len(body))
|
||||
truncated = end < len(body)
|
||||
preview = body[offset:end]
|
||||
if truncated:
|
||||
preview += f"\n... (truncated, {len(body)} chars total; next_offset={end})"
|
||||
anchor = f"[{doc.title}](#document-{doc.id})"
|
||||
return {
|
||||
"response": f"{anchor} — click to open in editor.\n\n```{doc.language or ''}\n{preview}\n```",
|
||||
"response": f"{header}{preview}\n```",
|
||||
"document": {
|
||||
"id": doc.id,
|
||||
"title": doc.title,
|
||||
|
||||
+37
-5
@@ -10,7 +10,7 @@ import os
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Tuple
|
||||
|
||||
from src.auth_helpers import owner_filter
|
||||
@@ -1957,6 +1957,23 @@ async def action_learn_sender_signatures(owner: str, **kwargs) -> Tuple[str, boo
|
||||
return str(e), False
|
||||
|
||||
|
||||
def _daily_brief_utc_boundary(local_dt, tz=None):
|
||||
"""Convert a local wall-clock boundary using the offset at that boundary."""
|
||||
if local_dt.tzinfo is None and tz is not None:
|
||||
local_dt = local_dt.replace(tzinfo=tz)
|
||||
return local_dt.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _daily_brief_local_start(event, tz=None):
|
||||
"""Keep date-only/floating rows local; convert only stored UTC instants."""
|
||||
start = event.dtstart
|
||||
if event.is_utc and not event.all_day:
|
||||
if start.tzinfo is None:
|
||||
start = start.replace(tzinfo=timezone.utc)
|
||||
return start.astimezone(tz).replace(tzinfo=None)
|
||||
return start
|
||||
|
||||
|
||||
async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
"""Build a short morning digest: today's calendar events, unread email count
|
||||
+ top-N senders/subjects, active todos."""
|
||||
@@ -1966,10 +1983,13 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
|
||||
from core.database import SessionLocal, CalendarEvent, CalendarCal, Note
|
||||
from routes.email_helpers import _imap_connect, _decode_header
|
||||
from sqlalchemy import and_, or_
|
||||
|
||||
# ----- Calendar: today's events -----
|
||||
today = _dt.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
tomorrow = today + _td(days=1)
|
||||
utc_today = _daily_brief_utc_boundary(today)
|
||||
utc_tomorrow = _daily_brief_utc_boundary(tomorrow)
|
||||
# v2 review HIGH-12: gate the OR-null branch on single-user
|
||||
# (unconfigured) deploys only. In a multi-user deploy, one
|
||||
# user's daily brief must not include another user's notes or
|
||||
@@ -1982,13 +2002,25 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ev_q = db.query(CalendarEvent).join(CalendarCal).filter(
|
||||
CalendarEvent.dtstart < tomorrow,
|
||||
CalendarEvent.dtend > today,
|
||||
or_(
|
||||
and_(
|
||||
CalendarEvent.is_utc.is_(True),
|
||||
CalendarEvent.all_day.is_not(True),
|
||||
CalendarEvent.dtstart < utc_tomorrow,
|
||||
CalendarEvent.dtend > utc_today,
|
||||
),
|
||||
and_(
|
||||
or_(CalendarEvent.is_utc.is_not(True), CalendarEvent.all_day.is_(True)),
|
||||
CalendarEvent.dtstart < tomorrow,
|
||||
CalendarEvent.dtend > today,
|
||||
),
|
||||
),
|
||||
CalendarEvent.status != "cancelled",
|
||||
)
|
||||
if owner:
|
||||
ev_q = owner_filter(ev_q, CalendarCal, owner, include_shared=_allow_null)
|
||||
events = ev_q.order_by(CalendarEvent.dtstart).all()
|
||||
events = ev_q.all()
|
||||
events.sort(key=_daily_brief_local_start)
|
||||
# ----- Notes: pinned + non-archived todos with at least one undone item -----
|
||||
n_q = db.query(Note).filter(Note.archived == False) # noqa: E712
|
||||
if owner:
|
||||
@@ -2058,7 +2090,7 @@ async def action_daily_brief(owner: str, **kwargs) -> Tuple[str, bool]:
|
||||
if events:
|
||||
plain.append("Calendar:")
|
||||
for e in events:
|
||||
t = e.dtstart.strftime("%H:%M") if not e.all_day else "all day"
|
||||
t = _daily_brief_local_start(e).strftime("%H:%M") if not e.all_day else "all day"
|
||||
loc = f" @ {e.location}" if e.location else ""
|
||||
plain.append(f" {t} {e.summary}{loc}")
|
||||
plain.append("")
|
||||
|
||||
+7
-1
@@ -282,7 +282,13 @@ class ChatHandler:
|
||||
_sync_upload_vision_to_gallery(file_info, owner, vl_desc)
|
||||
except Exception:
|
||||
pass
|
||||
enhanced_message = f"{enhanced_message}\n\n[Image: {file_info['name']}]\n{vl_desc}"
|
||||
enhanced_message = f"{enhanced_message}\n\n[Image: {file_info['name']}]\n"
|
||||
if vl_desc:
|
||||
# A separate vision call wrote this, but the chat model sees
|
||||
# one message and answers the prose as if the user wrote it
|
||||
# (#5573). Label it below the marker line — the client strips
|
||||
# the block by matching `[Image: <name>]\n`.
|
||||
enhanced_message += f"[Image description — context only, not part of the user's message]:\n{vl_desc}"
|
||||
# Surface the description to the client live so it renders as a
|
||||
# collapsible "image description" on the user bubble (not just
|
||||
# after a refresh that re-parses the stored message).
|
||||
|
||||
@@ -175,6 +175,7 @@ AGENT_ISOLATED_TMP_DIRNAME = ".tmp"
|
||||
|
||||
# Auth policy
|
||||
PASSWORD_MIN_LENGTH = 8
|
||||
USERNAME_MAX_LENGTH = 64
|
||||
|
||||
# Default parameters
|
||||
DEFAULT_TEMPERATURE = 1.0
|
||||
|
||||
@@ -31,63 +31,43 @@ def _internal_headers() -> dict:
|
||||
|
||||
|
||||
async def _delete_endpoint_for_task(task: dict) -> None:
|
||||
"""Drop the auto-registered model endpoint for a scheduled-stop serve.
|
||||
"""Remove a stopped serve's endpoint using the normal deletion contract."""
|
||||
import re
|
||||
from urllib.parse import quote
|
||||
|
||||
Without this, killing the tmux session leaves the endpoint sitting in
|
||||
the picker (probe goes offline; chats still try to route there) and
|
||||
the user has to delete it by hand in Settings -> Endpoints.
|
||||
"""
|
||||
endpoint_id = (task.get("_endpointId") or task.get("endpointId") or "").strip()
|
||||
if not endpoint_id:
|
||||
logger.info(
|
||||
"cookbook_serve_lifecycle: task %s has no endpoint id; skipping endpoint deletion",
|
||||
task.get("sessionId") or task.get("id") or "",
|
||||
)
|
||||
endpoint_id = str(task.get("_endpointId") or task.get("endpointId") or "").strip()
|
||||
serve_session_id = str(task.get("sessionId") or task.get("id") or "").strip()
|
||||
if (
|
||||
not re.fullmatch(r"(?:local|img)-[0-9a-f]{8}", endpoint_id)
|
||||
or not serve_session_id
|
||||
):
|
||||
logger.info("cookbook_serve_lifecycle: missing endpoint/session identity")
|
||||
return
|
||||
import re as _re
|
||||
payload = task.get("payload") or {}
|
||||
cmd = str(payload.get("_cmd") or "")
|
||||
remote = task.get("remoteHost") or ""
|
||||
# Build host the same way _auto_register_llm_endpoint does so URL match wins.
|
||||
if remote:
|
||||
host = remote.split("@")[-1] if "@" in remote else remote
|
||||
else:
|
||||
host = "host.docker.internal"
|
||||
port_match = _re.search(r"--port\s+(\d+)", cmd)
|
||||
ollama_host_match = _re.search(r"OLLAMA_HOST=[^\s]*?:(\d+)", cmd)
|
||||
if port_match:
|
||||
port = int(port_match.group(1))
|
||||
elif ollama_host_match:
|
||||
port = int(ollama_host_match.group(1))
|
||||
elif "ollama" in cmd:
|
||||
port = 11434
|
||||
else:
|
||||
port = 8080
|
||||
base_url = f"http://{host}:{port}/v1"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=8) as client:
|
||||
r = await client.get(
|
||||
f"{internal_api_base()}/api/model-endpoints",
|
||||
response = await client.delete(
|
||||
f"{internal_api_base()}/api/model-endpoints/{quote(endpoint_id, safe='')}",
|
||||
params={"expected_cookbook_serve_session_id": serve_session_id},
|
||||
headers=_internal_headers(),
|
||||
)
|
||||
if r.status_code >= 400:
|
||||
return
|
||||
eps = r.json() if r.content else []
|
||||
# Delete only the endpoint created by this scheduled serve. URL
|
||||
# matching is unsafe because a later scheduled serve can reuse the
|
||||
# same host:port after an older task has gone stale.
|
||||
ep = next((e for e in eps if e.get("id") == endpoint_id), None)
|
||||
if ep:
|
||||
await client.delete(
|
||||
f"{internal_api_base()}/api/model-endpoints/{ep['id']}",
|
||||
headers=_internal_headers(),
|
||||
)
|
||||
logger.info(
|
||||
f"cookbook_serve_lifecycle: deleted endpoint {ep.get('id')} "
|
||||
f"({ep.get('base_url')}) after scheduled stop"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"cookbook_serve_lifecycle: endpoint delete failed: {e}")
|
||||
if response.status_code == 200:
|
||||
logger.info(
|
||||
"cookbook_serve_lifecycle: deleted endpoint %s for serve %s",
|
||||
endpoint_id, serve_session_id,
|
||||
)
|
||||
elif response.status_code in (404, 409):
|
||||
logger.info(
|
||||
"cookbook_serve_lifecycle: endpoint %s absent or reassigned; keeping it",
|
||||
endpoint_id,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"cookbook_serve_lifecycle: endpoint %s deletion returned HTTP %s",
|
||||
endpoint_id, response.status_code,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("cookbook_serve_lifecycle: cleanup failed: %s", exc)
|
||||
|
||||
|
||||
async def _stop_serve(session_id: str, remote_host: str = "", ssh_port: str = "") -> bool:
|
||||
|
||||
@@ -142,7 +142,10 @@ def _process_pdf(
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||
temp_img_path = tmp.name
|
||||
try:
|
||||
img.image.save(temp_img_path, "PNG") # pypdf -> PIL image
|
||||
image = img.image
|
||||
if image.mode == "CMYK":
|
||||
image = image.convert("RGB")
|
||||
image.save(temp_img_path, "PNG")
|
||||
ocr_text = analyze_image_with_vl(temp_img_path, owner=owner)
|
||||
if ocr_text and "unavailable" not in ocr_text.lower():
|
||||
pdf_text += f"\n\n[Page {page_num + 1} image {img_index + 1} text]: {ocr_text}"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Shared MIME text decoding for the web email helpers and MCP server."""
|
||||
|
||||
|
||||
def decode_text_part(part) -> str:
|
||||
payload = part.get_payload(decode=True)
|
||||
if not payload:
|
||||
return ""
|
||||
charset = part.get_content_charset() or "utf-8"
|
||||
try:
|
||||
return payload.decode(charset, errors="replace")
|
||||
except (LookupError, ValueError):
|
||||
# Broken senders can label valid UTF-8 with an unsupported codec.
|
||||
return payload.decode("utf-8", errors="replace")
|
||||
+4
-1
@@ -770,8 +770,11 @@ class McpManager:
|
||||
qualified.add(f"mcp__{server_id}__{tool['name']}")
|
||||
return disabled_map, qualified
|
||||
|
||||
def is_builtin(self, server_id: str) -> bool:
|
||||
@staticmethod
|
||||
def is_builtin(server_id: str) -> bool:
|
||||
"""Check if a server is a built-in (auto-registered) server."""
|
||||
if not isinstance(server_id, str):
|
||||
return False
|
||||
return server_id.startswith("builtin_") or server_id in {
|
||||
"image_gen",
|
||||
"memory",
|
||||
|
||||
+15
-26
@@ -364,32 +364,32 @@ class MemoryManager:
|
||||
return []
|
||||
|
||||
# Define keyword categories for semantic matching
|
||||
identity_words = ["name", "who", "i", "am", "called", "identity", "myself", "me", "my"]
|
||||
identity_words = ["name", "called", "identity", "myself"]
|
||||
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()
|
||||
query_words = set(re.findall(r'\w+', query_lower))
|
||||
query_tokens = set(tokenize(query_lower))
|
||||
|
||||
# Determine query type based on keywords
|
||||
query_type = None
|
||||
if any(word in query_lower for word in identity_words):
|
||||
if query_words.intersection(identity_words) or re.search(r'\bwho\s+am\s+i\b', query_lower):
|
||||
query_type = "identity"
|
||||
elif any(word in query_lower for word in contact_words):
|
||||
elif query_words.intersection(contact_words):
|
||||
query_type = "contact"
|
||||
elif any(word in query_lower for word in preference_words):
|
||||
elif query_words.intersection(preference_words):
|
||||
query_type = "preference"
|
||||
elif any(word in query_lower for word in task_words):
|
||||
elif query_words.intersection(task_words):
|
||||
query_type = "task"
|
||||
elif any(word in query_lower for word in fact_words):
|
||||
elif query_words.intersection(fact_words):
|
||||
query_type = "fact"
|
||||
|
||||
relevant = []
|
||||
identity_memories = []
|
||||
other_memories = []
|
||||
|
||||
# Separate identity memories from others
|
||||
# Every memory participates in the same ranking and threshold check.
|
||||
# Query type is a scoring hint, not an unconditional insertion path.
|
||||
for memory in memories:
|
||||
memory_text = memory["text"].lower()
|
||||
# Check if this is an identity memory (contains name patterns or identity indicators)
|
||||
@@ -397,22 +397,7 @@ class MemoryManager:
|
||||
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:
|
||||
@@ -422,7 +407,11 @@ class MemoryManager:
|
||||
final_score = base_similarity
|
||||
|
||||
# Apply boosts based on semantic matching
|
||||
if query_type == "contact":
|
||||
if query_type == "identity" and is_identity:
|
||||
# Keep explicit name/identity recall useful without outranking
|
||||
# a closer textual match or bypassing the caller's threshold.
|
||||
final_score = max(final_score * 1.3, 0.6)
|
||||
elif 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",
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Preserve report TeX for markdown.js's existing deferred KaTeX renderer."""
|
||||
import re
|
||||
from html.parser import HTMLParser
|
||||
from xml.etree import ElementTree
|
||||
|
||||
from markdown.blockprocessors import BlockProcessor
|
||||
from markdown.extensions import Extension
|
||||
from markdown.inlinepatterns import InlineProcessor
|
||||
from markdown.util import AtomicString
|
||||
|
||||
|
||||
# Keep delimiter and currency rules aligned with mathBlocks in markdown.js.
|
||||
_ESCAPED_MATH = r"\\\[([\s\S]*?)\\\]|\\\(([^\n]*?)\\\)"
|
||||
_DOLLAR_MATH = (
|
||||
r"\$\$([\s\S]*?)\$\$|"
|
||||
r"(?<![\$\d])\$(?!\$)(?=\S)([^\$\n]+?)(?<=\S)\$(?!\$|\d)"
|
||||
)
|
||||
|
||||
|
||||
def _placeholder(tex, display):
|
||||
node = ElementTree.Element("span", {
|
||||
"class": "ody-math-pending", "data-display": str(display).lower(),
|
||||
})
|
||||
node.text = AtomicString(tex)
|
||||
return node
|
||||
|
||||
|
||||
class _HtmlContext(HTMLParser):
|
||||
"""Inline HTML is still raw when math runs, including tag attributes."""
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.protected = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag in {"code", "pre", "script", "style", "textarea"}:
|
||||
self.protected.append(tag)
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag in self.protected:
|
||||
del self.protected[self.protected.index(tag):]
|
||||
|
||||
|
||||
class _InlineMath(InlineProcessor):
|
||||
def handleMatch(self, match, data):
|
||||
prefix = data[:match.start()]
|
||||
if "<" in prefix:
|
||||
context = _HtmlContext()
|
||||
context.feed(prefix)
|
||||
if context.protected or re.match(r"<(?:/?[A-Za-z]|[!?])", context.rawdata.lstrip()):
|
||||
return None, None, None
|
||||
# Backticks run before this processor. Preserve a backslash-escaped
|
||||
# dollar too; math must run before Markdown consumes \( and \[.
|
||||
if prefix.endswith("\\") and (len(prefix) - len(prefix.rstrip("\\"))) % 2:
|
||||
return None, None, None
|
||||
tex = next(group for group in match.groups() if group is not None)
|
||||
display = match.group(1) is not None
|
||||
return _placeholder(tex, display), match.start(), match.end()
|
||||
|
||||
|
||||
class _DisplayMath(BlockProcessor):
|
||||
def test(self, parent, block):
|
||||
return re.match(r"^ {0,3}(\$\$|\\\[)", block) is not None
|
||||
|
||||
def run(self, parent, blocks):
|
||||
opening = re.match(r"^ {0,3}(\$\$|\\\[)", blocks[0])
|
||||
closing = "$$" if opening.group(1) == "$$" else "\\]"
|
||||
source = blocks[0][opening.end():]
|
||||
consumed = 1
|
||||
while closing not in source:
|
||||
if consumed == len(blocks):
|
||||
return False # Leave incomplete input to ordinary Markdown.
|
||||
source += "\n\n" + blocks[consumed]
|
||||
consumed += 1
|
||||
tex, _, tail = source.partition(closing)
|
||||
del blocks[:consumed]
|
||||
if tail.strip():
|
||||
blocks.insert(0, tail)
|
||||
parent.append(_placeholder(tex, True))
|
||||
|
||||
|
||||
class ReportMathExtension(Extension):
|
||||
def extendMarkdown(self, md):
|
||||
# Backticks (190) and indented code (80) retain precedence.
|
||||
md.inlinePatterns.register(_InlineMath(_ESCAPED_MATH, md), "report_math_escaped", 185)
|
||||
# Parse links before dollar math so URL destinations stay literal.
|
||||
md.inlinePatterns.register(_InlineMath(_DOLLAR_MATH, md), "report_math_dollar", 155)
|
||||
md.parser.blockprocessors.register(_DisplayMath(md.parser), "report_math", 79)
|
||||
+63
-8
@@ -76,9 +76,23 @@ _ERROR_DETAIL = {
|
||||
"auth_or_protocol_error": "authentication or protocol error",
|
||||
"no_models": "endpoint returned no models",
|
||||
"no_host": "no host configured",
|
||||
"config_source_error": "configuration source could not be read",
|
||||
"error": "probe failed",
|
||||
}
|
||||
|
||||
# Config/inventory source behind each network subsystem. `_gather_inputs()`
|
||||
# fails soft, so a source that could not be read hands its probe an empty
|
||||
# inventory — indistinguishable from "nothing configured", which every probe
|
||||
# answers with `disabled`. The collector therefore reports the source failure
|
||||
# instead of running a probe whose verdict would be a lie.
|
||||
CONFIG_SOURCE_ERROR = "config_source_error"
|
||||
_SUBSYSTEM_SOURCE = {
|
||||
"searxng": "settings",
|
||||
"ntfy": "integrations",
|
||||
"email": "accounts",
|
||||
"providers": "endpoints",
|
||||
}
|
||||
|
||||
|
||||
def _svc(name: str, status: str, detail: str, **meta: Any) -> Dict[str, Any]:
|
||||
return {"name": name, "status": status, "detail": detail, "meta": dict(meta)}
|
||||
@@ -411,26 +425,33 @@ def _gather_inputs() -> Dict[str, Any]:
|
||||
"""Pull live config/account/endpoint lists from the app's data sources.
|
||||
|
||||
Each lookup fails soft: a broken source yields an empty/neutral value so a
|
||||
single failure can't take down the whole health report.
|
||||
single failure can't take down the whole health report. The failure is also
|
||||
*recorded* under `failed` (source -> controlled category), because the empty
|
||||
value alone cannot be told apart from a source that loaded and is genuinely
|
||||
empty — and the probes read empty as `disabled`.
|
||||
"""
|
||||
settings: Dict[str, Any] = {}
|
||||
integrations: List[Dict[str, Any]] = []
|
||||
accounts: List[Dict[str, Any]] = []
|
||||
endpoints: List[Dict[str, Any]] = []
|
||||
failed: Dict[str, str] = {}
|
||||
try:
|
||||
from src.settings import load_settings
|
||||
settings = load_settings() or {}
|
||||
except Exception as e:
|
||||
failed["settings"] = _classify_error(e)
|
||||
logger.debug(f"service_health: settings load failed: {e}")
|
||||
try:
|
||||
from src.integrations import load_integrations
|
||||
integrations = load_integrations() or []
|
||||
except Exception as e:
|
||||
failed["integrations"] = _classify_error(e)
|
||||
logger.debug(f"service_health: integrations load failed: {e}")
|
||||
try:
|
||||
from routes.email_helpers import _list_email_accounts
|
||||
accounts = _list_email_accounts() or []
|
||||
except Exception as e:
|
||||
failed["accounts"] = _classify_error(e)
|
||||
logger.debug(f"service_health: email accounts load failed: {e}")
|
||||
try:
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
@@ -443,9 +464,10 @@ def _gather_inputs() -> Dict[str, Any]:
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
failed["endpoints"] = _classify_error(e)
|
||||
logger.debug(f"service_health: endpoint load failed: {e}")
|
||||
return {"settings": settings, "integrations": integrations,
|
||||
"accounts": accounts, "endpoints": endpoints}
|
||||
"accounts": accounts, "endpoints": endpoints, "failed": failed}
|
||||
|
||||
|
||||
async def _run_subsystem(name: str, fn: Callable, *args: Any) -> Dict[str, Any]:
|
||||
@@ -464,6 +486,24 @@ async def _run_subsystem(name: str, fn: Callable, *args: Any) -> Dict[str, Any]:
|
||||
return _svc(name, DOWN, _detail_for(category), error=category)
|
||||
|
||||
|
||||
async def _source_unavailable(name: str, source: str,
|
||||
category: str) -> Dict[str, Any]:
|
||||
"""Controlled entry for a subsystem whose config source could not be read.
|
||||
|
||||
Deliberately not `disabled`: the configured state is *unknown*, not known to
|
||||
be off. `degraded` rather than `down` because the failure is in reading the
|
||||
inventory, not a probe that reached the subsystem and failed — but it still
|
||||
carries non-zero severity, so a report containing one can never roll up to
|
||||
`ok`. `meta` names the source and the controlled category of the underlying
|
||||
exception; never its text.
|
||||
"""
|
||||
return _svc(name, DEGRADED,
|
||||
f"Could not read configuration "
|
||||
f"({_detail_for(CONFIG_SOURCE_ERROR)}).",
|
||||
error=CONFIG_SOURCE_ERROR, source=source,
|
||||
source_error=category)
|
||||
|
||||
|
||||
async def collect_service_health(rag_manager: Any = None,
|
||||
memory_vector: Any = None) -> Dict[str, Any]:
|
||||
"""Run every probe and return {overall, services, timestamp}.
|
||||
@@ -472,6 +512,10 @@ async def collect_service_health(rag_manager: Any = None,
|
||||
four network subsystems run concurrently, each under `_SUBSYSTEM_DEADLINE`,
|
||||
with an overall `_AGGREGATE_DEADLINE` backstop. Per-item probes inside
|
||||
providers/email are themselves bounded by `_FANOUT_BUDGET`.
|
||||
|
||||
A subsystem whose config/inventory source failed to load is reported via
|
||||
`_source_unavailable()` instead of being probed with an empty inventory,
|
||||
which every probe would otherwise report as `disabled`.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -482,12 +526,23 @@ async def collect_service_health(rag_manager: Any = None,
|
||||
chroma = chromadb_health(rag_manager, memory_vector)
|
||||
|
||||
names = ["searxng", "ntfy", "email", "providers"]
|
||||
coros = [
|
||||
_run_subsystem("searxng", searxng_health, settings),
|
||||
_run_subsystem("ntfy", ntfy_health, inputs["integrations"], settings),
|
||||
_run_subsystem("email", email_health, inputs["accounts"]),
|
||||
_run_subsystem("providers", providers_health, inputs["endpoints"]),
|
||||
]
|
||||
probes = {
|
||||
"searxng": (searxng_health, (settings,)),
|
||||
"ntfy": (ntfy_health, (inputs["integrations"], settings)),
|
||||
"email": (email_health, (inputs["accounts"],)),
|
||||
"providers": (providers_health, (inputs["endpoints"],)),
|
||||
}
|
||||
# A subsystem whose inventory could not be read is reported as such. The
|
||||
# others are still probed normally — one broken source degrades one entry.
|
||||
failed = inputs.get("failed") or {}
|
||||
coros = []
|
||||
for name in names:
|
||||
source = _SUBSYSTEM_SOURCE[name]
|
||||
if source in failed:
|
||||
coros.append(_source_unavailable(name, source, failed[source]))
|
||||
else:
|
||||
fn, args = probes[name]
|
||||
coros.append(_run_subsystem(name, fn, *args))
|
||||
try:
|
||||
results = await asyncio.wait_for(asyncio.gather(*coros),
|
||||
timeout=_AGGREGATE_DEADLINE)
|
||||
|
||||
+33
-5
@@ -57,25 +57,53 @@ def _message_to_context(msg: DBChatMessage) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _escape_like(value: str) -> str:
|
||||
def escape_like_pattern(value: str) -> str:
|
||||
"""Escape SQL LIKE wildcards (``%``, ``_``) and the escape char itself.
|
||||
|
||||
Public shared helper for building ``ilike(f"%{...}%", escape="\\\\")``
|
||||
patterns from user input. Must escape backslash first.
|
||||
"""
|
||||
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
|
||||
def _escape_like(value: str) -> str:
|
||||
return escape_like_pattern(value)
|
||||
|
||||
|
||||
def _snippet(content: str, query: str, radius: int = 60) -> str:
|
||||
content = content or ""
|
||||
query = query or ""
|
||||
if not query:
|
||||
return content[: radius * 2]
|
||||
|
||||
idx = content.lower().find(query.lower())
|
||||
if idx == -1:
|
||||
match_start = -1
|
||||
match_length = 0
|
||||
lowered_content = content.lower()
|
||||
for candidate in _query_terms(query):
|
||||
idx = lowered_content.find(candidate.lower())
|
||||
if idx != -1 and (match_start == -1 or idx < match_start):
|
||||
match_start = idx
|
||||
match_length = len(candidate)
|
||||
|
||||
if match_start == -1:
|
||||
return content[: radius * 2]
|
||||
|
||||
start = max(0, idx - radius)
|
||||
end = min(len(content), idx + len(query) + radius)
|
||||
start = max(0, match_start - radius)
|
||||
end = min(len(content), match_start + match_length + radius)
|
||||
return ("..." if start > 0 else "") + content[start:end] + ("..." if end < len(content) else "")
|
||||
|
||||
|
||||
def _query_terms(query: str) -> list[str]:
|
||||
"""Return meaningful quoted phrases and tokens in user-entered order."""
|
||||
terms: list[str] = []
|
||||
for match in re.finditer(r'"([^"]+)"|[\w][\w._-]*', query, flags=re.UNICODE):
|
||||
term = match.group(1) if match.group(1) is not None else match.group(0).strip("._-")
|
||||
term = term.strip()
|
||||
if term:
|
||||
terms.append(term)
|
||||
return terms
|
||||
|
||||
|
||||
def _sanitize_fts_query(query: str) -> str | None:
|
||||
"""Convert free text into a conservative FTS5 MATCH query.
|
||||
|
||||
|
||||
@@ -120,6 +120,7 @@ DEFAULT_SETTINGS = {
|
||||
# Tune via Settings or by editing data/settings.json.
|
||||
"research_run_timeout_seconds": 1800,
|
||||
"agent_max_tool_calls": 0,
|
||||
"tool_approval_mode": "ask",
|
||||
"agent_max_rounds": 20, # per-message agent step cap (clamped 1..200)
|
||||
# Soft input-token budget for the agent loop. The DEFAULT value (6000) is the
|
||||
# "auto" sentinel: it means "scale the budget to the model's context window"
|
||||
|
||||
@@ -334,6 +334,142 @@ _BROWSER_MCP_READ_TOOLS = frozenset(
|
||||
)
|
||||
|
||||
|
||||
LOCAL_MCP_READ_TOOLS_ENV = "ODYSSEUS_LOCAL_MCP_READ_TOOLS"
|
||||
LOCAL_MCP_WRITE_TOOLS_ENV = "ODYSSEUS_LOCAL_MCP_WRITE_TOOLS"
|
||||
|
||||
_LOCAL_MCP_READ_CAPABILITIES = _capabilities(
|
||||
ToolEffect.READ_PRIVATE,
|
||||
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||
)
|
||||
_LOCAL_MCP_WRITE_CAPABILITIES = _capabilities(
|
||||
ToolEffect.WRITE_PRIVATE,
|
||||
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||
)
|
||||
|
||||
|
||||
def _local_mcp_tools(variable: str) -> frozenset[str]:
|
||||
"""Tool names a deployment declares as touching only the local store.
|
||||
|
||||
Tools served by a user-configured MCP server are absent from the registry
|
||||
above, so they are classified as unknown and fail high. For a server with
|
||||
both a read and a write tool that is unusable: the read result arms the
|
||||
post-external gate, because an unknown result is not SYSTEM integrity, and
|
||||
the write that follows is refused until separately authorized. Every
|
||||
second call needs an approval, for a server that never leaves the host.
|
||||
|
||||
Declaring the tools resolves that without widening the gate:
|
||||
|
||||
ODYSSEUS_LOCAL_MCP_READ_TOOLS=mcp__<server>__search_notes,mcp__<server>__read_note
|
||||
ODYSSEUS_LOCAL_MCP_WRITE_TOOLS=mcp__<server>__create_note
|
||||
|
||||
Read tools become READ_PRIVATE with EXTERNAL_UNTRUSTED integrity. Their
|
||||
results remain untrusted and still arm the gate, preventing prompt
|
||||
injections from reaching shell, network, or unrelated tools. However,
|
||||
declared write tools on the exact same MCP server receive a narrowly
|
||||
scoped continuation exception as long as no external or error taint has
|
||||
entered the run. Once genuinely external content has entered the run,
|
||||
approvals remain required.
|
||||
"""
|
||||
raw = os.getenv(variable, "") or ""
|
||||
return frozenset(name.strip() for name in raw.split(",") if name.strip())
|
||||
|
||||
|
||||
def _current_mcp_manager():
|
||||
"""Retrieve the current McpManager instance safely without hard circular binds."""
|
||||
try:
|
||||
from src import tool_execution
|
||||
if hasattr(tool_execution, "get_mcp_manager"):
|
||||
mgr = tool_execution.get_mcp_manager()
|
||||
if mgr is not None:
|
||||
return mgr
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from src.tool_utils import get_mcp_manager
|
||||
return get_mcp_manager()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _is_declared_local_mcp_tool(
|
||||
tool_name: Any, server_id: str | None = None
|
||||
) -> bool:
|
||||
"""Return whether tool_name is an exact, well-formed declared local MCP tool."""
|
||||
if not isinstance(tool_name, str) or not tool_name:
|
||||
return False
|
||||
parts = tool_name.split("__", 2)
|
||||
if not (
|
||||
len(parts) == 3
|
||||
and parts[0] == "mcp"
|
||||
and bool(parts[1])
|
||||
and bool(parts[2])
|
||||
and not any(char.isspace() for part in parts[1:] for char in part)
|
||||
):
|
||||
return False
|
||||
sid = parts[1]
|
||||
if server_id is not None and sid != server_id:
|
||||
return False
|
||||
from src.mcp_manager import McpManager
|
||||
if McpManager.is_builtin(sid):
|
||||
return False
|
||||
if tool_name in TOOL_CAPABILITIES:
|
||||
return False
|
||||
declared_read = tool_name in _local_mcp_tools(LOCAL_MCP_READ_TOOLS_ENV)
|
||||
declared_write = tool_name in _local_mcp_tools(LOCAL_MCP_WRITE_TOOLS_ENV)
|
||||
return (declared_read or declared_write) and (declared_read != declared_write)
|
||||
|
||||
|
||||
def _is_declared_local_mcp_read_tool(
|
||||
tool_name: Any, server_id: str | None = None
|
||||
) -> bool:
|
||||
if not _is_declared_local_mcp_tool(tool_name, server_id):
|
||||
return False
|
||||
declared_read = tool_name in _local_mcp_tools(LOCAL_MCP_READ_TOOLS_ENV)
|
||||
declared_write = tool_name in _local_mcp_tools(LOCAL_MCP_WRITE_TOOLS_ENV)
|
||||
if not (declared_read and not declared_write):
|
||||
return False
|
||||
parts = tool_name.split("__", 2)
|
||||
mgr = _current_mcp_manager()
|
||||
if mgr is not None:
|
||||
tools = mgr._tools.get(parts[1], [])
|
||||
tool_row = next((r for r in tools if r.get("name") == parts[2]), None)
|
||||
if tool_row is not None:
|
||||
from src.mcp_manager import mcp_tool_is_readonly
|
||||
if not mcp_tool_is_readonly(tool_row):
|
||||
return False
|
||||
else:
|
||||
from src.mcp_manager import _MCP_READONLY_VERBS
|
||||
if not parts[2].lower().startswith(_MCP_READONLY_VERBS):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _is_declared_local_mcp_write_tool(
|
||||
tool_name: Any, server_id: str | None = None
|
||||
) -> bool:
|
||||
if not _is_declared_local_mcp_tool(tool_name, server_id):
|
||||
return False
|
||||
declared_read = tool_name in _local_mcp_tools(LOCAL_MCP_READ_TOOLS_ENV)
|
||||
declared_write = tool_name in _local_mcp_tools(LOCAL_MCP_WRITE_TOOLS_ENV)
|
||||
if not (declared_write and not declared_read):
|
||||
return False
|
||||
parts = tool_name.split("__", 2)
|
||||
mgr = _current_mcp_manager()
|
||||
if mgr is not None:
|
||||
tools = mgr._tools.get(parts[1], [])
|
||||
tool_row = next((r for r in tools if r.get("name") == parts[2]), None)
|
||||
if tool_row is not None:
|
||||
ann = tool_row.get("annotations") or {}
|
||||
destructive = (
|
||||
ann.get("destructiveHint")
|
||||
if isinstance(ann, dict)
|
||||
else getattr(ann, "destructiveHint", None)
|
||||
)
|
||||
if destructive is True:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def capabilities_for_tool(tool_name: Any) -> ToolCapabilities:
|
||||
"""Return deterministic capabilities; malformed and unknown tools fail high."""
|
||||
if not isinstance(tool_name, str) or not tool_name:
|
||||
@@ -348,6 +484,10 @@ def capabilities_for_tool(tool_name: Any) -> ToolCapabilities:
|
||||
return capabilities
|
||||
if tool_name in _BROWSER_MCP_READ_TOOLS:
|
||||
return _BROWSER_MCP_READ_CAPABILITIES
|
||||
if _is_declared_local_mcp_read_tool(tool_name):
|
||||
return _LOCAL_MCP_READ_CAPABILITIES
|
||||
if _is_declared_local_mcp_write_tool(tool_name):
|
||||
return _LOCAL_MCP_WRITE_CAPABILITIES
|
||||
return _UNKNOWN_CAPABILITIES
|
||||
|
||||
|
||||
@@ -785,6 +925,149 @@ def delegated_tool_is_blocked(tool_name: Any, content: Any = None) -> bool:
|
||||
)
|
||||
|
||||
|
||||
_SERVER_OWNED_CONTROL_PLANE_SOURCES = frozenset(
|
||||
{
|
||||
"skills",
|
||||
"client runtime context",
|
||||
"backend runtime context",
|
||||
"integrations",
|
||||
"mcp tools",
|
||||
"agents.md",
|
||||
}
|
||||
)
|
||||
|
||||
_PROHIBITED_CONTINUATION_EFFECTS = frozenset(
|
||||
{
|
||||
ToolEffect.NETWORK_EGRESS,
|
||||
ToolEffect.EXTERNAL_SIDE_EFFECT,
|
||||
ToolEffect.EXECUTE_CODE,
|
||||
ToolEffect.ADMIN_CHANGE,
|
||||
ToolEffect.UI_SIDE_EFFECT,
|
||||
ToolEffect.DESTRUCTIVE,
|
||||
ToolEffect.READ_WORKSPACE,
|
||||
ToolEffect.WRITE_WORKSPACE,
|
||||
ToolEffect.BROKERED_NETWORK_READ,
|
||||
ToolEffect.READ_PUBLIC,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_authorized_local_mcp_continuation(
|
||||
tool_name: Any,
|
||||
external_sources: Iterable[str],
|
||||
content: Any = None,
|
||||
) -> bool:
|
||||
"""Evaluate narrowly scoped continuation for declared local MCP tools.
|
||||
|
||||
Allows a declared local MCP write or read tool when the tool is verified
|
||||
against an explicitly configured and connected stdio MCP server, and the
|
||||
only untrusted sources observed in the run are declared local MCP tools
|
||||
belonging to the exact same MCP server (plus server-owned control plane
|
||||
metadata).
|
||||
"""
|
||||
if not isinstance(tool_name, str) or not tool_name:
|
||||
return False
|
||||
parts = tool_name.split("__", 2)
|
||||
if len(parts) != 3 or parts[0] != "mcp" or not parts[1] or not parts[2]:
|
||||
return False
|
||||
server_id = parts[1]
|
||||
|
||||
from src.mcp_manager import McpManager
|
||||
if McpManager.is_builtin(server_id):
|
||||
return False
|
||||
|
||||
if not _is_declared_local_mcp_tool(tool_name, server_id=server_id):
|
||||
return False
|
||||
|
||||
capabilities = capabilities_for_action(tool_name, content)
|
||||
if not capabilities.known:
|
||||
return False
|
||||
if capabilities.effects & _PROHIBITED_CONTINUATION_EFFECTS:
|
||||
return False
|
||||
if capabilities.effects - {ToolEffect.WRITE_PRIVATE, ToolEffect.READ_PRIVATE}:
|
||||
return False
|
||||
|
||||
manager = _current_mcp_manager()
|
||||
if manager is None:
|
||||
return False
|
||||
if manager.is_builtin(server_id):
|
||||
return False
|
||||
|
||||
conn = manager.get_server_status(server_id)
|
||||
if conn.get("status") != "connected":
|
||||
return False
|
||||
if conn.get("transport") != "stdio":
|
||||
return False
|
||||
|
||||
resource_id = manager.resource_identity(tool_name)
|
||||
if resource_id is None or resource_id.namespace != "mcp":
|
||||
return False
|
||||
if resource_id.endpoint_id != f"stdio:{server_id}":
|
||||
return False
|
||||
|
||||
tools_list = manager._tools.get(server_id, [])
|
||||
tool_row = next((r for r in tools_list if r.get("name") == parts[2]), None)
|
||||
if not tool_row:
|
||||
return False
|
||||
ann = tool_row.get("annotations") or {}
|
||||
destructive = (
|
||||
ann.get("destructiveHint")
|
||||
if isinstance(ann, dict)
|
||||
else getattr(ann, "destructiveHint", None)
|
||||
)
|
||||
if destructive is True:
|
||||
return False
|
||||
|
||||
from src.agent_runtime.authority import active_request_authority, ExactOperation, RequestAuthority
|
||||
authority = active_request_authority()
|
||||
if not isinstance(authority, RequestAuthority):
|
||||
return False
|
||||
if authority is not None:
|
||||
try:
|
||||
operation_input = content if isinstance(content, str) else "{}"
|
||||
operation = ExactOperation.normalize(tool_name, operation_input)
|
||||
if not authority.permits(operation):
|
||||
return False
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
if resource_id not in authority.backend_resources:
|
||||
return False
|
||||
|
||||
sources = list(external_sources or ())
|
||||
if not sources:
|
||||
return False
|
||||
|
||||
from src.mcp_manager import mcp_tool_is_readonly
|
||||
has_local_tool = False
|
||||
for source in sources:
|
||||
if source in _SERVER_OWNED_CONTROL_PLANE_SOURCES:
|
||||
continue
|
||||
if not _is_declared_local_mcp_tool(source, server_id=server_id):
|
||||
return False
|
||||
source_caps = capabilities_for_tool(source)
|
||||
if not source_caps.known or (source_caps.effects & _PROHIBITED_CONTINUATION_EFFECTS):
|
||||
return False
|
||||
if source_caps.effects - {ToolEffect.READ_PRIVATE, ToolEffect.WRITE_PRIVATE}:
|
||||
return False
|
||||
|
||||
source_resource = manager.resource_identity(source)
|
||||
if source_resource is None or source_resource.endpoint_id != resource_id.endpoint_id:
|
||||
return False
|
||||
if not str(source_resource.endpoint_id).startswith("stdio:"):
|
||||
return False
|
||||
|
||||
source_parts = source.split("__", 2)
|
||||
source_row = next((r for r in tools_list if r.get("name") == source_parts[2]), None)
|
||||
if not source_row:
|
||||
return False
|
||||
if _is_declared_local_mcp_read_tool(source, server_id=server_id) and not mcp_tool_is_readonly(source_row):
|
||||
return False
|
||||
|
||||
has_local_tool = True
|
||||
|
||||
return has_local_tool
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolRunSecurityContext:
|
||||
"""Server-owned integrity state for one agent run."""
|
||||
@@ -804,6 +1087,19 @@ class ToolRunSecurityContext:
|
||||
# Driven by a bearer API token, not a person at a browser. Privileged
|
||||
# tools are refused outright and no approval can lift that.
|
||||
delegated_credential: bool = False
|
||||
explicit_untrusted_seen: bool = False
|
||||
prompt_external_untrusted_seen: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.external_untrusted_context_seen and (
|
||||
not self.external_sources
|
||||
or any(
|
||||
not _is_declared_local_mcp_tool(s)
|
||||
and s not in _SERVER_OWNED_CONTROL_PLANE_SOURCES
|
||||
for s in self.external_sources
|
||||
)
|
||||
):
|
||||
self.prompt_external_untrusted_seen = True
|
||||
|
||||
def observe_messages(self, messages: Iterable[dict]) -> None:
|
||||
"""Apply server-owned chat scope and promote untrusted prompt context."""
|
||||
@@ -834,8 +1130,10 @@ class ToolRunSecurityContext:
|
||||
if source and source not in self.external_sources:
|
||||
self.external_sources.append(source)
|
||||
self.external_untrusted_context_seen = True
|
||||
self.prompt_external_untrusted_seen = True
|
||||
if messages_contain_external_untrusted_context(message_list):
|
||||
self.external_untrusted_context_seen = True
|
||||
self.prompt_external_untrusted_seen = True
|
||||
|
||||
def decision_for(self, tool_name: Any, content: Any = None) -> ToolGateDecision:
|
||||
# Checked before the bypasses below, because neither may lift it, and
|
||||
@@ -858,6 +1156,20 @@ class ToolRunSecurityContext:
|
||||
if not self.external_untrusted_context_seen:
|
||||
return ToolGateDecision(True)
|
||||
|
||||
# Scoped continuation exception for explicitly declared local MCP tools:
|
||||
# A declared write or read tool on the same MCP server is allowed if
|
||||
# the run's only untrusted context was introduced by declared local
|
||||
# tools on that server, without any explicit/error taint or external
|
||||
# message context.
|
||||
if (
|
||||
not self.prompt_external_untrusted_seen
|
||||
and not self.explicit_untrusted_seen
|
||||
and _is_authorized_local_mcp_continuation(
|
||||
tool_name, self.external_sources, content=content
|
||||
)
|
||||
):
|
||||
return ToolGateDecision(True)
|
||||
|
||||
# Skills and other server-owned descriptors are control-plane metadata
|
||||
# rather than external result content; web/document/tool-result taint
|
||||
# still gates actions that can mutate state or execute code.
|
||||
@@ -906,6 +1218,11 @@ class ToolRunSecurityContext:
|
||||
if not tool_result_should_arm_gate(tool_name, result, content):
|
||||
return
|
||||
self.external_untrusted_context_seen = True
|
||||
if isinstance(result, dict) and (
|
||||
result.get("untrusted_content") is True
|
||||
or not tool_result_is_successful(result)
|
||||
):
|
||||
self.explicit_untrusted_seen = True
|
||||
if isinstance(tool_name, str) and tool_name not in self.external_sources:
|
||||
self.external_sources.append(tool_name)
|
||||
|
||||
|
||||
+21
-4
@@ -1917,10 +1917,17 @@ async def execute_tool_block(
|
||||
)
|
||||
|
||||
if isinstance(security_context, ToolRunSecurityContext) and not approval_claimed:
|
||||
decision = security_context.decision_for(
|
||||
getattr(block, "tool_type", None),
|
||||
getattr(block, "content", None),
|
||||
)
|
||||
if isinstance(authority, RequestAuthority):
|
||||
with bind_request_authority(authority):
|
||||
decision = security_context.decision_for(
|
||||
getattr(block, "tool_type", None),
|
||||
getattr(block, "content", None),
|
||||
)
|
||||
else:
|
||||
decision = security_context.decision_for(
|
||||
getattr(block, "tool_type", None),
|
||||
getattr(block, "content", None),
|
||||
)
|
||||
if not decision.allowed:
|
||||
logger.warning(
|
||||
"External-context policy blocked tool=%r",
|
||||
@@ -2555,6 +2562,16 @@ def _compact_binary_like_output(value: object) -> str:
|
||||
|
||||
def format_tool_result(description: str, result: Dict) -> str:
|
||||
"""Format a tool result into text for feeding back to the LLM."""
|
||||
if (
|
||||
description.startswith("manage_documents:")
|
||||
and isinstance(result.get("document"), dict)
|
||||
and "response" in result
|
||||
):
|
||||
# Document reads already budget their complete page, anchor and
|
||||
# next_offset notice. A heading or a second JSON copy of the content
|
||||
# would exhaust that budget and obscure the continuation again.
|
||||
return _truncate(result["response"])
|
||||
|
||||
parts = [f"### {description}"]
|
||||
|
||||
if "stdout" in result:
|
||||
|
||||
@@ -6440,6 +6440,12 @@ def resolve_turn_contract(*, capabilities: Iterable[str], schemas: Iterable[dict
|
||||
if 'search_emails' in selected:
|
||||
selected.update({'read_email', 'download_attachment', 'list_email_accounts'})
|
||||
selected.update(canonical_tool(n) for n in always_available_tools)
|
||||
# Offer only dynamic MCP operations already sealed by trusted request
|
||||
# admission. Tool discovery and model routing never create these grants.
|
||||
from src.agent_runtime.authority import active_request_authority
|
||||
sealed_authority = active_request_authority()
|
||||
if sealed_authority is not None:
|
||||
selected.update(g.tool for g in sealed_authority.grants if g.tool.startswith("mcp__"))
|
||||
selected.update(canonical_tool(n) for n in warm_tools if str(n or "").strip())
|
||||
# Controls are neutral; enabling Web is permission, never a requested family.
|
||||
if selected:
|
||||
|
||||
+28
-3
@@ -23,6 +23,7 @@ from typing import Dict, List, Optional, Tuple
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from src.research_utils import strip_thinking
|
||||
from src.report_math import ReportMathExtension
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import markdown
|
||||
@@ -47,6 +48,7 @@ for _t in ("td", "th"):
|
||||
_REPORT_ALLOWED_ATTRS.setdefault(_t, set()).add("align")
|
||||
_REPORT_ALLOWED_ATTRS.setdefault("a", set()).update({"href", "title", "target", "rel"})
|
||||
_REPORT_ALLOWED_ATTRS.setdefault("img", set()).update({"src", "alt", "title"})
|
||||
_REPORT_ALLOWED_ATTRS.setdefault("span", set()).add("data-display")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
@@ -79,7 +81,7 @@ def _md_to_html(md_text: str) -> str:
|
||||
md_text = _autolink_urls(md_text)
|
||||
result = markdown.markdown(
|
||||
md_text,
|
||||
extensions=["extra", "codehilite", "toc", "tables", "sane_lists", "md_in_html"],
|
||||
extensions=["extra", "codehilite", "toc", "tables", "sane_lists", "md_in_html", ReportMathExtension()],
|
||||
extension_configs={
|
||||
"codehilite": {"css_class": "code", "guess_lang": False},
|
||||
"toc": {"marker": "", "toc_depth": "2-3"},
|
||||
@@ -1021,6 +1023,8 @@ body[class^="standard-report-"] .report-section:nth-of-type(even) {{
|
||||
.content code {{ font-family: var(--font-mono); font-size: 0.86em; background: var(--bg-surface-alt); padding: 0.15em 0.4em; border-radius: 4px; }}
|
||||
.content pre {{ background: var(--bg-surface-alt); border: 1px solid var(--border); border-radius: var(--radius); padding: 1.25rem 1.5rem; overflow-x: auto; margin: 1.25rem 0; font-size: 0.86rem; line-height: 1.6; }}
|
||||
.content pre code {{ background: none; padding: 0; }}
|
||||
/* Match the chat renderer's handling of wide display equations. */
|
||||
.content .katex-display {{ overflow-x: auto; overflow-y: hidden; }}
|
||||
.content table {{ width: 100%; border-collapse: collapse; margin: 1.25rem 0; font-size: 0.9rem; border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow-sm); }}
|
||||
.content th {{ text-align: left; padding: 0.7rem 1rem; background: var(--accent-bg); font-weight: 600; border-bottom: 2px solid var(--border-strong); }}
|
||||
.content td {{ padding: 0.6rem 1rem; border-bottom: 1px solid var(--border); vertical-align: top; }}
|
||||
@@ -1434,6 +1438,12 @@ body[class^="standard-report-"] .report-section:nth-of-type(even) {{
|
||||
|
||||
<script>
|
||||
(function() {{
|
||||
// Reuse the chat renderer and vendored assets only for reports with math.
|
||||
if (document.querySelector('.ody-math-pending')) {{
|
||||
import('/static/js/markdown.js').then(function(markdown) {{
|
||||
return markdown.renderMath(document);
|
||||
}}).catch(function(err) {{ console.warn('Report math render error:', err); }});
|
||||
}}
|
||||
// ESC closes the report tab. window.close() works when the tab was
|
||||
// opened via window.open() (which is how the panel launches it). If the
|
||||
// browser blocks self-close (rare — e.g. report opened by direct URL),
|
||||
@@ -2835,11 +2845,23 @@ def generate_visual_report(
|
||||
spare_images = section_pool[_consumed:]
|
||||
|
||||
# Build TOC
|
||||
math_labels = {}
|
||||
if 'ody-math-pending' in report_html:
|
||||
label_soup = BeautifulSoup(report_html, "html.parser")
|
||||
for heading in label_soup.select('h2[id], h3[id]'):
|
||||
if not heading.select_one('.ody-math-pending'):
|
||||
continue
|
||||
for child in list(heading.find_all(True)):
|
||||
if 'ody-math-pending' in child.get('class', []):
|
||||
child['data-display'] = 'false'
|
||||
else:
|
||||
child.unwrap()
|
||||
math_labels[heading['id']] = heading.decode_contents()
|
||||
toc_lines = []
|
||||
for h in headings:
|
||||
depth_class = f"depth-{h['level']}"
|
||||
toc_lines.append(
|
||||
f'<a href="#{h["slug"]}" class="{depth_class}">{html.escape(h["text"])}</a>'
|
||||
f'<a href="#{h["slug"]}" class="{depth_class}">{math_labels.get(h["slug"], html.escape(h["text"]))}</a>'
|
||||
)
|
||||
toc_html = "\n ".join(toc_lines) if toc_lines else ""
|
||||
|
||||
@@ -2848,8 +2870,11 @@ def generate_visual_report(
|
||||
options = []
|
||||
for h in headings:
|
||||
prefix = "\u2014 " if h["level"] == 3 else ""
|
||||
text = h["text"]
|
||||
if h["slug"] in math_labels:
|
||||
text = BeautifulSoup(math_labels[h["slug"]], "html.parser").get_text()
|
||||
options.append(
|
||||
f'<option value="{h["slug"]}">{prefix}{html.escape(h["text"])}</option>'
|
||||
f'<option value="{h["slug"]}">{prefix}{html.escape(text)}</option>'
|
||||
)
|
||||
mobile_toc_html = (
|
||||
'<div class="mobile-section-nav">'
|
||||
|
||||
+13
-3
@@ -561,10 +561,21 @@ function initializeEventListeners() {
|
||||
|
||||
// Export: PDF
|
||||
const exportPdfBtn = el('export-pdf-btn');
|
||||
let preparingPdf = false;
|
||||
if (exportPdfBtn) {
|
||||
exportPdfBtn.addEventListener('click', (e) => {
|
||||
exportPdfBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
exportMenu.classList.remove('open');
|
||||
if (preparingPdf) return;
|
||||
preparingPdf = true;
|
||||
try {
|
||||
await sessionModule.prepareHistoryForPrint();
|
||||
} catch (error) {
|
||||
uiModule.showError(error?.message || 'Could not prepare the chat for PDF export.');
|
||||
return;
|
||||
} finally {
|
||||
preparingPdf = false;
|
||||
}
|
||||
const meta = sessionModule.getSessions().find(s => s.id === sessionModule.getCurrentSessionId());
|
||||
const sessionName = meta ? meta.name : 'Odysseus Chat';
|
||||
const originalTitle = document.title;
|
||||
@@ -1308,8 +1319,7 @@ function initializeEventListeners() {
|
||||
const toolThemeBtn = el('tool-theme-btn');
|
||||
if (toolThemeBtn) {
|
||||
toolThemeBtn.addEventListener('click', () => {
|
||||
const tm = document.getElementById('theme-modal');
|
||||
if (tm) tm.classList.remove('hidden');
|
||||
themeModule.openPopup();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -6481,6 +6481,13 @@ body.bg-pattern-ascii-fireflies {
|
||||
display:flex; justify-content:flex-end; gap:8px; padding-top:6px;
|
||||
border-top:1px solid var(--border); margin-top:4px;
|
||||
}
|
||||
/* The global `button { height:32px }` would hold a wrapped label (e.g.
|
||||
"Delete chat and images") to one line's height, so its second line
|
||||
spills out below the button. Let confirm buttons grow to fit; 32px
|
||||
stays the minimum, so single-line buttons look the same. */
|
||||
.styled-confirm-box .confirm-btn {
|
||||
height:auto; min-height:32px;
|
||||
}
|
||||
@media (max-width:768px) {
|
||||
.styled-confirm-box {
|
||||
width: 85vw;
|
||||
@@ -6516,10 +6523,8 @@ body.bg-pattern-ascii-fireflies {
|
||||
}
|
||||
.confirm-btn-secondary { background:var(--bg); color:var(--fg); }
|
||||
.confirm-btn-secondary:hover { background:var(--border); }
|
||||
#styled-confirm-ok {
|
||||
position: relative;
|
||||
top: 2px;
|
||||
}
|
||||
#styled-confirm-ok .styled-confirm-btn-label,
|
||||
#styled-confirm-alt .styled-confirm-btn-label,
|
||||
#styled-confirm-cancel .styled-confirm-cancel-label {
|
||||
position:relative;
|
||||
top:2px;
|
||||
|
||||
@@ -1701,6 +1701,7 @@
|
||||
|
||||
/* ── Print / PDF Export ── */
|
||||
@media print {
|
||||
html, body { height: auto !important; overflow: visible !important; }
|
||||
body { background: #fff !important; color: #000 !important; }
|
||||
#sidebar, .sidebar, #icon-rail, .hamburger-btn, #sidebar-backdrop, .chat-input-bar, .input-bar-wrapper,
|
||||
#welcome-screen, .chat-top-bar, .chat-meta-overlay, .msg-footer,
|
||||
|
||||
@@ -52,6 +52,45 @@
|
||||
gap: 8px;
|
||||
}
|
||||
.admin-user-name { font-size: 13px; font-weight: 500; }
|
||||
/* Inline endpoint rename: the input keeps the name's typography so the row doesn't shift */
|
||||
.adm-ep-rename-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0 2px;
|
||||
color: inherit;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0.45;
|
||||
}
|
||||
.adm-ep-rename-btn:hover,
|
||||
.adm-ep-rename-btn:focus-visible { opacity: 0.9; }
|
||||
@media (hover: hover) {
|
||||
.adm-ep-rename-btn { opacity: 0; }
|
||||
.admin-user-row:hover .adm-ep-rename-btn { opacity: 0.45; }
|
||||
.admin-user-row .adm-ep-rename-btn:hover,
|
||||
.adm-ep-rename-btn:focus-visible { opacity: 0.9; }
|
||||
}
|
||||
.admin-user-row input.adm-ep-rename-input {
|
||||
width: auto;
|
||||
field-sizing: content;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
padding: 0 0 1px;
|
||||
line-height: inherit;
|
||||
color: inherit;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--red);
|
||||
border-radius: 0;
|
||||
outline: none;
|
||||
}
|
||||
.admin-user-info:has(.adm-ep-name, .adm-ep-rename-input) { min-width: 0; }
|
||||
.adm-ep-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Privilege panel */
|
||||
.admin-priv-panel {
|
||||
max-height: 600px;
|
||||
|
||||
@@ -339,6 +339,13 @@ mark.doc-find-mark {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
/* The container now fills the body exactly, so its inline 2px top margin
|
||||
(spacing for the grid view) would push it 2px past the body and add a
|
||||
second, outer scrollbar beside the inspector's own. !important is needed
|
||||
to beat the inline style. */
|
||||
.gallery-images-container:has(> #gallery-detail[style*="flex"]) {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
}
|
||||
.gallery-detail-image:hover .gallery-detail-rotate { opacity: 0.85; }
|
||||
.gallery-detail-nav-disabled {
|
||||
|
||||
+39
-23
@@ -247,32 +247,32 @@
|
||||
real request is discarded and the font fetched a second time. -->
|
||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-Regular.woff2">
|
||||
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-SemiBold.woff2">
|
||||
<link rel="stylesheet" href="/static/css/00-tokens.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/01-agent-chat.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/02-compare.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/03-agent-chat.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/04-memory.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/05-documents.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/06-admin-settings.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/07-documents.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/08-skills.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/09-gallery.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/10-cookbook.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/11-tasks.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/12-gallery.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/13-image-editor.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/14-email.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/15-notes.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/16-calendar.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/17-research.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/00-tokens.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/01-agent-chat.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/02-compare.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/03-agent-chat.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/04-memory.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/05-documents.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/06-admin-settings.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/07-documents.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/08-skills.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/09-gallery.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/10-cookbook.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/11-tasks.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/12-gallery.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/13-image-editor.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/14-email.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/15-notes.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/16-calendar.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/17-research.css?v=20261021confirmalign1">
|
||||
<!-- Panel stylesheets split out of style.css for maintainability. They are
|
||||
eager and their order is load-bearing: the cascade is the concatenation of
|
||||
these files in this order, so they must stay in this order here and in the
|
||||
PRECACHE list in sw.js. None of them is lazy-loaded. -->
|
||||
<link rel="stylesheet" href="/static/css/documents-gallery-editor.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/email-calendar-notes-tasks.css?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/cookbook-research-memory-settings.css?v=20260929csssplit">
|
||||
<link rel="modulepreload" href="/static/app.js?v=20260929csssplit">
|
||||
<link rel="stylesheet" href="/static/css/documents-gallery-editor.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/email-calendar-notes-tasks.css?v=20261021confirmalign1">
|
||||
<link rel="stylesheet" href="/static/css/cookbook-research-memory-settings.css?v=20261021confirmalign1">
|
||||
<link rel="modulepreload" href="/static/app.js?v=20261021confirmalign1">
|
||||
<link rel="modulepreload" href="/static/js/chat.js?v=20260917toolttft1">
|
||||
<link rel="modulepreload" href="/static/js/ui.js?v=20260916largetoolscroll1">
|
||||
<link rel="modulepreload" href="/static/js/sessions.js">
|
||||
@@ -2463,6 +2463,22 @@
|
||||
<label class="settings-label">Max steps per message</label>
|
||||
<input id="set-agentMaxRounds" type="text" inputmode="numeric" placeholder="20" class="settings-select" style="width:120px;">
|
||||
</div>
|
||||
<div class="settings-row">
|
||||
<label class="settings-label">Tool approval</label>
|
||||
<select id="set-toolApprovalMode" class="settings-select">
|
||||
<option value="ask">Always ask</option>
|
||||
<option value="off">Off (YOLO)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="admin-toggle-sub" style="margin-top:4px;">
|
||||
Off (YOLO) turns off the tool approval gate (same control as the
|
||||
ODYSSEUS_TOOL_APPROVAL_GATE env var). Bash, host_shell, send_email and
|
||||
delete_email then run without a confirmation even after the agent has
|
||||
seen untrusted external content (email, web pages, documents), so a
|
||||
prompt-injected instruction can reach them directly.
|
||||
</div>
|
||||
<div id="set-toolApprovalEnvNote" class="admin-toggle-sub" style="margin-top:4px;display:none;color:var(--red);"></div>
|
||||
<div id="set-agentMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2693,7 +2709,7 @@
|
||||
<script type="module" src="/static/js/censor.js"></script>
|
||||
<script type="module" src="/static/js/settings.js?v=20260912writingstyle3"></script>
|
||||
<script type="module" src="/static/js/assistant.js?v=20260912firefoxjscleanup1"></script>
|
||||
<script type="module" src="/static/app.js?v=20260929csssplit"></script> <!-- app.js must be LAST -->
|
||||
<script type="module" src="/static/app.js?v=20261021confirmalign1"></script> <!-- app.js must be LAST -->
|
||||
<script type="module" src="/static/js/init.js?v=20260829chatstyle12"></script>
|
||||
<script type="module" src="/static/js/a11y.js"></script>
|
||||
<script nonce="{{CSP_NONCE}}">if('serviceWorker' in navigator){navigator.serviceWorker.register('/static/sw.js?v=20260916autoscroll1').catch(()=>{});}</script>
|
||||
|
||||
+66
-1
@@ -1074,7 +1074,8 @@ async function loadEndpoints() {
|
||||
<span class="adm-check-dot adm-endpoint-select-dot" aria-hidden="true"></span>
|
||||
</label>` : ''}
|
||||
<span class="adm-ep-row-logo" style="display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;flex-shrink:0;opacity:0.9;">${providerLogoFromUrl(ep.base_url) || ''}</span>
|
||||
<span class="admin-user-name">${esc(epTitle)}</span>
|
||||
<span class="admin-user-name adm-ep-name" title="${esc(epTitle)}">${esc(epTitle)}</span>
|
||||
${_endpointSelectMode || (isChatgptAccount && ep.account_label) ? '' : `<button type="button" class="adm-ep-rename-btn" data-adm-rename-ep="${esc(ep.id)}" data-adm-ep-title="${esc(epTitle)}" title="Rename" aria-label="Rename endpoint"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 3a2.83 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/></svg></button>`}
|
||||
${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}
|
||||
@@ -1204,6 +1205,70 @@ async function loadEndpoints() {
|
||||
loadEndpoints();
|
||||
});
|
||||
});
|
||||
queryAll('[data-adm-rename-ep]').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const nameEl = btn.previousElementSibling;
|
||||
if (!nameEl || !nameEl.classList.contains('admin-user-name')) return;
|
||||
const original = btn.dataset.admEpTitle || '';
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.value = original;
|
||||
input.placeholder = 'Endpoint name';
|
||||
input.setAttribute('aria-label', 'Endpoint name');
|
||||
input.maxLength = 64;
|
||||
input.autocomplete = 'off';
|
||||
input.setAttribute('data-bwignore', '');
|
||||
input.className = 'admin-user-name adm-ep-rename-input';
|
||||
input.style.minWidth = `min(${nameEl.getBoundingClientRect().width}px, 100%)`;
|
||||
nameEl.replaceWith(input);
|
||||
btn.style.visibility = 'hidden';
|
||||
input.focus();
|
||||
input.select();
|
||||
let done = false;
|
||||
const restore = () => {
|
||||
input.replaceWith(nameEl);
|
||||
btn.style.visibility = '';
|
||||
};
|
||||
const commit = async () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
const name = input.value.trim();
|
||||
if (!name || name === original) { restore(); return; }
|
||||
const epId = btn.dataset.admRenameEp;
|
||||
if (data.some(ep => String(ep.id) !== epId && (ep.name || '').trim().toLowerCase() === name.toLowerCase())) {
|
||||
uiModule.showError(`An endpoint named "${name}" already exists`);
|
||||
done = false;
|
||||
input.focus();
|
||||
return;
|
||||
}
|
||||
input.disabled = true;
|
||||
try {
|
||||
const res = await fetch(`/api/model-endpoints/${encodeURIComponent(epId)}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.detail || 'Failed to rename endpoint');
|
||||
}
|
||||
} catch (err) {
|
||||
uiModule.showError(err.message || 'Failed to rename endpoint');
|
||||
restore();
|
||||
return;
|
||||
}
|
||||
await _refreshAfterEndpointChange();
|
||||
loadEndpoints();
|
||||
};
|
||||
input.addEventListener('blur', commit);
|
||||
input.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Enter') { ev.preventDefault(); input.blur(); }
|
||||
if (ev.key === 'Escape') { ev.preventDefault(); ev.stopPropagation(); done = true; restore(); }
|
||||
});
|
||||
});
|
||||
});
|
||||
queryAll('[data-adm-copy-url]').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
+159
-50
@@ -336,6 +336,9 @@ import { invalidateSettings } from './appConfig.js';
|
||||
let _sendInFlight = false; // covers the window from click → streaming start
|
||||
let _displayOverride = null; // Override visible user bubble text (hides injected prompts)
|
||||
let _hideUserBubble = false; // Skip user bubble entirely (e.g. continue after stop)
|
||||
// The user bubble a regenerate keeps on screen. Its resubmitted message is
|
||||
// a new DB row, so the saved id is attached here.
|
||||
let _regenKeptUserBubble = null;
|
||||
let _contextHeaderSeq = 0;
|
||||
let _contextHeaderData = null;
|
||||
let _contextHeaderBound = false;
|
||||
@@ -4995,6 +4998,15 @@ import { invalidateSettings } from './appConfig.js';
|
||||
refreshChatContextHeader('metrics');
|
||||
}
|
||||
|
||||
} else if (json.type === 'user_message_saved') {
|
||||
// The user bubble gets its DB id too, so regenerating or
|
||||
// editing it later cuts the chat by id, not page position.
|
||||
if (!_isBg && json.id) {
|
||||
if (_userMsgEl) _userMsgEl.dataset.dbId = json.id;
|
||||
else if (_regenKeptUserBubble) _regenKeptUserBubble.dataset.dbId = json.id;
|
||||
_regenKeptUserBubble = null;
|
||||
}
|
||||
|
||||
} else if (json.type === 'message_saved') {
|
||||
// Wire the persisted DB id onto the just-streamed bubble so it
|
||||
// can be edited/deleted immediately, without reloading the chat.
|
||||
@@ -7236,6 +7248,22 @@ import { invalidateSettings } from './appConfig.js';
|
||||
/**
|
||||
* Edit a user message: show an input, truncate to before it, resubmit the edited text.
|
||||
*/
|
||||
// Recover attached file IDs from rendered user bubbles, including older
|
||||
// image previews that were rendered before data-file-id was stamped.
|
||||
function _userMessageAttachmentIds(msgElement) {
|
||||
if (!msgElement) return [];
|
||||
const els = msgElement.querySelectorAll('[data-file-id]');
|
||||
const ids = Array.from(els).map(el => el.dataset.fileId).filter(Boolean);
|
||||
if (!ids.length) {
|
||||
const images = msgElement.querySelectorAll('.attach-image-preview img, .attach-card img');
|
||||
for (const image of images) {
|
||||
const match = (image.getAttribute('src') || '').match(/\/api\/upload\/([A-Za-z0-9_\-]+)/);
|
||||
if (match && match[1] && !ids.includes(match[1])) ids.push(match[1]);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export async function editUserMessage(userMsgElement) {
|
||||
const box = document.getElementById('chat-history');
|
||||
const allMsgs = Array.from(box.querySelectorAll('.msg'));
|
||||
@@ -7283,23 +7311,49 @@ import { invalidateSettings } from './appConfig.js';
|
||||
e.stopPropagation();
|
||||
const newText = editor.value.trim();
|
||||
if (!newText) return;
|
||||
const editAttachmentIds = _userMessageAttachmentIds(userMsgElement);
|
||||
|
||||
const sessionId = sessionModule.getCurrentSessionId();
|
||||
if (!sessionId) return;
|
||||
|
||||
// Everything after the original reply to this message would also be
|
||||
// destroyed. Cancelling leaves the editor open with the draft intact.
|
||||
const extraCount = allMsgs.length - msgIndex - 2;
|
||||
const choice = await _confirmTruncateIfDestructive(extraCount, 'edit');
|
||||
if (!choice) return;
|
||||
if (choice === 'fork') {
|
||||
// Branch just before this user turn and send the edit there; this
|
||||
// chat keeps the original message and everything after it.
|
||||
try {
|
||||
await _forkAndSend(msgIndex, userMsgElement.dataset.dbId || '', newText, editAttachmentIds);
|
||||
bodyEl.innerHTML = originalHTML;
|
||||
} catch (err) {
|
||||
console.error('Fork failed:', err);
|
||||
if (uiModule) uiModule.showError('Fork failed: ' + err.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const keepCount = msgIndex;
|
||||
// Cut at the message's database id: msgIndex counts only the messages
|
||||
// loaded in the page, so it is too small when older history has not
|
||||
// been scrolled in yet.
|
||||
const editBeforeId = userMsgElement.dataset.dbId || '';
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
|
||||
const editTruncate = await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ keep_count: keepCount })
|
||||
body: JSON.stringify(editBeforeId ? { before_msg_id: editBeforeId } : { keep_count: keepCount })
|
||||
});
|
||||
if (!editTruncate.ok) throw new Error('Server error ' + editTruncate.status);
|
||||
|
||||
// Remove DOM elements from msgIndex onward
|
||||
for (let i = allMsgs.length - 1; i >= msgIndex; i--) {
|
||||
allMsgs[i].remove();
|
||||
}
|
||||
|
||||
// The edited user turn still refers to its original attachments.
|
||||
_pendingRegenAttachments = editAttachmentIds;
|
||||
// Submit the edited text
|
||||
const messageInput = uiModule.el('message');
|
||||
messageInput.value = newText;
|
||||
@@ -7323,6 +7377,59 @@ import { invalidateSettings } from './appConfig.js';
|
||||
});
|
||||
}
|
||||
|
||||
// Regenerate, resend and edit permanently delete (server-side truncate)
|
||||
// everything after the point clicked. Ask first unless nothing comes after
|
||||
// it (extraCount 0), offering a fork that leaves this chat untouched.
|
||||
// Resolves to 'proceed', 'fork', or null (cancelled).
|
||||
const _TRUNCATE_ACTIONS = {
|
||||
regenerate: { lead: 'Regenerating here', confirmText: 'Delete and regenerate' },
|
||||
resend: { lead: 'Resending this message', confirmText: 'Delete and resend' },
|
||||
edit: { lead: 'Sending this edit', confirmText: 'Delete and send' },
|
||||
};
|
||||
async function _confirmTruncateIfDestructive(extraCount, action = 'regenerate') {
|
||||
if (extraCount <= 0) return 'proceed';
|
||||
const { lead, confirmText } = _TRUNCATE_ACTIONS[action] || _TRUNCATE_ACTIONS.regenerate;
|
||||
const result = await uiModule.styledConfirm(
|
||||
`${lead} will also permanently delete the ${extraCount} ` +
|
||||
`message${extraCount === 1 ? '' : 's'} after it. This can't be undone. ` +
|
||||
`Fork from here keeps this chat as it is and continues in a new one instead.`,
|
||||
{ confirmText, alternateText: 'Fork from here', cancelText: 'Cancel', danger: true }
|
||||
);
|
||||
if (result === 'alternate') return 'fork';
|
||||
return result ? 'proceed' : null;
|
||||
}
|
||||
|
||||
// Copy the first `keepCount` messages of the current chat into a new chat
|
||||
// and switch to it. Returns the new session id; throws on failure so callers
|
||||
// that replay an action in the fork can stop before sending anything.
|
||||
// Fork by message id when the element has one (before it, or through it);
|
||||
// keepCount is only a fallback, since it counts loaded messages only.
|
||||
async function _forkSessionAt(keepCount, beforeMsgId = '', throughMsgId = '') {
|
||||
const sessionId = sessionModule.getCurrentSessionId();
|
||||
if (!sessionId) throw new Error('No active chat');
|
||||
const res = await fetch(`${API_BASE}/api/session/${sessionId}/fork`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ keep_count: keepCount,
|
||||
...(beforeMsgId ? { before_msg_id: beforeMsgId } : throughMsgId ? { through_msg_id: throughMsgId } : {}) }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
await sessionModule.loadSessions();
|
||||
await sessionModule.selectSession(data.id);
|
||||
if (uiModule) uiModule.showToast(`Forked → ${data.name}`);
|
||||
return data.id;
|
||||
}
|
||||
|
||||
// Fork before a user message and send `text` there; this chat is untouched.
|
||||
// `attachments` (if given) are re-sent only once the fork has succeeded.
|
||||
async function _forkAndSend(keepCount, beforeMsgId, text, attachments) {
|
||||
await _forkSessionAt(keepCount, beforeMsgId);
|
||||
if (attachments !== undefined) _pendingRegenAttachments = attachments;
|
||||
uiModule.el('message').value = text;
|
||||
document.querySelector('.send-btn')?.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend a user message. By default this replaces from the selected user turn
|
||||
* so failed attempts do not remain in model context or SFT traces. Callers can
|
||||
@@ -7344,18 +7451,7 @@ import { invalidateSettings } from './appConfig.js';
|
||||
let text = (userMsgElement.dataset.raw || (bodyEl ? bodyEl.textContent : '') || '').trim();
|
||||
text = text.replace(/\s*\[\d+ attachment\(s\)\]$/, '');
|
||||
|
||||
// Collect file_ids attached to this user message so the resend re-carries
|
||||
// the photos / docs (and the chat handler picks up the user-edited OCR
|
||||
// text cached server-side under those file ids).
|
||||
const _attachEls = userMsgElement.querySelectorAll('[data-file-id]');
|
||||
let _ids = Array.from(_attachEls).map(el => el.dataset.fileId).filter(Boolean);
|
||||
if (!_ids.length) {
|
||||
const _imgs = userMsgElement.querySelectorAll('.attach-image-preview img, .attach-card img');
|
||||
for (const _im of _imgs) {
|
||||
const _m = (_im.getAttribute('src') || '').match(/\/api\/upload\/([A-Za-z0-9_\-]+)/);
|
||||
if (_m && _m[1] && !_ids.includes(_m[1])) _ids.push(_m[1]);
|
||||
}
|
||||
}
|
||||
const _ids = _userMessageAttachmentIds(userMsgElement);
|
||||
|
||||
// Rescue: legacy bubbles may have stored the filename as the message
|
||||
// content (artifact of earlier broken resends). Don't re-send that as
|
||||
@@ -7378,6 +7474,18 @@ import { invalidateSettings } from './appConfig.js';
|
||||
|
||||
try {
|
||||
if (replaceFromHere) {
|
||||
// Everything strictly after the immediate AI reply to this user
|
||||
// message would also be destroyed — that's the part a click on
|
||||
// Resend / "Regenerate message" doesn't expect to lose.
|
||||
const extraCount = allMsgs.length - msgIndex - 2;
|
||||
const choice = await _confirmTruncateIfDestructive(extraCount, 'resend');
|
||||
if (!choice) return;
|
||||
if (choice === 'fork') {
|
||||
// Branch just before this user turn and send it again there; this
|
||||
// chat keeps every message.
|
||||
await _forkAndSend(msgIndex, userMsgElement.dataset.dbId || '', text, _ids);
|
||||
return;
|
||||
}
|
||||
// Resend/regenerate trims history to this point before resubmitting so
|
||||
// the replacement request is the only copy the backend sees.
|
||||
const keepCount = msgIndex;
|
||||
@@ -7442,25 +7550,14 @@ import { invalidateSettings } from './appConfig.js';
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect any file_ids attached to the original user message so the
|
||||
// regenerated send re-uses them. Without this the AI is regenerated on
|
||||
// text alone — photos (and the user-edited OCR text cached server-side
|
||||
// under that file_id) would be silently dropped.
|
||||
const _attachEls = userMsgEl ? userMsgEl.querySelectorAll('[data-file-id]') : [];
|
||||
let _regenIds = Array.from(_attachEls).map(el => el.dataset.fileId).filter(Boolean);
|
||||
// Fallback for bubbles rendered before the data-file-id stamp landed:
|
||||
// sniff the file id straight out of any `.attach-image-preview img`
|
||||
// src URLs (matches /api/upload/<id>). Otherwise an older bubble would
|
||||
// regen with zero attachments and the photo would be lost from the
|
||||
// resulting message even though the file still exists on disk.
|
||||
if (!_regenIds.length && userMsgEl) {
|
||||
const _imgs = userMsgEl.querySelectorAll('.attach-image-preview img, .attach-card img');
|
||||
for (const _im of _imgs) {
|
||||
const _m = (_im.getAttribute('src') || '').match(/\/api\/upload\/([A-Za-z0-9_\-]+)/);
|
||||
if (_m && _m[1] && !_regenIds.includes(_m[1])) _regenIds.push(_m[1]);
|
||||
}
|
||||
}
|
||||
_pendingRegenAttachments = _regenIds;
|
||||
// Everything strictly after the AI reply being regenerated would also
|
||||
// be destroyed — that's the part a click on "Regenerate from here"
|
||||
// doesn't expect to lose.
|
||||
const extraCount = allMsgs.length - aiIndex - 1;
|
||||
const choice = await _confirmTruncateIfDestructive(extraCount, 'regenerate');
|
||||
if (!choice) return;
|
||||
|
||||
const _regenIds = _userMessageAttachmentIds(userMsgEl);
|
||||
|
||||
// Rescue: earlier-version regens (before the dataset.raw fix) stored the
|
||||
// photo's filename as the user-message content. On a follow-up regen,
|
||||
@@ -7469,7 +7566,7 @@ import { invalidateSettings } from './appConfig.js';
|
||||
// an image file". If userText is just a bare image filename and we have
|
||||
// attachments, drop it so the OCR text (or the image bytes for vision
|
||||
// models) is what the model actually sees.
|
||||
if (userText && _pendingRegenAttachments.length &&
|
||||
if (userText && _regenIds.length &&
|
||||
/^[^\n\r]{1,200}\.(png|jpe?g|gif|webp|svg|bmp|heic|heif)$/i.test(userText.trim())) {
|
||||
userText = '';
|
||||
}
|
||||
@@ -7477,7 +7574,7 @@ import { invalidateSettings } from './appConfig.js';
|
||||
// A photo-only message has empty user text — regen must still proceed,
|
||||
// because the attachments themselves are the message. Bail only if there
|
||||
// is no text AND no attachments to send.
|
||||
if (!userText && !_pendingRegenAttachments.length) {
|
||||
if (!userText && !_regenIds.length) {
|
||||
if (uiModule) uiModule.showError('Nothing to regenerate — the user message has no text and no attachments');
|
||||
return;
|
||||
}
|
||||
@@ -7485,6 +7582,20 @@ import { invalidateSettings } from './appConfig.js';
|
||||
const sessionId = sessionModule.getCurrentSessionId();
|
||||
if (!sessionId) return;
|
||||
|
||||
if (choice === 'fork') {
|
||||
// Branch just before the user turn and send it again there, so the new
|
||||
// chat gets a fresh reply to the same message; this chat keeps every
|
||||
// message. No variants or hidden bubble: the fork starts without them.
|
||||
try {
|
||||
await _forkAndSend(userIndex, (userMsgEl && userMsgEl.dataset.dbId) || '', userText, _regenIds);
|
||||
} catch (err) {
|
||||
if (_pendingRegenAttachments === _regenIds) _pendingRegenAttachments = null;
|
||||
console.error('Fork failed:', err);
|
||||
if (uiModule) uiModule.showError('Fork failed: ' + err.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Save current response as a variant
|
||||
const oldRaw = aiMsgElement.dataset.raw || aiMsgElement.querySelector('.body')?.textContent || '';
|
||||
const oldHtml = aiMsgElement.querySelector('.body')?.innerHTML || '';
|
||||
@@ -7496,13 +7607,19 @@ import { invalidateSettings } from './appConfig.js';
|
||||
}
|
||||
|
||||
const keepCount = userIndex;
|
||||
// Same as resend: cut at the user message's database id, because
|
||||
// userIndex counts only the messages loaded in the page.
|
||||
const regenBeforeId = (userMsgEl && userMsgEl.dataset.dbId) || '';
|
||||
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
|
||||
const regenTruncate = await fetch(`${API_BASE}/api/session/${sessionId}/truncate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ keep_count: keepCount })
|
||||
body: JSON.stringify(regenBeforeId ? { before_msg_id: regenBeforeId } : { keep_count: keepCount })
|
||||
});
|
||||
// A refused cut (e.g. a stale id) must not stack a second reply.
|
||||
if (!regenTruncate.ok) throw new Error('Server error ' + regenTruncate.status);
|
||||
_pendingRegenAttachments = _regenIds;
|
||||
|
||||
// Keep the original user bubble, but remove every rendered trace after
|
||||
// it, including agent-thread tool history between the user and AI bubble.
|
||||
@@ -7519,12 +7636,14 @@ import { invalidateSettings } from './appConfig.js';
|
||||
_pendingVariantLabel = 'regen';
|
||||
|
||||
_hideUserBubble = true;
|
||||
_regenKeptUserBubble = userMsgEl;
|
||||
const messageInput = uiModule.el('message');
|
||||
messageInput.value = userText;
|
||||
const submitBtn = document.querySelector('.send-btn');
|
||||
if (submitBtn) submitBtn.click();
|
||||
|
||||
} catch (err) {
|
||||
if (_pendingRegenAttachments === _regenIds) _pendingRegenAttachments = null;
|
||||
console.error('Regenerate failed:', err);
|
||||
if (uiModule) uiModule.showError('Regenerate failed: ' + err.message);
|
||||
}
|
||||
@@ -7536,6 +7655,8 @@ import { invalidateSettings } from './appConfig.js';
|
||||
// File-ids carried over from the original user message during a regen, so
|
||||
// photos / OCR overrides survive into the new send. Consumed once.
|
||||
let _pendingRegenAttachments = null;
|
||||
// Read-only view for tests.
|
||||
export function _pendingRegenAttachmentIds() { return _pendingRegenAttachments; }
|
||||
|
||||
/**
|
||||
* Called after streaming completes to attach variant navigation if this was a regen.
|
||||
@@ -7675,20 +7796,8 @@ import { invalidateSettings } from './appConfig.js';
|
||||
const sessionId = sessionModule.getCurrentSessionId();
|
||||
if (!sessionId) return;
|
||||
|
||||
const keepCount = aiIndex + 1;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/session/${sessionId}/fork`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ keep_count: keepCount }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
|
||||
await sessionModule.loadSessions();
|
||||
await sessionModule.selectSession(data.id);
|
||||
if (uiModule) uiModule.showToast(`Forked → ${data.name}`);
|
||||
await _forkSessionAt(aiIndex + 1, '', aiMsgElement.dataset.dbId || '');
|
||||
} catch (err) {
|
||||
console.error('Fork failed:', err);
|
||||
if (uiModule) uiModule.showError('Fork failed: ' + err.message);
|
||||
|
||||
@@ -1762,7 +1762,9 @@ async function _fetchDependencies() {
|
||||
envPrefix = 'eval "$(conda shell.bash hook)" && conda activate ' + _shellQuote(_envState.envPath);
|
||||
}
|
||||
const reqBody = {
|
||||
repo_id: `${backend} setup`,
|
||||
// The server validates repo_id as a pip package spec for pip
|
||||
// installs, so it can't contain a space.
|
||||
repo_id: `${backend}-setup`,
|
||||
cmd: cmd,
|
||||
remote_host: _envState.remoteHost || undefined,
|
||||
ssh_port: _getPort(_envState.remoteHost) || undefined,
|
||||
@@ -1780,7 +1782,7 @@ async function _fetchDependencies() {
|
||||
uiModule.showToast('Run failed: ' + String(data.detail || data.error || `HTTP ${res.status}`).slice(0, 200));
|
||||
return;
|
||||
}
|
||||
const payload = { repo_id: `${backend} setup`, _cmd: cmd, remote_host: _envState.remoteHost || '', _dep: true };
|
||||
const payload = { repo_id: reqBody.repo_id, _cmd: cmd, remote_host: _envState.remoteHost || '', _dep: true };
|
||||
_addTask(data.session_id, `${backend} setup`, 'download', payload);
|
||||
uiModule.showToast(`Running ${backend} setup on ${targetHost}…`);
|
||||
} catch (err) {
|
||||
|
||||
@@ -1062,15 +1062,6 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
}
|
||||
}
|
||||
|
||||
/** Switch chat to agent mode if not already */
|
||||
function _ensureAgentMode() {
|
||||
const ab = document.getElementById('mode-agent-btn');
|
||||
const cb = document.getElementById('mode-chat-btn');
|
||||
if (ab && !ab.classList.contains('active')) {
|
||||
ab.click();
|
||||
}
|
||||
}
|
||||
|
||||
export function init(apiBase) {
|
||||
API_BASE = apiBase;
|
||||
initLibrary({
|
||||
@@ -7415,7 +7406,6 @@ import { attachColorPicker } from './colorPicker.js?v=20260910eyedropper1';
|
||||
// Doc was opened last → it goes in front of the email windows (clears the
|
||||
// email-front flag; the doc/email z-index alternation lives in CSS).
|
||||
document.body.classList.remove('email-front');
|
||||
_ensureAgentMode();
|
||||
_markDocVisibleState(_lastSessionId, 'open');
|
||||
|
||||
document.body.classList.add('doc-view');
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// Own both completion paths of an animated close so neither can outlive it.
|
||||
const _pendingCloses = new WeakMap();
|
||||
|
||||
function cancelClose(modal) {
|
||||
const pending = _pendingCloses.get(modal);
|
||||
if (!pending) return;
|
||||
clearTimeout(pending.timer);
|
||||
pending.content.removeEventListener('animationend', pending.onEnd);
|
||||
pending.content.classList.remove('modal-closing');
|
||||
_pendingCloses.delete(modal);
|
||||
}
|
||||
|
||||
export function showModal(modal) {
|
||||
if (!modal) return;
|
||||
cancelClose(modal);
|
||||
modal.classList.remove('hidden');
|
||||
}
|
||||
|
||||
export function hideModal(modal) {
|
||||
if (!modal) return;
|
||||
cancelClose(modal);
|
||||
const content = modal.querySelector('.modal-content, .settings-modal-content');
|
||||
// A minimized window has no exit animation. Registering a listener here
|
||||
// would let its next entrance animation close the newly opened window.
|
||||
if (!content || modal.classList.contains('hidden')) {
|
||||
modal.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
const pending = { content, timer: null, onEnd: null };
|
||||
const finish = () => {
|
||||
if (_pendingCloses.get(modal) !== pending) return;
|
||||
cancelClose(modal);
|
||||
modal.classList.add('hidden');
|
||||
};
|
||||
pending.onEnd = event => {
|
||||
if (event.target === content) finish();
|
||||
};
|
||||
_pendingCloses.set(modal, pending);
|
||||
content.classList.add('modal-closing');
|
||||
content.addEventListener('animationend', pending.onEnd);
|
||||
pending.timer = setTimeout(finish, 250);
|
||||
}
|
||||
+11
-10
@@ -128,7 +128,7 @@ async function _ensureDefaultPendingChat() {
|
||||
if (!_deps || _defaultChatPickInFlight) return;
|
||||
if (_deps.getCurrentSessionId && _deps.getCurrentSessionId()) return;
|
||||
const pending = _deps.getPendingChat && _deps.getPendingChat();
|
||||
if (pending && pending.modelId) return;
|
||||
if (pending && pending.modelId && pending.source !== 'fallback') return;
|
||||
_defaultChatPickInFlight = true;
|
||||
const seq = ++_defaultPendingSeq;
|
||||
try {
|
||||
@@ -954,15 +954,6 @@ export function updateModelPicker() {
|
||||
}
|
||||
}
|
||||
const latestPending = _deps.getPendingChat && _deps.getPendingChat();
|
||||
if (
|
||||
!currentSessionId &&
|
||||
!_autoSelectingDefault &&
|
||||
window.modelsModule &&
|
||||
window.modelsModule.getCachedItems &&
|
||||
(!modelId || (latestPending && latestPending.source === 'fallback'))
|
||||
) {
|
||||
_ensureDefaultPendingChat();
|
||||
}
|
||||
|
||||
let displayName = modelId ? modelId.split('/').pop() : 'Select model';
|
||||
const routeItems = window.modelsModule?.getCachedItems?.() || [];
|
||||
@@ -994,6 +985,16 @@ export function updateModelPicker() {
|
||||
label.textContent = displayName;
|
||||
}
|
||||
_updateReasoningEffortUI(modelId, s, latestPending, selectedEndpoint);
|
||||
// A cached default can resolve synchronously and re-render the picker.
|
||||
if (
|
||||
!currentSessionId &&
|
||||
!_autoSelectingDefault &&
|
||||
window.modelsModule &&
|
||||
window.modelsModule.getCachedItems &&
|
||||
(!modelId || (latestPending && latestPending.source === 'fallback'))
|
||||
) {
|
||||
_ensureDefaultPendingChat();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reasoning effort control for models supporting reasoning levels ──
|
||||
|
||||
@@ -941,13 +941,32 @@ function _exitHistorySelectMode() {
|
||||
_renderJobs();
|
||||
}
|
||||
|
||||
async function _deleteResearchFromDisk(id) {
|
||||
const response = await fetch(`${_apiBase}/api/research/${id}`, { method: 'DELETE', credentials: 'same-origin' });
|
||||
if (!response.ok) throw new Error('Research deletion failed');
|
||||
}
|
||||
|
||||
function _showDeleteFailure(count) {
|
||||
if (typeof uiModule !== 'undefined' && uiModule?.showError) {
|
||||
uiModule.showError(`Could not delete ${count} research ${count === 1 ? 'report' : 'reports'}. Please try again.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function _deleteHistoryJobs(ids) {
|
||||
const selected = new Set(ids);
|
||||
const selectedJobs = jobs.getJobs().filter(job => selected.has(job.id));
|
||||
await Promise.all(selectedJobs.filter(job => job.status === 'done').map(async job => {
|
||||
try { await fetch(`${_apiBase}/api/research/${job.id}`, { method: 'DELETE', credentials: 'same-origin' }); } catch {}
|
||||
let failed = 0;
|
||||
await Promise.all(selectedJobs.map(async job => {
|
||||
try {
|
||||
if (job.status === 'done') await _deleteResearchFromDisk(job.id);
|
||||
_historySelectedIds.delete(job.id);
|
||||
jobs.removeJob(job.id);
|
||||
} catch {
|
||||
failed++;
|
||||
}
|
||||
}));
|
||||
selectedJobs.forEach(job => jobs.removeJob(job.id));
|
||||
if (failed) _showDeleteFailure(failed);
|
||||
return failed === 0;
|
||||
}
|
||||
|
||||
async function _bulkDeleteHistory() {
|
||||
@@ -957,8 +976,8 @@ async function _bulkDeleteHistory() {
|
||||
const ok = await window.styledConfirm(`Delete ${ids.length} research ${ids.length === 1 ? 'report' : 'reports'}?`, { confirmText: 'Delete', danger: true });
|
||||
if (!ok) return;
|
||||
}
|
||||
await _deleteHistoryJobs(ids);
|
||||
_exitHistorySelectMode();
|
||||
if (await _deleteHistoryJobs(ids)) _exitHistorySelectMode();
|
||||
else _updateHistoryBulkBar();
|
||||
}
|
||||
|
||||
async function _clearHistory() {
|
||||
@@ -1296,7 +1315,12 @@ function _buildJobCard(job) {
|
||||
const ok = await window.styledConfirm('Delete this research? This permanently removes it from disk.', { confirmText: 'Delete', danger: true });
|
||||
if (!ok) return;
|
||||
}
|
||||
try { await fetch(`${_apiBase}/api/research/${job.id}`, { method: 'DELETE', credentials: 'same-origin' }); } catch {}
|
||||
try {
|
||||
await _deleteResearchFromDisk(job.id);
|
||||
} catch {
|
||||
_showDeleteFailure(1);
|
||||
return;
|
||||
}
|
||||
overflow.close();
|
||||
_animateOutThenRemove(card, () => jobs.removeJob(job.id));
|
||||
});
|
||||
|
||||
+44
-15
@@ -353,36 +353,38 @@ function _installHistoryPager(id, pageInfo, modelName) {
|
||||
handler: null,
|
||||
};
|
||||
|
||||
const loadOlder = async () => {
|
||||
if (!_historyPager || _historyPager.loading || _historyPager.done) return;
|
||||
if (_historyPager.sessionId !== currentSessionId) return;
|
||||
if (box.scrollTop > 90) return;
|
||||
const loadOlder = async (force = false) => {
|
||||
const pager = _historyPager;
|
||||
if (!pager || pager.loading || pager.done) return;
|
||||
if (pager.sessionId !== currentSessionId) return;
|
||||
if (!force && box.scrollTop > 90) return;
|
||||
|
||||
const nextOffset = Math.max(0, _historyPager.offset - _historyPager.limit);
|
||||
const nextLimit = _historyPager.offset - nextOffset;
|
||||
const nextOffset = Math.max(0, pager.offset - pager.limit);
|
||||
const nextLimit = pager.offset - nextOffset;
|
||||
if (nextLimit <= 0) {
|
||||
_historyPager.done = true;
|
||||
pager.done = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_historyPager.loading = true;
|
||||
pager.loading = true;
|
||||
const anchor = box.querySelector('.msg, .agent-thread, .gallery-bubble');
|
||||
const beforeHeight = box.scrollHeight;
|
||||
try {
|
||||
const res = await fetch(_historyUrl(_historyPager.sessionId, { limit: nextLimit, offset: nextOffset }));
|
||||
const res = await fetch(_historyUrl(pager.sessionId, { limit: nextLimit, offset: nextOffset }));
|
||||
if (!res.ok) throw new Error(`Chat history request failed (HTTP ${res.status})`);
|
||||
const data = await res.json();
|
||||
if (!_historyPager || _historyPager.sessionId !== currentSessionId) return;
|
||||
if (_historyPager !== pager || pager.sessionId !== currentSessionId) return;
|
||||
const newEls = [];
|
||||
for (const msg of data.history || []) {
|
||||
if (msg.role !== 'user' && msg.role !== 'assistant') continue;
|
||||
const els = _renderHistoryMessage(msg, _historyPager.modelName);
|
||||
const els = _renderHistoryMessage(msg, pager.modelName);
|
||||
if (Array.isArray(els)) newEls.push(...els);
|
||||
}
|
||||
for (const el of newEls) {
|
||||
box.insertBefore(el, anchor || box.firstChild);
|
||||
}
|
||||
_historyPager.offset = Number(data.offset || nextOffset);
|
||||
_historyPager.done = !data.has_more_before;
|
||||
pager.offset = Number(data.offset || nextOffset);
|
||||
pager.done = !data.has_more_before;
|
||||
if (window.hljs) {
|
||||
newEls.forEach(el => el.querySelectorAll('pre code:not(.hljs)').forEach(block => window.hljs.highlightElement(block)));
|
||||
}
|
||||
@@ -391,16 +393,42 @@ function _installHistoryPager(id, pageInfo, modelName) {
|
||||
} catch (e) {
|
||||
console.warn('Failed to load older chat history:', e);
|
||||
} finally {
|
||||
if (_historyPager) _historyPager.loading = false;
|
||||
pager.loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
_historyPager.loadOlder = loadOlder;
|
||||
_historyPager.handler = () => {
|
||||
if (box.scrollTop <= 90) loadOlder();
|
||||
if (box.scrollTop <= 90 && _historyPager && !_historyPager.loading) {
|
||||
_historyPager.pending = loadOlder();
|
||||
}
|
||||
};
|
||||
box.addEventListener('scroll', _historyPager.handler, { passive: true });
|
||||
}
|
||||
|
||||
export async function prepareHistoryForPrint() {
|
||||
const pager = _historyPager;
|
||||
if (!pager) return;
|
||||
const ensureCurrentChat = () => {
|
||||
if (_historyPager !== pager || currentSessionId !== pager.sessionId) {
|
||||
throw new Error('Chat changed while preparing the PDF. Export again from the intended chat.');
|
||||
}
|
||||
};
|
||||
while (!pager.done) {
|
||||
await pager.pending;
|
||||
ensureCurrentChat();
|
||||
if (pager.done) break;
|
||||
const previousOffset = pager.offset;
|
||||
pager.pending = pager.loadOlder(true);
|
||||
await pager.pending;
|
||||
ensureCurrentChat();
|
||||
if (!pager.done && pager.offset >= previousOffset) {
|
||||
throw new Error('Could not load the complete chat for PDF export. Please try again.');
|
||||
}
|
||||
}
|
||||
ensureCurrentChat();
|
||||
}
|
||||
|
||||
function _getIncognitoIds() {
|
||||
try { return JSON.parse(sessionStorage.getItem(_INCOGNITO_SESSIONS_KEY) || '[]'); } catch { return []; }
|
||||
}
|
||||
@@ -4061,6 +4089,7 @@ const sessionModule = {
|
||||
renderSessionList,
|
||||
loadSessions,
|
||||
selectSession,
|
||||
prepareHistoryForPrint,
|
||||
createDirectChat,
|
||||
materializePendingSession,
|
||||
preMaterializePendingSession,
|
||||
|
||||
@@ -10,6 +10,7 @@ export async function initAgentSettings() {
|
||||
var toolsInput = el('set-agentMaxTools');
|
||||
var roundsInput = el('set-agentMaxRounds');
|
||||
var supInput = el('set-agentSupervisorLadder');
|
||||
var approvalModeInput = el('set-toolApprovalMode');
|
||||
var msg = el('set-agentMsg');
|
||||
if (!toolsInput) return;
|
||||
|
||||
@@ -19,6 +20,21 @@ export async function initAgentSettings() {
|
||||
if (settings.agent_max_tool_calls) toolsInput.value = settings.agent_max_tool_calls;
|
||||
if (roundsInput && settings.agent_max_rounds) roundsInput.value = settings.agent_max_rounds;
|
||||
if (supInput) supInput.checked = !!settings.agent_supervisor_ladder;
|
||||
if (approvalModeInput) {
|
||||
approvalModeInput.value =
|
||||
settings.tool_approval_mode === 'off' ? 'off' : 'ask';
|
||||
}
|
||||
var approvalEnvNote = el('set-toolApprovalEnvNote');
|
||||
if (approvalEnvNote) {
|
||||
if (settings.tool_approval_gate_env_disabled) {
|
||||
approvalEnvNote.textContent =
|
||||
'Forced by deployment: ODYSSEUS_TOOL_APPROVAL_GATE is set to a falsy value on this server, so the approval gate is off for every run regardless of this setting.';
|
||||
approvalEnvNote.style.display = 'block';
|
||||
} else {
|
||||
approvalEnvNote.textContent = '';
|
||||
approvalEnvNote.style.display = 'none';
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// Clamp + coerce a raw input to an int in [lo, hi]; falls back to `dflt`
|
||||
@@ -37,6 +53,10 @@ export async function initAgentSettings() {
|
||||
var payload = { agent_max_tool_calls: tools };
|
||||
if (rounds != null) payload.agent_max_rounds = rounds;
|
||||
if (supInput) payload.agent_supervisor_ladder = !!supInput.checked;
|
||||
if (approvalModeInput) {
|
||||
payload.tool_approval_mode =
|
||||
approvalModeInput.value === 'off' ? 'off' : 'ask';
|
||||
}
|
||||
try {
|
||||
await _postSettings(payload);
|
||||
msg.textContent = (tools > 0 ? 'Limit: ' + tools + ' tool calls' : 'Unlimited tool calls') +
|
||||
@@ -49,6 +69,7 @@ export async function initAgentSettings() {
|
||||
toolsInput.addEventListener('change', save);
|
||||
if (roundsInput) roundsInput.addEventListener('change', save);
|
||||
if (supInput) supInput.addEventListener('change', save);
|
||||
if (approvalModeInput) approvalModeInput.addEventListener('change', save);
|
||||
var cur = parseInt(toolsInput.value, 10) || 0;
|
||||
var curR = roundsInput ? (parseInt(roundsInput.value, 10) || 20) : null;
|
||||
msg.textContent = (cur > 0 ? 'Limit: ' + cur + ' tool calls' : 'Unlimited tool calls') +
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
import { makeWindowDraggable } from '../windowDrag.js';
|
||||
import { clearDockSide } from '../modalSnap.js';
|
||||
import { toggle as restoreMinimizedModal } from '../modalManager.js';
|
||||
import { showModal, hideModal } from '../modalVisibility.js';
|
||||
|
||||
const _dragBound = new WeakSet();
|
||||
const _closeBound = new WeakSet();
|
||||
@@ -147,30 +149,16 @@ export function bindSettingsClose(modalEl, options = {}) {
|
||||
|
||||
export function showSettingsModal(modalEl) {
|
||||
if (!modalEl) return;
|
||||
if (restoreMinimizedModal(modalEl.id)) {
|
||||
showModal(modalEl);
|
||||
return;
|
||||
}
|
||||
if (modalEl.classList.contains('hidden')) {
|
||||
resetSettingsWindowPlacement(modalEl);
|
||||
}
|
||||
modalEl.classList.remove('hidden');
|
||||
showModal(modalEl);
|
||||
}
|
||||
|
||||
export function hideSettingsModal(modalEl) {
|
||||
if (!modalEl) return;
|
||||
|
||||
const content = modalEl.querySelector('.modal-content, .settings-modal-content');
|
||||
if (content && !content.classList.contains('modal-closing')) {
|
||||
content.classList.add('modal-closing');
|
||||
content.addEventListener('animationend', () => {
|
||||
modalEl.classList.add('hidden');
|
||||
content.classList.remove('modal-closing');
|
||||
}, { once: true });
|
||||
setTimeout(() => {
|
||||
if (!modalEl.classList.contains('hidden')) {
|
||||
modalEl.classList.add('hidden');
|
||||
content.classList.remove('modal-closing');
|
||||
}
|
||||
}, 250);
|
||||
return;
|
||||
}
|
||||
|
||||
modalEl.classList.add('hidden');
|
||||
hideModal(modalEl);
|
||||
}
|
||||
|
||||
+34
-8
@@ -999,6 +999,7 @@ function renderSkillsList() {
|
||||
// the footer too so it's not buried under the "⋯" menu.
|
||||
const testBtn = document.createElement('button');
|
||||
testBtn.className = 'doclib-card-text-btn doclib-card-action-btn';
|
||||
testBtn.dataset.act = 'test';
|
||||
testBtn.innerHTML = _svg(_ICON.test, { size: 11 }) + 'Test';
|
||||
testBtn.title = 'Test this skill — run it + AI judge';
|
||||
testBtn.addEventListener('click', (e) => {
|
||||
@@ -1013,14 +1014,10 @@ function renderSkillsList() {
|
||||
const _origHTML = testBtn.innerHTML;
|
||||
testBtn.innerHTML = _svg(_ICON.test, { size: 11 }) + 'Starting…';
|
||||
Promise.resolve(_testSkill(card, name)).finally(() => {
|
||||
// The preview gets overwritten by _testSkill, which removes the
|
||||
// testBtn from the DOM. The cleanup below only matters if the
|
||||
// button still exists (e.g. _testSkill bailed early).
|
||||
if (document.body.contains(testBtn)) {
|
||||
testBtn.disabled = false;
|
||||
testBtn.dataset.busy = '';
|
||||
testBtn.innerHTML = _origHTML;
|
||||
}
|
||||
// Source controls are detached during results and reused by Edit.
|
||||
testBtn.disabled = false;
|
||||
testBtn.dataset.busy = '';
|
||||
testBtn.innerHTML = _origHTML;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1285,6 +1282,10 @@ function _toggleSkillEdit(card, name) {
|
||||
if (!card._mdLoaded) return;
|
||||
const preview = card.querySelector('.skill-card-preview');
|
||||
if (!preview) return;
|
||||
if (card._skillSourceNodes) {
|
||||
preview.replaceChildren(...card._skillSourceNodes);
|
||||
card._skillSourceNodes = null;
|
||||
}
|
||||
const existing = preview.querySelector('.skill-md-editor');
|
||||
if (existing) {
|
||||
// Already editing — treat Edit as Save.
|
||||
@@ -1305,7 +1306,26 @@ function _toggleSkillEdit(card, name) {
|
||||
ta.focus();
|
||||
// Flip the Edit button label to "Save".
|
||||
const editBtn = [...preview.querySelectorAll('.doclib-card-action-btn')].find(b => /Edit|Save/.test(b.textContent));
|
||||
const editLabel = editBtn?.innerHTML;
|
||||
if (editBtn) editBtn.innerHTML = '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:3px;"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>Save';
|
||||
const cancel = document.createElement('button');
|
||||
cancel.type = 'button';
|
||||
cancel.className = 'doclib-card-text-btn doclib-card-action-btn';
|
||||
cancel.textContent = 'Cancel';
|
||||
const testBtn = preview.querySelector('[data-act="test"]');
|
||||
const testDisplay = testBtn?.style.display;
|
||||
if (testBtn) testBtn.style.display = 'none';
|
||||
card._cancelSkillEdit = () => {
|
||||
ta.remove();
|
||||
if (pre) pre.style.removeProperty('display');
|
||||
if (editBtn) editBtn.innerHTML = editLabel;
|
||||
cancel.remove();
|
||||
if (testBtn) testBtn.style.display = testDisplay;
|
||||
card._cancelSkillEdit = null;
|
||||
card._fillH?.();
|
||||
};
|
||||
cancel.addEventListener('click', (e) => { e.stopPropagation(); card._cancelSkillEdit?.(); });
|
||||
if (editBtn) editBtn.after(cancel);
|
||||
}
|
||||
|
||||
async function _saveSkillEdit(card, name) {
|
||||
@@ -1454,6 +1474,12 @@ async function _testSkill(card, name, force = false) {
|
||||
if (!card.classList.contains('doclib-card-expanded')) await _expandSkillCard(card, name);
|
||||
const preview = card.querySelector('.skill-card-preview');
|
||||
if (!preview) return;
|
||||
if (!preview.querySelector('.skill-test')) {
|
||||
card._cancelSkillEdit?.();
|
||||
// Preserve the source nodes and handlers. Result actions are nested in
|
||||
// .skill-test, so they cannot be insertBefore references on the preview.
|
||||
card._skillSourceNodes = Array.from(preview.childNodes);
|
||||
}
|
||||
preview.innerHTML =
|
||||
'<div class="skill-test"><div class="skill-test-log"></div>' +
|
||||
'<div class="skill-test-verdict"></div></div>';
|
||||
|
||||
+16
-16
@@ -7,6 +7,8 @@ import { initColorPickers, attachColorPicker } from './colorPicker.js?v=20260910
|
||||
import { hexToRgb } from './color/hex.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
import { snapModalToZone } from './tileManager.js?v=20260910responsivebounds1';
|
||||
import { toggle as restoreMinimizedModal } from './modalManager.js';
|
||||
import { showModal, hideModal } from './modalVisibility.js';
|
||||
|
||||
export const THEMES = {
|
||||
dark: { bg:'#282c34', fg:'#9cdef2', panel:'#111111', border:'#355a66', red:'#e06c75' },
|
||||
@@ -1570,31 +1572,29 @@ export function makeDraggable(el, handle) {
|
||||
}
|
||||
|
||||
// Toggle the popup
|
||||
export function openPopup() {
|
||||
const modal = document.getElementById('theme-modal');
|
||||
if (!modal) return;
|
||||
restoreMinimizedModal(modal.id);
|
||||
showModal(modal);
|
||||
}
|
||||
|
||||
export function togglePopup() {
|
||||
const modal = document.getElementById('theme-modal');
|
||||
if (!modal) return;
|
||||
const visible = !modal.classList.contains('hidden');
|
||||
const visible = !modal.classList.contains('hidden')
|
||||
&& !modal.classList.contains('modal-minimized')
|
||||
&& !modal.querySelector('.modal-closing');
|
||||
if (visible) {
|
||||
modal.classList.add('hidden');
|
||||
closePopup();
|
||||
} else {
|
||||
modal.classList.remove('hidden');
|
||||
openPopup();
|
||||
}
|
||||
}
|
||||
|
||||
export function closePopup() {
|
||||
const modal = document.getElementById('theme-modal');
|
||||
if (!modal) return;
|
||||
const content = modal.querySelector('.modal-content');
|
||||
if (content && !content.classList.contains('modal-closing')) {
|
||||
content.classList.add('modal-closing');
|
||||
content.addEventListener('animationend', () => {
|
||||
modal.classList.add('hidden');
|
||||
content.classList.remove('modal-closing');
|
||||
}, { once: true });
|
||||
setTimeout(() => { if (!modal.classList.contains('hidden')) { modal.classList.add('hidden'); content.classList.remove('modal-closing'); } }, 250);
|
||||
} else {
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
hideModal(modal);
|
||||
}
|
||||
|
||||
// Expose for app.js wiring + AI ui_control
|
||||
@@ -2278,7 +2278,7 @@ function _initEmbers() {
|
||||
draw();
|
||||
}
|
||||
|
||||
const themeModule = { initThemeUI, togglePopup, closePopup, makeDraggable,
|
||||
const themeModule = { initThemeUI, openPopup, togglePopup, closePopup, makeDraggable,
|
||||
THEMES, applyColors, applyFontDensity, applyBgPattern,
|
||||
applyBgEffectColor, applyBgEffectIntensity, applyBgEffectSize, applyBgEffectSpeed,
|
||||
applyFrostedGlass,
|
||||
|
||||
+4
-2
@@ -776,14 +776,16 @@ export function styledConfirm(message, { confirmText = 'Confirm', cancelText = '
|
||||
|
||||
if (titleEl) titleEl.textContent = title || 'Confirm';
|
||||
msgEl.textContent = message;
|
||||
// Labels sit in spans so CSS can nudge the text without moving the button.
|
||||
const btnLabel = (text) => Object.assign(document.createElement('span'), { className: 'styled-confirm-btn-label', textContent: text });
|
||||
// Use the affirmative label consistently for generic confirmations.
|
||||
okBtn.textContent = confirmText === 'OK' ? 'Yes' : confirmText;
|
||||
okBtn.replaceChildren(btnLabel(confirmText === 'OK' ? 'Yes' : confirmText));
|
||||
cancelBtn.textContent = '';
|
||||
const cancelLabel = document.createElement('span');
|
||||
cancelLabel.className = 'styled-confirm-cancel-label';
|
||||
cancelLabel.textContent = cancelText;
|
||||
cancelBtn.appendChild(cancelLabel);
|
||||
altBtn.textContent = alternateText || '';
|
||||
altBtn.replaceChildren(btnLabel(alternateText || ''));
|
||||
okBtn.className = danger ? 'confirm-btn confirm-btn-danger' : 'confirm-btn confirm-btn-primary';
|
||||
cancelBtn.className = 'confirm-btn confirm-btn-secondary';
|
||||
altBtn.className = 'confirm-btn confirm-btn-secondary';
|
||||
|
||||
+24
-23
@@ -7,7 +7,7 @@
|
||||
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
|
||||
// - API / non-GET: never cached.
|
||||
// Bump CACHE_NAME whenever the precache list or SW logic changes.
|
||||
const CACHE_NAME = 'odysseus-v1134-pr40-editor-precache';
|
||||
const CACHE_NAME = 'odysseus-v1137-pr40-editor-precache';
|
||||
|
||||
// KaTeX resolves these from its own stylesheet, so caching the CSS without them
|
||||
// gives offline math fallback glyphs instead of proper typesetting.
|
||||
@@ -38,30 +38,31 @@ const KATEX_FONTS = [
|
||||
// Both are fetched at install time, in the background. Entries must match the
|
||||
// exact URL the browser requests, query string included.
|
||||
const PRECACHE = [
|
||||
'/static/js/modalVisibility.js',
|
||||
'/static/js/compare/sse.js',
|
||||
'/',
|
||||
'/static/css/00-tokens.css?v=20260929csssplit',
|
||||
'/static/css/01-agent-chat.css?v=20260929csssplit',
|
||||
'/static/css/02-compare.css?v=20260929csssplit',
|
||||
'/static/css/03-agent-chat.css?v=20260929csssplit',
|
||||
'/static/css/04-memory.css?v=20260929csssplit',
|
||||
'/static/css/05-documents.css?v=20260929csssplit',
|
||||
'/static/css/06-admin-settings.css?v=20260929csssplit',
|
||||
'/static/css/07-documents.css?v=20260929csssplit',
|
||||
'/static/css/08-skills.css?v=20260929csssplit',
|
||||
'/static/css/09-gallery.css?v=20260929csssplit',
|
||||
'/static/css/10-cookbook.css?v=20260929csssplit',
|
||||
'/static/css/11-tasks.css?v=20260929csssplit',
|
||||
'/static/css/12-gallery.css?v=20260929csssplit',
|
||||
'/static/css/13-image-editor.css?v=20260929csssplit',
|
||||
'/static/css/14-email.css?v=20260929csssplit',
|
||||
'/static/css/15-notes.css?v=20260929csssplit',
|
||||
'/static/css/16-calendar.css?v=20260929csssplit',
|
||||
'/static/css/17-research.css?v=20260929csssplit',
|
||||
'/static/css/documents-gallery-editor.css?v=20260929csssplit',
|
||||
'/static/css/email-calendar-notes-tasks.css?v=20260929csssplit',
|
||||
'/static/css/cookbook-research-memory-settings.css?v=20260929csssplit',
|
||||
'/static/app.js?v=20260929csssplit',
|
||||
'/static/css/00-tokens.css?v=20261021confirmalign1',
|
||||
'/static/css/01-agent-chat.css?v=20261021confirmalign1',
|
||||
'/static/css/02-compare.css?v=20261021confirmalign1',
|
||||
'/static/css/03-agent-chat.css?v=20261021confirmalign1',
|
||||
'/static/css/04-memory.css?v=20261021confirmalign1',
|
||||
'/static/css/05-documents.css?v=20261021confirmalign1',
|
||||
'/static/css/06-admin-settings.css?v=20261021confirmalign1',
|
||||
'/static/css/07-documents.css?v=20261021confirmalign1',
|
||||
'/static/css/08-skills.css?v=20261021confirmalign1',
|
||||
'/static/css/09-gallery.css?v=20261021confirmalign1',
|
||||
'/static/css/10-cookbook.css?v=20261021confirmalign1',
|
||||
'/static/css/11-tasks.css?v=20261021confirmalign1',
|
||||
'/static/css/12-gallery.css?v=20261021confirmalign1',
|
||||
'/static/css/13-image-editor.css?v=20261021confirmalign1',
|
||||
'/static/css/14-email.css?v=20261021confirmalign1',
|
||||
'/static/css/15-notes.css?v=20261021confirmalign1',
|
||||
'/static/css/16-calendar.css?v=20261021confirmalign1',
|
||||
'/static/css/17-research.css?v=20261021confirmalign1',
|
||||
'/static/css/documents-gallery-editor.css?v=20261021confirmalign1',
|
||||
'/static/css/email-calendar-notes-tasks.css?v=20261021confirmalign1',
|
||||
'/static/css/cookbook-research-memory-settings.css?v=20261021confirmalign1',
|
||||
'/static/app.js?v=20261021confirmalign1',
|
||||
'/static/js/storage.js',
|
||||
'/static/js/appConfig.js',
|
||||
'/static/js/ui.js?v=20260916largetoolscroll1',
|
||||
|
||||
@@ -3,7 +3,7 @@ from tests.helpers.db_stubs import make_core_db_stub
|
||||
|
||||
|
||||
def test_text_len_ignores_non_string_values(monkeypatch):
|
||||
make_core_db_stub(monkeypatch, models=["Document", "DocumentVersion"])
|
||||
make_core_db_stub(monkeypatch, models=["Document", "DocumentVersion", "ChatMessage", "Session"])
|
||||
cli = load_script("odysseus-docs")
|
||||
|
||||
assert cli._text_len("hello") == 5
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Regression for #2123 (CLI part): `odysseus-docs search` must match
|
||||
multi-word queries and treat % / _ literally.
|
||||
|
||||
Old code built one ``%{query}%`` LIKE over title/current_content, so
|
||||
"machine learning" only matched the exact adjacent phrase, and a literal
|
||||
"%" matched every document.
|
||||
"""
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
import core.database as cdb
|
||||
from core.database import Document
|
||||
from tests.helpers.cli_loader import load_script
|
||||
|
||||
|
||||
def _cli_with_documents(monkeypatch, tmp_path):
|
||||
engine = create_engine(
|
||||
f"sqlite:///{tmp_path / 'docs_cli_search.db'}",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=NullPool,
|
||||
)
|
||||
cdb.Base.metadata.create_all(engine)
|
||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
|
||||
db = session_factory()
|
||||
try:
|
||||
db.add_all(
|
||||
[
|
||||
Document(
|
||||
id="doc-multiword",
|
||||
title="ML notes",
|
||||
current_content="machine notes on learning",
|
||||
),
|
||||
Document(
|
||||
id="doc-percent",
|
||||
title="Fabric",
|
||||
current_content="100% cotton",
|
||||
),
|
||||
Document(
|
||||
id="doc-underscore",
|
||||
title="sale_2024",
|
||||
current_content="spring campaign",
|
||||
),
|
||||
Document(
|
||||
id="doc-backslash",
|
||||
title="Path",
|
||||
current_content=r"C:\photos\sunset",
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
cli = load_script("odysseus-docs")
|
||||
monkeypatch.setattr(cli, "SessionLocal", session_factory)
|
||||
return cli
|
||||
|
||||
|
||||
def _search_ids(cli, capsys, query):
|
||||
cli.cmd_search(SimpleNamespace(query=query, limit=50, pretty=False))
|
||||
return {doc["id"] for doc in json.loads(capsys.readouterr().out)}
|
||||
|
||||
|
||||
def test_search_matches_words_in_any_order(monkeypatch, tmp_path, capsys):
|
||||
cli = _cli_with_documents(monkeypatch, tmp_path)
|
||||
|
||||
assert _search_ids(cli, capsys, "machine learning") == {"doc-multiword"}
|
||||
|
||||
|
||||
def test_search_escapes_percent_wildcard(monkeypatch, tmp_path, capsys):
|
||||
cli = _cli_with_documents(monkeypatch, tmp_path)
|
||||
|
||||
assert _search_ids(cli, capsys, "%") == {"doc-percent"}
|
||||
|
||||
|
||||
def test_search_escapes_underscore_wildcard(monkeypatch, tmp_path, capsys):
|
||||
cli = _cli_with_documents(monkeypatch, tmp_path)
|
||||
|
||||
# Unescaped, "_" matches any single char so every document matches.
|
||||
assert _search_ids(cli, capsys, "_") == {"doc-underscore"}
|
||||
|
||||
|
||||
def test_search_escapes_backslash(monkeypatch, tmp_path, capsys):
|
||||
cli = _cli_with_documents(monkeypatch, tmp_path)
|
||||
|
||||
assert _search_ids(cli, capsys, "\\") == {"doc-backslash"}
|
||||
|
||||
|
||||
def test_search_whitespace_query_returns_all(monkeypatch, tmp_path, capsys):
|
||||
cli = _cli_with_documents(monkeypatch, tmp_path)
|
||||
|
||||
assert _search_ids(cli, capsys, " ") == {
|
||||
"doc-multiword",
|
||||
"doc-percent",
|
||||
"doc-underscore",
|
||||
"doc-backslash",
|
||||
}
|
||||
@@ -348,6 +348,8 @@ function moduleSource(relativePath) {
|
||||
},
|
||||
makeWindowDraggable: (...args) => dragCalls.push(args),
|
||||
clearDockSide: (...args) => dockCalls.push(args),
|
||||
restoreMinimizedModal: () => false,
|
||||
clearTimeout() {},
|
||||
setTimeout: callback => { callback(); return 1; },
|
||||
WeakSet,
|
||||
};
|
||||
@@ -356,6 +358,7 @@ function moduleSource(relativePath) {
|
||||
vm.runInContext(moduleSource('search.js'), context, { filename: 'search.js' });
|
||||
vm.runInContext(moduleSource('sidebar.js'), context, { filename: 'sidebar.js' });
|
||||
vm.runInContext(moduleSource('navigation.js'), context, { filename: 'navigation.js' });
|
||||
vm.runInContext(moduleSource('../modalVisibility.js'), context, { filename: 'modalVisibility.js' });
|
||||
vm.runInContext(moduleSource('lifecycle.js'), context, { filename: 'lifecycle.js' });
|
||||
vm.runInContext(moduleSource('dom.js'), context, { filename: 'dom.js' });
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ const REAL_MODULES = new Set([
|
||||
path.join(JS, 'settings/sidebar.js'),
|
||||
path.join(JS, 'settings/navigation.js'),
|
||||
path.join(JS, 'settings/lifecycle.js'),
|
||||
path.join(JS, 'modalVisibility.js'),
|
||||
path.join(JS, 'settings/api.js'),
|
||||
path.join(JS, 'settings/speech.js'),
|
||||
path.join(JS, 'settings/writingStyle.js'),
|
||||
@@ -869,6 +870,7 @@ const STUBS = new Map([
|
||||
clearDockSide() {},
|
||||
},
|
||||
],
|
||||
[path.join(JS, 'modalManager.js'), { toggle() { return false; } }],
|
||||
]);
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import unittest
|
||||
|
||||
|
||||
path = Path(__file__).resolve().parents[1] / "routes/chat_routes.py"
|
||||
tree = ast.parse(path.read_text())
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
node = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == "_AgentRenderState")
|
||||
namespace = {}
|
||||
exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), "exec"), namespace)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""An unknown context window must not be budgeted like a tiny local server (#6678).
|
||||
|
||||
``budget_context_for_model`` returns 0 when the endpoint does not prove its
|
||||
window, and ``compute_input_token_budget`` answers 0 with the conservative
|
||||
default budget. The agent loop's small-server clamp (``<= 8192``) also caught
|
||||
0 and cut the request to 1200 tokens, so an open document or earlier context
|
||||
of a few thousand tokens was trimmed away before the model saw it.
|
||||
"""
|
||||
import inspect
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
DONE = 'data: [DONE]\n\n'
|
||||
MARKER = 'NIGHTJAR-7'
|
||||
# ~3000 estimated tokens: over the old 1200 clamp, under the 6000 default.
|
||||
CONTEXT = ''.join(
|
||||
f'## Project-{n}\nOwner: team {n}. Status: active. Notes: routine maintenance '
|
||||
'and quarterly review scheduled for every workstream in this registry.\n\n'
|
||||
for n in range(60)
|
||||
) + f'## Project-Alpha\nAlias: {MARKER}\n'
|
||||
|
||||
|
||||
def _event(payload):
|
||||
return 'data: ' + json.dumps(payload) + '\n\n'
|
||||
|
||||
|
||||
async def _first_request(monkeypatch, tmp_path, context_window):
|
||||
import src.agent_loop as al
|
||||
from tests.test_agent_runtime_context import _patch_fake_skills
|
||||
_patch_fake_skills(monkeypatch)
|
||||
monkeypatch.setattr('src.tool_index.get_tool_index', lambda: None)
|
||||
monkeypatch.setattr('src.model_context.budget_context_for_model',
|
||||
lambda *args, **kwargs: context_window)
|
||||
seen = []
|
||||
|
||||
async def provider(candidates, messages, **kwargs):
|
||||
# Like llm_core: the per-candidate factory shapes and trims the request.
|
||||
url, model, headers = candidates[0][:3]
|
||||
request = kwargs['candidate_request_factory'](0, url, model, headers)
|
||||
if inspect.isawaitable(request):
|
||||
request = await request
|
||||
seen.append((request or {}).get('messages', messages))
|
||||
yield _event({'delta': 'ok'})
|
||||
yield DONE
|
||||
|
||||
monkeypatch.setattr(al, 'stream_llm_with_fallback', provider)
|
||||
history = [
|
||||
{'role': 'user', 'content': 'Keep this registry for later:\n\n' + CONTEXT},
|
||||
{'role': 'assistant', 'content': 'Saved.'},
|
||||
{'role': 'user', 'content': 'What is the alias of Project-Alpha?'},
|
||||
]
|
||||
async for _ in al.stream_agent_loop('http://127.0.0.1:9/v1', 'model', history,
|
||||
max_rounds=1, workspace=str(tmp_path)):
|
||||
pass
|
||||
assert seen, 'the provider was never called'
|
||||
return json.dumps(seen[0])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_context_window_keeps_earlier_context(monkeypatch, tmp_path):
|
||||
request = await _first_request(monkeypatch, tmp_path, 0)
|
||||
assert MARKER in request
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_known_small_window_is_still_clamped(monkeypatch, tmp_path):
|
||||
# The clamp exists for small local servers whose window is known (n_ctx
|
||||
# 2048 here); it must keep protecting them.
|
||||
request = await _first_request(monkeypatch, tmp_path, 2048)
|
||||
assert MARKER not in request
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
"""New usernames must be usable as a URL path segment and not carry control characters."""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from tests.test_auth_policy import _auth_module, _make_manager, _setup_endpoint, _signup_endpoint
|
||||
|
||||
BAD = ["a/b", "../x", "a\\b", "tab\tname", "new\nline", "..", ".", "u" * 65]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", BAD)
|
||||
def test_username_error_rejects(name):
|
||||
assert _auth_module().username_error(name)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["alice", "Bob Smith", "j.doe", "user@example.com", "日本語", "u" * 64])
|
||||
def test_username_error_accepts(name):
|
||||
assert _auth_module().username_error(name) is None
|
||||
|
||||
|
||||
def test_username_error_requires_a_name():
|
||||
assert _auth_module().username_error(" ") == "Username is required"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["a/b", "u" * 65])
|
||||
def test_setup_rejects_bad_username(tmp_path, name):
|
||||
mgr = _make_manager(tmp_path)
|
||||
endpoint, SetupRequest = _setup_endpoint(mgr)
|
||||
request = SimpleNamespace(client=SimpleNamespace(host="127.0.0.1"))
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
asyncio.run(endpoint(body=SetupRequest(username=name, password="long-enough-pw"), request=request))
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
assert not mgr.is_configured
|
||||
|
||||
|
||||
def test_signup_rejects_bad_username(tmp_path):
|
||||
mgr = _make_manager(tmp_path)
|
||||
mgr.create_user("admin", "admin-password", is_admin=True)
|
||||
mgr.signup_enabled = True
|
||||
endpoint, SignupRequest = _signup_endpoint(mgr)
|
||||
request = SimpleNamespace(client=SimpleNamespace(host="127.0.0.1"))
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
asyncio.run(endpoint(body=SignupRequest(username="a/b", password="long-enough-pw"), request=request))
|
||||
|
||||
assert exc.value.status_code == 400
|
||||
assert "a/b" not in mgr.users
|
||||
@@ -3,8 +3,8 @@ from tests.helpers.stylesheets import app_css
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CHAT = (ROOT / "static/js/chat.js").read_text()
|
||||
SESSIONS = (ROOT / "static/js/sessions.js").read_text()
|
||||
CHAT = (ROOT / "static/js/chat.js").read_text(encoding="utf-8")
|
||||
SESSIONS = (ROOT / "static/js/sessions.js").read_text(encoding="utf-8")
|
||||
CSS = app_css()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Effective RAG policy reaches the real search and context consumer."""
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.chat_processor import ChatProcessor
|
||||
from tests.test_kv_cache_invalidation_2927 import _build_context_harness
|
||||
|
||||
pytestmark = pytest.mark.area_routes
|
||||
|
||||
|
||||
@pytest.mark.parametrize('mode', ['chat', 'agent', 'compare'])
|
||||
@pytest.mark.parametrize('policy', [
|
||||
'normal', 'incognito', 'preprocessing_disabled', 'research_spinoff', 'low_signal',
|
||||
])
|
||||
@pytest.mark.parametrize('use_rag', [None, 'true', 'false', True, False])
|
||||
async def test_rag_policy_preserves_context_channels(monkeypatch, mode, policy, use_rag):
|
||||
"""Exercise effective policy with real preface assembly and synthetic I/O.
|
||||
|
||||
Nonempty results prove permitted retrieval still injects context. Independent
|
||||
memory, skills and web spies ensure a RAG disable cannot disable other channels.
|
||||
"""
|
||||
from routes import chat_helpers
|
||||
from src import chat_processor, llm_core, user_time
|
||||
|
||||
sess, request, handler, _ = _build_context_harness(monkeypatch, chat_helpers, history=[])
|
||||
monkeypatch.setattr(chat_helpers, 'load_prefs_for_user', lambda user: {
|
||||
'memory_enabled': True, 'skills_enabled': True})
|
||||
monkeypatch.setattr(user_time, 'current_datetime_context_message', lambda: {
|
||||
'role': 'user', 'content': 'Synthetic current date context'})
|
||||
incognito = policy == 'incognito'
|
||||
preprocessing = policy != 'preprocessing_disabled'
|
||||
if policy == 'research_spinoff':
|
||||
sess.history = [{'role': 'system', 'metadata': {'research_spinoff_from': 'fixture-report'}}]
|
||||
message = 'hi' if policy == 'low_signal' else 'Find the retention policy for archived project documents'
|
||||
search = Mock(return_value=[{
|
||||
'document': 'Synthetic retention document',
|
||||
'metadata': {'filename': 'fixture.txt'}, 'similarity': 0.99,
|
||||
}])
|
||||
memory = Mock(return_value=[{
|
||||
'id': 'fixture-memory', 'text': 'User identity synthetic memory',
|
||||
'category': 'identity', 'pinned': True,
|
||||
}])
|
||||
skills = Mock(return_value=[{'name': 'fixture_skill', 'description': 'Synthetic skill'}])
|
||||
web = Mock(return_value=('Synthetic web context', [{'url': 'https://example.invalid/fixture'}]))
|
||||
query = Mock(return_value='retention policy')
|
||||
monkeypatch.setattr(llm_core, 'llm_call', query)
|
||||
monkeypatch.setattr(chat_processor, 'comprehensive_web_search', web)
|
||||
processor = ChatProcessor(
|
||||
memory_manager=SimpleNamespace(load=memory),
|
||||
personal_docs_manager=SimpleNamespace(rag_manager=SimpleNamespace(search=search)),
|
||||
skills_manager=SimpleNamespace(index_for=skills),
|
||||
)
|
||||
received = []
|
||||
real_preface = processor.build_context_preface
|
||||
|
||||
def capture(**kwargs):
|
||||
received.append(dict(kwargs))
|
||||
return real_preface(**kwargs)
|
||||
|
||||
monkeypatch.setattr(processor, 'build_context_preface', capture)
|
||||
ctx = await chat_helpers.build_chat_context(
|
||||
sess=sess, request=request, chat_handler=handler, chat_processor=processor,
|
||||
message=message, session_id='c5-policy', use_rag=use_rag,
|
||||
incognito=incognito, allow_tool_preprocessing=preprocessing,
|
||||
persist_user_message=False, agent_mode=mode == 'agent',
|
||||
compare_mode=mode == 'compare', use_web='true',
|
||||
search_context='Synthetic prefetched context' if mode == 'compare' else None,
|
||||
)
|
||||
rag_allowed = policy == 'normal' and str(use_rag).lower() != 'false'
|
||||
assert len(received) == 1
|
||||
assert search.call_count == int(rag_allowed)
|
||||
assert received[0]['use_rag'] is rag_allowed
|
||||
if rag_allowed:
|
||||
search.assert_called_once_with(message, k=5, owner='tester')
|
||||
assert ctx.rag_sources == [{
|
||||
'filename': 'fixture.txt', 'snippet': 'Synthetic retention document', 'similarity': 0.99,
|
||||
}]
|
||||
else:
|
||||
assert ctx.rag_sources == []
|
||||
text = '\n'.join(m.get('content', '') for m in ctx.messages)
|
||||
assert ('Synthetic retention document' in text) is rag_allowed
|
||||
|
||||
memory_allowed = policy == 'normal'
|
||||
assert received[0]['use_memory'] is memory_allowed
|
||||
assert memory.call_count == int(memory_allowed)
|
||||
assert ('User identity synthetic memory' in text) is memory_allowed
|
||||
assert bool(ctx.used_memories) is memory_allowed
|
||||
skills_allowed = policy in {'normal', 'research_spinoff'}
|
||||
assert received[0]['use_skills'] is skills_allowed
|
||||
assert skills.call_count == int(skills_allowed and mode == 'agent')
|
||||
assert ('fixture_skill' in text) is (skills_allowed and mode == 'agent')
|
||||
web_allowed = preprocessing and policy != 'low_signal' and mode != 'compare'
|
||||
assert received[0]['use_web'] is web_allowed
|
||||
assert web.call_count == query.call_count == int(web_allowed)
|
||||
assert ('Synthetic web context' in text) is web_allowed
|
||||
assert bool(ctx.web_sources) is web_allowed
|
||||
assert ('Synthetic prefetched context' in text) is (
|
||||
mode == 'compare' and preprocessing and policy != 'low_signal')
|
||||
@@ -0,0 +1,61 @@
|
||||
"""The shipped CSS must print every message beyond the screen viewport."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pypdf import PdfReader
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
@pytest.mark.skipif(not shutil.which("node"), reason="node is required for Chromium printing")
|
||||
def test_chat_print_preserves_the_complete_transcript(tmp_path):
|
||||
script = r'''
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { chromium } from 'playwright';
|
||||
const origin = process.env.ODYSSEUS_TEST_STATIC_ORIGIN;
|
||||
const source = fs.readFileSync('static/index.html', 'utf8');
|
||||
const links = [...source.matchAll(/<link\b[^>]*rel="stylesheet"[^>]*>/g)]
|
||||
.map(match => match[0]).join('\n');
|
||||
const messages = Array.from({length: 24}, (_, index) => {
|
||||
const marker = 'EXPORTSENTINEL' + String(index + 1).padStart(2, '0');
|
||||
const paragraphs = Array.from({length:4}, () =>
|
||||
'<p>This synthetic message must survive printing beyond the screen viewport. '
|
||||
+ 'Every paragraph belongs to the saved conversation.</p>').join('');
|
||||
return `<div class="msg ${index % 2 ? 'msg-ai' : 'msg-user'}"><div class="body"><p>${marker}</p>${paragraphs}</div></div>`;
|
||||
}).join('');
|
||||
const browser = await chromium.launch({headless:true});
|
||||
try {
|
||||
const results=[];
|
||||
for (const [layout,viewport] of [['desktop',{width:1440,height:1000}],['mobile',{width:390,height:844}]]) {
|
||||
const page=await browser.newPage({viewport});
|
||||
await page.goto(origin+'/static/css/00-tokens.css');
|
||||
await page.setContent(`<html><head>${links}</head><body><main class="chat-container"><div id="chat-history">${messages}</div></main></body></html>`,{waitUntil:'load'});
|
||||
await page.evaluate(()=>document.fonts.ready);
|
||||
const file=path.join(process.env.ODYSSEUS_PDF_OUTPUT_DIR,layout+'.pdf');
|
||||
await page.pdf({path:file,format:'A4',printBackground:true});
|
||||
results.push({layout,file});
|
||||
await page.close();
|
||||
}
|
||||
console.log(JSON.stringify(results));
|
||||
} finally {await browser.close();}
|
||||
'''
|
||||
result = subprocess.run(
|
||||
["node", "--input-type=module", "-e", script], cwd=ROOT,
|
||||
capture_output=True, text=True, timeout=90,
|
||||
env={**os.environ, "ODYSSEUS_PDF_OUTPUT_DIR": str(tmp_path)},
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
results = json.loads(result.stdout)
|
||||
assert {row["layout"] for row in results} == {"desktop", "mobile"}
|
||||
for row in results:
|
||||
pdf = PdfReader(row["file"])
|
||||
text = "".join("".join(page.extract_text().split()) for page in pdf.pages)
|
||||
assert len(pdf.pages) > 1, row["layout"]
|
||||
for index in range(1, 25):
|
||||
assert f"EXPORTSENTINEL{index:02d}" in text, (row["layout"], index)
|
||||
@@ -2,8 +2,8 @@ from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CHAT = (ROOT / "static/js/chat.js").read_text()
|
||||
RENDERER = (ROOT / "static/js/chatRenderer.js").read_text()
|
||||
CHAT = (ROOT / "static/js/chat.js").read_text(encoding="utf-8")
|
||||
RENDERER = (ROOT / "static/js/chatRenderer.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_processing_indicator_shows_live_ttft_timer():
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Every variable a shipped compose file forwards must carry a default.
|
||||
|
||||
A bare ``${VAR}`` makes ``docker compose`` print "variable is not set" on every
|
||||
command for a default ``.env`` (#6619), which greets fresh installs with a
|
||||
warning that looks like a misconfiguration.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
COMPOSE_FILES = sorted(ROOT.glob("docker-compose*.yml")) + sorted((ROOT / "docker").glob("*.yml"))
|
||||
|
||||
# ${VAR} with no ``:-``/``-``/``:?``/``?`` modifier.
|
||||
BARE_VAR = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", COMPOSE_FILES, ids=lambda p: p.relative_to(ROOT).as_posix())
|
||||
def test_compose_variables_have_defaults(path):
|
||||
bare = [
|
||||
f"{path.name}:{lineno}: ${{{name}}}"
|
||||
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1)
|
||||
if not line.lstrip().startswith("#")
|
||||
for name in BARE_VAR.findall(line)
|
||||
]
|
||||
assert not bare, "compose variables without a default:\n" + "\n".join(bare)
|
||||
@@ -1,5 +1,9 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.helpers.database import disposable_database
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
COOKBOOK_RUNNING = ROOT / "static" / "js" / "cookbookRunning.js"
|
||||
@@ -28,3 +32,496 @@ def test_cookbook_advertised_bind_urls_keep_connectable_host():
|
||||
assert "function _endpointFromAdvertisedUrl" in src
|
||||
assert "_isAnyBindHost(u.hostname) ? currentHost" in src
|
||||
assert "host = u.hostname || host;" not in src
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def endpoint_db(tmp_path, monkeypatch):
|
||||
import core.database as database
|
||||
import routes.model_routes as model_routes
|
||||
|
||||
with disposable_database(tmp_path) as factory:
|
||||
monkeypatch.setattr(database, "SessionLocal", factory)
|
||||
monkeypatch.setattr(model_routes, "_probe_endpoint", lambda *args, **kwargs: None)
|
||||
yield factory
|
||||
|
||||
|
||||
def _launch_llm(
|
||||
repo_id: str,
|
||||
port: int,
|
||||
serve_session_id: str | None = None,
|
||||
) -> str:
|
||||
import routes.cookbook_routes as cookbook_routes
|
||||
from routes.cookbook_helpers import ServeRequest
|
||||
|
||||
router = cookbook_routes.setup_cookbook_routes()
|
||||
serve = next(
|
||||
route.endpoint for route in router.routes
|
||||
if route.path == "/api/model/serve" and "POST" in route.methods
|
||||
)
|
||||
cells = dict(zip(serve.__code__.co_freevars, serve.__closure__ or ()))
|
||||
register = cells["_auto_register_llm_endpoint"].cell_contents
|
||||
endpoint_id = register(
|
||||
ServeRequest(repo_id=repo_id, cmd=f"vllm serve {repo_id} --port {port}"),
|
||||
None,
|
||||
serve_session_id=serve_session_id,
|
||||
)
|
||||
assert endpoint_id
|
||||
return endpoint_id
|
||||
|
||||
|
||||
def _endpoint(factory, endpoint_id: str):
|
||||
import core.database as database
|
||||
|
||||
with factory() as db:
|
||||
return db.get(database.ModelEndpoint, endpoint_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reuse", [False, True])
|
||||
def test_launch_keeps_endpoint_of_other_model_with_same_short_name(endpoint_db, reuse):
|
||||
if reuse:
|
||||
mirror = _launch_llm("unsloth/Llama-3.1-8B-Instruct", 8002)
|
||||
original = _launch_llm("meta-llama/Llama-3.1-8B-Instruct", 8001)
|
||||
|
||||
launched = _launch_llm("unsloth/Llama-3.1-8B-Instruct", 8002)
|
||||
|
||||
if reuse:
|
||||
assert launched == mirror
|
||||
assert _endpoint(endpoint_db, original) is not None
|
||||
assert _endpoint(endpoint_db, launched) is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reuse", [False, True])
|
||||
def test_relaunch_on_another_port_preserves_existing_endpoint(endpoint_db, reuse):
|
||||
if reuse:
|
||||
current = _launch_llm("meta-llama/Llama-3.1-8B-Instruct", 8002)
|
||||
stale = _launch_llm("Qwen/Qwen3-8B", 8001)
|
||||
|
||||
launched = _launch_llm("Qwen/Qwen3-8B", 8002)
|
||||
|
||||
if reuse:
|
||||
assert launched == current
|
||||
assert _endpoint(endpoint_db, stale) is not None
|
||||
assert _endpoint(endpoint_db, launched) is not None
|
||||
|
||||
|
||||
def test_sweep_ignores_endpoint_now_serving_a_different_model(endpoint_db):
|
||||
endpoint = _launch_llm("Qwen/Qwen3-8B", 8001)
|
||||
_launch_llm("meta-llama/Llama-3.1-8B", 8001)
|
||||
_launch_llm("Qwen/Qwen3-8B", 8001)
|
||||
|
||||
_launch_llm("meta-llama/Llama-3.1-8B", 8002)
|
||||
|
||||
assert _endpoint(endpoint_db, endpoint) is not None
|
||||
|
||||
|
||||
def test_sweep_ignores_endpoints_not_created_by_cookbook(endpoint_db):
|
||||
import core.database as database
|
||||
|
||||
with endpoint_db() as db:
|
||||
db.add(database.ModelEndpoint(
|
||||
id="manual", name="Qwen3-8B", base_url="http://localhost:9000/v1",
|
||||
pinned_models='["Qwen/Qwen3-8B"]',
|
||||
))
|
||||
db.commit()
|
||||
|
||||
_launch_llm("Qwen/Qwen3-8B", 8002)
|
||||
|
||||
assert _endpoint(endpoint_db, "manual") is not None
|
||||
|
||||
|
||||
def test_manual_pin_order_does_not_change_on_relaunch(endpoint_db):
|
||||
import json
|
||||
from core.database import ModelEndpoint
|
||||
|
||||
endpoint_id = _launch_llm("organization-a/Model-A", 8001)
|
||||
|
||||
with endpoint_db() as db:
|
||||
endpoint = db.get(ModelEndpoint, endpoint_id)
|
||||
endpoint.pinned_models = json.dumps([
|
||||
"organization-a/Model-A",
|
||||
"organization-b/Model-B",
|
||||
])
|
||||
db.commit()
|
||||
|
||||
_launch_llm("organization-a/Model-A", 8001)
|
||||
|
||||
with endpoint_db() as db:
|
||||
endpoint = db.get(ModelEndpoint, endpoint_id)
|
||||
assert json.loads(endpoint.pinned_models) == [
|
||||
"organization-a/Model-A",
|
||||
"organization-b/Model-B",
|
||||
]
|
||||
|
||||
|
||||
def test_manual_pins_cannot_trigger_endpoint_deletion(endpoint_db):
|
||||
import json
|
||||
from core.database import ModelEndpoint
|
||||
|
||||
original = _launch_llm("organization-a/Model-A", 8001)
|
||||
|
||||
with endpoint_db() as db:
|
||||
endpoint = db.get(ModelEndpoint, original)
|
||||
endpoint.pinned_models = json.dumps([
|
||||
"organization-a/Model-A",
|
||||
"organization-b/Model-B",
|
||||
])
|
||||
db.commit()
|
||||
|
||||
_launch_llm("organization-b/Model-B", 8002)
|
||||
|
||||
assert _endpoint(endpoint_db, original) is not None
|
||||
|
||||
|
||||
def test_unavailable_model_probe_never_deletes_another_endpoint(
|
||||
endpoint_db, monkeypatch
|
||||
):
|
||||
import routes.model_routes as model_routes
|
||||
|
||||
original = _launch_llm("organization-a/Model-A", 8001)
|
||||
|
||||
monkeypatch.setattr(
|
||||
model_routes,
|
||||
"_probe_endpoint",
|
||||
lambda *args, **kwargs: [],
|
||||
)
|
||||
|
||||
_launch_llm("organization-a/Model-A", 8002)
|
||||
|
||||
assert _endpoint(endpoint_db, original) is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_old_watchdog_cannot_delete_reused_endpoint(
|
||||
endpoint_db,
|
||||
monkeypatch,
|
||||
endpoint_http_api,
|
||||
):
|
||||
import asyncio
|
||||
import urllib.request
|
||||
import routes.cookbook_routes as cookbook_routes
|
||||
|
||||
original = _launch_llm(
|
||||
"organization-a/Model-A",
|
||||
8001,
|
||||
serve_session_id="serve-old",
|
||||
)
|
||||
|
||||
reused = _launch_llm(
|
||||
"organization-b/Model-B",
|
||||
8001,
|
||||
serve_session_id="serve-new",
|
||||
)
|
||||
|
||||
assert original == reused
|
||||
|
||||
with endpoint_db() as db:
|
||||
from core.database import ModelEndpoint
|
||||
|
||||
endpoint = db.get(ModelEndpoint, reused)
|
||||
assert endpoint.cookbook_serve_session_id == "serve-new"
|
||||
|
||||
router = cookbook_routes.setup_cookbook_routes()
|
||||
serve = next(
|
||||
route.endpoint
|
||||
for route in router.routes
|
||||
if route.path == "/api/model/serve"
|
||||
and "POST" in route.methods
|
||||
)
|
||||
|
||||
cells = dict(
|
||||
zip(
|
||||
serve.__code__.co_freevars,
|
||||
serve.__closure__ or (),
|
||||
)
|
||||
)
|
||||
|
||||
watchdog = cells["_serve_crash_watchdog"].cell_contents
|
||||
|
||||
class FakeProcess:
|
||||
async def communicate(self):
|
||||
return (
|
||||
b"=== Process exited with code 1 ===\n",
|
||||
b"",
|
||||
)
|
||||
|
||||
async def fake_subprocess(*args, **kwargs):
|
||||
return FakeProcess()
|
||||
|
||||
async def fake_sleep(seconds):
|
||||
return None
|
||||
|
||||
def unavailable(*args, **kwargs):
|
||||
raise OSError("Server still warming up")
|
||||
|
||||
monkeypatch.setattr(
|
||||
asyncio,
|
||||
"create_subprocess_exec",
|
||||
fake_subprocess,
|
||||
)
|
||||
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
|
||||
monkeypatch.setattr(
|
||||
urllib.request,
|
||||
"urlopen",
|
||||
unavailable,
|
||||
)
|
||||
|
||||
await watchdog(
|
||||
endpoint_id=original,
|
||||
session_id="serve-old",
|
||||
remote=None,
|
||||
ssh_port=None,
|
||||
is_windows=False,
|
||||
)
|
||||
|
||||
assert _endpoint(endpoint_db, reused) is not None
|
||||
|
||||
# The current serve's own crash watchdog must still be able
|
||||
# to remove its endpoint when it actually fails.
|
||||
await watchdog(
|
||||
endpoint_id=reused,
|
||||
session_id="serve-new",
|
||||
remote=None,
|
||||
ssh_port=None,
|
||||
is_windows=False,
|
||||
)
|
||||
|
||||
assert _endpoint(endpoint_db, reused) is None
|
||||
|
||||
|
||||
def test_cookbook_serve_session_migration_is_idempotent(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
import core.database as database
|
||||
|
||||
engine = create_engine(
|
||||
f"sqlite:///{tmp_path / 'legacy-endpoints.db'}"
|
||||
)
|
||||
|
||||
try:
|
||||
with engine.begin() as conn:
|
||||
conn.execute(
|
||||
text(
|
||||
"CREATE TABLE model_endpoints "
|
||||
"(id VARCHAR PRIMARY KEY)"
|
||||
)
|
||||
)
|
||||
|
||||
monkeypatch.setattr(database, "engine", engine)
|
||||
|
||||
database._migrate_add_cookbook_serve_session_id_column()
|
||||
database._migrate_add_cookbook_serve_session_id_column()
|
||||
|
||||
columns = {
|
||||
column["name"]
|
||||
for column in inspect(engine).get_columns(
|
||||
"model_endpoints"
|
||||
)
|
||||
}
|
||||
|
||||
assert "cookbook_serve_session_id" in columns
|
||||
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def endpoint_http_api(endpoint_db, monkeypatch):
|
||||
"""Route deletion through a disposable in-process FastAPI app."""
|
||||
import httpx
|
||||
from fastapi import FastAPI
|
||||
import routes.model_routes as model_routes
|
||||
import routes.prefs_routes as prefs_routes
|
||||
import core.constants as constants
|
||||
import src.cookbook_serve_lifecycle as lifecycle
|
||||
|
||||
monkeypatch.setattr(model_routes, "SessionLocal", endpoint_db)
|
||||
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
|
||||
|
||||
settings = {}
|
||||
preferences = {}
|
||||
monkeypatch.setattr(model_routes, "_load_settings", lambda: settings)
|
||||
monkeypatch.setattr(model_routes, "_save_settings", lambda value: settings.update(value))
|
||||
monkeypatch.setattr(prefs_routes, "_load", lambda: preferences)
|
||||
monkeypatch.setattr(prefs_routes, "_save", lambda value: preferences.update(value))
|
||||
|
||||
# Endpoint deletion must not touch the user's running sessions.
|
||||
import src.ai_interaction as ai_interaction
|
||||
monkeypatch.setattr(ai_interaction, "get_session_manager", lambda: None)
|
||||
|
||||
app = FastAPI()
|
||||
router = model_routes.setup_model_routes(None)
|
||||
app.include_router(router)
|
||||
|
||||
original_client = httpx.AsyncClient
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
|
||||
def client_factory(*args, **kwargs):
|
||||
kwargs["transport"] = transport
|
||||
return original_client(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(httpx, "AsyncClient", client_factory)
|
||||
monkeypatch.setattr(constants, "internal_api_base", lambda: "http://testserver")
|
||||
monkeypatch.setattr(lifecycle, "internal_api_base", lambda: "http://testserver")
|
||||
|
||||
return {"settings": settings, "preferences": preferences, "router": router}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ownership_condition_preserves_settings_on_mismatch(
|
||||
endpoint_db, endpoint_http_api,
|
||||
):
|
||||
import httpx
|
||||
original = _launch_llm("organization-a/Model-A", 8001, "serve-old")
|
||||
reused = _launch_llm("organization-b/Model-B", 8001, "serve-new")
|
||||
assert reused == original
|
||||
settings = endpoint_http_api["settings"]
|
||||
settings.update({"default_endpoint_id": reused, "default_model": "organization-b/Model-B"})
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.delete(
|
||||
f"http://testserver/api/model-endpoints/{reused}",
|
||||
params={"expected_cookbook_serve_session_id": "serve-old"},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
assert _endpoint(endpoint_db, reused) is not None
|
||||
assert settings["default_endpoint_id"] == reused
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_owned_deletion_clears_settings_and_model_cache(
|
||||
endpoint_db, endpoint_http_api,
|
||||
):
|
||||
import httpx
|
||||
from core.database import Session as DbSession
|
||||
|
||||
endpoint_id = _launch_llm("organization-a/Model-A", 8001, "serve-current")
|
||||
settings = endpoint_http_api["settings"]
|
||||
settings.update({
|
||||
"default_endpoint_id": endpoint_id,
|
||||
"default_model": "organization-a/Model-A",
|
||||
"foreground_model_fallbacks": [{"endpoint_id": endpoint_id, "model": "x"}],
|
||||
})
|
||||
with endpoint_db() as db:
|
||||
db.add(DbSession(
|
||||
id="owned-delete-session", name="Test", owner=None,
|
||||
endpoint_url="http://localhost:8001/v1",
|
||||
endpoint_id=endpoint_id, model="organization-a/Model-A",
|
||||
headers={"Authorization": "Bearer test-only"},
|
||||
))
|
||||
db.commit()
|
||||
|
||||
# Seed the actual model listing cache captured by the route closure.
|
||||
router = endpoint_http_api["router"]
|
||||
route = next(r for r in router.routes if r.path == "/api/model-endpoints/{ep_id}" and "DELETE" in r.methods)
|
||||
closure = dict(zip(route.endpoint.__code__.co_freevars, route.endpoint.__closure__ or ()))
|
||||
invalidate = closure["_invalidate_models_cache"].cell_contents
|
||||
cache_closure = dict(zip(invalidate.__code__.co_freevars, invalidate.__closure__ or ()))
|
||||
models_cache = cache_closure["_models_cache"].cell_contents
|
||||
models_cache["test"] = {"data": [], "time": 0}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.delete(
|
||||
f"http://testserver/api/model-endpoints/{endpoint_id}",
|
||||
params={"expected_cookbook_serve_session_id": "serve-current"},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert _endpoint(endpoint_db, endpoint_id) is None
|
||||
assert settings["default_endpoint_id"] == ""
|
||||
assert settings["foreground_model_fallbacks"] == []
|
||||
assert not models_cache
|
||||
with endpoint_db() as db:
|
||||
sess = db.get(DbSession, "owned-delete-session")
|
||||
assert sess.headers == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ordinary_admin_delete_does_not_require_serve_owner(
|
||||
endpoint_db, endpoint_http_api,
|
||||
):
|
||||
import httpx
|
||||
endpoint_id = _launch_llm("organization-a/Model-A", 8001, "serve-new")
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.delete(f"http://testserver/api/model-endpoints/{endpoint_id}")
|
||||
assert response.status_code == 200, response.text
|
||||
assert _endpoint(endpoint_db, endpoint_id) is None
|
||||
|
||||
|
||||
def _launch_image(repo_id: str, port: int, monkeypatch) -> str:
|
||||
import routes.cookbook_routes as cookbook_routes
|
||||
import src.settings as settings
|
||||
from routes.cookbook_helpers import ServeRequest
|
||||
|
||||
monkeypatch.setattr(settings, "load_settings", lambda: {"image_gen_enabled": True, "image_model": repo_id})
|
||||
monkeypatch.setattr(settings, "save_settings", lambda _settings: None)
|
||||
router = cookbook_routes.setup_cookbook_routes()
|
||||
serve = next(
|
||||
route.endpoint for route in router.routes
|
||||
if route.path == "/api/model/serve" and "POST" in route.methods
|
||||
)
|
||||
cells = dict(zip(serve.__code__.co_freevars, serve.__closure__ or ()))
|
||||
register = cells["_auto_register_image_endpoint"].cell_contents
|
||||
endpoint_id = register(
|
||||
ServeRequest(repo_id=repo_id, cmd=f"python diffusion_server.py --model {repo_id} --port {port}"), None
|
||||
)
|
||||
assert endpoint_id
|
||||
return endpoint_id
|
||||
|
||||
|
||||
def _rename(factory, endpoint_id: str, name: str) -> None:
|
||||
import core.database as database
|
||||
|
||||
with factory() as db:
|
||||
db.get(database.ModelEndpoint, endpoint_id).name = name
|
||||
db.commit()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("relaunched", ["Qwen/Qwen3-8B", "meta-llama/Llama-3.1-8B"])
|
||||
def test_relaunch_keeps_renamed_endpoint_name(endpoint_db, relaunched):
|
||||
endpoint = _launch_llm("Qwen/Qwen3-8B", 8001)
|
||||
_rename(endpoint_db, endpoint, "Workstation GPU")
|
||||
|
||||
assert _launch_llm(relaunched, 8001) == endpoint
|
||||
|
||||
assert _endpoint(endpoint_db, endpoint).name == "Workstation GPU"
|
||||
|
||||
|
||||
def test_relaunch_names_endpoint_after_served_model_when_not_renamed(endpoint_db):
|
||||
endpoint = _launch_llm("Qwen/Qwen3-8B", 8001)
|
||||
|
||||
for repo_id, name in [
|
||||
("meta-llama/Llama-3.1-8B", "Llama-3.1-8B"),
|
||||
("Qwen/Qwen3-8B", "Qwen3-8B"),
|
||||
("meta-llama/Llama-3.1-8B", "Llama-3.1-8B"),
|
||||
]:
|
||||
_launch_llm(repo_id, 8001)
|
||||
assert _endpoint(endpoint_db, endpoint).name == name
|
||||
|
||||
|
||||
def test_launch_keeps_name_of_endpoint_without_pinned_models(endpoint_db):
|
||||
import core.database as database
|
||||
|
||||
with endpoint_db() as db:
|
||||
db.add(database.ModelEndpoint(id="manual", name="My vLLM box", base_url="http://localhost:8001/v1"))
|
||||
db.commit()
|
||||
|
||||
assert _launch_llm("Qwen/Qwen3-8B", 8001) == "manual"
|
||||
|
||||
assert _endpoint(endpoint_db, "manual").name == "My vLLM box"
|
||||
|
||||
|
||||
def test_image_relaunch_keeps_renamed_endpoint_name(endpoint_db, monkeypatch):
|
||||
endpoint = _launch_image("black-forest-labs/FLUX.1-schnell", 8100, monkeypatch)
|
||||
_rename(endpoint_db, endpoint, "Image box")
|
||||
|
||||
_launch_image("stabilityai/sdxl-turbo", 8100, monkeypatch)
|
||||
|
||||
assert _endpoint(endpoint_db, endpoint).name == "Image box"
|
||||
|
||||
|
||||
def test_image_relaunch_names_endpoint_after_served_model_when_not_renamed(endpoint_db, monkeypatch):
|
||||
endpoint = _launch_image("black-forest-labs/FLUX.1-schnell", 8100, monkeypatch)
|
||||
|
||||
_launch_image("stabilityai/sdxl-turbo", 8100, monkeypatch)
|
||||
|
||||
assert _endpoint(endpoint_db, endpoint).name == "sdxl-turbo (image)"
|
||||
|
||||
@@ -70,3 +70,63 @@ def test_no_gguf_found_pattern():
|
||||
result = _diagnose_serve_output(out)
|
||||
assert result is not None
|
||||
assert "GGUF" in result["message"]
|
||||
|
||||
|
||||
def test_cookbook_routes_uses_shared_diagnosis_helper():
|
||||
# /api/cookbook/tasks/status must use the maintained helper. A nested copy in
|
||||
# cookbook_routes.py shadowed the import and silently drifted (missing the
|
||||
# ModelOpt, mflux and MLX bridge diagnoses).
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
source = (Path(__file__).resolve().parents[1] / "routes" / "cookbook_routes.py").read_text(encoding="utf-8")
|
||||
local_defs = [
|
||||
node.lineno
|
||||
for node in ast.walk(ast.parse(source))
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == "_diagnose_serve_output"
|
||||
]
|
||||
assert local_defs == []
|
||||
|
||||
|
||||
def test_modelopt_lm_head_checkpoint_returns_diagnosis():
|
||||
out = "ValueError: There is no module or parameter named 'lm_head.input_scale' in Qwen3ForCausalLM"
|
||||
result = _diagnose_serve_output(out)
|
||||
assert result is not None
|
||||
assert "ModelOpt" in result["message"]
|
||||
|
||||
|
||||
def test_nvidia_driver_too_old_for_torch_returns_diagnosis():
|
||||
# vLLM 0.31 from PyPI ships torch cu130; on driver 570 (CUDA 12.8) the engine dies here.
|
||||
out = (
|
||||
"(EngineCore pid=1) ERROR [core.py:1433] self.driver_worker.init_device()\n"
|
||||
"(EngineCore pid=1) ERROR [core.py:1433] RuntimeError: The NVIDIA driver on your system is too old "
|
||||
"(found version 12080). Please update your GPU driver by downloading and installing a new version\n"
|
||||
"Traceback (most recent call last):\n"
|
||||
"RuntimeError: Engine core initialization failed. See root cause above. Failed core proc(s): {}\n"
|
||||
)
|
||||
result = _diagnose_serve_output(out)
|
||||
assert result is not None
|
||||
assert "driver" in result["message"].lower()
|
||||
assert any("cu129" in s["label"] for s in result["suggestions"])
|
||||
|
||||
|
||||
def test_cuda_runtime_newer_than_driver_returns_diagnosis():
|
||||
# torch switched to cu128 but the vLLM wheel's own kernels still need CUDA 13.
|
||||
out = (
|
||||
"RuntimeError: get_cuda_view_from_cpu_tensor, /workspace/csrc/libtorch_stable/cuda_view.cu:38, "
|
||||
"cudaHostGetDevicePointer failed: CUDA driver version is insufficient for CUDA runtime version\n"
|
||||
"Traceback (most recent call last):\n"
|
||||
)
|
||||
result = _diagnose_serve_output(out)
|
||||
assert result is not None
|
||||
assert "driver" in result["message"].lower()
|
||||
|
||||
|
||||
def test_tasks_status_capture_joins_wrapped_tmux_lines():
|
||||
# tmux wraps at the pane width (often mid-word), which hid errors like
|
||||
# "insufficie|nt for CUDA runtime version" from the diagnosis patterns.
|
||||
from pathlib import Path
|
||||
|
||||
source = (Path(__file__).resolve().parents[1] / "routes" / "cookbook_routes.py").read_text(encoding="utf-8")
|
||||
assert '_remote_tmux_command("capture-pane", "-t", session_id, "-p", "-J", "-S", "-500")' in source
|
||||
assert '["tmux", "capture-pane", "-t", session_id, "-p", "-J", "-S", "-500"]' in source
|
||||
|
||||
@@ -2,6 +2,7 @@ import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -1026,3 +1027,36 @@ def test_validate_serve_cmd_rejects_unrelated_subshell_pipelines():
|
||||
]:
|
||||
with pytest.raises(HTTPException):
|
||||
_validate_serve_cmd(cmd)
|
||||
|
||||
|
||||
def _dependency_recipe_pip_commands():
|
||||
src = (Path(__file__).resolve().parents[1] / "static" / "js" / "cookbook-deps-recipes.js").read_text(encoding="utf-8")
|
||||
return re.findall(r"pip:\s*\{\s*commands:\s*\['((?:[^'\\]|\\.)*)'\]\s*\}", src)
|
||||
|
||||
|
||||
def test_validate_serve_cmd_accepts_single_line_dependency_recipes():
|
||||
# The Dependencies recipe "Run" button posts these to /api/model/serve.
|
||||
cmds = _dependency_recipe_pip_commands()
|
||||
assert 'uv pip install -U vllm --torch-backend auto' in cmds
|
||||
assert 'uv pip install -U "sglang[all]" --torch-backend auto' in cmds
|
||||
for cmd in cmds:
|
||||
assert _validate_serve_cmd(cmd) == cmd
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cmd", [
|
||||
"uv run python -c 'print(1)'",
|
||||
"uv tool install ruff",
|
||||
"uv pip uninstall vllm",
|
||||
"uv",
|
||||
])
|
||||
def test_validate_serve_cmd_allows_uv_only_for_pip_install(cmd):
|
||||
with pytest.raises(HTTPException):
|
||||
_validate_serve_cmd(cmd)
|
||||
|
||||
|
||||
def test_dependency_recipe_run_sends_a_valid_pip_repo_id():
|
||||
# A pip-install repo_id must match the server's package-spec regex; the old
|
||||
# "<backend> setup" value had a space and every Run got "Invalid pip package name".
|
||||
src = (Path(__file__).resolve().parents[1] / "static" / "js" / "cookbook.js").read_text(encoding="utf-8")
|
||||
assert "repo_id: `${backend}-setup`," in src
|
||||
assert "repo_id: `${backend} setup`" not in src
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import pytest
|
||||
import httpx
|
||||
from unittest.mock import patch, AsyncMock
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from routes.cookbook_routes import setup_cookbook_routes
|
||||
from src.auth_helpers import require_user
|
||||
|
||||
app = FastAPI()
|
||||
router = setup_cookbook_routes()
|
||||
app.include_router(router)
|
||||
app.dependency_overrides[require_user] = lambda: "test_user"
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
||||
def test_hf_gguf_files_success():
|
||||
mock_resp = httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"siblings": [
|
||||
{"rfilename": "model-q4_k_m.gguf"},
|
||||
{"rfilename": "model-q8_0.gguf"},
|
||||
{"rfilename": "README.md"},
|
||||
]
|
||||
},
|
||||
request=httpx.Request("GET", "https://huggingface.co/api/models/TheBloke/Llama-2-7B-GGUF"),
|
||||
)
|
||||
|
||||
with patch("httpx.AsyncClient.get", new_callable=AsyncMock, return_value=mock_resp):
|
||||
response = client.get("/api/cookbook/hf-gguf-files?repo_id=TheBloke/Llama-2-7B-GGUF")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["ok"] is True
|
||||
assert data["repo_id"] == "TheBloke/Llama-2-7B-GGUF"
|
||||
assert data["files"] == ["model-q4_k_m.gguf", "model-q8_0.gguf"]
|
||||
|
||||
|
||||
def test_hf_gguf_files_http_error():
|
||||
mock_resp = httpx.Response(
|
||||
404,
|
||||
json={"error": "Model not found"},
|
||||
request=httpx.Request("GET", "https://huggingface.co/api/models/invalid/repo"),
|
||||
)
|
||||
|
||||
with patch("httpx.AsyncClient.get", new_callable=AsyncMock, return_value=mock_resp):
|
||||
response = client.get("/api/cookbook/hf-gguf-files?repo_id=invalid/repo")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["ok"] is False
|
||||
assert "HF API HTTP 404" in data["error"]
|
||||
|
||||
|
||||
def test_hf_gguf_files_exception_handling():
|
||||
with patch("httpx.AsyncClient.get", new_callable=AsyncMock, side_effect=httpx.ConnectError("Network unreachable")):
|
||||
response = client.get("/api/cookbook/hf-gguf-files?repo_id=TheBloke/Llama-2-7B-GGUF")
|
||||
# Should gracefully return JSON error payload, not raise NameError / 500
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["ok"] is False
|
||||
assert data["error"] == "HF API request failed"
|
||||
@@ -4,6 +4,13 @@ import pytest
|
||||
|
||||
from src import cookbook_serve_lifecycle as lifecycle
|
||||
|
||||
from tests.test_cookbook_endpoint_registration import (
|
||||
endpoint_db,
|
||||
endpoint_http_api,
|
||||
_endpoint,
|
||||
_launch_llm,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tick_persists_only_successfully_stopped_serves(tmp_path, monkeypatch):
|
||||
@@ -48,3 +55,219 @@ async def test_tick_persists_only_successfully_stopped_serves(tmp_path, monkeypa
|
||||
assert tasks["stop-succeeds"]["_scheduledStopAtMs"] is None
|
||||
assert tasks["stop-fails"]["status"] == "running"
|
||||
assert tasks["stop-fails"]["_scheduledStopAtMs"] == 0
|
||||
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scheduled_stop_preserves_endpoint_reused_by_newer_serve(
|
||||
endpoint_db,
|
||||
endpoint_http_api,
|
||||
):
|
||||
original = _launch_llm(
|
||||
"organization-a/Model-A",
|
||||
8001,
|
||||
serve_session_id="serve-old",
|
||||
)
|
||||
|
||||
reused = _launch_llm(
|
||||
"organization-b/Model-B",
|
||||
8001,
|
||||
serve_session_id="serve-new",
|
||||
)
|
||||
|
||||
assert original == reused
|
||||
|
||||
await lifecycle._delete_endpoint_for_task({
|
||||
"_endpointId": original,
|
||||
"sessionId": "serve-old",
|
||||
"type": "serve",
|
||||
})
|
||||
|
||||
assert _endpoint(endpoint_db, reused) is not None
|
||||
|
||||
await lifecycle._delete_endpoint_for_task({
|
||||
"_endpointId": reused,
|
||||
"sessionId": "serve-new",
|
||||
"type": "serve",
|
||||
})
|
||||
|
||||
assert _endpoint(endpoint_db, reused) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scheduled_stop_preserves_legacy_endpoint_without_owner(
|
||||
endpoint_db,
|
||||
endpoint_http_api,
|
||||
):
|
||||
endpoint_id = _launch_llm(
|
||||
"organization-a/Model-A",
|
||||
8002,
|
||||
)
|
||||
|
||||
await lifecycle._delete_endpoint_for_task({
|
||||
"_endpointId": endpoint_id,
|
||||
"sessionId": "serve-old",
|
||||
"type": "serve",
|
||||
})
|
||||
|
||||
assert _endpoint(endpoint_db, endpoint_id) is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scheduled_stop_cannot_delete_manual_endpoint(
|
||||
endpoint_db,
|
||||
endpoint_http_api,
|
||||
):
|
||||
from core.database import ModelEndpoint
|
||||
|
||||
with endpoint_db() as db:
|
||||
db.add(ModelEndpoint(
|
||||
id="manual-endpoint",
|
||||
name="Manual",
|
||||
base_url="http://localhost:9000/v1",
|
||||
cookbook_serve_session_id="serve-old",
|
||||
))
|
||||
db.commit()
|
||||
|
||||
await lifecycle._delete_endpoint_for_task({
|
||||
"_endpointId": "manual-endpoint",
|
||||
"sessionId": "serve-old",
|
||||
"type": "serve",
|
||||
})
|
||||
|
||||
assert _endpoint(endpoint_db, "manual-endpoint") is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scheduled_stop_requires_session_identity(endpoint_db, endpoint_http_api):
|
||||
endpoint_id = _launch_llm(
|
||||
"organization-a/Model-A",
|
||||
8003,
|
||||
serve_session_id="serve-current",
|
||||
)
|
||||
|
||||
await lifecycle._delete_endpoint_for_task({
|
||||
"_endpointId": endpoint_id,
|
||||
"type": "serve",
|
||||
})
|
||||
|
||||
assert _endpoint(endpoint_db, endpoint_id) is not None
|
||||
|
||||
|
||||
|
||||
def _launch_image(repo_id, port, serve_session_id):
|
||||
import routes.cookbook_routes as cookbook_routes
|
||||
from routes.cookbook_helpers import ServeRequest
|
||||
|
||||
router = cookbook_routes.setup_cookbook_routes()
|
||||
|
||||
serve = next(
|
||||
route.endpoint
|
||||
for route in router.routes
|
||||
if route.path == "/api/model/serve"
|
||||
and "POST" in route.methods
|
||||
)
|
||||
|
||||
cells = dict(
|
||||
zip(
|
||||
serve.__code__.co_freevars,
|
||||
serve.__closure__ or (),
|
||||
)
|
||||
)
|
||||
|
||||
register = cells["_auto_register_image_endpoint"].cell_contents
|
||||
|
||||
endpoint_id = register(
|
||||
ServeRequest(
|
||||
repo_id=repo_id,
|
||||
cmd=f"python scripts/diffusion_server.py --port {port}",
|
||||
),
|
||||
None,
|
||||
serve_session_id=serve_session_id,
|
||||
)
|
||||
|
||||
assert endpoint_id
|
||||
return endpoint_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scheduled_stop_deletes_owned_image_endpoint(
|
||||
endpoint_db,
|
||||
monkeypatch,
|
||||
endpoint_http_api,
|
||||
):
|
||||
import src.settings as settings
|
||||
from core.database import ModelEndpoint
|
||||
|
||||
monkeypatch.setattr(settings, "load_settings", lambda: {})
|
||||
monkeypatch.setattr(
|
||||
settings,
|
||||
"save_settings",
|
||||
lambda value: None,
|
||||
)
|
||||
|
||||
endpoint_id = _launch_image(
|
||||
"organization-a/Image-A",
|
||||
8101,
|
||||
"serve-image-a",
|
||||
)
|
||||
|
||||
assert endpoint_id.startswith("img-")
|
||||
|
||||
with endpoint_db() as db:
|
||||
endpoint = db.get(ModelEndpoint, endpoint_id)
|
||||
assert endpoint.cookbook_serve_session_id == "serve-image-a"
|
||||
|
||||
await lifecycle._delete_endpoint_for_task({
|
||||
"_endpointId": endpoint_id,
|
||||
"sessionId": "serve-image-a",
|
||||
"type": "serve",
|
||||
})
|
||||
|
||||
assert _endpoint(endpoint_db, endpoint_id) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_old_image_serve_cannot_delete_reused_endpoint(
|
||||
endpoint_db,
|
||||
monkeypatch,
|
||||
endpoint_http_api,
|
||||
):
|
||||
import src.settings as settings
|
||||
|
||||
monkeypatch.setattr(settings, "load_settings", lambda: {})
|
||||
monkeypatch.setattr(
|
||||
settings,
|
||||
"save_settings",
|
||||
lambda value: None,
|
||||
)
|
||||
|
||||
original = _launch_image(
|
||||
"organization-a/Image-A",
|
||||
8102,
|
||||
"serve-old",
|
||||
)
|
||||
|
||||
reused = _launch_image(
|
||||
"organization-b/Image-B",
|
||||
8102,
|
||||
"serve-new",
|
||||
)
|
||||
|
||||
assert original == reused
|
||||
|
||||
await lifecycle._delete_endpoint_for_task({
|
||||
"_endpointId": original,
|
||||
"sessionId": "serve-old",
|
||||
"type": "serve",
|
||||
})
|
||||
|
||||
assert _endpoint(endpoint_db, reused) is not None
|
||||
|
||||
await lifecycle._delete_endpoint_for_task({
|
||||
"_endpointId": reused,
|
||||
"sessionId": "serve-new",
|
||||
"type": "serve",
|
||||
})
|
||||
|
||||
assert _endpoint(endpoint_db, reused) is None
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Daily brief must query and display each calendar storage kind in local time."""
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from core import database
|
||||
from src import builtin_actions
|
||||
|
||||
|
||||
@contextmanager
|
||||
def system_timezone(name):
|
||||
previous = os.environ.get('TZ')
|
||||
os.environ['TZ'] = name
|
||||
time.tzset()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if previous is None:
|
||||
os.environ.pop('TZ', None)
|
||||
else:
|
||||
os.environ['TZ'] = previous
|
||||
time.tzset()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not hasattr(time, 'tzset'), reason='requires changing the process local timezone')
|
||||
@pytest.mark.parametrize('zone', ['Europe/Berlin', 'America/Los_Angeles'])
|
||||
def test_daily_brief_selects_local_day_and_orders_mixed_storage(monkeypatch, zone):
|
||||
engine = create_engine('sqlite://', poolclass=StaticPool)
|
||||
for model in [database.CalendarCal, database.CalendarEvent, database.Note]:
|
||||
model.__table__.create(engine)
|
||||
sessions = sessionmaker(bind=engine)
|
||||
monkeypatch.setattr(database, 'SessionLocal', sessions)
|
||||
from core.auth import AuthManager
|
||||
monkeypatch.setattr(AuthManager, 'is_configured', property(lambda self: True))
|
||||
from routes import email_helpers
|
||||
def no_email(*args):
|
||||
raise RuntimeError('No email account in this calendar-only test')
|
||||
monkeypatch.setattr(email_helpers, '_imap_connect', no_email)
|
||||
try:
|
||||
with system_timezone(zone):
|
||||
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
def utc(value):
|
||||
return value.astimezone(timezone.utc).replace(tzinfo=None)
|
||||
with sessions() as db:
|
||||
for owner in ['alice', 'bob', None]:
|
||||
db.add(database.CalendarCal(id=owner or 'shared', owner=owner, name=owner or 'shared'))
|
||||
db.flush()
|
||||
def add(uid, start, end, *, utc_row=False, all_day=False, owner='alice', status='confirmed'):
|
||||
db.add(database.CalendarEvent(uid=uid, calendar_id=owner or 'shared', summary=uid,
|
||||
dtstart=utc(start) if utc_row and not all_day else start,
|
||||
dtend=utc(end) if utc_row and not all_day else end,
|
||||
is_utc=utc_row, all_day=all_day, status=status))
|
||||
add('early', today+timedelta(minutes=30), today+timedelta(hours=1), utc_row=True)
|
||||
add('utc-ten', today+timedelta(hours=10), today+timedelta(hours=11), utc_row=True)
|
||||
add('local-nine', today+timedelta(hours=9), today+timedelta(hours=10))
|
||||
add('all-day', today, today+timedelta(days=1), utc_row=True, all_day=True)
|
||||
add('next-day', today+timedelta(days=1,minutes=30), today+timedelta(days=1,hours=1), utc_row=True)
|
||||
add('ended', today-timedelta(hours=1), today, utc_row=True)
|
||||
add('cancelled', today+timedelta(hours=12), today+timedelta(hours=13), utc_row=True, status='cancelled')
|
||||
add('foreign', today+timedelta(hours=12), today+timedelta(hours=13), utc_row=True, owner='bob')
|
||||
add('unowned', today+timedelta(hours=12), today+timedelta(hours=13), owner=None)
|
||||
db.commit()
|
||||
text, success = asyncio.run(builtin_actions.action_daily_brief('alice'))
|
||||
assert success, text
|
||||
lines = ['all day all-day', '00:30 early', '09:00 local-nine', '10:00 utc-ten']
|
||||
assert all(line in text for line in lines), text
|
||||
assert [text.index(line) for line in lines] == sorted(text.index(line) for line in lines), text
|
||||
for excluded in ['next-day', 'ended', 'cancelled', 'foreign', 'unowned']:
|
||||
assert excluded not in text, text
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('day, hours', [('2026-03-29', 23), ('2026-10-25', 25)])
|
||||
def test_utc_day_window_uses_offset_at_each_boundary(day, hours):
|
||||
start = datetime.fromisoformat(day)
|
||||
zone = ZoneInfo('Europe/Berlin')
|
||||
begin = builtin_actions._daily_brief_utc_boundary(start, zone)
|
||||
end = builtin_actions._daily_brief_utc_boundary(start+timedelta(days=1), zone)
|
||||
assert end-begin == timedelta(hours=hours)
|
||||
if hasattr(time, 'tzset'):
|
||||
with system_timezone('Europe/Berlin'):
|
||||
assert builtin_actions._daily_brief_utc_boundary(start) == begin
|
||||
assert builtin_actions._daily_brief_utc_boundary(start+timedelta(days=1)) == end
|
||||
|
||||
|
||||
@pytest.mark.parametrize('utc_row, all_day, expected', [
|
||||
(True, False, datetime(2026, 6, 1, 10)),
|
||||
(False, False, datetime(2026, 6, 1, 8)),
|
||||
(True, True, datetime(2026, 6, 1, 8)),
|
||||
])
|
||||
def test_local_start_preserves_floating_and_date_only_values(utc_row, all_day, expected):
|
||||
event = SimpleNamespace(dtstart=datetime(2026,6,1,8), is_utc=utc_row, all_day=all_day)
|
||||
assert builtin_actions._daily_brief_local_start(event, ZoneInfo('Europe/Berlin')) == expected
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Issue #2137: never preselect an embedding or other non-chat model."""
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from core.database import ModelEndpoint
|
||||
from routes import model_routes, prefs_routes
|
||||
from tests.helpers.database import disposable_database
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def defaults_api(monkeypatch, tmp_path):
|
||||
defaults = {}
|
||||
with disposable_database(tmp_path) as factory:
|
||||
monkeypatch.setattr(model_routes, "SessionLocal", factory)
|
||||
monkeypatch.setattr(model_routes, "_load_settings", lambda: {})
|
||||
monkeypatch.setattr(prefs_routes, "_load_for_user", lambda owner: defaults)
|
||||
app = FastAPI()
|
||||
app.state.auth_manager = SimpleNamespace(is_admin=lambda owner: False)
|
||||
|
||||
@app.middleware("http")
|
||||
async def owner(request, call_next):
|
||||
request.state.current_user = "alice"
|
||||
return await call_next(request)
|
||||
|
||||
app.include_router(model_routes.setup_model_routes(model_discovery=None))
|
||||
with TestClient(app) as client:
|
||||
yield client, factory, defaults
|
||||
|
||||
|
||||
def _endpoint(factory, *, cached=(), pinned=(), hidden=(), owner="alice", endpoint_id="ep"):
|
||||
with factory() as db:
|
||||
db.add(ModelEndpoint(
|
||||
id=endpoint_id, name="Fixture", base_url="http://model.invalid/v1", owner=owner,
|
||||
is_enabled=True, cached_models=json.dumps(cached),
|
||||
pinned_models=json.dumps(pinned), hidden_models=json.dumps(hidden),
|
||||
))
|
||||
db.commit()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("non_chat", [
|
||||
"text-embedding-3-small", "nomic-embed-text:latest", "jina-reranker-v2-base",
|
||||
"tts-1", "whisper-1", "gpt-image-1",
|
||||
])
|
||||
def test_automatic_default_skips_non_chat_models(defaults_api, non_chat):
|
||||
client, factory, _ = defaults_api
|
||||
_endpoint(factory, cached=[non_chat, "chat-model"])
|
||||
assert client.get("/api/default-chat").json()["model"] == "chat-model"
|
||||
|
||||
|
||||
def test_non_chat_saved_default_falls_back_to_visible_pinned_chat(defaults_api):
|
||||
client, factory, defaults = defaults_api
|
||||
_endpoint(factory, cached=["text-embedding-3-small", "hidden-chat"],
|
||||
pinned=["jina-reranker-v2-base", "pinned-chat"], hidden=["hidden-chat"])
|
||||
defaults.update(default_endpoint_id="ep", default_model="text-embedding-3-small")
|
||||
assert client.get("/api/default-chat").json()["model"] == "pinned-chat"
|
||||
|
||||
|
||||
def test_no_chat_candidate_leaves_model_empty(defaults_api):
|
||||
client, factory, defaults = defaults_api
|
||||
_endpoint(factory, cached=["text-embedding-3-small", "hidden-chat"],
|
||||
pinned=["jina-reranker-v2-base"], hidden=["hidden-chat"])
|
||||
defaults.update(default_endpoint_id="ep", default_model="text-embedding-3-small")
|
||||
result = client.get("/api/default-chat").json()
|
||||
assert result == {"endpoint_id": "ep", "endpoint_url": "http://model.invalid/v1/chat/completions", "model": ""}
|
||||
|
||||
|
||||
def test_valid_explicit_model_survives_stale_catalog(defaults_api):
|
||||
client, factory, defaults = defaults_api
|
||||
_endpoint(factory, cached=["text-embedding-3-small", "other-chat"])
|
||||
defaults.update(default_endpoint_id="ep", default_model="my-custom-chat")
|
||||
assert client.get("/api/default-chat").json()["model"] == "my-custom-chat"
|
||||
|
||||
|
||||
def test_model_filter_preserves_owner_scope(defaults_api):
|
||||
client, factory, defaults = defaults_api
|
||||
_endpoint(factory, owner="bob", endpoint_id="bobs-endpoint", cached=["bobs-chat"])
|
||||
_endpoint(factory, cached=["text-embedding-3-small", "alices-chat"])
|
||||
defaults.update(default_endpoint_id="bobs-endpoint")
|
||||
result = client.get("/api/default-chat").json()
|
||||
assert result["endpoint_id"] == "ep"
|
||||
assert result["model"] == "alices-chat"
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Demoting an admin purges their API tokens (issue #6831).
|
||||
|
||||
Token management is admin-only and scoped to the token's owner, so once the
|
||||
owner is demoted nobody can list or revoke their tokens any more.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.test_delete_user_revokes_api_tokens import (
|
||||
_auth_module,
|
||||
_FakeApiToken,
|
||||
_FakeSession,
|
||||
)
|
||||
|
||||
|
||||
@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("adm2", "secret-adm2-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_demote_revokes_api_tokens(manager, db_calls):
|
||||
result = manager.set_admin("adm2", False, "admin")
|
||||
|
||||
assert result.value == "ok"
|
||||
assert manager.is_admin("adm2") is False
|
||||
assert [("owner ==", "adm2")] in db_calls
|
||||
|
||||
|
||||
def test_promote_leaves_api_tokens_alone(manager, db_calls):
|
||||
manager.set_admin("bob", True, "admin")
|
||||
|
||||
assert db_calls == []
|
||||
|
||||
|
||||
def test_refused_demotion_leaves_api_tokens_alone(manager, db_calls):
|
||||
assert manager.set_admin("adm2", False, "bob").value == "not_authorized"
|
||||
assert db_calls == []
|
||||
|
||||
|
||||
def test_demote_fails_closed_when_token_purge_fails(manager, monkeypatch):
|
||||
@contextlib.contextmanager
|
||||
def _failing_db_session():
|
||||
raise RuntimeError("database unavailable")
|
||||
yield
|
||||
|
||||
db_stub = types.ModuleType("core.database")
|
||||
db_stub.get_db_session = _failing_db_session
|
||||
db_stub.ApiToken = _FakeApiToken
|
||||
monkeypatch.setitem(sys.modules, "core.database", db_stub)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
manager.set_admin("adm2", False, "admin")
|
||||
|
||||
assert manager.is_admin("adm2") is True
|
||||
@@ -58,6 +58,38 @@ def test_styled_confirm_cancel_or_close_label_is_shifted_without_moving_button()
|
||||
assert "top:2px;" in rule
|
||||
|
||||
|
||||
def test_styled_confirm_ok_and_alternate_labels_shift_without_moving_buttons():
|
||||
# Moving #styled-confirm-ok itself (position:relative; top:2px) left it 2px
|
||||
# below Cancel and the alternate button. The OK and alternate labels get the
|
||||
# same label nudge as Cancel instead, and no rule moves a footer button.
|
||||
css = app_css()
|
||||
|
||||
assert "okBtn.replaceChildren(btnLabel(" in _UI
|
||||
assert "altBtn.replaceChildren(btnLabel(" in _UI
|
||||
rule_start = css.index("#styled-confirm-ok .styled-confirm-btn-label")
|
||||
rule = css[rule_start:css.index("}", rule_start)]
|
||||
assert "#styled-confirm-alt .styled-confirm-btn-label" in rule
|
||||
assert "position:relative;" in rule and "top:2px;" in rule
|
||||
for selector in ("#styled-confirm-ok {", "#styled-confirm-alt {", "#styled-confirm-cancel {"):
|
||||
if selector in css:
|
||||
block = css[css.index(selector):].split("}", 1)[0]
|
||||
assert "top:" not in block.replace(" ", ""), selector
|
||||
|
||||
|
||||
def test_styled_confirm_buttons_grow_to_fit_wrapped_labels():
|
||||
# The global `button { height:32px }` held a two-line label (e.g.
|
||||
# "Delete chat and images") to one line's height, so its second line
|
||||
# rendered below the button. Confirm buttons must be allowed to grow,
|
||||
# with 32px kept as the minimum.
|
||||
css = app_css()
|
||||
|
||||
assert re.search(r"(^|\n)\s*button\s*\{\s*height:32px;", css)
|
||||
rule_start = css.index(".styled-confirm-box .confirm-btn {")
|
||||
rule = css[rule_start:rule_start + 120]
|
||||
assert "height:auto;" in rule
|
||||
assert "min-height:32px;" in rule
|
||||
|
||||
|
||||
def test_styled_dialogs_manage_focus():
|
||||
# A dialog is only really accessible if it restores focus to the trigger on
|
||||
# close and traps Tab while open. Both styledConfirm and styledPrompt should
|
||||
|
||||
@@ -67,6 +67,14 @@ def test_compose_files_forward_companion_base_url():
|
||||
assert "COMPANION_BASE_URL" in _compose_env_names(path), path.name
|
||||
|
||||
|
||||
def test_compose_files_forward_first_token_timeout():
|
||||
# The local first-event watchdog (src/llm_core.py) reads this from the
|
||||
# process environment, so a Docker install can only reach it when the
|
||||
# compose files forward it from the host .env.
|
||||
for path in COMPOSE_FILES:
|
||||
assert "ODYSSEUS_FIRST_TOKEN_TIMEOUT" in _compose_env_names(path), path.name
|
||||
|
||||
|
||||
def test_default_compose_files_do_not_mount_host_docker_socket():
|
||||
for path in COMPOSE_FILES:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""ZIP exports keep language extensions and place collision suffixes before them."""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from core.database import Base, Document
|
||||
from routes.document import document_routes
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def exports(tmp_path, monkeypatch):
|
||||
engine = create_engine(f"sqlite:///{tmp_path / 'documents.db'}", connect_args={"check_same_thread": False})
|
||||
Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine)
|
||||
monkeypatch.setattr(document_routes, "SessionLocal", factory)
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
app = FastAPI()
|
||||
|
||||
@app.middleware("http")
|
||||
async def fixture_user(request: Request, call_next):
|
||||
request.state.current_user = "alice"
|
||||
return await call_next(request)
|
||||
|
||||
app.include_router(document_routes.setup_document_routes(None))
|
||||
|
||||
def add(doc_id, title, language, content="body\n", owner="alice"):
|
||||
with factory() as db:
|
||||
db.add(Document(id=doc_id, title=title, language=language,
|
||||
current_content=content, owner=owner, is_active=True))
|
||||
db.commit()
|
||||
|
||||
with TestClient(app) as client:
|
||||
yield client, add
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("title,language,filename", [
|
||||
("config.local", "python", "config.local.py"),
|
||||
("report.v2", "markdown", "report.v2.md"),
|
||||
("data.2024", "json", "data.2024.json"),
|
||||
("module.ts", "typescript", "module.ts"),
|
||||
("SCRIPT.PY", "python", "SCRIPT.PY"),
|
||||
("résumé.v2", "text", "résumé.v2.txt"),
|
||||
("unknown.v2", "unknown", "unknown.v2.txt"),
|
||||
("draft", None, "draft.txt"),
|
||||
("project/report.v2", "markdown", "projectreport.v2.md"),
|
||||
])
|
||||
def test_export_uses_the_language_extension_even_for_dotted_titles(exports, title, language, filename):
|
||||
client, add = exports
|
||||
body = "Original text: α\n"
|
||||
add("doc", title, language, body)
|
||||
response = client.post("/api/documents/export-zip", json={"ids": ["doc"]})
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "application/zip"
|
||||
with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
|
||||
assert archive.namelist() == [filename]
|
||||
assert archive.read(filename).decode() == body
|
||||
|
||||
|
||||
@pytest.mark.parametrize("title", ["config.local", "config.local.py"])
|
||||
def test_duplicate_names_keep_the_extension_at_the_end(exports, title):
|
||||
client, add = exports
|
||||
for i in range(3):
|
||||
add(str(i), title, "python", f"body {i}")
|
||||
response = client.post("/api/documents/export-zip", json={"ids": ["0", "1", "2"]})
|
||||
assert response.status_code == 200
|
||||
with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
|
||||
assert set(archive.namelist()) == {"config.local.py", "config.local-1.py", "config.local-2.py"}
|
||||
assert {archive.read(name).decode() for name in archive.namelist()} == {"body 0", "body 1", "body 2"}
|
||||
|
||||
|
||||
def test_added_extension_collides_safely_with_an_existing_extension(exports):
|
||||
client, add = exports
|
||||
add("a", "report.v2", "markdown", "first")
|
||||
add("b", "report.v2.md", "markdown", "second")
|
||||
response = client.post("/api/documents/export-zip", json={"ids": ["a", "b"]})
|
||||
assert response.status_code == 200
|
||||
with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
|
||||
assert set(archive.namelist()) == {"report.v2.md", "report.v2-1.md"}
|
||||
assert {archive.read(name) for name in archive.namelist()} == {b"first", b"second"}
|
||||
|
||||
|
||||
def test_foreign_documents_are_still_excluded_and_email_body_is_preserved(exports):
|
||||
client, add = exports
|
||||
add("owned", "reply.v2", "email", "Subject: Hello\n---\nBody")
|
||||
add("foreign", "private", "python", "secret", owner="bob")
|
||||
response = client.post("/api/documents/export-zip", json={"ids": ["owned", "foreign"]})
|
||||
assert response.status_code == 200
|
||||
with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
|
||||
assert archive.namelist() == ["reply.v2.eml"]
|
||||
assert archive.read("reply.v2.eml") == b"Subject: Hello\r\n\r\nBody"
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Literal punctuation in library queries must not become SQL wildcards."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from core.database import Document
|
||||
from routes.document import document_routes
|
||||
from tests.helpers.database import disposable_database
|
||||
|
||||
|
||||
CASES = [
|
||||
("100%", "100% complete", "1000 complete"),
|
||||
("a_b", "a_b", "axb"),
|
||||
("%", "price 10%", "price 100"),
|
||||
("_", "a_b", "axb"),
|
||||
(r"path\notes", r"path\notes", "pathnotes"),
|
||||
(r"path\_tag", r"path\_tag", r"path\Xtag"),
|
||||
("50%_done", "50%_done", "500Xdone"),
|
||||
("can't", "can't stop", "cannot stop"),
|
||||
("mixed case", "Case ordered Mixed", "Mixed without"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("column", ["title", "current_content"])
|
||||
@pytest.mark.parametrize("query,literal,lookalike", CASES)
|
||||
@pytest.mark.asyncio
|
||||
async def test_library_search_matches_literal_tokens(tmp_path, monkeypatch, column, query, literal, lookalike):
|
||||
with disposable_database(tmp_path) as factory:
|
||||
monkeypatch.setattr(document_routes, "SessionLocal", factory)
|
||||
with factory() as db:
|
||||
for doc_id, text, owner, active, archived in [
|
||||
("literal", literal, "alice", True, False),
|
||||
("lookalike", lookalike, "alice", True, False),
|
||||
("foreign", literal, "bob", True, False),
|
||||
("inactive", literal, "alice", False, False),
|
||||
("archived", literal, "alice", True, True),
|
||||
]:
|
||||
fields = {"title": "Document", "current_content": "Body", column: text}
|
||||
db.add(Document(id=doc_id, owner=owner, is_active=active, archived=archived,
|
||||
language="text", version_count=1, **fields))
|
||||
db.commit()
|
||||
router = document_routes.setup_document_routes(MagicMock())
|
||||
endpoint = next(r.endpoint for r in router.routes if r.path == "/api/documents/library")
|
||||
request = SimpleNamespace(state=SimpleNamespace(current_user="alice"))
|
||||
result = await endpoint(request, search=query, language=None, sort="recent", offset=0, limit=20, archived=False)
|
||||
assert result["total"] == 1
|
||||
assert [row["id"] for row in result["documents"]] == ["literal"]
|
||||
archived = await endpoint(request, search=query, language=None, sort="recent", offset=0, limit=20, archived=True)
|
||||
assert [row["id"] for row in archived["documents"]] == ["archived"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_literal_search_keeps_cross_field_and_pagination_behavior(tmp_path, monkeypatch):
|
||||
with disposable_database(tmp_path) as factory:
|
||||
monkeypatch.setattr(document_routes, "SessionLocal", factory)
|
||||
with factory() as db:
|
||||
for doc_id, title, content in [
|
||||
("first", "100% ready", "done"),
|
||||
("second", "done", "100% ready"),
|
||||
("lookalike", "1000 ready", "done"),
|
||||
("missing-term", "100% ready", "waiting"),
|
||||
]:
|
||||
db.add(Document(id=doc_id, owner="alice", title=title, current_content=content,
|
||||
is_active=True, archived=False, language="text", version_count=1))
|
||||
db.commit()
|
||||
router = document_routes.setup_document_routes(MagicMock())
|
||||
endpoint = next(r.endpoint for r in router.routes if r.path == "/api/documents/library")
|
||||
request = SimpleNamespace(state=SimpleNamespace(current_user="alice"))
|
||||
results = [await endpoint(request, search="100% done", language="text", sort="alpha",
|
||||
offset=offset, limit=1, archived=False) for offset in [0, 1]]
|
||||
assert all(result["total"] == 2 for result in results)
|
||||
assert {row["id"] for result in results for row in result["documents"]} == {"first", "second"}
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Large document pages survive the model-facing tool-output boundary."""
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from src.constants import MAX_OUTPUT_CHARS
|
||||
from src.tool_execution import format_tool_result
|
||||
from core import database
|
||||
from src.agent_tools import TOOL_HANDLERS
|
||||
from src.agent_tools.document_tools import get_active_document, set_active_document
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def documents(tmp_path, monkeypatch):
|
||||
engine = create_engine(f"sqlite:///{tmp_path / 'documents.db'}")
|
||||
database.Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine)
|
||||
monkeypatch.setattr(database, "SessionLocal", factory)
|
||||
saved_active = get_active_document()
|
||||
with factory() as db:
|
||||
db.add(database.Document(id="owned-document", owner="fixture-owner", title="Owned",
|
||||
language="text", current_content=""))
|
||||
db.commit()
|
||||
yield
|
||||
set_active_document(saved_active)
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("limit", [None, 37, 20_000])
|
||||
def test_formatted_document_pages_reconstruct_the_entire_body(documents, limit):
|
||||
body = "".join(f"Row {i}: α, quote=\" and slash=\\\n" for i in range(1200))
|
||||
with database.SessionLocal() as db:
|
||||
db.query(database.Document).filter_by(id="owned-document").first().current_content = body
|
||||
db.commit()
|
||||
|
||||
offset = 0
|
||||
chunks = []
|
||||
while True:
|
||||
args = {"action": "read", "document_id": "owned-document", "offset": offset}
|
||||
if limit is not None:
|
||||
args["limit"] = limit
|
||||
content = json.dumps(args)
|
||||
result = asyncio.run(TOOL_HANDLERS["manage_documents"](content, {"owner": "fixture-owner"}))
|
||||
page = result["document"]
|
||||
rendered = format_tool_result(f"manage_documents: {content[:80]}", result)
|
||||
end = page["next_offset"] if page["truncated"] else len(body)
|
||||
chunk = body[offset:end]
|
||||
assert chunk in rendered
|
||||
assert len(rendered) <= MAX_OUTPUT_CHARS
|
||||
assert rendered.endswith("\n```")
|
||||
if page["truncated"]:
|
||||
assert end > offset
|
||||
assert f"next_offset={end}" in rendered
|
||||
chunks.append(chunk)
|
||||
if not page["truncated"]:
|
||||
break
|
||||
offset = end
|
||||
assert "".join(chunks) == body
|
||||
|
||||
|
||||
def test_document_result_cannot_bypass_the_output_cap():
|
||||
rendered = format_tool_result("manage_documents: read", {
|
||||
"response": "x" * (MAX_OUTPUT_CHARS * 2),
|
||||
"document": {"offset": 0, "next_offset": None},
|
||||
"exit_code": 0,
|
||||
})
|
||||
assert len(rendered) < MAX_OUTPUT_CHARS + 100
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body", ["", "short document"])
|
||||
@pytest.mark.parametrize("offset", [0, 100])
|
||||
def test_short_or_exhausted_read_has_no_continuation(documents, body, offset):
|
||||
with database.SessionLocal() as db:
|
||||
db.query(database.Document).filter_by(id="owned-document").first().current_content = body
|
||||
db.commit()
|
||||
result = asyncio.run(TOOL_HANDLERS["manage_documents"](json.dumps({
|
||||
"action": "read", "document_id": "owned-document", "offset": offset,
|
||||
}), {"owner": "fixture-owner"}))
|
||||
assert result["document"]["content"] == body[offset:]
|
||||
assert result["document"]["next_offset"] is None
|
||||
assert not result["document"]["truncated"]
|
||||
assert format_tool_result("manage_documents: read", result) == result["response"]
|
||||
|
||||
|
||||
def test_oversized_header_fails_without_a_nonadvancing_page(documents):
|
||||
with database.SessionLocal() as db:
|
||||
db.query(database.Document).filter_by(id="owned-document").first().title = "x" * MAX_OUTPUT_CHARS
|
||||
db.commit()
|
||||
result = asyncio.run(TOOL_HANDLERS["manage_documents"](
|
||||
'{"action":"read","document_id":"owned-document"}', {"owner": "fixture-owner"}))
|
||||
assert result["exit_code"] == 1
|
||||
assert "too long" in result["error"]
|
||||
@@ -41,7 +41,8 @@ def test_claim_is_fsynced_to_disk_before_returning(tmp_path, target, monkeypatch
|
||||
on_disk = records(log.path)
|
||||
assert [r["type"] for r in on_disk] == ["claim"]
|
||||
assert fx.EffectClaim.from_dict(on_disk[0]["record"]) == made
|
||||
assert oct(log.path.stat().st_mode & 0o777) == "0o600"
|
||||
if os.name != "nt": # Windows has no POSIX permission bits: os.open's 0o600 is not reflected in st_mode
|
||||
assert oct(log.path.stat().st_mode & 0o777) == "0o600"
|
||||
|
||||
|
||||
def test_claim_persistence_failure_raises_and_records_nothing(tmp_path, target):
|
||||
@@ -343,3 +344,13 @@ def test_hardlinked_effect_state_is_control_plane_without_scanning_the_store(tmp
|
||||
elsewhere.write_text("y")
|
||||
os.link(elsewhere, workspace / "pnpm-style.txt")
|
||||
assert resources._control_plane_path(str(workspace / "pnpm-style.txt")) is True
|
||||
|
||||
|
||||
def test_consecutive_claims_write_lf_only_records(tmp_path, target):
|
||||
# Raw os.open() is text-mode on Windows unless O_BINARY is set; the \r\n it
|
||||
# wrote made the next append read a stray empty line as a corrupt tail.
|
||||
log = EffectLog(RUN, directory=tmp_path / "fx")
|
||||
claim(log, target, "e1", "act-1")
|
||||
claim(log, target, "e2", "act-2")
|
||||
assert b"\r" not in log.path.read_bytes()
|
||||
assert len(records(log.path)) == 2
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""MIME alternatives are selected independently of part order and codec labels."""
|
||||
from email import message_from_bytes
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
import pytest
|
||||
|
||||
from mcp_servers.email_server import _extract_text as mcp_text
|
||||
from routes.email_helpers import _extract_html, _extract_text as route_text
|
||||
|
||||
|
||||
def alternative(order, plain='Body text', html='<p>Body text</p>'):
|
||||
msg = MIMEMultipart('alternative')
|
||||
parts = {'plain': MIMEText(plain, 'plain', 'utf-8'), 'html': MIMEText(html, 'html', 'utf-8')}
|
||||
for kind in order:
|
||||
msg.attach(parts[kind])
|
||||
return message_from_bytes(msg.as_bytes())
|
||||
|
||||
|
||||
@pytest.mark.parametrize('extract', [route_text, mcp_text], ids=['route', 'mcp'])
|
||||
@pytest.mark.parametrize('order', [('html', 'plain'), ('plain', 'html')])
|
||||
def test_plain_alternative_wins_in_either_order(extract, order):
|
||||
assert extract(alternative(order)) == 'Body text'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('extract', [route_text, mcp_text], ids=['route', 'mcp'])
|
||||
def test_empty_plain_allows_html_fallback(extract):
|
||||
assert extract(alternative(('plain', 'html'), plain='', html='Hello<br>world & all')) == 'Hello\nworld & all'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('extract', [route_text, mcp_text], ids=['route', 'mcp'])
|
||||
def test_text_attachments_are_not_body_alternatives(extract):
|
||||
msg = MIMEMultipart('mixed')
|
||||
attachment = MIMEText('Attachment text', 'plain')
|
||||
attachment.add_header('Content-Disposition', 'attachment', filename='note.txt')
|
||||
msg.attach(attachment)
|
||||
msg.attach(MIMEText('<p>Body text</p>', 'html'))
|
||||
assert extract(msg) == 'Body text'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('extract', [route_text, mcp_text], ids=['route', 'mcp'])
|
||||
def test_multiple_plain_body_parts_remain_joined(extract):
|
||||
msg = MIMEMultipart('mixed')
|
||||
msg.attach(MIMEText('Part one', 'plain'))
|
||||
msg.attach(MIMEText('Part two', 'plain'))
|
||||
assert extract(msg) == 'Part one\nPart two'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('extract', [route_text, mcp_text], ids=['route', 'mcp'])
|
||||
@pytest.mark.parametrize('kind', ['plain', 'html'])
|
||||
@pytest.mark.parametrize('multipart', [False, True])
|
||||
def test_unknown_charset_falls_back_to_utf8(extract, kind, multipart):
|
||||
body = 'Hello café' if kind == 'plain' else '<p>Hello café</p>'
|
||||
raw = f'Content-Type: text/{kind}; charset=x-unknown\r\nContent-Transfer-Encoding: 8bit\r\n\r\n'.encode()+body.encode()
|
||||
part = message_from_bytes(raw)
|
||||
if multipart:
|
||||
msg = MIMEMultipart('mixed');msg.attach(part)
|
||||
else:
|
||||
msg = part
|
||||
assert extract(msg) == 'Hello café'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('extract', [route_text, mcp_text], ids=['route', 'mcp'])
|
||||
def test_declared_latin1_is_preserved(extract):
|
||||
assert extract(MIMEText('café', 'plain', 'iso-8859-1')) == 'café'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('multipart', [False, True])
|
||||
def test_raw_html_unknown_charset_is_readable(multipart):
|
||||
part = message_from_bytes(b'Content-Type: text/html; charset=x-unknown\r\n\r\n<p>Hello caf\xc3\xa9</p>')
|
||||
if multipart:
|
||||
msg=MIMEMultipart('mixed');msg.attach(part)
|
||||
else:msg=part
|
||||
assert _extract_html(msg) == '<p>Hello café</p>'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('extract', [route_text, mcp_text], ids=['route', 'mcp'])
|
||||
def test_invalid_bytes_are_replaced_in_unknown_charset(extract):
|
||||
msg=message_from_bytes(b'Content-Type: text/plain; charset=x-unknown\r\n\r\nHello\xff')
|
||||
assert extract(msg) == 'Hello\ufffd'
|
||||
@@ -90,3 +90,42 @@ def test_fork_does_not_corrupt_source_message_metadata(monkeypatch):
|
||||
# ...and the source session's _db_id values are untouched.
|
||||
assert source.history[0].metadata["_db_id"] == "src-0"
|
||||
assert source.history[1].metadata["_db_id"] == "src-1"
|
||||
|
||||
|
||||
def _fork_with(monkeypatch, body, db_ids):
|
||||
"""Fork a 4-message source whose DB rows have the given ids."""
|
||||
monkeypatch.setattr(mod, "_verify_session_owner", lambda *a, **k: None)
|
||||
source = _FakeSession(name="Long chat")
|
||||
for i, role in enumerate(["user", "assistant", "user", "assistant"]):
|
||||
source.history.append(ChatMessage(role=role, content=f"m{i}", metadata={"_db_id": db_ids[i]}))
|
||||
manager = _FakeSessionManager(source)
|
||||
rows = [SimpleNamespace(id=i) for i in db_ids]
|
||||
|
||||
class _Query:
|
||||
def filter(self, *a): return self
|
||||
def order_by(self, *a): return self
|
||||
def all(self): return rows
|
||||
|
||||
class _DB:
|
||||
def query(self, *a): return _Query()
|
||||
def close(self): pass
|
||||
|
||||
monkeypatch.setattr(mod, "SessionLocal", lambda: _DB())
|
||||
router = mod.setup_history_routes(manager)
|
||||
|
||||
class _Req:
|
||||
async def json(self): return body
|
||||
|
||||
asyncio.run(_fork_handler(router)(_Req(), "src-id"))
|
||||
return [m.content for m in manager.created.history]
|
||||
|
||||
|
||||
def test_fork_before_a_message_id_ignores_a_stale_keep_count(monkeypatch):
|
||||
copied = _fork_with(monkeypatch, {"before_msg_id": "c", "keep_count": 0}, ["a", "b", "c", "d"])
|
||||
assert copied == ["m0", "m1"]
|
||||
|
||||
|
||||
def test_fork_through_a_message_id_includes_it(monkeypatch):
|
||||
copied = _fork_with(monkeypatch, {"through_msg_id": "b", "keep_count": 0}, ["a", "b", "c", "d"])
|
||||
assert copied == ["m0", "m1"]
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
import routes.gallery_routes as gallery
|
||||
|
||||
|
||||
def route(path, method):
|
||||
return next(r for r in gallery.setup_gallery_routes().routes if r.path == path and method in r.methods)
|
||||
|
||||
|
||||
def call(path, method, monkeypatch, body):
|
||||
monkeypatch.setattr(gallery, 'get_current_user', lambda request: 'alice')
|
||||
|
||||
class Request:
|
||||
async def json(self):
|
||||
return body
|
||||
|
||||
return asyncio.run(route(path, method).endpoint(Request(), 'album-1'))
|
||||
|
||||
|
||||
@pytest.mark.parametrize('body', [
|
||||
{'name': ''},
|
||||
{'name': ' '},
|
||||
{'name': 123},
|
||||
{'name': ['x']},
|
||||
{'description': {'a': 1}},
|
||||
[1],
|
||||
])
|
||||
def test_album_update_rejects_bad_input(body, monkeypatch):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
call('/api/gallery/albums/{album_id}', 'PUT', monkeypatch, body)
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.parametrize('op', ['add', 'remove'])
|
||||
@pytest.mark.parametrize('body', [
|
||||
{'image_ids': 'abc'},
|
||||
{'image_ids': 5},
|
||||
{'image_ids': None},
|
||||
{'image_ids': [{'a': 1}]},
|
||||
{'image_ids': ['ok', 1]},
|
||||
[1],
|
||||
])
|
||||
def test_album_image_ids_must_be_a_list_of_strings(op, body, monkeypatch):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
call(f'/api/gallery/albums/{{album_id}}/{op}', 'POST', monkeypatch, body)
|
||||
assert exc.value.status_code == 400
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Regression for #2123 (gallery part): library search must match multi-word
|
||||
queries in any order and treat % / _ literally.
|
||||
|
||||
Old code built a single ``%{search}%`` LIKE over prompt/tags/ai_tags, so
|
||||
"red car" missed "a car that is red", and a literal "%" matched every row.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
import core.database as cdb
|
||||
from core.database import GalleryImage
|
||||
import routes.gallery_routes as gallery_routes
|
||||
|
||||
|
||||
def _client_with_gallery(monkeypatch, tmp_path):
|
||||
engine = create_engine(
|
||||
f"sqlite:///{tmp_path / 'gallery_search.db'}",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=NullPool,
|
||||
)
|
||||
cdb.Base.metadata.create_all(engine)
|
||||
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
monkeypatch.setattr(gallery_routes, "SessionLocal", session_factory)
|
||||
|
||||
db = session_factory()
|
||||
try:
|
||||
db.add_all(
|
||||
[
|
||||
GalleryImage(
|
||||
id="img-red-car",
|
||||
filename=f"{uuid.uuid4().hex}.png",
|
||||
prompt="a car that is red",
|
||||
model="model-a",
|
||||
tags="",
|
||||
ai_tags="",
|
||||
is_active=True,
|
||||
file_size=10,
|
||||
),
|
||||
GalleryImage(
|
||||
id="img-boat",
|
||||
filename=f"{uuid.uuid4().hex}.png",
|
||||
prompt="blue boat on the lake",
|
||||
model="model-a",
|
||||
tags="",
|
||||
ai_tags="",
|
||||
is_active=True,
|
||||
file_size=10,
|
||||
),
|
||||
GalleryImage(
|
||||
id="img-percent",
|
||||
filename=f"{uuid.uuid4().hex}.png",
|
||||
prompt="100% cotton shirt",
|
||||
model="model-a",
|
||||
tags="sale_2024",
|
||||
ai_tags="",
|
||||
is_active=True,
|
||||
file_size=10,
|
||||
),
|
||||
GalleryImage(
|
||||
id="img-backslash",
|
||||
filename=f"{uuid.uuid4().hex}.png",
|
||||
prompt=r"C:\photos\sunset",
|
||||
model="model-a",
|
||||
tags="",
|
||||
ai_tags="",
|
||||
is_active=True,
|
||||
file_size=10,
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(gallery_routes.setup_gallery_routes())
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_gallery_search_matches_words_in_any_order(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
client = _client_with_gallery(monkeypatch, tmp_path)
|
||||
|
||||
body = client.get("/api/gallery/library", params={"search": "red car"}).json()
|
||||
ids = {item["id"] for item in body["items"]}
|
||||
assert "img-red-car" in ids
|
||||
assert "img-boat" not in ids
|
||||
|
||||
|
||||
def test_gallery_search_escapes_percent_wildcard(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
client = _client_with_gallery(monkeypatch, tmp_path)
|
||||
|
||||
body = client.get("/api/gallery/library", params={"search": "%"}).json()
|
||||
ids = {item["id"] for item in body["items"]}
|
||||
assert ids == {"img-percent"}
|
||||
|
||||
|
||||
def test_gallery_search_escapes_underscore_wildcard(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
client = _client_with_gallery(monkeypatch, tmp_path)
|
||||
|
||||
# Only img-percent has a literal underscore (in tags "sale_2024").
|
||||
# Unescaped, "_" matches any single char so every row matches.
|
||||
body = client.get("/api/gallery/library", params={"search": "_"}).json()
|
||||
ids = {item["id"] for item in body["items"]}
|
||||
assert ids == {"img-percent"}
|
||||
|
||||
|
||||
def test_gallery_search_escapes_backslash(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
client = _client_with_gallery(monkeypatch, tmp_path)
|
||||
|
||||
# Only img-backslash has a literal backslash. The escape char itself
|
||||
# must survive escaping instead of escaping the surrounding wildcards.
|
||||
body = client.get("/api/gallery/library", params={"search": "\\"}).json()
|
||||
ids = {item["id"] for item in body["items"]}
|
||||
assert ids == {"img-backslash"}
|
||||
|
||||
|
||||
def test_gallery_tag_filter_escapes_wildcards(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("AUTH_ENABLED", "false")
|
||||
client = _client_with_gallery(monkeypatch, tmp_path)
|
||||
|
||||
# Unescaped, "%" / "_" match every row; escaped, only literal matches.
|
||||
assert client.get("/api/gallery/library", params={"tag": "%"}).json()["items"] == []
|
||||
|
||||
body = client.get("/api/gallery/library", params={"tag": "_"}).json()
|
||||
assert {item["id"] for item in body["items"]} == {"img-percent"}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user