feat(streaming): publish observed local runtime model to SSE and journal

This commit is contained in:
Anthony Ruiz committed 2026-09-23 11:11:52 +00:00
1 parent 3f00824916
commit c45cab3779
7 files changed
+372 -8

No files matched your search

+2
View File
@@ -4067,6 +4067,7 @@ def _run_journal_live_snapshot(stream_id: str | None, *, handler=None) -> dict |
"tool_calls": tool_calls,
"last_assistant_text": assistant_text,
"last_reasoning_text": reasoning_text,
"runtime_model": runtime_model_from_events(session_id, stream_id, events),
"activity_burst_anchors": activity_burst_anchors,
"current_activity_burst_id": current_activity_burst_id,
"current_live_segment_seq": current_live_segment_seq,
@@ -10839,6 +10840,7 @@ from api.streaming import (
)
from api.gateway_chat import _run_gateway_chat_streaming, webui_gateway_chat_enabled
from api.run_journal import (
runtime_model_from_events,
_parse_run_journal_event_id as _shared_parse_run_journal_event_id,
_summary_from_events,
bound_run_journal_snapshot_args,
+35 -2
View File
@@ -10,6 +10,7 @@ import os
import re
import threading
import time
from copy import deepcopy
from collections import OrderedDict
from pathlib import Path
from typing import Any, Iterable
@@ -130,7 +131,7 @@ def _get_cached_summary(path: Path) -> dict | None:
_SUMMARY_CACHE.pop(key, None)
return None
_SUMMARY_CACHE.move_to_end(key)
return dict(summary)
return deepcopy(summary)
def _cache_summary(
@@ -147,7 +148,7 @@ def _cache_summary(
return
key = str(path)
with _SUMMARY_CACHE_LOCK:
_SUMMARY_CACHE[key] = (signature, dict(summary))
_SUMMARY_CACHE[key] = (signature, deepcopy(summary))
_SUMMARY_CACHE.move_to_end(key)
while len(_SUMMARY_CACHE) > _SUMMARY_CACHE_MAX_ENTRIES:
_SUMMARY_CACHE.popitem(last=False)
@@ -534,6 +535,37 @@ def select_authoritative_terminal_event(events: Iterable[dict]) -> dict | None:
)
def runtime_model_from_events(session_id: str, stream_id: str, events: Iterable[dict]) -> dict | None:
"""Project only observed serving identity for this journal owner.
A fallback warning or malformed later observation invalidates old evidence;
the configured selection and free-text status never supply serving identity.
"""
observed = None
for event in events:
if not isinstance(event, dict) or event.get("session_id") != session_id or event.get("run_id") != stream_id:
continue
payload = event.get("payload")
if not isinstance(payload, dict):
if event.get("event") == "runtime_model":
observed = None
continue
if event.get("event") == "warning" and payload.get("type") == "fallback":
observed = None
elif event.get("event") == "runtime_model":
observed = None
if (payload.get("session_id") != session_id or payload.get("stream_id") != stream_id
or not isinstance(payload.get("model"), str) or not payload["model"].strip()
or not isinstance(payload.get("fallback_active"), bool)
or payload.get("phase") not in ("observed_output", "route_observed")):
continue
observed = {key: payload[key] for key in (
"session_id", "stream_id", "model", "fallback_active", "phase")}
if isinstance(payload.get("provider"), str) and payload["provider"].strip():
observed["provider"] = payload["provider"]
return observed
def _summary_from_events(session_id: str, run_id: str, events: Iterable[dict]) -> dict:
ordered = [event for event in events if isinstance(event, dict)]
last = ordered[-1] if ordered else None
@@ -549,6 +581,7 @@ def _summary_from_events(session_id: str, run_id: str, events: Iterable[dict]) -
"terminal": bool(terminal),
"terminal_state": status,
"last_event": (last or {}).get("event"),
"runtime_model": runtime_model_from_events(session_id, run_id, ordered),
}
+54 -1
View File
@@ -891,6 +891,9 @@ def _is_fallback_lifecycle_message(kind: str, message: str) -> bool:
or 'falling back' in m
or 'fallback activated' in m
or 'trying fallback' in m
or 'model fallback:' in m
or 'switched to fallback' in m
or 'primary model restored:' in m
)
)
@@ -9734,6 +9737,37 @@ def _run_agent_streaming(
except Exception:
logger.debug("Failed to put event to queue")
_last_runtime_model_identity = None
_runtime_model_session_id = session_id
def _observe_runtime_model():
"""Publish this turn's Agent identity at output, never at an attempted route."""
nonlocal _last_runtime_model_identity
raw_model = getattr(agent, 'model', None)
if not isinstance(raw_model, str) or not raw_model.strip():
return
raw_provider = getattr(agent, 'provider', None)
provider = str(raw_provider).strip().lstrip('@').lower() if isinstance(raw_provider, str) else ''
# The Agent may carry a provider-qualified routing hint in its model.
from api.config import _parse_provider_qualified_model_id
parsed = _parse_provider_qualified_model_id(raw_model.strip())
model_id = (parsed[0] if parsed else raw_model).strip()
if not model_id:
return
fallback_active = getattr(agent, '_provider_fallback_active', None) is True
identity = (provider, model_id.lower(), fallback_active)
if identity == _last_runtime_model_identity:
return
payload = {
'session_id': _runtime_model_session_id, 'stream_id': stream_id,
'model': model_id, 'fallback_active': fallback_active,
'phase': 'observed_output',
}
if provider:
payload['provider'] = provider
put('runtime_model', payload)
_last_runtime_model_identity = identity
# #5940: capture a terminal (non-retryable) provider error the Agent emits via
# its lifecycle status_callback. The Agent aborts a non-retryable API error
# (e.g. HTTP 400 "invalid model / no credentials") with
@@ -9754,6 +9788,7 @@ def _run_agent_streaming(
turn-completion classifier can report the real cause instead of the
generic no_response fallback. All other lifecycle messages are dropped.
"""
nonlocal _last_runtime_model_identity
_message = str(message or '').strip()
_kind = str(kind or '').strip().lower()
if not _message:
@@ -9778,6 +9813,7 @@ def _run_agent_streaming(
# show them as warnings via the existing messages.js 'warning' listener.
_is_fallback_notice = _is_fallback_lifecycle_message(_kind, _message)
if _is_fallback_notice:
_last_runtime_model_identity = None
put('warning', {'type': 'fallback', 'message': _message})
# xsession wakeup misroute root fix (Option 1): pre-init so the outer
@@ -10325,6 +10361,8 @@ def _run_agent_streaming(
nonlocal _token_sent
if text is None:
return # end-of-stream sentinel
if text:
_observe_runtime_model()
# #4729: visible output is starting — flush any buffered reasoning tail
# first so the live Thinking stream is complete before/at the transition.
_flush_reasoning_buffer()
@@ -10368,6 +10406,8 @@ def _run_agent_streaming(
# partial window is not lost when the reasoning phase ends.
_flush_reasoning_buffer()
return
if text:
_observe_runtime_model()
_tool_boundary_advanced = False
reasoning_delta = str(text)
# Some runtimes mirror user-visible progress text through the
@@ -11570,6 +11610,11 @@ def _run_agent_streaming(
if isinstance(_m, dict) and _m.get('role') == 'assistant':
_answer = str(_m.get('content', ''))
break
if (_answer.strip() and not result.get('error')
and not _agent_result_terminal_failure(result)
and not getattr(agent, '_last_error', None)
and not _captured_terminal_error[0]):
_observe_runtime_model()
# /btw is intentionally non-persistent, but its terminal SSE
# payload is still public output. Project the ephemeral
# session before enqueueing it so raw Agent ``api_content`` or
@@ -12272,6 +12317,8 @@ def _run_agent_streaming(
# the catch-all label, hint, and provider details.
return # apperror already closes the stream on the client side
_observe_runtime_model()
# ── Handle context compression side effects ──
# Also detect compression via the result dict or compressor state
if not _compressed:
@@ -12478,7 +12525,9 @@ def _run_agent_streaming(
# mutates agent.model when a fallback fires, so the pre-run
# resolved_model would mis-attribute exactly the turns where
# attribution matters most.
_used_model = getattr(agent, 'model', None) or resolved_model or model
# The configured selection is not proof that it served this turn.
_observed_model = getattr(agent, 'model', None)
_used_model = _observed_model.strip() if isinstance(_observed_model, str) else None
if _gateway_routing:
s.gateway_routing = _gateway_routing
_history = list(getattr(s, 'gateway_routing_history', None) or [])
@@ -13364,6 +13413,9 @@ def _run_agent_streaming(
if 'credential_pool' in _agent_params:
_heal_kwargs['credential_pool'] = _runtime_bundle['credential_pool']
_heal_agent = _AIAgent(**_heal_kwargs)
# Delta callbacks close over `agent`; the replacement, not
# the failed original, owns any successful retry output.
agent = _heal_agent
_agent_sig = _compute_agent_cache_signature(
resolved_model,
resolved_api_key,
@@ -13491,6 +13543,7 @@ def _run_agent_streaming(
s, tool_calls=s.tool_calls
)
)
_observe_runtime_model()
if _done_session_payload is not None:
put('done', {
'session': _done_session_payload,