Compare commits

...
Author SHA1 Message Date
Léo bec4d1805d fix(docker): let searxng boot when the settings migration fails
The migration runs under `set -eu`, so any settings file it cannot parse or
rewrite took the container down instead of merely going unmigrated. A symlinked
/etc/searxng/settings.yml is enough: the migration refuses a non-regular file
and searxng, which reads through the symlink perfectly well, never got to start.

Guard the call with `|| true` in all three Compose variants. The failure still
prints its reason on stderr, and searxng is left to report anything genuinely
wrong with the file.
2026-08-16 04:06:38 +02:00
Léo 54d794e8de fix(docker): chmod the settings temp file before chowning it
The Compose cap set is `cap_drop: ALL` plus CHOWN/SETGID/SETUID/DAC_OVERRIDE
and carries no FOWNER, and searxng's own entrypoint chowns /etc/searxng to
searxng:searxng, so every retained settings file belongs to that user by the
second boot. Chowning the temporary file first left root unable to chmod it,
so the migration exited 1 and `set -eu` killed the container before
`exec /usr/local/searxng/entrypoint.sh` — SearXNG never started and odysseus
blocked on its healthcheck.

Swap the two calls so the chmod lands while the temporary file is still
root-owned, and cover the ordering with a test that refuses the chmod once
the chown has happened, the way the kernel does.
2026-08-16 03:53:42 +02:00
RaresKeY 3cd6cdb638 fix(docker): migrate retained SearXNG settings
Retained nonempty SearXNG settings can miss defaults required by newer pinned images while bypassing the entrypoint's narrow regeneration checks.

Add an atomic PyYAML-aware migration to all Compose variants. Preserve existing inheritance choices, custom content, secrets, ownership, and mode while inserting only the missing top-level default-inheritance key.

Validated with 39 focused and adjacent tests, compile checks, and fresh and retained pinned-image HTTP 200 gates. Full repository CI remains for the PR.
2026-08-15 10:52:51 +00:00
Joeseph GreyandRaresKeY 2c394704c6 fix(personal): run directory indexing off the event loop (#5634)
* fix(personal): run directory indexing off the event loop (#5558)

POST /api/personal/add_directory called rag.index_personal_documents
inline from an async handler, so the whole indexing job (os.walk, file
reads, per-chunk embedding, Chroma inserts) ran on the event loop and
every other request queued behind it. Indexing a real directory froze
the UI and API for 25+ minutes with no sign of life.

Move the blocking section into the threadpool via run_in_threadpool.
personal_docs_manager.add_directory stays inside it because its
refresh_index() re-extracts text across tracked directories, which is
also blocking work. A module-level lock serializes index jobs so the
threadpool move does not introduce parallel jobs racing
PersonalDocsManager's unsynchronized list mutations and file writes;
they previously serialized on the blocked loop, so one-at-a-time is
behavior parity.

* fix(personal): serialize add/remove/reload on an async job lock

The #5558 fix took the job lock INSIDE the threadpool worker and only on the
add path, so (1) remove_directory and /reload mutated PersonalDocsManager's
unsynchronized list/index concurrently with an in-flight add — the inconsistent
state the PR claimed to prevent — and (2) a queued add blocked on the lock while
holding an AnyIO threadpool token, starving the shared pool.

Move the lock to an asyncio.Lock acquired in the async handler BEFORE offloading,
and route add, remove and reload through it. A waiting request now parks on the
event loop instead of pinning a worker, and all three mutators are serialized so
the 'add/remove are serialized and cannot leave inconsistent state' guarantee
holds. remove and reload also run their blocking work off the event loop. The
lock is per-router so each app binds it to its own loop; single-process scope.

Tests: add-vs-remove and add-vs-reload serialization regressions (async via
ASGITransport, since asyncio.Lock deadlocks starlette TestClient's portal); the
existing add-vs-add test converted to the same driver.

* fix(personal): route upload and delete through the index job lock

/api/personal/upload and DELETE /api/personal/file mutated the same
vector and tracking state add/remove/reload serialize on, outside
_index_job_lock and inline on the event loop.

Both now stage async work on the loop, then run the complete transition
(vector writes, disk change, personal_docs_manager update) in one
offloaded critical section under the shared lock, acquired before the
offload so queued requests park on the loop rather than pinning a
threadpool worker.

Adds add-vs-upload and add-vs-file ordering regressions.

* fix(personal): bound multi-file upload memory

---------

Co-authored-by: RaresKeY <158580472+RaresKeY@users.noreply.github.com>
2026-08-15 10:12:47 +01:00
LéoandAlexandre Teixeira f9235ebbf1 docs(setup): document the HTTP/2 reverse-proxy setup (#6046)
* docs(setup): document the HTTP/2 reverse-proxy setup

The "private or proxied deployments" section named Caddy, nginx and Traefik
but gave no runnable config, and never mentioned the main reason to bother:
the frontend is unbundled ES modules, so a page load is a few hundred small
same-origin requests. Over HTTP/1.1 the 6-connection cap serialises those
into dozens of round trips, which is invisible on localhost and dominates
load time over a LAN or VPN.

Adds a five-step setup you can paste: a Caddyfile for each of the three ways
people reach these boxes (public domain, Tailscale, own certificate), how to
run the proxy in the foreground and then as a service, the .env keys that
have to follow the origin, and a curl one-liner to confirm HTTP/2 actually
negotiated.

Also covers what bites when moving an existing install behind TLS:
SECURE_COOKIES applying regardless of the scheme the request arrived on,
OAUTH_REDIRECT_BASE_URL still defaulting to localhost because the MCP
redirect is registered up front rather than derived per request, and HSTS
being host-wide and port-agnostic. Notes that a custom HTTPS port does not
stop Caddy binding port 80 for the redirect, which is the failure I hit
first.

Docs only — no code change is needed to run behind HTTP/2 today.

* docs(setup): clarify HTTP/2 and origin migration

---------

Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
2026-08-14 18:44:42 +01:00
49e4e55d2c fix(skills): harden skill import against DNS rebinding and SSRF TOCTOU (#5986)
* fix(skill-importer): validate URL scheme and improve skills.sh handling

* fix(skill-importer): enhance DNS resolution and SSRF protection in fetch URL handling

* fix(url-safety): add allowed_dist parameter to check_outbound_url for flexible private blocking

* test(skill-importer): add comprehensive tests for URL parsing and outbound checks

* ensure newline at end of file in test_check_outbound_url_allows_public_ip

* fix(skill-importer): improve TLS certificate handling in _get_checked function

* fix(skill-importer): enhance _check_fetch_url to handle both hostnames and full URLs

* fix(skill-importer): enhance parse_skill_source to support skills.sh URLs in path and netloc

* fix(skill-importer): simplify skills.sh hostname check in parse_skill_source

* fix(skill-importer): enhance parse_skill_source to identify skills.sh URLs in path and handle localhost/IP addresses

* fix(skill-importer): enhance _resolve_and_check_url to validate all resolved IP addresses and prevent TOCTOU vulnerabilities

* fix(skill-importer): enhance parse_skill_source to support schemeless GitHub and skills.sh URLs

* fix(memory): resolve CodeQL URL sanitization warning and restore _check_fetch_url test alias

* fix(memory): pin skill fetch sockets without rewriting URLs

* fix(memory): reject unsupported skill wrapper hosts

* refactor(url-safety): remove unused importer exception

* test(memory): keep redirect regression hermetic

* test(dns-rebinding): add test for _PinnedTransport to ensure connection to pinned IP

* fix(skill-importer): enhance skills.sh support to extract GitHub links from page content

* fix(skill-importer): improve URL scheme validation for GitHub and skills.sh links

* fix(skills): reject unusable skill URLs instead of guessing

Resolving a skills.sh link by scraping the first github.com URL out of
the page body cannot work. Skill pages only ever link the repository
root, never the skill's subdirectory, so every skill in a repo resolved
to the same bundle: importing skills.sh/anthropics/skills/pdf walked the
whole monorepo, saturated the 64-file cap, and installed algorithmic-art
behind an ok:true response. Restore the redirect-target unwrap and fail
with a message that says what to do instead.

Also report the real reason a URL is rejected. The scheme check keyed off
"://" appearing anywhere in the string, so a supplied-but-unusable URL
came back as "URL is required", and a schemeless URL carrying "://" in
its query was reported as an unsupported scheme. Key off the parsed
scheme and let opaque schemes (mailto:, javascript:) and a schemeless
host:port fall through to the host check.

* test(skills): tighten the real-socket pinning regression

The handler swallowed its own exceptions, so a failure inside it
surfaced as a confusing assertion on the captured client address.
Record the exception and assert on it, run the thread as a daemon, and
close the listening socket from the test so a hang cannot outlive the
run. Also drop the duplicate ipaddress import and the missing newline.

* fix(skills): require exact GitHub skill URLs

* test(skills): read complete pinned request headers

---------

Co-authored-by: RaresKeY <158580472+RaresKeY@users.noreply.github.com>
Co-authored-by: Léo <leograndcontact@gmail.com>
2026-08-14 13:33:06 +01:00
Christian SidakandAlexandre Teixeira b2789d04fb fix: stop status polling from cancelling running scheduled tasks (#5789)
* fix: stop polling GET /api/tasks/runs/recent from cancelling running tasks

Two paths caused the scheduler to interrupt a running background task
when the frontend Activity view polled for status:

1. GET /api/tasks/runs/recent was not in _PASSIVE_EXACT_PATHS, so
   _InteractiveActivityMiddleware treated it as a foreground request
   and called stop_background_tasks_for_foreground, cancelling any
   in-flight scheduled task. Add it to _PASSIVE_EXACT_PATHS alongside
   the other read-only polling endpoints.

2. The /api/activity/heartbeat handler called
   stop_background_tasks_for_foreground unconditionally, ignoring
   BACKGROUND_TASK_FOREGROUND_GATE=false. Wrap the call in a
   _gate_enabled() guard so the env var fully disables heartbeat-
   triggered cancellations.

Fixes #5782

Signed-off-by: Christian Sidak <christian@sentineltech.eu>
Signed-off-by: Christian-Sidak <61099993+Christian-Sidak@users.noreply.github.com>

* fix(scheduler): respect foreground gate for heartbeat

---------

Signed-off-by: Christian Sidak <christian@sentineltech.eu>
Signed-off-by: Christian-Sidak <61099993+Christian-Sidak@users.noreply.github.com>
Co-authored-by: Alexandre Teixeira <alexandremagteixeira@gmail.com>
2026-08-14 10:47:47 +01:00
Michaelandmichaelxer a6bc86e331 fix(scheduler): treat /api/email/unread-state as passive UI poll (#6009)
Background scheduled agent runs were aborted as "Stopped by user" when
the web UI was merely open, because the idle /api/email/unread-state
poll was counted as foreground activity while its sibling
/api/email/urgency-state was already excluded.

Fixes #5981

Co-authored-by: michaelxer <michaelxer@users.noreply.github.com>
2026-08-14 10:22:27 +01:00
c4369305f0 refactor(model-routing): centralize explicit foreground fallback policy (#6020)
* refactor(model-routing): centralize explicit foreground fallback policy

Make foreground fallback an explicit per-user, availability-only policy shared by streaming Chat, non-stream Chat, and Agent runs.

Preserve strict defaults, owner/model and credential boundaries, pinned Agent routes, and truthful per-round provenance/accounting. Carry provider-reported model identifiers through native streaming adapters, non-stream responses, and caches, and keep legacy default_model_fallbacks as tombstoned raw storage that generic settings APIs and agent tools cannot expose or mutate.

* fix(agent-loop): restore rebase-dropped qwen routing, workspace prompt, and temperature clamp

* fix(model-routing): thread selected endpoint identity, fix cost classification and fallback eligibility

* fix(chat): restore stream helpers and harden run stop lifecycle

* fix(model-routing): let numeric provider codes win over symbolic rate-limit statuses

* fix(agent-loop): apply qwen temperature and notes-tool clamps per fallback candidate

* fix(chat): honor queued stop across resend and reload canonical terminal on EOF

* fix(chat): track stop queue and cleanup ownership by per-send generation

* fix(agent-loop): preserve requested temperature for non-qwen fallback candidates

* fix(chat): reserve send ownership before any await and scope stop to the current send

* fix(chat): clear the previous run identity at send reservation

---------

Co-authored-by: RaresKeY <158580472+RaresKeY@users.noreply.github.com>
Co-authored-by: StressTestor <212606152+StressTestor@users.noreply.github.com>
2026-08-14 08:10:30 +01:00
63 changed files with 13355 additions and 1160 deletions
+14 -3
View File
@@ -630,13 +630,24 @@ app.include_router(auth_router)
@app.post("/api/activity/heartbeat")
async def activity_heartbeat():
from src.interactive_gate import mark_browser_activity
from src.interactive_gate import (
mark_browser_activity,
maybe_stop_background_tasks_for_heartbeat,
)
await mark_browser_activity()
async def _stop_background():
try:
await task_scheduler.stop_background_tasks_for_foreground(reason="browser heartbeat")
await maybe_stop_background_tasks_for_heartbeat(
task_scheduler.stop_background_tasks_for_foreground
)
except Exception:
logging.getLogger("app.foreground_gate").debug("heartbeat task stop failed", exc_info=True)
logging.getLogger("app.foreground_gate").debug(
"heartbeat task stop failed",
exc_info=True,
)
asyncio.create_task(_stop_background())
return {"ok": True}
+19 -2
View File
@@ -1491,8 +1491,25 @@ def _migrate_assign_legacy_owner():
with open(prefs_path, "r", encoding="utf-8") as f:
prefs = _json.load(f)
if "_users" not in prefs and prefs:
# Flat format → nest under admin user
new_prefs = {"_users": {admin_user: prefs}}
# Flat format → nest ordinary preferences under the admin
# user. Foreground fallback is an explicit per-owner opt-in,
# so auth-disabled consent must remain inert at the flat root
# rather than becoming consent for the first named owner.
foreground_keys = {
"foreground_fallback_enabled",
"foreground_model_fallbacks",
}
named_prefs = {
key: value
for key, value in prefs.items()
if key not in foreground_keys
}
new_prefs = {
key: prefs[key]
for key in foreground_keys
if key in prefs
}
new_prefs["_users"] = {admin_user: named_prefs}
with open(prefs_path, "w", encoding="utf-8") as f:
_json.dump(new_prefs, f, indent=2)
logger.info(f"Migrated user_prefs.json to per-user format under '{admin_user}'")
+5
View File
@@ -129,12 +129,17 @@ services:
fi
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
fi
# Advisory: a settings file the migration cannot parse or rewrite must
# not be what stops searxng from booting. It explains itself on stderr
# and we carry on, letting searxng report anything genuinely wrong.
/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"
volumes:
- searxng-data:/etc/searxng
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
- ./scripts/migrate_searxng_settings.py:/tmp/migrate-searxng-settings.py:ro,z
environment:
- SEARXNG_BASE_URL=http://localhost:8080/
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
+5
View File
@@ -132,12 +132,17 @@ services:
fi
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
fi
# Advisory: a settings file the migration cannot parse or rewrite must
# not be what stops searxng from booting. It explains itself on stderr
# and we carry on, letting searxng report anything genuinely wrong.
/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"
volumes:
- searxng-data:/etc/searxng
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
- ./scripts/migrate_searxng_settings.py:/tmp/migrate-searxng-settings.py:ro,z
environment:
- SEARXNG_BASE_URL=http://localhost:8080/
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
+5
View File
@@ -110,12 +110,17 @@ services:
fi
sed "s|__SEARXNG_SECRET__|$$secret|g" /tmp/searxng-settings.yml.template > /etc/searxng/settings.yml
fi
# Advisory: a settings file the migration cannot parse or rewrite must
# not be what stops searxng from booting. It explains itself on stderr
# and we carry on, letting searxng report anything genuinely wrong.
/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"
volumes:
- searxng-data:/etc/searxng
- ./config/searxng/settings.yml:/tmp/searxng-settings.yml.template:ro,z
- ./scripts/migrate_searxng_settings.py:/tmp/migrate-searxng-settings.py:ro,z
environment:
- SEARXNG_BASE_URL=http://localhost:8080/
- SEARXNG_SECRET=${SEARXNG_SECRET:-}
+148
View File
@@ -497,6 +497,154 @@ Odysseus serves plain HTTP on its app port. Docker Compose binds Odysseus and th
Cloudflare Access, Tailscale, Caddy, nginx, and Traefik can all fit this pattern; none are required by Odysseus. If your access layer reaches Odysseus on the same host, proxy to `http://127.0.0.1:7000` and keep `AUTH_ENABLED=true`, `LOCALHOST_BYPASS=false`, and `SECURE_COOKIES=true`.
`ALLOWED_ORIGINS` lists exact permitted origins for cross-origin browser/API clients; ordinary same-origin reverse-proxy access usually does not need a special CORS entry.
#### Faster over the network: HTTP/2
The frontend is raw ES modules with no bundler, so a page load is a few hundred
small same-origin requests. Over HTTP/1.1 browsers typically allow only a small
number of concurrent connections per host (commonly around six), so many of
those requests are serialized across multiple round trips. On localhost that
costs almost nothing. Over a LAN, VPN, or remote link it can become a major
part of load time, especially as latency increases.
HTTP/2 multiplexes them onto one connection and the serialisation disappears.
Odysseus needs no changes for this — uvicorn keeps speaking HTTP/1.1 on
loopback and the proxy speaks HTTP/2 to the browser. Mainstream browsers
negotiate HTTP/2 for normal web pages over TLS; they do not use the cleartext
h2c mode here, so browser-facing HTTP/2 requires a certificate. The
`--ssl-certfile` route in *HTTPS + LAN/Tailscale exposure* above gives you
HTTPS but not HTTP/2 — uvicorn does not speak it.
**1. Install Caddy.** See the [install docs](https://caddyserver.com/docs/install)
for your platform; on macOS, `brew install caddy`.
**2. Write a `Caddyfile`.** Pick the block that matches how you reach the
machine. Replace `7000` if Odysseus listens elsewhere — the macOS start script
uses `7860`.
Public domain, Caddy obtains and renews the certificate itself:
```
odysseus.example.com {
reverse_proxy 127.0.0.1:7000
}
```
Tailscale, no public DNS needed — `tailscale cert` issues a browser-trusted
certificate for a tailnet name and writes `<domain>.crt` and `<domain>.key`:
```bash
tailscale cert myhost.tailnet-name.ts.net
```
```
myhost.tailnet-name.ts.net {
tls /path/to/myhost.tailnet-name.ts.net.crt /path/to/myhost.tailnet-name.ts.net.key
reverse_proxy 127.0.0.1:7000
}
```
LAN with your own certificate — same shape, your own files:
```
odysseus.lan {
tls /path/to/cert.pem /path/to/key.pem
reverse_proxy 127.0.0.1:7000
}
```
Give `tls` absolute paths: a service starts in a working directory you did not
choose. If port 443 is already taken, append a port to the site address
(`odysseus.example.com:8443`) and use it in the URL. That alone does not free
port 80 — Caddy still binds it for the HTTP-to-HTTPS redirect, and fails to
start with `listen tcp :80: bind: address already in use` if something else
holds it. Turn the redirect off with a global block at the top of the file:
```
{
auto_https disable_redirects
}
```
**3. Run it in the foreground first:**
```bash
caddy run --config ./Caddyfile
```
Once that works, run it as a service:
```bash
brew services start caddy # macOS — reads $(brew --prefix)/etc/Caddyfile, not ./Caddyfile
sudo systemctl enable --now caddy # Linux, if your package installed the unit
```
Odysseus's own service is unchanged; the proxy runs alongside it. Under Docker,
run the proxy as another container, or on the host pointing at the published
port.
**4. Point Odysseus at the new origin** in `.env`, then restart it:
```bash
SECURE_COOKIES=true
# only if you use remote MCP servers with OAuth:
OAUTH_REDIRECT_BASE_URL=https://odysseus.example.com
```
Gmail OAuth needs nothing here when the proxy runs on the same host: the
redirect URI is built from the incoming request, and uvicorn rewrites the
scheme from `X-Forwarded-Proto` for proxies it trusts — by default only
`127.0.0.1`. A proxy in a separate container or on another machine is not
trusted, so pin the URI there:
```bash
GOOGLE_OAUTH_REDIRECT_URI=https://odysseus.example.com/api/email/oauth/google/callback
```
(uvicorn's own `FORWARDED_ALLOW_IPS` widens that trust, but it has to be in the
environment uvicorn starts with — `.env` is read by the app afterwards, too
late for it to take effect.)
**5. Confirm HTTP/2 is really on:**
```bash
curl -s -o /dev/null -w '%{http_version}\n' https://odysseus.example.com/
# 2
```
The status code is not the thing to check here — a logged-out request redirects
to the login page, so `curl -I` shows `HTTP/2 302`, and the `HTTP/2` prefix is
the part that matters. The browser reports the same in the Network panel's
Protocol column (`h2`); in Chrome and Firefox that column is hidden until you
enable it by right-clicking the column headers.
Three things bite when moving an existing install behind TLS:
- Set `SECURE_COOKIES=true` **at the same time** you stop serving plain HTTP,
not before. The flag is applied to every login regardless of the scheme the
request arrived on, so while an HTTP entrypoint is still reachable the
browser will reject the `Secure` cookie there and login will appear to loop.
- `OAUTH_REDIRECT_BASE_URL` defaults to `http://localhost:7000`. Unlike the
Gmail redirect URI it cannot be derived from a request — it is registered
with each MCP authorization server up front — so set it to the external
origin if you use remote MCP servers over OAuth.
- Odysseus sends `Strict-Transport-Security` once it sees `X-Forwarded-Proto:
https`. HSTS applies to the whole hostname and ignores the port, so any other
plain-HTTP service on that same hostname becomes unreachable in browsers that
have visited Odysseus. Give Odysseus its own hostname, or strip the header at
the proxy (`header_down -Strict-Transport-Security` in Caddy).
Server-sent events are not buffered by this configuration, so chat streaming
arrives token by token; add `flush_interval -1` inside the `reverse_proxy`
block if you want that pinned explicitly. nginx needs `proxy_buffering off;`
for the same reason.
Changing the external origin also affects state scoped to it. Service workers
and their caches are origin-scoped, so moving to a different origin starts with
a cold load. Cookies follow their own domain/path/security rules rather than
being port-scoped: changing the hostname normally requires a new login, while
changing only the scheme or port does not by itself guarantee that existing
cookies disappear.
Common internal-only ports from the default docs/compose setup:
| Port | Service |
-8
View File
@@ -1802,7 +1802,6 @@ async def _ai_draft_reply_to_email(uid, folder="INBOX", reply_all=False, account
from src.endpoint_resolver import (
resolve_endpoint,
resolve_utility_fallback_candidates,
resolve_chat_fallback_candidates,
)
from src.llm_core import llm_call_async_with_fallback
except Exception as exc:
@@ -1843,13 +1842,6 @@ async def _ai_draft_reply_to_email(uid, folder="INBOX", reply_all=False, account
utility_fallbacks = resolve_utility_fallback_candidates() or []
for cand in utility_fallbacks:
_add(*cand)
try:
chat_fallbacks = resolve_chat_fallback_candidates(owner=None) or []
except TypeError:
chat_fallbacks = resolve_chat_fallback_candidates() or []
for cand in chat_fallbacks:
_add(*cand)
if not candidates:
return {"error": "No LLM endpoint configured for AI reply"}
+6 -2
View File
@@ -22,6 +22,8 @@ from src.settings import (
load_features as _load_features,
save_features as _save_features,
DEFAULT_SETTINGS,
RETIRED_SETTING_KEYS,
without_retired_settings,
)
from src.integrations import (
load_integrations,
@@ -689,7 +691,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
a scrubbed copy with secret keys blanked. The frontend uses this
for keybinds + TTS prefs, so it stays callable without admin."""
user = _get_current_user(request)
settings = _load_settings()
settings = without_retired_settings(_load_settings())
if user and auth_manager.is_admin(user):
return settings
return scrub_settings(settings)
@@ -709,6 +711,8 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
"agent_max_tool_calls": (0, 1000), # 0 = unlimited
}
for key in DEFAULT_SETTINGS:
if key in RETIRED_SETTING_KEYS:
continue
if key not in body:
continue
val = body[key]
@@ -721,7 +725,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
val = max(lo, min(val, hi))
current[key] = val
_save_settings(current)
return current
return without_retired_settings(current)
# ---- Integrations CRUD ----
+47 -100
View File
@@ -15,7 +15,7 @@ from core.database import Session as DBSession, ModelEndpoint
from src.llm_core import normalize_model_id
from src.endpoint_resolver import normalize_base
from src.context_compactor import maybe_compact, trim_for_context
from src.model_context import estimate_tokens
from src.model_context import estimate_tokens, get_context_length
from src.auth_helpers import effective_user
from src.prompt_security import untrusted_context_message
from src.attachment_refs import attachment_ref
@@ -152,10 +152,38 @@ class ChatContext:
# Uploads attached to this user turn, resolved and owner-checked for the
# agent's private context. This is not emitted to the browser.
uploaded_files: list = field(default_factory=list)
# Route-neutral prompt before any model-window compaction/trimming. This is
# retained only when explicit foreground fallbacks are enabled so each
# concrete candidate can apply its own context budget independently.
route_messages: list = field(default_factory=list)
# ── Helpers ────────────────────────────────────────────────────────────── #
def _allowed_models_from_privileges(privs: dict) -> Optional[frozenset[str]]:
if privs.get("block_all_models"):
return frozenset()
allowed_raw = privs.get("allowed_models")
allowed = allowed_raw if isinstance(allowed_raw, list) else []
restricted = bool(privs.get("allowed_models_restricted")) or bool(allowed)
return frozenset(model for model in allowed if isinstance(model, str)) if restricted else None
def _allowed_models_for_request(request) -> Optional[frozenset[str]]:
"""Return the caller's model allowlist, or ``None`` when unrestricted."""
try:
user = effective_user(request)
except Exception:
user = None
if not user:
return None
auth_manager = getattr(getattr(request.app, "state", None), "auth_manager", None)
if not auth_manager:
return None
privs = auth_manager.get_privileges(user) or {}
return _allowed_models_from_privileges(privs)
def _enforce_chat_privileges(request, sess) -> None:
"""Apply the per-user privilege gates (allowed_models + max_messages_per_day)
that both /api/chat and /api/chat_stream must enforce BEFORE any LLM work.
@@ -185,10 +213,8 @@ def _enforce_chat_privileges(request, sess) -> None:
if privs.get("block_all_models"):
raise HTTPException(403, f"Your account is not allowed to use model '{sess.model}'.")
allowed_raw = privs.get("allowed_models")
allowed = allowed_raw if isinstance(allowed_raw, list) else []
restricted = bool(privs.get("allowed_models_restricted")) or bool(allowed)
if restricted and sess.model and sess.model not in allowed:
allowed_models = _allowed_models_from_privileges(privs)
if allowed_models is not None and sess.model and sess.model not in allowed_models:
raise HTTPException(403, f"Your account is not allowed to use model '{sess.model}'.")
cap = int(privs.get("max_messages_per_day") or 0)
@@ -287,96 +313,6 @@ async def auto_name_session(session_manager, sess):
logger.error(f"Auto-name failed for {sess.id}: {e}\n{traceback.format_exc()}")
def try_fallback_endpoint(sess, session_id: str) -> dict | None:
"""Find an alternative working endpoint when the current one fails.
Returns {"model": ..., "endpoint_url": ..., "endpoint_name": ...} or None.
"""
import requests as _req
from src.endpoint_resolver import (
build_chat_url,
build_headers,
build_models_url,
normalize_base,
resolve_endpoint_runtime,
)
from src.chatgpt_subscription import is_chatgpt_subscription_base
current_url = sess.endpoint_url or ""
owner = getattr(sess, "owner", None)
db = SessionLocal()
try:
q = db.query(ModelEndpoint).filter(
ModelEndpoint.is_enabled == True
)
if owner:
from src.auth_helpers import owner_filter
q = owner_filter(q, ModelEndpoint, owner)
endpoints = q.all()
finally:
db.close()
for ep in endpoints:
base = normalize_base(ep.base_url)
# Skip current endpoint
if current_url and base in current_url:
continue
try:
base, api_key = resolve_endpoint_runtime(ep, owner=owner)
except Exception:
continue
ping_url = build_models_url(base)
headers = build_headers(api_key, base)
try:
if ping_url:
r = _req.get(ping_url, headers=headers, timeout=5)
r.raise_for_status()
data = r.json()
models = [m.get("id") for m in (data.get("data") or []) if m.get("id")]
if not models:
models = [
m.get("name") or m.get("model")
for m in (data.get("models") or [])
if m.get("name") or m.get("model")
]
else:
models = json.loads(ep.cached_models or "[]")
if not models:
continue
# Found a working endpoint — update session
new_model = models[0]
chat_url = build_chat_url(base)
new_headers = build_headers(api_key, base)
persisted_headers = {} if is_chatgpt_subscription_base(base) else new_headers
sess.model = new_model
sess.endpoint_url = chat_url
sess.headers = new_headers
# Persist
_db = SessionLocal()
try:
_db.query(DBSession).filter(DBSession.id == session_id).update({
"model": new_model,
"endpoint_url": chat_url,
"headers": persisted_headers,
})
_db.commit()
finally:
_db.close()
logger.info(f"Fallback: switched session {session_id} from {current_url} to {ep.name} ({new_model})")
return {
"model": new_model,
"endpoint_url": chat_url,
"endpoint_name": ep.name,
}
except Exception:
continue
return None
def extract_preset(chat_handler, preset_id) -> PresetInfo:
"""Extract preset parameters via chat_handler."""
temperature, max_tokens, system_prompt, char_name = (
@@ -687,6 +623,7 @@ async def build_chat_context(
use_enhanced_message: bool = False,
agent_mode: bool = False,
allow_tool_preprocessing: bool = True,
defer_context_shaping: bool = False,
) -> ChatContext:
"""Build the full context (preface + messages) for an LLM call.
@@ -830,13 +767,22 @@ async def build_chat_context(
except Exception:
logger.debug("Failed to add current date/time context", exc_info=True)
# Auto-compact
messages, context_length, was_compacted = await maybe_compact(
sess, sess.endpoint_url, sess.model, messages, sess.headers, owner=user,
)
route_messages = list(messages)
# Explicit fallback routing must shape from the same route-neutral prompt
# for every candidate. Running selected-model compaction here would mutate
# session history before we know which route can answer and would make a
# later larger-context candidate unable to recover discarded history.
if defer_context_shaping:
context_length = get_context_length(sess.endpoint_url, sess.model)
was_compacted = False
else:
messages, context_length, was_compacted = await maybe_compact(
sess, sess.endpoint_url, sess.model, messages, sess.headers, owner=user,
)
_before_trim_messages = len(messages)
_before_trim_tokens = estimate_tokens(messages)
messages = trim_for_context(messages, context_length)
if not defer_context_shaping:
messages = trim_for_context(messages, context_length)
_after_trim_messages = len(messages)
_after_trim_tokens = estimate_tokens(messages)
_context_trimmed = _after_trim_messages < _before_trim_messages or _after_trim_tokens < _before_trim_tokens
@@ -860,6 +806,7 @@ async def build_chat_context(
context_tokens_after_trim=_after_trim_tokens,
auto_opened_docs=auto_opened_docs,
uploaded_files=uploaded_files,
route_messages=route_messages,
)
+561 -37
View File
@@ -15,13 +15,28 @@ from pydantic import ValidationError
from core.models import ChatMessage
from src.request_models import ChatRequest
from src.llm_core import llm_call_async, stream_llm, stream_llm_with_fallback
from src.llm_core import (
_normalize_http_status,
llm_call_async,
llm_call_async_with_route_fallback,
stream_llm,
stream_llm_with_fallback,
)
from src.agent_loop import stream_agent_loop
from src import agent_runs
from src.model_context import estimate_tokens
from src.context_compactor import (
apply_compaction_state,
maybe_compact,
trim_for_context,
)
from src.chat_helpers import coerce_message_and_session
from src.endpoint_resolver import normalize_base as _normalize_base, build_chat_url
from src.foreground_model_routing import build_foreground_model_candidates
from src.foreground_model_routing import (
build_foreground_model_candidates,
build_foreground_route_descriptors,
resolve_foreground_model_policy,
)
from src.session_search import search_session_messages
from src.prompt_security import untrusted_context_message
from core.exceptions import SessionNotFoundError
@@ -39,7 +54,9 @@ from routes.chat_helpers import (
build_chat_context,
save_assistant_response,
run_post_response_tasks,
accumulate_token_usage,
clean_thinking_for_save,
_allowed_models_for_request,
_enforce_chat_privileges,
)
from src.action_intents import ToolIntent, classify_tool_intent as _classify_tool_intent
@@ -57,6 +74,74 @@ logger = logging.getLogger(__name__)
_active_streams: Dict[str, dict] = {}
def _stream_failure_status(chunk: str) -> Optional[int]:
"""Extract a provider status without retaining provider-supplied detail."""
try:
for line in str(chunk or "").splitlines():
if not line.startswith("data: "):
continue
status = json.loads(line[6:]).get("status")
return _normalize_http_status(status)
except json.JSONDecodeError:
return None
return None
def _chat_candidate_request_factory(
messages,
fallback_context_length: int = 0,
*,
session=None,
owner: Optional[str] = None,
):
"""Shape one route-neutral Chat prompt for each candidate window."""
state = {
"requests": {},
"context_lengths": {},
"trim_stats": {},
"compactions": {},
"was_compacted": {},
}
async def factory(index, candidate_url, candidate_model, candidate_headers):
compaction_state = {}
candidate_messages, context_length, was_compacted = await maybe_compact(
session,
candidate_url,
candidate_model,
list(messages),
candidate_headers,
owner=owner,
persist=False,
compaction_state=compaction_state,
)
if not context_length:
context_length = fallback_context_length
request_messages = trim_for_context(candidate_messages, context_length)
state["requests"][index] = request_messages
state["context_lengths"][index] = context_length
state["compactions"][index] = compaction_state
state["was_compacted"][index] = was_compacted
state["trim_stats"][index] = {
"messages_before": len(messages),
"messages_after": len(request_messages),
"tokens_before": estimate_tokens(messages),
"tokens_after": estimate_tokens(request_messages),
}
return {"messages": request_messages}
return factory, state
def _candidate_index(candidates, actual_candidate) -> int:
for index, candidate in enumerate(candidates):
if candidate == actual_candidate:
return index
return 0
def _stream_set(session_id: str, **fields) -> None:
"""Update fields on the active-stream entry for `session_id`, or
no-op if the entry has already been popped. Using .get() avoids a
@@ -590,8 +675,8 @@ def setup_chat_routes(
# ------------------------------------------------------------------ #
# POST /api/chat (non-streaming)
# ------------------------------------------------------------------ #
@router.post("/api/chat", response_model=Dict[str, str])
async def chat_endpoint(request: Request, chat_request: ChatRequest) -> Dict[str, str]:
@router.post("/api/chat", response_model=Dict[str, Any])
async def chat_endpoint(request: Request, chat_request: ChatRequest) -> Dict[str, Any]:
_set_user_time_from_request(request)
message = chat_request.message
@@ -623,6 +708,8 @@ def setup_chat_routes(
400,
"No model selected for this chat. Open the model picker and choose one before sending.",
)
if not (getattr(sess, "endpoint_url", "") or "").strip():
raise HTTPException(400, "Selected model endpoint is not configured")
# Same allowed_models + daily-cap gate as chat_stream (mirror so the
# non-streaming path can't be used to bypass).
@@ -638,6 +725,11 @@ def setup_chat_routes(
if memory_response:
return {"response": memory_response}
foreground_policy = resolve_foreground_model_policy(
owner=owner,
allowed_models=_allowed_models_for_request(request),
)
# Build shared context (preset, preprocess, preface, compact)
ctx = await build_chat_context(
sess, request, chat_handler, chat_processor,
@@ -649,6 +741,7 @@ def setup_chat_routes(
time_filter=time_filter,
webhook_manager=webhook_manager,
allow_tool_preprocessing=allow_tool_preprocessing,
defer_context_shaping=foreground_policy.enabled,
)
# Research injection
@@ -662,24 +755,88 @@ def setup_chat_routes(
research_ctx = await research_handler.call_research_service(
message, _r_ep, _r_model, llm_headers=_r_headers
)
ctx.messages.insert(
len(ctx.preface),
untrusted_context_message("research context", research_ctx),
)
research_message = untrusted_context_message("research context", research_ctx)
ctx.messages.insert(len(ctx.preface), research_message)
if foreground_policy.enabled:
getattr(ctx, "route_messages", ctx.messages).insert(
len(ctx.preface),
research_message,
)
except Exception as e:
logger.error(f"Research failed: {e}")
reply = await llm_call_async(
foreground_candidates = build_foreground_model_candidates(
sess.endpoint_url,
sess.model,
ctx.messages,
headers=sess.headers,
sess.headers,
owner=owner,
policy=foreground_policy,
)
route_descriptors = build_foreground_route_descriptors(
sess.endpoint_url,
sess.model,
sess.headers,
owner=owner,
policy=foreground_policy,
selected_endpoint_id=chat_request.selected_endpoint_id,
)
candidate_request_factory = None
selected_context_length = getattr(ctx, "context_length", 0)
candidate_request_state = {
"context_lengths": {0: selected_context_length},
"requests": {0: ctx.messages},
"trim_stats": {},
}
request_messages = ctx.messages
if foreground_policy.enabled:
request_messages = getattr(ctx, "route_messages", ctx.messages)
candidate_request_factory, candidate_request_state = _chat_candidate_request_factory(
request_messages,
selected_context_length,
session=sess,
owner=owner,
)
requested_model = sess.model
reply, actual_candidate, actual_model = await llm_call_async_with_route_fallback(
foreground_candidates,
request_messages,
fallback_statuses=foreground_policy.eligible_statuses,
candidate_request_factory=candidate_request_factory,
temperature=ctx.preset.temperature,
max_tokens=ctx.preset.max_tokens,
prompt_type=preset_id,
session_id=session,
)
_clean_reply, _clean_md = clean_thinking_for_save(reply, {"model": sess.model})
actual_index = _candidate_index(foreground_candidates, actual_candidate)
apply_compaction_state(
sess,
candidate_request_state.get("compactions", {}).get(actual_index),
)
requested_route = route_descriptors[0]
actual_route = route_descriptors[actual_index]
actual_trim = candidate_request_state.get("trim_stats", {}).get(actual_index, {})
_clean_reply, _clean_md = clean_thinking_for_save(
reply,
{
"model": actual_model,
"requested_model": requested_model,
"endpoint_id": actual_route.get("endpoint_id"),
"endpoint_label": actual_route.get("endpoint_label"),
"requested_endpoint_id": requested_route.get("endpoint_id"),
"requested_endpoint_label": requested_route.get("endpoint_label"),
"context_length": candidate_request_state["context_lengths"].get(
actual_index,
selected_context_length,
),
"context_trimmed": bool(
actual_trim
and (
actual_trim.get("messages_after") < actual_trim.get("messages_before")
or actual_trim.get("tokens_after") < actual_trim.get("tokens_before")
)
),
},
)
sess.add_message(ChatMessage("assistant", _clean_reply, metadata=_clean_md))
from core.database import update_session_last_accessed
@@ -695,7 +852,15 @@ def setup_chat_routes(
allow_background_extraction=not tool_policy.block_all_tool_calls,
)
return {"response": reply}
return {
"response": reply,
"requested_model": requested_model,
"model": actual_model,
"requested_endpoint_id": requested_route.get("endpoint_id"),
"requested_endpoint_label": requested_route.get("endpoint_label"),
"endpoint_id": actual_route.get("endpoint_id"),
"endpoint_label": actual_route.get("endpoint_label"),
}
# ------------------------------------------------------------------ #
# POST /api/chat_stream
@@ -724,6 +889,11 @@ def setup_chat_routes(
use_research = form_data.get("use_research")
time_filter = form_data.get("time_filter")
preset_id = form_data.get("preset_id")
selected_endpoint_id = str(
form_data.get("selected_endpoint_id")
or (body or {}).get("selected_endpoint_id")
or ""
).strip()
# Issue #3229: API callers send JSON, not FormData. Read from the
# JSON body as fallback so callers who send {"allow_bash": true}
# actually get bash enabled.
@@ -896,6 +1066,8 @@ def setup_chat_routes(
400,
"No model selected for this chat. Open the model picker and choose one before sending.",
)
if not (getattr(sess, "endpoint_url", "") or "").strip():
raise HTTPException(400, "Selected model endpoint is not configured")
if (
chat_mode == "chat"
and isinstance(message, str)
@@ -971,6 +1143,10 @@ def setup_chat_routes(
last_user_message=message,
)
allow_tool_preprocessing = not pre_context_tool_policy.block_all_tool_calls
foreground_policy = resolve_foreground_model_policy(
owner=owner,
allowed_models=_allowed_models_for_request(request),
)
# Build shared context (stream path uses enhanced_message for context preface)
ctx = await build_chat_context(
@@ -993,6 +1169,7 @@ def setup_chat_routes(
# index would be useless / unwanted noise.
agent_mode=(chat_mode == "agent"),
allow_tool_preprocessing=allow_tool_preprocessing,
defer_context_shaping=foreground_policy.enabled,
)
_research_flags = {"do": do_research} # Mutable container for generator scope
@@ -1292,6 +1469,8 @@ def setup_chat_routes(
"what aspects matter most, are they comparing to something, what's their context "
"(moving, traveling, curiosity). Be conversational. Keep it short."
})
if foreground_policy.enabled:
getattr(ctx, "route_messages", ctx.messages).insert(0, dict(ctx.messages[0]))
_skip_research = True
else:
_skip_research = False
@@ -1388,7 +1567,12 @@ def setup_chat_routes(
_active_streams.pop(session, None)
return
messages = _ensure_current_request_is_latest_user(ctx.messages, message)
context_source = (
getattr(ctx, "route_messages", ctx.messages)
if foreground_policy.enabled
else ctx.messages
)
messages = _ensure_current_request_is_latest_user(context_source, message)
# Auto-compact notification
if ctx.was_compacted:
@@ -1400,25 +1584,56 @@ def setup_chat_routes(
thinking_response = ""
last_metrics = None
# Foreground Chat and Agent requests use one owner-aware policy
# boundary. Legacy `default_model_fallbacks` data is not eligible.
# Foreground Chat and Agent requests share one explicit owner-aware
# policy. Strict mode is the default; legacy values are unrelated.
_foreground_policy = foreground_policy
_foreground_candidates = build_foreground_model_candidates(
sess.endpoint_url,
sess.model,
sess.headers,
owner=_user,
policy=_foreground_policy,
)
_foreground_route_descriptors = build_foreground_route_descriptors(
sess.endpoint_url,
sess.model,
sess.headers,
owner=_user,
policy=_foreground_policy,
selected_endpoint_id=selected_endpoint_id,
)
_chat_request_factory = None
_selected_context_length = getattr(ctx, "context_length", 0)
_chat_request_state = {
"context_lengths": {0: _selected_context_length},
"requests": {0: messages},
"trim_stats": {},
}
if _foreground_policy.enabled:
_chat_request_factory, _chat_request_state = _chat_candidate_request_factory(
messages,
_selected_context_length,
session=sess,
owner=_user,
)
# Send model name early so the frontend can show it during streaming
_model_suffix = "Research" if effective_do_research else None
_model_info = {"type": "model_info", "model": sess.model}
_selected_route = _foreground_route_descriptors[0]
_model_info = {
"type": "model_info",
"model": sess.model,
"endpoint_id": _selected_route.get("endpoint_id"),
"endpoint_label": _selected_route.get("endpoint_label"),
}
if _model_suffix:
_model_info["suffix"] = _model_suffix
if ctx.preset.character_name:
_model_info["character_name"] = ctx.preset.character_name
yield f'data: {json.dumps(_model_info)}\n\n'
if image_generation_session:
_terminal_saved = False
if _is_image_generation_session(sess, owner=_user):
from src.settings import get_setting
if tool_policy.blocks("generate_image"):
_blocked_msg = tool_policy.reason_for("generate_image")
@@ -1521,6 +1736,16 @@ def setup_chat_routes(
_answered_by = None # set if the selected model failed and a fallback answered
_requested_model = sess.model
_actual_model = None
_requested_route = _foreground_route_descriptors[0]
_actual_route = _requested_route
_actual_candidate_index = 0
_chat_terminal_saved = False
def _commit_chat_compaction(candidate_index: int) -> bool:
return apply_compaction_state(
sess,
_chat_request_state.get("compactions", {}).get(candidate_index),
)
# ── Chat mode: call stream_llm directly, NO tools, NO document access ──
try:
async for chunk in stream_llm_with_fallback(
@@ -1536,11 +1761,21 @@ def setup_chat_routes(
prompt_type=preset_id,
tools=None,
session_id=session,
fallback_statuses=_foreground_policy.eligible_statuses,
fallback_on_empty=_foreground_policy.fallback_on_empty,
candidate_request_factory=_chat_request_factory,
candidate_route_descriptors=_foreground_route_descriptors,
):
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
try:
data = json.loads(chunk[6:])
if "delta" in data:
if _commit_chat_compaction(_actual_candidate_index):
_compacted_length = _chat_request_state["context_lengths"].get(
_actual_candidate_index,
_selected_context_length,
)
yield f'data: {json.dumps({"type": "compacted", "context_length": _compacted_length})}\n\n'
# Reasoning tokens arrive flagged thinking:true.
# Forward them so the client can show a thinking
# indicator, but don't fold them into the saved
@@ -1556,29 +1791,82 @@ def setup_chat_routes(
# Forward the notice and remember the real model.
_answered_by = data.get("answered_by") or _answered_by
_actual_model = _actual_model or _answered_by
_actual_candidate_index = data.get("candidate_index", 0)
if not isinstance(_actual_candidate_index, int):
_actual_candidate_index = 0
if 0 <= _actual_candidate_index < len(_foreground_route_descriptors):
_actual_route = _foreground_route_descriptors[_actual_candidate_index]
if _commit_chat_compaction(_actual_candidate_index):
_compacted_length = _chat_request_state["context_lengths"].get(
_actual_candidate_index,
_selected_context_length,
)
yield f'data: {json.dumps({"type": "compacted", "context_length": _compacted_length})}\n\n'
data["selected_model"] = data.get("selected_model") or _requested_model
yield chunk
yield f'data: {json.dumps(data)}\n\n'
elif data.get("type") == "model_actual":
if _commit_chat_compaction(_actual_candidate_index):
_compacted_length = _chat_request_state["context_lengths"].get(
_actual_candidate_index,
_selected_context_length,
)
yield f'data: {json.dumps({"type": "compacted", "context_length": _compacted_length})}\n\n'
_actual_model = data.get("model") or _actual_model
data["requested_model"] = _requested_model
data["requested_endpoint_id"] = _requested_route.get("endpoint_id")
data["requested_endpoint_label"] = _requested_route.get("endpoint_label")
data["endpoint_id"] = _actual_route.get("endpoint_id")
data["endpoint_label"] = _actual_route.get("endpoint_label")
yield f'data: {json.dumps(data)}\n\n'
elif data.get("type") == "usage":
if _commit_chat_compaction(_actual_candidate_index):
_compacted_length = _chat_request_state["context_lengths"].get(
_actual_candidate_index,
_selected_context_length,
)
yield f'data: {json.dumps({"type": "compacted", "context_length": _compacted_length})}\n\n'
last_metrics = data.get("data", {})
_reported_model = last_metrics.get("model")
last_metrics["requested_model"] = _requested_model
last_metrics["model"] = _reported_model or _actual_model or _answered_by or _requested_model
if ctx.context_trimmed:
last_metrics["requested_endpoint_id"] = _requested_route.get("endpoint_id")
last_metrics["requested_endpoint_label"] = _requested_route.get("endpoint_label")
last_metrics["endpoint_id"] = _actual_route.get("endpoint_id")
last_metrics["endpoint_label"] = _actual_route.get("endpoint_label")
if isinstance(
_actual_route.get("endpoint_cost_tracked"),
bool,
):
last_metrics["endpoint_cost_tracked"] = _actual_route.get(
"endpoint_cost_tracked"
)
_actual_context_length = _chat_request_state["context_lengths"].get(
_actual_candidate_index,
_selected_context_length,
)
_route_trim = _chat_request_state.get("trim_stats", {}).get(
_actual_candidate_index,
{},
)
if _route_trim and (
_route_trim.get("messages_after") < _route_trim.get("messages_before")
or _route_trim.get("tokens_after") < _route_trim.get("tokens_before")
):
last_metrics["context_trimmed"] = True
last_metrics["context_messages_before_trim"] = _route_trim.get("messages_before")
last_metrics["context_messages_after_trim"] = _route_trim.get("messages_after")
last_metrics["context_tokens_before_trim"] = _route_trim.get("tokens_before")
last_metrics["context_tokens_after_trim"] = _route_trim.get("tokens_after")
elif ctx.context_trimmed:
last_metrics["context_trimmed"] = True
last_metrics["context_messages_before_trim"] = ctx.context_messages_before_trim
last_metrics["context_messages_after_trim"] = ctx.context_messages_after_trim
last_metrics["context_tokens_before_trim"] = ctx.context_tokens_before_trim
last_metrics["context_tokens_after_trim"] = ctx.context_tokens_after_trim
request_context_tokens = ctx.context_tokens_after_trim or estimate_tokens(messages)
last_metrics["request_context_tokens"] = request_context_tokens
if ctx.context_length and request_context_tokens:
pct = min(round((request_context_tokens / ctx.context_length) * 100, 1), 100.0)
if _actual_context_length and last_metrics.get("input_tokens"):
pct = min(round((last_metrics["input_tokens"] / _actual_context_length) * 100, 1), 100.0)
last_metrics["context_percent"] = pct
last_metrics["context_length"] = ctx.context_length
last_metrics["context_length"] = _actual_context_length
# The frontend reads `tokens_per_second`; the raw usage event
# carries the backend's true gen speed as `gen_tps` (llama.cpp
# timings). Map it through so this direct-chat path shows real
@@ -1593,17 +1881,121 @@ def setup_chat_routes(
yield chunk
elif chunk.startswith("event: error"):
logger.warning(f"Stream error for {sess.model} on {sess.endpoint_url}: {chunk!r}")
if (
not _chat_terminal_saved
and (full_response.strip() or thinking_response.strip())
):
_failure_status = _stream_failure_status(chunk)
_failure_message = (
f"Model request failed (HTTP {_failure_status})"
if _failure_status is not None
else "Model request failed"
)
_terminal_content = full_response.strip()
_failure_note = f"[Response stopped: {_failure_message}]"
_terminal_content = (
f"{_terminal_content}\n\n{_failure_note}"
if _terminal_content
else _failure_note
)
_had_terminal_usage = bool(last_metrics)
_terminal_metrics = dict(last_metrics or {})
if not _had_terminal_usage:
_actual_request_messages = _chat_request_state["requests"].get(
_actual_candidate_index,
messages,
)
_actual_context_length = _chat_request_state["context_lengths"].get(
_actual_candidate_index,
_selected_context_length,
)
_estimated_input = estimate_tokens(_actual_request_messages)
_estimated_output = max(
len(full_response + thinking_response) // 4,
0,
)
_terminal_metrics.update({
"input_tokens": _estimated_input,
"output_tokens": _estimated_output,
"total_tokens": _estimated_input + _estimated_output,
"usage_source": "estimated",
"response_time": round(time.time() - _chat_start, 2),
"context_length": _actual_context_length,
"context_percent": (
min(
round(
(_estimated_input / _actual_context_length) * 100,
1,
),
100.0,
)
if _actual_context_length
else 0
),
})
_terminal_metrics.update({
"failed": True,
"failure": {
"status": _failure_status,
"message": _failure_message,
},
"model": _actual_model or _answered_by or _requested_model,
"requested_model": _requested_model,
"endpoint_id": _actual_route.get("endpoint_id"),
"endpoint_label": _actual_route.get("endpoint_label"),
"requested_endpoint_id": _requested_route.get("endpoint_id"),
"requested_endpoint_label": _requested_route.get("endpoint_label"),
})
if isinstance(
_actual_route.get("endpoint_cost_tracked"),
bool,
):
_terminal_metrics["endpoint_cost_tracked"] = _actual_route.get(
"endpoint_cost_tracked"
)
if thinking_response.strip():
_terminal_metrics["thinking"] = thinking_response.strip()
_commit_chat_compaction(_actual_candidate_index)
_saved_id = save_assistant_response(
sess,
session_manager,
session,
_terminal_content,
_terminal_metrics,
character_name=ctx.preset.character_name,
incognito=incognito,
)
accumulate_token_usage(session, _terminal_metrics)
_chat_terminal_saved = True
_stream_set(session, status="error")
if _saved_id:
yield f'data: {json.dumps({"type": "message_saved", "id": _saved_id})}\n\n'
yield f'data: {json.dumps({"type": "chat_terminal", "data": _terminal_metrics})}\n\n'
yield chunk
elif chunk.startswith("event: "):
yield chunk
elif chunk == "data: [DONE]\n\n":
if _chat_terminal_saved:
# Some providers append DONE after a terminal
# error. The failed partial is already saved;
# never re-save/post-process it as a success or
# advertise successful completion to the client.
continue
# Generate fallback metrics if LLM didn't send usage
if not last_metrics and full_response:
_elapsed = time.time() - _chat_start
_est_in = estimate_tokens(messages)
_est_out = len(full_response) // 4
_tps = round(_est_out / _elapsed, 2) if _elapsed > 0 else 0
_ctx_pct = min(round((_est_in / ctx.context_length) * 100, 1), 100.0) if ctx.context_length else 0
_actual_context_length = _chat_request_state["context_lengths"].get(
_actual_candidate_index,
_selected_context_length,
)
_actual_request_messages = _chat_request_state["requests"].get(
_actual_candidate_index,
messages,
)
_est_in = estimate_tokens(_actual_request_messages)
_ctx_pct = min(round((_est_in / _actual_context_length) * 100, 1), 100.0) if _actual_context_length else 0
last_metrics = {
"response_time": round(_elapsed, 2),
"input_tokens": _est_in,
@@ -1611,13 +2003,25 @@ def setup_chat_routes(
"tokens_per_second": _tps,
"request_context_tokens": _est_in,
"context_percent": _ctx_pct,
"context_length": ctx.context_length,
"context_length": _actual_context_length,
"model": _actual_model or _answered_by or _requested_model,
"requested_model": _requested_model,
"requested_endpoint_id": _requested_route.get("endpoint_id"),
"requested_endpoint_label": _requested_route.get("endpoint_label"),
"endpoint_id": _actual_route.get("endpoint_id"),
"endpoint_label": _actual_route.get("endpoint_label"),
"usage_source": "estimated",
}
if isinstance(
_actual_route.get("endpoint_cost_tracked"),
bool,
):
last_metrics["endpoint_cost_tracked"] = _actual_route.get(
"endpoint_cost_tracked"
)
yield f'data: {json.dumps({"type": "metrics", "data": last_metrics})}\n\n'
if full_response:
_commit_chat_compaction(_actual_candidate_index)
_metrics_to_save = dict(last_metrics or {})
if thinking_response.strip() and not _metrics_to_save.get("thinking"):
_metrics_to_save["thinking"] = thinking_response.strip()
@@ -1652,6 +2056,10 @@ def setup_chat_routes(
"stopped": True,
"model": _actual_model or _answered_by or _requested_model,
"requested_model": _requested_model,
"endpoint_id": _actual_route.get("endpoint_id"),
"endpoint_label": _actual_route.get("endpoint_label"),
"requested_endpoint_id": _requested_route.get("endpoint_id"),
"requested_endpoint_label": _requested_route.get("endpoint_label"),
},
)
sess.add_message(ChatMessage("assistant", _stopped_content, metadata=_stopped_md))
@@ -1666,6 +2074,12 @@ def setup_chat_routes(
_answered_by = None # set if the selected model failed and a fallback answered
_requested_model = sess.model
_actual_model = None
_agent_requested_route = _foreground_route_descriptors[0]
_agent_actual_endpoint_id = _agent_requested_route.get("endpoint_id")
_agent_actual_endpoint_label = _agent_requested_route.get("endpoint_label")
_agent_round_models = {1: _requested_model}
_agent_round_endpoint_ids = {1: _agent_actual_endpoint_id}
_agent_round_endpoint_labels = {1: _agent_actual_endpoint_label}
try:
from src.settings import get_setting
from src.agent_tools import MAX_AGENT_ROUNDS as _DEFAULT_ROUNDS
@@ -1703,19 +2117,24 @@ def setup_chat_routes(
prompt_type=preset_id,
max_tool_calls=_tool_budget,
max_rounds=_max_rounds,
context_length=ctx.context_length,
context_length=_selected_context_length,
active_document=active_doc,
active_email=active_email_ctx,
session_id=session,
history_session=sess,
disabled_tools=disabled_tools if disabled_tools else None,
tool_policy=tool_policy,
owner=_user,
fallbacks=_foreground_candidates[1:],
route_descriptors=_foreground_route_descriptors,
fallback_statuses=_foreground_policy.eligible_statuses,
fallback_on_empty=_foreground_policy.fallback_on_empty,
plan_mode=plan_mode,
approved_plan=approved_plan or None,
workspace=workspace or None,
forced_tools=_forced_tools,
uploaded_files=ctx.uploaded_files,
defer_context_shaping=_foreground_policy.enabled,
):
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
try:
@@ -1744,7 +2163,20 @@ def setup_chat_routes(
"plan_update",
):
if data.get("type") == "agent_step":
_agent_rounds = max(_agent_rounds, data.get("round", 1))
_event_round = data.get("round", 1)
_agent_rounds = max(_agent_rounds, _event_round)
_agent_round_models.setdefault(
_event_round,
_actual_model or _answered_by or _requested_model,
)
_agent_round_endpoint_ids.setdefault(
_event_round,
_agent_actual_endpoint_id,
)
_agent_round_endpoint_labels.setdefault(
_event_round,
_agent_actual_endpoint_label,
)
elif data.get("type") == "tool_start":
_agent_tool_calls += 1
yield chunk
@@ -1754,13 +2186,70 @@ def setup_chat_routes(
# model so metrics reflect it, not the masked
# selected model.
_answered_by = data.get("answered_by") or _answered_by
_actual_model = _actual_model or _answered_by
_actual_model = _answered_by or _actual_model
if "answered_by_endpoint_id" in data:
_agent_actual_endpoint_id = data.get("answered_by_endpoint_id")
if data.get("answered_by_endpoint_label"):
_agent_actual_endpoint_label = data.get("answered_by_endpoint_label")
_event_round = data.get("round") or max(_agent_rounds, 1)
_agent_round_models[_event_round] = _answered_by or _requested_model
_agent_round_endpoint_ids[_event_round] = _agent_actual_endpoint_id
_agent_round_endpoint_labels[_event_round] = _agent_actual_endpoint_label
data["selected_model"] = data.get("selected_model") or _requested_model
yield chunk
elif data.get("type") == "model_actual":
_actual_model = data.get("model") or _actual_model
if "endpoint_id" in data:
_agent_actual_endpoint_id = data.get("endpoint_id")
if data.get("endpoint_label"):
_agent_actual_endpoint_label = data.get("endpoint_label")
_event_round = data.get("round") or max(_agent_rounds, 1)
_agent_round_models[_event_round] = _actual_model or _requested_model
_agent_round_endpoint_ids[_event_round] = _agent_actual_endpoint_id
_agent_round_endpoint_labels[_event_round] = _agent_actual_endpoint_label
data["requested_model"] = _requested_model
yield f'data: {json.dumps(data)}\n\n'
elif data.get("type") == "agent_terminal":
terminal_metadata = dict(data.get("data") or {})
last_metrics = terminal_metadata
failure = terminal_metadata.get("failure") or {}
failure_status = _normalize_http_status(
failure.get("status")
)
failure_message = (
f"Model request failed (HTTP {failure_status})"
if failure_status is not None
else "Model request failed"
)
terminal_metadata["failure"] = {
"status": failure_status,
"message": failure_message,
}
terminal_content = full_response.strip()
failure_note = f"[Agent stopped: {failure_message}]"
if terminal_content:
terminal_content = f"{terminal_content}\n\n{failure_note}"
else:
terminal_content = failure_note
if not _terminal_saved:
_saved_id = save_assistant_response(
sess,
session_manager,
session,
terminal_content,
terminal_metadata,
character_name=ctx.preset.character_name,
web_sources=web_sources,
rag_sources=ctx.rag_sources,
used_memories=ctx.used_memories,
incognito=incognito,
)
_terminal_saved = True
accumulate_token_usage(session, terminal_metadata)
_stream_set(session, status="error")
if _saved_id:
yield f'data: {json.dumps({"type": "message_saved", "id": _saved_id})}\n\n'
yield chunk
elif data.get("type") == "metrics":
last_metrics = data.get("data", {})
_reported_model = last_metrics.get("model")
@@ -1772,7 +2261,16 @@ def setup_chat_routes(
last_metrics["context_messages_after_trim"] = ctx.context_messages_after_trim
last_metrics["context_tokens_before_trim"] = ctx.context_tokens_before_trim
last_metrics["context_tokens_after_trim"] = ctx.context_tokens_after_trim
yield f'data: {json.dumps({"type": "metrics", "data": last_metrics})}\n\n'
_metrics_event = {"type": "metrics", "data": last_metrics}
# Inline teacher escalation marks its
# recursively emitted events at the SSE
# envelope. Preserve that non-secret marker
# when normalizing metrics so the browser's
# replay-stable ledger keeps primary and
# teacher segments distinct.
if data.get("teacher") is True:
_metrics_event["teacher"] = True
yield f'data: {json.dumps(_metrics_event)}\n\n'
except json.JSONDecodeError:
yield chunk
elif chunk.startswith("event: "):
@@ -1824,6 +2322,22 @@ def setup_chat_routes(
"stopped": True,
"model": _actual_model or _answered_by or _requested_model,
"requested_model": _requested_model,
"endpoint_id": _agent_actual_endpoint_id,
"endpoint_label": _agent_actual_endpoint_label,
"requested_endpoint_id": _agent_requested_route.get("endpoint_id"),
"requested_endpoint_label": _agent_requested_route.get("endpoint_label"),
"round_models": [
_agent_round_models.get(i, _actual_model or _requested_model)
for i in range(1, max(_agent_round_models, default=1) + 1)
],
"round_endpoint_ids": [
_agent_round_endpoint_ids.get(i)
for i in range(1, max(_agent_round_models, default=1) + 1)
],
"round_endpoint_labels": [
_agent_round_endpoint_labels.get(i)
for i in range(1, max(_agent_round_models, default=1) + 1)
],
},
)
sess.add_message(ChatMessage("assistant", _stopped_content2, metadata=_stopped_md2))
@@ -1866,8 +2380,12 @@ def setup_chat_routes(
if compare_mode:
return StreamingResponse(_safe_stream(), media_type="text/event-stream")
agent_runs.start(session, _safe_stream())
return StreamingResponse(agent_runs.subscribe(session), media_type="text/event-stream")
_detached_run = agent_runs.start(session, _safe_stream())
return StreamingResponse(
agent_runs.subscribe(session, _detached_run),
media_type="text/event-stream",
headers={"X-Odysseus-Run-Id": _detached_run.run_id},
)
# ------------------------------------------------------------------ #
# GET /api/chat/resume — reconnect to a detached run that's still going
@@ -1876,9 +2394,14 @@ def setup_chat_routes(
@router.get("/api/chat/resume/{session_id}")
async def chat_resume(request: Request, session_id: str) -> StreamingResponse:
_verify_session_owner(request, session_id)
if not agent_runs.is_active(session_id):
_active_run = agent_runs.get_active_run(session_id)
if _active_run is None:
raise HTTPException(404, "No active run for this session")
return StreamingResponse(agent_runs.subscribe(session_id), media_type="text/event-stream")
return StreamingResponse(
agent_runs.subscribe(session_id, _active_run),
media_type="text/event-stream",
headers={"X-Odysseus-Run-Id": _active_run.run_id},
)
# ------------------------------------------------------------------ #
# POST /api/chat/stop — cancel a detached run (Stop button). Closing the SSE
@@ -1887,7 +2410,8 @@ def setup_chat_routes(
@router.post("/api/chat/stop/{session_id}")
async def chat_stop(request: Request, session_id: str) -> Dict[str, Any]:
_verify_session_owner(request, session_id)
stopped = agent_runs.stop(session_id)
_expected_run_id = request.headers.get("X-Odysseus-Run-Id")
stopped = agent_runs.stop(session_id, _expected_run_id)
return {"stopped": stopped}
# ------------------------------------------------------------------ #
+3 -10
View File
@@ -5004,7 +5004,6 @@ def setup_email_routes():
from src.endpoint_resolver import (
resolve_endpoint,
resolve_utility_fallback_candidates,
resolve_chat_fallback_candidates,
)
from src.llm_core import llm_call_async_with_fallback
@@ -5066,8 +5065,6 @@ def setup_email_routes():
pass
for cand in resolve_utility_fallback_candidates(owner=owner) or []:
_add(*cand)
for cand in resolve_chat_fallback_candidates(owner=owner) or []:
_add(*cand)
if not candidates:
return {"success": False, "error": "No LLM endpoint configured"}
@@ -5327,13 +5324,11 @@ def setup_email_routes():
# Build a candidate chain so a stale session-stored API key
# (the most common cause of "authentication failed" here)
# doesn't kill AI Reply outright — fall through to the
# user's Utility / Default endpoints and the active Utility
# fallback chain. The retired default-fallback hook stays empty.
# Dedupe by url+model so we don't retry the same broken endpoint.
# user's Utility / Default endpoints and active Utility fallback
# chain. Dedupe by url+model so we don't retry the same endpoint.
from src.llm_core import llm_call_async_with_fallback
from src.endpoint_resolver import (
resolve_utility_fallback_candidates,
resolve_chat_fallback_candidates,
)
_seen = set()
_candidates = []
@@ -5358,11 +5353,9 @@ def setup_email_routes():
_add(_d_url, _d_model, _d_headers)
except Exception:
pass
# Active Utility fallbacks, then the retired default hook.
# Active Utility fallbacks last.
for cand in resolve_utility_fallback_candidates(owner=owner) or []:
_add(*cand)
for cand in resolve_chat_fallback_candidates(owner=owner) or []:
_add(*cand)
_messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_msg},
+7 -1
View File
@@ -46,6 +46,7 @@ _ENDPOINT_SETTING_FIELDS = {
}
_ENDPOINT_FALLBACK_FIELDS = {
"foreground_model_fallbacks": "Foreground Model Fallbacks",
"utility_model_fallbacks": "Utility Model Fallbacks",
"vision_model_fallbacks": "Vision Model Fallbacks",
}
@@ -180,7 +181,12 @@ def _clear_user_pref_endpoint_refs(all_prefs: dict, ep_id: str) -> int:
if not isinstance(all_prefs, dict):
return 0
users = all_prefs.get("_users")
pref_sets = users.values() if isinstance(users, dict) else [all_prefs]
# A mixed store can contain auth-disabled foreground policy at the root
# alongside named-owner preferences. Both are active namespaces; legacy
# `default_model_fallbacks` remains untouched by the field allowlist.
pref_sets = [all_prefs]
if isinstance(users, dict):
pref_sets.extend(users.values())
cleared_users = 0
for prefs in pref_sets:
if isinstance(prefs, dict) and _clear_endpoint_settings_for_endpoint(prefs, ep_id):
+163 -92
View File
@@ -1,11 +1,13 @@
# routes/personal_routes.py
"""Routes for personal documents management."""
import asyncio
import os
import logging
import shutil
import uuid
from typing import Any, Dict, List, Tuple
from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File, Depends
from fastapi.concurrency import run_in_threadpool
from src.request_models import DirectoryRequest
from core.constants import BASE_DIR, PERSONAL_DIR, PERSONAL_UPLOADS_DIR
from src.rag_singleton import get_rag_manager
@@ -18,7 +20,6 @@ UPLOADS_DIR = PERSONAL_UPLOADS_DIR
logger = logging.getLogger(__name__)
def _personal_upload_dir_for_owner(owner: str | None, *, create: bool = True) -> str:
"""Return the per-owner upload directory used for direct RAG uploads."""
owner_segment = secure_filename((owner or "local").strip())[:80] or "local"
@@ -141,6 +142,22 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
"""
router = APIRouter(prefix="/api/personal")
# Serializes directory index jobs across requests. Indexing runs in the
# threadpool (#5558), so concurrent requests would otherwise run in parallel
# and race PersonalDocsManager's unsynchronized list mutations and file
# writes; before the threadpool move they serialized on the blocked event
# loop, so one-at-a-time is behavior parity.
#
# An asyncio.Lock acquired in the async handler BEFORE offloading: a waiting
# request parks on the event loop instead of pinning a threadpool worker (an
# earlier threading.Lock taken INSIDE the worker meant queued jobs held pool
# tokens while blocked, starving every other run_in_threadpool caller).
# add/remove/reload all take this lock, so their mutations never interleave.
# Per-router (not module-global) so each app binds it to its own event loop.
# Scope is the single process: multi-worker deployments would need a shared
# lock (out of scope for #5558).
_index_job_lock = asyncio.Lock()
def _rag():
"""Get the current RAG manager, retrying init if needed."""
return get_rag_manager()
@@ -172,8 +189,12 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
return {"files": files, "directories": directories}
@router.post("/reload")
def api_personal_reload(owner: str = Depends(require_user), _admin: None = Depends(require_admin)):
personal_docs_manager.refresh_index()
async def api_personal_reload(owner: str = Depends(require_user), _admin: None = Depends(require_admin)):
# refresh_index() re-extracts text across every tracked directory —
# blocking work. Take the shared job lock (so it cannot race an add /
# remove) and run it off the event loop.
async with _index_job_lock:
await run_in_threadpool(personal_docs_manager.refresh_index)
return {"ok": True, "count": len(personal_docs_manager.index)}
@router.post("/add_directory")
@@ -207,12 +228,26 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
# Use the RAGManager to index the directory
rag = _rag()
if rag:
result = rag.index_personal_documents(directory, owner=owner)
def _index_directory():
result = rag.index_personal_documents(directory, owner=owner)
if result["success"]:
# Also update the personal_docs_manager to track this
# directory. Kept inside the offloaded call: it triggers
# refresh_index(), which re-extracts text across tracked
# directories.
personal_docs_manager.add_directory(directory, index=False)
return result
# Indexing walks, embeds, and stores the whole tree — minutes
# on a real directory. The handler is async, so calling it
# inline runs it on the event loop and every other request
# queues behind it until it finishes (#5558). Serialize on the
# async job lock BEFORE offloading so a queued request parks on
# the loop instead of pinning a threadpool worker.
async with _index_job_lock:
result = await run_in_threadpool(_index_directory)
if result["success"]:
# Also update the personal_docs_manager to track this directory
personal_docs_manager.add_directory(directory, index=False)
return {
"success": True,
"message": f"Successfully indexed {result['indexed_count']} chunks from {directory}",
@@ -251,17 +286,25 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
logger.info(f"Removing directory from RAG: {directory}")
# Always remove from personal_docs_manager tracking
if hasattr(personal_docs_manager, 'remove_directory'):
personal_docs_manager.remove_directory(directory)
# Remove from RAG vector store (best-effort)
rag = _rag()
if rag:
try:
rag.remove_directory(directory)
except Exception as e:
logger.warning(f"RAG removal failed for directory {directory}: {e}")
def _remove_directory():
# Always remove from personal_docs_manager tracking. This
# mutates the same unsynchronized list/index an add job touches
# and re-extracts text (refresh_index), so it is blocking work.
if hasattr(personal_docs_manager, 'remove_directory'):
personal_docs_manager.remove_directory(directory)
# Remove from RAG vector store (best-effort).
if rag:
try:
rag.remove_directory(directory)
except Exception as e:
logger.warning(f"RAG removal failed for directory {directory}: {e}")
# Same job lock as add/reload so remove cannot interleave with an
# in-flight add; offloaded off the event loop.
async with _index_job_lock:
await run_in_threadpool(_remove_directory)
return {
"success": True,
@@ -289,54 +332,73 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
total_failed = 0
uploaded_files = []
for upload in files:
try:
file_path, stored_name, safe_name = _unique_personal_upload_path(upload_dir, upload.filename)
content_bytes = await upload.read(PERSONAL_UPLOAD_MAX_BYTES + 1)
if len(content_bytes) > PERSONAL_UPLOAD_MAX_BYTES:
logger.warning(f"Rejected oversized personal upload: {upload.filename!r}")
total_failed += 1
continue
with open(file_path, "wb") as f:
f.write(content_bytes)
ext = os.path.splitext(safe_name)[1].lower()
if ext == ".pdf":
from src.personal_docs import extract_pdf_text
text = extract_pdf_text(file_path)
else:
text = content_bytes.decode("utf-8", errors="replace")
if not text or not text.strip():
total_failed += 1
continue
# Chunk and index
chunks = rag._split_into_chunks(text, chunk_size=500)
for i, chunk in enumerate(chunks):
metadata = {
"source": file_path,
"filename": safe_name,
"stored_filename": stored_name,
"directory": upload_dir,
"type": ext,
"chunk_id": i,
}
if user:
metadata["owner"] = user
if rag.add_document(chunk, metadata):
total_indexed += 1
else:
# Chunking, embedding and the tracking update are blocking work over the
# same vector/tracking state add_directory mutates (#5634). Take the
# shared job lock BEFORE offloading so a queued request parks on the loop
# instead of pinning a threadpool worker, matching add_directory.
# Read and process one capped payload at a time so a multi-file request
# cannot retain len(files) * PERSONAL_UPLOAD_MAX_BYTES in memory.
async with _index_job_lock:
for upload in files:
try:
file_path, stored_name, safe_name = _unique_personal_upload_path(
upload_dir, upload.filename
)
content_bytes = await upload.read(PERSONAL_UPLOAD_MAX_BYTES + 1)
if len(content_bytes) > PERSONAL_UPLOAD_MAX_BYTES:
logger.warning(f"Rejected oversized personal upload: {upload.filename!r}")
total_failed += 1
continue
uploaded_files.append(safe_name)
except Exception as e:
logger.error(f"Failed to upload/index {upload.filename}: {e}")
total_failed += 1
def _index_upload():
with open(file_path, "wb") as f:
f.write(content_bytes)
# Track uploads directory
if uploaded_files and hasattr(personal_docs_manager, "add_directory"):
personal_docs_manager.add_directory(upload_dir, index=False)
ext = os.path.splitext(safe_name)[1].lower()
if ext == ".pdf":
from src.personal_docs import extract_pdf_text
text = extract_pdf_text(file_path)
else:
text = content_bytes.decode("utf-8", errors="replace")
if not text or not text.strip():
return 0, 1, None
indexed = 0
failed = 0
chunks = rag._split_into_chunks(text, chunk_size=500)
for i, chunk in enumerate(chunks):
metadata = {
"source": file_path,
"filename": safe_name,
"stored_filename": stored_name,
"directory": upload_dir,
"type": ext,
"chunk_id": i,
}
if user:
metadata["owner"] = user
if rag.add_document(chunk, metadata):
indexed += 1
else:
failed += 1
return indexed, failed, safe_name
indexed, failed, uploaded_name = await run_in_threadpool(_index_upload)
total_indexed += indexed
total_failed += failed
if uploaded_name:
uploaded_files.append(uploaded_name)
except Exception as e:
logger.error(f"Failed to upload/index {upload.filename}: {e}")
total_failed += 1
# Same transition, same lock: the tracking update must not land
# while another job is mid-write over the same state.
if uploaded_files and hasattr(personal_docs_manager, "add_directory"):
await run_in_threadpool(
personal_docs_manager.add_directory, upload_dir, index=False
)
return {
"success": True,
@@ -349,38 +411,47 @@ def setup_personal_routes(personal_docs_manager, rag_manager, rag_available):
async def delete_file_from_rag(filepath: str = Query(...), owner: str = Depends(require_user), _admin: None = Depends(require_admin)):
"""Delete a specific file from RAG index and optionally from disk."""
try:
# Remove chunks from RAG vector store (best-effort)
removed = 0
rag = _rag()
if rag:
try:
removed = rag.delete_by_source(filepath)
except Exception as e:
logger.warning(f"RAG removal failed for {filepath}: {e}")
def _delete_file():
# Remove chunks from RAG vector store (best-effort)
removed = 0
rag = _rag()
if rag:
try:
removed = rag.delete_by_source(filepath)
except Exception as e:
logger.warning(f"RAG removal failed for {filepath}: {e}")
# Delete file from disk if it's in the caller's own uploads dir.
# Scope to the per-owner subdir, not the shared uploads root, so one
# admin can't delete another user's personal files by path.
deleted_from_disk = False
try:
abs_target = os.path.realpath(filepath)
base_abs = os.path.realpath(_personal_upload_dir_for_owner(owner, create=False))
in_uploads = (
abs_target == base_abs
or os.path.commonpath([abs_target, base_abs]) == base_abs
)
except ValueError:
# commonpath raises on mixed drives / non-comparable paths
in_uploads = False
if in_uploads and abs_target != base_abs:
# Delete file from disk if it's in the caller's own uploads dir.
# Scope to the per-owner subdir, not the shared uploads root, so one
# admin can't delete another user's personal files by path.
deleted_from_disk = False
try:
os.remove(abs_target)
deleted_from_disk = True
except FileNotFoundError:
pass # already gone — race with another request or cleanup
abs_target = os.path.realpath(filepath)
base_abs = os.path.realpath(_personal_upload_dir_for_owner(owner, create=False))
in_uploads = (
abs_target == base_abs
or os.path.commonpath([abs_target, base_abs]) == base_abs
)
except ValueError:
# commonpath raises on mixed drives / non-comparable paths
in_uploads = False
if in_uploads and abs_target != base_abs:
try:
os.remove(abs_target)
deleted_from_disk = True
except FileNotFoundError:
pass # already gone — race with another request or cleanup
# Exclude the file from the listing (persists across restarts)
personal_docs_manager.exclude_file(filepath)
# Exclude the file from the listing (persists across restarts)
personal_docs_manager.exclude_file(filepath)
return removed, deleted_from_disk
# Vector removal, the disk unlink and the exclusion write are one
# transition over the same state add_directory mutates (#5634), and
# all three block. Take the shared job lock BEFORE offloading, as
# add_directory does.
async with _index_job_lock:
removed, deleted_from_disk = await run_in_threadpool(_delete_file)
return {
"success": True,
+51 -11
View File
@@ -7,6 +7,10 @@ from src.auth_helpers import get_current_user
from src.constants import USER_PREFS_FILE
PREFS_FILE = USER_PREFS_FILE
_FOREGROUND_POLICY_KEYS = (
"foreground_fallback_enabled",
"foreground_model_fallbacks",
)
def _load():
@@ -26,14 +30,27 @@ def _save(prefs):
def _load_for_user(user: Optional[str] = None) -> dict:
"""Load preferences for a specific user."""
all_prefs = _load()
if "_users" in all_prefs:
users = all_prefs.get("_users")
if isinstance(users, dict):
if user is None:
# Auth disabled — return first user's prefs for backward compat
users = all_prefs["_users"]
return dict(next(iter(users.values()), {}))
return dict(all_prefs["_users"].get(user, {}))
# Legacy flat format — return as-is
return dict(all_prefs)
prefs = dict(next(iter(users.values()), {}))
# Foreground fallback consent is never borrowed from a named
# owner. Auth-disabled operation has a separate flat/root opt-in
# that remains inert when authentication is enabled again.
for key in _FOREGROUND_POLICY_KEYS:
prefs.pop(key, None)
if key in all_prefs:
prefs[key] = all_prefs[key]
return prefs
prefs = users.get(user, {})
return dict(prefs) if isinstance(prefs, dict) else {}
# A legacy flat store belongs only to auth-disabled single-user mode.
# Copying it into the first named user's new `_users` record during an
# auth transition would silently transfer another user's preferences and,
# critically, foreground fallback consent. Named owners therefore start
# with an empty record and must write their own preferences explicitly.
return dict(all_prefs) if user is None else {}
def _save_for_user(user: Optional[str], prefs: dict):
@@ -45,17 +62,40 @@ def _save_for_user(user: Optional[str], prefs: dict):
# `prefs` flat would overwrite the whole `_users` map and destroy every
# other user's preferences. Instead write back into the same (first)
# slot _load_for_user(None) reads from, preserving the others.
if "_users" in all_prefs:
users = all_prefs["_users"]
users = all_prefs.get("_users")
if isinstance(users, dict):
first_key = next(iter(users), None)
if first_key is not None:
users[first_key] = prefs
existing_named = users.get(first_key)
existing_named = (
dict(existing_named)
if isinstance(existing_named, dict)
else {}
)
named_foreground = {
key: existing_named[key]
for key in _FOREGROUND_POLICY_KEYS
if key in existing_named
}
users[first_key] = {
key: value
for key, value in prefs.items()
if key not in _FOREGROUND_POLICY_KEYS
}
users[first_key].update(named_foreground)
for key in _FOREGROUND_POLICY_KEYS:
if key in prefs:
all_prefs[key] = prefs[key]
_save(all_prefs)
return
_save(prefs)
return
if "_users" not in all_prefs:
all_prefs = {"_users": {}}
if not isinstance(all_prefs.get("_users"), dict):
# Preserve the flat single-user object as inert legacy data while
# creating the first named-owner namespace. In particular, historical
# fallback values must not be deleted or copied into the new owner.
all_prefs = dict(all_prefs)
all_prefs["_users"] = {}
all_prefs["_users"][user] = prefs
_save(all_prefs)
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""Make retained SearXNG settings inherit defaults without replacing them."""
from __future__ import annotations
import os
import stat
import sys
import tempfile
from pathlib import Path
import yaml
from yaml.nodes import MappingNode
from yaml.tokens import BlockMappingStartToken, FlowMappingStartToken
_UTF8_BOM = b"\xef\xbb\xbf"
def _parse_root_mapping(text: str) -> tuple[MappingNode | None, dict]:
"""Parse settings with the same safe YAML semantics SearXNG uses."""
try:
loaded = yaml.safe_load(text)
node = yaml.compose(text, Loader=yaml.SafeLoader)
except yaml.YAMLError:
raise ValueError("settings file is not valid single-document YAML") from None
if loaded is None and node is None:
return None, {}
if not isinstance(loaded, dict) or not isinstance(node, MappingNode):
raise ValueError("settings root is not a mapping")
return node, loaded
def _flow_mapping_start(text: str) -> int:
"""Return the root flow mapping's opening-brace character offset."""
try:
for token in yaml.scan(text, Loader=yaml.SafeLoader):
if isinstance(token, FlowMappingStartToken):
return token.start_mark.index
except yaml.YAMLError:
pass
raise ValueError("flow-style settings mapping has no opening brace")
def _newline_for(contents: bytes) -> bytes:
first_lf = contents.find(b"\n")
if first_lf > 0 and contents[first_lf - 1 : first_lf + 1] == b"\r\n":
return b"\r\n"
return b"\n"
def _block_mapping_position(text: str, root: MappingNode | None) -> tuple[int, int]:
"""Return a safe character offset and indent for a root block mapping key."""
if root is None:
return len(text), 0
try:
for token in yaml.scan(text, Loader=yaml.SafeLoader):
if not isinstance(token, BlockMappingStartToken):
continue
line_start = token.start_mark.index - token.start_mark.column
if not text[line_start : token.start_mark.index].strip():
return line_start, token.start_mark.column
return root.end_mark.index, token.start_mark.column
except yaml.YAMLError:
pass
return root.end_mark.index, root.start_mark.column
def _add_block_default_inheritance(
contents: bytes, text: str, root: MappingNode | None
) -> bytes:
newline = _newline_for(contents)
character_offset, indent_width = _block_mapping_position(text, root)
bom_length = len(_UTF8_BOM) if contents.startswith(_UTF8_BOM) else 0
offset = bom_length + len(text[:character_offset].encode("utf-8"))
separator = b""
if offset not in (0, bom_length) and not contents[:offset].endswith((b"\n", b"\r")):
separator = newline
addition = (
separator
+ b" " * indent_width
+ b"use_default_settings: true"
+ newline
)
return contents[:offset] + addition + contents[offset:]
def migrate_settings(path: Path) -> bool:
"""Add the missing inheritance key atomically; return whether the file changed."""
source_stat = path.lstat()
if not stat.S_ISREG(source_stat.st_mode):
raise ValueError(f"settings path is not a regular file: {path}")
contents = path.read_bytes()
if not contents:
return False
text = contents.decode("utf-8-sig")
root, loaded = _parse_root_mapping(text)
if "use_default_settings" in loaded:
return False
if root is not None and root.flow_style:
start = _flow_mapping_start(text)
bom_length = len(_UTF8_BOM) if contents.startswith(_UTF8_BOM) else 0
offset = bom_length + len(text[: start + 1].encode("utf-8"))
separator = b", " if root.value else b""
updated = (
contents[:offset]
+ b"use_default_settings: true"
+ separator
+ contents[offset:]
)
else:
updated = _add_block_default_inheritance(contents, text, root)
fd, temporary_name = tempfile.mkstemp(
prefix=f".{path.name}.odysseus-", dir=path.parent
)
temporary = Path(temporary_name)
try:
# chmod before chown: the Compose cap set is `cap_drop: ALL` plus
# CHOWN/SETGID/SETUID/DAC_OVERRIDE, with no FOWNER. Once the temporary
# file belongs to searxng:searxng — which every retained settings file
# does, because searxng's entrypoint chowns /etc/searxng — root can no
# longer chmod it and the migration dies with EPERM.
os.fchmod(fd, stat.S_IMODE(source_stat.st_mode))
os.fchown(fd, source_stat.st_uid, source_stat.st_gid)
with os.fdopen(fd, "wb") as handle:
fd = -1
handle.write(updated)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
directory_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
finally:
if fd >= 0:
os.close(fd)
temporary.unlink(missing_ok=True)
return True
def main(argv: list[str]) -> int:
if len(argv) > 2:
print(f"usage: {Path(argv[0]).name} [settings.yml]", file=sys.stderr)
return 2
path = Path(argv[1]) if len(argv) == 2 else Path("/etc/searxng/settings.yml")
try:
changed = migrate_settings(path)
except (OSError, UnicodeError, ValueError) as exc:
print(f"SearXNG settings migration failed: {exc}", file=sys.stderr)
return 1
if changed:
print("Added use_default_settings inheritance to retained SearXNG settings")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
+213 -43
View File
@@ -1,16 +1,18 @@
"""Import SKILL.md bundles from public GitHub (or skills.sh → GitHub) URLs."""
from __future__ import annotations
import ipaddress
import logging
import os
import re
import time
from dataclasses import dataclass
from typing import Dict, List, Optional, Tuple
from typing import Dict, Iterable, List, Optional, Tuple, cast
from urllib.parse import quote, urljoin, urlparse
import httpcore
import httpx
from src.url_safety import check_outbound_url
from src.url_safety import _default_resolver, check_outbound_url
logger = logging.getLogger(__name__)
@@ -25,6 +27,7 @@ TEXT_NAMES = {"skill.md", "license", "license.md", "readme.md"}
_GITHUB_HOSTS = frozenset({
"github.com", "www.github.com", "api.github.com", "raw.githubusercontent.com",
})
_SKILLS_SH_HOSTS = frozenset({"skills.sh", "www.skills.sh"})
def _github_host(url: str) -> str:
@@ -72,18 +75,158 @@ def _is_text_file(name: str) -> bool:
_MAX_FETCH_REDIRECTS = 5
def _check_fetch_url(url: str) -> None:
"""SSRF guard for skill-import fetches (defense-in-depth).
def _validated_ips(raw_ips: List[str]) -> List[ipaddress._BaseAddress]:
"""Parse and de-duplicate one resolver snapshot in resolver order."""
ips: List[ipaddress._BaseAddress] = []
seen = set()
for raw in raw_ips:
if not isinstance(raw, str):
continue
try:
ip = ipaddress.ip_address(raw.split("%", 1)[0])
except ValueError:
continue
if ip in seen:
continue
seen.add(ip)
ips.append(ip)
return ips
Skill bundles only ever come from public GitHub, never an internal
address, so block private/loopback/link-local targets on every hop
matching the hardened web-fetch path in
``services/search/content.py:_get_public_url`` rather than the lenient
default used for admin-configured model endpoints.
"""
ok, reason = check_outbound_url(url, block_private=True)
def _resolve_and_check_url(url: str) -> List[ipaddress._BaseAddress]:
"""Return the exact address snapshot approved for one fetch hop."""
resolved_ips: List[str] = []
def _recording_resolver(host: str) -> List[str]:
answers = list(_default_resolver(host))
resolved_ips[:] = answers
return answers
ok, reason = check_outbound_url(
url,
block_private=True,
resolver=_recording_resolver,
)
if not ok:
raise SkillImportError(reason)
raise SkillImportError(f"outbound URL blocked: {reason}")
pinned_ips = _validated_ips(resolved_ips)
if not pinned_ips:
raise SkillImportError("outbound URL blocked: host did not resolve to a usable address")
return pinned_ips
# Backward compatibility alias for tests importing _check_fetch_url directly
_check_fetch_url = _resolve_and_check_url
class _PinnedBackend(httpcore.NetworkBackend):
"""Connect only to addresses from one validated DNS snapshot."""
def __init__(self, ips: List[ipaddress._BaseAddress]):
self._ips = [str(ip) for ip in ips]
self._real = httpcore.SyncBackend()
def connect_tcp(
self,
host: str,
port: int,
timeout: float | None = None,
local_address: str | None = None,
socket_options=None,
):
deadline = None if timeout is None else time.monotonic() + timeout
last_exc: Optional[Exception] = None
for ip in self._ips:
remaining = None if deadline is None else max(0.0, deadline - time.monotonic())
try:
return self._real.connect_tcp(
ip,
port,
remaining,
local_address,
socket_options,
)
except (httpcore.ConnectError, httpcore.ConnectTimeout) as exc:
last_exc = exc
if deadline is not None and time.monotonic() >= deadline:
break
if last_exc is not None:
raise last_exc
raise httpcore.ConnectError("no validated address available")
def connect_unix_socket(self, path, timeout=None, socket_options=None):
return self._real.connect_unix_socket(path, timeout, socket_options)
def sleep(self, seconds: float) -> None:
return self._real.sleep(seconds)
_HTTPCORE_TO_HTTPX_EXC = {
httpcore.ConnectError: httpx.ConnectError,
httpcore.ConnectTimeout: httpx.ConnectTimeout,
httpcore.LocalProtocolError: httpx.LocalProtocolError,
httpcore.NetworkError: httpx.NetworkError,
httpcore.PoolTimeout: httpx.PoolTimeout,
httpcore.ProtocolError: httpx.ProtocolError,
httpcore.ProxyError: httpx.ProxyError,
httpcore.ReadError: httpx.ReadError,
httpcore.ReadTimeout: httpx.ReadTimeout,
httpcore.RemoteProtocolError: httpx.RemoteProtocolError,
httpcore.TimeoutException: httpx.TimeoutException,
httpcore.UnsupportedProtocol: httpx.UnsupportedProtocol,
httpcore.WriteError: httpx.WriteError,
httpcore.WriteTimeout: httpx.WriteTimeout,
}
class _PinnedTransport(httpx.BaseTransport):
"""Pin socket connects while preserving URL authority, Host, and TLS SNI."""
def __init__(self, ips: List[ipaddress._BaseAddress]):
self._pinned_ips = list(ips)
self._pool = httpcore.ConnectionPool(
ssl_context=httpx.create_ssl_context(),
http1=True,
http2=False,
network_backend=_PinnedBackend(ips),
)
def handle_request(self, request: httpx.Request) -> httpx.Response:
core_request = httpcore.Request(
method=request.method,
url=httpcore.URL(
scheme=request.url.raw_scheme,
host=request.url.raw_host,
port=request.url.port,
target=request.url.raw_path,
),
headers=request.headers.raw,
content=request.stream,
extensions=request.extensions,
)
core_response = None
try:
core_response = self._pool.handle_request(core_request)
content = b"".join(cast(Iterable[bytes], core_response.stream))
except Exception as exc:
mapped = _HTTPCORE_TO_HTTPX_EXC.get(type(exc))
if mapped is not None:
raise mapped(str(exc)) from exc
raise
finally:
if core_response is not None:
core_response.close()
return httpx.Response(
status_code=core_response.status,
headers=core_response.headers,
content=content,
extensions=core_response.extensions,
)
def close(self) -> None:
self._pool.close()
def _get_checked(
@@ -100,49 +243,76 @@ def _get_checked(
hand lets us re-validate every hop, closing that blind-SSRF gap.
"""
current = url
with httpx.Client(follow_redirects=False, timeout=timeout) as client:
for _ in range(_MAX_FETCH_REDIRECTS + 1):
_check_fetch_url(current)
for _ in range(_MAX_FETCH_REDIRECTS + 1):
pinned_ips = _resolve_and_check_url(current)
with httpx.Client(
transport=_PinnedTransport(pinned_ips),
follow_redirects=False,
timeout=timeout,
) as client:
r = client.get(current, headers=headers)
if r.status_code in (301, 302, 303, 307, 308):
location = r.headers.get("location")
if not location:
return r
current = urljoin(str(r.url), location)
continue
return r
if r.status_code in (301, 302, 303, 307, 308):
location = r.headers.get("location")
if not location:
return r
current = urljoin(str(r.url), location)
continue
return r
raise SkillImportError("too many redirects while fetching skill bundle")
def parse_skill_source(url: str) -> ResolvedSource:
"""Normalize skills.sh / GitHub web URLs into owner/repo/ref/path."""
raw = (url or "").strip()
if not raw:
url = (url or "").strip()
if not url:
raise SkillImportError("URL is required")
# skills.sh often links to GitHub; try to unwrap ?url= or redirect target later.
if "skills.sh" in raw and "github.com" not in raw:
r = _get_checked(raw, timeout=20.0)
# ``urlparse`` only reports an unambiguous scheme when the URL carries the
# ``scheme://`` form. Opaque schemes (``mailto:``, ``javascript:``) and a
# schemeless ``host:port`` both parse a "scheme" that is not one, so they
# fall through to the host check below and are rejected on the host instead.
scheme = urlparse(url).scheme.lower()
if scheme not in ("http", "https"):
if scheme and url.lower().startswith(f"{scheme}://"):
raise SkillImportError(f"unsupported URL scheme: {scheme}")
# Schemeless "github.com/owner/repo" — accept only a supported host.
rough_host = (urlparse("//" + url).hostname or "").lower()
if rough_host not in _GITHUB_HOSTS and rough_host not in _SKILLS_SH_HOSTS:
raise SkillImportError("Only GitHub or skills.sh URLs are supported")
url = "https://" + url
parsed = urlparse(url)
hostname = (parsed.hostname or "").lower()
if hostname not in _GITHUB_HOSTS and hostname not in _SKILLS_SH_HOSTS:
raise SkillImportError("Only GitHub or skills.sh URLs are supported")
# A skills.sh link is only usable if it redirects to an exact supported
# GitHub host. Scraping the page body for a github.com link cannot work:
# skill pages only ever link the repository root, never the skill's
# subdirectory, so the scrape resolves every skill in a repo to the same
# (wrong) bundle. Fail with an actionable message instead.
if hostname in _SKILLS_SH_HOSTS:
r = _get_checked(url, timeout=20.0)
if r.status_code >= 400:
raise _github_response_error(r)
final = str(r.url)
_assert_github_url(final, context="redirect target")
# Page may embed a github link; prefer final URL if redirected.
if "github.com" in final:
raw = final
else:
m = re.search(r"https?://github\.com/[^\s\"')]+", r.text or "")
if m:
raw = m.group(0).rstrip(".,)")
if _github_host(final) not in _GITHUB_HOSTS:
raise SkillImportError(
"skills.sh did not redirect to GitHub — open the skill's "
"repository on GitHub, navigate to the exact skill folder or "
"SKILL.md file, and paste that URL; the repository-root link "
"alone is not sufficient"
)
url = final
parsed = urlparse(raw)
host = _github_host(raw)
if host not in _GITHUB_HOSTS:
raise SkillImportError(
"Only GitHub URLs are supported (https://github.com/... or raw.githubusercontent.com/...)"
)
# Update parsed and hostname to reflect the new GitHub URL
parsed = urlparse(url)
hostname = (parsed.hostname or "").lower()
if host == "raw.githubusercontent.com":
_assert_github_url(url)
if hostname == "raw.githubusercontent.com":
# /owner/repo/ref/path/to/file
bits = [p for p in parsed.path.split("/") if p]
if len(bits) < 4:
+1090 -291
View File
File diff suppressed because it is too large Load Diff
+84 -26
View File
@@ -17,13 +17,14 @@ close / navigation / refresh). It does NOT survive a server restart.
import asyncio
import json
import logging
import uuid
from typing import AsyncGenerator, Dict, Optional
logger = logging.getLogger(__name__)
class _Run:
__slots__ = ("buffer", "subscribers", "status", "task", "evict_task")
__slots__ = ("buffer", "subscribers", "status", "task", "evict_task", "run_id")
def __init__(self) -> None:
self.buffer: list = [] # ordered SSE event strings (replay log)
@@ -31,6 +32,9 @@ class _Run:
self.status: str = "running" # running | done | error | stopped
self.task: Optional[asyncio.Task] = None
self.evict_task: Optional[asyncio.Task] = None
# Stable across every subscription/replay of this exact detached run.
# The browser uses it to make local cost accounting replay-idempotent.
self.run_id: str = uuid.uuid4().hex
_RUNS: Dict[str, _Run] = {}
@@ -53,13 +57,24 @@ def _publish(run: _Run, ev: str) -> None:
pass
def _schedule_evict(session_id: str) -> None:
def _wake_run_subscribers(run: _Run) -> None:
"""Close subscribers even when the drain task never reached its body."""
for q in list(run.subscribers):
try:
q.put_nowait((None, None))
except Exception:
pass
def _schedule_evict(session_id: str, expected_run: Optional[_Run] = None) -> None:
"""(Re)arm a grace-period eviction for a terminal run with no subscribers.
Identity-checked so a run that gets replaced/reused is never evicted by a
stale timer."""
run = _RUNS.get(session_id)
if run is None:
return
if expected_run is not None and run is not expected_run:
return
if run.evict_task and not run.evict_task.done():
run.evict_task.cancel()
@@ -85,25 +100,38 @@ def get_status(session_id: str) -> Optional[str]:
return r.status if r else None
async def _drain(session_id: str, agen: AsyncGenerator[str, None],
def get_run_id(session_id: str) -> Optional[str]:
"""Return the opaque identity of the current detached run, if present."""
r = _RUNS.get(session_id)
return r.run_id if r else None
def get_active_run(session_id: str) -> Optional[_Run]:
"""Return the exact active run currently registered for a session."""
r = _RUNS.get(session_id)
return r if r and r.status == "running" else None
async def _drain(session_id: str, run: _Run, agen: AsyncGenerator[str, None],
prev_task: Optional[asyncio.Task] = None) -> None:
"""Pull every event from the wrapped generator into the run buffer, fanning
each out to live subscribers. Runs to completion regardless of subscribers."""
run = _RUNS.get(session_id)
if run is None:
return
subscribers_woken = False
def _wake_subscribers() -> None:
nonlocal subscribers_woken
if subscribers_woken:
return
subscribers_woken = True
_wake_run_subscribers(run)
# If this run replaced an in-flight one (rapid double-send), wait for that
# one to fully finish first. Its CancelledError handler calls aclose(), which
# persists its partial response — letting it complete before we start writing
# keeps the two runs' session saves sequential instead of interleaved.
if prev_task is not None and not prev_task.done():
try:
await asyncio.wait({prev_task})
except asyncio.CancelledError:
raise # our own cancellation — propagate
except Exception:
pass
try:
if prev_task is not None and not prev_task.done():
await asyncio.wait({prev_task})
async for ev in agen:
_publish(run, ev)
if run.status == "running":
@@ -116,6 +144,16 @@ async def _drain(session_id: str, agen: AsyncGenerator[str, None],
await agen.aclose()
except Exception:
pass
# A rapid third replacement can cancel this task while it is still
# waiting for its predecessor. Close this run's subscribers promptly,
# but keep the task alive until the predecessor finishes so the next
# run still observes the transitive session-save ordering barrier.
_wake_subscribers()
if prev_task is not None and not prev_task.done():
try:
await asyncio.shield(prev_task)
except (asyncio.CancelledError, Exception):
pass
except Exception as e:
logger.error("[agent-run] %s failed: %s", session_id, e, exc_info=True)
run.status = "error"
@@ -127,15 +165,11 @@ async def _drain(session_id: str, agen: AsyncGenerator[str, None],
_publish(run, "data: [DONE]\n\n")
finally:
# Wake every subscriber with the end sentinel so their SSE closes.
for q in list(run.subscribers):
try:
q.put_nowait((None, None))
except Exception:
pass
_wake_subscribers()
# Run is terminal — arm the grace timer so it (and its buffer) is
# eventually freed even if nobody ever reconnects. subscribe() cancels
# this on connect and re-arms on disconnect.
_schedule_evict(session_id)
_schedule_evict(session_id, run)
def start(session_id: str, agen: AsyncGenerator[str, None]) -> _Run:
@@ -145,20 +179,37 @@ def start(session_id: str, agen: AsyncGenerator[str, None]) -> _Run:
prev_task: Optional[asyncio.Task] = None
if prev:
if prev.task and not prev.task.done():
# A task cancelled before its first instruction never enters
# _drain(), so its except/finally blocks cannot update status or
# wake a response already bound to this exact run. Terminalize it
# synchronously before cancelling; _drain's cleanup is idempotent
# when the task had already started.
if prev.status == "running":
prev.status = "stopped"
_wake_run_subscribers(prev)
prev.task.cancel()
prev_task = prev.task # new run awaits this before it starts writing
if prev.evict_task and not prev.evict_task.done():
prev.evict_task.cancel()
run = _Run()
_RUNS[session_id] = run
run.task = asyncio.create_task(_drain(session_id, agen, prev_task))
run.task = asyncio.create_task(_drain(session_id, run, agen, prev_task))
return run
async def subscribe(session_id: str) -> AsyncGenerator[str, None]:
async def subscribe(
session_id: str,
expected_run: Optional[_Run] = None,
) -> AsyncGenerator[str, None]:
"""Replay the run's buffer from the start, then stream live until it ends.
Safe to call repeatedly (reconnect) and from multiple clients at once."""
run = _RUNS.get(session_id)
Safe to call repeatedly (reconnect) and from multiple clients at once.
``expected_run`` binds a lazy StreamingResponse body to the same run whose
identity was put in its response headers. Without that binding, a rapid
replacement between response construction and body iteration could replay
the replacement run under the prior run's identity.
"""
run = expected_run or _RUNS.get(session_id)
if run is None:
return
q: asyncio.Queue = asyncio.Queue()
@@ -201,12 +252,19 @@ async def subscribe(session_id: str) -> AsyncGenerator[str, None]:
# Last subscriber gone on a finished run — (re)arm eviction so the
# buffer doesn't linger indefinitely.
if not run.subscribers and run.status != "running":
_schedule_evict(session_id)
_schedule_evict(session_id, run)
def stop(session_id: str) -> bool:
"""Cancel an in-flight run (the wrapped generator saves its partial)."""
def stop(session_id: str, expected_run_id: Optional[str] = None) -> bool:
"""Cancel the matching in-flight run (which saves its partial output).
A stale browser may issue Stop after another tab has replaced the session's
run. Once the caller knows its opaque run identity, fail closed rather than
cancelling that newer run.
"""
run = _RUNS.get(session_id)
if not expected_run_id or run is None or run.run_id != expected_run_id:
return False
if run and run.task and not run.task.done():
run.task.cancel()
return True
+18 -6
View File
@@ -510,7 +510,12 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
# set/get/list/delete operate on the REAL app settings (the same store
# the Settings panel writes), so changing a model / voice / search
# engine / reminder channel from chat actually takes effect.
from src.settings import load_settings, save_settings, DEFAULT_SETTINGS
from src.settings import (
DEFAULT_SETTINGS,
RETIRED_SETTING_KEYS,
load_settings,
save_settings,
)
# Secrets/credentials the agent must NOT write: kept read-only (masked)
# so API keys never flow through chat. User sets these in the panel.
@@ -562,6 +567,9 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
return k2
return _ALIASES_SET.get(k2, (k or "").strip())
def _is_managed_key(key):
return key in DEFAULT_SETTINGS and key not in RETIRED_SETTING_KEYS
_ENUMS = {
"image_quality": ["low", "medium", "high"],
"reminder_channel": ["browser", "email", "ntfy", "webhook"],
@@ -624,14 +632,18 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
if action == "list":
s = load_settings()
shown = {k: _mask(k, v) for k, v in s.items() if k in DEFAULT_SETTINGS and not isinstance(v, dict)}
shown = {
k: _mask(k, v)
for k, v in s.items()
if _is_managed_key(k) and not isinstance(v, dict)
}
return {"response": f"{len(shown)} settings (use get/set with a key)", "settings": shown, "exit_code": 0}
elif action == "get":
key = _resolve(args.get("key", ""))
if not key:
return {"error": "key is required", "exit_code": 1}
if key not in DEFAULT_SETTINGS:
if not _is_managed_key(key):
return {"error": f"Unknown setting '{args.get('key')}'. Use action='list' to see them.", "exit_code": 1}
val = load_settings().get(key, DEFAULT_SETTINGS.get(key))
return {"response": f"{key} = {_mask(key, val)}", "value": _mask(key, val), "exit_code": 0}
@@ -642,11 +654,11 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
if not raw:
return {"error": "key is required", "exit_code": 1}
key = _resolve(raw)
if key not in DEFAULT_SETTINGS:
if not _is_managed_key(key):
return {"error": f"Unknown setting '{raw}'. Use action='list' to see available settings.", "exit_code": 1}
if _is_secret(key):
return {"response": f"'{key}' is a credential/secret. For security I can't set it from chat. Open Settings and set it there.", "exit_code": 0}
# Structured settings (dicts/lists like keybinds, default_model_fallbacks)
# Structured settings (dicts/lists like keybinds or vision fallbacks)
# have no safe scalar coercion; _coerce would pass a bare string
# straight through and clobber the structure. Refuse them here; they're
# edited in their dedicated panels. (reset/delete still restore the
@@ -675,7 +687,7 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict:
elif action == "delete" or action == "reset":
key = _resolve(args.get("key", ""))
if key not in DEFAULT_SETTINGS:
if not _is_managed_key(key):
return {"error": f"Unknown setting '{args.get('key')}'.", "exit_code": 1}
if _is_secret(key):
return {"response": f"'{key}' is a credential. Reset it in the panel.", "exit_code": 0}
+62 -2
View File
@@ -282,7 +282,9 @@ def trim_for_context(messages: List[Dict], context_length: int, reserve_tokens:
if essential_system:
sys_text = essential_system[0].get("content", "")
if len(sys_text) > 2000:
essential_system[0] = {"role": "system", "content": sys_text[:2000] + "\n[System prompt truncated for context limits]"}
truncated_system = dict(essential_system[0])
truncated_system["content"] = sys_text[:2000] + "\n[System prompt truncated for context limits]"
essential_system[0] = truncated_system
trimmed = essential_system + convo_msgs
if estimate_tokens(trimmed) <= budget:
return _sanitize_tool_messages(essential_system + protected_msgs + convo_msgs)
@@ -325,6 +327,9 @@ async def maybe_compact(
messages: List[Dict],
headers: Optional[Dict] = None,
owner: Optional[str] = None,
*,
persist: bool = True,
compaction_state: Optional[Dict[str, Any]] = None,
) -> tuple:
"""Check context usage and compact if above threshold.
@@ -416,7 +421,17 @@ async def maybe_compact(
# offset — session.history INCLUDES the system messages, but
# split_point is indexed against convo_msgs which does NOT. Without
# this, the slice drops the leading system message(s).
_update_session_history(session, split_point, summary, system_msg_count=len(system_msgs))
if compaction_state is not None:
compaction_state.update({
"split_point": split_point,
"summary": summary,
"system_msg_count": len(system_msgs),
"applied": False,
})
if persist:
_update_session_history(session, split_point, summary, system_msg_count=len(system_msgs))
if compaction_state is not None:
compaction_state["applied"] = True
new_used = estimate_tokens(compacted)
logger.info(
@@ -427,6 +442,51 @@ async def maybe_compact(
return compacted, context_length, True
def apply_compaction_state(session, compaction_state: Optional[Dict[str, Any]]) -> bool:
"""Persist a route-specific compaction after that route commits output.
Candidate prompts may be compacted speculatively while an explicit
foreground fallback chain is being tried. Persisting at construction time
would let an unavailable route rewrite history before another route answers,
so callers hold this small plan and apply only the winning route's plan.
"""
state = compaction_state if isinstance(compaction_state, dict) else None
if not state or state.get("applied"):
return False
summary = state.get("summary")
split_point = state.get("split_point")
system_msg_count = state.get("system_msg_count", 0)
if not isinstance(summary, str) or not isinstance(split_point, int):
return False
_update_session_history(
session,
split_point,
summary,
system_msg_count=system_msg_count if isinstance(system_msg_count, int) else 0,
)
state["applied"] = True
return True
def apply_compaction_state_for_session(
session_id: Optional[str],
compaction_state: Optional[Dict[str, Any]],
) -> bool:
"""Resolve an in-memory session and apply a deferred compaction plan."""
if not session_id:
return False
try:
from core.models import get_session_manager_instance
manager = get_session_manager_instance()
session = manager.get_session(session_id) if manager else None
except Exception:
session = None
return apply_compaction_state(session, compaction_state) if session else False
def _update_session_history(session, split_point: int, summary: str,
system_msg_count: int = 0):
"""Update the in-memory session history after compaction.
+216 -20
View File
@@ -5,6 +5,7 @@ Consolidates the 4+ copies of normalize_base / resolve_endpoint logic into one p
"""
import json
import ipaddress
import logging
import socket
import subprocess
@@ -27,6 +28,43 @@ _NON_CHAT_MODEL = (
)
def endpoint_cost_tracked(url: str, endpoint_kind: Optional[str] = None) -> bool:
"""Return whether token cost should be tracked for a concrete route.
This is intentionally a non-secret route classification. It mirrors the
frontend's local/subscription exclusions without exposing endpoint URLs to
message metadata.
"""
try:
parsed = urlparse(url or "")
host = (parsed.hostname or "").lower().rstrip(".")
path = (parsed.path or "").rstrip("/")
except Exception:
return False
if not host:
return False
if host == "chatgpt.com" and (
path == "/backend-api/codex" or path.startswith("/backend-api/codex/")
):
return False
kind = str(endpoint_kind or "auto").strip().lower()
if kind == "local":
return False
if kind in {"api", "proxy"}:
return True
if host in {"localhost", "0.0.0.0", "host.docker.internal"} or host.endswith(".local"):
return False
try:
ip = ipaddress.ip_address(host)
return ip.is_global
except ValueError:
pass
if "." not in host:
return False
return True
def _first_chat_model(models) -> Optional[str]:
"""First model that isn't an embedding/tts/etc.; falls back to models[0]."""
for m in (models or []):
@@ -396,10 +434,14 @@ def resolve_endpoint(
db.close()
def resolve_endpoint_by_id(
ep_id: str, model: Optional[str] = None, owner: Optional[str] = None
) -> Optional[Tuple[str, str, Dict]]:
"""Resolve a specific endpoint id (+ optional model) to (chat_url, model, headers).
def _resolve_endpoint_by_id_with_descriptor(
ep_id: str,
model: Optional[str] = None,
owner: Optional[str] = None,
*,
require_exact_model: bool = False,
) -> Optional[Tuple[Tuple[str, str, Dict], dict]]:
"""Resolve a concrete endpoint/model plus its non-secret descriptor.
Returns None if the endpoint doesn't exist or is disabled. Used to turn
a configured fallback entry ({endpoint_id, model}) into a dispatch target.
@@ -426,15 +468,34 @@ def resolve_endpoint_by_id(
chat_url = build_chat_url(base)
headers = build_headers(api_key, base)
m = (model or "").strip()
# Drop a model the user disabled on the endpoint, then pick the first
# enabled chat model rather than a hidden one.
if m and m in _endpoint_hidden_models(ep):
m = ""
if not m:
m = _first_chat_model(_endpoint_enabled_models(ep)) or ""
enabled_models = _endpoint_enabled_models(ep)
if require_exact_model:
# Explicit foreground fallback entries are concrete choices. A
# hidden or known-missing model must disable the entry instead of
# silently substituting another model from the endpoint.
if not m or m in _endpoint_hidden_models(ep):
return None
if enabled_models and m not in enabled_models:
return None
else:
# Legacy Utility/Vision chains retain their model-repair behavior.
if m and m in _endpoint_hidden_models(ep):
m = ""
if not m:
m = _first_chat_model(enabled_models) or ""
if not m:
return None
return chat_url, m, headers
return (
(chat_url, m, headers),
{
"endpoint_id": ep.id,
"endpoint_label": getattr(ep, "name", None) or ep.id,
"endpoint_cost_tracked": endpoint_cost_tracked(
chat_url,
getattr(ep, "endpoint_kind", None),
),
},
)
except Exception as e:
logger.debug(f"Could not resolve endpoint {ep_id}: {e}")
return None
@@ -442,11 +503,101 @@ def resolve_endpoint_by_id(
db.close()
def resolve_chat_fallback_candidates(owner: Optional[str] = None) -> list:
"""Compatibility shim for the retired default-chat fallback chain."""
def resolve_endpoint_by_id(
ep_id: str,
model: Optional[str] = None,
owner: Optional[str] = None,
*,
require_exact_model: bool = False,
) -> Optional[Tuple[str, str, Dict]]:
"""Resolve a specific endpoint id (+ optional model) to its runtime route."""
del owner
return []
resolved = _resolve_endpoint_by_id_with_descriptor(
ep_id,
model,
owner=owner,
require_exact_model=require_exact_model,
)
return resolved[0] if resolved else None
def resolve_route_descriptor(
endpoint_url: str,
model: str,
headers: Optional[Dict] = None,
owner: Optional[str] = None,
) -> dict:
"""Return the visible endpoint identity for an already-resolved route.
Headers are compared only inside the process so two endpoints using the
same provider URL/model but different credentials remain distinguishable.
No credential material is returned or logged.
"""
if not endpoint_url or not model:
return {
"endpoint_id": None,
"endpoint_label": "Selected route",
"endpoint_cost_tracked": endpoint_cost_tracked(endpoint_url),
}
db = SessionLocal()
try:
q = db.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
if owner:
from src.auth_helpers import owner_filter
q = owner_filter(q, ModelEndpoint, owner)
expected = (endpoint_url.rstrip("/"), model, headers or {})
for ep in q.all():
resolved = _resolve_endpoint_by_id_with_descriptor(
ep.id,
model,
owner=owner,
require_exact_model=True,
)
if not resolved:
continue
candidate, descriptor = resolved
actual = (candidate[0].rstrip("/"), candidate[1], candidate[2] or {})
if actual == expected:
return descriptor
except Exception as e:
logger.debug("Could not identify selected endpoint route: %s", e)
finally:
db.close()
return {
"endpoint_id": None,
"endpoint_label": "Selected route",
"endpoint_cost_tracked": endpoint_cost_tracked(endpoint_url),
}
def resolve_route_descriptor_by_id(
endpoint_id: str,
endpoint_url: str,
model: str,
headers: Optional[Dict] = None,
owner: Optional[str] = None,
) -> Optional[dict]:
"""Resolve a selected route's identity without relying on row order.
The explicit endpoint id is still verified against the resolved runtime
route. This prevents stale or mismatched request metadata from being used
for attribution while disambiguating endpoints whose routes are otherwise
identical.
"""
resolved = _resolve_endpoint_by_id_with_descriptor(
endpoint_id,
model,
owner=owner,
require_exact_model=True,
)
if not resolved:
return None
candidate, descriptor = resolved
expected = ((endpoint_url or "").rstrip("/"), model, headers or {})
actual = (candidate[0].rstrip("/"), candidate[1], candidate[2] or {})
return descriptor if actual == expected else None
def resolve_utility_fallback_candidates(owner: Optional[str] = None) -> list:
@@ -460,17 +611,62 @@ def resolve_vision_fallback_candidates(owner: Optional[str] = None) -> list:
def _resolve_fallback_candidates(setting_key: str, owner: Optional[str] = None) -> list:
out = []
try:
from src.settings import get_user_setting, load_settings
settings = load_settings()
chain = get_user_setting(setting_key, owner or "", settings.get(setting_key) or []) or []
except Exception:
return out
for entry in chain:
return []
return resolve_fallback_entries(chain, owner=owner)
def resolve_fallback_entries(
entries,
owner: Optional[str] = None,
*,
require_exact_model: bool = False,
) -> list:
"""Resolve ordered endpoint/model entries within the caller's owner scope."""
out = []
for entry in entries or []:
if not isinstance(entry, dict):
continue
resolved = resolve_endpoint_by_id(entry.get("endpoint_id", ""), entry.get("model", ""), owner=owner)
if resolved:
resolved = resolve_endpoint_by_id(
entry.get("endpoint_id", ""),
entry.get("model", ""),
owner=owner,
require_exact_model=require_exact_model,
)
if resolved and resolved not in out:
out.append(resolved)
return out
def resolve_fallback_entries_with_descriptors(
entries,
owner: Optional[str] = None,
*,
require_exact_model: bool = False,
) -> list:
"""Resolve ordered entries while retaining safe endpoint provenance."""
out = []
seen = []
for entry in entries or []:
if not isinstance(entry, dict):
continue
resolved = _resolve_endpoint_by_id_with_descriptor(
entry.get("endpoint_id", ""),
entry.get("model", ""),
owner=owner,
require_exact_model=require_exact_model,
)
if not resolved:
continue
candidate, descriptor = resolved
if any(candidate == prior for prior in seen):
continue
seen.append(candidate)
out.append((candidate, descriptor))
return out
+189 -14
View File
@@ -1,22 +1,155 @@
"""Foreground Chat and Agent model-routing policy.
"""Explicit foreground Chat and Agent model-routing policy."""
The selected session model is strict by default. Historical
``default_model_fallbacks`` values remain stored for compatibility, but this
policy intentionally does not read or migrate them.
"""
from dataclasses import dataclass
from typing import Any, Collection, Dict, FrozenSet, Optional, Tuple
from typing import Any, Dict, Optional
from src.endpoint_resolver import (
endpoint_cost_tracked,
resolve_fallback_entries,
resolve_fallback_entries_with_descriptors,
resolve_route_descriptor,
resolve_route_descriptor_by_id,
)
_DEFAULT_FALLBACK_ENTRY_RESOLVER = resolve_fallback_entries
FOREGROUND_FALLBACK_ENABLED_KEY = "foreground_fallback_enabled"
FOREGROUND_FALLBACK_LIST_KEY = "foreground_model_fallbacks"
FOREGROUND_AVAILABILITY_STATUSES: FrozenSet[int] = frozenset({
408, 425, 429, 500, 502, 503, 504, 507, 508, 529,
})
MAX_FOREGROUND_FALLBACKS = 10
@dataclass(frozen=True)
class ForegroundModelPolicy:
"""Resolved per-user foreground fallback policy."""
enabled: bool = False
fallback_candidates: Tuple[tuple, ...] = ()
fallback_descriptors: Tuple[dict, ...] = ()
eligible_statuses: FrozenSet[int] = FOREGROUND_AVAILABILITY_STATUSES
fallback_on_empty: bool = False
def _load_policy_preferences(owner: Optional[str]) -> dict:
"""Load only preferences that explicitly belong to ``owner``.
The generic preferences loader intentionally treats a legacy flat store as
the single-user preferences object. That compatibility must not cross an
authentication transition: once a named owner is present, foreground
fallback consent exists only in an actual ``_users[owner]`` dictionary.
"""
from routes import prefs_routes
if owner is None:
prefs = prefs_routes._load_for_user(None)
return dict(prefs) if isinstance(prefs, dict) else {}
raw = prefs_routes._load()
users = raw.get("_users") if isinstance(raw, dict) else None
if not isinstance(users, dict):
return {}
prefs = users.get(owner)
return dict(prefs) if isinstance(prefs, dict) else {}
def resolve_foreground_model_policy(
owner: Optional[str] = None,
allowed_models: Optional[Collection[str]] = None,
) -> ForegroundModelPolicy:
"""Resolve an explicit owner-scoped policy, failing closed to strict mode.
The policy is stored in user preferences even when authentication is
disabled. Historical ``default_model_fallbacks`` values are deliberately
unrelated and are never read or migrated.
"""
try:
prefs = _load_policy_preferences(owner)
except Exception:
return ForegroundModelPolicy()
if prefs.get(FOREGROUND_FALLBACK_ENABLED_KEY) is not True:
return ForegroundModelPolicy()
entries = prefs.get(FOREGROUND_FALLBACK_LIST_KEY)
if not isinstance(entries, list) or not entries:
return ForegroundModelPolicy()
if allowed_models is not None:
allowed = frozenset(allowed_models)
entries = [
entry for entry in entries
if (
isinstance(entry, dict)
and isinstance(entry.get("model"), str)
and entry.get("model") in allowed
)
]
if not entries:
return ForegroundModelPolicy()
entries = entries[:MAX_FOREGROUND_FALLBACKS]
if resolve_fallback_entries is not _DEFAULT_FALLBACK_ENTRY_RESOLVER:
# Preserve the long-standing resolver seam used by downstream tests and
# integrations. Production uses the descriptor-aware resolver below.
compatibility_candidates = resolve_fallback_entries(
entries,
owner=owner,
require_exact_model=True,
)
# Known limitation of this test-only seam: alignment matches on model
# alone, so when two entries share a model and the resolver skips the
# first, the surviving candidate inherits the skipped entry's
# endpoint_id. Production uses the descriptor-aware branch below,
# which is unaffected.
resolved_routes = []
remaining_entries = list(entries)
for candidate in compatibility_candidates:
matching_index = next(
(
index for index, entry in enumerate(remaining_entries)
if isinstance(entry, dict)
and entry.get("model") == candidate[1]
),
None,
)
matching_entry = (
remaining_entries.pop(matching_index)
if matching_index is not None
else {}
)
descriptor = {
"endpoint_id": matching_entry.get("endpoint_id"),
"endpoint_label": matching_entry.get("endpoint_id") or "Fallback route",
"endpoint_cost_tracked": endpoint_cost_tracked(candidate[0]),
}
resolved_routes.append((candidate, descriptor))
else:
resolved_routes = resolve_fallback_entries_with_descriptors(
entries,
owner=owner,
require_exact_model=True,
)
candidates = [candidate for candidate, _descriptor in resolved_routes]
if not candidates:
return ForegroundModelPolicy()
return ForegroundModelPolicy(
enabled=True,
fallback_candidates=tuple(candidates),
fallback_descriptors=tuple(
dict(descriptor) for _candidate, descriptor in resolved_routes
),
)
def resolve_foreground_fallback_candidates(owner: Optional[str] = None) -> list:
"""Return fallback candidates for a foreground Chat or Agent request.
"""Return only candidates explicitly enabled by the current user."""
Foreground routing is strict, so no alternate endpoint/model is eligible.
``owner`` is accepted to keep this policy boundary owner-aware.
"""
del owner
return []
return list(resolve_foreground_model_policy(owner).fallback_candidates)
def build_foreground_model_candidates(
@@ -24,8 +157,50 @@ def build_foreground_model_candidates(
model: str,
headers: Optional[Dict[str, Any]] = None,
owner: Optional[str] = None,
policy: Optional[ForegroundModelPolicy] = None,
) -> list:
"""Build the ordered candidate list for a foreground request."""
policy = policy or resolve_foreground_model_policy(owner)
primary = (endpoint_url, model, headers or {})
return [primary] + resolve_foreground_fallback_candidates(owner=owner)
candidates = [primary]
for candidate in policy.fallback_candidates:
if candidate not in candidates:
candidates.append(candidate)
return candidates
def build_foreground_route_descriptors(
endpoint_url: str,
model: str,
headers: Optional[Dict[str, Any]] = None,
owner: Optional[str] = None,
policy: Optional[ForegroundModelPolicy] = None,
selected_endpoint_id: Optional[str] = None,
) -> list:
"""Build safe route metadata parallel to foreground candidates."""
policy = policy or resolve_foreground_model_policy(owner)
selected = None
if selected_endpoint_id:
selected = resolve_route_descriptor_by_id(
selected_endpoint_id,
endpoint_url,
model,
headers or {},
owner=owner,
)
if selected is None:
selected = resolve_route_descriptor(endpoint_url, model, headers or {}, owner=owner)
primary = (endpoint_url, model, headers or {})
candidates = [primary]
descriptors = [selected]
for candidate, descriptor in zip(
policy.fallback_candidates,
policy.fallback_descriptors,
):
if candidate in candidates:
continue
candidates.append(candidate)
descriptors.append(dict(descriptor))
return descriptors
+16
View File
@@ -63,8 +63,11 @@ _PASSIVE_EXACT_PATHS = {
"/api/activity/heartbeat",
"/api/client-perf",
"/api/tasks/notifications",
"/api/tasks/runs/recent",
"/api/research/active",
"/api/email/urgency-state",
# UI idle poll sibling of urgency-state; must not pre-empt background tasks.
"/api/email/unread-state",
}
_PASSIVE_PREFIXES = (
@@ -74,6 +77,19 @@ _PASSIVE_PREFIXES = (
)
async def maybe_stop_background_tasks_for_heartbeat(stop_background) -> bool:
"""Stop background work for browser activity only when the gate is enabled.
``stop_background`` is injected by the application boundary so this policy
remains independently testable without importing the full FastAPI app.
"""
if not _enabled():
return False
await stop_background(reason="browser heartbeat")
return True
def should_track_interactive_request(path: str, method: str = "GET") -> bool:
if not _enabled():
return False
+885 -126
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -12,6 +12,7 @@ class ChatRequest(BaseModel):
use_research: Optional[bool] = Field(default=False, description="Enable deep research")
time_filter: Optional[str] = Field(default=None, description="Time filter for search")
preset_id: Optional[str] = Field(default=None, description="Preset identifier")
selected_endpoint_id: Optional[str] = Field(default=None, description="Selected model endpoint ID")
@field_validator('message')
@classmethod
+19 -1
View File
@@ -14,6 +14,13 @@ from src.constants import SETTINGS_FILE, FEATURES_FILE
logger = logging.getLogger(__name__)
# Keys retained in the raw settings store for compatibility and rollback, but
# deliberately unavailable through generic settings APIs or agent tools. They
# must stay in ``DEFAULT_SETTINGS`` so old files continue to load without data
# loss; callers that present or mutate settings should use this set as a
# tombstone boundary.
RETIRED_SETTING_KEYS = frozenset({"default_model_fallbacks"})
# Tiny TTL cache for settings/features. get_setting() is called on hot paths
# (every chat, every preprocess); without this it re-parses the JSON each call.
# Picks up edits within _CACHE_TTL seconds, which is fine for human-edited config.
@@ -197,6 +204,17 @@ DEFAULT_SETTINGS = {
},
}
def without_retired_settings(settings: dict) -> dict:
"""Return a shallow copy suitable for generic settings interfaces."""
if not isinstance(settings, dict):
return {}
return {
key: value
for key, value in settings.items()
if key not in RETIRED_SETTING_KEYS
}
DEFAULT_FEATURES = {
"web_search": True,
"web_fetch": True,
@@ -269,7 +287,7 @@ _PER_USER_KEYS = {
# Default chat endpoint / model — without per-user resolution every new
# account inherited whatever the most-recent admin picked, which then
# got injected into the chat composer on first open.
"default_endpoint_id", "default_model", "default_model_fallbacks",
"default_endpoint_id", "default_model",
"utility_endpoint_id", "utility_model", "utility_model_fallbacks",
"research_endpoint_id", "research_model",
}
-5
View File
@@ -1,7 +1,6 @@
"""Shared resolver for background-task AI endpoints."""
from src.endpoint_resolver import (
resolve_chat_fallback_candidates,
resolve_endpoint,
resolve_utility_fallback_candidates,
)
@@ -32,7 +31,6 @@ def resolve_task_candidates(
2. Utility endpoint/model
3. Default endpoint/model
4. Utility fallback chain
5. Retired default-fallback compatibility hook (currently empty)
"""
candidates = []
@@ -49,9 +47,6 @@ def resolve_task_candidates(
_append(*resolve_endpoint("default", owner=owner))
for url, model, headers in resolve_utility_fallback_candidates(owner=owner):
_append(url, model, headers)
for url, model, headers in resolve_chat_fallback_candidates(owner=owner):
_append(url, model, headers)
return candidates
-7
View File
@@ -1504,13 +1504,6 @@
<span class="adm-model-logo" id="set-defaultModelSelect-logo" style="display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;flex-shrink:0;opacity:0.9;color:var(--fg);"></span>
<select id="set-defaultModelSelect" class="settings-select"></select>
</div>
<div class="settings-row" style="align-items:flex-start;" hidden>
<label class="settings-label" style="margin-top:6px;">Fallbacks</label>
<div style="flex:1;display:flex;flex-direction:column;gap:6px;">
<div id="set-defaultFallbacks" class="settings-fallbacks"></div>
<button type="button" class="settings-fallback-add" id="set-defaultAddFallback" title="Add a model to try if the one above fails">+ Add fallback</button>
</div>
</div>
<div id="set-defaultChatMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
</div>
</div>
+512 -118
View File
@@ -29,9 +29,16 @@ import {
createThinkingAnalysisGate,
stripLiveThinkingTags,
} from './liveThinkingThrottle.js';
import {
applyModelMetricsState,
applyModelRouteEventState,
inheritModelRouteState,
} from './chatModelProvenance.js';
import { createTerminalStreamError, isRecoverableStreamError } from './chatStreamErrors.js';
const RESEARCH_TIMEOUT_MS = 360000;
const DEFAULT_TIMEOUT_MS = 120000;
const RUN_ID_ABORT_GRACE_MS = 2000; // timeout waits this long for a run-id header before hard-aborting
const RESEARCH_SVG = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>';
let API_BASE = '';
@@ -394,13 +401,27 @@ import {
const tsSpan = roleEl.querySelector('.role-timestamp');
const req = requestedModel || actualModel || '';
const actual = actualModel || requestedModel || '';
let label = _modelRouteLabel(req, actual);
let label = _modelRouteLabel(
req,
actual,
opts.requestedEndpointLabel,
opts.actualEndpointLabel,
opts.requestedEndpointId,
opts.actualEndpointId,
);
if (opts.suffix) label += ' (' + opts.suffix + ')';
if (opts.characterName) label = opts.characterName;
roleEl.textContent = label + ' ';
_applyModelColor(roleEl, actual || req);
if (req && actual && !_sameModelName(req, actual)) {
roleEl.title = req + ' -> ' + actual + (opts.reason ? ': ' + opts.reason : '');
const endpointChanged = Boolean(
opts.requestedEndpointId
&& opts.actualEndpointId
&& opts.requestedEndpointId !== opts.actualEndpointId
);
if (req && actual && (!_sameModelName(req, actual) || endpointChanged)) {
roleEl.title = req + ' -> ' + actual
+ (endpointChanged ? ' (' + opts.requestedEndpointLabel + ' -> ' + opts.actualEndpointLabel + ')' : '')
+ (opts.reason ? ': ' + opts.reason : '');
} else if (!opts.reason) {
roleEl.removeAttribute('title');
}
@@ -570,6 +591,11 @@ import {
const _backgroundStreams = new Map(); // sessionId -> { status, accumulated, sourcesHtml, abortCtrl, query, metrics }
const _activeStreams = new Map(); // sessionId -> { abortCtrl, holder, query, startedAt, cancelViewWork, finalizeView }
const _resumingStreams = new Set(); // sessionId -> a resumeStream() reader is live (re-attach lock)
const _terminalSavedStreams = new Set(); // sessionId -> canonical terminal event seen by active reader
const _streamRunIds = new Map(); // sessionId -> opaque identity of the current send's detached run
const _streamGenerations = new Map(); // sessionId -> generation of the current (latest) send
const _sendStates = new Map(); // sessionId -> { generation, abortCtrl } of the current send, installed synchronously at send commit so Stop never has to borrow an older send's controller
const _pendingRunStops = new Map(); // 'sessionId:generation' -> abortCtrl|null; Stop queued for that send while it awaits headers. Keyed per send so concurrent sends' cancellation intents never displace each other.
let _streamSessionId = null; // Session ID for the currently active reader loop
let _lastReaderActivity = 0; // Timestamp of last reader.read() success — used to detect frozen streams
let _webLockRelease = null; // Function to release the Web Lock held during streaming
@@ -608,6 +634,60 @@ import {
return now;
}
/** Stable cost identity for one logical metrics segment within a run. */
function _metricsCostRecordId(runId, event) {
if (!runId) return '';
return `${runId}:${event && event.teacher ? 'teacher' : 'primary'}`;
}
/** POST the exact Stop for one observed run identity. */
function _postExactStop(sessionId, runId) {
fetch(`/api/chat/stop/${encodeURIComponent(sessionId)}`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'X-Odysseus-Run-Id': runId },
}).catch(() => {});
}
/** Stop only the exact detached run whose identity this browser observed. */
function _stopExactRun(sessionId, abortCtrl = null) {
if (!sessionId) return false;
const runId = _streamRunIds.get(sessionId);
if (!runId) {
// Queue against the CURRENT send's generation: its POST is the only
// identity channel that can name the run, so the Stop fires from that
// send's own header arrival even if a replacement starts meanwhile.
const generation = _streamGenerations.get(sessionId) || 0;
const pendingKey = sessionId + ':' + generation;
if (abortCtrl || !_pendingRunStops.has(pendingKey)) {
_pendingRunStops.set(pendingKey, abortCtrl);
}
return false;
}
_postExactStop(sessionId, runId);
return true;
}
function _rememberStreamRunId(sessionId, runId, generation) {
if (!sessionId || !runId) return;
// A superseded send must not record its run id as the session's current
// identity, but it must still flush its own queued Stop: this is the only
// channel that can cancel that run when the replacement dies before its
// own POST reaches the server.
if (_streamGenerations.get(sessionId) === generation) {
_streamRunIds.set(sessionId, runId);
}
const pendingKey = sessionId + ':' + generation;
if (!_pendingRunStops.has(pendingKey)) return;
const pendingAbort = _pendingRunStops.get(pendingKey);
_pendingRunStops.delete(pendingKey);
_postExactStop(sessionId, runId);
if (pendingAbort && !pendingAbort.signal.aborted) {
pendingAbort._reason = 'user-stop';
pendingAbort.abort();
}
}
// Sources box builder and toggleSources are now in chatRenderer.js
var _buildSourcesBox = chatRenderer.buildSourcesBox;
@@ -1342,6 +1422,26 @@ import {
if (messageInput) messageInput.disabled = false;
updateSubmitButton('streaming', submitBtn);
if (submitBtn) submitBtn.classList.remove('send-pending');
// Per-send generation, reserved SYNCHRONOUSLY before the send gate clears
// and before the first await: from this instant the superseded send may
// not clean session state, register, or POST (each checked at its own
// await boundaries). Session-keyed state (run id, queued Stop, cleanup
// rights) belongs to the latest generation only. A queued Stop from the
// superseded send is deliberately left in place, tagged with ITS
// generation: that send's still-alive POST is the only identity channel
// able to name its run, so the Stop fires from its own header arrival
// (see _rememberStreamRunId) even if this replacement dies before fetch.
const streamSessionId = sessionModule.getCurrentSessionId();
const streamGeneration = (_streamGenerations.get(streamSessionId) || 0) + 1;
_streamGenerations.set(streamSessionId, streamGeneration);
const _sendState = { generation: streamGeneration, abortCtrl: null };
_sendStates.set(streamSessionId, _sendState);
// The previous send's run identity dies with its ownership: a Stop after
// this instant must queue for THIS send, not fire against the old run.
// (The old send's own queued Stop still works — its flush carries the run
// id from its header, and its stale generation cannot repopulate this map.)
_streamRunIds.delete(streamSessionId);
_streamSessionId = streamSessionId;
_sendInFlight = false;
try {
@@ -1350,10 +1450,12 @@ import {
await pendingSwitch;
}
} catch (_) {}
// Superseded while awaiting the model switch: the replacement owns the
// session now, and everything below (state resets, registration, POST)
// is its business alone.
if (_streamGenerations.get(streamSessionId) !== streamGeneration) return;
// Capture session ID for background stream detection
const streamSessionId = sessionModule.getCurrentSessionId();
_streamSessionId = streamSessionId;
_terminalSavedStreams.delete(streamSessionId);
const streamQuery = msg;
_touchStreamActivity(streamSessionId);
@@ -1373,6 +1475,7 @@ import {
let _thinkOpen = false;
let holder = null;
let finalMeta = null;
let _canonicalTerminalSaved = false;
let spinner = null;
let timedOut = false;
let processingProbeTimer = null;
@@ -1742,8 +1845,26 @@ import {
}
// Superseded during preflight (uploads, document saves): a newer send
// owns the session. Bailing here — before registration and before the
// POST — keeps this stale send from overwriting the replacement's
// stream entry or reaching the server last, where agent_runs.start
// would cancel the newer run in favor of this old one.
if (_streamGenerations.get(streamSessionId) !== streamGeneration) {
// The optimistic user bubble is already in the DOM looking sent, but
// this message never reaches the server. Say so instead of leaving a
// ghost that vanishes on refresh.
if (_userMsgEl && _userMsgEl.parentNode) {
const _notSentNote = document.createElement('div');
_notSentNote.style.cssText = 'color: var(--color-error); font-style: italic; font-size: 0.85em; padding: 2px 0;';
_notSentNote.textContent = '[Not sent — superseded by a newer message]';
_userMsgEl.appendChild(_notSentNote);
}
return;
}
abortCtrl = new AbortController();
abortCtrl._reason = '';
_sendState.abortCtrl = abortCtrl;
currentAbort = abortCtrl;
const _tState = Storage.loadToggleState();
@@ -1755,15 +1876,28 @@ import {
if (!abortCtrl.signal.aborted) {
timedOut = true;
abortCtrl._reason = 'timeout';
if (_streamGenerations.get(streamSessionId) !== streamGeneration) {
// Superseded send: the session's run id and Stop queue belong to
// the replacement now. Just kill this hung POST.
abortCtrl.abort();
return;
}
let abortNow = true;
try {
if (streamSessionId) {
fetch(`/api/chat/stop/${encodeURIComponent(streamSessionId)}`, {
method: 'POST',
credentials: 'same-origin',
}).catch(() => {});
}
abortNow = _streamRunIds.has(streamSessionId)
? _stopExactRun(streamSessionId)
: _stopExactRun(streamSessionId, abortCtrl);
} catch (_) {}
abortCtrl.abort();
if (abortNow) {
abortCtrl.abort();
} else {
// The Stop is queued on the run-id header, but a request this
// stalled may never send one. Hard-abort after a short grace so
// the timeout still guarantees cancellation.
setTimeout(() => {
if (!abortCtrl.signal.aborted) abortCtrl.abort();
}, RUN_ID_ABORT_GRACE_MS);
}
}
}, timeoutMs);
clearResponseTimeout = () => {
@@ -1912,6 +2046,8 @@ import {
enableResearchBtn();
return;
}
const streamRunId = res.headers.get('X-Odysseus-Run-Id') || '';
if (streamRunId) _rememberStreamRunId(streamSessionId, streamRunId, streamGeneration);
// Mark the chat log busy while streaming so screen readers wait for the
// settled response instead of announcing every token. Cleared in finally.
@@ -1986,9 +2122,17 @@ import {
const newRole = document.createElement('div');
newRole.className = 'role';
const metaS = sessionModule.getSessions().find(s => s.id === streamSessionId);
const requested = holder?._requestedModel || metaS?.model || modelName;
const actual = holder?._actualModel || requested;
newRole.textContent = _modelRouteLabel(requested, actual) || '';
inheritModelRouteState(holder, roundHolder, newWrap, metaS?.model || modelName);
const requested = newWrap._requestedModel;
const actual = newWrap._actualModel;
newRole.textContent = _modelRouteLabel(
requested,
actual,
newWrap._requestedEndpointLabel,
newWrap._actualEndpointLabel,
newWrap._requestedEndpointId,
newWrap._actualEndpointId,
) || '';
_applyModelColor(newRole, actual);
newWrap.appendChild(newRole);
const newBody = document.createElement('div');
@@ -2514,6 +2658,7 @@ import {
let _nextIsError = false;
let _streamSawDone = false;
let _streamTerminalError = null;
let _firstVisibleOutputSeen = false;
const markFirstVisibleOutput = () => {
if (_firstVisibleOutputSeen) return;
@@ -2638,10 +2783,9 @@ import {
// Handle SSE error events (e.g. HTTP 404 from provider)
if (_nextIsError || json.status >= 400) {
_nextIsError = false;
const errMsg = json.text || json.error?.message || `Error ${json.status || 'unknown'}`;
console.error('Stream error:', errMsg);
_streamTerminalError = createTerminalStreamError(json);
console.error('Stream error:', _streamTerminalError.message);
if (spinner && spinner.element) spinner.destroy();
typewriterInto(roundHolder.querySelector('.body'), errMsg);
break;
}
if (json.delta || json.type === 'agent_prep' || json.type === 'generated_image' || json.type === 'tool_start' || json.type === 'tool_output' || json.type === 'tool_progress' || json.type === 'agent_step' || json.type === 'loop_breaker_triggered' || json.type === 'intent_nudge_exhausted' || json.type === 'doc_stream_open' || json.type === 'doc_stream_delta' || json.type === 'research_progress') {
@@ -3040,18 +3184,6 @@ import {
6000
);
continue;
} else if (json.type === 'model_fallback') {
// Model went offline — switched to fallback
var _fbData = json.data || {};
uiModule.showToast(
`Model ${_fbData.old_model || '?'} offline — switched to ${_fbData.new_model || '?'}`,
5000
);
// Update the model picker to reflect the new model
if (sessionModule && sessionModule.updateModelPicker) {
sessionModule.updateModelPicker();
}
continue;
} else if (json.type === 'model_info') {
// Update role label with model name as soon as we know it
if (!_isBg && holder) {
@@ -3059,6 +3191,10 @@ import {
if (roleEl) {
holder._requestedModel = json.requested_model || json.model || holder._requestedModel;
holder._actualModel = json.model || holder._actualModel || holder._requestedModel;
holder._requestedEndpointId = json.requested_endpoint_id || json.endpoint_id || holder._requestedEndpointId || null;
holder._requestedEndpointLabel = json.requested_endpoint_label || json.endpoint_label || holder._requestedEndpointLabel || 'Selected route';
holder._actualEndpointId = json.endpoint_id || holder._actualEndpointId || holder._requestedEndpointId;
holder._actualEndpointLabel = json.endpoint_label || holder._actualEndpointLabel || holder._requestedEndpointLabel;
if (json.suffix) holder._roleSuffix = json.suffix;
// Prepend character name if sent by server or set locally
var _charName = json.character_name || (presetsModule.getCharacterName ? presetsModule.getCharacterName() : '');
@@ -3066,6 +3202,10 @@ import {
_setRoleModelLabel(roleEl, holder._requestedModel, holder._actualModel, {
suffix: holder._roleSuffix,
characterName: holder._characterName,
requestedEndpointId: holder._requestedEndpointId,
requestedEndpointLabel: holder._requestedEndpointLabel,
actualEndpointId: holder._actualEndpointId,
actualEndpointLabel: holder._actualEndpointLabel,
});
}
}
@@ -3076,9 +3216,10 @@ import {
if (!_isBg) {
var _selM = _shortModel(json.selected_model || '');
var _ansM = _shortModel(json.answered_by || '');
uiModule.showToast(' ' + _selM + ' failed — answered by ' + _ansM, 6000);
if (holder) {
var _rEl = holder.querySelector('.role');
uiModule.showToast('Fallback: ' + _selM + ' failed — answered by ' + _ansM, 6000);
var _fallbackHolder = applyModelRouteEventState(json, holder, roundHolder, modelName);
if (_fallbackHolder) {
var _rEl = _fallbackHolder.querySelector('.role');
if (_rEl) {
var _tsS = _rEl.querySelector('.role-timestamp');
_rEl.textContent = _ansM + ' (fallback) ';
@@ -3086,13 +3227,14 @@ import {
(json.reason ? ': ' + json.reason : '') + ' — answered by ' + (json.answered_by || '');
_applyModelColor(_rEl, json.answered_by);
if (_tsS) _rEl.appendChild(_tsS);
holder._requestedModel = json.selected_model || holder._requestedModel || modelName;
const _hasResolvedActual = holder._actualModel && !_sameModelName(holder._actualModel, holder._requestedModel);
holder._actualModel = _hasResolvedActual ? holder._actualModel : (json.answered_by || holder._actualModel || holder._requestedModel);
_setRoleModelLabel(_rEl, holder._requestedModel, holder._actualModel, {
suffix: holder._roleSuffix,
characterName: holder._characterName,
_setRoleModelLabel(_rEl, _fallbackHolder._requestedModel, _fallbackHolder._actualModel, {
suffix: _fallbackHolder._roleSuffix,
characterName: _fallbackHolder._characterName,
reason: json.reason,
requestedEndpointId: _fallbackHolder._requestedEndpointId,
requestedEndpointLabel: _fallbackHolder._requestedEndpointLabel,
actualEndpointId: _fallbackHolder._actualEndpointId,
actualEndpointLabel: _fallbackHolder._actualEndpointLabel,
});
}
}
@@ -3136,12 +3278,15 @@ import {
try { note.scrollIntoView({ block: 'end', behavior: 'smooth' }); } catch (_) { uiModule.scrollHistory && uiModule.scrollHistory(); }
}
} else if (json.type === 'model_actual') {
if (!_isBg && holder) {
holder._requestedModel = json.requested_model || holder._requestedModel || modelName;
holder._actualModel = json.model || holder._actualModel || holder._requestedModel;
_setRoleModelLabel(holder.querySelector('.role'), holder._requestedModel, holder._actualModel, {
suffix: holder._roleSuffix,
characterName: holder._characterName,
if (!_isBg) {
var _modelHolder = applyModelRouteEventState(json, holder, roundHolder, modelName);
if (_modelHolder) _setRoleModelLabel(_modelHolder.querySelector('.role'), _modelHolder._requestedModel, _modelHolder._actualModel, {
suffix: _modelHolder._roleSuffix,
characterName: _modelHolder._characterName,
requestedEndpointId: _modelHolder._requestedEndpointId,
requestedEndpointLabel: _modelHolder._requestedEndpointLabel,
actualEndpointId: _modelHolder._actualEndpointId,
actualEndpointLabel: _modelHolder._actualEndpointLabel,
});
}
} else if (json.type === 'attachments') {
@@ -3227,15 +3372,60 @@ import {
const detail = before && after && before > after ? ` (${after}/${before} messages sent)` : '';
uiModule.showToast(`Context trimmed for this model${detail}`);
}
} else if (json.type === 'agent_terminal' || json.type === 'chat_terminal') {
// The backend persisted canonical partial output, sanitized
// failure metadata, and actual-route provenance before this
// event. The terminal catch below reloads that exact record.
_canonicalTerminalSaved = true;
_terminalSavedStreams.add(streamSessionId);
const priorMetrics = metrics;
metrics = json.data || metrics;
if (metrics && streamRunId) {
metrics._costRecordId = _metricsCostRecordId(streamRunId, json);
}
// Direct Chat may have emitted provider usage before its
// terminal event. Carry that already-recorded state onto the
// canonical terminal metadata instead of billing it twice.
if (priorMetrics && priorMetrics._costRecorded && metrics) {
metrics._costRecorded = true;
}
if (_isBg) {
var bgTerminal = _backgroundStreams.get(streamSessionId);
if (bgTerminal) {
if (
bgTerminal.metrics
&& bgTerminal.metrics._costRecorded
&& metrics
) {
metrics._costRecorded = true;
}
bgTerminal.metrics = metrics;
bgTerminal.status = 'completed';
if (metrics) {
chatRenderer.recordSessionMetricsCost(metrics, streamSessionId);
}
}
continue;
}
if (holder && metrics) {
applyModelMetricsState(metrics, holder, roundHolder, modelName);
const terminalMetricsTarget = _metricsTargetForTurn();
if (terminalMetricsTarget) displayMetrics(terminalMetricsTarget, metrics);
}
} else if (json.type === 'metrics') {
metrics = json.data;
if (metrics && streamRunId) {
metrics._costRecordId = _metricsCostRecordId(streamRunId, json);
}
if (!_isBg && holder && metrics) {
holder._requestedModel = metrics.requested_model || holder._requestedModel || modelName;
holder._actualModel = metrics.model || holder._actualModel || holder._requestedModel;
applyModelMetricsState(metrics, holder, roundHolder, modelName);
}
if (_isBg) {
var bgM = _backgroundStreams.get(streamSessionId);
if (bgM) bgM.metrics = json.data;
if (bgM) {
bgM.metrics = json.data;
chatRenderer.recordSessionMetricsCost(bgM.metrics, streamSessionId);
}
continue;
}
if (metrics) {
@@ -3616,9 +3806,17 @@ import {
const newRole = document.createElement('div');
newRole.className = 'role';
const metaS = sessionModule.getSessions().find(s => s.id === streamSessionId);
const _roundRequested = holder?._requestedModel || metaS?.model;
const _roundActual = holder?._actualModel || _roundRequested;
newRole.textContent = _modelRouteLabel(_roundRequested, _roundActual) || '';
inheritModelRouteState(holder, roundHolder, newWrap, metaS?.model || modelName);
const _roundRequested = newWrap._requestedModel;
const _roundActual = newWrap._actualModel;
newRole.textContent = _modelRouteLabel(
_roundRequested,
_roundActual,
newWrap._requestedEndpointLabel,
newWrap._actualEndpointLabel,
newWrap._requestedEndpointId,
newWrap._actualEndpointId,
) || '';
_applyModelColor(newRole, _roundActual);
newWrap.appendChild(newRole);
const newBody = document.createElement('div');
@@ -3725,8 +3923,21 @@ import {
}
}
if (_streamTerminalError) {
throw _streamTerminalError;
}
if (!_streamSawDone) {
throw new Error('Stream closed before completion');
if (!_canonicalTerminalSaved) {
throw new Error('Stream closed before completion');
}
// The backend persisted a canonical terminal record (partial output +
// failure metadata) before the connection died. Route through the
// terminal-error path so that record is reloaded; falling through to
// the success renderer would present the partial output as a clean
// completion.
throw createTerminalStreamError({
text: 'Stream closed after canonical terminal event',
});
}
// The final foreground render below is authoritative. Cancel any delayed
@@ -3746,15 +3957,25 @@ import {
const _isBgFinal = (sessionModule.getCurrentSessionId() !== streamSessionId) || _backgroundStreams.has(streamSessionId);
if (!_isBgFinal) {
finalMeta = sessionModule.getSessions().find(s => s.id === sessionModule.getCurrentSessionId());
const _finalActualModel = metrics?.model || holder._actualModel || finalMeta?.model;
const _finalRequestedModel = metrics?.requested_model || holder._requestedModel || finalMeta?.model || _finalActualModel;
const _finalModelHolder = applyModelMetricsState(
metrics,
holder,
roundHolder,
finalMeta?.model || modelName,
) || holder;
const _finalActualModel = _finalModelHolder._actualModel || finalMeta?.model;
const _finalRequestedModel = _finalModelHolder._requestedModel || finalMeta?.model || _finalActualModel;
// Prepend character name if set
var _charNameFinal = presetsModule.getCharacterName ? presetsModule.getCharacterName() : '';
const roleEl = holder.querySelector('.role');
const roleEl = _finalModelHolder.querySelector('.role');
if (roleEl) {
_setRoleModelLabel(roleEl, _finalRequestedModel, _finalActualModel, {
suffix: holder._roleSuffix,
characterName: _charNameFinal || holder._characterName,
suffix: _finalModelHolder._roleSuffix,
characterName: _charNameFinal || _finalModelHolder._characterName,
requestedEndpointId: _finalModelHolder._requestedEndpointId,
requestedEndpointLabel: _finalModelHolder._requestedEndpointLabel,
actualEndpointId: _finalModelHolder._actualEndpointId,
actualEndpointLabel: _finalModelHolder._actualEndpointLabel,
});
}
holder.dataset.raw = accumulated;
@@ -4013,6 +4234,21 @@ import {
} // end if (!_isBgFinal)
} catch (err) {
// If a Stop or timeout was waiting for an identity header and the POST
// failed before producing one, keep this on the cancellation path. There
// is no safe headerless server cancel to send, but it must not be turned
// into an automatic recovery attempt either. Only this send's own
// queued Stop counts; a replacement's queued Stop is not ours to spend.
const _pendingCatchKey = streamSessionId + ':' + streamGeneration;
if (
_pendingRunStops.has(_pendingCatchKey)
&& abortCtrl
&& !abortCtrl.signal.aborted
) {
_pendingRunStops.delete(_pendingCatchKey);
abortCtrl._reason = 'user-stop';
abortCtrl.abort();
}
// Check if this stream was running in background — needed before any
// stop-state write, so an errored background stream can't clobber the
// foreground session's text.
@@ -4021,6 +4257,18 @@ import {
_closeOpenThinkingMarkup(_isBgCatch);
if (_isBgCatch) {
_cancelLiveThinkingWork();
// A canonical terminal event may have been persisted immediately
// before the stream moved into the background. Preserve that terminal
// state instead of allowing the catch path to turn it back into a
// running/error stream.
const bgTerminal = _backgroundStreams.get(streamSessionId);
if (bgTerminal && _terminalSavedStreams.has(streamSessionId)) {
bgTerminal.status = 'completed';
if (sessionModule && sessionModule.clearStreaming) {
sessionModule.clearStreaming(streamSessionId);
}
}
} else if (accumulated) {
_catchTerminalView = _finalizeInterruptedView();
} else {
@@ -4039,7 +4287,10 @@ import {
// Error happened while backgrounded — update map, don't touch DOM
console.error('Background stream error:', err);
var bgErr = _backgroundStreams.get(streamSessionId);
if (bgErr && bgErr.status === 'completed') {
if (bgErr && (
bgErr.status === 'completed' || _terminalSavedStreams.has(streamSessionId)
)) {
bgErr.status = 'completed';
// [DONE] was already processed — this error is benign (e.g. reader.read() after close)
// Don't override the completed status; just ensure the completed dot stays
if (sessionModule && sessionModule.clearStreaming) {
@@ -4191,8 +4442,36 @@ import {
// cap. Only auto-recover from connection-class failures; deterministic
// errors (unsupported tools, 4xx/5xx, parse failures) surface right away
// instead of burning the nudge budget on a guaranteed-to-fail retry.
if (!(_isRecoverableStreamErr(err) && _tryAutoRecover(_catchViewHolder, accumulated, streamSessionId))) {
const errorHolder = _catchViewHolder?.querySelector('.body') || document.querySelector('.msg-ai:last-of-type .body');
if (!(isRecoverableStreamError(err) && _tryAutoRecover(_catchViewHolder, accumulated, streamSessionId))) {
if (err.terminalStreamError) {
if (_canonicalTerminalSaved || accumulated.trim()) {
// Let this stream's finally block clear foreground state before
// reselecting; otherwise selectSession would detach the already
// terminal reader and leave a stale background-stream marker.
setTimeout(async () => {
if (sessionModule.getCurrentSessionId() === streamSessionId) {
await sessionModule.selectSession(streamSessionId, { showLoading: false });
} else {
await sessionModule.loadSessions();
}
}, 0);
} else {
const terminalBody =
_catchViewHolder?.querySelector('.body')
|| roundHolder?.querySelector('.body')
|| document.querySelector('.msg-ai:last-of-type .body');
if (terminalBody) {
const terminalNote = document.createElement('div');
terminalNote.style.cssText = 'color: var(--color-error); font-style: italic; padding: 4px 0;';
terminalNote.textContent = `[Error: ${err.message}]`;
terminalBody.appendChild(terminalNote);
}
}
return;
}
const errorHolder =
_catchViewHolder?.querySelector('.body')
|| document.querySelector('.msg-ai:last-of-type .body');
if (errorHolder) {
let errMsg = `Error: ${err.message}`;
// Add hint for tool-call errors
@@ -4209,23 +4488,52 @@ import {
clearResponseTimeout();
clearProcessingProbe();
clearFirstTokenWaitTimers();
_activeStreams.delete(streamSessionId);
if (_streamSessionId === streamSessionId) _streamSessionId = null;
_syncForegroundStreamGlobals();
// A replacement send bumps the session's generation the moment it
// starts, before it registers or reaches the server, so cleanup rights
// are decided by generation: a superseded send may remove only what it
// itself owns (its stream registration by controller identity, its own
// generation's queued Stop) and must leave session-level state — the
// reader session id, research marker, UI — to the replacement.
const _ownsStreamState =
_streamGenerations.get(streamSessionId) === streamGeneration;
const _finallyRegistered = _activeStreams.get(streamSessionId);
if (!_finallyRegistered || _finallyRegistered.abortCtrl === abortCtrl) {
_activeStreams.delete(streamSessionId);
}
_pendingRunStops.delete(streamSessionId + ':' + streamGeneration);
if (_ownsStreamState) {
if (_streamSessionId === streamSessionId) _streamSessionId = null;
if (_sendStates.get(streamSessionId) === _sendState) {
_sendStates.delete(streamSessionId);
}
// Superseded sends must not resync: with the replacement not yet
// registered, a stale sync would set isStreaming false and drop
// currentAbort while _sendInFlight is already false, reopening the
// send gate mid-preflight. The replacement syncs when it registers
// or finishes.
_syncForegroundStreamGlobals();
}
// Streaming done — let screen readers announce the settled response.
const _chatLogDone = document.getElementById('chat-history');
if (_chatLogDone) _chatLogDone.setAttribute('aria-busy', 'false');
// Always clean up research tracking regardless of background state
_researchingStreamIds.delete(streamSessionId);
if (_ownsStreamState) {
const _chatLogDone = document.getElementById('chat-history');
if (_chatLogDone) _chatLogDone.setAttribute('aria-busy', 'false');
}
// Research markers gate /api/research/cancel in the Stop handler, so a
// superseded send must not strip a replacement research run's marker.
if (_ownsStreamState) _researchingStreamIds.delete(streamSessionId);
if (_researchingStreamIds.size === 0) {
var _rToggleCleanup = document.getElementById('research-toggle-btn');
if (_rToggleCleanup) _rToggleCleanup.classList.remove('research-running');
}
// Only reset UI state if still on the stream's session and was never backgrounded
// Only reset UI state if still on the stream's session, never
// backgrounded, and no replacement stream owns the session now — the
// replacement disabled the composer for its own send, so re-enabling
// it here would hand input back mid-stream.
const _isBgFinally = (sessionModule.getCurrentSessionId() !== streamSessionId) || _backgroundStreams.has(streamSessionId);
if (_ownsStreamState) _terminalSavedStreams.delete(streamSessionId);
if (!_isBgFinally) {
if (!_isBgFinally && _ownsStreamState) {
// Reset button to idle state
updateSubmitButton('idle', submitBtn);
@@ -4320,69 +4628,64 @@ import {
// the server run — otherwise closing the tab would kill the background task,
// defeating the whole point. Only the Stop button cancels the server run.
export function abortCurrentRequest(stopServer = false) {
const _sid = (sessionModule && sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId())
|| _streamSessionId
|| (window.sessionModule && window.sessionModule.getCurrentSessionId && window.sessionModule.getCurrentSessionId());
// The CURRENT send's controller comes from its send state, installed at
// send commit — never borrowed from the stream registry, which during the
// replacement's preflight still holds the superseded send's entry.
// Aborting that older controller here would sever the only identity
// channel able to name the old run. A send committed but pre-POST has a
// null controller: the Stop queues and there is nothing to abort yet.
const _sendStateNow = _sid ? _sendStates.get(_sid) : null;
const active = _getForegroundStreamState();
const abortCtrl = active ? active.abortCtrl : currentAbort;
if (abortCtrl) {
abortCtrl.abort();
// Don't set to null here - let catch block handle it
}
const abortCtrl = _sendStateNow
? _sendStateNow.abortCtrl
: (active ? active.abortCtrl : currentAbort);
let abortNow = true;
if (stopServer) {
try {
const _sid = (sessionModule && sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId())
|| _streamSessionId
|| (window.sessionModule && window.sessionModule.getCurrentSessionId && window.sessionModule.getCurrentSessionId());
if (_sid) {
fetch(`/api/chat/stop/${encodeURIComponent(_sid)}`, { method: 'POST', credentials: 'same-origin' }).catch(() => {});
// Before response headers arrive there is no safe server-side stop
// identity yet. Keep the POST alive just long enough to receive that
// opaque id, then _rememberStreamRunId sends the exact Stop and aborts
// this reader. Never fall back to a headerless session-wide cancel.
abortNow = _stopExactRun(_sid, abortCtrl);
}
} catch (_) {}
}
if (abortCtrl && abortNow) {
abortCtrl.abort();
// Don't set to null here - let catch block handle it
}
}
// ── Stall watchdog ──────────────────────────────────────────────
// Auto-recover a turn whose stream died (connection drop) or went silent:
// preserve the partial, then re-submit a completion handshake by reusing the
// existing continue/resume path. Returns false at the cap so the caller can
// surface the failure instead of nudging forever.
// Auto-recover a turn whose browser stream died by reconnecting to the exact
// detached server run. Returns false at the cap so the caller can surface
// the failure instead of retrying forever.
// Only auto-recover from connection-class failures (the genuine "silently
// died" case). Deterministic errors — unsupported tools, HTTP 4xx/5xx, JSON
// parse failures — will fail identically on retry, so surfacing them
// immediately is both more honest and avoids wasting the nudge budget.
function _isRecoverableStreamErr(err) {
if (!err) return false;
if (err.name === 'TypeError') return true; // fetch/reader network failure
const m = (err.message || '').toLowerCase();
if (/\btool\b|unsupported|json|parse|\b4\d\d\b|\b5\d\d\b/.test(m)) return false;
return /network|fetch|connection|reset|closed|aborted|stream|tim(?:e|ed)\s?out|econn|eof/.test(m);
}
function _tryAutoRecover(holder, accumulated, sessionId) {
if (_autoNudges >= _AUTO_NUDGE_CAP) return false;
_autoNudges++;
if (holder && accumulated) {
holder.dataset.raw = accumulated;
}
_pendingContinue = holder || null; // merge the continuation into the same bubble
_hideUserBubble = true; // no user bubble for the handshake
_autoContinuePending = true; // don't reset the counter on this submit
const _abandon = () => { // clear the pending flags so they can't
_pendingContinue = null; // leak into whatever chat is now open
_hideUserBubble = false;
_autoContinuePending = false;
};
// Defer so the stream's finally resets state first — otherwise the send
// button is still in "stop" mode and clicking it would toggle, not send.
setTimeout(() => {
// The server run is detached and keeps its exact pinned model/tool state.
// Reconnect to that run instead of submitting a new user turn, which would
// cancel it, retry the selected model, and risk duplicating side effects.
setTimeout(async () => {
// The stream that died may not be the chat the user is now looking at —
// never inject the recovery handshake into the wrong conversation.
if (sessionId && sessionModule.getCurrentSessionId() !== sessionId) { _abandon(); return; }
const msgInput = uiModule.el('message');
const sb = document.querySelector('.send-btn');
if (!msgInput || !sb) { _abandon(); return; }
const tail = (accumulated || '').slice(-400);
msgInput.value = tail
? `The stream dropped before you finished. It ended with:\n\n${tail}\n\nIf the task is fully complete, reply with just: DONE. Otherwise continue exactly where you left off and finish it — do not repeat what you already wrote.`
: `The stream dropped before you produced anything. If the task is already done, reply with just: DONE. Otherwise complete it now.`;
sb.click();
// never attach the recovery reader to the wrong conversation.
if (sessionId && sessionModule.getCurrentSessionId() !== sessionId) return;
const resumed = await resumeStream(sessionId, holder || null);
if (!resumed && holder && holder.isConnected) {
const body = holder.querySelector('.body');
if (body) typewriterInto(body, 'Connection lost. The existing run could not be resumed.');
}
}, 200);
return true;
}
@@ -4545,9 +4848,13 @@ import {
// view must stop all delayed rendering immediately. The reader loop may not
// receive another SSE line for an arbitrary amount of time.
if (active.cancelViewWork) active.cancelViewWork();
// Store background stream state
const terminalSaved = _terminalSavedStreams.has(sessionId);
// Store background stream state. A canonical terminal event can precede
// its SSE error event; preserve completion if the user switches sessions
// during that gap instead of creating a fresh running/error marker.
_backgroundStreams.set(sessionId, {
status: 'running',
status: terminalSaved ? 'completed' : 'running',
accumulated: currentAccumulated,
sourcesHtml: '',
findingsData: null,
@@ -4556,8 +4863,10 @@ import {
metrics: null,
});
// Mark session with pulsing dot in sidebar
if (sessionModule && sessionModule.markStreaming) {
if (!terminalSaved && sessionModule && sessionModule.markStreaming) {
sessionModule.markStreaming(sessionId);
} else if (terminalSaved && sessionModule && sessionModule.clearStreaming) {
sessionModule.clearStreaming(sessionId);
}
// Clear local state WITHOUT aborting the fetch
if (currentAbort === active.abortCtrl) currentAbort = null;
@@ -4584,7 +4893,7 @@ import {
* reloaded from the DB so its full render stays faithful. Returns true if it
* attached, false to let the caller fall back to spinner+poll.
*/
export async function resumeStream(sessionId) {
export async function resumeStream(sessionId, replaceHolder = null) {
if (!sessionId) return false;
if (hasActiveStream(sessionId)) return false;
@@ -4595,9 +4904,12 @@ import {
return false;
}
if (!res.ok || !res.body) return false;
const resumeRunId = res.headers.get('X-Odysseus-Run-Id') || '';
if (resumeRunId) _streamRunIds.set(sessionId, resumeRunId);
const box = document.getElementById('chat-history');
if (!box) return false;
if (replaceHolder && replaceHolder.parentNode) replaceHolder.remove();
// Block duplicate re-attach attempts while this reader is live. A dedicated
// set (not _backgroundStreams) so checkBackgroundStream doesn't mistake this
@@ -4612,6 +4924,8 @@ import {
holder.innerHTML = '<div class="role">' + uiModule.esc(roleLabel) +
' <span class="role-timestamp">' + roleTs + '</span></div>' +
'<div class="body"><div class="stream-content"></div></div>';
holder._requestedModel = meta && meta.model;
holder._actualModel = holder._requestedModel;
_applyModelColor(holder.querySelector('.role'), meta && meta.model);
const contentDiv = holder.querySelector('.stream-content');
box.appendChild(holder);
@@ -4629,6 +4943,8 @@ import {
let gotDelta = false;
let leftSession = false;
let metricsData = null;
let replayError = null;
let canonicalTerminalSeen = false;
// "Rich" responses (tool calls, sources, doc streaming, multi-round) need the
// full canonical render, which is rebuilt from the saved DB record on reload.
// Plain text replies can be finalized in place without a reload.
@@ -4665,6 +4981,8 @@ import {
const parts = buffer.split('\n\n');
buffer = parts.pop();
for (const part of parts) {
const eventIsError = part.split('\n').some(l => l.trim() === 'event: error');
if (eventIsError) rich = true;
const line = part.split('\n').find(l => l.startsWith('data: '));
if (!line) continue;
const payload = line.slice(6);
@@ -4674,7 +4992,9 @@ import {
}
let json;
try { json = JSON.parse(payload); } catch (_) { continue; }
if (json.delta) {
if (eventIsError) {
replayError = createTerminalStreamError(json);
} else if (json.delta) {
roundText += json.delta;
if (!docFenceOpened && (roundText.includes('```create_document\n') || roundText.includes('```document\n') || roundText.includes('```documen\n'))) {
docFenceOpened = true;
@@ -4690,6 +5010,64 @@ import {
if (documentModule) documentModule.streamDocDelta(json.content || json.delta || '');
} else if (json.type === 'metrics') {
metricsData = json.data || metricsData;
if (metricsData && resumeRunId) {
metricsData._costRecordId = _metricsCostRecordId(resumeRunId, json);
}
if (metricsData) {
chatRenderer.recordSessionMetricsCost(metricsData, sessionId);
}
} else if (json.type === 'fallback') {
// Replay can attach after the selected route has already failed.
// Reflect the fallback immediately, then reload the canonical
// multi-round record when the detached run completes.
rich = true;
const fallbackHolder = applyModelRouteEventState(json, holder, null, meta && meta.model);
if (fallbackHolder) {
_setRoleModelLabel(
fallbackHolder.querySelector('.role'),
fallbackHolder._requestedModel,
fallbackHolder._actualModel,
{
reason: json.reason,
requestedEndpointId: fallbackHolder._requestedEndpointId,
requestedEndpointLabel: fallbackHolder._requestedEndpointLabel,
actualEndpointId: fallbackHolder._actualEndpointId,
actualEndpointLabel: fallbackHolder._actualEndpointLabel,
},
);
}
uiModule.showToast(
'Fallback: ' + _shortModel(json.selected_model || '') + ' failed — answered by ' +
_shortModel(json.answered_by || ''),
6000,
);
} else if (json.type === 'model_actual') {
rich = true;
const modelHolder = applyModelRouteEventState(json, holder, null, meta && meta.model);
if (modelHolder) {
_setRoleModelLabel(
modelHolder.querySelector('.role'),
modelHolder._requestedModel,
modelHolder._actualModel,
{
requestedEndpointId: modelHolder._requestedEndpointId,
requestedEndpointLabel: modelHolder._requestedEndpointLabel,
actualEndpointId: modelHolder._actualEndpointId,
actualEndpointLabel: modelHolder._actualEndpointLabel,
},
);
}
} else if (json.type === 'agent_terminal' || json.type === 'chat_terminal') {
// The server has already persisted canonical partial content plus
// a sanitized failure note and actual route provenance. Do not
// finalize replayed deltas as a successful local-only answer.
rich = true;
canonicalTerminalSeen = true;
metricsData = json.data || metricsData;
if (metricsData && resumeRunId) {
metricsData._costRecordId = _metricsCostRecordId(resumeRunId, json);
}
if (metricsData) displayMetrics(holder, metricsData);
} else if (json.type === 'tool_start' || json.type === 'tool_output' ||
json.type === 'tool_progress' || json.type === 'agent_step' ||
json.type === 'web_sources' || json.type === 'rag_sources' ||
@@ -4700,7 +5078,8 @@ import {
}
}
} catch (e) {
// Network drop or parse failure: fall through to the reload below.
// Network drop or parse failure: fall through to the canonical reload.
rich = true;
}
cleanup();
@@ -4710,6 +5089,18 @@ import {
const onThisSession = sessionModule.getCurrentSessionId &&
sessionModule.getCurrentSessionId() === sessionId;
// A failure before substantive output has no persisted assistant record to
// recover through a canonical reload. Keep its sanitized provider/request
// error visible in the replay holder instead of deleting the only evidence.
if (onThisSession && replayError && !canonicalTerminalSeen) {
const errorDiv = document.createElement('div');
errorDiv.style.cssText = 'color: var(--color-error); font-style: italic; padding: 4px 0;';
errorDiv.textContent = `[Error: ${replayError.message}]`;
contentDiv.appendChild(errorDiv);
uiModule.scrollHistory();
return true;
}
// Plain text reply: finalize in place. Replace the live bubble with a
// canonical single message (markdown + footer actions + metrics) using the
// same renderer history does. No history refetch, no end-of-stream flicker.
@@ -4726,6 +5117,9 @@ import {
// reload from the DB for the full canonical render.
if (holder._docWritingThread && holder._docWritingThread.parentNode) holder._docWritingThread.remove();
if (holder.parentNode) holder.remove();
if (metricsData) {
chatRenderer.recordSessionMetricsCost(metricsData, sessionId);
}
if (onThisSession) sessionModule.selectSession(sessionId);
else sessionModule.loadSessions();
return true;
+104
View File
@@ -0,0 +1,104 @@
/** Select and update the response holder for a route-provenance event. */
export function applyModelRouteEventState(event, holder, roundHolder, defaultModel = '') {
const target = event && event.round && roundHolder ? roundHolder : holder;
if (!target) return null;
target._requestedModel = (
event.requested_model
|| event.selected_model
|| target._requestedModel
|| defaultModel
);
target._actualModel = (
event.model
|| event.answered_by
|| target._actualModel
|| target._requestedModel
);
const hasEndpointRoute = Boolean(
event.requested_endpoint_id
|| event.selected_endpoint_id
|| event.endpoint_id
|| event.answered_by_endpoint_id
|| event.requested_endpoint_label
|| event.selected_endpoint_label
|| event.endpoint_label
|| event.answered_by_endpoint_label
|| target._requestedEndpointLabel
);
if (hasEndpointRoute) {
target._requestedEndpointId = (
event.requested_endpoint_id
|| event.selected_endpoint_id
|| target._requestedEndpointId
|| null
);
target._requestedEndpointLabel = (
event.requested_endpoint_label
|| event.selected_endpoint_label
|| target._requestedEndpointLabel
|| 'Selected route'
);
target._actualEndpointId = (
event.endpoint_id
|| event.answered_by_endpoint_id
|| target._actualEndpointId
|| target._requestedEndpointId
|| null
);
target._actualEndpointLabel = (
event.endpoint_label
|| event.answered_by_endpoint_label
|| target._actualEndpointLabel
|| target._requestedEndpointLabel
);
}
return target;
}
/** Copy the active route into the bubble created for the next Agent round. */
export function inheritModelRouteState(holder, roundHolder, target, defaultModel = '') {
if (!target) return null;
const source = roundHolder || holder;
target._requestedModel = source?._requestedModel || defaultModel;
target._actualModel = source?._actualModel || target._requestedModel;
if (source?._requestedEndpointLabel || source?._actualEndpointLabel) {
target._requestedEndpointId = source?._requestedEndpointId || null;
target._requestedEndpointLabel = source?._requestedEndpointLabel || 'Selected route';
target._actualEndpointId = source?._actualEndpointId || target._requestedEndpointId;
target._actualEndpointLabel = source?._actualEndpointLabel || target._requestedEndpointLabel;
}
return target;
}
/** Apply final/metrics provenance to the active round, not the first bubble. */
export function applyModelMetricsState(metrics, holder, roundHolder, defaultModel = '') {
const target = roundHolder || holder;
if (!target || !metrics) return target || null;
const roundModels = Array.isArray(metrics.round_models) ? metrics.round_models : [];
const roundModel = roundHolder && roundModels.length
? roundModels[roundModels.length - 1]
: null;
target._requestedModel = metrics.requested_model || target._requestedModel || defaultModel;
target._actualModel = roundModel || metrics.model || target._actualModel || target._requestedModel;
const roundEndpointIds = Array.isArray(metrics.round_endpoint_ids) ? metrics.round_endpoint_ids : [];
const roundEndpointLabels = Array.isArray(metrics.round_endpoint_labels) ? metrics.round_endpoint_labels : [];
if (
metrics.requested_endpoint_label
|| metrics.endpoint_label
|| roundEndpointLabels.length
|| target._requestedEndpointLabel
) {
target._requestedEndpointId = metrics.requested_endpoint_id || target._requestedEndpointId || null;
target._requestedEndpointLabel = metrics.requested_endpoint_label || target._requestedEndpointLabel || 'Selected route';
const hasRoundEndpointId = Boolean(roundHolder && roundEndpointIds.length);
const hasRoundEndpointLabel = Boolean(roundHolder && roundEndpointLabels.length);
target._actualEndpointId = hasRoundEndpointId
? roundEndpointIds[roundEndpointIds.length - 1]
: (metrics.endpoint_id || target._actualEndpointId || target._requestedEndpointId);
target._actualEndpointLabel = hasRoundEndpointLabel
? roundEndpointLabels[roundEndpointLabels.length - 1]
: (metrics.endpoint_label || target._actualEndpointLabel || target._requestedEndpointLabel);
}
return target;
}
+254 -46
View File
@@ -615,10 +615,36 @@ export function sameModelName(left, right) {
|| shortModel(a).toLowerCase() === shortModel(b).toLowerCase();
}
export function modelRouteLabel(requestedModel, actualModel) {
function shortEndpointLabel(label) {
const value = modelValue(label);
if (!value) return '';
return value.length > 18 ? value.slice(0, 17) + '…' : value;
}
export function modelRouteLabel(
requestedModel,
actualModel,
requestedEndpointLabel = '',
actualEndpointLabel = '',
requestedEndpointId = '',
actualEndpointId = '',
) {
const requested = modelValue(requestedModel);
const actual = modelValue(actualModel) || requested;
if (!requested || sameModelName(requested, actual)) return shortModel(actual || requested);
const requestedRoute = modelValue(requestedEndpointId || requestedEndpointLabel);
const actualRoute = modelValue(actualEndpointId || actualEndpointLabel);
const routeChanged = Boolean(
actualRoute
&& requestedRoute
&& actualRoute !== requestedRoute
);
if (!requested || sameModelName(requested, actual)) {
const model = shortModel(actual || requested);
if (!routeChanged) return model;
const from = shortEndpointLabel(requestedEndpointLabel || 'Selected route');
const to = shortEndpointLabel(actualEndpointLabel || actualEndpointId);
return model + ' (' + from + ' -> ' + to + ')';
}
return shortModel(requested) + ' -> ' + shortModel(actual);
}
@@ -629,10 +655,24 @@ export function replyModelPair(modelName, metadata) {
if (actualFromMeta || requestedFromMeta) {
const actual = actualFromMeta || requestedFromMeta || modelValue(modelName);
const requested = requestedFromMeta || actual;
return { requestedModel: requested, actualModel: actual };
return {
requestedModel: requested,
actualModel: actual,
requestedEndpointId: meta.requested_endpoint_id || null,
requestedEndpointLabel: meta.requested_endpoint_label || 'Selected route',
actualEndpointId: meta.endpoint_id || null,
actualEndpointLabel: meta.endpoint_label || meta.requested_endpoint_label || 'Selected route',
};
}
const fallback = modelValue(modelName);
return { requestedModel: fallback, actualModel: fallback };
return {
requestedModel: fallback,
actualModel: fallback,
requestedEndpointId: null,
requestedEndpointLabel: 'Selected route',
actualEndpointId: null,
actualEndpointLabel: 'Selected route',
};
}
/**
@@ -824,12 +864,50 @@ export function isCostTrackedEndpoint(url) {
}
/** Cost for the current turn, returning null for non-billable endpoints. */
function _billableCost(model, inputTokens, outputTokens) {
const url = _currentEndpointUrl();
if (!isCostTrackedEndpoint(url)) return null;
function _billableCost(model, inputTokens, outputTokens, endpointCostTracked, selectedEndpointUrl) {
// Foreground fallback can answer on a different endpoint than the session's
// selected route. Prefer the backend's non-secret actual-route
// classification; retain the selected-endpoint check for older history.
if (endpointCostTracked === false) return null;
const selectedUrl = selectedEndpointUrl === undefined
? _currentEndpointUrl()
: selectedEndpointUrl;
if (endpointCostTracked !== true && !isCostTrackedEndpoint(selectedUrl)) {
return null;
}
return getModelCost(model, inputTokens, outputTokens);
}
/** Sum cost using the route/model that produced each Agent round. */
function _metricsBillableCost(metrics, model, inputTokens, outputTokens, selectedEndpointUrl) {
const buckets = Array.isArray(metrics.usage_buckets) ? metrics.usage_buckets : [];
if (!buckets.length) {
return _billableCost(
model,
inputTokens,
outputTokens,
metrics.endpoint_cost_tracked,
selectedEndpointUrl,
);
}
let total = 0;
let hasPricedUsage = false;
for (const bucket of buckets) {
if (!bucket || typeof bucket !== 'object') continue;
const bucketCost = _billableCost(
bucket.model || model,
Number(bucket.input_tokens) || 0,
Number(bucket.output_tokens) || 0,
bucket.endpoint_cost_tracked,
selectedEndpointUrl,
);
if (bucketCost === null) continue;
total += bucketCost;
hasPricedUsage = true;
}
return hasPricedUsage ? total : null;
}
export function getImageCost(model, quality, size) {
if (!model) return null;
const m = model.toLowerCase();
@@ -844,6 +922,9 @@ export function getImageCost(model, quality, size) {
/* ── Session cost helpers ─────────────────────────────────────────── */
const _COST_KEY = 'ody-session-cost';
const _COST_RUNS_KEY = 'ody-session-cost-runs';
const _MAX_COST_RUNS_PER_SESSION = 256;
const _COST_LEDGER_LOCK = 'odysseus-session-cost-ledger';
/** Return the accumulated cost for the current (or given) session. */
export function getSessionCost(sessionId) {
@@ -851,7 +932,14 @@ export function getSessionCost(sessionId) {
if (!sid) return 0;
try {
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
return costs[sid] || 0;
const runCosts = JSON.parse(localStorage.getItem(_COST_RUNS_KEY) || '{}');
const recordedRuns = runCosts[sid] && typeof runCosts[sid] === 'object'
? Object.values(runCosts[sid])
: [];
return (costs[sid] || 0) + recordedRuns.reduce(
(total, value) => total + (Number(value) || 0),
0,
);
} catch (_e) { return 0; }
}
@@ -863,6 +951,9 @@ export function resetSessionCost(sessionId) {
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
delete costs[sid];
localStorage.setItem(_COST_KEY, JSON.stringify(costs));
const runCosts = JSON.parse(localStorage.getItem(_COST_RUNS_KEY) || '{}');
delete runCosts[sid];
localStorage.setItem(_COST_RUNS_KEY, JSON.stringify(runCosts));
} catch (_e) { /* ignore */ }
updateSessionCostUI();
}
@@ -871,21 +962,8 @@ export function resetSessionCost(sessionId) {
export function updateSessionCostUI() {
const el = document.getElementById('session-cost-display');
if (!el) return;
// Non-billable endpoint? Hide the badge and clear stale cost that a previous
// cloud-rate calculation may have left in localStorage for this session.
const _url = _currentEndpointUrl();
if (!isCostTrackedEndpoint(_url)) {
const sid = window.sessionModule && window.sessionModule.getCurrentSessionId();
if (sid && getSessionCost(sid) > 0) {
try {
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
delete costs[sid];
localStorage.setItem(_COST_KEY, JSON.stringify(costs));
} catch (_e) { /* ignore */ }
}
el.style.display = 'none';
return;
}
// The ledger records billable work already performed in this session. A
// selected local endpoint does not erase cost from a paid fallback route.
const cost = getSessionCost();
if (cost > 0) {
el.textContent = '$' + (cost < 0.01 ? cost.toFixed(4) : cost < 1 ? cost.toFixed(3) : cost.toFixed(2));
@@ -895,6 +973,94 @@ export function updateSessionCostUI() {
}
}
/** Record one metrics payload in a session ledger at most once. */
export function recordSessionMetricsCost(metrics, sessionId, selectedEndpointUrl) {
if (!metrics || typeof metrics !== 'object') return null;
const cost = _metricsBillableCost(
metrics,
metrics.model || 'Unknown',
metrics.input_tokens || 0,
metrics.output_tokens || 0,
selectedEndpointUrl,
);
if (metrics._fromHistory) return cost;
const sid = sessionId || (
window.sessionModule && window.sessionModule.getCurrentSessionId()
);
if (!sid || cost === null) return cost;
const runId = typeof metrics._costRecordId === 'string'
? metrics._costRecordId.trim()
: '';
if ((metrics._costRecorded || metrics._costRecordPending) && !runId) return cost;
// Recorded is only set once the write actually runs; pending covers the
// window while the write waits on the cross-tab lock, so a replay in that
// window cannot double-add and a tab closed mid-queue never claims recorded.
metrics._costRecordPending = true;
const writeCost = () => {
if (runId) {
try {
const runCosts = JSON.parse(localStorage.getItem(_COST_RUNS_KEY) || '{}');
const sessionRuns = runCosts[sid] && typeof runCosts[sid] === 'object'
? runCosts[sid]
: {};
// Assigning by detached-run identity is replay-idempotent even when a
// refresh produces a fresh metrics object. The Web Lock around this
// read/modify/write also keeps distinct runs from two tabs from
// overwriting one another's stale snapshot.
sessionRuns[runId] = cost;
const entries = Object.entries(sessionRuns);
if (entries.length > _MAX_COST_RUNS_PER_SESSION) {
const overflow = entries.slice(0, entries.length - _MAX_COST_RUNS_PER_SESSION);
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
costs[sid] = (costs[sid] || 0) + overflow.reduce(
(total, entry) => total + (Number(entry[1]) || 0),
0,
);
overflow.forEach(([oldRunId]) => delete sessionRuns[oldRunId]);
localStorage.setItem(_COST_KEY, JSON.stringify(costs));
}
runCosts[sid] = sessionRuns;
localStorage.setItem(_COST_RUNS_KEY, JSON.stringify(runCosts));
} catch (_e) { /* ignore */ }
} else {
try {
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
costs[sid] = (costs[sid] || 0) + cost;
localStorage.setItem(_COST_KEY, JSON.stringify(costs));
} catch (_e) { /* ignore */ }
}
metrics._costRecorded = true;
metrics._costRecordPending = false;
const currentSid = window.sessionModule && window.sessionModule.getCurrentSessionId();
if (currentSid === sid) updateSessionCostUI();
};
let writeStarted = false;
const guardedWrite = () => {
writeStarted = true;
writeCost();
};
try {
if (
typeof navigator !== 'undefined'
&& navigator.locks
&& typeof navigator.locks.request === 'function'
) {
const pendingWrite = navigator.locks.request(_COST_LEDGER_LOCK, guardedWrite);
if (pendingWrite && typeof pendingWrite.catch === 'function') {
pendingWrite.catch(() => {
if (!writeStarted) guardedWrite();
});
}
} else {
guardedWrite();
}
} catch (_e) {
if (!writeStarted) guardedWrite();
}
return cost;
}
/** Create a timestamp span for role labels.
* Pass an ISO string / Date / epoch-ms to render the message's own time
* (used when replaying history). Falls back to "now" when no value is given. */
@@ -1874,23 +2040,19 @@ export function displayMetrics(messageElement, metrics) {
const isReal = metrics.usage_source === 'real';
const ctxPct = metrics.context_percent;
const model = metrics.model || 'Unknown';
const cost = _billableCost(model, inputTokens, outputTokens);
const cost = _metricsBillableCost(
metrics,
model,
inputTokens,
outputTokens,
);
// Nothing useful to show — bail out (only if ALL metrics are missing)
if (!responseTime && !inputTokens && !outputTokens && tps == null && !ctxPct) return;
// Accumulate session cost (only on fresh metrics, not history reload)
if (!metrics._fromHistory) {
const _sid = window.sessionModule && window.sessionModule.getCurrentSessionId();
if (_sid && cost !== null) {
try {
const _costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
_costs[_sid] = (_costs[_sid] || 0) + cost;
localStorage.setItem(_COST_KEY, JSON.stringify(_costs));
} catch (_e) { /* ignore */ }
updateSessionCostUI();
}
}
// Rendering can occur when metrics arrive and again after [DONE]. The
// ledger mutation is idempotent for that shared payload.
recordSessionMetricsCost(metrics);
// Keep token counts in the Message Stats popup; the footer should stay slim.
const costStr0 = cost !== null ? `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}` : null;
@@ -2307,9 +2469,19 @@ export function addMessage(role, content, modelName, metadata) {
const textRaw = Array.isArray(content) ? markdownModule.renderContent(content) : content;
// --- Agent multi-bubble reconstruction from saved metadata ---
if (role === 'assistant' && metadata && metadata.tool_events && metadata.tool_events.length > 0) {
if (
role === 'assistant'
&& metadata
&& (
(Array.isArray(metadata.tool_events) && metadata.tool_events.length > 0)
|| (Array.isArray(metadata.round_texts) && metadata.round_texts.length > 1)
)
) {
const roundTexts = metadata.round_texts || [];
const toolEvents = metadata.tool_events;
const roundModels = metadata.round_models || [];
const roundEndpointIds = metadata.round_endpoint_ids || [];
const roundEndpointLabels = metadata.round_endpoint_labels || [];
const toolEvents = metadata.tool_events || [];
let pendingAskUser = null;
let lastWrap = null;
let firstMsgAi = null;
@@ -2322,7 +2494,8 @@ export function addMessage(role, content, modelName, metadata) {
toolsByRound[r].push(ev);
}
const maxRound = Math.max(...Object.keys(toolsByRound).map(Number), roundTexts.length);
const toolRounds = Object.keys(toolsByRound).map(Number);
const maxRound = Math.max(toolRounds.length ? Math.max(...toolRounds) : 0, roundTexts.length);
for (let r = 0; r < maxRound; r++) {
const roundNum = r + 1;
@@ -2334,10 +2507,31 @@ export function addMessage(role, content, modelName, metadata) {
const roleEl = document.createElement('div');
roleEl.className = 'role';
const pair = replyModelPair(modelName, metadata);
const contModel = pair.actualModel || pair.requestedModel;
roleEl.textContent = modelRouteLabel(pair.requestedModel, contModel);
if (pair.requestedModel && contModel && !sameModelName(pair.requestedModel, contModel)) {
roleEl.title = pair.requestedModel + ' -> ' + contModel;
const contModel = roundModels[r] || pair.actualModel || pair.requestedModel;
const contEndpointId = r < roundEndpointIds.length
? roundEndpointIds[r]
: pair.actualEndpointId;
const contEndpointLabel = r < roundEndpointLabels.length
? roundEndpointLabels[r]
: pair.actualEndpointLabel;
roleEl.textContent = modelRouteLabel(
pair.requestedModel,
contModel,
pair.requestedEndpointLabel,
contEndpointLabel,
pair.requestedEndpointId,
contEndpointId,
);
if (
pair.requestedModel
&& contModel
&& (
!sameModelName(pair.requestedModel, contModel)
|| (pair.requestedEndpointId && contEndpointId && pair.requestedEndpointId !== contEndpointId)
)
) {
roleEl.title = pair.requestedModel + ' -> ' + contModel
+ ' (' + pair.requestedEndpointLabel + ' -> ' + contEndpointLabel + ')';
}
applyModelColor(roleEl, contModel);
if (r === 0) roleEl.appendChild(roleTimestamp(metadata?.timestamp));
@@ -2492,7 +2686,14 @@ export function addMessage(role, content, modelName, metadata) {
const isCompacted = metadata?.compacted;
const replyModels = replyModelPair(modelName, metadata);
const resolvedModel = replyModels.actualModel || replyModels.requestedModel;
var _roleText = role === 'user' ? 'You' : (isSlash || isCompacted) ? 'Odysseus' : modelRouteLabel(replyModels.requestedModel, resolvedModel);
var _roleText = role === 'user' ? 'You' : (isSlash || isCompacted) ? 'Odysseus' : modelRouteLabel(
replyModels.requestedModel,
resolvedModel,
replyModels.requestedEndpointLabel,
replyModels.actualEndpointLabel,
replyModels.requestedEndpointId,
replyModels.actualEndpointId,
);
if (role === 'assistant' && (metadata?.research || metadata?.research_clarification)) {
_roleText += ' (Research)';
}
@@ -2503,8 +2704,14 @@ export function addMessage(role, content, modelName, metadata) {
}
r.textContent = _roleText;
if (role !== 'user') {
if (!isSlash && !isCompacted && replyModels.requestedModel && resolvedModel && !sameModelName(replyModels.requestedModel, resolvedModel)) {
r.title = replyModels.requestedModel + ' -> ' + resolvedModel;
const endpointChanged = Boolean(
replyModels.requestedEndpointId
&& replyModels.actualEndpointId
&& replyModels.requestedEndpointId !== replyModels.actualEndpointId
);
if (!isSlash && !isCompacted && replyModels.requestedModel && resolvedModel && (!sameModelName(replyModels.requestedModel, resolvedModel) || endpointChanged)) {
r.title = replyModels.requestedModel + ' -> ' + resolvedModel
+ ' (' + replyModels.requestedEndpointLabel + ' -> ' + replyModels.actualEndpointLabel + ')';
}
if (!isSlash && !isCompacted) applyModelColor(r, resolvedModel);
r.appendChild(roleTimestamp(metadata?.timestamp));
@@ -2788,6 +2995,7 @@ const chatRenderer = {
getSessionCost,
resetSessionCost,
updateSessionCostUI,
recordSessionMetricsCost,
roleTimestamp,
stripToolBlocks,
copyMessageText,
+23
View File
@@ -0,0 +1,23 @@
/** Build a terminal stream error while preserving provider-supplied text. */
export function createTerminalStreamError(payload = {}) {
const rawError = payload.error;
const message = (
payload.text
|| (typeof rawError === 'string' ? rawError : rawError?.message)
|| `Error ${payload.status || 'unknown'}`
);
const error = new Error(message);
error.name = 'TerminalStreamError';
error.terminalStreamError = true;
error.status = payload.status;
return error;
}
/** Only connection-class stream failures are safe to resubmit automatically. */
export function isRecoverableStreamError(error) {
if (!error || error.terminalStreamError || error.name === 'TerminalStreamError') return false;
if (error.name === 'TypeError') return true;
const message = (error.message || '').toLowerCase();
if (/\btool\b|unsupported|json|parse|\b4\d\d\b|\b5\d\d\b/.test(message)) return false;
return /network|fetch|connection|reset|closed|aborted|stream|tim(?:e|ed)\s?out|econn|eof/.test(message);
}
-73
View File
@@ -445,14 +445,7 @@ async function initDefaultChat() {
var epSel = el('set-defaultEpSelect');
var modelSel = el('set-defaultModelSelect');
var msg = el('set-defaultChatMsg');
var fbContainer = el('set-defaultFallbacks');
var addFbBtn = el('set-defaultAddFallback');
var _endpoints = [];
var _fallbacks = []; // Hidden legacy DOM hook; stored values are not loaded or saved.
function enabledEndpoints() {
return _endpoints.filter(function(e) { return e.is_enabled; });
}
// Fill any <select> with the models for a given endpoint id.
function fillModels(selectEl, epId, selected) {
@@ -469,64 +462,6 @@ async function initDefaultChat() {
function refreshEndpointOptions(selectedEndpoint, selectedModel) {
_fillEndpointSelect(epSel, _endpoints, selectedEndpoint !== undefined ? selectedEndpoint : epSel.value, false);
refreshModels(selectedModel !== undefined ? selectedModel : modelSel.value);
renderFallbacks();
}
// Render the fallback chain. Each row is endpoint + model + remove.
function renderFallbacks() {
fbContainer.innerHTML = '';
_fallbacks.forEach(function(fb, idx) {
var row = document.createElement('div');
row.className = 'settings-fallback-row';
var num = document.createElement('span');
num.className = 'settings-fallback-num';
num.textContent = (idx + 1) + '.';
var epS = document.createElement('select');
epS.className = 'settings-select';
enabledEndpoints().forEach(function(ep) {
var o = document.createElement('option');
o.value = ep.id;
o.textContent = ep.name + (ep.online ? '' : ' (offline)');
epS.appendChild(o);
});
var first = enabledEndpoints()[0];
epS.value = fb.endpoint_id || (first ? first.id : '');
var mS = document.createElement('select');
mS.className = 'settings-select';
fillModels(mS, epS.value, fb.model);
// Keep the model in sync with the values actually shown.
fb.endpoint_id = epS.value;
fb.model = mS.value;
epS.addEventListener('change', function() {
fb.endpoint_id = epS.value;
fillModels(mS, epS.value, '');
fb.model = mS.value;
saveDefault();
});
mS.addEventListener('change', function() { fb.model = mS.value; saveDefault(); });
var rm = document.createElement('button');
rm.type = 'button';
rm.className = 'settings-fallback-remove';
rm.title = 'Remove fallback';
rm.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>';
rm.addEventListener('click', function() {
_fallbacks.splice(idx, 1);
renderFallbacks();
saveDefault();
});
row.appendChild(num);
row.appendChild(epS);
row.appendChild(mS);
row.appendChild(rm);
fbContainer.appendChild(row);
});
}
try {
@@ -534,7 +469,6 @@ async function initDefaultChat() {
var settings = await res.json();
if (settings.default_endpoint_id) epSel.value = settings.default_endpoint_id;
refreshModels(settings.default_model || '');
renderFallbacks();
} catch (e) { console.warn('Failed to load default chat settings', e); }
epSel.addEventListener('change', function() { refreshModels(''); saveDefault(); });
@@ -554,13 +488,6 @@ async function initDefaultChat() {
} catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; }
}
if (addFbBtn) addFbBtn.addEventListener('click', function() {
var first = enabledEndpoints()[0];
_fallbacks.push({ endpoint_id: first ? first.id : '', model: '' });
renderFallbacks();
saveDefault();
});
_registerAiEndpointRefresh(function(endpoints) {
_endpoints = endpoints;
refreshEndpointOptions(epSel.value, modelSel.value);
+6 -6
View File
@@ -2027,12 +2027,12 @@ async function _cmdUsage(args, ctx) {
const messageCount = Number(session?.message_count || 0);
const totalTokens = Number(session?.total_tokens || 0);
const costTracked = chatRenderer.isCostTrackedEndpoint ? chatRenderer.isCostTrackedEndpoint(endpointUrl) : true;
const cost = costTracked && chatRenderer.getSessionCost ? Number(chatRenderer.getSessionCost(sid) || 0) : 0;
const costLine = costTracked
? (cost > 0
? `Estimated local cost: $${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}`
: 'Estimated local cost: unavailable or zero')
: 'Estimated local cost: not tracked for this endpoint';
const cost = chatRenderer.getSessionCost ? Number(chatRenderer.getSessionCost(sid) || 0) : 0;
const costLine = cost > 0
? `Estimated local cost: $${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}`
: costTracked
? 'Estimated local cost: unavailable or zero'
: 'Estimated local cost: no billable usage recorded';
slashReply(`<pre>${[
`Session: ${ctx.esc(session?.name || 'Current chat')}`,
+394
View File
@@ -0,0 +1,394 @@
"""Regression guard for #5558 — POST /api/personal/add_directory must not run
the indexing job on the event loop.
The handler is ``async def`` but called ``rag.index_personal_documents``
(os.walk + file reads + per-chunk embedding + Chroma inserts) inline, so
FastAPI ran the whole job on the event loop and every other request queued
behind it: indexing a real directory froze the UI and API for 25+ minutes.
``personal_docs_manager.add_directory`` sits in the same blocking section it
triggers ``refresh_index()``, which re-extracts text across tracked dirs.
These tests build the real router with fake managers and compare the thread
the indexing work runs on against the event loop's thread.
"""
import asyncio
import os
import threading
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
import httpx
from fastapi import FastAPI
from fastapi.testclient import TestClient
def _serialization_probe():
"""Shared counter proving two critical sections never overlap."""
state = {"active": 0, "max_active": 0}
lock = threading.Lock()
def enter():
with lock:
state["active"] += 1
state["max_active"] = max(state["max_active"], state["active"])
def leave():
with lock:
state["active"] -= 1
return state, enter, leave
# Concurrency tests are `async def` (pyproject asyncio_mode="auto") and drive the
# ASGI app through httpx.ASGITransport + AsyncClient + asyncio.gather, NOT starlette
# TestClient + ThreadPoolExecutor: the job lock is an asyncio.Lock acquired in the
# async handler, and TestClient's portal-thread dispatch deadlocks against it (same
# reason test_notes_fail_closed_auth.py uses ASGITransport). asyncio.gather runs both
# requests on the test's own loop.
def _async_client(app):
return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://t")
import routes.personal_routes as personal_routes
from core.middleware import require_admin
from src.auth_helpers import require_user
class _FakeRag:
def __init__(self, record):
self._record = record
def index_personal_documents(self, directory, owner=None):
self._record["index_thread"] = threading.get_ident()
return {"success": True, "indexed_count": 3, "failed_count": 0}
def _split_into_chunks(self, text, chunk_size=500):
return [text]
def add_document(self, chunk, metadata):
self._record["add_document_thread"] = threading.get_ident()
return True
def delete_by_source(self, filepath):
self._record["delete_thread"] = threading.get_ident()
return 1
class _FakeDocsManager:
def __init__(self, record):
self._record = record
self.index = []
def add_directory(self, directory, *, index=True, owner=None):
self._record["bookkeeping_thread"] = threading.get_ident()
self._record["bookkeeping_index_flag"] = index
def exclude_file(self, filepath):
self._record["exclude_thread"] = threading.get_ident()
def _build_app(tmp_path, monkeypatch, record):
monkeypatch.setattr(personal_routes, "PERSONAL_DIR", str(tmp_path))
monkeypatch.setattr(personal_routes, "get_rag_manager", lambda: _FakeRag(record))
app = FastAPI()
app.include_router(
personal_routes.setup_personal_routes(_FakeDocsManager(record), None, True)
)
app.dependency_overrides[require_user] = lambda: "tester"
app.dependency_overrides[require_admin] = lambda: None
@app.get("/loop-thread")
async def loop_thread_probe():
return {"thread": threading.get_ident()}
return app
def test_indexing_runs_off_the_event_loop(tmp_path, monkeypatch):
record = {}
app = _build_app(tmp_path, monkeypatch, record)
target = tmp_path / "docs"
target.mkdir()
# Context-manager client: one portal/event loop serves both requests, so
# the probe and the POST are guaranteed to see the same loop thread.
with TestClient(app) as client:
loop_thread = client.get("/loop-thread").json()["thread"]
resp = client.post(
"/api/personal/add_directory", json={"directory": str(target)}
)
assert resp.status_code == 200
assert record["index_thread"] != loop_thread, (
"index_personal_documents ran on the event loop thread — every other "
"request queues behind the indexing job (#5558)"
)
assert record["bookkeeping_thread"] != loop_thread, (
"personal_docs_manager.add_directory (refresh_index) ran on the event "
"loop thread"
)
def test_response_and_bookkeeping_unchanged(tmp_path, monkeypatch):
record = {}
app = _build_app(tmp_path, monkeypatch, record)
target = tmp_path / "docs"
target.mkdir()
client = TestClient(app)
resp = client.post("/api/personal/add_directory", json={"directory": str(target)})
assert resp.status_code == 200
body = resp.json()
assert body["success"] is True
assert body["indexed_count"] == 3
assert body["failed_count"] == 0
assert body["directory"] == os.path.realpath(str(target))
assert record["bookkeeping_index_flag"] is False
async def test_concurrent_add_directory_requests_serialize_indexing(tmp_path, monkeypatch):
"""Off-loop execution must not mean parallel index jobs: concurrent
requests would race PersonalDocsManager's unsynchronized list mutations
and file writes (save_directories/_save_excluded are plain open('w'))."""
import time
state, enter, leave = _serialization_probe()
def _slow_index(self, directory, owner=None):
enter(); time.sleep(0.2); leave()
return {"success": True, "indexed_count": 1, "failed_count": 0}
monkeypatch.setattr(_FakeRag, "index_personal_documents", _slow_index)
record = {}
app = _build_app(tmp_path, monkeypatch, record)
for name in ("docs_a", "docs_b"):
(tmp_path / name).mkdir()
async with _async_client(app) as ac:
results = await asyncio.gather(
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_a")}),
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_b")}),
)
assert all(r.status_code == 200 for r in results)
assert state["max_active"] == 1, (
f"{state['max_active']} index jobs ran in parallel — concurrent "
"add_directory requests must serialize"
)
def test_failed_indexing_still_returns_500(tmp_path, monkeypatch):
record = {}
app = _build_app(tmp_path, monkeypatch, record)
target = tmp_path / "docs"
target.mkdir()
def _fail(directory, owner=None):
return {"success": False, "message": "boom"}
monkeypatch.setattr(_FakeRag, "index_personal_documents", staticmethod(_fail))
client = TestClient(app)
resp = client.post("/api/personal/add_directory", json={"directory": str(target)})
assert resp.status_code == 500
assert "boom" in resp.json()["detail"]
async def test_add_and_remove_serialize(tmp_path, monkeypatch):
"""#5634: remove must hold the SAME job lock as add. Otherwise a remove
running while an add job is in flight races PersonalDocsManager's
unsynchronized list/index mutations the inconsistent state the PR's
'add/remove are serialized' guarantee claims to prevent."""
import time
state, enter, leave = _serialization_probe()
def _slow_index(self, directory, owner=None):
enter(); time.sleep(0.25); leave()
return {"success": True, "indexed_count": 1, "failed_count": 0}
def _slow_remove(self, directory):
enter(); time.sleep(0.25); leave()
monkeypatch.setattr(_FakeRag, "index_personal_documents", _slow_index)
monkeypatch.setattr(_FakeDocsManager, "remove_directory", _slow_remove, raising=False)
record = {}
app = _build_app(tmp_path, monkeypatch, record)
(tmp_path / "docs_a").mkdir()
(tmp_path / "docs_b").mkdir()
async with _async_client(app) as ac:
results = await asyncio.gather(
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_a")}),
ac.delete("/api/personal/remove_directory", params={"directory": str(tmp_path / "docs_b")}),
)
assert all(r.status_code == 200 for r in results)
assert state["max_active"] == 1, (
f"{state['max_active']} add/remove critical sections overlapped — "
"remove must hold the same index job lock as add"
)
async def test_add_and_upload_serialize(tmp_path, monkeypatch):
"""#5634 follow-up: POST /upload writes chunks into the vector store and then
calls personal_docs_manager.add_directory the same vector/tracking state
add_directory mutates. It must hold the SAME job lock, or an upload landing
mid-add interleaves two writers over unsynchronized state."""
import time
state, enter, leave = _serialization_probe()
def _slow_index(self, directory, owner=None):
enter(); time.sleep(0.25); leave()
return {"success": True, "indexed_count": 1, "failed_count": 0}
def _slow_add_document(self, chunk, metadata):
self._record["add_document_thread"] = threading.get_ident()
enter(); time.sleep(0.25); leave()
return True
monkeypatch.setattr(_FakeRag, "index_personal_documents", _slow_index)
monkeypatch.setattr(_FakeRag, "add_document", _slow_add_document)
record = {}
app = _build_app(tmp_path, monkeypatch, record)
monkeypatch.setattr(personal_routes, "UPLOADS_DIR", str(tmp_path / "uploads"))
monkeypatch.setattr(personal_routes, "require_privilege", lambda request, key: "tester")
(tmp_path / "docs_a").mkdir()
async with _async_client(app) as ac:
results = await asyncio.gather(
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_a")}),
ac.post("/api/personal/upload", files={"files": ("a.txt", b"hello world", "text/plain")}),
)
assert all(r.status_code == 200 for r in results)
# The test coroutine runs on the event loop, so this IS the loop thread.
assert record["add_document_thread"] != threading.get_ident(), (
"rag.add_document ran on the event loop thread — chunk writes block "
"every other request for the duration of the upload"
)
assert state["max_active"] == 1, (
f"{state['max_active']} add/upload critical sections overlapped — "
"upload must hold the same index job lock as add"
)
async def test_upload_processes_each_payload_before_reading_the_next(tmp_path, monkeypatch):
"""A multi-file upload must retain at most one capped payload at a time."""
from starlette.datastructures import UploadFile as StarletteUploadFile
reads = []
original_read = StarletteUploadFile.read
async def _recording_read(upload, size=-1):
reads.append(upload.filename)
return await original_read(upload, size)
def _record_first_index(self, chunk, metadata):
self._record.setdefault("reads_at_first_index", len(reads))
return True
monkeypatch.setattr(StarletteUploadFile, "read", _recording_read)
monkeypatch.setattr(_FakeRag, "add_document", _record_first_index)
record = {}
app = _build_app(tmp_path, monkeypatch, record)
monkeypatch.setattr(personal_routes, "UPLOADS_DIR", str(tmp_path / "uploads"))
monkeypatch.setattr(personal_routes, "require_privilege", lambda request, key: "tester")
files = [
("files", ("a.txt", b"alpha", "text/plain")),
("files", ("b.txt", b"bravo", "text/plain")),
("files", ("c.txt", b"charlie", "text/plain")),
]
async with _async_client(app) as ac:
response = await ac.post("/api/personal/upload", files=files)
assert response.status_code == 200
assert response.json()["uploaded"] == ["a.txt", "b.txt", "c.txt"]
assert reads == ["a.txt", "b.txt", "c.txt"]
assert record["reads_at_first_index"] == 1, (
"all upload bodies were retained before worker processing began"
)
async def test_add_and_delete_file_serialize(tmp_path, monkeypatch):
"""#5634 follow-up: DELETE /file removes chunks from the vector store and
calls personal_docs_manager.exclude_file. Both mutate state add_directory
also touches, so the delete must hold the SAME job lock as add."""
import time
state, enter, leave = _serialization_probe()
def _slow_index(self, directory, owner=None):
enter(); time.sleep(0.25); leave()
return {"success": True, "indexed_count": 1, "failed_count": 0}
def _slow_delete(self, filepath):
self._record["delete_thread"] = threading.get_ident()
enter(); time.sleep(0.25); leave()
return 1
monkeypatch.setattr(_FakeRag, "index_personal_documents", _slow_index)
monkeypatch.setattr(_FakeRag, "delete_by_source", _slow_delete)
record = {}
app = _build_app(tmp_path, monkeypatch, record)
monkeypatch.setattr(personal_routes, "UPLOADS_DIR", str(tmp_path / "uploads"))
(tmp_path / "docs_a").mkdir()
doomed = tmp_path / "doomed.txt"
doomed.write_text("bye")
async with _async_client(app) as ac:
results = await asyncio.gather(
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_a")}),
ac.delete("/api/personal/file", params={"filepath": str(doomed)}),
)
assert all(r.status_code == 200 for r in results)
assert record["delete_thread"] != threading.get_ident(), (
"rag.delete_by_source ran on the event loop thread"
)
assert state["max_active"] == 1, (
f"{state['max_active']} add/delete critical sections overlapped — "
"delete must hold the same index job lock as add"
)
async def test_reload_serializes_with_add(tmp_path, monkeypatch):
"""#5634: POST /reload rebuilds the index via refresh_index(); it must hold
the same job lock so it cannot race an in-flight add job."""
import time
state, enter, leave = _serialization_probe()
def _slow_index(self, directory, owner=None):
enter(); time.sleep(0.25); leave()
return {"success": True, "indexed_count": 1, "failed_count": 0}
def _slow_refresh(self):
enter(); time.sleep(0.25); leave()
monkeypatch.setattr(_FakeRag, "index_personal_documents", _slow_index)
monkeypatch.setattr(_FakeDocsManager, "refresh_index", _slow_refresh, raising=False)
record = {}
app = _build_app(tmp_path, monkeypatch, record)
(tmp_path / "docs_a").mkdir()
async with _async_client(app) as ac:
results = await asyncio.gather(
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_a")}),
ac.post("/api/personal/reload"),
)
assert all(r.status_code == 200 for r in results)
assert state["max_active"] == 1, (
f"{state['max_active']} add/reload critical sections overlapped — "
"reload must hold the same index job lock as add"
)
+4
View File
@@ -314,17 +314,21 @@ class TestComputeFinalMetrics:
def test_tool_events_included(self):
events = [{"tool": "bash", "duration": 1.0}]
texts = ["round 1 text"]
models = ["round-1-model"]
m = _compute_final_metrics(**self._base_args(
tool_events=events,
round_texts=texts,
round_models=models,
))
assert m["tool_events"] == events
assert m["round_texts"] == texts
assert m["round_models"] == models
def test_no_tool_events_excluded(self):
m = _compute_final_metrics(**self._base_args(tool_events=[], round_texts=[]))
assert "tool_events" not in m
assert "round_texts" not in m
assert "round_models" not in m
# ---------------------------------------------------------------------------
@@ -0,0 +1,237 @@
"""Saved Agent rounds must render and bill with actual per-round provenance."""
import json
from pathlib import Path
import re
import shutil
import subprocess
import pytest
_SOURCE = (
Path(__file__).resolve().parents[1] / "static" / "js" / "chatRenderer.js"
).read_text(encoding="utf-8")
_CHAT_SOURCE = (
Path(__file__).resolve().parents[1] / "static" / "js" / "chat.js"
).read_text(encoding="utf-8")
_SLASH_SOURCE = (
Path(__file__).resolve().parents[1] / "static" / "js" / "slashCommands.js"
).read_text(encoding="utf-8")
_HAS_NODE = shutil.which("node") is not None
def _function_source(name):
match = re.search(
rf"^(?:export )?function {name}\(.*?^\}}",
_SOURCE,
re.MULTILINE | re.DOTALL,
)
assert match, f"{name} not found"
return match.group(0).replace("export function", "function", 1)
def _run_node(source):
proc = subprocess.run(
["node", "--input-type=module"],
input=source,
capture_output=True,
text=True,
timeout=30,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout.strip())
def test_saved_agent_rounds_prefer_round_model_provenance():
assert "const roundModels = metadata.round_models || [];" in _SOURCE
assert "const contModel = roundModels[r] || pair.actualModel || pair.requestedModel;" in _SOURCE
assert "Array.isArray(metadata.round_texts) && metadata.round_texts.length > 1" in _SOURCE
assert "const roundEndpointIds = metadata.round_endpoint_ids || [];" in _SOURCE
assert "const roundEndpointLabels = metadata.round_endpoint_labels || [];" in _SOURCE
assert "r < roundEndpointIds.length" in _SOURCE
assert "r < roundEndpointLabels.length" in _SOURCE
assert "roundEndpointIds[r] || pair.actualEndpointId" not in _SOURCE
def test_metrics_cost_uses_actual_fallback_endpoint_classification():
assert "metrics.endpoint_cost_tracked" in _SOURCE
assert "endpointCostTracked === false" in _SOURCE
assert "endpointCostTracked !== true && !isCostTrackedEndpoint(selectedUrl)" in _SOURCE
assert "Array.isArray(metrics.usage_buckets)" in _SOURCE
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_agent_usage_buckets_sum_only_billable_answering_routes():
source = "\n".join([
"let currentUrl = '';",
"function _currentEndpointUrl() { return currentUrl; }",
"function isCostTrackedEndpoint(url) { return url === 'paid'; }",
"function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }",
_function_source("_billableCost"),
_function_source("_metricsBillableCost"),
"const paidSelected = {usage_buckets: [",
" {model: 'selected', input_tokens: 100, output_tokens: 10, endpoint_cost_tracked: true},",
" {model: 'local-fallback', input_tokens: 200, output_tokens: 20, endpoint_cost_tracked: false},",
"]};",
"const localSelected = {usage_buckets: [",
" {model: 'selected', input_tokens: 100, output_tokens: 10, endpoint_cost_tracked: false},",
" {model: 'paid-fallback', input_tokens: 200, output_tokens: 20, endpoint_cost_tracked: true},",
"]};",
"currentUrl = 'local';",
"const paidToLocal = _metricsBillableCost(paidSelected, 'final', 300, 30);",
"currentUrl = 'paid';",
"const localToPaid = _metricsBillableCost(localSelected, 'final', 300, 30);",
"console.log(JSON.stringify({paidToLocal, localToPaid}));",
])
assert _run_node(source) == {"paidToLocal": 0.11, "localToPaid": 0.22}
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_force_answer_synthesis_segment_is_included_in_fallback_cost():
source = "\n".join([
"function _currentEndpointUrl() { return 'local-selected'; }",
"function isCostTrackedEndpoint() { return false; }",
"function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }",
_function_source("_billableCost"),
_function_source("_metricsBillableCost"),
"const metrics = {usage_buckets: [",
" {round: 6, model: 'paid-fallback', input_tokens: 100, output_tokens: 0, endpoint_cost_tracked: true},",
" {round: 6, model: 'paid-fallback', input_tokens: 80, output_tokens: 20, endpoint_cost_tracked: true},",
"]};",
"console.log(JSON.stringify({cost: _metricsBillableCost(metrics, 'paid-fallback', 180, 20)}));",
])
assert _run_node(source) == {"cost": 0.2}
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_repeated_live_metrics_render_records_session_cost_once():
source = "\n".join([
"const _COST_KEY = 'ody-session-cost';",
"const state = {};",
"const localStorage = {",
" getItem(key) { return state[key] || null; },",
" setItem(key, value) { state[key] = value; },",
"};",
"const window = {sessionModule: {getCurrentSessionId() { return 'session'; }}};",
"function updateSessionCostUI() {}",
"function _currentEndpointUrl() { return 'local'; }",
"function isCostTrackedEndpoint(url) { return url === 'paid'; }",
"function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }",
_function_source("_billableCost"),
_function_source("_metricsBillableCost"),
_function_source("recordSessionMetricsCost"),
"const metrics = {model: 'paid-model', input_tokens: 100, output_tokens: 10, endpoint_cost_tracked: true};",
"recordSessionMetricsCost(metrics);",
"recordSessionMetricsCost(metrics);",
"console.log(JSON.stringify({cost: JSON.parse(state[_COST_KEY]).session, recorded: metrics._costRecorded}));",
])
assert _run_node(source) == {"cost": 0.11, "recorded": True}
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_replayed_metrics_use_run_identity_for_durable_cost_deduplication():
source = "\n".join([
"const _COST_KEY = 'ody-session-cost';",
"const _COST_RUNS_KEY = 'ody-session-cost-runs';",
"const _MAX_COST_RUNS_PER_SESSION = 256;",
"const state = {};",
"const localStorage = {",
" getItem(key) { return state[key] || null; },",
" setItem(key, value) { state[key] = value; },",
"};",
"const window = {sessionModule: {getCurrentSessionId() { return 'session'; }}};",
"function updateSessionCostUI() {}",
"function _currentEndpointUrl() { return 'local'; }",
"function isCostTrackedEndpoint(url) { return url === 'paid'; }",
"function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }",
_function_source("_billableCost"),
_function_source("_metricsBillableCost"),
_function_source("recordSessionMetricsCost"),
_function_source("getSessionCost"),
"const firstObject = {model: 'paid-model', input_tokens: 100, output_tokens: 10, endpoint_cost_tracked: true, _costRecordId: 'run-1'};",
"const replayedObject = {...firstObject};",
"recordSessionMetricsCost(firstObject);",
"recordSessionMetricsCost(replayedObject);",
"console.log(JSON.stringify({cost: getSessionCost('session'), runs: JSON.parse(state[_COST_RUNS_KEY]).session}));",
])
assert _run_node(source) == {"cost": 0.11, "runs": {"run-1": 0.11}}
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_run_cost_ledger_sums_segments_and_updates_repeated_segment_metrics():
source = "\n".join([
"const _COST_KEY = 'ody-session-cost';",
"const _COST_RUNS_KEY = 'ody-session-cost-runs';",
"const _MAX_COST_RUNS_PER_SESSION = 256;",
"const state = {};",
"const localStorage = {",
" getItem(key) { return state[key] || null; },",
" setItem(key, value) { state[key] = value; },",
"};",
"const window = {sessionModule: {getCurrentSessionId() { return 'session'; }}};",
"function updateSessionCostUI() {}",
"function _currentEndpointUrl() { return 'paid'; }",
"function isCostTrackedEndpoint() { return true; }",
"function getModelCost(_model, inputTokens, outputTokens) { return (inputTokens + outputTokens) / 1000; }",
_function_source("_billableCost"),
_function_source("_metricsBillableCost"),
_function_source("recordSessionMetricsCost"),
_function_source("getSessionCost"),
"recordSessionMetricsCost({model: 'student', input_tokens: 100, output_tokens: 10, _costRecordId: 'run:primary'});",
"recordSessionMetricsCost({model: 'student', input_tokens: 120, output_tokens: 20, _costRecordId: 'run:primary'});",
"recordSessionMetricsCost({model: 'teacher', input_tokens: 200, output_tokens: 30, _costRecordId: 'run:teacher'});",
"console.log(JSON.stringify({cost: getSessionCost('session'), runs: JSON.parse(state[_COST_RUNS_KEY]).session}));",
])
assert _run_node(source) == {
"cost": 0.37,
"runs": {"run:primary": 0.14, "run:teacher": 0.23},
}
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_local_selected_endpoint_does_not_erase_paid_fallback_ledger():
source = "\n".join([
"const _COST_KEY = 'ody-session-cost';",
"const _COST_RUNS_KEY = 'ody-session-cost-runs';",
"const state = {'ody-session-cost': JSON.stringify({session: 0.125})};",
"const localStorage = {",
" getItem(key) { return state[key] || null; },",
" setItem(key, value) { state[key] = value; },",
"};",
"const badge = {style: {}, textContent: ''};",
"const document = {getElementById() { return badge; }};",
"const window = {sessionModule: {getCurrentSessionId() { return 'session'; }, getCurrentEndpointUrl() { return 'local'; }}};",
_function_source("getSessionCost"),
_function_source("updateSessionCostUI"),
"updateSessionCostUI();",
"console.log(JSON.stringify({stored: JSON.parse(state[_COST_KEY]).session, display: badge.style.display, text: badge.textContent}));",
])
assert _run_node(source) == {
"stored": 0.125,
"display": "",
"text": "$0.125",
}
def test_live_and_resumed_terminal_events_apply_usage_metrics_before_reload():
assert "metrics = json.data || metrics;" in _CHAT_SOURCE
assert "displayMetrics(terminalMetricsTarget, metrics);" in _CHAT_SOURCE
assert "metricsData = json.data || metricsData;" in _CHAT_SOURCE
assert "displayMetrics(holder, metricsData);" in _CHAT_SOURCE
assert "json.type === 'agent_terminal' || json.type === 'chat_terminal'" in _CHAT_SOURCE
assert "chatRenderer.recordSessionMetricsCost(metrics, streamSessionId);" in _CHAT_SOURCE
assert "chatRenderer.recordSessionMetricsCost(metricsData, sessionId);" in _CHAT_SOURCE
assert "metricsData._costRecordId = _metricsCostRecordId(resumeRunId, json);" in _CHAT_SOURCE
assert "bgTerminal.status = 'completed';" in _CHAT_SOURCE
def test_usage_command_does_not_hide_existing_fallback_cost_for_local_selection():
assert "const cost = chatRenderer.getSessionCost" in _SLASH_SOURCE
assert "const cost = costTracked && chatRenderer.getSessionCost" not in _SLASH_SOURCE
+203
View File
@@ -0,0 +1,203 @@
"""Execute the round-aware live model-provenance state helper under Node."""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
_REPO = Path(__file__).resolve().parents[1]
_MODULE = (_REPO / "static" / "js" / "chatModelProvenance.js").as_uri()
def test_round_two_fallback_then_provider_alias_does_not_relabel_round_one():
if not shutil.which("node"):
pytest.skip("node is not installed")
script = f"""
import {{ applyModelRouteEventState }} from {json.dumps(_MODULE)};
const round1 = {{ _requestedModel: 'selected-model', _actualModel: 'selected-model' }};
const round2 = {{ _requestedModel: 'selected-model', _actualModel: 'selected-model' }};
const fallbackTarget = applyModelRouteEventState({{
type: 'fallback', round: 2,
selected_model: 'selected-model', answered_by: 'backup-model'
}}, round1, round2, 'selected-model');
const aliasTarget = applyModelRouteEventState({{
type: 'model_actual', round: 2,
requested_model: 'selected-model', model: 'provider-backup-alias'
}}, round1, round2, 'selected-model');
console.log(JSON.stringify({{
fallbackIsRound2: fallbackTarget === round2,
aliasIsRound2: aliasTarget === round2,
round1,
round2,
}}));
"""
result = subprocess.run(
["node", "--input-type=module"],
input=script,
capture_output=True,
text=True,
cwd=_REPO,
timeout=30,
)
assert result.returncode == 0, result.stderr
state = json.loads(result.stdout)
assert state == {
"fallbackIsRound2": True,
"aliasIsRound2": True,
"round1": {
"_requestedModel": "selected-model",
"_actualModel": "selected-model",
},
"round2": {
"_requestedModel": "selected-model",
"_actualModel": "provider-backup-alias",
},
}
def test_next_round_and_final_metrics_preserve_each_agent_round_route():
if not shutil.which("node"):
pytest.skip("node is not installed")
script = f"""
import {{
applyModelMetricsState,
applyModelRouteEventState,
inheritModelRouteState,
}} from {json.dumps(_MODULE)};
const round1 = {{ _requestedModel: 'selected-model', _actualModel: 'selected-model' }};
const round2 = {{}};
inheritModelRouteState(round1, round1, round2, 'selected-model');
applyModelRouteEventState({{
type: 'fallback', round: 2,
selected_model: 'selected-model', answered_by: 'backup-model'
}}, round1, round2, 'selected-model');
applyModelRouteEventState({{
type: 'model_actual', round: 2,
requested_model: 'selected-model', model: 'provider-backup-alias'
}}, round1, round2, 'selected-model');
const round3 = {{}};
inheritModelRouteState(round1, round2, round3, 'selected-model');
const metricsTarget = applyModelMetricsState({{
requested_model: 'selected-model',
model: 'provider-backup-alias',
round_models: ['selected-model', 'provider-backup-alias', 'backup-model'],
}}, round1, round3, 'selected-model');
console.log(JSON.stringify({{
metricsIsRound3: metricsTarget === round3,
round1,
round2,
round3,
}}));
"""
result = subprocess.run(
["node", "--input-type=module"],
input=script,
capture_output=True,
text=True,
cwd=_REPO,
timeout=30,
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"metricsIsRound3": True,
"round1": {
"_requestedModel": "selected-model",
"_actualModel": "selected-model",
},
"round2": {
"_requestedModel": "selected-model",
"_actualModel": "provider-backup-alias",
},
"round3": {
"_requestedModel": "selected-model",
"_actualModel": "backup-model",
},
}
def test_same_model_fallback_preserves_distinct_endpoint_route_state():
if not shutil.which("node"):
pytest.skip("node is not installed")
script = f"""
import {{ applyModelMetricsState, applyModelRouteEventState }} from {json.dumps(_MODULE)};
const holder = {{ _requestedModel: 'same-model', _actualModel: 'same-model' }};
applyModelRouteEventState({{
type: 'fallback',
selected_model: 'same-model', answered_by: 'same-model',
selected_endpoint_id: 'account-one', selected_endpoint_label: 'Account one',
answered_by_endpoint_id: 'account-two', answered_by_endpoint_label: 'Account two',
}}, holder, null, 'same-model');
applyModelMetricsState({{
requested_model: 'same-model', model: 'same-model',
requested_endpoint_id: 'account-one', requested_endpoint_label: 'Account one',
endpoint_id: 'account-two', endpoint_label: 'Account two',
}}, holder, null, 'same-model');
console.log(JSON.stringify(holder));
"""
result = subprocess.run(
["node", "--input-type=module"],
input=script,
capture_output=True,
text=True,
cwd=_REPO,
timeout=30,
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"_requestedModel": "same-model",
"_actualModel": "same-model",
"_requestedEndpointId": "account-one",
"_requestedEndpointLabel": "Account one",
"_actualEndpointId": "account-two",
"_actualEndpointLabel": "Account two",
}
def test_metrics_preserve_explicitly_unknown_round_endpoint():
if not shutil.which("node"):
pytest.skip("node is not installed")
script = f"""
import {{ applyModelMetricsState }} from {json.dumps(_MODULE)};
const holder = {{
_requestedModel: 'same-model',
_actualModel: 'same-model',
_requestedEndpointId: 'account-one',
_requestedEndpointLabel: 'Account one',
}};
const roundHolder = {{}};
applyModelMetricsState({{
requested_model: 'same-model', model: 'same-model',
requested_endpoint_id: 'account-one', requested_endpoint_label: 'Account one',
endpoint_id: 'account-two', endpoint_label: 'Account two',
round_endpoint_ids: [null], round_endpoint_labels: [null],
}}, holder, roundHolder, 'same-model');
console.log(JSON.stringify(roundHolder));
"""
result = subprocess.run(
["node", "--input-type=module"],
input=script,
capture_output=True,
text=True,
cwd=_REPO,
timeout=30,
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"_requestedModel": "same-model",
"_actualModel": "same-model",
"_requestedEndpointId": "account-one",
"_requestedEndpointLabel": "Account one",
"_actualEndpointId": None,
"_actualEndpointLabel": None,
}
+47
View File
@@ -0,0 +1,47 @@
"""Execute terminal stream-error classification under Node."""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
_REPO = Path(__file__).resolve().parents[1]
_MODULE = (_REPO / "static" / "js" / "chatStreamErrors.js").as_uri()
def test_terminal_provider_errors_preserve_text_and_never_auto_retry():
if not shutil.which("node"):
pytest.skip("node is not installed")
script = f"""
import {{ createTerminalStreamError, isRecoverableStreamError }} from {json.dumps(_MODULE)};
const stringError = createTerminalStreamError({{ status: 401, error: 'invalid key' }});
const objectError = createTerminalStreamError({{ status: 404, error: {{ message: 'model missing' }} }});
console.log(JSON.stringify({{
stringMessage: stringError.message,
objectMessage: objectError.message,
terminalRecoverable: isRecoverableStreamError(stringError),
eofRecoverable: isRecoverableStreamError(new Error('Stream closed before completion')),
networkRecoverable: isRecoverableStreamError(new TypeError('fetch failed')),
}}));
"""
result = subprocess.run(
["node", "--input-type=module"],
input=script,
capture_output=True,
text=True,
cwd=_REPO,
timeout=30,
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout) == {
"stringMessage": "invalid key",
"objectMessage": "model missing",
"terminalRecoverable": False,
"eofRecoverable": True,
"networkRecoverable": True,
}
+174 -1
View File
@@ -81,6 +81,13 @@ def _make_stream_with_save(sink, chunks, *, hang_after=None):
return gen()
async def _collect_subscription(session_id, expected_run=None):
return [
event
async for event in agent_runs.subscribe(session_id, expected_run)
]
# --------------------------------------------------------------------------- #
# agent_runs: detached-run semantics (what NORMAL chat/agent streams use)
# --------------------------------------------------------------------------- #
@@ -136,7 +143,7 @@ async def test_stop_cancels_detached_run_and_saves_partial_exactly_once():
break
await sub.aclose()
stopped = agent_runs.stop(session_id)
stopped = agent_runs.stop(session_id, run.run_id)
assert stopped is True
await run.task # propagates promptly — not stuck on the hung await
@@ -165,6 +172,172 @@ async def test_normal_completion_saves_exactly_once_not_partial():
assert sink.saves == []
@pytest.mark.asyncio
async def test_detached_run_identity_is_stable_for_replay_and_unique_per_run():
session_id = "sess-detached-run-identity"
agent_runs._RUNS.pop(session_id, None)
first = agent_runs.start(session_id, _make_stream_with_save(_FakeSaveSink(), ["one"]))
first_id = first.run_id
assert agent_runs.get_run_id(session_id) == first_id
await first.task
assert agent_runs.get_run_id(session_id) == first_id
second = agent_runs.start(session_id, _make_stream_with_save(_FakeSaveSink(), ["two"]))
assert second.run_id != first_id
assert agent_runs.get_run_id(session_id) == second.run_id
await second.task
@pytest.mark.asyncio
async def test_lazy_subscription_stays_bound_to_header_run_after_replacement():
session_id = "sess-detached-lazy-subscription"
agent_runs._RUNS.pop(session_id, None)
async def stream(label):
yield f'data: {{"delta":"{label}"}}\n\n'
first = agent_runs.start(session_id, stream("first"))
await first.task
# StreamingResponse does not iterate its body until after construction.
# Capture the same exact run object used for its identity header.
lazy_body = agent_runs.subscribe(session_id, first)
second = agent_runs.start(session_id, stream("second"))
await second.task
replayed = [event async for event in lazy_body]
assert replayed == ['data: {"delta":"first"}\n\n']
assert agent_runs.get_run_id(session_id) == second.run_id
@pytest.mark.asyncio
async def test_stale_run_identity_cannot_stop_replacement_run():
session_id = "sess-detached-stale-stop"
agent_runs._RUNS.pop(session_id, None)
release = asyncio.Event()
async def finished():
yield 'data: {"delta":"old"}\n\n'
async def replacement():
yield 'data: {"delta":"new"}\n\n'
await release.wait()
first = agent_runs.start(session_id, finished())
await first.task
second = agent_runs.start(session_id, replacement())
await asyncio.sleep(0)
assert agent_runs.stop(session_id) is False
assert agent_runs.stop(session_id, first.run_id) is False
assert second.task is not None and not second.task.done()
assert agent_runs.stop(session_id, second.run_id) is True
await second.task
@pytest.mark.asyncio
async def test_triple_replacement_closes_middle_subscriber_and_preserves_save_order():
session_id = "sess-detached-triple-replacement"
agent_runs._RUNS.pop(session_id, None)
first_closing = asyncio.Event()
release_first = asyncio.Event()
third_started = asyncio.Event()
async def first_stream():
try:
yield 'data: {"delta":"first"}\n\n'
await asyncio.Event().wait()
finally:
first_closing.set()
await release_first.wait()
async def middle_stream():
yield 'data: {"delta":"middle"}\n\n'
async def third_stream():
third_started.set()
yield 'data: {"delta":"third"}\n\n'
first = agent_runs.start(session_id, first_stream())
while not first.buffer:
await asyncio.sleep(0)
middle = agent_runs.start(session_id, middle_stream())
await first_closing.wait()
assert middle.task is not None and not middle.task.done()
middle_events_task = asyncio.create_task(
_collect_subscription(session_id, middle)
)
while not middle.subscribers:
await asyncio.sleep(0)
third = agent_runs.start(session_id, third_stream())
# The superseded middle response closes immediately even though its task
# remains as the transitive barrier for the first run's partial save.
assert await asyncio.wait_for(middle_events_task, timeout=1) == []
assert middle.status == "stopped"
assert middle.task is not None and not middle.task.done()
assert not third_started.is_set()
release_first.set()
await asyncio.wait_for(first.task, timeout=1)
await asyncio.wait_for(middle.task, timeout=1)
await asyncio.wait_for(third.task, timeout=1)
assert first.status == "stopped"
assert middle.status == "stopped"
assert third.status == "done"
assert third_started.is_set()
@pytest.mark.asyncio
async def test_reconnect_replays_pinned_fallback_run_without_restarting_tools():
session_id = "sess-detached-fallback-resume"
agent_runs._RUNS.pop(session_id, None)
release = asyncio.Event()
tool_executions = 0
fallback = 'data: {"type":"fallback","answered_by":"backup","candidate_index":1}\n\n'
tool = 'data: {"type":"tool_output","tool":"bash","output":"ok"}\n\n'
async def pinned_run():
nonlocal tool_executions
yield fallback
tool_executions += 1
yield tool
await release.wait()
yield 'data: {"delta":"backup finished"}\n\n'
yield "data: [DONE]\n\n"
run = agent_runs.start(session_id, pinned_run())
first = agent_runs.subscribe(session_id)
first_events = []
async for event in first:
first_events.append(event)
if len(first_events) == 2:
break
await first.aclose()
assert run.status == "running"
assert tool_executions == 1
assert agent_runs._RUNS[session_id] is run
resumed_events = []
resumed = agent_runs.subscribe(session_id)
async for event in resumed:
resumed_events.append(event)
if len(resumed_events) == 2:
release.set()
await run.task
assert resumed_events[:2] == [fallback, tool]
assert resumed_events[-1] == "data: [DONE]\n\n"
assert tool_executions == 1
assert agent_runs._RUNS[session_id] is run
# --------------------------------------------------------------------------- #
# chat_stream: Compare panes must NOT be detached, so the Stop button (closing
# the SSE) cancels the upstream generator promptly — exercising the same
+61
View File
@@ -63,6 +63,23 @@ class TestSelfSummaryPrompt:
class TestTrimForContext:
def test_system_truncation_preserves_internal_route_metadata(self):
messages = [
{
"role": "system",
"content": "persona\n\n" + ("agent prompt " * 2000),
"_agent_injected": "merged_prompt",
"_agent_base_message": {"role": "system", "content": "persona"},
},
{"role": "user", "content": "latest"},
]
trimmed = trim_for_context(messages, context_length=1024, reserve_tokens=256)
system = next(message for message in trimmed if message.get("role") == "system")
assert system["_agent_injected"] == "merged_prompt"
assert system["_agent_base_message"] == {"role": "system", "content": "persona"}
def test_keeps_current_large_user_message_by_truncating(self):
huge = "A" * 20000
messages = [
@@ -194,6 +211,50 @@ class TestMaybeCompactFourthMessage:
assert len(result) == 3 and result[2] is True
@pytest.mark.asyncio
async def test_deferred_compaction_persists_only_after_route_commit(monkeypatch):
updates = []
state = {}
messages = [
{"role": "system", "content": "system " * 100},
{"role": "user", "content": "one"},
{"role": "assistant", "content": "two"},
{"role": "user", "content": "three"},
{"role": "assistant", "content": "four"},
{"role": "user", "content": "five"},
]
monkeypatch.setattr(cc, "get_context_length", lambda *args: 100)
monkeypatch.setattr(cc, "resolve_endpoint", lambda *args, **kwargs: (None, None, None))
async def fake_summary(*args, **kwargs):
return "route-specific summary"
monkeypatch.setattr(cc, "llm_call_async", fake_summary)
monkeypatch.setattr(
cc,
"_update_session_history",
lambda *args, **kwargs: updates.append((args, kwargs)),
)
_compacted, _context, was_compacted = await cc.maybe_compact(
object(),
"https://candidate.example/v1",
"candidate-model",
messages,
persist=False,
compaction_state=state,
)
assert was_compacted is True
assert updates == []
assert state["summary"] == "route-specific summary"
assert cc.apply_compaction_state(object(), state) is True
assert len(updates) == 1
assert cc.apply_compaction_state(object(), state) is False
assert len(updates) == 1
class TestResearchPrimerPreserved:
"""A research-spinoff primer (metadata research_spinoff_from) must never be
trimmed away it is the Discuss chat's sole knowledge base (drift fix)."""
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,19 @@
"""Regression for idle UI polls that must not count as foreground activity."""
from src.interactive_gate import should_track_interactive_request
def test_email_unread_state_is_passive_like_urgency_state():
assert should_track_interactive_request("/api/email/urgency-state") is False
assert should_track_interactive_request("/api/email/unread-state") is False
def test_real_interactive_paths_still_tracked():
assert should_track_interactive_request("/api/chat_stream") is True
assert should_track_interactive_request("/api/email/messages") is True
assert should_track_interactive_request("/api/tasks", method="POST") is True
def test_options_never_tracked():
assert should_track_interactive_request("/api/email/unread-state", method="OPTIONS") is False
assert should_track_interactive_request("/api/chat_stream", method="OPTIONS") is False
+5 -3
View File
@@ -8,15 +8,15 @@ from bs4 import BeautifulSoup
_REPO = Path(__file__).resolve().parents[1]
def test_legacy_default_fallback_editor_is_hidden():
def test_legacy_default_fallback_editor_is_absent():
soup = BeautifulSoup(
(_REPO / "static" / "index.html").read_text(encoding="utf-8"),
"html.parser",
)
editor = soup.find(id="set-defaultFallbacks")
assert editor is not None
assert editor.find_parent(class_="settings-row").has_attr("hidden")
assert editor is None
assert soup.find(id="set-defaultAddFallback") is None
def test_default_model_save_does_not_rewrite_legacy_fallbacks():
@@ -27,3 +27,5 @@ def test_default_model_save_does_not_rewrite_legacy_fallbacks():
assert "settings.default_model_fallbacks" not in default_chat_source
assert "default_model_fallbacks:" not in default_chat_source
assert "set-defaultFallbacks" not in default_chat_source
assert "set-defaultAddFallback" not in default_chat_source
@@ -0,0 +1,284 @@
"""Source contract for live multi-round fallback attribution."""
import json
from pathlib import Path
import shutil
import subprocess
import pytest
CHAT_JS = Path("static/js/chat.js").read_text(encoding="utf-8")
_HAS_NODE = shutil.which("node") is not None
def _resume_function_source():
body = CHAT_JS.split("export async function resumeStream", 1)[1].split(
"export function checkBackgroundStream", 1
)[0]
return "async function resumeStream" + body.rstrip()
def _run_node(source):
proc = subprocess.run(
["node", "--input-type=module"],
input=source,
capture_output=True,
text=True,
timeout=30,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout.strip())
def test_live_fallback_targets_the_active_round_and_replaces_actual_model():
fallback_block = CHAT_JS.split("json.type === 'fallback'", 1)[1].split(
"json.type === 'doc_stream_open'", 1
)[0]
assert "applyModelRouteEventState(json, holder, roundHolder, modelName)" in fallback_block
assert "_fallbackHolder.querySelector('.role')" in fallback_block
assert "_hasResolvedActual" not in fallback_block
def test_provider_alias_uses_the_same_round_aware_holder_selection():
actual_block = CHAT_JS.split("json.type === 'model_actual'", 1)[1].split(
"json.type === 'attachments'", 1
)[0]
assert "applyModelRouteEventState(json, holder, roundHolder, modelName)" in actual_block
assert "_modelHolder.querySelector('.role')" in actual_block
def test_new_round_and_final_metrics_target_the_active_round():
agent_step_block = CHAT_JS.split("} else if (json.type === 'agent_step')", 1)[1].split(
"json.type === 'budget_exceeded'", 1
)[0]
metrics_block = CHAT_JS.split("json.type === 'metrics'", 1)[1].split(
"json.type === 'message_saved'", 1
)[0]
final_block = CHAT_JS.split("const _isBgFinal", 1)[1].split(
"holder.dataset.raw", 1
)[0]
assert "inheritModelRouteState(holder, roundHolder, newWrap" in agent_step_block
assert "applyModelMetricsState(metrics, holder, roundHolder, modelName)" in metrics_block
assert "_finalModelHolder.querySelector('.role')" in final_block
assert "holder.querySelector('.role')" not in final_block
def test_terminal_sse_error_bypasses_eof_auto_recovery():
parser_block = CHAT_JS.split("if (_nextIsError || json.status >= 400)", 1)[1].split(
"if (json.delta", 1
)[0]
completion_gate = CHAT_JS.split("if (_streamTerminalError)", 1)[1].split(
"if (!_streamSawDone)", 1
)[0]
recovery_block = CHAT_JS.split("isRecoverableStreamError(err)", 1)[1].split(
"const errorHolder", 1
)[0]
assert "createTerminalStreamError(json)" in parser_block
assert "throw _streamTerminalError" in completion_gate
assert "if (err.terminalStreamError)" in recovery_block
assert "await sessionModule.selectSession(streamSessionId, { showLoading: false })" in recovery_block
def test_connection_recovery_resumes_detached_run_without_resubmitting_selected_model():
recovery = CHAT_JS.split("function _tryAutoRecover", 1)[1].split(
"function _removeStallBanner", 1
)[0]
assert "await resumeStream(sessionId, holder || null)" in recovery
assert "/api/chat_stream" not in recovery
assert ".click()" not in recovery
assert "_pendingContinue" not in recovery
assert "if (_streamSessionId === streamSessionId) _streamSessionId = null" in CHAT_JS
def test_detached_resume_reloads_canonical_terminal_failures():
resume = CHAT_JS.split("export async function resumeStream", 1)[1].split(
"export function checkBackgroundStream", 1
)[0]
assert "l.trim() === 'event: error'" in resume
assert "json.type === 'agent_terminal'" in resume
assert "rich = true" in resume
assert "Network drop or parse failure: fall through to the canonical reload" in resume
assert "if (onThisSession && !rich && roundText.trim())" in resume
assert "res.headers.get('X-Odysseus-Run-Id')" in resume
assert "chatRenderer.recordSessionMetricsCost(metricsData, sessionId)" in resume
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_detached_resume_surfaces_fallback_then_provider_alias_before_reload():
source = "\n".join([
"import { applyModelRouteEventState } from './static/js/chatModelProvenance.js';",
"class Element {",
" constructor(tag = 'div') { this.tag = tag; this.children = []; this.parentNode = null; this.style = {}; this.textContent = ''; this._html = ''; }",
" appendChild(child) { child.parentNode = this; this.children.push(child); return child; }",
" remove() { if (!this.parentNode) return; this.parentNode.children = this.parentNode.children.filter(c => c !== this); this.parentNode = null; }",
" set innerHTML(value) {",
" this._html = value;",
" if (value.includes('stream-content')) {",
" this._role = new Element('div'); this._role.parentNode = this;",
" this._body = new Element('div'); this._body.parentNode = this;",
" this._content = new Element('div'); this._body.appendChild(this._content);",
" }",
" }",
" get innerHTML() { return this._html; }",
" querySelector(selector) { if (selector === '.role') return this._role || null; if (selector === '.body') return this._body || null; if (selector === '.stream-content') return this._content || null; return null; }",
"}",
"const box = new Element('main');",
"const document = { getElementById(id) { return id === 'chat-history' ? box : null; }, createElement(tag) { return new Element(tag); } };",
"const window = {};",
"let selectCalls = 0; const labels = []; const toasts = [];",
"const sessionModule = { getSessions() { return [{id: 's1', model: 'selected-model'}]; }, getCurrentSessionId() { return 's1'; }, selectSession() { selectCalls += 1; }, loadSessions() {} };",
"const uiModule = { esc(value) { return String(value); }, scrollHistory() {}, showToast(value) { toasts.push(value); } };",
"const spinnerModule = { create() { return { element: null, createElement() { this.element = new Element('spinner'); return this.element; }, start() {}, destroy() { if (this.element) this.element.remove(); } }; } };",
"const markdownModule = { normalizeThinkingMarkup(v) { return v; }, mdToHtml(v) { return v; }, squashOutsideCode(v) { return v; } };",
"const documentModule = null; const chatRenderer = { recordSessionMetricsCost() {}, addMessage() {} };",
"const _resumingStreams = new Set(); const _streamRunIds = new Map(); const API_BASE = '';",
"function hasActiveStream() { return false; } function _shortModel(v) { return v; } function _applyModelColor() {}",
"function _setRoleModelLabel(role, requested, actual) { labels.push({requested, actual}); role.textContent = requested + ' -> ' + actual; }",
"function _streamDisplayText(v) { return v; } function _showDocumentWritingStatus() {} function _finishDocumentWritingStatus() {} function _metricsCostRecordId() { return 'run'; }",
"const events = [",
" 'data: {\"type\":\"fallback\",\"selected_model\":\"selected-model\",\"answered_by\":\"fallback-model\",\"reason\":\"429\"}\\n\\n',",
" 'data: {\"type\":\"model_actual\",\"model\":\"provider/fallback-alias\"}\\n\\n',",
" 'data: {\"delta\":\"hello\"}\\n\\n',",
" 'data: [DONE]\\n\\n',",
"].join('');",
"const encoded = new TextEncoder().encode(events); let reads = 0;",
"const reader = { async read() { return reads++ === 0 ? {done:false, value:encoded} : {done:true}; }, async cancel() {} };",
"async function fetch() { return { ok:true, body:{getReader(){return reader;}}, headers:{get(){return 'run-1';}} }; }",
_resume_function_source(),
"await resumeStream('s1');",
"console.log(JSON.stringify({labels, toasts, selectCalls, holderCount: box.children.length}));",
])
assert _run_node(source) == {
"labels": [
{"requested": "selected-model", "actual": "fallback-model"},
{"requested": "selected-model", "actual": "provider/fallback-alias"},
],
"toasts": ["Fallback: selected-model failed — answered by fallback-model"],
"selectCalls": 1,
"holderCount": 0,
}
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_detached_resume_renders_preoutput_error_without_empty_reload():
source = "\n".join([
"import { createTerminalStreamError } from './static/js/chatStreamErrors.js';",
"class Element {",
" constructor(tag = 'div') { this.tag = tag; this.children = []; this.parentNode = null; this.style = {}; this.textContent = ''; this._html = ''; }",
" appendChild(child) { child.parentNode = this; this.children.push(child); return child; }",
" remove() { if (!this.parentNode) return; this.parentNode.children = this.parentNode.children.filter(c => c !== this); this.parentNode = null; }",
" set innerHTML(value) {",
" this._html = value;",
" if (value.includes('stream-content')) {",
" this._role = new Element('div'); this._role.parentNode = this;",
" this._body = new Element('div'); this._body.parentNode = this;",
" this._content = new Element('div'); this._body.appendChild(this._content);",
" }",
" }",
" get innerHTML() { return this._html; }",
" querySelector(selector) { if (selector === '.role') return this._role || null; if (selector === '.body') return this._body || null; if (selector === '.stream-content') return this._content || null; return null; }",
"}",
"const box = new Element('main');",
"const document = { getElementById(id) { return id === 'chat-history' ? box : null; }, createElement(tag) { return new Element(tag); } };",
"const window = {};",
"let selectCalls = 0;",
"const sessionModule = { getSessions() { return [{id: 's1', model: 'selected'}]; }, getCurrentSessionId() { return 's1'; }, selectSession() { selectCalls += 1; }, loadSessions() {} };",
"const uiModule = { esc(value) { return String(value); }, scrollHistory() {} };",
"const spinnerModule = { create() { return { element: null, createElement() { this.element = new Element('spinner'); return this.element; }, start() {}, destroy() { if (this.element) this.element.remove(); } }; } };",
"const markdownModule = { normalizeThinkingMarkup(v) { return v; }, mdToHtml(v) { return v; }, squashOutsideCode(v) { return v; } };",
"const documentModule = null;",
"const chatRenderer = { recordSessionMetricsCost() {}, addMessage() {} };",
"const _resumingStreams = new Set(); const _streamRunIds = new Map(); const API_BASE = '';",
"function hasActiveStream() { return false; } function _shortModel(v) { return v; } function _applyModelColor() {}",
"function _streamDisplayText(v) { return v; } function _showDocumentWritingStatus() {} function _finishDocumentWritingStatus() {} function _metricsCostRecordId() { return 'run'; }",
"const encoded = new TextEncoder().encode('event: error\\ndata: {\"status\":401,\"error\":\"invalid key <img src=x>\"}\\n\\n');",
"let reads = 0; const reader = { async read() { return reads++ === 0 ? {done:false, value:encoded} : {done:true}; }, async cancel() {} };",
"async function fetch() { return { ok:true, body:{getReader(){return reader;}}, headers:{get(){return 'run-1';}} }; }",
_resume_function_source(),
"const result = await resumeStream('s1');",
"const holder = box.children[0]; const errorNode = holder && holder._content.children.find(node => node.textContent.startsWith('[Error:'));",
"console.log(JSON.stringify({result, selectCalls, holderCount: box.children.length, errorText: errorNode && errorNode.textContent}));",
])
assert _run_node(source) == {
"result": True,
"selectCalls": 0,
"holderCount": 1,
"errorText": "[Error: invalid key <img src=x>]",
}
def test_terminal_then_session_switch_preserves_completed_background_state():
terminal = CHAT_JS.split(
"json.type === 'agent_terminal' || json.type === 'chat_terminal'", 1
)[1].split("json.type === 'metrics'", 1)[0]
detach = CHAT_JS.split("export function detachCurrentStream", 1)[1].split(
"export async function resumeStream", 1
)[0]
background_catch = CHAT_JS.split("if (_isBgCatch)", 1)[1].split(
"} else {", 1
)[0]
assert "_terminalSavedStreams.add(streamSessionId)" in terminal
assert "terminalSaved ? 'completed' : 'running'" in detach
assert "!terminalSaved && sessionModule && sessionModule.markStreaming" in detach
assert "_terminalSavedStreams.has(streamSessionId)" in background_catch
def test_detached_run_identity_is_attached_to_live_metrics():
routes = Path("routes/chat_routes.py").read_text(encoding="utf-8")
assert "headers={\"X-Odysseus-Run-Id\": _detached_run.run_id}" in routes
assert "agent_runs.subscribe(session, _detached_run)" in routes
assert "agent_runs.subscribe(session_id, _active_run)" in routes
assert "const streamRunId = res.headers.get('X-Odysseus-Run-Id')" in CHAT_JS
assert "metrics._costRecordId = _metricsCostRecordId(streamRunId, json)" in CHAT_JS
assert "'X-Odysseus-Run-Id': runId" in CHAT_JS
assert "agent_runs.stop(session_id, _expected_run_id)" in routes
assert "_stopExactRun(streamSessionId)" in CHAT_JS
timeout_block = CHAT_JS.split("timeoutId = setTimeout", 1)[1].split(
"clearResponseTimeout", 1
)[0]
assert "/api/chat/stop/" not in timeout_block
def test_replay_cost_identity_distinguishes_primary_and_teacher_segments():
identity = CHAT_JS.split("function _metricsCostRecordId", 1)[1].split("\n }", 1)[0]
resume = CHAT_JS.split("export async function resumeStream", 1)[1].split(
"export function checkBackgroundStream", 1
)[0]
assert "event.teacher ? 'teacher' : 'primary'" in identity
assert "_metricsCostRecordId(resumeRunId, json)" in resume
metrics_block = resume.split("json.type === 'metrics'", 1)[1].split(
"json.type === 'agent_terminal'", 1
)[0]
assert "chatRenderer.recordSessionMetricsCost(metricsData, sessionId)" in metrics_block
routes = Path("routes/chat_routes.py").read_text(encoding="utf-8")
route_metrics = routes.split('elif data.get("type") == "metrics"', 1)[1].split(
"except json.JSONDecodeError", 1
)[0]
assert 'if data.get("teacher") is True' in route_metrics
assert '_metrics_event["teacher"] = True' in route_metrics
def test_foreground_terminal_error_reloads_saved_partial_without_typewriter_race():
parser = CHAT_JS.split("if (_nextIsError || json.status >= 400)", 1)[1].split(
"if (json.delta", 1
)[0]
terminal_catch = CHAT_JS.split("if (err.terminalStreamError)", 1)[1].split(
"const errorHolder", 1
)[0]
assert "typewriterInto" not in parser
assert "json.type === 'agent_terminal'" in CHAT_JS
assert "_canonicalTerminalSaved = true" in CHAT_JS
assert "await sessionModule.selectSession(streamSessionId, { showLoading: false })" in terminal_catch
File diff suppressed because it is too large Load Diff
+64 -1
View File
@@ -9,6 +9,8 @@ stream_llm only captured usage when the delta was exactly None / {} /
import asyncio
import json
import pytest
from src import llm_core
@@ -116,7 +118,8 @@ def test_null_choice_chunk_does_not_crash(monkeypatch):
def test_null_choice_with_null_usage_does_not_crash(monkeypatch):
# Chunk with both choices:[null] and usage:null — neither field should panic.
# Chunk with both choices:[null] and usage:null is a keepalive, not a real
# zero-token accounting record.
lines = [
'data: ' + json.dumps({"choices": [{"delta": {"content": "Hi"}}]}),
'data: ' + json.dumps({"choices": [None], "usage": None}),
@@ -124,6 +127,66 @@ def test_null_choice_with_null_usage_does_not_crash(monkeypatch):
]
result = _drive(monkeypatch, lines)
assert "Hi" in result
assert _usage_events(result) == []
def test_empty_usage_object_is_not_reported_as_real_zero_usage(monkeypatch):
lines = [
'data: ' + json.dumps({"choices": [{"delta": {"content": "Hi"}}]}),
'data: ' + json.dumps({"choices": [], "usage": {}}),
'data: [DONE]',
]
result = _drive(monkeypatch, lines)
assert "Hi" in result
assert _usage_events(result) == []
def test_explicit_zero_token_usage_is_preserved(monkeypatch):
lines = [
'data: ' + json.dumps({"choices": [{"delta": {"content": "Hi"}}]}),
'data: ' + json.dumps({
"choices": [],
"usage": {"prompt_tokens": 0, "completion_tokens": 0},
}),
'data: [DONE]',
]
usage = _usage_events(_drive(monkeypatch, lines))
assert usage == [{"input_tokens": 0, "output_tokens": 0}]
@pytest.mark.parametrize(
"usage_payload",
[
{"prompt_tokens": None, "completion_tokens": 1},
{"prompt_tokens": "bad", "completion_tokens": 1},
{"prompt_tokens": -1, "completion_tokens": 1},
{"prompt_tokens": True, "completion_tokens": 1},
{"prompt_tokens": 1.5, "completion_tokens": 1},
{"prompt_tokens": float("inf"), "completion_tokens": 1},
],
)
def test_malformed_token_values_do_not_emit_usage(monkeypatch, usage_payload):
lines = [
'data: ' + json.dumps({"choices": [{"delta": {"content": "Hi"}}]}),
'data: ' + json.dumps({"choices": [], "usage": usage_payload}),
'data: [DONE]',
]
result = _drive(monkeypatch, lines)
assert "Hi" in result
assert _usage_events(result) == []
def test_missing_usage_counterpart_defaults_to_zero(monkeypatch):
lines = [
'data: ' + json.dumps({"choices": [{"delta": {"content": "Hi"}}]}),
'data: ' + json.dumps({
"choices": [],
"usage": {"completion_tokens": 2},
}),
'data: [DONE]',
]
usage = _usage_events(_drive(monkeypatch, lines))
assert usage == [{"input_tokens": 0, "output_tokens": 2}]
def test_null_tool_call_in_delta_is_skipped(monkeypatch):
+21 -1
View File
@@ -98,6 +98,9 @@ def test_endpoint_cleanup_preserves_legacy_default_fallback_data():
{"endpoint_id": "dead", "model": "fallback-a"},
{"endpoint_id": "keep", "model": "fallback-b"},
],
"foreground_model_fallbacks": [
{"endpoint_id": "dead", "model": "foreground"},
],
"utility_model_fallbacks": [{"endpoint_id": "dead", "model": "utility"}],
"vision_model_fallbacks": [{"endpoint_id": "dead", "model": "vision"}],
"stt_provider": "endpoint:dead",
@@ -106,12 +109,14 @@ def test_endpoint_cleanup_preserves_legacy_default_fallback_data():
assert _endpoint_settings_using_endpoint(settings, "dead", include_speech=True) == [
"Default Model",
"Foreground Model Fallbacks",
"Utility Model Fallbacks",
"Vision Model Fallbacks",
"Speech to Text",
]
assert _clear_endpoint_settings_for_endpoint(settings, "dead", include_speech=True) == [
"Default Model",
"Foreground Model Fallbacks",
"Utility Model Fallbacks",
"Vision Model Fallbacks",
"Speech to Text",
@@ -122,6 +127,7 @@ def test_endpoint_cleanup_preserves_legacy_default_fallback_data():
{"endpoint_id": "dead", "model": "fallback-a"},
{"endpoint_id": "keep", "model": "fallback-b"},
]
assert settings["foreground_model_fallbacks"] == []
assert settings["utility_model_fallbacks"] == []
assert settings["vision_model_fallbacks"] == []
assert settings["stt_provider"] == "disabled"
@@ -130,10 +136,19 @@ def test_endpoint_cleanup_preserves_legacy_default_fallback_data():
def test_endpoint_cleanup_updates_active_scoped_prefs_but_preserves_legacy_data():
scoped = {
"foreground_model_fallbacks": [
{"endpoint_id": "dead", "model": "ownerless"},
],
"default_model_fallbacks": [
{"endpoint_id": "dead", "model": "legacy-ownerless"},
],
"_users": {
"alice": {
"utility_endpoint_id": "dead",
"utility_model": "utility",
"foreground_model_fallbacks": [
{"endpoint_id": "dead", "model": "foreground"},
],
"vision_model_fallbacks": [{"endpoint_id": "dead", "model": "vision"}],
},
"bob": {
@@ -142,10 +157,15 @@ def test_endpoint_cleanup_updates_active_scoped_prefs_but_preserves_legacy_data(
},
},
}
assert _clear_user_pref_endpoint_refs(scoped, "dead") == 1
assert _clear_user_pref_endpoint_refs(scoped, "dead") == 2
assert scoped["foreground_model_fallbacks"] == []
assert scoped["default_model_fallbacks"] == [
{"endpoint_id": "dead", "model": "legacy-ownerless"},
]
assert scoped["_users"]["alice"] == {
"utility_endpoint_id": "",
"utility_model": "",
"foreground_model_fallbacks": [],
"vision_model_fallbacks": [],
}
assert scoped["_users"]["bob"]["default_endpoint_id"] == "keep"
@@ -0,0 +1,66 @@
"""Regression tests for polling endpoints and foreground task interruption."""
import asyncio
import importlib
def _reload_gate():
import src.interactive_gate as ig
importlib.reload(ig)
return ig
def test_tasks_runs_recent_is_passive():
ig = _reload_gate()
assert not ig.should_track_interactive_request(
"/api/tasks/runs/recent", "GET"
)
def test_tasks_runs_recent_does_not_affect_other_task_paths():
ig = _reload_gate()
# A neighboring mutating path must remain interactive.
assert ig.should_track_interactive_request(
"/api/tasks/runs/recent/something", "POST"
)
def test_heartbeat_does_not_stop_background_tasks_when_gate_disabled(monkeypatch):
ig = _reload_gate()
monkeypatch.setenv("BACKGROUND_TASK_FOREGROUND_GATE", "false")
stop_calls = []
async def fake_stop_background_tasks_for_foreground(*, reason):
stop_calls.append(reason)
result = asyncio.run(
ig.maybe_stop_background_tasks_for_heartbeat(
fake_stop_background_tasks_for_foreground
)
)
assert result is False
assert stop_calls == []
def test_heartbeat_stops_background_tasks_when_gate_enabled(monkeypatch):
ig = _reload_gate()
monkeypatch.delenv("BACKGROUND_TASK_FOREGROUND_GATE", raising=False)
stop_calls = []
async def fake_stop_background_tasks_for_foreground(*, reason):
stop_calls.append(reason)
result = asyncio.run(
ig.maybe_stop_background_tasks_for_heartbeat(
fake_stop_background_tasks_for_foreground
)
)
assert result is True
assert stop_calls == ["browser heartbeat"]
@@ -0,0 +1,933 @@
"""Executable regressions for the browser/run-lifecycle review of PR #6020.
These tests intentionally exercise JavaScript under Node rather than treating
``node --check`` or source-string presence as proof that the browser paths are
usable. The detached-run replacement case drives the real Python manager.
"""
from __future__ import annotations
import asyncio
import json
from pathlib import Path
import re
import shutil
import subprocess
import pytest
from src import agent_runs
_REPO = Path(__file__).resolve().parents[1]
_CHAT_PATH = _REPO / "static" / "js" / "chat.js"
_CHAT = _CHAT_PATH.read_text(encoding="utf-8")
_RENDERER = (_REPO / "static" / "js" / "chatRenderer.js").read_text(
encoding="utf-8"
)
_STREAM_ERRORS_URI = (_REPO / "static" / "js" / "chatStreamErrors.js").as_uri()
_HAS_NODE = shutil.which("node") is not None
def _extract_source(source: str, start: str, end: str) -> str:
"""Slice module source between two anchors, failing loudly if one moved.
The extracted region ships to Node verbatim, so the anchors must stay
unique strings in the module. A refactor that renames or duplicates an
anchor fails here with the anchor named, not with an opaque split error.
"""
assert source.count(start) == 1, f"start anchor not unique in source: {start!r}"
tail = source.split(start, 1)[1]
assert end in tail, f"end anchor not found after start anchor: {end!r}"
return start + tail.split(end, 1)[0]
def _run_node(source: str) -> dict:
proc = subprocess.run(
["node", "--input-type=module"],
input=source,
capture_output=True,
text=True,
cwd=_REPO,
timeout=30,
)
assert proc.returncode == 0, proc.stderr
# A few imported browser modules log optional-service status at startup.
# Keep the runtime smoke honest while reading only its final JSON result.
return json.loads(proc.stdout.strip().splitlines()[-1])
def _chat_smoke_source(extra_source: str) -> str:
"""Return chat.js source with its real imports made absolute."""
def absolute_import(match: re.Match[str]) -> str:
relative = match.group("relative")
path_part, separator, query = relative.partition("?")
target = (_CHAT_PATH.parent / path_part).resolve().as_uri()
if separator:
target += "?" + query
return match.group("prefix") + target + match.group("quote")
source = re.sub(
r"(?P<prefix>from\s+(?P<quote>['\"]))(?P<relative>\./[^'\"]+)(?P=quote)",
absolute_import,
_CHAT,
)
source += "\n" + extra_source
return source
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_chat_runtime_stream_state_helpers_are_all_callable(tmp_path):
"""Import the real module and execute all three rebased-away helpers."""
module_source = _chat_smoke_source(
"""
export function __pr6020StreamStateSmoke() {
const sid = 'pr6020-runtime-smoke';
const controller = { abort() {}, signal: { aborted: false } };
const originalGetSessionId = sessionModule.getCurrentSessionId;
sessionModule.getCurrentSessionId = () => sid;
try {
_activeStreams.set(sid, {
abortCtrl: controller,
holder: { id: 'holder' },
lastActivity: 0,
});
const active = _getForegroundStreamState();
const touchedAt = _touchStreamActivity(sid);
const synced = _syncForegroundStreamGlobals();
return {
activeController: active && active.abortCtrl === controller,
touched: touchedAt > 0 && _activeStreams.get(sid).lastActivity === touchedAt,
synced: synced === active && currentAbort === controller && isStreaming,
};
} finally {
_activeStreams.delete(sid);
sessionModule.getCurrentSessionId = originalGetSessionId;
}
}
"""
)
module_path = tmp_path / "chat-runtime-smoke.mjs"
module_path.write_text(module_source, encoding="utf-8")
module_uri = module_path.as_uri()
script = f"""
globalThis.window = globalThis;
globalThis.addEventListener = () => {{}};
globalThis.removeEventListener = () => {{}};
globalThis.dispatchEvent = () => {{}};
globalThis.requestAnimationFrame = () => 0;
globalThis.cancelAnimationFrame = () => {{}};
globalThis.fetch = async () => ({{
ok: false,
json: async () => ({{}}),
text: async () => '',
headers: {{ get() {{ return null; }} }},
}});
class Element {{
constructor() {{
this.children = [];
this.classList = {{
add() {{}}, remove() {{}}, toggle() {{}}, contains() {{ return false; }},
}};
this.style = {{ setProperty() {{}} }};
this.dataset = {{}};
}}
querySelector() {{ return null; }}
querySelectorAll() {{ return []; }}
appendChild(child) {{ this.children.push(child); return child; }}
addEventListener() {{}}
removeEventListener() {{}}
}}
class HTMLInputElement extends Element {{
get value() {{ return this._value || ''; }}
set value(value) {{ this._value = value; }}
}}
globalThis.HTMLInputElement = HTMLInputElement;
const root = new Element();
globalThis.document = {{
body: root,
head: root,
documentElement: root,
getElementById() {{ return null; }},
querySelector() {{ return null; }},
querySelectorAll() {{ return []; }},
createElement(tag) {{ return tag === 'input' ? new HTMLInputElement() : new Element(); }},
createTextNode(text) {{ return {{ textContent: text }}; }},
addEventListener() {{}},
removeEventListener() {{}},
}};
globalThis.localStorage = {{ getItem() {{ return null; }}, setItem() {{}}, removeItem() {{}} }};
globalThis.location = {{}};
globalThis.history = {{}};
globalThis.MutationObserver = class {{ observe() {{}} }};
globalThis.CustomEvent = class {{}};
globalThis.Storage = class {{}};
const chat = await import({json.dumps(module_uri)});
console.log(JSON.stringify(chat.__pr6020StreamStateSmoke()));
process.exit(0);
"""
assert _run_node(script) == {
"activeController": True,
"touched": True,
"synced": True,
}
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_canonical_terminal_followed_by_eof_is_not_auto_recovered():
"""A persisted terminal marker owns EOF; a plain premature EOF still retries."""
completion_gate = _extract_source(
_CHAT,
"if (_streamTerminalError)",
"// The final foreground render below is authoritative.",
)
script = f"""
import {{
createTerminalStreamError,
isRecoverableStreamError,
}} from {json.dumps(_STREAM_ERRORS_URI)};
function runCompletionGate(canonicalTerminalSaved) {{
let _streamTerminalError = null;
let _streamSawDone = false;
let _canonicalTerminalSaved = canonicalTerminalSaved;
try {{
{completion_gate}
return {{ recovered: false, completed: true }};
}} catch (error) {{
return {{
recovered: isRecoverableStreamError(error),
completed: false,
terminal: !!error.terminalStreamError,
message: error.message,
}};
}}
}}
console.log(JSON.stringify({{
savedTerminal: runCompletionGate(true),
plainEof: runCompletionGate(false),
}}));
"""
assert _run_node(script) == {
# A saved canonical terminal must neither auto-recover nor render as a
# clean success: it takes the terminal-error path, whose catch handler
# reloads the persisted record.
"savedTerminal": {
"recovered": False,
"completed": False,
"terminal": True,
"message": "Stream closed after canonical terminal event",
},
"plainEof": {
"recovered": True,
"completed": False,
"terminal": False,
"message": "Stream closed before completion",
},
}
@pytest.mark.asyncio
async def test_immediate_replacement_closes_subscriber_bound_to_never_started_run():
"""Cancellation before _drain's first instruction must still terminalize run 1."""
session_id = "pr6020-immediate-replacement"
agent_runs._RUNS.pop(session_id, None)
async def never_started():
yield 'data: {"delta":"old"}\n\n'
async def replacement():
yield 'data: {"delta":"new"}\n\n'
first = agent_runs.start(session_id, never_started())
first_subscription = asyncio.create_task(
_collect_run_events(session_id, first)
)
# Do not yield between starts: first.task is cancelled before _drain gets
# its first instruction, exactly the race a rapid double-send creates.
second = agent_runs.start(session_id, replacement())
assert await asyncio.wait_for(first_subscription, timeout=0.5) == []
await asyncio.wait_for(second.task, timeout=0.5)
assert first.status == "stopped"
assert second.status == "done"
async def _collect_run_events(session_id: str, run: object) -> list[str]:
return [event async for event in agent_runs.subscribe(session_id, run)]
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_stop_before_response_headers_waits_for_exact_run_identity():
"""Never send headerless Stop, but flush the queued Stop once headers arrive."""
state_and_stop = _extract_source(
_CHAT, "const _backgroundStreams", "// Sources box builder"
)
header_capture = _extract_source(
_CHAT,
"const streamRunId = res.headers.get('X-Odysseus-Run-Id')",
"// Mark the chat log busy",
)
script = f"""
const calls = [];
function _setForegroundChatBusy() {{}}
const window = {{}};
const sessionModule = {{ getCurrentSessionId() {{ return 'session-1'; }} }};
const fetch = async (url, options) => {{ calls.push({{ url, options }}); return {{ ok: true }}; }};
{state_and_stop}
{{
const streamSessionId = 'normal-session';
const streamGeneration = 1;
_streamGenerations.set(streamSessionId, streamGeneration);
const res = {{ headers: {{ get(name) {{
return name === 'X-Odysseus-Run-Id' ? 'normal-run' : null;
}} }} }};
{header_capture}
}}
await new Promise(resolve => setTimeout(resolve, 0));
const normalHeaderCalls = calls.length;
let beforeHeaders;
{{
const streamSessionId = 'session-1';
const streamGeneration = 1;
_streamGenerations.set(streamSessionId, streamGeneration);
_stopExactRun(streamSessionId);
beforeHeaders = calls.length;
const res = {{ headers: {{ get(name) {{
return name === 'X-Odysseus-Run-Id' ? 'run-1' : null;
}} }} }};
{header_capture}
}}
await new Promise(resolve => setTimeout(resolve, 0));
console.log(JSON.stringify({{
normalHeaderCalls,
beforeHeaders,
calls: calls.map(call => ({{
url: call.url,
method: call.options.method,
runId: call.options.headers['X-Odysseus-Run-Id'],
}})),
}}));
"""
assert _run_node(script) == {
"normalHeaderCalls": 0,
"beforeHeaders": 0,
"calls": [
{
"url": "/api/chat/stop/session-1",
"method": "POST",
"runId": "run-1",
}
],
}
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_timeout_before_response_headers_also_waits_for_exact_run_identity():
"""The automatic timeout must preserve the POST until its run id arrives."""
script = f"""
{_timeout_harness_prelude()}
callbacks[0]();
const beforeHeaders = {{ aborted: abortCtrl.signal.aborted, calls: calls.length }};
_rememberStreamRunId(streamSessionId, 'run-1', streamGeneration);
await Promise.resolve();
console.log(JSON.stringify({{
beforeHeaders,
afterHeaders: {{
aborted: abortCtrl.signal.aborted,
runId: calls[0] && calls[0].options.headers['X-Odysseus-Run-Id'],
}},
}}));
"""
assert _run_node(script) == {
"beforeHeaders": {"aborted": False, "calls": 0},
"afterHeaders": {"aborted": True, "runId": "run-1"},
}
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_resend_preserves_queued_stop_until_old_run_identity_arrives():
"""A replacement must not sever the superseded POST's identity channel.
The queued Stop stays generation-tagged and fires from the OLD send's own
header arrival, so the old run is cancelled even when the replacement dies
before its POST reaches the server (which is what would otherwise cancel
it). The old run id must not leak into the replacement's identity map.
"""
state_and_stop = _extract_source(
_CHAT, "const _backgroundStreams", "// Sources box builder"
)
resend_reset = _extract_source(
_CHAT,
"const streamGeneration = (_streamGenerations.get(streamSessionId) || 0) + 1;",
"_sendInFlight = false;",
)
header_capture = _extract_source(
_CHAT,
"const streamRunId = res.headers.get('X-Odysseus-Run-Id')",
"// Mark the chat log busy",
)
script = f"""
const calls = [];
function _setForegroundChatBusy() {{}}
const window = {{}};
const sessionModule = {{ getCurrentSessionId() {{ return 'session-1'; }} }};
const fetch = async (url, options) => {{ calls.push({{ url, options }}); return {{ ok: true }}; }};
{state_and_stop}
const oldCtrl = {{
_reason: '',
signal: {{ aborted: false }},
abort() {{ this.signal.aborted = true; }},
}};
// Old send (generation 1) queues a Stop before its headers arrive.
_streamGenerations.set('session-1', 1);
const oldGeneration = 1;
_stopExactRun('session-1', oldCtrl);
const queuedBefore = _pendingRunStops.has('session-1:1');
// Replacement send starts: bumps the generation, leaves the queued Stop.
{{
const streamSessionId = 'session-1';
{resend_reset}
}}
// The replacement is ALSO stopped before its headers arrive: both
// sends' cancellation intents must coexist, neither displacing the
// other (a single session-keyed slot loses the old send's Stop, and
// with it the only cancel for that run if this replacement dies
// before its own POST reaches the server).
const newCtrl = {{
_reason: '',
signal: {{ aborted: false }},
abort() {{ this.signal.aborted = true; }},
}};
_stopExactRun('session-1', newCtrl);
const afterResend = {{
oldQueuedKept: _pendingRunStops.has('session-1:1'),
newQueued: _pendingRunStops.has('session-1:2'),
oldAborted: oldCtrl.signal.aborted,
generation: _streamGenerations.get('session-1'),
}};
// The old POST's headers finally arrive: its queued Stop fires with its
// own run id, and the old controller aborts.
{{
const streamSessionId = 'session-1';
const streamGeneration = oldGeneration;
const res = {{ headers: {{ get(name) {{
return name === 'X-Odysseus-Run-Id' ? 'old-run' : null;
}} }} }};
{header_capture}
}}
await new Promise(resolve => setTimeout(resolve, 0));
console.log(JSON.stringify({{
queuedBefore,
afterResend,
afterOldHeaders: {{
oldQueued: _pendingRunStops.has('session-1:1'),
newQueuedKept: _pendingRunStops.has('session-1:2'),
oldAborted: oldCtrl.signal.aborted,
oldReason: oldCtrl._reason,
newAborted: newCtrl.signal.aborted,
currentRunIdPolluted: _streamRunIds.has('session-1'),
stopCalls: calls.map(call => ({{
url: call.url,
runId: call.options.headers['X-Odysseus-Run-Id'],
}})),
}},
}}));
"""
assert _run_node(script) == {
"queuedBefore": True,
"afterResend": {
"oldQueuedKept": True,
"newQueued": True,
"oldAborted": False,
"generation": 2,
},
"afterOldHeaders": {
"oldQueued": False,
# The replacement's own queued Stop must survive the old send's
# flush untouched.
"newQueuedKept": True,
"oldAborted": True,
"oldReason": "user-stop",
"newAborted": False,
# The stale send's run id must not become the replacement's
# identity, but its exact Stop must still go out.
"currentRunIdPolluted": False,
"stopCalls": [
{"url": "/api/chat/stop/session-1", "runId": "old-run"}
],
},
}
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_superseded_stream_cleanup_leaves_replacement_state_alone():
"""A stale send's finally must not clear state the replacement owns.
Ownership is decided by generation, which the replacement bumps at its
very first synchronous step so the guard holds even in the window
BEFORE the replacement registers its own stream entry (where the old
finally still sees its own registration and controller identity alone
would call it the owner).
"""
state_and_stop = _extract_source(
_CHAT, "const _backgroundStreams", "// Sources box builder"
)
finally_cleanup = _extract_source(
_CHAT,
"const _ownsStreamState =",
"// Streaming done — let screen readers announce",
)
script = f"""
let currentAbort = null;
let isStreaming = false;
let currentHolder = null;
let _sendInFlight = false;
function _setForegroundChatBusy() {{}}
const window = {{}};
const sessionModule = {{ getCurrentSessionId() {{ return 'session-1'; }} }};
{state_and_stop}
function runCleanup(abortCtrl, streamGeneration) {{
const streamSessionId = 'session-1';
const _sendState = {{ generation: streamGeneration, abortCtrl }};
{finally_cleanup}
return _ownsStreamState;
}}
const oldCtrl = {{ signal: {{ aborted: true }}, abort() {{}} }};
const newCtrl = {{ signal: {{ aborted: false }}, abort() {{}} }};
// Pre-registration supersession: the replacement bumped the generation
// and set the session id, but has NOT registered its stream entry yet
// the old send's own entry is still the one in the map.
_streamGenerations.set('session-1', 2);
_streamSessionId = 'session-1';
_activeStreams.set('session-1', {{ abortCtrl: oldCtrl, holder: null, lastActivity: 1 }});
_pendingRunStops.set('session-1:2', newCtrl);
const preRegOwns = runCleanup(oldCtrl, 1);
const afterPreReg = {{
ownEntryRemoved: !_activeStreams.has('session-1'),
replacementPendingKept: _pendingRunStops.has('session-1:2'),
sessionKept: _streamSessionId === 'session-1',
}};
// Post-registration supersession: the replacement's entry is in the map.
_activeStreams.set('session-1', {{ abortCtrl: newCtrl, holder: null, lastActivity: 2 }});
const postRegOwns = runCleanup(oldCtrl, 1);
const afterPostReg = {{
replacementRegistrationKept: _activeStreams.has('session-1'),
replacementPendingKept: _pendingRunStops.has('session-1:2'),
sessionKept: _streamSessionId === 'session-1',
}};
// Owner: the current-generation send cleans up normally.
const ownerOwns = runCleanup(newCtrl, 2);
const afterOwner = {{
registered: _activeStreams.has('session-1'),
pendingKept: _pendingRunStops.has('session-1:2'),
sessionCleared: _streamSessionId === null,
}};
console.log(JSON.stringify({{
preRegOwns, afterPreReg, postRegOwns, afterPostReg, ownerOwns, afterOwner,
}}));
"""
assert _run_node(script) == {
"preRegOwns": False,
"afterPreReg": {
"ownEntryRemoved": True,
"replacementPendingKept": True,
"sessionKept": True,
},
"postRegOwns": False,
"afterPostReg": {
"replacementRegistrationKept": True,
"replacementPendingKept": True,
"sessionKept": True,
},
"ownerOwns": True,
"afterOwner": {
"registered": False,
"pendingKept": False,
"sessionCleared": True,
},
}
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_real_reservation_supersedes_and_stale_cleanup_keeps_gate_closed():
"""Drive the REAL send-commit reservation, then a stale send's cleanup.
The reservation is synchronous, so the previous send is superseded before
any await runs; its cleanup must then neither clear session state nor
resync the foreground globals (a stale sync would set isStreaming false
while _sendInFlight is already false, reopening the send gate before the
replacement registers).
"""
state_and_stop = _extract_source(
_CHAT, "const _backgroundStreams", "// Sources box builder"
)
reservation = _extract_source(
_CHAT,
"const streamGeneration = (_streamGenerations.get(streamSessionId) || 0) + 1;",
"_sendInFlight = false;",
)
finally_cleanup = _extract_source(
_CHAT,
"const _ownsStreamState =",
"// Streaming done — let screen readers announce",
)
script = f"""
let currentAbort = null;
let isStreaming = true;
let currentHolder = null;
let _sendInFlight = false;
function _setForegroundChatBusy() {{}}
const window = {{}};
const sessionModule = {{ getCurrentSessionId() {{ return 'session-1'; }} }};
{state_and_stop}
const oldCtrl = {{ signal: {{ aborted: false }}, abort() {{ this.signal.aborted = true; }} }};
// Old send (generation 1) is mid-stream and registered.
_streamGenerations.set('session-1', 1);
_streamSessionId = 'session-1';
_activeStreams.set('session-1', {{ abortCtrl: oldCtrl, holder: null, lastActivity: 1 }});
// Replacement commits: run the REAL reservation block synchronously.
let installed;
{{
const streamSessionId = 'session-1';
{reservation}
installed = {{ generation: streamGeneration, sendState: _sendState }};
}}
const afterReservation = {{
generation: _streamGenerations.get('session-1'),
sendStateInstalled: _sendStates.get('session-1') === installed.sendState,
controllerPending: installed.sendState.abortCtrl === null,
}};
// Old send's cleanup runs mid-preflight (before the replacement
// registers): it must treat itself as superseded.
let staleOwns;
{{
const streamSessionId = 'session-1';
const streamGeneration = 1;
const abortCtrl = oldCtrl;
const _sendState = {{ generation: 1, abortCtrl: oldCtrl }};
{finally_cleanup}
staleOwns = _ownsStreamState;
}}
console.log(JSON.stringify({{
afterReservation,
staleOwns,
afterStaleCleanup: {{
sessionKept: _streamSessionId === 'session-1',
sendStateKept: _sendStates.get('session-1') === installed.sendState,
gateStillClosed: isStreaming === true,
}},
}}));
"""
assert _run_node(script) == {
"afterReservation": {
"generation": 2,
"sendStateInstalled": True,
"controllerPending": True,
},
"staleOwns": False,
"afterStaleCleanup": {
"sessionKept": True,
"sendStateKept": True,
# isStreaming untouched because the superseded send skipped the
# foreground resync entirely.
"gateStillClosed": True,
},
}
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_stale_preflight_bails_before_creating_controller():
"""A send superseded during preflight must not proceed to register/POST."""
state_and_stop = _extract_source(
_CHAT, "const _backgroundStreams", "// Sources box builder"
)
preflight_gate = _extract_source(
_CHAT, "// Superseded during preflight", "currentAbort = abortCtrl;"
) + "currentAbort = abortCtrl;"
script = f"""
let currentAbort = null;
let isStreaming = false;
let currentHolder = null;
let _sendInFlight = false;
function _setForegroundChatBusy() {{}}
const window = {{}};
const document = {{
createElement() {{ return {{ style: {{}}, textContent: '' }}; }},
}};
const sessionModule = {{ getCurrentSessionId() {{ return 'session-1'; }} }};
{state_and_stop}
function runPreflightGate(streamGeneration, _sendState, _userMsgEl) {{
const streamSessionId = 'session-1';
let abortCtrl = null;
{preflight_gate}
return abortCtrl;
}}
// Stale: generation 1 resumes after generation 2 reserved the session.
// Its optimistic user bubble must be marked undelivered, not left as a
// ghost that looks sent.
_streamGenerations.set('session-1', 2);
const staleState = {{ generation: 1, abortCtrl: null }};
const staleBubble = {{
parentNode: {{}},
notes: [],
appendChild(node) {{ this.notes.push(node.textContent); }},
}};
const staleResult = runPreflightGate(1, staleState, staleBubble);
// Current: generation 2 proceeds and wires its controller.
const currentState = {{ generation: 2, abortCtrl: null }};
const currentBubble = {{
parentNode: {{}},
notes: [],
appendChild(node) {{ this.notes.push(node.textContent); }},
}};
const currentResult = runPreflightGate(2, currentState, currentBubble);
console.log(JSON.stringify({{
staleBailed: staleResult === undefined,
staleControllerNever: staleState.abortCtrl === null,
staleBubbleNotes: staleBubble.notes,
currentProceeded: !!currentResult,
currentWired: currentState.abortCtrl === currentResult && currentAbort === currentResult,
currentBubbleNotes: currentBubble.notes,
}}));
"""
assert _run_node(script) == {
"staleBailed": True,
"staleControllerNever": True,
"staleBubbleNotes": ["[Not sent — superseded by a newer message]"],
"currentProceeded": True,
"currentWired": True,
"currentBubbleNotes": [],
}
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_stop_during_replacement_preflight_never_borrows_old_controller():
"""Stop must take the current send's controller from its send state.
During the replacement's preflight the stream registry still holds the
superseded send's entry; borrowing that controller would abort the only
identity channel able to name the old run while queueing a Stop for the
new one. A committed-but-pre-POST send has a null controller: the Stop
queues under the new generation and nothing is aborted yet.
"""
state_and_stop = _extract_source(
_CHAT, "const _backgroundStreams", "// Sources box builder"
)
abort_current = _extract_source(
_CHAT, "export function abortCurrentRequest", "// ── Stall watchdog"
).replace("export function", "function")
reservation = _extract_source(
_CHAT,
"const streamGeneration = (_streamGenerations.get(streamSessionId) || 0) + 1;",
"_sendInFlight = false;",
)
script = f"""
let currentAbort = null;
let isStreaming = false;
let currentHolder = null;
let _sendInFlight = false;
function _setForegroundChatBusy() {{}}
const calls = [];
const fetch = async (url, options) => {{ calls.push({{ url, options }}); return {{ ok: true }}; }};
const window = {{}};
const sessionModule = {{ getCurrentSessionId() {{ return 'session-1'; }} }};
{state_and_stop}
{abort_current}
const oldCtrl = {{ signal: {{ aborted: false }}, abort() {{ this.signal.aborted = true; }} }};
// Generation 1 is registered, streaming, and its run id is KNOWN the
// exact window daybreak probed: a Stop right after the replacement
// commits must not consume the old run identity.
_streamGenerations.set('session-1', 1);
_streamRunIds.set('session-1', 'old-run');
_activeStreams.set('session-1', {{ abortCtrl: oldCtrl, holder: null, lastActivity: 1 }});
currentAbort = oldCtrl;
// Replacement (generation 2) commits via the REAL reservation block; no
// controller exists yet and the model-switch await has not resolved.
{{
const streamSessionId = 'session-1';
{reservation}
}}
abortCurrentRequest(true);
const preRegistration = {{
oldRunIdCleared: !_streamRunIds.has('session-1'),
queuedForNew: _pendingRunStops.has('session-1:2'),
queuedController: _pendingRunStops.get('session-1:2') || null,
oldAborted: oldCtrl.signal.aborted,
stopCalls: calls.length,
}};
// Normal case: the current send's own controller, run id known.
const ownCtrl = {{ _reason: '', signal: {{ aborted: false }}, abort() {{ this.signal.aborted = true; }} }};
_sendStates.set('session-1', {{ generation: 2, abortCtrl: ownCtrl }});
_streamRunIds.set('session-1', 'run-2');
abortCurrentRequest(true);
await new Promise(resolve => setTimeout(resolve, 0));
console.log(JSON.stringify({{
preRegistration,
normal: {{
ownAborted: ownCtrl.signal.aborted,
oldStillUntouched: oldCtrl.signal.aborted,
stopRunId: calls[0] && calls[0].options.headers['X-Odysseus-Run-Id'],
}},
}}));
"""
assert _run_node(script) == {
"preRegistration": {
# The old run identity dies at reservation: the Stop queues for
# the NEW send instead of firing against the old run and skipping
# the queue entirely.
"oldRunIdCleared": True,
"queuedForNew": True,
"queuedController": None,
"oldAborted": False,
"stopCalls": 0,
},
"normal": {
"ownAborted": True,
"oldStillUntouched": False,
"stopRunId": "run-2",
},
}
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_timeout_grace_hard_aborts_when_run_identity_never_arrives():
"""A POST hung before headers is still cancelled by the timeout's grace."""
script = f"""
{_timeout_harness_prelude()}
callbacks[0]();
const afterTimeout = {{ aborted: abortCtrl.signal.aborted, pending: callbacks.length }};
callbacks[1]();
console.log(JSON.stringify({{
afterTimeout,
afterGrace: {{ aborted: abortCtrl.signal.aborted, stopCalls: calls.length }},
}}));
"""
assert _run_node(script) == {
"afterTimeout": {"aborted": False, "pending": 2},
"afterGrace": {"aborted": True, "stopCalls": 0},
}
def _timeout_harness_prelude() -> str:
"""Shared Node harness: real stop/state and timeout blocks, fake timers."""
state_and_stop = _extract_source(
_CHAT, "const _backgroundStreams", "// Sources box builder"
)
timeout_setup = _extract_source(
_CHAT, "timeoutId = setTimeout(() =>", "}, timeoutMs);"
) + "}, timeoutMs);"
return f"""
const calls = [];
const callbacks = [];
function _setForegroundChatBusy() {{}}
function setTimeout(callback) {{ callbacks.push(callback); return 1; }}
const window = {{}};
const sessionModule = {{ getCurrentSessionId() {{ return 'session-1'; }} }};
const fetch = async (url, options) => {{ calls.push({{ url, options }}); return {{ ok: true }}; }};
const RUN_ID_ABORT_GRACE_MS = 2000;
{state_and_stop}
const streamSessionId = 'session-1';
const streamGeneration = 1;
_streamGenerations.set(streamSessionId, streamGeneration);
const timeoutMs = 1;
let timeoutId;
let timedOut = false;
const abortCtrl = {{
_reason: '',
signal: {{ aborted: false }},
abort() {{ this.signal.aborted = true; }},
}};
{timeout_setup}
"""
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_cost_ledger_serializes_stale_cross_tab_writers():
"""A stale writer must merge, not overwrite a distinct run recorded by a peer."""
ledger = _extract_source(
_RENDERER, "const _COST_KEY", "/** Create a timestamp span"
).replace("export function", "function")
script = f"""
const state = {{}};
let triggerPeerWrite = true;
let lockTail = Promise.resolve();
const navigator = {{ locks: {{
request(_name, callback) {{
const next = lockTail.then(callback);
lockTail = next.catch(() => {{}});
return next;
}},
}} }};
const window = {{ sessionModule: {{ getCurrentSessionId() {{ return 'session'; }} }} }};
const document = {{ getElementById() {{ return null; }} }};
function _metricsBillableCost(metrics) {{ return metrics.testCost; }}
const peerMetrics = {{ testCost: 0.22, _costRecordId: 'run-b' }};
let tabA;
let tabB;
const localStorage = {{
getItem(key) {{
const staleSnapshot = state[key] || null;
if (key === 'ody-session-cost-runs' && triggerPeerWrite) {{
triggerPeerWrite = false;
tabB.recordSessionMetricsCost(peerMetrics, 'session');
}}
return staleSnapshot;
}},
setItem(key, value) {{ state[key] = value; }},
}};
function createTab() {{
{ledger}
return {{ recordSessionMetricsCost }};
}}
tabA = createTab();
tabB = createTab();
const metricsA = {{ testCost: 0.11, _costRecordId: 'run-a' }};
tabA.recordSessionMetricsCost(metricsA, 'session');
const queued = {{
recorded: !!metricsA._costRecorded,
pending: !!metricsA._costRecordPending,
}};
await new Promise(resolve => setTimeout(resolve, 0));
await lockTail;
const runs = JSON.parse(state['ody-session-cost-runs'] || '{{}}').session || {{}};
console.log(JSON.stringify({{
queued,
settled: {{
recorded: !!metricsA._costRecorded,
pending: !!metricsA._costRecordPending,
}},
runs,
}}));
"""
assert _run_node(script) == {
# Recorded must not be claimed while the write only sits queued behind
# the lock; it flips once the write has actually run.
"queued": {"recorded": False, "pending": True},
"settled": {"recorded": True, "pending": False},
"runs": {"run-a": 0.11, "run-b": 0.22},
}
+335
View File
@@ -0,0 +1,335 @@
"""Executable regression coverage for behavior lost in PR #6020's rebase."""
import asyncio
import json
import src.agent_loop as agent_loop
ODY_QWEN = "odysseus-qwen3-4b"
NOTES_TOOLS = {
"manage_notes",
"manage_calendar",
"manage_tasks",
"ask_user",
"update_plan",
}
def _collect(generator):
async def _run():
return [chunk async for chunk in generator]
return asyncio.run(_run())
def _events(chunks):
return [
json.loads(chunk[6:])
for chunk in chunks
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]")
]
def _install_route_probe(monkeypatch):
prompt_calls = []
stream_calls = []
monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default)
monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None)
monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10)
monkeypatch.setattr(agent_loop, "blocked_tools_for_owner", lambda owner: set())
monkeypatch.setattr(
agent_loop,
"_agent_route_tool_mode",
lambda *args, **kwargs: (True, False, False),
)
def fake_build(
messages,
model,
_active_document,
_mcp_mgr,
disabled_tools=None,
**kwargs,
):
prompt_calls.append(
{
"model": model,
"relevant_tools": set(kwargs.get("relevant_tools") or set()),
"disabled_tools": set(disabled_tools or set()),
"workspace": kwargs.get("workspace"),
}
)
return (list(messages), [])
async def fake_stream(_candidates, _messages, **kwargs):
stream_calls.append(kwargs)
yield 'data: {"delta": "ok"}\n\n'
yield "data: [DONE]\n\n"
monkeypatch.setattr(agent_loop, "_build_system_prompt", fake_build)
monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
return prompt_calls, stream_calls
def _run_probe(messages, *, relevant_tools, **kwargs):
return _collect(
agent_loop.stream_agent_loop(
"https://api.example/v1",
kwargs.pop("model", ODY_QWEN),
messages,
max_rounds=1,
relevant_tools=set(relevant_tools),
_is_teacher_run=True,
**kwargs,
)
)
def test_odysseus_notes_mode_clamps_and_reenables_all_personal_managers(monkeypatch):
prompt_calls, _ = _install_route_probe(monkeypatch)
_run_probe(
[{"role": "user", "content": "Add buy milk to my notes."}],
relevant_tools={"bash", "manage_notes", "manage_calendar", "manage_tasks"},
disabled_tools={"manage_notes", "manage_calendar", "manage_tasks"},
)
route = prompt_calls[0]
assert route["relevant_tools"] == NOTES_TOOLS
assert route["disabled_tools"].isdisjoint(
{"manage_notes", "manage_calendar", "manage_tasks"}
)
def test_odysseus_general_mode_disables_every_tool(monkeypatch):
from src.tool_policy import known_tool_names
prompt_calls, _ = _install_route_probe(monkeypatch)
_run_probe(
[{"role": "user", "content": "Explain the CAP theorem."}],
relevant_tools={"bash", "manage_notes", "ask_user"},
)
route = prompt_calls[0]
assert route["relevant_tools"] == set()
assert known_tool_names() <= route["disabled_tools"]
def test_odysseus_calendar_intent_uses_notes_mode(monkeypatch):
prompt_calls, _ = _install_route_probe(monkeypatch)
_run_probe(
[{"role": "user", "content": "Add lunch tomorrow to my calendar."}],
relevant_tools={"manage_notes", "manage_calendar", "manage_tasks", "bash"},
)
assert prompt_calls[0]["relevant_tools"] == NOTES_TOOLS
def test_odysseus_calendar_followup_keeps_notes_mode(monkeypatch):
prompt_calls, _ = _install_route_probe(monkeypatch)
messages = [
{"role": "user", "content": "Add lunch tomorrow to my calendar."},
{
"role": "assistant",
"content": "Done.",
"metadata": {
"tool_events": [
{
"tool": "manage_calendar",
"command": '{"action":"create_event","summary":"Lunch"}',
"output": "Created event evt-123 at noon.",
}
]
},
},
{"role": "user", "content": "Move it to 3pm."},
]
_run_probe(
messages,
relevant_tools={"manage_notes", "manage_calendar", "manage_tasks", "bash"},
)
assert prompt_calls[0]["relevant_tools"] == NOTES_TOOLS
def test_agent_route_passes_workspace_to_system_prompt(monkeypatch):
prompt_calls, _ = _install_route_probe(monkeypatch)
_run_probe(
[{"role": "user", "content": "Fix the failing test in this project."}],
model="gpt-4o",
relevant_tools={"bash", "read_file", "apply_patch"},
workspace="/tmp/example-repo",
)
assert prompt_calls[0]["workspace"] == "/tmp/example-repo"
def test_odysseus_qwen_temperature_is_capped_for_agent_requests(monkeypatch):
_, stream_calls = _install_route_probe(monkeypatch)
_run_probe(
[{"role": "user", "content": "Add buy milk to my notes."}],
relevant_tools={"manage_notes"},
temperature=1.2,
)
assert stream_calls[0]["temperature"] == 0.2
def test_qwen_fallback_candidate_gets_capped_temperature(monkeypatch):
"""A non-qwen primary must not leak its temperature into a qwen fallback."""
_, stream_calls = _install_route_probe(monkeypatch)
_run_probe(
[{"role": "user", "content": "Explain the CAP theorem."}],
model="gpt-4o",
relevant_tools={"bash"},
temperature=1.2,
fallbacks=[("https://qwen.example/v1", ODY_QWEN, {})],
)
assert stream_calls[0]["temperature"] == 1.2
factory = stream_calls[0]["candidate_request_factory"]
request = asyncio.run(factory(1, "https://qwen.example/v1", ODY_QWEN, {}))
assert request["kwargs"]["temperature"] == 0.2
def test_non_qwen_fallback_keeps_requested_temperature(monkeypatch):
"""A qwen primary's 0.2 cap must not leak into a non-qwen fallback."""
_, stream_calls = _install_route_probe(monkeypatch)
_run_probe(
[{"role": "user", "content": "Add buy milk to my notes."}],
relevant_tools={"manage_notes"},
temperature=1.2,
fallbacks=[("https://backup.example/v1", "gpt-4o", {})],
)
assert stream_calls[0]["temperature"] == 0.2
factory = stream_calls[0]["candidate_request_factory"]
request = asyncio.run(factory(1, "https://backup.example/v1", "gpt-4o", {}))
assert request["kwargs"]["temperature"] == 1.2
def test_qwen_notes_fallback_reenables_personal_managers(monkeypatch):
"""The answering candidate's notes mode must unblock the managers for
execution, not just enable them in its own route schemas."""
_install_route_probe(monkeypatch)
stream_round = 0
resolve_round = 0
seen_exec = {}
async def fake_stream(_candidates, _messages, **kwargs):
nonlocal stream_round
stream_round += 1
if stream_round == 1:
yield (
"data: "
+ json.dumps(
{
"type": "fallback",
"answered_by": ODY_QWEN,
"candidate_index": 1,
}
)
+ "\n\n"
)
yield 'data: {"delta": "Adding the note."}\n\n'
else:
yield 'data: {"delta": "Done."}\n\n'
yield "data: [DONE]\n\n"
def fake_resolve(*args, **kwargs):
nonlocal resolve_round
resolve_round += 1
if resolve_round == 1:
return ([agent_loop.ToolBlock("manage_notes", "{}")], False, [])
return ([], False, [])
async def fake_execute(block, *args, **kwargs):
# Execution is the consumer daybreak's probe showed rejecting the
# managers: it receives the shared disabled_tools set, not the
# answering route's own tool state.
seen_exec["disabled_tools"] = set(kwargs.get("disabled_tools") or [])
return ("manage_notes: saved", {"output": "noted", "exit_code": 0})
monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
monkeypatch.setattr(agent_loop, "_resolve_tool_blocks", fake_resolve)
monkeypatch.setattr(agent_loop, "execute_tool_block", fake_execute)
_collect(
agent_loop.stream_agent_loop(
"https://api.example/v1",
"gpt-4o",
[{"role": "user", "content": "Add buy milk to my notes."}],
max_rounds=2,
relevant_tools={"manage_notes", "manage_calendar", "manage_tasks", "bash"},
disabled_tools={"manage_notes", "manage_calendar", "manage_tasks"},
fallbacks=[("https://qwen.example/v1", ODY_QWEN, {})],
_is_teacher_run=True,
)
)
assert seen_exec["disabled_tools"].isdisjoint(
{"manage_notes", "manage_calendar", "manage_tasks"}
)
def test_persisted_mcp_tool_event_keeps_description_and_resolved_name(monkeypatch):
_install_route_probe(monkeypatch)
stream_round = 0
resolve_round = 0
async def fake_stream(_candidates, _messages, **kwargs):
nonlocal stream_round
stream_round += 1
if stream_round == 1:
yield 'data: {"delta": "Calling calendar."}\n\n'
else:
yield 'data: {"delta": "Finished."}\n\n'
yield "data: [DONE]\n\n"
def fake_resolve(*args, **kwargs):
nonlocal resolve_round
resolve_round += 1
if resolve_round == 1:
return ([agent_loop.ToolBlock("mcp", "{}")], False, [])
return ([], False, [])
async def fake_execute(block, *args, **kwargs):
assert block.tool_type == "mcp"
return (
"mcp__calendar__create_event: created team sync",
{"output": "Created event evt-456.", "exit_code": 0},
)
monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
monkeypatch.setattr(agent_loop, "_resolve_tool_blocks", fake_resolve)
monkeypatch.setattr(agent_loop, "execute_tool_block", fake_execute)
chunks = _collect(
agent_loop.stream_agent_loop(
"https://api.example/v1",
"gpt-4o",
[{"role": "user", "content": "Create the team sync event."}],
max_rounds=2,
relevant_tools={"mcp"},
_is_teacher_run=True,
)
)
metrics = next(
event["data"] for event in _events(chunks) if event.get("type") == "metrics"
)
persisted = metrics["tool_events"][0]
assert persisted["tool"] == "mcp__calendar__create_event"
assert persisted["desc"] == "mcp__calendar__create_event: created team sync"
+23 -1
View File
@@ -17,4 +17,26 @@ def test_load_keeps_object_prefs_file(tmp_path, monkeypatch):
prefs_file.write_text(json.dumps({"theme": "dark"}), encoding="utf-8")
monkeypatch.setattr(prefs_routes, "PREFS_FILE", str(prefs_file))
assert prefs_routes._load_for_user("alice") == {"theme": "dark"}
assert prefs_routes._load_for_user(None) == {"theme": "dark"}
assert prefs_routes._load_for_user("alice") == {}
def test_named_preference_write_does_not_copy_flat_fallback_consent(tmp_path, monkeypatch):
prefs_file = tmp_path / "user_prefs.json"
prefs_file.write_text(json.dumps({
"theme": "light",
"foreground_fallback_enabled": True,
"foreground_model_fallbacks": [
{"endpoint_id": "legacy-single-user", "model": "legacy-model"},
],
}), encoding="utf-8")
monkeypatch.setattr(prefs_routes, "PREFS_FILE", str(prefs_file))
bob = prefs_routes._load_for_user("bob")
bob["theme"] = "dark"
prefs_routes._save_for_user("bob", bob)
raw = prefs_routes._load()
assert raw["_users"] == {"bob": {"theme": "dark"}}
assert raw["foreground_fallback_enabled"] is True
assert raw["foreground_model_fallbacks"][0]["endpoint_id"] == "legacy-single-user"
@@ -6,6 +6,9 @@ every other user's preferences (a realistic ops transition: auth turned off
on a deployment that previously ran multi-user). It must preserve the other
users and round-trip the change into the same (first) slot _load_for_user
reads from.
Foreground fallback keys are the exception: auth-disabled consent is stored
at the flat root so it can never become consent for the first named owner.
"""
import json
@@ -51,3 +54,58 @@ def test_named_user_save_unaffected(tmp_path, monkeypatch):
data = json.loads(f.read_text())
assert data["_users"]["alice"] == {"theme": "light"}
assert data["_users"]["bob"] == {"theme": "dark"}
def test_auth_disabled_fallback_consent_does_not_mutate_first_named_user(
tmp_path,
monkeypatch,
):
f = tmp_path / "user_prefs.json"
f.write_text(json.dumps({"_users": {
"alice": {"theme": "light"},
"bob": {"theme": "paper"},
}}), encoding="utf-8")
monkeypatch.setattr(pr, "PREFS_FILE", str(f))
current = pr._load_for_user(None)
current["foreground_fallback_enabled"] = True
current["foreground_model_fallbacks"] = [
{"endpoint_id": "single-user", "model": "single-model"},
]
pr._save_for_user(None, current)
data = json.loads(f.read_text(encoding="utf-8"))
assert data["foreground_fallback_enabled"] is True
assert data["foreground_model_fallbacks"][0]["endpoint_id"] == "single-user"
assert data["_users"]["alice"] == {"theme": "light"}
assert data["_users"]["bob"] == {"theme": "paper"}
def test_auth_disabled_save_preserves_named_fallback_consent(tmp_path, monkeypatch):
f = tmp_path / "user_prefs.json"
alice_fallbacks = [{"endpoint_id": "alice", "model": "alice-model"}]
f.write_text(json.dumps({"_users": {
"alice": {
"theme": "light",
"foreground_fallback_enabled": True,
"foreground_model_fallbacks": alice_fallbacks,
},
}}), encoding="utf-8")
monkeypatch.setattr(pr, "PREFS_FILE", str(f))
current = pr._load_for_user(None)
assert "foreground_fallback_enabled" not in current
assert "foreground_model_fallbacks" not in current
current["theme"] = "dark"
current["foreground_fallback_enabled"] = False
current["foreground_model_fallbacks"] = []
pr._save_for_user(None, current)
data = json.loads(f.read_text(encoding="utf-8"))
assert data["foreground_fallback_enabled"] is False
assert data["foreground_model_fallbacks"] == []
assert data["_users"]["alice"] == {
"theme": "dark",
"foreground_fallback_enabled": True,
"foreground_model_fallbacks": alice_fallbacks,
}
+150 -1
View File
@@ -3,8 +3,16 @@
import json
from types import SimpleNamespace
import pytest
import src.endpoint_resolver as endpoint_resolver
from src.endpoint_resolver import resolve_endpoint
from src.endpoint_resolver import (
endpoint_cost_tracked,
resolve_endpoint,
resolve_endpoint_by_id,
resolve_fallback_entries,
resolve_fallback_entries_with_descriptors,
)
class _FakeColumn:
@@ -34,6 +42,9 @@ class _FakeQuery:
def first(self):
return self.rows[0] if self.rows else None
def all(self):
return list(self.rows)
class _FakeDb:
def __init__(self, rows):
@@ -49,6 +60,7 @@ class _FakeDb:
def _endpoint(ep_id, model, *, hidden=None):
return SimpleNamespace(
id=ep_id,
name=f"Endpoint {ep_id}",
base_url=f"https://{ep_id}.example/v1",
api_key=f"key-{ep_id}",
cached_models=json.dumps([model]),
@@ -191,3 +203,140 @@ def test_hidden_configured_model_selects_first_enabled_chat_model(monkeypatch):
assert url == "https://default.example/v1/chat/completions"
assert model == "enabled-chat"
assert headers == {"Authorization": "Bearer key-default"}
def test_exact_fallback_drops_hidden_model_instead_of_substituting(monkeypatch):
endpoint = SimpleNamespace(
id="fallback",
base_url="https://fallback.example/v1",
api_key="key-fallback",
cached_models=json.dumps(["chosen-hidden", "different-live"]),
hidden_models=json.dumps(["chosen-hidden"]),
is_enabled=True,
)
_install_resolver_fakes(monkeypatch, {}, [endpoint])
assert resolve_endpoint_by_id(
"fallback",
"chosen-hidden",
require_exact_model=True,
) is None
assert resolve_endpoint_by_id("fallback", "chosen-hidden")[1] == "different-live"
def test_exact_fallback_drops_known_missing_model(monkeypatch):
_install_resolver_fakes(monkeypatch, {}, [_endpoint("fallback", "known-live")])
assert resolve_endpoint_by_id(
"fallback",
"unlisted-model",
require_exact_model=True,
) is None
def test_fallback_entry_resolution_preserves_credential_distinct_endpoints(monkeypatch):
seen = []
def fake_resolve(ep_id, model, owner=None, *, require_exact_model=False):
seen.append((ep_id, model, owner, require_exact_model))
return (
"https://provider.example/v1/chat/completions",
model,
{"Authorization": f"Bearer {ep_id}"},
)
monkeypatch.setattr(endpoint_resolver, "resolve_endpoint_by_id", fake_resolve)
entries = [
{"endpoint_id": "key-one", "model": "same-model"},
{"endpoint_id": "key-two", "model": "same-model"},
]
assert resolve_fallback_entries(
entries,
owner="alice",
require_exact_model=True,
) == [
("https://provider.example/v1/chat/completions", "same-model", {"Authorization": "Bearer key-one"}),
("https://provider.example/v1/chat/completions", "same-model", {"Authorization": "Bearer key-two"}),
]
assert seen == [
("key-one", "same-model", "alice", True),
("key-two", "same-model", "alice", True),
]
def test_descriptor_resolution_preserves_safe_endpoint_identity(monkeypatch):
_install_resolver_fakes(monkeypatch, {}, [_endpoint("backup", "backup-model")])
routes = resolve_fallback_entries_with_descriptors(
[{"endpoint_id": "backup", "model": "backup-model"}],
require_exact_model=True,
)
assert routes == [(
(
"https://backup.example/v1/chat/completions",
"backup-model",
{"Authorization": "Bearer key-backup"},
),
{
"endpoint_id": "backup",
"endpoint_label": "Endpoint backup",
"endpoint_cost_tracked": True,
},
)]
def test_exact_id_descriptor_wins_when_routes_are_identical(monkeypatch):
first = _endpoint("account-one", "same-model")
second = _endpoint("account-two", "same-model")
for endpoint in (first, second):
endpoint.base_url = "https://provider.example/v1"
endpoint.api_key = "shared-key"
_install_resolver_fakes(monkeypatch, {}, [first, second])
import src.auth_helpers as auth_helpers
seen_owners = []
def scoped(query, model_cls, owner, *, include_shared=True):
seen_owners.append(owner)
return query
monkeypatch.setattr(auth_helpers, "owner_filter", scoped)
resolver = getattr(endpoint_resolver, "resolve_route_descriptor_by_id", None)
assert resolver is not None
assert resolver(
"account-two",
"https://provider.example/v1/chat/completions",
"same-model",
{"Authorization": "Bearer shared-key"},
owner="alice",
) == {
"endpoint_id": "account-two",
"endpoint_label": "Endpoint account-two",
"endpoint_cost_tracked": True,
}
assert seen_owners == ["alice"]
def test_endpoint_cost_tracking_is_non_secret_route_classification():
assert endpoint_cost_tracked("http://localhost:11434/v1") is False
assert endpoint_cost_tracked("http://model-service:8000/v1") is False
assert endpoint_cost_tracked("http://192.168.1.20:8000/v1") is False
assert endpoint_cost_tracked("https://chatgpt.com/backend-api/codex") is False
assert endpoint_cost_tracked("https://api.example.com/v1") is True
assert endpoint_cost_tracked("http://192.168.1.20:8000/v1", "api") is True
assert endpoint_cost_tracked("https://api.example.com/v1", "local") is False
@pytest.mark.parametrize(
("url", "expected"),
[
("https://[2606:4700:4700::1111]/v1", True),
("http://169.254.10.20:8000/v1", False),
],
)
def test_endpoint_cost_tracking_classifies_public_ipv6_and_link_local_ipv4(url, expected):
assert endpoint_cost_tracked(url) is expected
@@ -163,53 +163,3 @@ def test_chatgpt_subscription_clears_previously_persisted_bearer(monkeypatch):
)
finally:
db.close()
def test_chatgpt_subscription_fallback_auth_is_not_written_to_sessions_table(monkeypatch):
"""Fallback endpoint selection must keep the resolved bearer request-local."""
TestSessionLocal = _mem_db(monkeypatch)
db = TestSessionLocal()
try:
db.add(ModelEndpoint(
id="ep1", name="ChatGPT Subscription", base_url=_CODEX_BASE,
provider_auth_id="auth1", owner="alice", is_enabled=True, api_key=None,
cached_models='["gpt-5.1-codex"]',
))
db.add(DbSession(
id="sess1", name="chat", endpoint_url="https://old.example/v1",
model="old-model", owner="alice", headers={},
))
db.commit()
finally:
db.close()
monkeypatch.setattr(
endpoint_resolver,
"resolve_endpoint_runtime",
lambda ep, owner=None: (_CODEX_BASE, "live-access-token"),
)
sess = types.SimpleNamespace(
id="sess1", endpoint_url="https://old.example/v1", model="old-model",
owner="alice", headers={},
)
result = chat_helpers.try_fallback_endpoint(sess, "sess1")
assert result == {
"model": "gpt-5.1-codex",
"endpoint_url": _CODEX_BASE + "/responses",
"endpoint_name": "ChatGPT Subscription",
}
assert sess.headers["Authorization"] == "Bearer live-access-token"
db = TestSessionLocal()
try:
row = db.query(DbSession).filter(DbSession.id == "sess1").first()
assert row.model == "gpt-5.1-codex"
assert row.endpoint_url == _CODEX_BASE + "/responses"
stored = row.headers or {}
assert not any(k.lower() == "authorization" for k in stored), (
f"ChatGPT fallback bearer leaked into sessions table: {stored}"
)
finally:
db.close()
+119
View File
@@ -0,0 +1,119 @@
"""Retired settings stay stored but cannot leak through generic interfaces."""
import asyncio
import json
from types import SimpleNamespace
import pytest
import core.database as database
import routes.auth_routes as auth_routes
import src.settings as settings_mod
from src.agent_tools.admin_tools import do_manage_settings
LEGACY_VALUE = [
{"endpoint_id": "private-endpoint-id", "model": "private-model-name"},
]
class _AuthManager:
def get_username_for_token(self, token):
return "admin" if token == "admin-session" else None
def is_admin(self, username):
return username == "admin"
class _Request(SimpleNamespace):
def __init__(self, body=None, *, admin=False):
super().__init__(
cookies={
auth_routes.SESSION_COOKIE: "admin-session"
} if admin else {},
_body=body,
)
async def json(self):
return self._body
def _route(router, path, method):
return next(
route.endpoint
for route in router.routes
if route.path == path and method in route.methods
)
@pytest.mark.asyncio
async def test_generic_settings_hide_and_preserve_retired_fallbacks(monkeypatch):
store = {
**settings_mod.DEFAULT_SETTINGS,
"default_model_fallbacks": list(LEGACY_VALUE),
"tts_enabled": True,
}
monkeypatch.setattr(auth_routes, "migrate_from_settings", lambda: None)
monkeypatch.setattr(auth_routes, "_load_settings", lambda: dict(store))
def save_settings(updated):
store.clear()
store.update(updated)
monkeypatch.setattr(auth_routes, "_save_settings", save_settings)
router = auth_routes.setup_auth_routes(_AuthManager())
get_settings = _route(router, "/api/auth/settings", "GET")
set_settings = _route(router, "/api/auth/settings", "POST")
anonymous = await get_settings(_Request())
admin = await get_settings(_Request(admin=True))
assert "default_model_fallbacks" not in anonymous
assert "default_model_fallbacks" not in admin
assert store["default_model_fallbacks"] == LEGACY_VALUE
response = await set_settings(_Request({
"default_model_fallbacks": [],
"tts_enabled": False,
}, admin=True))
assert "default_model_fallbacks" not in response
assert store["default_model_fallbacks"] == LEGACY_VALUE
assert store["tts_enabled"] is False
def test_manage_settings_tombstones_legacy_fallback_key(monkeypatch):
store = {
**settings_mod.DEFAULT_SETTINGS,
"default_model_fallbacks": list(LEGACY_VALUE),
}
save_calls = []
class _Db:
def close(self):
return None
monkeypatch.setattr(database, "SessionLocal", lambda: _Db())
monkeypatch.setattr(settings_mod, "load_settings", lambda: dict(store))
def save_settings(updated):
save_calls.append(dict(updated))
store.clear()
store.update(updated)
monkeypatch.setattr(settings_mod, "save_settings", save_settings)
listed = asyncio.run(do_manage_settings(json.dumps({"action": "list"})))
assert "default_model_fallbacks" not in listed["settings"]
for action in ("get", "set", "reset", "delete"):
payload = {"action": action, "key": "default_model_fallbacks"}
if action == "set":
payload["value"] = []
result = asyncio.run(do_manage_settings(json.dumps(payload)))
assert result["exit_code"] == 1
assert "Unknown setting" in result["error"]
assert save_calls == []
assert store["default_model_fallbacks"] == LEGACY_VALUE
+323
View File
@@ -0,0 +1,323 @@
import importlib.util
import os
import stat
import subprocess
import sys
from pathlib import Path
import pytest
import yaml
ROOT = Path(__file__).resolve().parent.parent
MIGRATION = ROOT / "scripts" / "migrate_searxng_settings.py"
COMPOSE_FILES = (
ROOT / "docker-compose.yml",
ROOT / "docker-compose.gpu-amd.yml",
ROOT / "docker-compose.gpu-nvidia.yml",
)
def _run(path: Path) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, str(MIGRATION), str(path)],
capture_output=True,
check=False,
text=True,
)
def _load_migration_module():
spec = importlib.util.spec_from_file_location("searxng_settings_migration", MIGRATION)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def test_retained_settings_gain_defaults_without_changing_custom_content(tmp_path):
settings = tmp_path / "settings.yml"
retained = (
b"# retained deployment settings\n"
b"server:\n"
b' secret_key: "representative-retained-secret"\n'
b"search:\n"
b" safe_search: 1\n"
b" formats:\n"
b" - html\n"
b" - json\n"
b"ui:\n"
b" static_use_hash: true\n"
)
settings.write_bytes(retained)
settings.chmod(0o640)
before = settings.stat()
first = _run(settings)
assert first.returncode == 0, first.stderr
assert "representative-retained-secret" not in first.stdout + first.stderr
assert settings.read_bytes() == (
b"# retained deployment settings\n"
b"use_default_settings: true\n"
+ retained.removeprefix(b"# retained deployment settings\n")
)
after = settings.stat()
assert stat.S_IMODE(after.st_mode) == 0o640
assert (after.st_uid, after.st_gid) == (before.st_uid, before.st_gid)
migrated = settings.read_bytes()
second = _run(settings)
assert second.returncode == 0, second.stderr
assert settings.read_bytes() == migrated
assert second.stdout == ""
def test_fresh_generated_settings_are_left_byte_identical(tmp_path):
settings = tmp_path / "settings.yml"
generated = (ROOT / "config" / "searxng" / "settings.yml").read_bytes().replace(
b"__SEARXNG_SECRET__", b"representative-generated-secret"
)
settings.write_bytes(generated)
result = _run(settings)
assert result.returncode == 0, result.stderr
assert settings.read_bytes() == generated
assert result.stdout == ""
@pytest.mark.parametrize(
"key",
(
b"use_default_settings: false\n",
b"use_default_settings:\n engines:\n keep_only:\n - brave\n",
b"'use_default_settings': true\n",
b'"use_default_settings": true\n',
),
)
def test_explicit_top_level_setting_is_not_overridden(tmp_path, key):
settings = tmp_path / "settings.yml"
original = key + b"server:\n secret_key: retained\n"
settings.write_bytes(original)
result = _run(settings)
assert result.returncode == 0, result.stderr
assert settings.read_bytes() == original
def test_key_is_inserted_inside_explicit_yaml_document(tmp_path):
settings = tmp_path / "settings.yml"
original = b"\xef\xbb\xbf# header\r\n---\r\nserver:\r\n secret_key: retained\r\n"
settings.write_bytes(original)
result = _run(settings)
assert result.returncode == 0, result.stderr
assert settings.read_bytes() == (
b"\xef\xbb\xbf# header\r\n---\r\nuse_default_settings: true\r\n"
b"server:\r\n secret_key: retained\r\n"
)
def test_indented_root_mapping_keeps_its_existing_indent(tmp_path):
settings = tmp_path / "settings.yml"
original = b" server:\n secret_key: retained\n search:\n safe_search: 1\n"
settings.write_bytes(original)
result = _run(settings)
assert result.returncode == 0, result.stderr
assert settings.read_bytes() == b" use_default_settings: true\n" + original
@pytest.mark.parametrize(
"property_line",
(b"!!map\n", b"&settings\n", b"--- !!map\n"),
)
def test_block_mapping_properties_stay_attached_to_the_root(tmp_path, property_line):
settings = tmp_path / "settings.yml"
mapping = b"server:\n secret_key: retained\nsearch:\n safe_search: 1\n"
original = property_line + mapping
settings.write_bytes(original)
original_data = yaml.safe_load(original)
result = _run(settings)
assert result.returncode == 0, result.stderr
expected = property_line + b"use_default_settings: true\n" + mapping
assert settings.read_bytes() == expected
migrated_data = yaml.safe_load(settings.read_bytes())
assert migrated_data.pop("use_default_settings") is True
assert migrated_data == original_data
def test_flow_mapping_gains_default_inheritance_without_reformatting(tmp_path):
settings = tmp_path / "settings.yml"
original = b"{server: {secret_key: retained}, search: {safe_search: 1}}\n"
settings.write_bytes(original)
result = _run(settings)
assert result.returncode == 0, result.stderr
assert settings.read_bytes() == (
b"{use_default_settings: true, " + original.removeprefix(b"{")
)
@pytest.mark.parametrize(
("original", "expected"),
(
(b"{}\n", b"{use_default_settings: true}\n"),
(
b"{server: {use_default_settings: false, secret_key: retained}}\n",
b"{use_default_settings: true, "
b"server: {use_default_settings: false, secret_key: retained}}\n",
),
(
b"!!map {server: {secret_key: retained}}\n",
b"!!map {use_default_settings: true, server: {secret_key: retained}}\n",
),
),
)
def test_other_flow_mapping_shapes_gain_only_the_root_key(tmp_path, original, expected):
settings = tmp_path / "settings.yml"
settings.write_bytes(original)
result = _run(settings)
assert result.returncode == 0, result.stderr
assert settings.read_bytes() == expected
@pytest.mark.parametrize(
"original",
(
b"{use_default_settings: true, server: {secret_key: retained}}\n",
b'{"use_default_settings": {engines: {keep_only: [brave]}}}\n',
b"{use_default_settings: true, server: {secret_key: abc#def}}\n",
b"{use_default_settings: true, server: {secret_key: 'ab''cd'}}\n",
),
)
def test_flow_mapping_with_existing_setting_is_left_untouched(tmp_path, original):
settings = tmp_path / "settings.yml"
settings.write_bytes(original)
result = _run(settings)
assert result.returncode == 0, result.stderr
assert settings.read_bytes() == original
@pytest.mark.parametrize(
"original",
(
b"%YAML 1.1\n---\nuse_default_settings: true\n"
b"server:\n secret_key: retained\n",
b"use_default_settings: true\nserver:\n secret_key: retained\n...\n",
),
)
def test_valid_document_metadata_with_existing_key_is_left_untouched(
tmp_path, original
):
settings = tmp_path / "settings.yml"
settings.write_bytes(original)
result = _run(settings)
assert result.returncode == 0, result.stderr
assert settings.read_bytes() == original
def test_invalid_utf8_is_not_replaced(tmp_path):
settings = tmp_path / "settings.yml"
original = b"server:\n secret_key: \xff\n"
settings.write_bytes(original)
before = os.stat(settings)
result = _run(settings)
after = os.stat(settings)
assert result.returncode == 1
assert settings.read_bytes() == original
assert after.st_ino == before.st_ino
def test_temporary_file_is_chmodded_before_it_is_chowned(tmp_path, monkeypatch):
# The Compose cap set is `cap_drop: ALL` plus CHOWN/SETGID/SETUID/
# DAC_OVERRIDE and carries no FOWNER, and searxng's entrypoint chowns
# /etc/searxng to searxng:searxng, so every retained settings file is owned
# by that user. Chowning the temporary file first therefore makes the chmod
# that follows fail with EPERM, and `set -eu` in the Compose entrypoint
# turns that into a container that never starts. The guard below refuses
# the chmod once the chown has landed, the way the kernel does.
migration = _load_migration_module()
settings = tmp_path / "settings.yml"
settings.write_bytes(b"server:\n secret_key: retained\n")
settings.chmod(0o640)
calls = []
real_fchmod = migration.os.fchmod
real_fchown = migration.os.fchown
def guarded_fchmod(fd, mode):
if "fchown" in calls:
raise PermissionError(1, "Operation not permitted")
calls.append("fchmod")
return real_fchmod(fd, mode)
def recording_fchown(fd, uid, gid):
calls.append("fchown")
return real_fchown(fd, uid, gid)
monkeypatch.setattr(migration.os, "fchmod", guarded_fchmod)
monkeypatch.setattr(migration.os, "fchown", recording_fchown)
assert migration.migrate_settings(settings) is True
assert calls == ["fchmod", "fchown"]
assert stat.S_IMODE(settings.stat().st_mode) == 0o640
assert settings.read_bytes() == (
b"use_default_settings: true\nserver:\n secret_key: retained\n"
)
def test_replace_failure_preserves_original_and_removes_temporary_file(
tmp_path, monkeypatch
):
migration = _load_migration_module()
settings = tmp_path / "settings.yml"
original = b"server:\n secret_key: retained\n"
settings.write_bytes(original)
before = settings.stat()
def fail_replace(_source, _destination):
raise OSError("injected replace failure")
monkeypatch.setattr(migration.os, "replace", fail_replace)
with pytest.raises(OSError, match="injected replace failure"):
migration.migrate_settings(settings)
after = settings.stat()
assert settings.read_bytes() == original
assert after.st_ino == before.st_ino
assert list(tmp_path.iterdir()) == [settings]
@pytest.mark.parametrize("compose_file", COMPOSE_FILES, ids=lambda path: path.name)
def test_compose_runs_migration_for_all_variants(compose_file):
text = compose_file.read_text(encoding="utf-8")
# The `|| true` is load-bearing: the entrypoint runs under `set -eu`, so
# without it a settings file the migration cannot parse or rewrite stops
# searxng from starting at all instead of merely going unmigrated.
assert (
"/usr/local/searxng/.venv/bin/python /tmp/migrate-searxng-settings.py "
"/etc/searxng/settings.yml || true" in text
)
assert (
"./scripts/migrate_searxng_settings.py:"
"/tmp/migrate-searxng-settings.py:ro,z" in text
)
+12 -12
View File
@@ -1,4 +1,6 @@
"""Skill URL importer — GitHub path parsing."""
import ipaddress
import pytest
from services.memory.skill_importer import (
@@ -11,6 +13,13 @@ from services.memory.skill_importer import (
)
def _allow_fetch(monkeypatch):
monkeypatch.setattr(
"services.memory.skill_importer._resolve_and_check_url",
lambda url: [ipaddress.ip_address("93.184.216.34")],
)
def test_parse_github_blob_skill_md():
src = parse_skill_source(
"https://github.com/anthropics/skills/blob/main/skills/pdf/SKILL.md"
@@ -69,10 +78,7 @@ def test_fetch_bytes_rejects_cross_host_redirect(monkeypatch):
return _Resp()
monkeypatch.setattr("services.memory.skill_importer.httpx.Client", _Client)
monkeypatch.setattr(
"services.memory.skill_importer.check_outbound_url",
lambda url, **kwargs: (True, ""),
)
_allow_fetch(monkeypatch)
with pytest.raises(SkillImportError, match="redirect target"):
_fetch_bytes("https://raw.githubusercontent.com/o/r/main/SKILL.md")
@@ -89,10 +95,7 @@ def test_list_github_dir_accepts_api_github_response(monkeypatch):
"services.memory.skill_importer._fetch_text",
lambda url: "# skill\n",
)
monkeypatch.setattr(
"services.memory.skill_importer.check_outbound_url",
lambda url, **kwargs: (True, ""),
)
_allow_fetch(monkeypatch)
class _Resp:
url = "https://api.github.com/repos/o/r/contents?ref=main"
@@ -144,10 +147,7 @@ def _mock_httpx_client(monkeypatch, response):
return response
monkeypatch.setattr("services.memory.skill_importer.httpx.Client", _Client)
monkeypatch.setattr(
"services.memory.skill_importer.check_outbound_url",
lambda url, **kwargs: (True, ""),
)
_allow_fetch(monkeypatch)
def test_list_github_dir_surfaces_rate_limit(monkeypatch):
+217
View File
@@ -0,0 +1,217 @@
"""Deterministic regressions for skill-import DNS validation and pinning."""
import gzip
import ipaddress
import socket
import threading
import httpcore
import httpx
from services.memory import skill_importer
PUBLIC_A = ipaddress.ip_address("93.184.216.34")
PUBLIC_B = ipaddress.ip_address("1.1.1.1")
def test_validation_snapshot_is_the_only_connect_destination(monkeypatch):
answers = iter([
[str(PUBLIC_A)],
["127.0.0.1"],
])
resolver_calls = []
def _flipping_resolver(host):
resolver_calls.append(host)
return next(answers)
monkeypatch.setattr(skill_importer, "_default_resolver", _flipping_resolver)
pinned_ips = skill_importer._check_fetch_url("https://rebind.example/skill")
connected = []
class _RecordingBackend:
def connect_tcp(self, host, port, timeout, local_address, socket_options):
connected.append((host, port))
return object()
backend = skill_importer._PinnedBackend(pinned_ips)
backend._real = _RecordingBackend()
backend.connect_tcp("rebind.example", 443, timeout=1.0)
assert resolver_calls == ["rebind.example"]
assert pinned_ips == [PUBLIC_A]
assert connected == [(str(PUBLIC_A), 443)]
def test_pinned_backend_falls_back_only_within_validated_snapshot():
attempts = []
class _FallbackBackend:
def connect_tcp(self, host, port, timeout, local_address, socket_options):
attempts.append((host, timeout))
if host == str(PUBLIC_A):
raise httpcore.ConnectError("first address unavailable")
return "connected"
backend = skill_importer._PinnedBackend([PUBLIC_A, PUBLIC_B])
backend._real = _FallbackBackend()
assert backend.connect_tcp("rebind.example", 443, timeout=1.0) == "connected"
assert [host for host, _ in attempts] == [str(PUBLIC_A), str(PUBLIC_B)]
assert all(timeout is not None and 0 <= timeout <= 1.0 for _, timeout in attempts)
def test_transport_preserves_request_authority_and_response_url():
recorded = []
class _CoreResponse:
status = 200
headers = [(b"content-type", b"text/plain")]
stream = [b"ok"]
extensions = {}
def close(self):
return None
class _RecordingPool:
def handle_request(self, request):
recorded.append(request)
return _CoreResponse()
def close(self):
return None
transport = skill_importer._PinnedTransport([PUBLIC_A])
transport._pool.close()
transport._pool = _RecordingPool()
url = "https://github.com:444/octocat/repo?q=1"
with httpx.Client(transport=transport) as client:
response = client.get(url)
core_request = recorded[0]
assert core_request.url.host == b"github.com"
assert core_request.url.port == 444
assert core_request.url.target == b"/octocat/repo?q=1"
assert (b"host", b"github.com:444") in [
(name.lower(), value) for name, value in core_request.headers
]
assert str(response.url) == url
def test_get_checked_uses_fresh_transport_per_redirect_hop(monkeypatch):
first = "https://github.com/owner/repo"
second = "https://raw.githubusercontent.com/owner/repo/main/SKILL.md"
snapshots = {
first: [PUBLIC_A],
second: [PUBLIC_B],
}
clients = []
requested = []
monkeypatch.setattr(
skill_importer,
"_resolve_and_check_url",
lambda url: snapshots[url],
)
class _Client:
def __init__(self, *, transport, follow_redirects, timeout):
assert follow_redirects is False
clients.append((transport._pinned_ips, timeout))
def __enter__(self):
return self
def __exit__(self, *args):
return False
def get(self, url, headers=None):
requested.append((url, headers))
request = httpx.Request("GET", url)
if url == first:
return httpx.Response(
302,
headers={"location": second},
request=request,
)
return httpx.Response(200, content=b"ok", request=request)
monkeypatch.setattr(skill_importer.httpx, "Client", _Client)
response = skill_importer._get_checked(first, headers={"Accept": "text/plain"})
assert [ips for ips, _ in clients] == [[PUBLIC_A], [PUBLIC_B]]
assert requested == [
(first, {"Accept": "text/plain"}),
(second, {"Accept": "text/plain"}),
]
assert str(response.url) == second
def test_dns_rebinding_pinned_transport_dials_pinned_ip():
"""The real pool must dial the pinned IP and keep the logical request intact.
Everything above this test replaces the pool or the backend, so this is the
only case that exercises ``httpcore.ConnectionPool`` end to end: the socket
goes to the pinned address while URL, ``Host``, and the decoded body stay on
the original hostname.
"""
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.bind(("127.0.0.1", 0))
server_socket.listen(1)
port = server_socket.getsockname()[1]
captured_request = b""
client_address = None
server_error = None
def handle_client():
nonlocal captured_request, client_address, server_error
try:
conn, client_address = server_socket.accept()
with conn:
conn.settimeout(2.0)
while b"\r\n\r\n" not in captured_request:
if len(captured_request) >= 16_384:
raise AssertionError("request headers exceeded 16 KiB")
chunk = conn.recv(min(4096, 16_384 - len(captured_request)))
if not chunk:
break
captured_request += chunk
if b"\r\n\r\n" not in captured_request:
raise AssertionError(
"connection closed before request headers completed"
)
body = gzip.compress(b"successfully decoded gzip body")
conn.sendall(
b"HTTP/1.1 200 OK\r\n"
b"Content-Encoding: gzip\r\n"
b"Content-Length: " + str(len(body)).encode() + b"\r\n"
b"\r\n" + body
)
except Exception as exc: # surfaced by the assertion below
server_error = exc
server_thread = threading.Thread(target=handle_client, daemon=True)
server_thread.start()
# Any hostname works: only the pinned snapshot decides where the socket goes.
url = f"http://rebind.example:{port}/secret-metadata"
try:
with httpx.Client(
transport=skill_importer._PinnedTransport([ipaddress.ip_address("127.0.0.1")])
) as client:
response = client.get(url)
server_thread.join(timeout=5.0)
finally:
server_socket.close()
assert server_error is None, server_error
assert not server_thread.is_alive()
assert client_address is not None and client_address[0] == "127.0.0.1"
assert f"host: rebind.example:{port}".encode() in captured_request.lower()
assert str(response.url) == url
assert response.text == "successfully decoded gzip body"
+155
View File
@@ -0,0 +1,155 @@
import re
from unittest.mock import MagicMock, patch
import pytest
from services.memory.skill_importer import (
ResolvedSource,
SkillImportError,
check_outbound_url,
parse_skill_source,
)
## 1. Tests for Hostname Dispatch & Substring Spoofing
@pytest.mark.parametrize(
"url",
[
"https://skills.sh.attacker.com/owner/repo",
"https://evilskills.sh/owner/repo",
"https://notskills.sh/owner/repo",
"https://api.skills.sh/owner/repo",
"https://1.1.1.1/skills.sh/owner/repo",
"http://localhost/skills.sh/owner/repo",
],
)
def test_parse_skill_source_rejects_unsupported_host_before_fetch(url):
"""Unsupported authorities must never reach the network unwrap path."""
with patch("services.memory.skill_importer._get_checked") as mock_get:
with pytest.raises(SkillImportError):
parse_skill_source(url)
mock_get.assert_not_called()
@pytest.mark.parametrize("entry", ["https://skills.sh/my-skill", "https://www.skills.sh/my-skill"])
def test_parse_skill_source_unwraps_skills_sh_redirect_to_github(entry):
"""Both skills.sh spellings unwrap when the fetch lands on a GitHub host."""
with patch("services.memory.skill_importer._get_checked") as mock_get:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.url = "https://github.com/test-owner/test-repo"
mock_get.return_value = mock_response
source = parse_skill_source(entry)
assert source.owner == "test-owner"
assert source.repo == "test-repo"
def test_parse_skill_source_rejects_skills_sh_page_that_never_reaches_github():
"""A skills.sh page that does not redirect to GitHub must fail loudly.
The live site serves the skill page from ``www.skills.sh`` and only ever
links the repository root, never the skill's subdirectory. Scraping a
``github.com`` link out of the body therefore resolves every skill in a
repo to the same bundle, so the importer must refuse rather than guess.
"""
body = '<html><body><a href="https://github.com/test-owner/test-repo">Repository</a></body></html>'
with patch("services.memory.skill_importer._get_checked") as mock_get:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.url = "https://www.skills.sh/anthropics/skills/pdf"
mock_response.text = body
mock_get.return_value = mock_response
with pytest.raises(
SkillImportError, match="did not redirect to GitHub"
) as exc_info:
parse_skill_source("https://skills.sh/anthropics/skills/pdf")
message = str(exc_info.value)
assert "exact skill folder or SKILL.md file" in message
assert "repository-root link alone is not sufficient" in message
@pytest.mark.parametrize(
("url", "expected"),
[
("ftp://github.com/o/r", "unsupported URL scheme: ftp"),
("file:///etc/passwd", "unsupported URL scheme: file"),
("gopher://github.com/o/r", "unsupported URL scheme: gopher"),
("javascript:alert(1)", "Only GitHub or skills.sh URLs are supported"),
("mailto:x@y.z", "Only GitHub or skills.sh URLs are supported"),
("data:text/html,<b>x</b>", "Only GitHub or skills.sh URLs are supported"),
("https://evil.example/o/r", "Only GitHub or skills.sh URLs are supported"),
],
)
def test_parse_skill_source_reports_the_real_reason_for_rejection(url, expected):
"""A supplied-but-unusable URL must not be reported as a missing URL."""
with patch("services.memory.skill_importer._get_checked") as mock_get:
with pytest.raises(SkillImportError, match=re.escape(expected)):
parse_skill_source(url)
mock_get.assert_not_called()
@pytest.mark.parametrize(
("url", "owner", "repo"),
[
("github.com/octocat/Hello-World", "octocat", "Hello-World"),
("HTTPS://github.com/octocat/Hello-World", "octocat", "Hello-World"),
("github.com:443/octocat/Hello-World", "octocat", "Hello-World"),
("github.com/octocat/Hello-World?q=a://b", "octocat", "Hello-World"),
],
)
def test_parse_skill_source_accepts_schemeless_and_uppercase_github(url, owner, repo):
"""A schemeless host, an uppercase scheme, and a ``://`` in the query all parse."""
source = parse_skill_source(url)
assert (source.owner, source.repo) == (owner, repo)
def test_parse_skill_source_valid_github():
"""Ensure standard GitHub URLs parse into the correct ResolvedSource fields."""
source = parse_skill_source("https://github.com/octocat/Hello-World/tree/main/docs")
assert isinstance(source, ResolvedSource)
assert source.owner == "octocat"
assert source.repo == "Hello-World"
assert source.ref == "main"
assert source.path == "docs"
## 2. Tests for SSRF Guard & CGNAT
def test_check_outbound_url_blocks_cgnat():
"""Ensure Carrier-Grade NAT (RFC 6598) block 100.64.0.0/10 is blocked."""
def mock_resolver(host):
return ["100.64.5.10"]
ok, reason = check_outbound_url("http://example.com", block_private=True, resolver=mock_resolver)
assert not ok
assert "private/shared/loopback" in reason # Updated to match your codebase's error string
def test_check_outbound_url_blocks_loopback():
"""Ensure loopback IPs (127.0.0.1) are blocked by default."""
def mock_resolver(host):
return ["127.0.0.1"]
ok, reason = check_outbound_url("http://localhost", block_private=True, resolver=mock_resolver)
assert not ok
def test_check_outbound_url_blocks_metadata():
"""Ensure cloud metadata endpoints (169.254.169.254) are blocked."""
def mock_resolver(host):
return ["169.254.169.254"]
ok, reason = check_outbound_url("http://metadata.google.internal", block_private=True, resolver=mock_resolver)
assert not ok
def test_check_outbound_url_allows_public_ip():
"""Ensure public routable IPs pass successfully."""
def mock_resolver(host):
return ["93.184.216.34"]
ok, reason = check_outbound_url("http://example.com", block_private=True, resolver=mock_resolver)
assert ok
assert reason == "ok"
+16 -4
View File
@@ -6,10 +6,12 @@ path in ``services/search/content.py:_get_public_url``. Previously it used
``httpx``'s ``follow_redirects=True`` with the lenient guard on the *initial*
URL only, so a ``3xx`` to an internal/metadata address was still connected to.
These tests are hermetic: every host is an IP literal, so ``check_outbound_url``
resolves them locally (``getaddrinfo`` on a numeric address does no DNS) and no
network access is required. The HTTP layer is faked so no real request is made.
These tests are hermetic: public and internal guard cases use IP literals, while
the exact ``skills.sh`` case injects its validated address snapshot. The HTTP
layer is faked, so no real DNS lookup or request is made.
"""
import ipaddress
import pytest
from services.memory import skill_importer
@@ -108,10 +110,20 @@ def test_fetch_bytes_blocks_redirect_to_internal(monkeypatch, internal):
def test_skills_sh_entry_blocks_redirect_to_metadata(monkeypatch):
# The skills.sh unwrap path (user-supplied host) must also revalidate hops.
raw = "http://1.1.1.1/skills.sh" # contains "skills.sh", not "github.com"
raw = "https://skills.sh/example/skill"
checked = []
def _check_hop(url):
checked.append(url)
if url == raw:
return [ipaddress.ip_address("1.1.1.1")]
raise SkillImportError("outbound URL blocked: private target")
monkeypatch.setattr(skill_importer, "_resolve_and_check_url", _check_hop)
_install_fake_client(monkeypatch, redirect_from=raw, redirect_to=METADATA)
with pytest.raises(SkillImportError, match="blocked"):
parse_skill_source(raw)
assert checked == [raw, METADATA]
# --- Positive: a legitimate public->public redirect is still followed --------
+62 -1
View File
@@ -6,8 +6,15 @@ Verifies two critical cases:
2. api.deepseek.com must still be treated as tool-capable via the host
allow-list (_API_HOSTS), so cloud deepseek users keep working.
"""
from types import SimpleNamespace
import pytest
from src.agent_loop import _API_HOSTS, _endpoint_lookup_keys, _is_ollama_openai_compat_url
from src.agent_loop import (
_API_HOSTS,
_agent_route_tool_mode,
_endpoint_lookup_keys,
_is_ollama_openai_compat_url,
)
from src.llm_core import _is_ollama_native_url
@@ -164,3 +171,57 @@ class TestEndpointLookupKeys:
keys = _endpoint_lookup_keys("http://host.docker.internal:11434/api/chat")
assert "http://host.docker.internal:11434/api" in keys
def test_route_tool_mode_matches_credential_distinct_endpoint(monkeypatch):
from core import database
from src import endpoint_resolver
rows = [
SimpleNamespace(
id="one",
base_url="https://same.example/v1",
api_key="key-one",
provider_auth_id=None,
supports_tools=True,
),
SimpleNamespace(
id="two",
base_url="https://same.example/v1",
api_key="key-two",
provider_auth_id=None,
supports_tools=False,
),
]
class Query:
def filter(self, *args, **kwargs):
return self
def all(self):
return rows
class Db:
def query(self, *args, **kwargs):
return Query()
def close(self):
return None
monkeypatch.setattr(database, "SessionLocal", lambda: Db())
monkeypatch.setattr(
endpoint_resolver,
"resolve_endpoint_runtime",
lambda endpoint, owner=None: (endpoint.base_url, endpoint.api_key),
)
assert _agent_route_tool_mode(
"https://same.example/v1",
"custom-model",
headers={"Authorization": "Bearer key-one"},
)[0] is True
assert _agent_route_tool_mode(
"https://same.example/v1",
"custom-model",
headers={"Authorization": "Bearer key-two"},
)[0] is False
+25
View File
@@ -117,6 +117,31 @@ def test_agent_system_prompt_includes_shared_current_time(monkeypatch):
assert "Australia/Brisbane, UTC+10:00" in datetime_messages[0]["content"]
def test_route_prompt_rebuild_restores_leading_user_system_message(monkeypatch):
import src.agent_loop as agent_loop
monkeypatch.setattr(agent_loop, "_build_base_prompt", lambda *args, **kwargs: ("AGENT PROMPT", ""))
monkeypatch.setattr(agent_loop, "set_active_model", lambda model: None)
monkeypatch.setattr(agent_loop, "get_builtin_overrides", lambda: {})
monkeypatch.setattr(agent_loop, "_cached_base_prompt", None)
monkeypatch.setattr(agent_loop, "_cached_base_prompt_key", None)
original = [
{"role": "system", "content": "USER PERSONA"},
{"role": "user", "content": "hello"},
]
built, _ = agent_loop._build_system_prompt(
original,
model="selected-model",
active_document=None,
mcp_mgr=None,
)
assert built[0]["content"] == "USER PERSONA\n\nAGENT PROMPT"
assert built[0]["_agent_injected"] == "merged_prompt"
assert agent_loop._strip_agent_injected_messages(built) == original
def test_calendar_relative_time_parser_handles_dotted_pm(monkeypatch):
import routes.calendar_routes as calendar_routes