mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-10 18:22:20 +02:00
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>
This commit is contained in:
co-authored by
RaresKeY
StressTestor
parent
b52296471b
commit
c4369305f0
+19
-2
@@ -1491,8 +1491,25 @@ def _migrate_assign_legacy_owner():
|
|||||||
with open(prefs_path, "r", encoding="utf-8") as f:
|
with open(prefs_path, "r", encoding="utf-8") as f:
|
||||||
prefs = _json.load(f)
|
prefs = _json.load(f)
|
||||||
if "_users" not in prefs and prefs:
|
if "_users" not in prefs and prefs:
|
||||||
# Flat format → nest under admin user
|
# Flat format → nest ordinary preferences under the admin
|
||||||
new_prefs = {"_users": {admin_user: prefs}}
|
# 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:
|
with open(prefs_path, "w", encoding="utf-8") as f:
|
||||||
_json.dump(new_prefs, f, indent=2)
|
_json.dump(new_prefs, f, indent=2)
|
||||||
logger.info(f"Migrated user_prefs.json to per-user format under '{admin_user}'")
|
logger.info(f"Migrated user_prefs.json to per-user format under '{admin_user}'")
|
||||||
|
|||||||
@@ -1802,7 +1802,6 @@ async def _ai_draft_reply_to_email(uid, folder="INBOX", reply_all=False, account
|
|||||||
from src.endpoint_resolver import (
|
from src.endpoint_resolver import (
|
||||||
resolve_endpoint,
|
resolve_endpoint,
|
||||||
resolve_utility_fallback_candidates,
|
resolve_utility_fallback_candidates,
|
||||||
resolve_chat_fallback_candidates,
|
|
||||||
)
|
)
|
||||||
from src.llm_core import llm_call_async_with_fallback
|
from src.llm_core import llm_call_async_with_fallback
|
||||||
except Exception as exc:
|
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 []
|
utility_fallbacks = resolve_utility_fallback_candidates() or []
|
||||||
for cand in utility_fallbacks:
|
for cand in utility_fallbacks:
|
||||||
_add(*cand)
|
_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:
|
if not candidates:
|
||||||
return {"error": "No LLM endpoint configured for AI reply"}
|
return {"error": "No LLM endpoint configured for AI reply"}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ from src.settings import (
|
|||||||
load_features as _load_features,
|
load_features as _load_features,
|
||||||
save_features as _save_features,
|
save_features as _save_features,
|
||||||
DEFAULT_SETTINGS,
|
DEFAULT_SETTINGS,
|
||||||
|
RETIRED_SETTING_KEYS,
|
||||||
|
without_retired_settings,
|
||||||
)
|
)
|
||||||
from src.integrations import (
|
from src.integrations import (
|
||||||
load_integrations,
|
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
|
a scrubbed copy with secret keys blanked. The frontend uses this
|
||||||
for keybinds + TTS prefs, so it stays callable without admin."""
|
for keybinds + TTS prefs, so it stays callable without admin."""
|
||||||
user = _get_current_user(request)
|
user = _get_current_user(request)
|
||||||
settings = _load_settings()
|
settings = without_retired_settings(_load_settings())
|
||||||
if user and auth_manager.is_admin(user):
|
if user and auth_manager.is_admin(user):
|
||||||
return settings
|
return settings
|
||||||
return scrub_settings(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
|
"agent_max_tool_calls": (0, 1000), # 0 = unlimited
|
||||||
}
|
}
|
||||||
for key in DEFAULT_SETTINGS:
|
for key in DEFAULT_SETTINGS:
|
||||||
|
if key in RETIRED_SETTING_KEYS:
|
||||||
|
continue
|
||||||
if key not in body:
|
if key not in body:
|
||||||
continue
|
continue
|
||||||
val = body[key]
|
val = body[key]
|
||||||
@@ -721,7 +725,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
|||||||
val = max(lo, min(val, hi))
|
val = max(lo, min(val, hi))
|
||||||
current[key] = val
|
current[key] = val
|
||||||
_save_settings(current)
|
_save_settings(current)
|
||||||
return current
|
return without_retired_settings(current)
|
||||||
|
|
||||||
# ---- Integrations CRUD ----
|
# ---- Integrations CRUD ----
|
||||||
|
|
||||||
|
|||||||
+43
-96
@@ -15,7 +15,7 @@ from core.database import Session as DBSession, ModelEndpoint
|
|||||||
from src.llm_core import normalize_model_id
|
from src.llm_core import normalize_model_id
|
||||||
from src.endpoint_resolver import normalize_base
|
from src.endpoint_resolver import normalize_base
|
||||||
from src.context_compactor import maybe_compact, trim_for_context
|
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.auth_helpers import effective_user
|
||||||
from src.prompt_security import untrusted_context_message
|
from src.prompt_security import untrusted_context_message
|
||||||
from src.attachment_refs import attachment_ref
|
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
|
# Uploads attached to this user turn, resolved and owner-checked for the
|
||||||
# agent's private context. This is not emitted to the browser.
|
# agent's private context. This is not emitted to the browser.
|
||||||
uploaded_files: list = field(default_factory=list)
|
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 ────────────────────────────────────────────────────────────── #
|
# ── 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:
|
def _enforce_chat_privileges(request, sess) -> None:
|
||||||
"""Apply the per-user privilege gates (allowed_models + max_messages_per_day)
|
"""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.
|
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"):
|
if privs.get("block_all_models"):
|
||||||
raise HTTPException(403, f"Your account is not allowed to use model '{sess.model}'.")
|
raise HTTPException(403, f"Your account is not allowed to use model '{sess.model}'.")
|
||||||
|
|
||||||
allowed_raw = privs.get("allowed_models")
|
allowed_models = _allowed_models_from_privileges(privs)
|
||||||
allowed = allowed_raw if isinstance(allowed_raw, list) else []
|
if allowed_models is not None and sess.model and sess.model not in allowed_models:
|
||||||
restricted = bool(privs.get("allowed_models_restricted")) or bool(allowed)
|
|
||||||
if restricted and sess.model and sess.model not in allowed:
|
|
||||||
raise HTTPException(403, f"Your account is not allowed to use model '{sess.model}'.")
|
raise HTTPException(403, f"Your account is not allowed to use model '{sess.model}'.")
|
||||||
|
|
||||||
cap = int(privs.get("max_messages_per_day") or 0)
|
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()}")
|
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:
|
def extract_preset(chat_handler, preset_id) -> PresetInfo:
|
||||||
"""Extract preset parameters via chat_handler."""
|
"""Extract preset parameters via chat_handler."""
|
||||||
temperature, max_tokens, system_prompt, char_name = (
|
temperature, max_tokens, system_prompt, char_name = (
|
||||||
@@ -687,6 +623,7 @@ async def build_chat_context(
|
|||||||
use_enhanced_message: bool = False,
|
use_enhanced_message: bool = False,
|
||||||
agent_mode: bool = False,
|
agent_mode: bool = False,
|
||||||
allow_tool_preprocessing: bool = True,
|
allow_tool_preprocessing: bool = True,
|
||||||
|
defer_context_shaping: bool = False,
|
||||||
) -> ChatContext:
|
) -> ChatContext:
|
||||||
"""Build the full context (preface + messages) for an LLM call.
|
"""Build the full context (preface + messages) for an LLM call.
|
||||||
|
|
||||||
@@ -830,12 +767,21 @@ async def build_chat_context(
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Failed to add current date/time context", exc_info=True)
|
logger.debug("Failed to add current date/time context", exc_info=True)
|
||||||
|
|
||||||
# Auto-compact
|
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(
|
messages, context_length, was_compacted = await maybe_compact(
|
||||||
sess, sess.endpoint_url, sess.model, messages, sess.headers, owner=user,
|
sess, sess.endpoint_url, sess.model, messages, sess.headers, owner=user,
|
||||||
)
|
)
|
||||||
_before_trim_messages = len(messages)
|
_before_trim_messages = len(messages)
|
||||||
_before_trim_tokens = estimate_tokens(messages)
|
_before_trim_tokens = estimate_tokens(messages)
|
||||||
|
if not defer_context_shaping:
|
||||||
messages = trim_for_context(messages, context_length)
|
messages = trim_for_context(messages, context_length)
|
||||||
_after_trim_messages = len(messages)
|
_after_trim_messages = len(messages)
|
||||||
_after_trim_tokens = estimate_tokens(messages)
|
_after_trim_tokens = estimate_tokens(messages)
|
||||||
@@ -860,6 +806,7 @@ async def build_chat_context(
|
|||||||
context_tokens_after_trim=_after_trim_tokens,
|
context_tokens_after_trim=_after_trim_tokens,
|
||||||
auto_opened_docs=auto_opened_docs,
|
auto_opened_docs=auto_opened_docs,
|
||||||
uploaded_files=uploaded_files,
|
uploaded_files=uploaded_files,
|
||||||
|
route_messages=route_messages,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+559
-35
@@ -15,13 +15,28 @@ from pydantic import ValidationError
|
|||||||
|
|
||||||
from core.models import ChatMessage
|
from core.models import ChatMessage
|
||||||
from src.request_models import ChatRequest
|
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.agent_loop import stream_agent_loop
|
||||||
from src import agent_runs
|
from src import agent_runs
|
||||||
from src.model_context import estimate_tokens
|
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.chat_helpers import coerce_message_and_session
|
||||||
from src.endpoint_resolver import normalize_base as _normalize_base, build_chat_url
|
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.session_search import search_session_messages
|
||||||
from src.prompt_security import untrusted_context_message
|
from src.prompt_security import untrusted_context_message
|
||||||
from core.exceptions import SessionNotFoundError
|
from core.exceptions import SessionNotFoundError
|
||||||
@@ -39,7 +54,9 @@ from routes.chat_helpers import (
|
|||||||
build_chat_context,
|
build_chat_context,
|
||||||
save_assistant_response,
|
save_assistant_response,
|
||||||
run_post_response_tasks,
|
run_post_response_tasks,
|
||||||
|
accumulate_token_usage,
|
||||||
clean_thinking_for_save,
|
clean_thinking_for_save,
|
||||||
|
_allowed_models_for_request,
|
||||||
_enforce_chat_privileges,
|
_enforce_chat_privileges,
|
||||||
)
|
)
|
||||||
from src.action_intents import ToolIntent, classify_tool_intent as _classify_tool_intent
|
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] = {}
|
_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:
|
def _stream_set(session_id: str, **fields) -> None:
|
||||||
"""Update fields on the active-stream entry for `session_id`, or
|
"""Update fields on the active-stream entry for `session_id`, or
|
||||||
no-op if the entry has already been popped. Using .get() avoids a
|
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)
|
# POST /api/chat (non-streaming)
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
@router.post("/api/chat", response_model=Dict[str, str])
|
@router.post("/api/chat", response_model=Dict[str, Any])
|
||||||
async def chat_endpoint(request: Request, chat_request: ChatRequest) -> Dict[str, str]:
|
async def chat_endpoint(request: Request, chat_request: ChatRequest) -> Dict[str, Any]:
|
||||||
_set_user_time_from_request(request)
|
_set_user_time_from_request(request)
|
||||||
|
|
||||||
message = chat_request.message
|
message = chat_request.message
|
||||||
@@ -623,6 +708,8 @@ def setup_chat_routes(
|
|||||||
400,
|
400,
|
||||||
"No model selected for this chat. Open the model picker and choose one before sending.",
|
"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
|
# Same allowed_models + daily-cap gate as chat_stream (mirror so the
|
||||||
# non-streaming path can't be used to bypass).
|
# non-streaming path can't be used to bypass).
|
||||||
@@ -638,6 +725,11 @@ def setup_chat_routes(
|
|||||||
if memory_response:
|
if memory_response:
|
||||||
return {"response": 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)
|
# Build shared context (preset, preprocess, preface, compact)
|
||||||
ctx = await build_chat_context(
|
ctx = await build_chat_context(
|
||||||
sess, request, chat_handler, chat_processor,
|
sess, request, chat_handler, chat_processor,
|
||||||
@@ -649,6 +741,7 @@ def setup_chat_routes(
|
|||||||
time_filter=time_filter,
|
time_filter=time_filter,
|
||||||
webhook_manager=webhook_manager,
|
webhook_manager=webhook_manager,
|
||||||
allow_tool_preprocessing=allow_tool_preprocessing,
|
allow_tool_preprocessing=allow_tool_preprocessing,
|
||||||
|
defer_context_shaping=foreground_policy.enabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Research injection
|
# Research injection
|
||||||
@@ -662,24 +755,88 @@ def setup_chat_routes(
|
|||||||
research_ctx = await research_handler.call_research_service(
|
research_ctx = await research_handler.call_research_service(
|
||||||
message, _r_ep, _r_model, llm_headers=_r_headers
|
message, _r_ep, _r_model, llm_headers=_r_headers
|
||||||
)
|
)
|
||||||
ctx.messages.insert(
|
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),
|
len(ctx.preface),
|
||||||
untrusted_context_message("research context", research_ctx),
|
research_message,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Research failed: {e}")
|
logger.error(f"Research failed: {e}")
|
||||||
|
|
||||||
reply = await llm_call_async(
|
foreground_candidates = build_foreground_model_candidates(
|
||||||
sess.endpoint_url,
|
sess.endpoint_url,
|
||||||
sess.model,
|
sess.model,
|
||||||
ctx.messages,
|
sess.headers,
|
||||||
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,
|
temperature=ctx.preset.temperature,
|
||||||
max_tokens=ctx.preset.max_tokens,
|
max_tokens=ctx.preset.max_tokens,
|
||||||
prompt_type=preset_id,
|
prompt_type=preset_id,
|
||||||
session_id=session,
|
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))
|
sess.add_message(ChatMessage("assistant", _clean_reply, metadata=_clean_md))
|
||||||
|
|
||||||
from core.database import update_session_last_accessed
|
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,
|
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
|
# POST /api/chat_stream
|
||||||
@@ -724,6 +889,11 @@ def setup_chat_routes(
|
|||||||
use_research = form_data.get("use_research")
|
use_research = form_data.get("use_research")
|
||||||
time_filter = form_data.get("time_filter")
|
time_filter = form_data.get("time_filter")
|
||||||
preset_id = form_data.get("preset_id")
|
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
|
# Issue #3229: API callers send JSON, not FormData. Read from the
|
||||||
# JSON body as fallback so callers who send {"allow_bash": true}
|
# JSON body as fallback so callers who send {"allow_bash": true}
|
||||||
# actually get bash enabled.
|
# actually get bash enabled.
|
||||||
@@ -896,6 +1066,8 @@ def setup_chat_routes(
|
|||||||
400,
|
400,
|
||||||
"No model selected for this chat. Open the model picker and choose one before sending.",
|
"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 (
|
if (
|
||||||
chat_mode == "chat"
|
chat_mode == "chat"
|
||||||
and isinstance(message, str)
|
and isinstance(message, str)
|
||||||
@@ -971,6 +1143,10 @@ def setup_chat_routes(
|
|||||||
last_user_message=message,
|
last_user_message=message,
|
||||||
)
|
)
|
||||||
allow_tool_preprocessing = not pre_context_tool_policy.block_all_tool_calls
|
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)
|
# Build shared context (stream path uses enhanced_message for context preface)
|
||||||
ctx = await build_chat_context(
|
ctx = await build_chat_context(
|
||||||
@@ -993,6 +1169,7 @@ def setup_chat_routes(
|
|||||||
# index would be useless / unwanted noise.
|
# index would be useless / unwanted noise.
|
||||||
agent_mode=(chat_mode == "agent"),
|
agent_mode=(chat_mode == "agent"),
|
||||||
allow_tool_preprocessing=allow_tool_preprocessing,
|
allow_tool_preprocessing=allow_tool_preprocessing,
|
||||||
|
defer_context_shaping=foreground_policy.enabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
_research_flags = {"do": do_research} # Mutable container for generator scope
|
_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 "
|
"what aspects matter most, are they comparing to something, what's their context "
|
||||||
"(moving, traveling, curiosity). Be conversational. Keep it short."
|
"(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
|
_skip_research = True
|
||||||
else:
|
else:
|
||||||
_skip_research = False
|
_skip_research = False
|
||||||
@@ -1388,7 +1567,12 @@ def setup_chat_routes(
|
|||||||
_active_streams.pop(session, None)
|
_active_streams.pop(session, None)
|
||||||
return
|
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
|
# Auto-compact notification
|
||||||
if ctx.was_compacted:
|
if ctx.was_compacted:
|
||||||
@@ -1400,25 +1584,56 @@ def setup_chat_routes(
|
|||||||
thinking_response = ""
|
thinking_response = ""
|
||||||
last_metrics = None
|
last_metrics = None
|
||||||
|
|
||||||
# Foreground Chat and Agent requests use one owner-aware policy
|
# Foreground Chat and Agent requests share one explicit owner-aware
|
||||||
# boundary. Legacy `default_model_fallbacks` data is not eligible.
|
# policy. Strict mode is the default; legacy values are unrelated.
|
||||||
|
_foreground_policy = foreground_policy
|
||||||
_foreground_candidates = build_foreground_model_candidates(
|
_foreground_candidates = build_foreground_model_candidates(
|
||||||
sess.endpoint_url,
|
sess.endpoint_url,
|
||||||
sess.model,
|
sess.model,
|
||||||
sess.headers,
|
sess.headers,
|
||||||
owner=_user,
|
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
|
# Send model name early so the frontend can show it during streaming
|
||||||
_model_suffix = "Research" if effective_do_research else None
|
_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:
|
if _model_suffix:
|
||||||
_model_info["suffix"] = _model_suffix
|
_model_info["suffix"] = _model_suffix
|
||||||
if ctx.preset.character_name:
|
if ctx.preset.character_name:
|
||||||
_model_info["character_name"] = ctx.preset.character_name
|
_model_info["character_name"] = ctx.preset.character_name
|
||||||
yield f'data: {json.dumps(_model_info)}\n\n'
|
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
|
from src.settings import get_setting
|
||||||
if tool_policy.blocks("generate_image"):
|
if tool_policy.blocks("generate_image"):
|
||||||
_blocked_msg = tool_policy.reason_for("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
|
_answered_by = None # set if the selected model failed and a fallback answered
|
||||||
_requested_model = sess.model
|
_requested_model = sess.model
|
||||||
_actual_model = None
|
_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 ──
|
# ── Chat mode: call stream_llm directly, NO tools, NO document access ──
|
||||||
try:
|
try:
|
||||||
async for chunk in stream_llm_with_fallback(
|
async for chunk in stream_llm_with_fallback(
|
||||||
@@ -1536,11 +1761,21 @@ def setup_chat_routes(
|
|||||||
prompt_type=preset_id,
|
prompt_type=preset_id,
|
||||||
tools=None,
|
tools=None,
|
||||||
session_id=session,
|
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]"):
|
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
|
||||||
try:
|
try:
|
||||||
data = json.loads(chunk[6:])
|
data = json.loads(chunk[6:])
|
||||||
if "delta" in data:
|
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.
|
# Reasoning tokens arrive flagged thinking:true.
|
||||||
# Forward them so the client can show a thinking
|
# Forward them so the client can show a thinking
|
||||||
# indicator, but don't fold them into the saved
|
# 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.
|
# Forward the notice and remember the real model.
|
||||||
_answered_by = data.get("answered_by") or _answered_by
|
_answered_by = data.get("answered_by") or _answered_by
|
||||||
_actual_model = _actual_model 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
|
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":
|
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
|
_actual_model = data.get("model") or _actual_model
|
||||||
data["requested_model"] = _requested_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'
|
yield f'data: {json.dumps(data)}\n\n'
|
||||||
elif data.get("type") == "usage":
|
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", {})
|
last_metrics = data.get("data", {})
|
||||||
_reported_model = last_metrics.get("model")
|
_reported_model = last_metrics.get("model")
|
||||||
last_metrics["requested_model"] = _requested_model
|
last_metrics["requested_model"] = _requested_model
|
||||||
last_metrics["model"] = _reported_model or _actual_model or _answered_by or _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_trimmed"] = True
|
||||||
last_metrics["context_messages_before_trim"] = ctx.context_messages_before_trim
|
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_messages_after_trim"] = ctx.context_messages_after_trim
|
||||||
last_metrics["context_tokens_before_trim"] = ctx.context_tokens_before_trim
|
last_metrics["context_tokens_before_trim"] = ctx.context_tokens_before_trim
|
||||||
last_metrics["context_tokens_after_trim"] = ctx.context_tokens_after_trim
|
last_metrics["context_tokens_after_trim"] = ctx.context_tokens_after_trim
|
||||||
request_context_tokens = ctx.context_tokens_after_trim or estimate_tokens(messages)
|
if _actual_context_length and last_metrics.get("input_tokens"):
|
||||||
last_metrics["request_context_tokens"] = request_context_tokens
|
pct = min(round((last_metrics["input_tokens"] / _actual_context_length) * 100, 1), 100.0)
|
||||||
if ctx.context_length and request_context_tokens:
|
|
||||||
pct = min(round((request_context_tokens / ctx.context_length) * 100, 1), 100.0)
|
|
||||||
last_metrics["context_percent"] = pct
|
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
|
# The frontend reads `tokens_per_second`; the raw usage event
|
||||||
# carries the backend's true gen speed as `gen_tps` (llama.cpp
|
# carries the backend's true gen speed as `gen_tps` (llama.cpp
|
||||||
# timings). Map it through so this direct-chat path shows real
|
# timings). Map it through so this direct-chat path shows real
|
||||||
@@ -1593,17 +1881,121 @@ def setup_chat_routes(
|
|||||||
yield chunk
|
yield chunk
|
||||||
elif chunk.startswith("event: error"):
|
elif chunk.startswith("event: error"):
|
||||||
logger.warning(f"Stream error for {sess.model} on {sess.endpoint_url}: {chunk!r}")
|
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
|
yield chunk
|
||||||
elif chunk.startswith("event: "):
|
elif chunk.startswith("event: "):
|
||||||
yield chunk
|
yield chunk
|
||||||
elif chunk == "data: [DONE]\n\n":
|
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
|
# Generate fallback metrics if LLM didn't send usage
|
||||||
if not last_metrics and full_response:
|
if not last_metrics and full_response:
|
||||||
_elapsed = time.time() - _chat_start
|
_elapsed = time.time() - _chat_start
|
||||||
_est_in = estimate_tokens(messages)
|
|
||||||
_est_out = len(full_response) // 4
|
_est_out = len(full_response) // 4
|
||||||
_tps = round(_est_out / _elapsed, 2) if _elapsed > 0 else 0
|
_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 = {
|
last_metrics = {
|
||||||
"response_time": round(_elapsed, 2),
|
"response_time": round(_elapsed, 2),
|
||||||
"input_tokens": _est_in,
|
"input_tokens": _est_in,
|
||||||
@@ -1611,13 +2003,25 @@ def setup_chat_routes(
|
|||||||
"tokens_per_second": _tps,
|
"tokens_per_second": _tps,
|
||||||
"request_context_tokens": _est_in,
|
"request_context_tokens": _est_in,
|
||||||
"context_percent": _ctx_pct,
|
"context_percent": _ctx_pct,
|
||||||
"context_length": ctx.context_length,
|
"context_length": _actual_context_length,
|
||||||
"model": _actual_model or _answered_by or _requested_model,
|
"model": _actual_model or _answered_by or _requested_model,
|
||||||
"requested_model": _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",
|
"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'
|
yield f'data: {json.dumps({"type": "metrics", "data": last_metrics})}\n\n'
|
||||||
if full_response:
|
if full_response:
|
||||||
|
_commit_chat_compaction(_actual_candidate_index)
|
||||||
_metrics_to_save = dict(last_metrics or {})
|
_metrics_to_save = dict(last_metrics or {})
|
||||||
if thinking_response.strip() and not _metrics_to_save.get("thinking"):
|
if thinking_response.strip() and not _metrics_to_save.get("thinking"):
|
||||||
_metrics_to_save["thinking"] = thinking_response.strip()
|
_metrics_to_save["thinking"] = thinking_response.strip()
|
||||||
@@ -1652,6 +2056,10 @@ def setup_chat_routes(
|
|||||||
"stopped": True,
|
"stopped": True,
|
||||||
"model": _actual_model or _answered_by or _requested_model,
|
"model": _actual_model or _answered_by or _requested_model,
|
||||||
"requested_model": _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))
|
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
|
_answered_by = None # set if the selected model failed and a fallback answered
|
||||||
_requested_model = sess.model
|
_requested_model = sess.model
|
||||||
_actual_model = None
|
_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:
|
try:
|
||||||
from src.settings import get_setting
|
from src.settings import get_setting
|
||||||
from src.agent_tools import MAX_AGENT_ROUNDS as _DEFAULT_ROUNDS
|
from src.agent_tools import MAX_AGENT_ROUNDS as _DEFAULT_ROUNDS
|
||||||
@@ -1703,19 +2117,24 @@ def setup_chat_routes(
|
|||||||
prompt_type=preset_id,
|
prompt_type=preset_id,
|
||||||
max_tool_calls=_tool_budget,
|
max_tool_calls=_tool_budget,
|
||||||
max_rounds=_max_rounds,
|
max_rounds=_max_rounds,
|
||||||
context_length=ctx.context_length,
|
context_length=_selected_context_length,
|
||||||
active_document=active_doc,
|
active_document=active_doc,
|
||||||
active_email=active_email_ctx,
|
active_email=active_email_ctx,
|
||||||
session_id=session,
|
session_id=session,
|
||||||
|
history_session=sess,
|
||||||
disabled_tools=disabled_tools if disabled_tools else None,
|
disabled_tools=disabled_tools if disabled_tools else None,
|
||||||
tool_policy=tool_policy,
|
tool_policy=tool_policy,
|
||||||
owner=_user,
|
owner=_user,
|
||||||
fallbacks=_foreground_candidates[1:],
|
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,
|
plan_mode=plan_mode,
|
||||||
approved_plan=approved_plan or None,
|
approved_plan=approved_plan or None,
|
||||||
workspace=workspace or None,
|
workspace=workspace or None,
|
||||||
forced_tools=_forced_tools,
|
forced_tools=_forced_tools,
|
||||||
uploaded_files=ctx.uploaded_files,
|
uploaded_files=ctx.uploaded_files,
|
||||||
|
defer_context_shaping=_foreground_policy.enabled,
|
||||||
):
|
):
|
||||||
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
|
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
|
||||||
try:
|
try:
|
||||||
@@ -1744,7 +2163,20 @@ def setup_chat_routes(
|
|||||||
"plan_update",
|
"plan_update",
|
||||||
):
|
):
|
||||||
if data.get("type") == "agent_step":
|
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":
|
elif data.get("type") == "tool_start":
|
||||||
_agent_tool_calls += 1
|
_agent_tool_calls += 1
|
||||||
yield chunk
|
yield chunk
|
||||||
@@ -1754,13 +2186,70 @@ def setup_chat_routes(
|
|||||||
# model so metrics reflect it, not the masked
|
# model so metrics reflect it, not the masked
|
||||||
# selected model.
|
# selected model.
|
||||||
_answered_by = data.get("answered_by") or _answered_by
|
_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
|
data["selected_model"] = data.get("selected_model") or _requested_model
|
||||||
yield chunk
|
yield chunk
|
||||||
elif data.get("type") == "model_actual":
|
elif data.get("type") == "model_actual":
|
||||||
_actual_model = data.get("model") or _actual_model
|
_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
|
data["requested_model"] = _requested_model
|
||||||
yield f'data: {json.dumps(data)}\n\n'
|
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":
|
elif data.get("type") == "metrics":
|
||||||
last_metrics = data.get("data", {})
|
last_metrics = data.get("data", {})
|
||||||
_reported_model = last_metrics.get("model")
|
_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_messages_after_trim"] = ctx.context_messages_after_trim
|
||||||
last_metrics["context_tokens_before_trim"] = ctx.context_tokens_before_trim
|
last_metrics["context_tokens_before_trim"] = ctx.context_tokens_before_trim
|
||||||
last_metrics["context_tokens_after_trim"] = ctx.context_tokens_after_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:
|
except json.JSONDecodeError:
|
||||||
yield chunk
|
yield chunk
|
||||||
elif chunk.startswith("event: "):
|
elif chunk.startswith("event: "):
|
||||||
@@ -1824,6 +2322,22 @@ def setup_chat_routes(
|
|||||||
"stopped": True,
|
"stopped": True,
|
||||||
"model": _actual_model or _answered_by or _requested_model,
|
"model": _actual_model or _answered_by or _requested_model,
|
||||||
"requested_model": _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))
|
sess.add_message(ChatMessage("assistant", _stopped_content2, metadata=_stopped_md2))
|
||||||
@@ -1866,8 +2380,12 @@ def setup_chat_routes(
|
|||||||
if compare_mode:
|
if compare_mode:
|
||||||
return StreamingResponse(_safe_stream(), media_type="text/event-stream")
|
return StreamingResponse(_safe_stream(), media_type="text/event-stream")
|
||||||
|
|
||||||
agent_runs.start(session, _safe_stream())
|
_detached_run = agent_runs.start(session, _safe_stream())
|
||||||
return StreamingResponse(agent_runs.subscribe(session), media_type="text/event-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
|
# 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}")
|
@router.get("/api/chat/resume/{session_id}")
|
||||||
async def chat_resume(request: Request, session_id: str) -> StreamingResponse:
|
async def chat_resume(request: Request, session_id: str) -> StreamingResponse:
|
||||||
_verify_session_owner(request, session_id)
|
_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")
|
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
|
# 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}")
|
@router.post("/api/chat/stop/{session_id}")
|
||||||
async def chat_stop(request: Request, session_id: str) -> Dict[str, Any]:
|
async def chat_stop(request: Request, session_id: str) -> Dict[str, Any]:
|
||||||
_verify_session_owner(request, session_id)
|
_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}
|
return {"stopped": stopped}
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
+3
-10
@@ -5004,7 +5004,6 @@ def setup_email_routes():
|
|||||||
from src.endpoint_resolver import (
|
from src.endpoint_resolver import (
|
||||||
resolve_endpoint,
|
resolve_endpoint,
|
||||||
resolve_utility_fallback_candidates,
|
resolve_utility_fallback_candidates,
|
||||||
resolve_chat_fallback_candidates,
|
|
||||||
)
|
)
|
||||||
from src.llm_core import llm_call_async_with_fallback
|
from src.llm_core import llm_call_async_with_fallback
|
||||||
|
|
||||||
@@ -5066,8 +5065,6 @@ def setup_email_routes():
|
|||||||
pass
|
pass
|
||||||
for cand in resolve_utility_fallback_candidates(owner=owner) or []:
|
for cand in resolve_utility_fallback_candidates(owner=owner) or []:
|
||||||
_add(*cand)
|
_add(*cand)
|
||||||
for cand in resolve_chat_fallback_candidates(owner=owner) or []:
|
|
||||||
_add(*cand)
|
|
||||||
if not candidates:
|
if not candidates:
|
||||||
return {"success": False, "error": "No LLM endpoint configured"}
|
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
|
# Build a candidate chain so a stale session-stored API key
|
||||||
# (the most common cause of "authentication failed" here)
|
# (the most common cause of "authentication failed" here)
|
||||||
# doesn't kill AI Reply outright — fall through to the
|
# doesn't kill AI Reply outright — fall through to the
|
||||||
# user's Utility / Default endpoints and the active Utility
|
# user's Utility / Default endpoints and active Utility fallback
|
||||||
# fallback chain. The retired default-fallback hook stays empty.
|
# chain. Dedupe by url+model so we don't retry the same endpoint.
|
||||||
# Dedupe by url+model so we don't retry the same broken endpoint.
|
|
||||||
from src.llm_core import llm_call_async_with_fallback
|
from src.llm_core import llm_call_async_with_fallback
|
||||||
from src.endpoint_resolver import (
|
from src.endpoint_resolver import (
|
||||||
resolve_utility_fallback_candidates,
|
resolve_utility_fallback_candidates,
|
||||||
resolve_chat_fallback_candidates,
|
|
||||||
)
|
)
|
||||||
_seen = set()
|
_seen = set()
|
||||||
_candidates = []
|
_candidates = []
|
||||||
@@ -5358,11 +5353,9 @@ def setup_email_routes():
|
|||||||
_add(_d_url, _d_model, _d_headers)
|
_add(_d_url, _d_model, _d_headers)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# Active Utility fallbacks, then the retired default hook.
|
# Active Utility fallbacks last.
|
||||||
for cand in resolve_utility_fallback_candidates(owner=owner) or []:
|
for cand in resolve_utility_fallback_candidates(owner=owner) or []:
|
||||||
_add(*cand)
|
_add(*cand)
|
||||||
for cand in resolve_chat_fallback_candidates(owner=owner) or []:
|
|
||||||
_add(*cand)
|
|
||||||
_messages = [
|
_messages = [
|
||||||
{"role": "system", "content": system_prompt},
|
{"role": "system", "content": system_prompt},
|
||||||
{"role": "user", "content": user_msg},
|
{"role": "user", "content": user_msg},
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ _ENDPOINT_SETTING_FIELDS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_ENDPOINT_FALLBACK_FIELDS = {
|
_ENDPOINT_FALLBACK_FIELDS = {
|
||||||
|
"foreground_model_fallbacks": "Foreground Model Fallbacks",
|
||||||
"utility_model_fallbacks": "Utility Model Fallbacks",
|
"utility_model_fallbacks": "Utility Model Fallbacks",
|
||||||
"vision_model_fallbacks": "Vision 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):
|
if not isinstance(all_prefs, dict):
|
||||||
return 0
|
return 0
|
||||||
users = all_prefs.get("_users")
|
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
|
cleared_users = 0
|
||||||
for prefs in pref_sets:
|
for prefs in pref_sets:
|
||||||
if isinstance(prefs, dict) and _clear_endpoint_settings_for_endpoint(prefs, ep_id):
|
if isinstance(prefs, dict) and _clear_endpoint_settings_for_endpoint(prefs, ep_id):
|
||||||
|
|||||||
+51
-11
@@ -7,6 +7,10 @@ from src.auth_helpers import get_current_user
|
|||||||
from src.constants import USER_PREFS_FILE
|
from src.constants import USER_PREFS_FILE
|
||||||
|
|
||||||
PREFS_FILE = USER_PREFS_FILE
|
PREFS_FILE = USER_PREFS_FILE
|
||||||
|
_FOREGROUND_POLICY_KEYS = (
|
||||||
|
"foreground_fallback_enabled",
|
||||||
|
"foreground_model_fallbacks",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _load():
|
def _load():
|
||||||
@@ -26,14 +30,27 @@ def _save(prefs):
|
|||||||
def _load_for_user(user: Optional[str] = None) -> dict:
|
def _load_for_user(user: Optional[str] = None) -> dict:
|
||||||
"""Load preferences for a specific user."""
|
"""Load preferences for a specific user."""
|
||||||
all_prefs = _load()
|
all_prefs = _load()
|
||||||
if "_users" in all_prefs:
|
users = all_prefs.get("_users")
|
||||||
|
if isinstance(users, dict):
|
||||||
if user is None:
|
if user is None:
|
||||||
# Auth disabled — return first user's prefs for backward compat
|
# Auth disabled — return first user's prefs for backward compat
|
||||||
users = all_prefs["_users"]
|
prefs = dict(next(iter(users.values()), {}))
|
||||||
return dict(next(iter(users.values()), {}))
|
# Foreground fallback consent is never borrowed from a named
|
||||||
return dict(all_prefs["_users"].get(user, {}))
|
# owner. Auth-disabled operation has a separate flat/root opt-in
|
||||||
# Legacy flat format — return as-is
|
# that remains inert when authentication is enabled again.
|
||||||
return dict(all_prefs)
|
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):
|
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
|
# `prefs` flat would overwrite the whole `_users` map and destroy every
|
||||||
# other user's preferences. Instead write back into the same (first)
|
# other user's preferences. Instead write back into the same (first)
|
||||||
# slot _load_for_user(None) reads from, preserving the others.
|
# slot _load_for_user(None) reads from, preserving the others.
|
||||||
if "_users" in all_prefs:
|
users = all_prefs.get("_users")
|
||||||
users = all_prefs["_users"]
|
if isinstance(users, dict):
|
||||||
first_key = next(iter(users), None)
|
first_key = next(iter(users), None)
|
||||||
if first_key is not 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)
|
_save(all_prefs)
|
||||||
return
|
return
|
||||||
_save(prefs)
|
_save(prefs)
|
||||||
return
|
return
|
||||||
if "_users" not in all_prefs:
|
if not isinstance(all_prefs.get("_users"), dict):
|
||||||
all_prefs = {"_users": {}}
|
# 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
|
all_prefs["_users"][user] = prefs
|
||||||
_save(all_prefs)
|
_save(all_prefs)
|
||||||
|
|
||||||
|
|||||||
+1063
-264
File diff suppressed because it is too large
Load Diff
+82
-24
@@ -17,13 +17,14 @@ close / navigation / refresh). It does NOT survive a server restart.
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import uuid
|
||||||
from typing import AsyncGenerator, Dict, Optional
|
from typing import AsyncGenerator, Dict, Optional
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class _Run:
|
class _Run:
|
||||||
__slots__ = ("buffer", "subscribers", "status", "task", "evict_task")
|
__slots__ = ("buffer", "subscribers", "status", "task", "evict_task", "run_id")
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.buffer: list = [] # ordered SSE event strings (replay log)
|
self.buffer: list = [] # ordered SSE event strings (replay log)
|
||||||
@@ -31,6 +32,9 @@ class _Run:
|
|||||||
self.status: str = "running" # running | done | error | stopped
|
self.status: str = "running" # running | done | error | stopped
|
||||||
self.task: Optional[asyncio.Task] = None
|
self.task: Optional[asyncio.Task] = None
|
||||||
self.evict_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] = {}
|
_RUNS: Dict[str, _Run] = {}
|
||||||
@@ -53,13 +57,24 @@ def _publish(run: _Run, ev: str) -> None:
|
|||||||
pass
|
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.
|
"""(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
|
Identity-checked so a run that gets replaced/reused is never evicted by a
|
||||||
stale timer."""
|
stale timer."""
|
||||||
run = _RUNS.get(session_id)
|
run = _RUNS.get(session_id)
|
||||||
if run is None:
|
if run is None:
|
||||||
return
|
return
|
||||||
|
if expected_run is not None and run is not expected_run:
|
||||||
|
return
|
||||||
if run.evict_task and not run.evict_task.done():
|
if run.evict_task and not run.evict_task.done():
|
||||||
run.evict_task.cancel()
|
run.evict_task.cancel()
|
||||||
|
|
||||||
@@ -85,25 +100,38 @@ def get_status(session_id: str) -> Optional[str]:
|
|||||||
return r.status if r else None
|
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:
|
prev_task: Optional[asyncio.Task] = None) -> None:
|
||||||
"""Pull every event from the wrapped generator into the run buffer, fanning
|
"""Pull every event from the wrapped generator into the run buffer, fanning
|
||||||
each out to live subscribers. Runs to completion regardless of subscribers."""
|
each out to live subscribers. Runs to completion regardless of subscribers."""
|
||||||
run = _RUNS.get(session_id)
|
subscribers_woken = False
|
||||||
if run is None:
|
|
||||||
|
def _wake_subscribers() -> None:
|
||||||
|
nonlocal subscribers_woken
|
||||||
|
if subscribers_woken:
|
||||||
return
|
return
|
||||||
|
subscribers_woken = True
|
||||||
|
_wake_run_subscribers(run)
|
||||||
|
|
||||||
# If this run replaced an in-flight one (rapid double-send), wait for that
|
# 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
|
# one to fully finish first. Its CancelledError handler calls aclose(), which
|
||||||
# persists its partial response — letting it complete before we start writing
|
# persists its partial response — letting it complete before we start writing
|
||||||
# keeps the two runs' session saves sequential instead of interleaved.
|
# keeps the two runs' session saves sequential instead of interleaved.
|
||||||
|
try:
|
||||||
if prev_task is not None and not prev_task.done():
|
if prev_task is not None and not prev_task.done():
|
||||||
try:
|
|
||||||
await asyncio.wait({prev_task})
|
await asyncio.wait({prev_task})
|
||||||
except asyncio.CancelledError:
|
|
||||||
raise # our own cancellation — propagate
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
async for ev in agen:
|
async for ev in agen:
|
||||||
_publish(run, ev)
|
_publish(run, ev)
|
||||||
if run.status == "running":
|
if run.status == "running":
|
||||||
@@ -116,6 +144,16 @@ async def _drain(session_id: str, agen: AsyncGenerator[str, None],
|
|||||||
await agen.aclose()
|
await agen.aclose()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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:
|
except Exception as e:
|
||||||
logger.error("[agent-run] %s failed: %s", session_id, e, exc_info=True)
|
logger.error("[agent-run] %s failed: %s", session_id, e, exc_info=True)
|
||||||
run.status = "error"
|
run.status = "error"
|
||||||
@@ -127,15 +165,11 @@ async def _drain(session_id: str, agen: AsyncGenerator[str, None],
|
|||||||
_publish(run, "data: [DONE]\n\n")
|
_publish(run, "data: [DONE]\n\n")
|
||||||
finally:
|
finally:
|
||||||
# Wake every subscriber with the end sentinel so their SSE closes.
|
# Wake every subscriber with the end sentinel so their SSE closes.
|
||||||
for q in list(run.subscribers):
|
_wake_subscribers()
|
||||||
try:
|
|
||||||
q.put_nowait((None, None))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# Run is terminal — arm the grace timer so it (and its buffer) is
|
# Run is terminal — arm the grace timer so it (and its buffer) is
|
||||||
# eventually freed even if nobody ever reconnects. subscribe() cancels
|
# eventually freed even if nobody ever reconnects. subscribe() cancels
|
||||||
# this on connect and re-arms on disconnect.
|
# 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:
|
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
|
prev_task: Optional[asyncio.Task] = None
|
||||||
if prev:
|
if prev:
|
||||||
if prev.task and not prev.task.done():
|
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.cancel()
|
||||||
prev_task = prev.task # new run awaits this before it starts writing
|
prev_task = prev.task # new run awaits this before it starts writing
|
||||||
if prev.evict_task and not prev.evict_task.done():
|
if prev.evict_task and not prev.evict_task.done():
|
||||||
prev.evict_task.cancel()
|
prev.evict_task.cancel()
|
||||||
run = _Run()
|
run = _Run()
|
||||||
_RUNS[session_id] = 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
|
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.
|
"""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."""
|
Safe to call repeatedly (reconnect) and from multiple clients at once.
|
||||||
run = _RUNS.get(session_id)
|
|
||||||
|
``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:
|
if run is None:
|
||||||
return
|
return
|
||||||
q: asyncio.Queue = asyncio.Queue()
|
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
|
# Last subscriber gone on a finished run — (re)arm eviction so the
|
||||||
# buffer doesn't linger indefinitely.
|
# buffer doesn't linger indefinitely.
|
||||||
if not run.subscribers and run.status != "running":
|
if not run.subscribers and run.status != "running":
|
||||||
_schedule_evict(session_id)
|
_schedule_evict(session_id, run)
|
||||||
|
|
||||||
|
|
||||||
def stop(session_id: str) -> bool:
|
def stop(session_id: str, expected_run_id: Optional[str] = None) -> bool:
|
||||||
"""Cancel an in-flight run (the wrapped generator saves its partial)."""
|
"""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)
|
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():
|
if run and run.task and not run.task.done():
|
||||||
run.task.cancel()
|
run.task.cancel()
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -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
|
# set/get/list/delete operate on the REAL app settings (the same store
|
||||||
# the Settings panel writes), so changing a model / voice / search
|
# the Settings panel writes), so changing a model / voice / search
|
||||||
# engine / reminder channel from chat actually takes effect.
|
# 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)
|
# Secrets/credentials the agent must NOT write: kept read-only (masked)
|
||||||
# so API keys never flow through chat. User sets these in the panel.
|
# 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 k2
|
||||||
return _ALIASES_SET.get(k2, (k or "").strip())
|
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 = {
|
_ENUMS = {
|
||||||
"image_quality": ["low", "medium", "high"],
|
"image_quality": ["low", "medium", "high"],
|
||||||
"reminder_channel": ["browser", "email", "ntfy", "webhook"],
|
"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":
|
if action == "list":
|
||||||
s = load_settings()
|
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}
|
return {"response": f"{len(shown)} settings (use get/set with a key)", "settings": shown, "exit_code": 0}
|
||||||
|
|
||||||
elif action == "get":
|
elif action == "get":
|
||||||
key = _resolve(args.get("key", ""))
|
key = _resolve(args.get("key", ""))
|
||||||
if not key:
|
if not key:
|
||||||
return {"error": "key is required", "exit_code": 1}
|
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}
|
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))
|
val = load_settings().get(key, DEFAULT_SETTINGS.get(key))
|
||||||
return {"response": f"{key} = {_mask(key, val)}", "value": _mask(key, val), "exit_code": 0}
|
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:
|
if not raw:
|
||||||
return {"error": "key is required", "exit_code": 1}
|
return {"error": "key is required", "exit_code": 1}
|
||||||
key = _resolve(raw)
|
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}
|
return {"error": f"Unknown setting '{raw}'. Use action='list' to see available settings.", "exit_code": 1}
|
||||||
if _is_secret(key):
|
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}
|
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
|
# have no safe scalar coercion; _coerce would pass a bare string
|
||||||
# straight through and clobber the structure. Refuse them here; they're
|
# straight through and clobber the structure. Refuse them here; they're
|
||||||
# edited in their dedicated panels. (reset/delete still restore the
|
# 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":
|
elif action == "delete" or action == "reset":
|
||||||
key = _resolve(args.get("key", ""))
|
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}
|
return {"error": f"Unknown setting '{args.get('key')}'.", "exit_code": 1}
|
||||||
if _is_secret(key):
|
if _is_secret(key):
|
||||||
return {"response": f"'{key}' is a credential. Reset it in the panel.", "exit_code": 0}
|
return {"response": f"'{key}' is a credential. Reset it in the panel.", "exit_code": 0}
|
||||||
|
|||||||
@@ -282,7 +282,9 @@ def trim_for_context(messages: List[Dict], context_length: int, reserve_tokens:
|
|||||||
if essential_system:
|
if essential_system:
|
||||||
sys_text = essential_system[0].get("content", "")
|
sys_text = essential_system[0].get("content", "")
|
||||||
if len(sys_text) > 2000:
|
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
|
trimmed = essential_system + convo_msgs
|
||||||
if estimate_tokens(trimmed) <= budget:
|
if estimate_tokens(trimmed) <= budget:
|
||||||
return _sanitize_tool_messages(essential_system + protected_msgs + convo_msgs)
|
return _sanitize_tool_messages(essential_system + protected_msgs + convo_msgs)
|
||||||
@@ -325,6 +327,9 @@ async def maybe_compact(
|
|||||||
messages: List[Dict],
|
messages: List[Dict],
|
||||||
headers: Optional[Dict] = None,
|
headers: Optional[Dict] = None,
|
||||||
owner: Optional[str] = None,
|
owner: Optional[str] = None,
|
||||||
|
*,
|
||||||
|
persist: bool = True,
|
||||||
|
compaction_state: Optional[Dict[str, Any]] = None,
|
||||||
) -> tuple:
|
) -> tuple:
|
||||||
"""Check context usage and compact if above threshold.
|
"""Check context usage and compact if above threshold.
|
||||||
|
|
||||||
@@ -416,7 +421,17 @@ async def maybe_compact(
|
|||||||
# offset — session.history INCLUDES the system messages, but
|
# offset — session.history INCLUDES the system messages, but
|
||||||
# split_point is indexed against convo_msgs which does NOT. Without
|
# split_point is indexed against convo_msgs which does NOT. Without
|
||||||
# this, the slice drops the leading system message(s).
|
# this, the slice drops the leading system message(s).
|
||||||
|
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))
|
_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)
|
new_used = estimate_tokens(compacted)
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -427,6 +442,51 @@ async def maybe_compact(
|
|||||||
return compacted, context_length, True
|
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,
|
def _update_session_history(session, split_point: int, summary: str,
|
||||||
system_msg_count: int = 0):
|
system_msg_count: int = 0):
|
||||||
"""Update the in-memory session history after compaction.
|
"""Update the in-memory session history after compaction.
|
||||||
|
|||||||
+213
-17
@@ -5,6 +5,7 @@ Consolidates the 4+ copies of normalize_base / resolve_endpoint logic into one p
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
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]:
|
def _first_chat_model(models) -> Optional[str]:
|
||||||
"""First model that isn't an embedding/tts/etc.; falls back to models[0]."""
|
"""First model that isn't an embedding/tts/etc.; falls back to models[0]."""
|
||||||
for m in (models or []):
|
for m in (models or []):
|
||||||
@@ -396,10 +434,14 @@ def resolve_endpoint(
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def resolve_endpoint_by_id(
|
def _resolve_endpoint_by_id_with_descriptor(
|
||||||
ep_id: str, model: Optional[str] = None, owner: Optional[str] = None
|
ep_id: str,
|
||||||
) -> Optional[Tuple[str, str, Dict]]:
|
model: Optional[str] = None,
|
||||||
"""Resolve a specific endpoint id (+ optional model) to (chat_url, model, headers).
|
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
|
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.
|
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)
|
chat_url = build_chat_url(base)
|
||||||
headers = build_headers(api_key, base)
|
headers = build_headers(api_key, base)
|
||||||
m = (model or "").strip()
|
m = (model or "").strip()
|
||||||
# Drop a model the user disabled on the endpoint, then pick the first
|
enabled_models = _endpoint_enabled_models(ep)
|
||||||
# enabled chat model rather than a hidden one.
|
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):
|
if m and m in _endpoint_hidden_models(ep):
|
||||||
m = ""
|
m = ""
|
||||||
if not m:
|
if not m:
|
||||||
m = _first_chat_model(_endpoint_enabled_models(ep)) or ""
|
m = _first_chat_model(enabled_models) or ""
|
||||||
if not m:
|
if not m:
|
||||||
return None
|
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:
|
except Exception as e:
|
||||||
logger.debug(f"Could not resolve endpoint {ep_id}: {e}")
|
logger.debug(f"Could not resolve endpoint {ep_id}: {e}")
|
||||||
return None
|
return None
|
||||||
@@ -442,11 +503,101 @@ def resolve_endpoint_by_id(
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def resolve_chat_fallback_candidates(owner: Optional[str] = None) -> list:
|
def resolve_endpoint_by_id(
|
||||||
"""Compatibility shim for the retired default-chat fallback chain."""
|
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
|
resolved = _resolve_endpoint_by_id_with_descriptor(
|
||||||
return []
|
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:
|
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:
|
def _resolve_fallback_candidates(setting_key: str, owner: Optional[str] = None) -> list:
|
||||||
out = []
|
|
||||||
try:
|
try:
|
||||||
from src.settings import get_user_setting, load_settings
|
from src.settings import get_user_setting, load_settings
|
||||||
settings = load_settings()
|
settings = load_settings()
|
||||||
chain = get_user_setting(setting_key, owner or "", settings.get(setting_key) or []) or []
|
chain = get_user_setting(setting_key, owner or "", settings.get(setting_key) or []) or []
|
||||||
except Exception:
|
except Exception:
|
||||||
return out
|
return []
|
||||||
for entry in chain:
|
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):
|
if not isinstance(entry, dict):
|
||||||
continue
|
continue
|
||||||
resolved = resolve_endpoint_by_id(entry.get("endpoint_id", ""), entry.get("model", ""), owner=owner)
|
resolved = resolve_endpoint_by_id(
|
||||||
if resolved:
|
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)
|
out.append(resolved)
|
||||||
return out
|
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
|
||||||
|
|||||||
+188
-13
@@ -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
|
from dataclasses import dataclass
|
||||||
``default_model_fallbacks`` values remain stored for compatibility, but this
|
from typing import Any, Collection, Dict, FrozenSet, Optional, Tuple
|
||||||
policy intentionally does not read or migrate them.
|
|
||||||
|
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 typing import Any, Dict, Optional
|
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:
|
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.
|
return list(resolve_foreground_model_policy(owner).fallback_candidates)
|
||||||
``owner`` is accepted to keep this policy boundary owner-aware.
|
|
||||||
"""
|
|
||||||
|
|
||||||
del owner
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def build_foreground_model_candidates(
|
def build_foreground_model_candidates(
|
||||||
@@ -24,8 +157,50 @@ def build_foreground_model_candidates(
|
|||||||
model: str,
|
model: str,
|
||||||
headers: Optional[Dict[str, Any]] = None,
|
headers: Optional[Dict[str, Any]] = None,
|
||||||
owner: Optional[str] = None,
|
owner: Optional[str] = None,
|
||||||
|
policy: Optional[ForegroundModelPolicy] = None,
|
||||||
) -> list:
|
) -> list:
|
||||||
"""Build the ordered candidate list for a foreground request."""
|
"""Build the ordered candidate list for a foreground request."""
|
||||||
|
|
||||||
|
policy = policy or resolve_foreground_model_policy(owner)
|
||||||
primary = (endpoint_url, model, headers or {})
|
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
|
||||||
|
|||||||
+828
-69
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ class ChatRequest(BaseModel):
|
|||||||
use_research: Optional[bool] = Field(default=False, description="Enable deep research")
|
use_research: Optional[bool] = Field(default=False, description="Enable deep research")
|
||||||
time_filter: Optional[str] = Field(default=None, description="Time filter for search")
|
time_filter: Optional[str] = Field(default=None, description="Time filter for search")
|
||||||
preset_id: Optional[str] = Field(default=None, description="Preset identifier")
|
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')
|
@field_validator('message')
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
+19
-1
@@ -14,6 +14,13 @@ from src.constants import SETTINGS_FILE, FEATURES_FILE
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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
|
# 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.
|
# (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.
|
# 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 = {
|
DEFAULT_FEATURES = {
|
||||||
"web_search": True,
|
"web_search": True,
|
||||||
"web_fetch": True,
|
"web_fetch": True,
|
||||||
@@ -269,7 +287,7 @@ _PER_USER_KEYS = {
|
|||||||
# Default chat endpoint / model — without per-user resolution every new
|
# Default chat endpoint / model — without per-user resolution every new
|
||||||
# account inherited whatever the most-recent admin picked, which then
|
# account inherited whatever the most-recent admin picked, which then
|
||||||
# got injected into the chat composer on first open.
|
# 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",
|
"utility_endpoint_id", "utility_model", "utility_model_fallbacks",
|
||||||
"research_endpoint_id", "research_model",
|
"research_endpoint_id", "research_model",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Shared resolver for background-task AI endpoints."""
|
"""Shared resolver for background-task AI endpoints."""
|
||||||
|
|
||||||
from src.endpoint_resolver import (
|
from src.endpoint_resolver import (
|
||||||
resolve_chat_fallback_candidates,
|
|
||||||
resolve_endpoint,
|
resolve_endpoint,
|
||||||
resolve_utility_fallback_candidates,
|
resolve_utility_fallback_candidates,
|
||||||
)
|
)
|
||||||
@@ -32,7 +31,6 @@ def resolve_task_candidates(
|
|||||||
2. Utility endpoint/model
|
2. Utility endpoint/model
|
||||||
3. Default endpoint/model
|
3. Default endpoint/model
|
||||||
4. Utility fallback chain
|
4. Utility fallback chain
|
||||||
5. Retired default-fallback compatibility hook (currently empty)
|
|
||||||
"""
|
"""
|
||||||
candidates = []
|
candidates = []
|
||||||
|
|
||||||
@@ -49,9 +47,6 @@ def resolve_task_candidates(
|
|||||||
_append(*resolve_endpoint("default", owner=owner))
|
_append(*resolve_endpoint("default", owner=owner))
|
||||||
for url, model, headers in resolve_utility_fallback_candidates(owner=owner):
|
for url, model, headers in resolve_utility_fallback_candidates(owner=owner):
|
||||||
_append(url, model, headers)
|
_append(url, model, headers)
|
||||||
for url, model, headers in resolve_chat_fallback_candidates(owner=owner):
|
|
||||||
_append(url, model, headers)
|
|
||||||
|
|
||||||
return candidates
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
<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>
|
<select id="set-defaultModelSelect" class="settings-select"></select>
|
||||||
</div>
|
</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 id="set-defaultChatMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+507
-113
@@ -29,9 +29,16 @@ import {
|
|||||||
createThinkingAnalysisGate,
|
createThinkingAnalysisGate,
|
||||||
stripLiveThinkingTags,
|
stripLiveThinkingTags,
|
||||||
} from './liveThinkingThrottle.js';
|
} from './liveThinkingThrottle.js';
|
||||||
|
import {
|
||||||
|
applyModelMetricsState,
|
||||||
|
applyModelRouteEventState,
|
||||||
|
inheritModelRouteState,
|
||||||
|
} from './chatModelProvenance.js';
|
||||||
|
import { createTerminalStreamError, isRecoverableStreamError } from './chatStreamErrors.js';
|
||||||
|
|
||||||
const RESEARCH_TIMEOUT_MS = 360000;
|
const RESEARCH_TIMEOUT_MS = 360000;
|
||||||
const DEFAULT_TIMEOUT_MS = 120000;
|
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>';
|
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 = '';
|
let API_BASE = '';
|
||||||
@@ -394,13 +401,27 @@ import {
|
|||||||
const tsSpan = roleEl.querySelector('.role-timestamp');
|
const tsSpan = roleEl.querySelector('.role-timestamp');
|
||||||
const req = requestedModel || actualModel || '';
|
const req = requestedModel || actualModel || '';
|
||||||
const actual = actualModel || requestedModel || '';
|
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.suffix) label += ' (' + opts.suffix + ')';
|
||||||
if (opts.characterName) label = opts.characterName;
|
if (opts.characterName) label = opts.characterName;
|
||||||
roleEl.textContent = label + ' ';
|
roleEl.textContent = label + ' ';
|
||||||
_applyModelColor(roleEl, actual || req);
|
_applyModelColor(roleEl, actual || req);
|
||||||
if (req && actual && !_sameModelName(req, actual)) {
|
const endpointChanged = Boolean(
|
||||||
roleEl.title = req + ' -> ' + actual + (opts.reason ? ': ' + opts.reason : '');
|
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) {
|
} else if (!opts.reason) {
|
||||||
roleEl.removeAttribute('title');
|
roleEl.removeAttribute('title');
|
||||||
}
|
}
|
||||||
@@ -570,6 +591,11 @@ import {
|
|||||||
const _backgroundStreams = new Map(); // sessionId -> { status, accumulated, sourcesHtml, abortCtrl, query, metrics }
|
const _backgroundStreams = new Map(); // sessionId -> { status, accumulated, sourcesHtml, abortCtrl, query, metrics }
|
||||||
const _activeStreams = new Map(); // sessionId -> { abortCtrl, holder, query, startedAt, cancelViewWork, finalizeView }
|
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 _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 _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 _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
|
let _webLockRelease = null; // Function to release the Web Lock held during streaming
|
||||||
@@ -608,6 +634,60 @@ import {
|
|||||||
return now;
|
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
|
// Sources box builder and toggleSources are now in chatRenderer.js
|
||||||
var _buildSourcesBox = chatRenderer.buildSourcesBox;
|
var _buildSourcesBox = chatRenderer.buildSourcesBox;
|
||||||
|
|
||||||
@@ -1342,6 +1422,26 @@ import {
|
|||||||
if (messageInput) messageInput.disabled = false;
|
if (messageInput) messageInput.disabled = false;
|
||||||
updateSubmitButton('streaming', submitBtn);
|
updateSubmitButton('streaming', submitBtn);
|
||||||
if (submitBtn) submitBtn.classList.remove('send-pending');
|
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;
|
_sendInFlight = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1350,10 +1450,12 @@ import {
|
|||||||
await pendingSwitch;
|
await pendingSwitch;
|
||||||
}
|
}
|
||||||
} catch (_) {}
|
} 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
|
_terminalSavedStreams.delete(streamSessionId);
|
||||||
const streamSessionId = sessionModule.getCurrentSessionId();
|
|
||||||
_streamSessionId = streamSessionId;
|
|
||||||
const streamQuery = msg;
|
const streamQuery = msg;
|
||||||
_touchStreamActivity(streamSessionId);
|
_touchStreamActivity(streamSessionId);
|
||||||
|
|
||||||
@@ -1373,6 +1475,7 @@ import {
|
|||||||
let _thinkOpen = false;
|
let _thinkOpen = false;
|
||||||
let holder = null;
|
let holder = null;
|
||||||
let finalMeta = null;
|
let finalMeta = null;
|
||||||
|
let _canonicalTerminalSaved = false;
|
||||||
let spinner = null;
|
let spinner = null;
|
||||||
let timedOut = false;
|
let timedOut = false;
|
||||||
let processingProbeTimer = null;
|
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 = new AbortController();
|
||||||
abortCtrl._reason = '';
|
abortCtrl._reason = '';
|
||||||
|
_sendState.abortCtrl = abortCtrl;
|
||||||
currentAbort = abortCtrl;
|
currentAbort = abortCtrl;
|
||||||
|
|
||||||
const _tState = Storage.loadToggleState();
|
const _tState = Storage.loadToggleState();
|
||||||
@@ -1755,15 +1876,28 @@ import {
|
|||||||
if (!abortCtrl.signal.aborted) {
|
if (!abortCtrl.signal.aborted) {
|
||||||
timedOut = true;
|
timedOut = true;
|
||||||
abortCtrl._reason = 'timeout';
|
abortCtrl._reason = 'timeout';
|
||||||
try {
|
if (_streamGenerations.get(streamSessionId) !== streamGeneration) {
|
||||||
if (streamSessionId) {
|
// Superseded send: the session's run id and Stop queue belong to
|
||||||
fetch(`/api/chat/stop/${encodeURIComponent(streamSessionId)}`, {
|
// the replacement now. Just kill this hung POST.
|
||||||
method: 'POST',
|
|
||||||
credentials: 'same-origin',
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
abortCtrl.abort();
|
abortCtrl.abort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let abortNow = true;
|
||||||
|
try {
|
||||||
|
abortNow = _streamRunIds.has(streamSessionId)
|
||||||
|
? _stopExactRun(streamSessionId)
|
||||||
|
: _stopExactRun(streamSessionId, abortCtrl);
|
||||||
|
} catch (_) {}
|
||||||
|
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);
|
}, timeoutMs);
|
||||||
clearResponseTimeout = () => {
|
clearResponseTimeout = () => {
|
||||||
@@ -1912,6 +2046,8 @@ import {
|
|||||||
enableResearchBtn();
|
enableResearchBtn();
|
||||||
return;
|
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
|
// Mark the chat log busy while streaming so screen readers wait for the
|
||||||
// settled response instead of announcing every token. Cleared in finally.
|
// settled response instead of announcing every token. Cleared in finally.
|
||||||
@@ -1986,9 +2122,17 @@ import {
|
|||||||
const newRole = document.createElement('div');
|
const newRole = document.createElement('div');
|
||||||
newRole.className = 'role';
|
newRole.className = 'role';
|
||||||
const metaS = sessionModule.getSessions().find(s => s.id === streamSessionId);
|
const metaS = sessionModule.getSessions().find(s => s.id === streamSessionId);
|
||||||
const requested = holder?._requestedModel || metaS?.model || modelName;
|
inheritModelRouteState(holder, roundHolder, newWrap, metaS?.model || modelName);
|
||||||
const actual = holder?._actualModel || requested;
|
const requested = newWrap._requestedModel;
|
||||||
newRole.textContent = _modelRouteLabel(requested, actual) || '';
|
const actual = newWrap._actualModel;
|
||||||
|
newRole.textContent = _modelRouteLabel(
|
||||||
|
requested,
|
||||||
|
actual,
|
||||||
|
newWrap._requestedEndpointLabel,
|
||||||
|
newWrap._actualEndpointLabel,
|
||||||
|
newWrap._requestedEndpointId,
|
||||||
|
newWrap._actualEndpointId,
|
||||||
|
) || '';
|
||||||
_applyModelColor(newRole, actual);
|
_applyModelColor(newRole, actual);
|
||||||
newWrap.appendChild(newRole);
|
newWrap.appendChild(newRole);
|
||||||
const newBody = document.createElement('div');
|
const newBody = document.createElement('div');
|
||||||
@@ -2514,6 +2658,7 @@ import {
|
|||||||
|
|
||||||
let _nextIsError = false;
|
let _nextIsError = false;
|
||||||
let _streamSawDone = false;
|
let _streamSawDone = false;
|
||||||
|
let _streamTerminalError = null;
|
||||||
let _firstVisibleOutputSeen = false;
|
let _firstVisibleOutputSeen = false;
|
||||||
const markFirstVisibleOutput = () => {
|
const markFirstVisibleOutput = () => {
|
||||||
if (_firstVisibleOutputSeen) return;
|
if (_firstVisibleOutputSeen) return;
|
||||||
@@ -2638,10 +2783,9 @@ import {
|
|||||||
// Handle SSE error events (e.g. HTTP 404 from provider)
|
// Handle SSE error events (e.g. HTTP 404 from provider)
|
||||||
if (_nextIsError || json.status >= 400) {
|
if (_nextIsError || json.status >= 400) {
|
||||||
_nextIsError = false;
|
_nextIsError = false;
|
||||||
const errMsg = json.text || json.error?.message || `Error ${json.status || 'unknown'}`;
|
_streamTerminalError = createTerminalStreamError(json);
|
||||||
console.error('Stream error:', errMsg);
|
console.error('Stream error:', _streamTerminalError.message);
|
||||||
if (spinner && spinner.element) spinner.destroy();
|
if (spinner && spinner.element) spinner.destroy();
|
||||||
typewriterInto(roundHolder.querySelector('.body'), errMsg);
|
|
||||||
break;
|
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') {
|
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
|
6000
|
||||||
);
|
);
|
||||||
continue;
|
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') {
|
} else if (json.type === 'model_info') {
|
||||||
// Update role label with model name as soon as we know it
|
// Update role label with model name as soon as we know it
|
||||||
if (!_isBg && holder) {
|
if (!_isBg && holder) {
|
||||||
@@ -3059,6 +3191,10 @@ import {
|
|||||||
if (roleEl) {
|
if (roleEl) {
|
||||||
holder._requestedModel = json.requested_model || json.model || holder._requestedModel;
|
holder._requestedModel = json.requested_model || json.model || holder._requestedModel;
|
||||||
holder._actualModel = json.model || holder._actualModel || 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;
|
if (json.suffix) holder._roleSuffix = json.suffix;
|
||||||
// Prepend character name if sent by server or set locally
|
// Prepend character name if sent by server or set locally
|
||||||
var _charName = json.character_name || (presetsModule.getCharacterName ? presetsModule.getCharacterName() : '');
|
var _charName = json.character_name || (presetsModule.getCharacterName ? presetsModule.getCharacterName() : '');
|
||||||
@@ -3066,6 +3202,10 @@ import {
|
|||||||
_setRoleModelLabel(roleEl, holder._requestedModel, holder._actualModel, {
|
_setRoleModelLabel(roleEl, holder._requestedModel, holder._actualModel, {
|
||||||
suffix: holder._roleSuffix,
|
suffix: holder._roleSuffix,
|
||||||
characterName: holder._characterName,
|
characterName: holder._characterName,
|
||||||
|
requestedEndpointId: holder._requestedEndpointId,
|
||||||
|
requestedEndpointLabel: holder._requestedEndpointLabel,
|
||||||
|
actualEndpointId: holder._actualEndpointId,
|
||||||
|
actualEndpointLabel: holder._actualEndpointLabel,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3076,9 +3216,10 @@ import {
|
|||||||
if (!_isBg) {
|
if (!_isBg) {
|
||||||
var _selM = _shortModel(json.selected_model || '');
|
var _selM = _shortModel(json.selected_model || '');
|
||||||
var _ansM = _shortModel(json.answered_by || '');
|
var _ansM = _shortModel(json.answered_by || '');
|
||||||
uiModule.showToast('⚠ ' + _selM + ' failed — answered by ' + _ansM, 6000);
|
uiModule.showToast('Fallback: ' + _selM + ' failed — answered by ' + _ansM, 6000);
|
||||||
if (holder) {
|
var _fallbackHolder = applyModelRouteEventState(json, holder, roundHolder, modelName);
|
||||||
var _rEl = holder.querySelector('.role');
|
if (_fallbackHolder) {
|
||||||
|
var _rEl = _fallbackHolder.querySelector('.role');
|
||||||
if (_rEl) {
|
if (_rEl) {
|
||||||
var _tsS = _rEl.querySelector('.role-timestamp');
|
var _tsS = _rEl.querySelector('.role-timestamp');
|
||||||
_rEl.textContent = _ansM + ' (fallback) ';
|
_rEl.textContent = _ansM + ' (fallback) ';
|
||||||
@@ -3086,13 +3227,14 @@ import {
|
|||||||
(json.reason ? ': ' + json.reason : '') + ' — answered by ' + (json.answered_by || '');
|
(json.reason ? ': ' + json.reason : '') + ' — answered by ' + (json.answered_by || '');
|
||||||
_applyModelColor(_rEl, json.answered_by);
|
_applyModelColor(_rEl, json.answered_by);
|
||||||
if (_tsS) _rEl.appendChild(_tsS);
|
if (_tsS) _rEl.appendChild(_tsS);
|
||||||
holder._requestedModel = json.selected_model || holder._requestedModel || modelName;
|
_setRoleModelLabel(_rEl, _fallbackHolder._requestedModel, _fallbackHolder._actualModel, {
|
||||||
const _hasResolvedActual = holder._actualModel && !_sameModelName(holder._actualModel, holder._requestedModel);
|
suffix: _fallbackHolder._roleSuffix,
|
||||||
holder._actualModel = _hasResolvedActual ? holder._actualModel : (json.answered_by || holder._actualModel || holder._requestedModel);
|
characterName: _fallbackHolder._characterName,
|
||||||
_setRoleModelLabel(_rEl, holder._requestedModel, holder._actualModel, {
|
|
||||||
suffix: holder._roleSuffix,
|
|
||||||
characterName: holder._characterName,
|
|
||||||
reason: json.reason,
|
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(); }
|
try { note.scrollIntoView({ block: 'end', behavior: 'smooth' }); } catch (_) { uiModule.scrollHistory && uiModule.scrollHistory(); }
|
||||||
}
|
}
|
||||||
} else if (json.type === 'model_actual') {
|
} else if (json.type === 'model_actual') {
|
||||||
if (!_isBg && holder) {
|
if (!_isBg) {
|
||||||
holder._requestedModel = json.requested_model || holder._requestedModel || modelName;
|
var _modelHolder = applyModelRouteEventState(json, holder, roundHolder, modelName);
|
||||||
holder._actualModel = json.model || holder._actualModel || holder._requestedModel;
|
if (_modelHolder) _setRoleModelLabel(_modelHolder.querySelector('.role'), _modelHolder._requestedModel, _modelHolder._actualModel, {
|
||||||
_setRoleModelLabel(holder.querySelector('.role'), holder._requestedModel, holder._actualModel, {
|
suffix: _modelHolder._roleSuffix,
|
||||||
suffix: holder._roleSuffix,
|
characterName: _modelHolder._characterName,
|
||||||
characterName: holder._characterName,
|
requestedEndpointId: _modelHolder._requestedEndpointId,
|
||||||
|
requestedEndpointLabel: _modelHolder._requestedEndpointLabel,
|
||||||
|
actualEndpointId: _modelHolder._actualEndpointId,
|
||||||
|
actualEndpointLabel: _modelHolder._actualEndpointLabel,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (json.type === 'attachments') {
|
} else if (json.type === 'attachments') {
|
||||||
@@ -3227,15 +3372,60 @@ import {
|
|||||||
const detail = before && after && before > after ? ` (${after}/${before} messages sent)` : '';
|
const detail = before && after && before > after ? ` (${after}/${before} messages sent)` : '';
|
||||||
uiModule.showToast(`Context trimmed for this model${detail}`);
|
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') {
|
} else if (json.type === 'metrics') {
|
||||||
metrics = json.data;
|
metrics = json.data;
|
||||||
|
if (metrics && streamRunId) {
|
||||||
|
metrics._costRecordId = _metricsCostRecordId(streamRunId, json);
|
||||||
|
}
|
||||||
if (!_isBg && holder && metrics) {
|
if (!_isBg && holder && metrics) {
|
||||||
holder._requestedModel = metrics.requested_model || holder._requestedModel || modelName;
|
applyModelMetricsState(metrics, holder, roundHolder, modelName);
|
||||||
holder._actualModel = metrics.model || holder._actualModel || holder._requestedModel;
|
|
||||||
}
|
}
|
||||||
if (_isBg) {
|
if (_isBg) {
|
||||||
var bgM = _backgroundStreams.get(streamSessionId);
|
var bgM = _backgroundStreams.get(streamSessionId);
|
||||||
if (bgM) bgM.metrics = json.data;
|
if (bgM) {
|
||||||
|
bgM.metrics = json.data;
|
||||||
|
chatRenderer.recordSessionMetricsCost(bgM.metrics, streamSessionId);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (metrics) {
|
if (metrics) {
|
||||||
@@ -3616,9 +3806,17 @@ import {
|
|||||||
const newRole = document.createElement('div');
|
const newRole = document.createElement('div');
|
||||||
newRole.className = 'role';
|
newRole.className = 'role';
|
||||||
const metaS = sessionModule.getSessions().find(s => s.id === streamSessionId);
|
const metaS = sessionModule.getSessions().find(s => s.id === streamSessionId);
|
||||||
const _roundRequested = holder?._requestedModel || metaS?.model;
|
inheritModelRouteState(holder, roundHolder, newWrap, metaS?.model || modelName);
|
||||||
const _roundActual = holder?._actualModel || _roundRequested;
|
const _roundRequested = newWrap._requestedModel;
|
||||||
newRole.textContent = _modelRouteLabel(_roundRequested, _roundActual) || '';
|
const _roundActual = newWrap._actualModel;
|
||||||
|
newRole.textContent = _modelRouteLabel(
|
||||||
|
_roundRequested,
|
||||||
|
_roundActual,
|
||||||
|
newWrap._requestedEndpointLabel,
|
||||||
|
newWrap._actualEndpointLabel,
|
||||||
|
newWrap._requestedEndpointId,
|
||||||
|
newWrap._actualEndpointId,
|
||||||
|
) || '';
|
||||||
_applyModelColor(newRole, _roundActual);
|
_applyModelColor(newRole, _roundActual);
|
||||||
newWrap.appendChild(newRole);
|
newWrap.appendChild(newRole);
|
||||||
const newBody = document.createElement('div');
|
const newBody = document.createElement('div');
|
||||||
@@ -3725,9 +3923,22 @@ import {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_streamTerminalError) {
|
||||||
|
throw _streamTerminalError;
|
||||||
|
}
|
||||||
if (!_streamSawDone) {
|
if (!_streamSawDone) {
|
||||||
|
if (!_canonicalTerminalSaved) {
|
||||||
throw new Error('Stream closed before completion');
|
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
|
// The final foreground render below is authoritative. Cancel any delayed
|
||||||
// live-view work instead of parsing and rendering the full round once
|
// live-view work instead of parsing and rendering the full round once
|
||||||
@@ -3746,15 +3957,25 @@ import {
|
|||||||
const _isBgFinal = (sessionModule.getCurrentSessionId() !== streamSessionId) || _backgroundStreams.has(streamSessionId);
|
const _isBgFinal = (sessionModule.getCurrentSessionId() !== streamSessionId) || _backgroundStreams.has(streamSessionId);
|
||||||
if (!_isBgFinal) {
|
if (!_isBgFinal) {
|
||||||
finalMeta = sessionModule.getSessions().find(s => s.id === sessionModule.getCurrentSessionId());
|
finalMeta = sessionModule.getSessions().find(s => s.id === sessionModule.getCurrentSessionId());
|
||||||
const _finalActualModel = metrics?.model || holder._actualModel || finalMeta?.model;
|
const _finalModelHolder = applyModelMetricsState(
|
||||||
const _finalRequestedModel = metrics?.requested_model || holder._requestedModel || finalMeta?.model || _finalActualModel;
|
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
|
// Prepend character name if set
|
||||||
var _charNameFinal = presetsModule.getCharacterName ? presetsModule.getCharacterName() : '';
|
var _charNameFinal = presetsModule.getCharacterName ? presetsModule.getCharacterName() : '';
|
||||||
const roleEl = holder.querySelector('.role');
|
const roleEl = _finalModelHolder.querySelector('.role');
|
||||||
if (roleEl) {
|
if (roleEl) {
|
||||||
_setRoleModelLabel(roleEl, _finalRequestedModel, _finalActualModel, {
|
_setRoleModelLabel(roleEl, _finalRequestedModel, _finalActualModel, {
|
||||||
suffix: holder._roleSuffix,
|
suffix: _finalModelHolder._roleSuffix,
|
||||||
characterName: _charNameFinal || holder._characterName,
|
characterName: _charNameFinal || _finalModelHolder._characterName,
|
||||||
|
requestedEndpointId: _finalModelHolder._requestedEndpointId,
|
||||||
|
requestedEndpointLabel: _finalModelHolder._requestedEndpointLabel,
|
||||||
|
actualEndpointId: _finalModelHolder._actualEndpointId,
|
||||||
|
actualEndpointLabel: _finalModelHolder._actualEndpointLabel,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
holder.dataset.raw = accumulated;
|
holder.dataset.raw = accumulated;
|
||||||
@@ -4013,6 +4234,21 @@ import {
|
|||||||
} // end if (!_isBgFinal)
|
} // end if (!_isBgFinal)
|
||||||
|
|
||||||
} catch (err) {
|
} 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
|
// Check if this stream was running in background — needed before any
|
||||||
// stop-state write, so an errored background stream can't clobber the
|
// stop-state write, so an errored background stream can't clobber the
|
||||||
// foreground session's text.
|
// foreground session's text.
|
||||||
@@ -4021,6 +4257,18 @@ import {
|
|||||||
_closeOpenThinkingMarkup(_isBgCatch);
|
_closeOpenThinkingMarkup(_isBgCatch);
|
||||||
if (_isBgCatch) {
|
if (_isBgCatch) {
|
||||||
_cancelLiveThinkingWork();
|
_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) {
|
} else if (accumulated) {
|
||||||
_catchTerminalView = _finalizeInterruptedView();
|
_catchTerminalView = _finalizeInterruptedView();
|
||||||
} else {
|
} else {
|
||||||
@@ -4039,7 +4287,10 @@ import {
|
|||||||
// Error happened while backgrounded — update map, don't touch DOM
|
// Error happened while backgrounded — update map, don't touch DOM
|
||||||
console.error('Background stream error:', err);
|
console.error('Background stream error:', err);
|
||||||
var bgErr = _backgroundStreams.get(streamSessionId);
|
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)
|
// [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
|
// Don't override the completed status; just ensure the completed dot stays
|
||||||
if (sessionModule && sessionModule.clearStreaming) {
|
if (sessionModule && sessionModule.clearStreaming) {
|
||||||
@@ -4191,8 +4442,36 @@ import {
|
|||||||
// cap. Only auto-recover from connection-class failures; deterministic
|
// cap. Only auto-recover from connection-class failures; deterministic
|
||||||
// errors (unsupported tools, 4xx/5xx, parse failures) surface right away
|
// errors (unsupported tools, 4xx/5xx, parse failures) surface right away
|
||||||
// instead of burning the nudge budget on a guaranteed-to-fail retry.
|
// instead of burning the nudge budget on a guaranteed-to-fail retry.
|
||||||
if (!(_isRecoverableStreamErr(err) && _tryAutoRecover(_catchViewHolder, accumulated, streamSessionId))) {
|
if (!(isRecoverableStreamError(err) && _tryAutoRecover(_catchViewHolder, accumulated, streamSessionId))) {
|
||||||
const errorHolder = _catchViewHolder?.querySelector('.body') || document.querySelector('.msg-ai:last-of-type .body');
|
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) {
|
if (errorHolder) {
|
||||||
let errMsg = `Error: ${err.message}`;
|
let errMsg = `Error: ${err.message}`;
|
||||||
// Add hint for tool-call errors
|
// Add hint for tool-call errors
|
||||||
@@ -4209,23 +4488,52 @@ import {
|
|||||||
clearResponseTimeout();
|
clearResponseTimeout();
|
||||||
clearProcessingProbe();
|
clearProcessingProbe();
|
||||||
clearFirstTokenWaitTimers();
|
clearFirstTokenWaitTimers();
|
||||||
|
// 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);
|
_activeStreams.delete(streamSessionId);
|
||||||
|
}
|
||||||
|
_pendingRunStops.delete(streamSessionId + ':' + streamGeneration);
|
||||||
|
if (_ownsStreamState) {
|
||||||
if (_streamSessionId === streamSessionId) _streamSessionId = null;
|
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();
|
_syncForegroundStreamGlobals();
|
||||||
|
}
|
||||||
// Streaming done — let screen readers announce the settled response.
|
// Streaming done — let screen readers announce the settled response.
|
||||||
|
if (_ownsStreamState) {
|
||||||
const _chatLogDone = document.getElementById('chat-history');
|
const _chatLogDone = document.getElementById('chat-history');
|
||||||
if (_chatLogDone) _chatLogDone.setAttribute('aria-busy', 'false');
|
if (_chatLogDone) _chatLogDone.setAttribute('aria-busy', 'false');
|
||||||
// Always clean up research tracking regardless of background state
|
}
|
||||||
_researchingStreamIds.delete(streamSessionId);
|
// 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) {
|
if (_researchingStreamIds.size === 0) {
|
||||||
var _rToggleCleanup = document.getElementById('research-toggle-btn');
|
var _rToggleCleanup = document.getElementById('research-toggle-btn');
|
||||||
if (_rToggleCleanup) _rToggleCleanup.classList.remove('research-running');
|
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);
|
const _isBgFinally = (sessionModule.getCurrentSessionId() !== streamSessionId) || _backgroundStreams.has(streamSessionId);
|
||||||
|
if (_ownsStreamState) _terminalSavedStreams.delete(streamSessionId);
|
||||||
|
|
||||||
if (!_isBgFinally) {
|
if (!_isBgFinally && _ownsStreamState) {
|
||||||
// Reset button to idle state
|
// Reset button to idle state
|
||||||
updateSubmitButton('idle', submitBtn);
|
updateSubmitButton('idle', submitBtn);
|
||||||
|
|
||||||
@@ -4320,69 +4628,64 @@ import {
|
|||||||
// the server run — otherwise closing the tab would kill the background task,
|
// the server run — otherwise closing the tab would kill the background task,
|
||||||
// defeating the whole point. Only the Stop button cancels the server run.
|
// defeating the whole point. Only the Stop button cancels the server run.
|
||||||
export function abortCurrentRequest(stopServer = false) {
|
export function abortCurrentRequest(stopServer = false) {
|
||||||
const active = _getForegroundStreamState();
|
|
||||||
const abortCtrl = active ? active.abortCtrl : currentAbort;
|
|
||||||
if (abortCtrl) {
|
|
||||||
abortCtrl.abort();
|
|
||||||
// Don't set to null here - let catch block handle it
|
|
||||||
}
|
|
||||||
if (stopServer) {
|
|
||||||
try {
|
|
||||||
const _sid = (sessionModule && sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId())
|
const _sid = (sessionModule && sessionModule.getCurrentSessionId && sessionModule.getCurrentSessionId())
|
||||||
|| _streamSessionId
|
|| _streamSessionId
|
||||||
|| (window.sessionModule && window.sessionModule.getCurrentSessionId && window.sessionModule.getCurrentSessionId());
|
|| (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 = _sendStateNow
|
||||||
|
? _sendStateNow.abortCtrl
|
||||||
|
: (active ? active.abortCtrl : currentAbort);
|
||||||
|
let abortNow = true;
|
||||||
|
if (stopServer) {
|
||||||
|
try {
|
||||||
if (_sid) {
|
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 (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
if (abortCtrl && abortNow) {
|
||||||
|
abortCtrl.abort();
|
||||||
|
// Don't set to null here - let catch block handle it
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Stall watchdog ──────────────────────────────────────────────
|
// ── Stall watchdog ──────────────────────────────────────────────
|
||||||
// Auto-recover a turn whose stream died (connection drop) or went silent:
|
// Auto-recover a turn whose browser stream died by reconnecting to the exact
|
||||||
// preserve the partial, then re-submit a completion handshake by reusing the
|
// detached server run. Returns false at the cap so the caller can surface
|
||||||
// existing continue/resume path. Returns false at the cap so the caller can
|
// the failure instead of retrying forever.
|
||||||
// surface the failure instead of nudging forever.
|
|
||||||
// Only auto-recover from connection-class failures (the genuine "silently
|
// Only auto-recover from connection-class failures (the genuine "silently
|
||||||
// died" case). Deterministic errors — unsupported tools, HTTP 4xx/5xx, JSON
|
// died" case). Deterministic errors — unsupported tools, HTTP 4xx/5xx, JSON
|
||||||
// parse failures — will fail identically on retry, so surfacing them
|
// parse failures — will fail identically on retry, so surfacing them
|
||||||
// immediately is both more honest and avoids wasting the nudge budget.
|
// 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) {
|
function _tryAutoRecover(holder, accumulated, sessionId) {
|
||||||
if (_autoNudges >= _AUTO_NUDGE_CAP) return false;
|
if (_autoNudges >= _AUTO_NUDGE_CAP) return false;
|
||||||
_autoNudges++;
|
_autoNudges++;
|
||||||
if (holder && accumulated) {
|
if (holder && accumulated) {
|
||||||
holder.dataset.raw = accumulated;
|
holder.dataset.raw = accumulated;
|
||||||
}
|
}
|
||||||
_pendingContinue = holder || null; // merge the continuation into the same bubble
|
// The server run is detached and keeps its exact pinned model/tool state.
|
||||||
_hideUserBubble = true; // no user bubble for the handshake
|
// Reconnect to that run instead of submitting a new user turn, which would
|
||||||
_autoContinuePending = true; // don't reset the counter on this submit
|
// cancel it, retry the selected model, and risk duplicating side effects.
|
||||||
const _abandon = () => { // clear the pending flags so they can't
|
setTimeout(async () => {
|
||||||
_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 stream that died may not be the chat the user is now looking at —
|
// The stream that died may not be the chat the user is now looking at —
|
||||||
// never inject the recovery handshake into the wrong conversation.
|
// never attach the recovery reader to the wrong conversation.
|
||||||
if (sessionId && sessionModule.getCurrentSessionId() !== sessionId) { _abandon(); return; }
|
if (sessionId && sessionModule.getCurrentSessionId() !== sessionId) return;
|
||||||
const msgInput = uiModule.el('message');
|
const resumed = await resumeStream(sessionId, holder || null);
|
||||||
const sb = document.querySelector('.send-btn');
|
if (!resumed && holder && holder.isConnected) {
|
||||||
if (!msgInput || !sb) { _abandon(); return; }
|
const body = holder.querySelector('.body');
|
||||||
const tail = (accumulated || '').slice(-400);
|
if (body) typewriterInto(body, 'Connection lost. The existing run could not be resumed.');
|
||||||
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();
|
|
||||||
}, 200);
|
}, 200);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -4545,9 +4848,13 @@ import {
|
|||||||
// view must stop all delayed rendering immediately. The reader loop may not
|
// view must stop all delayed rendering immediately. The reader loop may not
|
||||||
// receive another SSE line for an arbitrary amount of time.
|
// receive another SSE line for an arbitrary amount of time.
|
||||||
if (active.cancelViewWork) active.cancelViewWork();
|
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, {
|
_backgroundStreams.set(sessionId, {
|
||||||
status: 'running',
|
status: terminalSaved ? 'completed' : 'running',
|
||||||
accumulated: currentAccumulated,
|
accumulated: currentAccumulated,
|
||||||
sourcesHtml: '',
|
sourcesHtml: '',
|
||||||
findingsData: null,
|
findingsData: null,
|
||||||
@@ -4556,8 +4863,10 @@ import {
|
|||||||
metrics: null,
|
metrics: null,
|
||||||
});
|
});
|
||||||
// Mark session with pulsing dot in sidebar
|
// Mark session with pulsing dot in sidebar
|
||||||
if (sessionModule && sessionModule.markStreaming) {
|
if (!terminalSaved && sessionModule && sessionModule.markStreaming) {
|
||||||
sessionModule.markStreaming(sessionId);
|
sessionModule.markStreaming(sessionId);
|
||||||
|
} else if (terminalSaved && sessionModule && sessionModule.clearStreaming) {
|
||||||
|
sessionModule.clearStreaming(sessionId);
|
||||||
}
|
}
|
||||||
// Clear local state WITHOUT aborting the fetch
|
// Clear local state WITHOUT aborting the fetch
|
||||||
if (currentAbort === active.abortCtrl) currentAbort = null;
|
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
|
* 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.
|
* 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 (!sessionId) return false;
|
||||||
if (hasActiveStream(sessionId)) return false;
|
if (hasActiveStream(sessionId)) return false;
|
||||||
|
|
||||||
@@ -4595,9 +4904,12 @@ import {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!res.ok || !res.body) 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');
|
const box = document.getElementById('chat-history');
|
||||||
if (!box) return false;
|
if (!box) return false;
|
||||||
|
if (replaceHolder && replaceHolder.parentNode) replaceHolder.remove();
|
||||||
|
|
||||||
// Block duplicate re-attach attempts while this reader is live. A dedicated
|
// Block duplicate re-attach attempts while this reader is live. A dedicated
|
||||||
// set (not _backgroundStreams) so checkBackgroundStream doesn't mistake this
|
// set (not _backgroundStreams) so checkBackgroundStream doesn't mistake this
|
||||||
@@ -4612,6 +4924,8 @@ import {
|
|||||||
holder.innerHTML = '<div class="role">' + uiModule.esc(roleLabel) +
|
holder.innerHTML = '<div class="role">' + uiModule.esc(roleLabel) +
|
||||||
' <span class="role-timestamp">' + roleTs + '</span></div>' +
|
' <span class="role-timestamp">' + roleTs + '</span></div>' +
|
||||||
'<div class="body"><div class="stream-content"></div></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);
|
_applyModelColor(holder.querySelector('.role'), meta && meta.model);
|
||||||
const contentDiv = holder.querySelector('.stream-content');
|
const contentDiv = holder.querySelector('.stream-content');
|
||||||
box.appendChild(holder);
|
box.appendChild(holder);
|
||||||
@@ -4629,6 +4943,8 @@ import {
|
|||||||
let gotDelta = false;
|
let gotDelta = false;
|
||||||
let leftSession = false;
|
let leftSession = false;
|
||||||
let metricsData = null;
|
let metricsData = null;
|
||||||
|
let replayError = null;
|
||||||
|
let canonicalTerminalSeen = false;
|
||||||
// "Rich" responses (tool calls, sources, doc streaming, multi-round) need the
|
// "Rich" responses (tool calls, sources, doc streaming, multi-round) need the
|
||||||
// full canonical render, which is rebuilt from the saved DB record on reload.
|
// full canonical render, which is rebuilt from the saved DB record on reload.
|
||||||
// Plain text replies can be finalized in place without a reload.
|
// Plain text replies can be finalized in place without a reload.
|
||||||
@@ -4665,6 +4981,8 @@ import {
|
|||||||
const parts = buffer.split('\n\n');
|
const parts = buffer.split('\n\n');
|
||||||
buffer = parts.pop();
|
buffer = parts.pop();
|
||||||
for (const part of parts) {
|
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: '));
|
const line = part.split('\n').find(l => l.startsWith('data: '));
|
||||||
if (!line) continue;
|
if (!line) continue;
|
||||||
const payload = line.slice(6);
|
const payload = line.slice(6);
|
||||||
@@ -4674,7 +4992,9 @@ import {
|
|||||||
}
|
}
|
||||||
let json;
|
let json;
|
||||||
try { json = JSON.parse(payload); } catch (_) { continue; }
|
try { json = JSON.parse(payload); } catch (_) { continue; }
|
||||||
if (json.delta) {
|
if (eventIsError) {
|
||||||
|
replayError = createTerminalStreamError(json);
|
||||||
|
} else if (json.delta) {
|
||||||
roundText += json.delta;
|
roundText += json.delta;
|
||||||
if (!docFenceOpened && (roundText.includes('```create_document\n') || roundText.includes('```document\n') || roundText.includes('```documen\n'))) {
|
if (!docFenceOpened && (roundText.includes('```create_document\n') || roundText.includes('```document\n') || roundText.includes('```documen\n'))) {
|
||||||
docFenceOpened = true;
|
docFenceOpened = true;
|
||||||
@@ -4690,6 +5010,64 @@ import {
|
|||||||
if (documentModule) documentModule.streamDocDelta(json.content || json.delta || '');
|
if (documentModule) documentModule.streamDocDelta(json.content || json.delta || '');
|
||||||
} else if (json.type === 'metrics') {
|
} else if (json.type === 'metrics') {
|
||||||
metricsData = json.data || metricsData;
|
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' ||
|
} else if (json.type === 'tool_start' || json.type === 'tool_output' ||
|
||||||
json.type === 'tool_progress' || json.type === 'agent_step' ||
|
json.type === 'tool_progress' || json.type === 'agent_step' ||
|
||||||
json.type === 'web_sources' || json.type === 'rag_sources' ||
|
json.type === 'web_sources' || json.type === 'rag_sources' ||
|
||||||
@@ -4700,7 +5078,8 @@ import {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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();
|
cleanup();
|
||||||
@@ -4710,6 +5089,18 @@ import {
|
|||||||
const onThisSession = sessionModule.getCurrentSessionId &&
|
const onThisSession = sessionModule.getCurrentSessionId &&
|
||||||
sessionModule.getCurrentSessionId() === sessionId;
|
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
|
// Plain text reply: finalize in place. Replace the live bubble with a
|
||||||
// canonical single message (markdown + footer actions + metrics) using the
|
// canonical single message (markdown + footer actions + metrics) using the
|
||||||
// same renderer history does. No history refetch, no end-of-stream flicker.
|
// 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.
|
// reload from the DB for the full canonical render.
|
||||||
if (holder._docWritingThread && holder._docWritingThread.parentNode) holder._docWritingThread.remove();
|
if (holder._docWritingThread && holder._docWritingThread.parentNode) holder._docWritingThread.remove();
|
||||||
if (holder.parentNode) holder.remove();
|
if (holder.parentNode) holder.remove();
|
||||||
|
if (metricsData) {
|
||||||
|
chatRenderer.recordSessionMetricsCost(metricsData, sessionId);
|
||||||
|
}
|
||||||
if (onThisSession) sessionModule.selectSession(sessionId);
|
if (onThisSession) sessionModule.selectSession(sessionId);
|
||||||
else sessionModule.loadSessions();
|
else sessionModule.loadSessions();
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -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
@@ -615,10 +615,36 @@ export function sameModelName(left, right) {
|
|||||||
|| shortModel(a).toLowerCase() === shortModel(b).toLowerCase();
|
|| 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 requested = modelValue(requestedModel);
|
||||||
const actual = modelValue(actualModel) || requested;
|
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);
|
return shortModel(requested) + ' -> ' + shortModel(actual);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -629,10 +655,24 @@ export function replyModelPair(modelName, metadata) {
|
|||||||
if (actualFromMeta || requestedFromMeta) {
|
if (actualFromMeta || requestedFromMeta) {
|
||||||
const actual = actualFromMeta || requestedFromMeta || modelValue(modelName);
|
const actual = actualFromMeta || requestedFromMeta || modelValue(modelName);
|
||||||
const requested = requestedFromMeta || actual;
|
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);
|
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. */
|
/** Cost for the current turn, returning null for non-billable endpoints. */
|
||||||
function _billableCost(model, inputTokens, outputTokens) {
|
function _billableCost(model, inputTokens, outputTokens, endpointCostTracked, selectedEndpointUrl) {
|
||||||
const url = _currentEndpointUrl();
|
// Foreground fallback can answer on a different endpoint than the session's
|
||||||
if (!isCostTrackedEndpoint(url)) return null;
|
// 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);
|
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) {
|
export function getImageCost(model, quality, size) {
|
||||||
if (!model) return null;
|
if (!model) return null;
|
||||||
const m = model.toLowerCase();
|
const m = model.toLowerCase();
|
||||||
@@ -844,6 +922,9 @@ export function getImageCost(model, quality, size) {
|
|||||||
|
|
||||||
/* ── Session cost helpers ─────────────────────────────────────────── */
|
/* ── Session cost helpers ─────────────────────────────────────────── */
|
||||||
const _COST_KEY = 'ody-session-cost';
|
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. */
|
/** Return the accumulated cost for the current (or given) session. */
|
||||||
export function getSessionCost(sessionId) {
|
export function getSessionCost(sessionId) {
|
||||||
@@ -851,7 +932,14 @@ export function getSessionCost(sessionId) {
|
|||||||
if (!sid) return 0;
|
if (!sid) return 0;
|
||||||
try {
|
try {
|
||||||
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
|
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; }
|
} catch (_e) { return 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -863,6 +951,9 @@ export function resetSessionCost(sessionId) {
|
|||||||
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
|
const costs = JSON.parse(localStorage.getItem(_COST_KEY) || '{}');
|
||||||
delete costs[sid];
|
delete costs[sid];
|
||||||
localStorage.setItem(_COST_KEY, JSON.stringify(costs));
|
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 */ }
|
} catch (_e) { /* ignore */ }
|
||||||
updateSessionCostUI();
|
updateSessionCostUI();
|
||||||
}
|
}
|
||||||
@@ -871,21 +962,8 @@ export function resetSessionCost(sessionId) {
|
|||||||
export function updateSessionCostUI() {
|
export function updateSessionCostUI() {
|
||||||
const el = document.getElementById('session-cost-display');
|
const el = document.getElementById('session-cost-display');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
// Non-billable endpoint? Hide the badge and clear stale cost that a previous
|
// The ledger records billable work already performed in this session. A
|
||||||
// cloud-rate calculation may have left in localStorage for this session.
|
// selected local endpoint does not erase cost from a paid fallback route.
|
||||||
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;
|
|
||||||
}
|
|
||||||
const cost = getSessionCost();
|
const cost = getSessionCost();
|
||||||
if (cost > 0) {
|
if (cost > 0) {
|
||||||
el.textContent = '$' + (cost < 0.01 ? cost.toFixed(4) : cost < 1 ? cost.toFixed(3) : cost.toFixed(2));
|
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.
|
/** Create a timestamp span for role labels.
|
||||||
* Pass an ISO string / Date / epoch-ms to render the message's own time
|
* 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. */
|
* (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 isReal = metrics.usage_source === 'real';
|
||||||
const ctxPct = metrics.context_percent;
|
const ctxPct = metrics.context_percent;
|
||||||
const model = metrics.model || 'Unknown';
|
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)
|
// Nothing useful to show — bail out (only if ALL metrics are missing)
|
||||||
if (!responseTime && !inputTokens && !outputTokens && tps == null && !ctxPct) return;
|
if (!responseTime && !inputTokens && !outputTokens && tps == null && !ctxPct) return;
|
||||||
|
|
||||||
// Accumulate session cost (only on fresh metrics, not history reload)
|
// Rendering can occur when metrics arrive and again after [DONE]. The
|
||||||
if (!metrics._fromHistory) {
|
// ledger mutation is idempotent for that shared payload.
|
||||||
const _sid = window.sessionModule && window.sessionModule.getCurrentSessionId();
|
recordSessionMetricsCost(metrics);
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keep token counts in the Message Stats popup; the footer should stay slim.
|
// 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;
|
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;
|
const textRaw = Array.isArray(content) ? markdownModule.renderContent(content) : content;
|
||||||
|
|
||||||
// --- Agent multi-bubble reconstruction from saved metadata ---
|
// --- 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 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 pendingAskUser = null;
|
||||||
let lastWrap = null;
|
let lastWrap = null;
|
||||||
let firstMsgAi = null;
|
let firstMsgAi = null;
|
||||||
@@ -2322,7 +2494,8 @@ export function addMessage(role, content, modelName, metadata) {
|
|||||||
toolsByRound[r].push(ev);
|
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++) {
|
for (let r = 0; r < maxRound; r++) {
|
||||||
const roundNum = r + 1;
|
const roundNum = r + 1;
|
||||||
@@ -2334,10 +2507,31 @@ export function addMessage(role, content, modelName, metadata) {
|
|||||||
const roleEl = document.createElement('div');
|
const roleEl = document.createElement('div');
|
||||||
roleEl.className = 'role';
|
roleEl.className = 'role';
|
||||||
const pair = replyModelPair(modelName, metadata);
|
const pair = replyModelPair(modelName, metadata);
|
||||||
const contModel = pair.actualModel || pair.requestedModel;
|
const contModel = roundModels[r] || pair.actualModel || pair.requestedModel;
|
||||||
roleEl.textContent = modelRouteLabel(pair.requestedModel, contModel);
|
const contEndpointId = r < roundEndpointIds.length
|
||||||
if (pair.requestedModel && contModel && !sameModelName(pair.requestedModel, contModel)) {
|
? roundEndpointIds[r]
|
||||||
roleEl.title = pair.requestedModel + ' -> ' + contModel;
|
: 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);
|
applyModelColor(roleEl, contModel);
|
||||||
if (r === 0) roleEl.appendChild(roleTimestamp(metadata?.timestamp));
|
if (r === 0) roleEl.appendChild(roleTimestamp(metadata?.timestamp));
|
||||||
@@ -2492,7 +2686,14 @@ export function addMessage(role, content, modelName, metadata) {
|
|||||||
const isCompacted = metadata?.compacted;
|
const isCompacted = metadata?.compacted;
|
||||||
const replyModels = replyModelPair(modelName, metadata);
|
const replyModels = replyModelPair(modelName, metadata);
|
||||||
const resolvedModel = replyModels.actualModel || replyModels.requestedModel;
|
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)) {
|
if (role === 'assistant' && (metadata?.research || metadata?.research_clarification)) {
|
||||||
_roleText += ' (Research)';
|
_roleText += ' (Research)';
|
||||||
}
|
}
|
||||||
@@ -2503,8 +2704,14 @@ export function addMessage(role, content, modelName, metadata) {
|
|||||||
}
|
}
|
||||||
r.textContent = _roleText;
|
r.textContent = _roleText;
|
||||||
if (role !== 'user') {
|
if (role !== 'user') {
|
||||||
if (!isSlash && !isCompacted && replyModels.requestedModel && resolvedModel && !sameModelName(replyModels.requestedModel, resolvedModel)) {
|
const endpointChanged = Boolean(
|
||||||
r.title = replyModels.requestedModel + ' -> ' + resolvedModel;
|
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);
|
if (!isSlash && !isCompacted) applyModelColor(r, resolvedModel);
|
||||||
r.appendChild(roleTimestamp(metadata?.timestamp));
|
r.appendChild(roleTimestamp(metadata?.timestamp));
|
||||||
@@ -2788,6 +2995,7 @@ const chatRenderer = {
|
|||||||
getSessionCost,
|
getSessionCost,
|
||||||
resetSessionCost,
|
resetSessionCost,
|
||||||
updateSessionCostUI,
|
updateSessionCostUI,
|
||||||
|
recordSessionMetricsCost,
|
||||||
roleTimestamp,
|
roleTimestamp,
|
||||||
stripToolBlocks,
|
stripToolBlocks,
|
||||||
copyMessageText,
|
copyMessageText,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -445,14 +445,7 @@ async function initDefaultChat() {
|
|||||||
var epSel = el('set-defaultEpSelect');
|
var epSel = el('set-defaultEpSelect');
|
||||||
var modelSel = el('set-defaultModelSelect');
|
var modelSel = el('set-defaultModelSelect');
|
||||||
var msg = el('set-defaultChatMsg');
|
var msg = el('set-defaultChatMsg');
|
||||||
var fbContainer = el('set-defaultFallbacks');
|
|
||||||
var addFbBtn = el('set-defaultAddFallback');
|
|
||||||
var _endpoints = [];
|
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.
|
// Fill any <select> with the models for a given endpoint id.
|
||||||
function fillModels(selectEl, epId, selected) {
|
function fillModels(selectEl, epId, selected) {
|
||||||
@@ -469,64 +462,6 @@ async function initDefaultChat() {
|
|||||||
function refreshEndpointOptions(selectedEndpoint, selectedModel) {
|
function refreshEndpointOptions(selectedEndpoint, selectedModel) {
|
||||||
_fillEndpointSelect(epSel, _endpoints, selectedEndpoint !== undefined ? selectedEndpoint : epSel.value, false);
|
_fillEndpointSelect(epSel, _endpoints, selectedEndpoint !== undefined ? selectedEndpoint : epSel.value, false);
|
||||||
refreshModels(selectedModel !== undefined ? selectedModel : modelSel.value);
|
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 {
|
try {
|
||||||
@@ -534,7 +469,6 @@ async function initDefaultChat() {
|
|||||||
var settings = await res.json();
|
var settings = await res.json();
|
||||||
if (settings.default_endpoint_id) epSel.value = settings.default_endpoint_id;
|
if (settings.default_endpoint_id) epSel.value = settings.default_endpoint_id;
|
||||||
refreshModels(settings.default_model || '');
|
refreshModels(settings.default_model || '');
|
||||||
renderFallbacks();
|
|
||||||
} catch (e) { console.warn('Failed to load default chat settings', e); }
|
} catch (e) { console.warn('Failed to load default chat settings', e); }
|
||||||
|
|
||||||
epSel.addEventListener('change', function() { refreshModels(''); saveDefault(); });
|
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)'; }
|
} 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) {
|
_registerAiEndpointRefresh(function(endpoints) {
|
||||||
_endpoints = endpoints;
|
_endpoints = endpoints;
|
||||||
refreshEndpointOptions(epSel.value, modelSel.value);
|
refreshEndpointOptions(epSel.value, modelSel.value);
|
||||||
|
|||||||
@@ -2027,12 +2027,12 @@ async function _cmdUsage(args, ctx) {
|
|||||||
const messageCount = Number(session?.message_count || 0);
|
const messageCount = Number(session?.message_count || 0);
|
||||||
const totalTokens = Number(session?.total_tokens || 0);
|
const totalTokens = Number(session?.total_tokens || 0);
|
||||||
const costTracked = chatRenderer.isCostTrackedEndpoint ? chatRenderer.isCostTrackedEndpoint(endpointUrl) : true;
|
const costTracked = chatRenderer.isCostTrackedEndpoint ? chatRenderer.isCostTrackedEndpoint(endpointUrl) : true;
|
||||||
const cost = costTracked && chatRenderer.getSessionCost ? Number(chatRenderer.getSessionCost(sid) || 0) : 0;
|
const cost = chatRenderer.getSessionCost ? Number(chatRenderer.getSessionCost(sid) || 0) : 0;
|
||||||
const costLine = costTracked
|
const costLine = cost > 0
|
||||||
? (cost > 0
|
|
||||||
? `Estimated local cost: $${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}`
|
? `Estimated local cost: $${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(3)}`
|
||||||
: 'Estimated local cost: unavailable or zero')
|
: costTracked
|
||||||
: 'Estimated local cost: not tracked for this endpoint';
|
? 'Estimated local cost: unavailable or zero'
|
||||||
|
: 'Estimated local cost: no billable usage recorded';
|
||||||
|
|
||||||
slashReply(`<pre>${[
|
slashReply(`<pre>${[
|
||||||
`Session: ${ctx.esc(session?.name || 'Current chat')}`,
|
`Session: ${ctx.esc(session?.name || 'Current chat')}`,
|
||||||
|
|||||||
@@ -314,17 +314,21 @@ class TestComputeFinalMetrics:
|
|||||||
def test_tool_events_included(self):
|
def test_tool_events_included(self):
|
||||||
events = [{"tool": "bash", "duration": 1.0}]
|
events = [{"tool": "bash", "duration": 1.0}]
|
||||||
texts = ["round 1 text"]
|
texts = ["round 1 text"]
|
||||||
|
models = ["round-1-model"]
|
||||||
m = _compute_final_metrics(**self._base_args(
|
m = _compute_final_metrics(**self._base_args(
|
||||||
tool_events=events,
|
tool_events=events,
|
||||||
round_texts=texts,
|
round_texts=texts,
|
||||||
|
round_models=models,
|
||||||
))
|
))
|
||||||
assert m["tool_events"] == events
|
assert m["tool_events"] == events
|
||||||
assert m["round_texts"] == texts
|
assert m["round_texts"] == texts
|
||||||
|
assert m["round_models"] == models
|
||||||
|
|
||||||
def test_no_tool_events_excluded(self):
|
def test_no_tool_events_excluded(self):
|
||||||
m = _compute_final_metrics(**self._base_args(tool_events=[], round_texts=[]))
|
m = _compute_final_metrics(**self._base_args(tool_events=[], round_texts=[]))
|
||||||
assert "tool_events" not in m
|
assert "tool_events" not in m
|
||||||
assert "round_texts" 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
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -81,6 +81,13 @@ def _make_stream_with_save(sink, chunks, *, hang_after=None):
|
|||||||
return gen()
|
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)
|
# 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
|
break
|
||||||
await sub.aclose()
|
await sub.aclose()
|
||||||
|
|
||||||
stopped = agent_runs.stop(session_id)
|
stopped = agent_runs.stop(session_id, run.run_id)
|
||||||
assert stopped is True
|
assert stopped is True
|
||||||
|
|
||||||
await run.task # propagates promptly — not stuck on the hung await
|
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 == []
|
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
|
# chat_stream: Compare panes must NOT be detached, so the Stop button (closing
|
||||||
# the SSE) cancels the upstream generator promptly — exercising the same
|
# the SSE) cancels the upstream generator promptly — exercising the same
|
||||||
|
|||||||
@@ -63,6 +63,23 @@ class TestSelfSummaryPrompt:
|
|||||||
|
|
||||||
|
|
||||||
class TestTrimForContext:
|
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):
|
def test_keeps_current_large_user_message_by_truncating(self):
|
||||||
huge = "A" * 20000
|
huge = "A" * 20000
|
||||||
messages = [
|
messages = [
|
||||||
@@ -194,6 +211,50 @@ class TestMaybeCompactFourthMessage:
|
|||||||
assert len(result) == 3 and result[2] is True
|
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:
|
class TestResearchPrimerPreserved:
|
||||||
"""A research-spinoff primer (metadata research_spinoff_from) must never be
|
"""A research-spinoff primer (metadata research_spinoff_from) must never be
|
||||||
trimmed away — it is the Discuss chat's sole knowledge base (drift fix)."""
|
trimmed away — it is the Discuss chat's sole knowledge base (drift fix)."""
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,15 +8,15 @@ from bs4 import BeautifulSoup
|
|||||||
_REPO = Path(__file__).resolve().parents[1]
|
_REPO = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
def test_legacy_default_fallback_editor_is_hidden():
|
def test_legacy_default_fallback_editor_is_absent():
|
||||||
soup = BeautifulSoup(
|
soup = BeautifulSoup(
|
||||||
(_REPO / "static" / "index.html").read_text(encoding="utf-8"),
|
(_REPO / "static" / "index.html").read_text(encoding="utf-8"),
|
||||||
"html.parser",
|
"html.parser",
|
||||||
)
|
)
|
||||||
editor = soup.find(id="set-defaultFallbacks")
|
editor = soup.find(id="set-defaultFallbacks")
|
||||||
|
|
||||||
assert editor is not None
|
assert editor is None
|
||||||
assert editor.find_parent(class_="settings-row").has_attr("hidden")
|
assert soup.find(id="set-defaultAddFallback") is None
|
||||||
|
|
||||||
|
|
||||||
def test_default_model_save_does_not_rewrite_legacy_fallbacks():
|
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 "settings.default_model_fallbacks" not in default_chat_source
|
||||||
assert "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
|
||||||
+1267
-14
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,8 @@ stream_llm only captured usage when the delta was exactly None / {} /
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from src import llm_core
|
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):
|
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 = [
|
lines = [
|
||||||
'data: ' + json.dumps({"choices": [{"delta": {"content": "Hi"}}]}),
|
'data: ' + json.dumps({"choices": [{"delta": {"content": "Hi"}}]}),
|
||||||
'data: ' + json.dumps({"choices": [None], "usage": None}),
|
'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)
|
result = _drive(monkeypatch, lines)
|
||||||
assert "Hi" in result
|
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):
|
def test_null_tool_call_in_delta_is_skipped(monkeypatch):
|
||||||
|
|||||||
@@ -98,6 +98,9 @@ def test_endpoint_cleanup_preserves_legacy_default_fallback_data():
|
|||||||
{"endpoint_id": "dead", "model": "fallback-a"},
|
{"endpoint_id": "dead", "model": "fallback-a"},
|
||||||
{"endpoint_id": "keep", "model": "fallback-b"},
|
{"endpoint_id": "keep", "model": "fallback-b"},
|
||||||
],
|
],
|
||||||
|
"foreground_model_fallbacks": [
|
||||||
|
{"endpoint_id": "dead", "model": "foreground"},
|
||||||
|
],
|
||||||
"utility_model_fallbacks": [{"endpoint_id": "dead", "model": "utility"}],
|
"utility_model_fallbacks": [{"endpoint_id": "dead", "model": "utility"}],
|
||||||
"vision_model_fallbacks": [{"endpoint_id": "dead", "model": "vision"}],
|
"vision_model_fallbacks": [{"endpoint_id": "dead", "model": "vision"}],
|
||||||
"stt_provider": "endpoint:dead",
|
"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) == [
|
assert _endpoint_settings_using_endpoint(settings, "dead", include_speech=True) == [
|
||||||
"Default Model",
|
"Default Model",
|
||||||
|
"Foreground Model Fallbacks",
|
||||||
"Utility Model Fallbacks",
|
"Utility Model Fallbacks",
|
||||||
"Vision Model Fallbacks",
|
"Vision Model Fallbacks",
|
||||||
"Speech to Text",
|
"Speech to Text",
|
||||||
]
|
]
|
||||||
assert _clear_endpoint_settings_for_endpoint(settings, "dead", include_speech=True) == [
|
assert _clear_endpoint_settings_for_endpoint(settings, "dead", include_speech=True) == [
|
||||||
"Default Model",
|
"Default Model",
|
||||||
|
"Foreground Model Fallbacks",
|
||||||
"Utility Model Fallbacks",
|
"Utility Model Fallbacks",
|
||||||
"Vision Model Fallbacks",
|
"Vision Model Fallbacks",
|
||||||
"Speech to Text",
|
"Speech to Text",
|
||||||
@@ -122,6 +127,7 @@ def test_endpoint_cleanup_preserves_legacy_default_fallback_data():
|
|||||||
{"endpoint_id": "dead", "model": "fallback-a"},
|
{"endpoint_id": "dead", "model": "fallback-a"},
|
||||||
{"endpoint_id": "keep", "model": "fallback-b"},
|
{"endpoint_id": "keep", "model": "fallback-b"},
|
||||||
]
|
]
|
||||||
|
assert settings["foreground_model_fallbacks"] == []
|
||||||
assert settings["utility_model_fallbacks"] == []
|
assert settings["utility_model_fallbacks"] == []
|
||||||
assert settings["vision_model_fallbacks"] == []
|
assert settings["vision_model_fallbacks"] == []
|
||||||
assert settings["stt_provider"] == "disabled"
|
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():
|
def test_endpoint_cleanup_updates_active_scoped_prefs_but_preserves_legacy_data():
|
||||||
scoped = {
|
scoped = {
|
||||||
|
"foreground_model_fallbacks": [
|
||||||
|
{"endpoint_id": "dead", "model": "ownerless"},
|
||||||
|
],
|
||||||
|
"default_model_fallbacks": [
|
||||||
|
{"endpoint_id": "dead", "model": "legacy-ownerless"},
|
||||||
|
],
|
||||||
"_users": {
|
"_users": {
|
||||||
"alice": {
|
"alice": {
|
||||||
"utility_endpoint_id": "dead",
|
"utility_endpoint_id": "dead",
|
||||||
"utility_model": "utility",
|
"utility_model": "utility",
|
||||||
|
"foreground_model_fallbacks": [
|
||||||
|
{"endpoint_id": "dead", "model": "foreground"},
|
||||||
|
],
|
||||||
"vision_model_fallbacks": [{"endpoint_id": "dead", "model": "vision"}],
|
"vision_model_fallbacks": [{"endpoint_id": "dead", "model": "vision"}],
|
||||||
},
|
},
|
||||||
"bob": {
|
"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"] == {
|
assert scoped["_users"]["alice"] == {
|
||||||
"utility_endpoint_id": "",
|
"utility_endpoint_id": "",
|
||||||
"utility_model": "",
|
"utility_model": "",
|
||||||
|
"foreground_model_fallbacks": [],
|
||||||
"vision_model_fallbacks": [],
|
"vision_model_fallbacks": [],
|
||||||
}
|
}
|
||||||
assert scoped["_users"]["bob"]["default_endpoint_id"] == "keep"
|
assert scoped["_users"]["bob"]["default_endpoint_id"] == "keep"
|
||||||
|
|||||||
@@ -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},
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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")
|
prefs_file.write_text(json.dumps({"theme": "dark"}), encoding="utf-8")
|
||||||
monkeypatch.setattr(prefs_routes, "PREFS_FILE", str(prefs_file))
|
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
|
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
|
users and round-trip the change into the same (first) slot _load_for_user
|
||||||
reads from.
|
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
|
import json
|
||||||
|
|
||||||
@@ -51,3 +54,58 @@ def test_named_user_save_unaffected(tmp_path, monkeypatch):
|
|||||||
data = json.loads(f.read_text())
|
data = json.loads(f.read_text())
|
||||||
assert data["_users"]["alice"] == {"theme": "light"}
|
assert data["_users"]["alice"] == {"theme": "light"}
|
||||||
assert data["_users"]["bob"] == {"theme": "dark"}
|
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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,8 +3,16 @@
|
|||||||
import json
|
import json
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
import src.endpoint_resolver as endpoint_resolver
|
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:
|
class _FakeColumn:
|
||||||
@@ -34,6 +42,9 @@ class _FakeQuery:
|
|||||||
def first(self):
|
def first(self):
|
||||||
return self.rows[0] if self.rows else None
|
return self.rows[0] if self.rows else None
|
||||||
|
|
||||||
|
def all(self):
|
||||||
|
return list(self.rows)
|
||||||
|
|
||||||
|
|
||||||
class _FakeDb:
|
class _FakeDb:
|
||||||
def __init__(self, rows):
|
def __init__(self, rows):
|
||||||
@@ -49,6 +60,7 @@ class _FakeDb:
|
|||||||
def _endpoint(ep_id, model, *, hidden=None):
|
def _endpoint(ep_id, model, *, hidden=None):
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
id=ep_id,
|
id=ep_id,
|
||||||
|
name=f"Endpoint {ep_id}",
|
||||||
base_url=f"https://{ep_id}.example/v1",
|
base_url=f"https://{ep_id}.example/v1",
|
||||||
api_key=f"key-{ep_id}",
|
api_key=f"key-{ep_id}",
|
||||||
cached_models=json.dumps([model]),
|
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 url == "https://default.example/v1/chat/completions"
|
||||||
assert model == "enabled-chat"
|
assert model == "enabled-chat"
|
||||||
assert headers == {"Authorization": "Bearer key-default"}
|
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:
|
finally:
|
||||||
db.close()
|
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()
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -6,8 +6,15 @@ Verifies two critical cases:
|
|||||||
2. api.deepseek.com must still be treated as tool-capable via the host
|
2. api.deepseek.com must still be treated as tool-capable via the host
|
||||||
allow-list (_API_HOSTS), so cloud deepseek users keep working.
|
allow-list (_API_HOSTS), so cloud deepseek users keep working.
|
||||||
"""
|
"""
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import pytest
|
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
|
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")
|
keys = _endpoint_lookup_keys("http://host.docker.internal:11434/api/chat")
|
||||||
|
|
||||||
assert "http://host.docker.internal:11434/api" in keys
|
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
|
||||||
|
|||||||
@@ -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"]
|
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):
|
def test_calendar_relative_time_parser_handles_dotted_pm(monkeypatch):
|
||||||
import routes.calendar_routes as calendar_routes
|
import routes.calendar_routes as calendar_routes
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user