From f2094410cc4720c58e99cec208ab14d87eb1a3d7 Mon Sep 17 00:00:00 2001 From: witbrock Date: Tue, 11 Aug 2026 22:19:08 +0100 Subject: [PATCH] Show conversation runtime cost in footer --- src/backend/server/routes/von_routes.py | 117 ++++++ .../conversation_runtime_cost_service.py | 346 +++++++++++++++++ .../services/turn_execution_record_service.py | 18 + .../web/von_interface/static/js/chatTab.js | 353 ++++++++++++++++++ .../web/von_interface/static/js/domUtils.js | 110 ++++++ .../web/von_interface/static/js/main.js | 10 + .../static/js/test/chatTab.test.js | 92 +++++ .../js/test/conversationRuntimeCost.test.js | 113 ++++++ .../js/utils/conversationRuntimeCost.js | 208 +++++++++++ .../web/von_interface/static/styles.css | 65 ++++ .../test_conversation_runtime_cost_service.py | 352 +++++++++++++++++ 11 files changed, 1784 insertions(+) create mode 100644 src/backend/services/conversation_runtime_cost_service.py create mode 100644 src/frontend/web/von_interface/static/js/test/conversationRuntimeCost.test.js create mode 100644 src/frontend/web/von_interface/static/js/utils/conversationRuntimeCost.js create mode 100644 tests/backend/test_conversation_runtime_cost_service.py diff --git a/src/backend/server/routes/von_routes.py b/src/backend/server/routes/von_routes.py index 21fc3810e..7c2c3f7bc 100644 --- a/src/backend/server/routes/von_routes.py +++ b/src/backend/server/routes/von_routes.py @@ -92,6 +92,9 @@ LLM_USAGE_COST_SUMMARY_SCHEMA_VERSION, build_llm_usage_cost_summary, ) +from ...services.conversation_runtime_cost_service import ( + get_conversation_runtime_cost_snapshot, +) from ...services.model_registry_service import get_model_registry_snapshot from ...services.feature_flags import ( get_display_elements_screen_fence_compat_enabled, @@ -13816,6 +13819,120 @@ def history(): return jsonify({"error": str(e)}), 500 +@von_bp.route("/history/cost_summary", methods=["GET"]) +def history_cost_summary(): + """Return content-free conversation and actor-runtime cost estimates.""" + + try: + from ...security.access_control import get_effective_user_concept_id + + user_concept_id = get_effective_user_concept_id() + except Exception: + user_concept_id = session.get("user_concept_id") + user_concept_id = _normalise_concept_id(user_concept_id) + if not user_concept_id: + return jsonify({"error": "Not authenticated"}), 401 + + session_id = _clean_optional_text( + request.args.get("session_id") or session.get("session_id") + ) + if not session_id: + return jsonify({"error": "session_id required"}), 400 + exclude_request_id = _clean_optional_text(request.args.get("exclude_request_id")) + observe_request_id = _clean_optional_text(request.args.get("observe_request_id")) + + try: + # Do not accept namespace or organisation query parameters here. The + # runtime aggregate is private to the authenticated actor's effective + # server-side context, not an arbitrary client-selected scope. + window_session_id = request.headers.get("X-Von-Window-Session") + effective = get_effective_context( + window_session_id, dict(session), user_concept_id + ) + actor_namespace = _clean_optional_text(effective.get("namespace")) + if not actor_namespace: + actor_namespace = chat_history_service.resolve_chat_history_namespace( + user_concept_id + ) + if not actor_namespace: + return jsonify({"error": "Actor namespace unavailable"}), 503 + + owner_user_id, shared_invite = _resolve_shared_conversation_owner( + user_concept_id=user_concept_id, + session_id=session_id, + ) + if shared_invite: + if not owner_user_id: + return jsonify({"error": "Not authorised for conversation"}), 403 + elif not chat_history_service.has_chat_history_session( + user_concept_id, + session_id, + namespace=actor_namespace, + ): + return jsonify({"error": "Not authorised for conversation"}), 403 + if not owner_user_id: + return jsonify({"error": "Not authorised for conversation"}), 403 + + server_started_at_utc = _clean_optional_text( + current_app.config.get("SERVER_START_TIME") + ) + if not server_started_at_utc: + return jsonify({"error": "Runtime start time unavailable"}), 503 + raw_started_at = ( + f"{server_started_at_utc[:-1]}+00:00" + if server_started_at_utc.endswith("Z") + else server_started_at_utc + ) + try: + server_started_at = datetime.fromisoformat(raw_started_at) + except ValueError: + return jsonify({"error": "Runtime start time unavailable"}), 503 + if server_started_at.tzinfo is None: + server_started_at = server_started_at.replace(tzinfo=timezone.utc) + server_started_at = server_started_at.astimezone(timezone.utc) + + try: + model_registry = get_model_registry_snapshot() + except Exception: + # Cost availability must remain honest when pricing authority is + # temporarily unavailable; the standard builder reports it as such. + current_app.logger.warning( + "history_cost_summary: model pricing registry unavailable", + exc_info=True, + ) + model_registry = None + snapshot = get_conversation_runtime_cost_snapshot( + conversation_session_id=session_id, + actor_concept_id=user_concept_id, + actor_namespace=actor_namespace, + server_started_at=server_started_at, + server_started_at_utc=server_started_at_utc, + pid=os.getpid(), + model_registry=model_registry, + exclude_request_id=exclude_request_id, + observe_request_id=observe_request_id, + ) + return jsonify(snapshot) + except ValueError as exc: + if str(exc) == "exclude_request_id_not_owned_by_actor": + return jsonify({"error": "exclude_request_id not owned by actor"}), 403 + return jsonify({"error": "Invalid conversation cost request"}), 400 + except Exception as exc: + current_app.logger.warning( + "history_cost_summary unavailable: %s", exc, exc_info=True + ) + return ( + jsonify( + { + "error": "Conversation cost summary temporarily unavailable", + "retryable": True, + "availability_status": "transient_storage_error", + } + ), + 503, + ) + + def _build_transient_chat_history_payload( *, error_message: str, diff --git a/src/backend/services/conversation_runtime_cost_service.py b/src/backend/services/conversation_runtime_cost_service.py new file mode 100644 index 000000000..98752efb8 --- /dev/null +++ b/src/backend/services/conversation_runtime_cost_service.py @@ -0,0 +1,346 @@ +"""Content-free runtime cost snapshots for an authorised conversation. + +This module deliberately does not resolve an actor, namespace, or conversation +owner. Those are authority decisions made by the authenticated route. It +only projects already-scoped turn records and reuses the canonical LLM usage +and cost summary builder. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from datetime import UTC, datetime +from typing import Any + +from .llm_usage_cost_service import build_llm_usage_cost_summary +from .turn_execution_record_service import get_turn_execution_records_collection + +CONVERSATION_RUNTIME_COST_SNAPSHOT_SCHEMA_VERSION = ( + "conversation_runtime_cost_snapshot.v1" +) + + +_CALL_FIELDS = ( + "call_id", + "type", + "call_type", + "stage", + "status", + "success", + "provider_request_sent", + "provider", + "requested_model", + "selected_model", + "effective_model", + "model_identity_source", + "effective_service_tier", + "connection_id", + "transport.effective_service_tier", + "transport.effective_connection_id", + "transport.connection_id", + "transport.pricing_context_input_tokens", + "usage", + "started_at_utc", + "completed_at_utc", + "at_utc", +) + + +def turn_execution_cost_projection() -> dict[str, int]: + """Return the content-free fields required for exact cost re-projection.""" + + projection = { + "_id": 0, + "request_id": 1, + "user_id": 1, + "namespace": 1, + "session_id": 1, + "created_at_utc": 1, + "updated_at_utc": 1, + } + for root in ("llm_calls", "execution.llm_calls"): + for field in _CALL_FIELDS: + projection[f"{root}.{field}"] = 1 + return projection + + +def _safe_text(value: Any) -> str | None: + if not isinstance(value, str): + return None + cleaned = value.strip() + return cleaned or None + + +def _calls_from_one_canonical_source(record: Mapping[str, Any]) -> list[Mapping[str, Any]]: + """Read one call list per turn, matching the persisted-call convention.""" + + calls = record.get("llm_calls") + if isinstance(calls, Sequence) and not isinstance(calls, (str, bytes)): + return [call for call in calls if isinstance(call, Mapping)] + execution = record.get("execution") + calls = execution.get("llm_calls") if isinstance(execution, Mapping) else None + if isinstance(calls, Sequence) and not isinstance(calls, (str, bytes)): + return [call for call in calls if isinstance(call, Mapping)] + return [] + + +def _parse_utc(value: Any) -> datetime | None: + if isinstance(value, datetime): + parsed = value + elif isinstance(value, str) and value.strip(): + raw = value.strip() + try: + parsed = datetime.fromisoformat( + f"{raw[:-1]}+00:00" if raw.endswith("Z") else raw + ) + except ValueError: + return None + else: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=UTC) + return parsed.astimezone(UTC) + + +def _call_is_since_restart(call: Mapping[str, Any], *, server_started_at: datetime) -> bool: + """A call belongs to this process only when a call timestamp proves it. + + Prefer the completion timestamp, but accept the start timestamp when a + completion value was not retained. This keeps the boundary conservative + while still accounting for an in-flight call which began after restart. + """ + + completed = _parse_utc(call.get("completed_at_utc")) or _parse_utc( + call.get("at_utc") + ) + started = _parse_utc(call.get("started_at_utc")) + return bool( + (completed is not None and completed >= server_started_at) + or (started is not None and started >= server_started_at) + ) + + +def _calls_from_records( + records: Sequence[Mapping[str, Any]], *, exclude_request_id: str | None = None +) -> list[Mapping[str, Any]]: + calls: list[Mapping[str, Any]] = [] + for record in records: + if not isinstance(record, Mapping): + continue + if exclude_request_id and _safe_text(record.get("request_id")) == exclude_request_id: + continue + calls.extend(_calls_from_one_canonical_source(record)) + return calls + + +def _mark_temporally_incomplete_summary( + summary: dict[str, Any], *, included_call_count: int, missing_timestamp_call_count: int +) -> dict[str, Any]: + """Keep an unprovable restart boundary from looking like a zero-cost run.""" + + coverage_status = "complete" + if missing_timestamp_call_count: + coverage_status = "partial" if included_call_count else "unavailable" + cost = summary.get("estimated_cost") + if isinstance(cost, dict): + if cost.get("status") == "estimated": + cost["status"] = "partial" + cost["amount"] = None + elif cost.get("status") == "not_applicable": + cost.update( + { + "status": "unavailable", + "amount": None, + "known_amount": None, + "currency": None, + "reason": "call_timestamp_unavailable", + } + ) + usage = summary.get("usage") + if isinstance(usage, dict): + if usage.get("status") == "reported": + usage["status"] = "partial" + elif usage.get("status") == "not_applicable": + usage["status"] = "unavailable" + summary["runtime_coverage"] = { + "status": coverage_status, + "timestamp_eligible_call_count": included_call_count, + "missing_timestamp_call_count": missing_timestamp_call_count, + } + return summary + + +def build_conversation_runtime_cost_snapshot( + *, + conversation_records: Sequence[Mapping[str, Any]], + actor_runtime_records: Sequence[Mapping[str, Any]], + conversation_session_id: str, + actor_concept_id: str, + namespace: str, + server_started_at: datetime, + pid: int, + server_started_at_utc: str, + model_registry: Any = None, + exclude_request_id: str | None = None, + observe_request_id: str | None = None, + observe_request_id_persisted: bool = False, + as_of: datetime | None = None, +) -> dict[str, Any]: + """Build a pure snapshot from records already constrained by authority. + + ``conversation_records`` must be the authorised exact session scope + (including authorised shared-session participants) and + ``actor_runtime_records`` must be the current actor/current namespace. + Keeping these inputs separate prevents shared-conversation access from + broadening the actor's since-restart aggregate. + """ + + clean_exclude_request_id = _safe_text(exclude_request_id) + clean_observe_request_id = _safe_text(observe_request_id) + conversation_calls = _calls_from_records( + conversation_records, exclude_request_id=clean_exclude_request_id + ) + candidate_runtime_calls = _calls_from_records( + actor_runtime_records, exclude_request_id=clean_exclude_request_id + ) + runtime_calls = [ + call + for call in candidate_runtime_calls + if _call_is_since_restart(call, server_started_at=server_started_at) + ] + missing_timestamp_call_count = sum( + call.get("provider_request_sent") is not False + and (_safe_text(call.get("provider")) or "").lower() != "ollama" + and not any( + _parse_utc(call.get(key)) is not None + for key in ("completed_at_utc", "at_utc", "started_at_utc") + ) + for call in candidate_runtime_calls + ) + generated_at = (as_of or datetime.now(UTC)).astimezone(UTC) + return { + "schema_version": CONVERSATION_RUNTIME_COST_SNAPSHOT_SCHEMA_VERSION, + "scope": { + "conversation_session_id": conversation_session_id, + "actor_concept_id": actor_concept_id, + "namespace": namespace, + }, + "runtime": { + "pid": pid, + "started_at_utc": server_started_at_utc, + }, + "conversation": build_llm_usage_cost_summary( + conversation_calls, model_registry=model_registry + ), + "since_restart": _mark_temporally_incomplete_summary( + build_llm_usage_cost_summary(runtime_calls, model_registry=model_registry), + included_call_count=len(runtime_calls), + missing_timestamp_call_count=missing_timestamp_call_count, + ), + "handover": ( + { + "request_id": clean_observe_request_id, + "observed": bool(observe_request_id_persisted), + } + if clean_observe_request_id + else None + ), + "as_of_utc": generated_at.isoformat().replace("+00:00", "Z"), + } + + +def get_conversation_runtime_cost_snapshot( + *, + conversation_session_id: str, + actor_concept_id: str, + actor_namespace: str, + server_started_at: datetime, + server_started_at_utc: str, + pid: int, + model_registry: Any = None, + exclude_request_id: str | None = None, + observe_request_id: str | None = None, +) -> dict[str, Any]: + """Load content-free scoped records and return a runtime cost snapshot. + + ``exclude_request_id`` is for a separately rendered cumulative live + request summary. A not-yet-persisted ID is therefore a safe no-op; a + persisted ID may be excluded only when it belongs to this actor, namespace + and selected conversation. ``observe_request_id`` reports whether that + same actor-scoped record has reached durable projection so the caller can + hand a live overlay back to the stored aggregate without guessing. + """ + + collection = get_turn_execution_records_collection() + if collection is None: + raise RuntimeError("turn_execution_records_unavailable") + clean_exclude_request_id = _safe_text(exclude_request_id) + clean_observe_request_id = _safe_text(observe_request_id) + if clean_exclude_request_id: + excluded_record = collection.find_one( + { + "session_id": conversation_session_id, + "request_id": clean_exclude_request_id, + }, + {"_id": 0, "user_id": 1, "namespace": 1}, + ) + if isinstance(excluded_record, Mapping) and ( + excluded_record.get("user_id") != actor_concept_id + or excluded_record.get("namespace") != actor_namespace + ): + raise ValueError("exclude_request_id_not_owned_by_actor") + observed_record = None + if clean_observe_request_id: + observed_record = collection.find_one( + { + "session_id": conversation_session_id, + "request_id": clean_observe_request_id, + "user_id": actor_concept_id, + "namespace": actor_namespace, + }, + {"_id": 1}, + ) + projection = turn_execution_cost_projection() + conversation_records: list[Mapping[str, Any]] = [] + actor_runtime_records: list[Mapping[str, Any]] = [] + # An authenticated caller has already proved access to this exact + # conversation. Records can be authored by the owner or an accepted + # participant, so restricting this aggregate to owner scope would make a + # visible chat total silently incomplete. + conversation_query: dict[str, Any] = {"session_id": conversation_session_id} + actor_query: dict[str, Any] = { + "user_id": actor_concept_id, + "namespace": actor_namespace, + "$or": [ + {"created_at_utc": {"$gte": server_started_at_utc}}, + {"updated_at_utc": {"$gte": server_started_at_utc}}, + ], + } + if clean_exclude_request_id: + # Exclude in both reads so an active-record insert between validation + # and aggregation cannot be counted under the persisted base as well + # as the live overlay. + conversation_query["request_id"] = {"$ne": clean_exclude_request_id} + actor_query["request_id"] = {"$ne": clean_exclude_request_id} + conversation_cursor = collection.find(conversation_query, projection) + actor_cursor = collection.find(actor_query, projection) + conversation_records = [ + record for record in conversation_cursor if isinstance(record, Mapping) + ] + actor_runtime_records = [ + record for record in actor_cursor if isinstance(record, Mapping) + ] + return build_conversation_runtime_cost_snapshot( + conversation_records=conversation_records, + actor_runtime_records=actor_runtime_records, + conversation_session_id=conversation_session_id, + actor_concept_id=actor_concept_id, + namespace=actor_namespace, + server_started_at=server_started_at, + server_started_at_utc=server_started_at_utc, + pid=pid, + model_registry=model_registry, + exclude_request_id=clean_exclude_request_id, + observe_request_id=clean_observe_request_id, + observe_request_id_persisted=isinstance(observed_record, Mapping), + ) diff --git a/src/backend/services/turn_execution_record_service.py b/src/backend/services/turn_execution_record_service.py index 8c877e41e..c7b951382 100644 --- a/src/backend/services/turn_execution_record_service.py +++ b/src/backend/services/turn_execution_record_service.py @@ -11628,6 +11628,24 @@ def _ensure_turn_execution_indexes(collection) -> None: [("namespace", ASCENDING), ("created_at_utc", DESCENDING)], name="namespace_created_desc", ) + if "user_namespace_created_desc" not in existing_indexes: + collection.create_index( + [ + ("user_id", ASCENDING), + ("namespace", ASCENDING), + ("created_at_utc", DESCENDING), + ], + name="user_namespace_created_desc", + ) + if "user_namespace_updated_desc" not in existing_indexes: + collection.create_index( + [ + ("user_id", ASCENDING), + ("namespace", ASCENDING), + ("updated_at_utc", DESCENDING), + ], + name="user_namespace_updated_desc", + ) if "session_created_desc" not in existing_indexes: collection.create_index( [("session_id", ASCENDING), ("created_at_utc", DESCENDING)], diff --git a/src/frontend/web/von_interface/static/js/chatTab.js b/src/frontend/web/von_interface/static/js/chatTab.js index 44917475c..7b492dbd0 100644 --- a/src/frontend/web/von_interface/static/js/chatTab.js +++ b/src/frontend/web/von_interface/static/js/chatTab.js @@ -111,6 +111,7 @@ const CONVERSATION_INFO_EXPORT_SCHEMA_VERSION = 'conversation_info_export.v1'; const CONVERSATION_TELEMETRY_ACCESS_SCHEMA_VERSION = 'conversation_telemetry_access.v1'; const CONVERSATION_LLM_TELEMETRY_SCHEMA_VERSION = 'conversation_llm_telemetry.v1'; const CONVERSATION_LLM_TELEMETRY_LOCATOR_SCHEMA_VERSION = 'conversation_llm_telemetry_locator.v1'; +const CONVERSATION_RUNTIME_COST_SNAPSHOT_SCHEMA_VERSION = 'conversation_runtime_cost_snapshot.v1'; const CONVERSATION_SITUATION_EXPORT_SCHEMA_VERSION = 'conversation_situation_export.v1'; const CONVERSATION_TELEMETRY_LOCATOR_FETCH_TIMEOUT_MS = 4000; const TURN_TELEMETRY_MCP_ACCESS_SCHEMA_VERSION = 'turn_telemetry_mcp_access.v1'; @@ -279,6 +280,20 @@ const WORKFLOW_DEFINITIONS_TIMEOUT_RETRY_MAX_ATTEMPTS = 2; let workflowStatusPanelInitialised = false; let workflowCapabilityIndexStatusUnsubscribe = null; let chatTabInitialised = false; +const conversationRuntimeCostState = { + generation: 0, + fetchSequence: 0, + contextSignature: null, + context: null, + runtime: null, + runtimeIdentitySignature: null, + baseline: null, + live: null, + loading: false, + error: null, + inFlight: null, + handoverRetryTimerId: null +}; const REALTIME_CONNECTION_TELEMETRY_SCHEMA_VERSION = 1; const workflowEpisodesState = { open: false, @@ -380,6 +395,7 @@ function synchroniseLlmExecutionContext({ force = false, reason = 'context_refre ...current, }; publishCurrentLlmExecutionContextBinding(bound); + synchroniseConversationRuntimeCostContext({ force, reason }); return bound; } @@ -4019,6 +4035,14 @@ function applyThinkingProgressUpdate(request, nextProgress) { } request.latestProgress = cloneThinkingLatestProgress(nextProgress) || nextProgress; + const liveCostSummary = ( + nextProgress?.llm_usage_cost_summary + && typeof nextProgress.llm_usage_cost_summary === 'object' + ) ? nextProgress.llm_usage_cost_summary : null; + setConversationRuntimeCostLiveSummary(request, liveCostSummary, { + active: !isTerminalThinkingProgress(nextProgress), + reason: 'live_progress' + }); const workflowSelection = buildThinkingWorkflowSelectionSnapshot(nextProgress); if (workflowSelection) { request.workflowSelection = workflowSelection; @@ -30803,6 +30827,13 @@ export function initializeChatTab() { chatTabInitialised = true; console.log("Initializing chat tab..."); + if (!document.__vonConversationRuntimeCostRuntimeListenerBound) { + document.__vonConversationRuntimeCostRuntimeListenerBound = true; + document.addEventListener('von:runtimeIdentityUpdated', (event) => { + handleConversationRuntimeIdentityUpdate(event?.detail); + }); + } + // JVNAUTOSCI-1014: Load hidden session IDs from localStorage (user-scoped) loadHiddenChatSessionIds(); loadPinnedChatSessionIds(); @@ -31474,6 +31505,19 @@ function abortActiveChatRequest(options = {}) { setLiveChatRequestForSession(request.sessionId, null); setFinishedThinkingCardForSession(request.sessionId, null); + const latestKnownCostSummary = request.llmUsageCostSummary + || request.latestProgress?.llm_usage_cost_summary + || null; + setConversationRuntimeCostLiveSummary(request, latestKnownCostSummary, { + active: false, + reason: 'request_aborted' + }); + if (request.sessionId === activeChatSessionId) { + void refreshConversationRuntimeCostSnapshot({ + observeRequestId: request.clientRequestId, + reason: 'request_aborted' + }); + } syncActiveChatSessionThinkingState(); restorePromptEditingState(request, options); } @@ -33062,6 +33106,16 @@ async function handleSendPrompt(options = {}) { }); setFinishedThinkingCardForSession(targetSessionId, null); setLiveChatRequestForSession(targetSessionId, request); + setConversationRuntimeCostLiveSummary(request, null, { + active: true, + reason: 'request_started' + }); + if (request.sessionId === activeChatSessionId) { + void refreshConversationRuntimeCostSnapshot({ + excludeRequestId: request.clientRequestId, + reason: 'request_started' + }); + } if (selectedQueueEntry) { hideChatPromptQueueEntryLocally(selectedQueueEntry); } @@ -33177,6 +33231,10 @@ async function handleSendPrompt(options = {}) { enriched.llm_usage_cost_summary && typeof enriched.llm_usage_cost_summary === 'object' ) ? { ...enriched.llm_usage_cost_summary } : null; + setConversationRuntimeCostLiveSummary(request, request.llmUsageCostSummary, { + active: false, + reason: 'terminal_summary' + }); syncThinkingCriticOutputFromSource(request, enriched); setLlmDebugDataEntry(assistantTurnId, enriched, request.executionContextBinding); if (isRequestVisible()) { @@ -33250,6 +33308,10 @@ async function handleSendPrompt(options = {}) { data.llm_debug.llm_usage_cost_summary && typeof data.llm_debug.llm_usage_cost_summary === 'object' ) ? { ...data.llm_debug.llm_usage_cost_summary } : null; + setConversationRuntimeCostLiveSummary(request, request.llmUsageCostSummary, { + active: false, + reason: 'terminal_summary' + }); syncThinkingCriticOutputFromSource(request, data.llm_debug); setLlmDebugDataEntry(errorTurnId, data.llm_debug, request.executionContextBinding); if (isRequestVisible()) { @@ -33472,6 +33534,12 @@ async function handleSendPrompt(options = {}) { setFinishedThinkingCardForSession(request.sessionId, null); } setLiveChatRequestForSession(request.sessionId, null); + if (request.sessionId === activeChatSessionId) { + void refreshConversationRuntimeCostSnapshot({ + observeRequestId: request.clientRequestId, + reason: 'terminal_baseline_refresh' + }); + } } syncActiveChatSessionThinkingState(); updateHistoryLength(); @@ -35047,6 +35115,256 @@ function buildConversationTelemetryNamespaceContext() { }; } +function normaliseConversationRuntimeCostString(value) { + return (typeof value === 'string' && value.trim()) ? value.trim() : null; +} + +function cloneConversationRuntimeCostValue(value) { + if (!value || typeof value !== 'object') return null; + try { + return JSON.parse(JSON.stringify(value)); + } catch (_) { + return null; + } +} + +function buildConversationRuntimeCostContext() { + const namespaceContext = buildConversationTelemetryNamespaceContext(); + return { + conversation_session_id: normaliseHistorySessionId(activeChatSessionId), + window_session_id: normaliseConversationRuntimeCostString(getWindowSessionId()), + user_concept_id: normaliseConversationRuntimeCostString(namespaceContext.user_id), + organisation_concept_id: normaliseConversationRuntimeCostString(namespaceContext.org_id), + namespace: normaliseConversationRuntimeCostString(namespaceContext.namespace) + }; +} + +function runtimeIdentitySignature(runtime) { + if (!runtime || typeof runtime !== 'object') return null; + const pid = Number.isFinite(Number(runtime.pid)) ? String(Number(runtime.pid)) : ''; + const startedAt = normaliseConversationRuntimeCostString(runtime.started_at_utc || runtime.start_time); + return (pid || startedAt) ? `${pid}|${startedAt || ''}` : null; +} + +function publishConversationRuntimeCostSnapshot(reason = 'update') { + const snapshot = { + schema_version: CONVERSATION_RUNTIME_COST_SNAPSHOT_SCHEMA_VERSION, + reason, + context: cloneConversationRuntimeCostValue(conversationRuntimeCostState.context), + runtime: cloneConversationRuntimeCostValue(conversationRuntimeCostState.runtime), + baseline: cloneConversationRuntimeCostValue(conversationRuntimeCostState.baseline), + live: cloneConversationRuntimeCostValue(conversationRuntimeCostState.live), + loading: conversationRuntimeCostState.loading === true, + error: conversationRuntimeCostState.error || null, + as_of_utc: new Date().toISOString() + }; + try { + window.__vonConversationRuntimeCostSnapshot = snapshot; + } catch (_) { + // Publishing is best effort; rendering remains non-critical. + } + try { + document.dispatchEvent(new CustomEvent('von:conversationRuntimeCostUpdated', { + detail: snapshot + })); + } catch (_) { + // Footer telemetry cannot interrupt chat execution. + } + return snapshot; +} + +function clearConversationRuntimeCostSnapshot(reason = 'context_changed', context = buildConversationRuntimeCostContext()) { + if (conversationRuntimeCostState.handoverRetryTimerId) { + clearTimeout(conversationRuntimeCostState.handoverRetryTimerId); + conversationRuntimeCostState.handoverRetryTimerId = null; + } + conversationRuntimeCostState.generation += 1; + conversationRuntimeCostState.fetchSequence += 1; + conversationRuntimeCostState.context = context; + conversationRuntimeCostState.contextSignature = JSON.stringify(context); + conversationRuntimeCostState.baseline = null; + conversationRuntimeCostState.live = null; + conversationRuntimeCostState.loading = false; + conversationRuntimeCostState.error = null; + conversationRuntimeCostState.inFlight = null; + publishConversationRuntimeCostSnapshot(reason); +} + +function scheduleConversationRuntimeCostHandoverRetry({ observeRequestId, retryAttempt, reason }) { + if (retryAttempt >= 3 || conversationRuntimeCostState.handoverRetryTimerId) return; + const contextSignature = conversationRuntimeCostState.contextSignature; + const delayMs = 600 * (2 ** retryAttempt); + conversationRuntimeCostState.handoverRetryTimerId = setTimeout(() => { + conversationRuntimeCostState.handoverRetryTimerId = null; + if (conversationRuntimeCostState.contextSignature !== contextSignature) return; + void refreshConversationRuntimeCostSnapshot({ + observeRequestId, + retryAttempt: retryAttempt + 1, + reason + }); + }, delayMs); +} + +function refreshConversationRuntimeCostSnapshot({ + excludeRequestId = null, + observeRequestId = null, + retryAttempt = 0, + reason = 'baseline_refresh' +} = {}) { + const context = buildConversationRuntimeCostContext(); + const sessionId = context.conversation_session_id; + if (!sessionId) { + clearConversationRuntimeCostSnapshot('no_conversation_context', context); + return Promise.resolve(null); + } + // Some narrowly isolated unit tests intentionally omit the browser fetch + // surface. Do not turn a support-only footer refresh into a chat failure. + if (typeof fetch !== 'function') { + return Promise.resolve(null); + } + const signature = JSON.stringify(context); + if (conversationRuntimeCostState.contextSignature !== signature) { + clearConversationRuntimeCostSnapshot('context_changed', context); + } + const requestGeneration = conversationRuntimeCostState.generation; + const cleanExcludedRequestId = normaliseConversationRuntimeCostString(excludeRequestId); + const cleanObservedRequestId = normaliseConversationRuntimeCostString(observeRequestId); + const inFlight = conversationRuntimeCostState.inFlight; + if (inFlight && inFlight.generation === requestGeneration + && inFlight.excludeRequestId === cleanExcludedRequestId + && inFlight.observeRequestId === cleanObservedRequestId) { + return inFlight.promise; + } + const requestFetchSequence = conversationRuntimeCostState.fetchSequence + 1; + conversationRuntimeCostState.fetchSequence = requestFetchSequence; + conversationRuntimeCostState.loading = true; + conversationRuntimeCostState.error = null; + publishConversationRuntimeCostSnapshot(reason); + const params = new URLSearchParams({ session_id: sessionId }); + if (cleanExcludedRequestId) params.set('exclude_request_id', cleanExcludedRequestId); + if (cleanObservedRequestId) params.set('observe_request_id', cleanObservedRequestId); + const promise = fetch(`/von/history/cost_summary?${params.toString()}`, { + method: 'GET', + headers: buildChatFetchHeaders(), + cache: 'no-store' + }) + .then(async (response) => { + const payload = await response.json().catch(() => null); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + if (payload?.schema_version !== CONVERSATION_RUNTIME_COST_SNAPSHOT_SCHEMA_VERSION) { + throw new Error('Unexpected conversation cost summary schema'); + } + if (normaliseConversationRuntimeCostString(payload?.scope?.conversation_session_id) !== sessionId) { + throw new Error('Conversation cost summary scope did not match the selected conversation'); + } + return payload; + }) + .then((payload) => { + if (conversationRuntimeCostState.generation !== requestGeneration + || conversationRuntimeCostState.fetchSequence !== requestFetchSequence + || conversationRuntimeCostState.contextSignature !== signature) return null; + conversationRuntimeCostState.baseline = payload; + conversationRuntimeCostState.runtime = payload.runtime && typeof payload.runtime === 'object' + ? cloneConversationRuntimeCostValue(payload.runtime) + : null; + conversationRuntimeCostState.runtimeIdentitySignature = runtimeIdentitySignature(conversationRuntimeCostState.runtime); + // Retire a completed live overlay only after the server proves that + // this exact request is present in the scoped persisted baseline. + // This avoids a transient undercount during durable-record lag. + const handoverObserved = payload?.handover?.observed === true; + if (cleanObservedRequestId + && handoverObserved + && conversationRuntimeCostState.live?.request_id === cleanObservedRequestId + && conversationRuntimeCostState.live?.active !== true) { + if (conversationRuntimeCostState.handoverRetryTimerId) { + clearTimeout(conversationRuntimeCostState.handoverRetryTimerId); + conversationRuntimeCostState.handoverRetryTimerId = null; + } + conversationRuntimeCostState.live = null; + } else if (cleanObservedRequestId && !handoverObserved) { + scheduleConversationRuntimeCostHandoverRetry({ + observeRequestId: cleanObservedRequestId, + retryAttempt, + reason: 'terminal_handover_retry' + }); + } + return payload; + }) + .catch((error) => { + if (conversationRuntimeCostState.generation === requestGeneration + && conversationRuntimeCostState.fetchSequence === requestFetchSequence + && conversationRuntimeCostState.contextSignature === signature) { + conversationRuntimeCostState.error = error?.message || 'Cost estimate unavailable'; + } + return null; + }) + .finally(() => { + if (conversationRuntimeCostState.generation === requestGeneration + && conversationRuntimeCostState.fetchSequence === requestFetchSequence + && conversationRuntimeCostState.contextSignature === signature) { + conversationRuntimeCostState.loading = false; + conversationRuntimeCostState.inFlight = null; + publishConversationRuntimeCostSnapshot(reason); + } + }); + conversationRuntimeCostState.inFlight = { + generation: requestGeneration, + fetchSequence: requestFetchSequence, + excludeRequestId: cleanExcludedRequestId, + observeRequestId: cleanObservedRequestId, + promise + }; + return promise; +} + +function synchroniseConversationRuntimeCostContext({ force = false, reason = 'context_refresh' } = {}) { + const context = buildConversationRuntimeCostContext(); + const signature = JSON.stringify(context); + if (!force && conversationRuntimeCostState.contextSignature === signature) return; + clearConversationRuntimeCostSnapshot(reason, context); + if (context.conversation_session_id) { + void refreshConversationRuntimeCostSnapshot({ reason }); + } +} + +function setConversationRuntimeCostLiveSummary(request, summary = null, { active = true, reason = 'live_progress' } = {}) { + const context = buildConversationRuntimeCostContext(); + const requestSessionId = normaliseHistorySessionId(request?.sessionId); + const requestId = normaliseConversationRuntimeCostString( + request?.clientRequestId || request?.latestProgress?.request_id + ); + if (!requestId || !requestSessionId || requestSessionId !== context.conversation_session_id) return; + if (conversationRuntimeCostState.contextSignature !== JSON.stringify(context)) { + clearConversationRuntimeCostSnapshot('context_changed', context); + } + conversationRuntimeCostState.live = { + request_id: requestId, + active: active === true, + summary: summary && typeof summary === 'object' ? cloneConversationRuntimeCostValue(summary) : null + }; + publishConversationRuntimeCostSnapshot(reason); +} + +function handleConversationRuntimeIdentityUpdate(detail) { + const incoming = detail && typeof detail === 'object' ? detail : null; + const nextSignature = runtimeIdentitySignature(incoming); + if (!nextSignature) return; + const currentSignature = conversationRuntimeCostState.runtimeIdentitySignature; + if (!currentSignature) { + conversationRuntimeCostState.runtimeIdentitySignature = nextSignature; + return; + } + if (currentSignature === nextSignature) return; + conversationRuntimeCostState.runtime = { + pid: Number.isFinite(Number(incoming.pid)) ? Number(incoming.pid) : null, + started_at_utc: normaliseConversationRuntimeCostString(incoming.start_time || incoming.started_at_utc) + }; + clearConversationRuntimeCostSnapshot('runtime_identity_changed'); + if (conversationRuntimeCostState.context?.conversation_session_id) { + void refreshConversationRuntimeCostSnapshot({ reason: 'runtime_identity_changed' }); + } +} + function buildMcpToolAccess(toolName, args = {}, purpose = null) { const cleanArgs = {}; if (args && typeof args === 'object') { @@ -36248,6 +36566,26 @@ export async function __testOnly_refreshChatPromptQueueFromServer() { return refreshChatPromptQueueFromServer({ silent: false }); } export function __testOnly_resetChatRequestState() { + if (conversationRuntimeCostState.handoverRetryTimerId) { + clearTimeout(conversationRuntimeCostState.handoverRetryTimerId); + } + conversationRuntimeCostState.generation += 1; + conversationRuntimeCostState.fetchSequence += 1; + conversationRuntimeCostState.contextSignature = null; + conversationRuntimeCostState.context = null; + conversationRuntimeCostState.runtime = null; + conversationRuntimeCostState.runtimeIdentitySignature = null; + conversationRuntimeCostState.baseline = null; + conversationRuntimeCostState.live = null; + conversationRuntimeCostState.loading = false; + conversationRuntimeCostState.error = null; + conversationRuntimeCostState.inFlight = null; + conversationRuntimeCostState.handoverRetryTimerId = null; + try { + window.__vonConversationRuntimeCostSnapshot = null; + } catch (_) { + // Ignore constrained test globals. + } liveChatRequestsBySession.clear(); finishedThinkingCardsBySession.clear(); pendingFileCopyConceptIdsBySession.clear(); @@ -36282,6 +36620,21 @@ export function __testOnly_resetChatRequestState() { renderChatTaskQueuePanel(); updateSendButtonForCurrentChatState(); } +export function __testOnly_refreshConversationRuntimeCostSnapshot(options = {}) { + return refreshConversationRuntimeCostSnapshot(options); +} +export function __testOnly_setConversationRuntimeCostLiveSummary(request, summary = null, options = {}) { + setConversationRuntimeCostLiveSummary(request, summary, options); +} +export function __testOnly_getConversationRuntimeCostSnapshot() { + return { + context: cloneConversationRuntimeCostValue(conversationRuntimeCostState.context), + baseline: cloneConversationRuntimeCostValue(conversationRuntimeCostState.baseline), + live: cloneConversationRuntimeCostValue(conversationRuntimeCostState.live), + loading: conversationRuntimeCostState.loading === true, + error: conversationRuntimeCostState.error || null, + }; +} export function __testOnly_shouldMaintainSharedConversationStreamForInputs(inputs = {}) { return shouldMaintainSharedConversationStreamForInputs(inputs); } diff --git a/src/frontend/web/von_interface/static/js/domUtils.js b/src/frontend/web/von_interface/static/js/domUtils.js index 2459dbaeb..4346d8a19 100644 --- a/src/frontend/web/von_interface/static/js/domUtils.js +++ b/src/frontend/web/von_interface/static/js/domUtils.js @@ -2,6 +2,7 @@ import { openSettingsTabAndFocus } from './utils/settingsNavigation.js'; import { parseStoredContextValue } from './utils/runtimeIdentityBootstrap.js'; import { applyLocalModelPreferenceOverlay, getEffectiveLocalModelPreference } from './utils/localModelPreferences.js'; import { getWindowSessionId, WINDOW_SESSION_HEADER } from './apiService.js'; +import { formatConversationRuntimeCostFooter } from './utils/conversationRuntimeCost.js'; import { getLatestWorkflowCapabilityIndexStatus, refreshWorkflowCapabilityIndexStatus, @@ -375,6 +376,112 @@ const FOOTER_DB_PROBE_STATS_KEY = 'von_footer_db_probe_stats_v1'; const FOOTER_DB_PROBE_SAMPLE_LIMIT = 32; const FOOTER_DB_RETRY_MIN_MS = 1500; const FOOTER_DB_RETRY_MAX_MS = 20000; + +function getConversationRuntimeCostSnapshot() { + try { + return window.__vonConversationRuntimeCostSnapshot || null; + } catch (_) { + return null; + } +} + +function applyConversationRuntimeCostFooterSnapshot(button, snapshot = getConversationRuntimeCostSnapshot()) { + if (!button) return; + const presentation = formatConversationRuntimeCostFooter(snapshot); + const segment = button.closest('.conversation-runtime-cost-segment'); + if (segment) segment.hidden = presentation.visible !== true; + const desktop = button.querySelector('.conversation-runtime-cost-desktop'); + const mobile = button.querySelector('.conversation-runtime-cost-mobile'); + const details = button.parentElement?.querySelector('.conversation-runtime-cost-details'); + if (desktop) desktop.textContent = presentation.desktopText; + if (mobile) mobile.textContent = presentation.mobileText; + if (details) { + details.replaceChildren(...presentation.details.map((detail) => { + const line = document.createElement('div'); + line.textContent = detail; + return line; + })); + } + button.setAttribute('aria-label', presentation.ariaLabel); + setKeptNativeTitle(button, presentation.title); + button.dataset.costState = presentation.state; + button.dataset.costDetails = presentation.details.join('\n'); + if (presentation.visible !== true) { + button.setAttribute('aria-expanded', 'false'); + if (details) details.hidden = true; + } +} + +function makeConversationRuntimeCostFooterSegment() { + const segment = document.createElement('span'); + segment.className = 'footer-segment conversation-runtime-cost-segment'; + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'conversation-runtime-cost-button'; + button.setAttribute('aria-live', 'polite'); + button.setAttribute('aria-expanded', 'false'); + const desktop = document.createElement('span'); + desktop.className = 'conversation-runtime-cost-desktop'; + const mobile = document.createElement('span'); + mobile.className = 'conversation-runtime-cost-mobile'; + mobile.setAttribute('aria-hidden', 'true'); + button.append(desktop, mobile); + const details = document.createElement('div'); + details.className = 'conversation-runtime-cost-details'; + details.id = 'conversationRuntimeCostDetails'; + details.setAttribute('role', 'region'); + details.setAttribute('aria-label', 'Conversation cost estimate details'); + details.hidden = true; + button.setAttribute('aria-controls', details.id); + button.addEventListener('click', (event) => { + event.stopPropagation(); + if (segment.hidden) return; + const nextOpen = details.hidden; + document.querySelectorAll('.conversation-runtime-cost-details').forEach((panel) => { + panel.hidden = true; + const control = panel.parentElement?.querySelector('.conversation-runtime-cost-button'); + if (control) control.setAttribute('aria-expanded', 'false'); + }); + details.hidden = !nextOpen; + button.setAttribute('aria-expanded', nextOpen ? 'true' : 'false'); + }); + segment.append(button, details); + applyConversationRuntimeCostFooterSnapshot(button); + return segment; +} + +function refreshConversationRuntimeCostFooter(snapshot = getConversationRuntimeCostSnapshot()) { + try { + document.querySelectorAll('.conversation-runtime-cost-button').forEach((button) => { + applyConversationRuntimeCostFooterSnapshot(button, snapshot); + }); + } catch (_) { + // A telemetry repaint must not interfere with the ordinary footer lifecycle. + } +} + +if (typeof document !== 'undefined' && !document.__vonConversationRuntimeCostFooterListenerBound) { + document.__vonConversationRuntimeCostFooterListenerBound = true; + document.addEventListener('von:conversationRuntimeCostUpdated', (event) => { + refreshConversationRuntimeCostFooter(event?.detail || null); + }); + document.addEventListener('click', (event) => { + if (event.target?.closest?.('.conversation-runtime-cost-segment')) return; + document.querySelectorAll('.conversation-runtime-cost-details').forEach((panel) => { + panel.hidden = true; + const control = panel.parentElement?.querySelector('.conversation-runtime-cost-button'); + if (control) control.setAttribute('aria-expanded', 'false'); + }); + }); + document.addEventListener('keydown', (event) => { + if (event.key !== 'Escape') return; + document.querySelectorAll('.conversation-runtime-cost-details').forEach((panel) => { + panel.hidden = true; + const control = panel.parentElement?.querySelector('.conversation-runtime-cost-button'); + if (control) control.setAttribute('aria-expanded', 'false'); + }); + }); +} const FOOTER_WORKFLOW_CAPABILITY_RETRY_MS = 60000; function normaliseFooterServerReachability(value) { @@ -1466,6 +1573,9 @@ export async function setModelInfoFooterText() { ); if (llmClass) modelSettingsSegment.classList.add('llm-status-badge', llmClass); segments.push(modelSettingsSegment); + // This is deliberately a lightweight, independently repaintable telemetry + // segment: cost updates must not retrigger settings, DB, or auth footer loads. + segments.push(makeConversationRuntimeCostFooterSegment()); } // User segment diff --git a/src/frontend/web/von_interface/static/js/main.js b/src/frontend/web/von_interface/static/js/main.js index dab8ff7e9..23506985d 100644 --- a/src/frontend/web/von_interface/static/js/main.js +++ b/src/frontend/web/von_interface/static/js/main.js @@ -1164,6 +1164,16 @@ function startHealthPolling() { const newPid = (typeof data.pid !== 'undefined') ? data.pid : null; lastHealthSuccessPid = Number.isFinite(Number(newPid)) ? Number(newPid) : null; const newStart = data.start_time || null; + try { + document.dispatchEvent(new CustomEvent('von:runtimeIdentityUpdated', { + detail: { + pid: lastHealthSuccessPid, + start_time: typeof newStart === 'string' && newStart.trim() ? newStart.trim() : null + } + })); + } catch (_) { + // Runtime identity is a support signal; health polling remains authoritative. + } const newLocalIp = data.local_ip || null; const newPublicIp = data.public_ip || null; const ragPending = (typeof data.rag_pending_count !== 'undefined') ? data.rag_pending_count : null; diff --git a/src/frontend/web/von_interface/static/js/test/chatTab.test.js b/src/frontend/web/von_interface/static/js/test/chatTab.test.js index 64a31c7a7..3c255b094 100644 --- a/src/frontend/web/von_interface/static/js/test/chatTab.test.js +++ b/src/frontend/web/von_interface/static/js/test/chatTab.test.js @@ -84,6 +84,9 @@ import { __testOnly_copyConversationInfoToClipboard, __testOnly_clearLlmDebugData, __testOnly_resetChatRequestState, + __testOnly_refreshConversationRuntimeCostSnapshot, + __testOnly_setConversationRuntimeCostLiveSummary, + __testOnly_getConversationRuntimeCostSnapshot, __testOnly_setSessionTabsCache, __testOnly_setTranscriptTurns, setLlmDebugDataForTurn, @@ -106,6 +109,95 @@ jest.mock('../apiService.js', () => { WINDOW_SESSION_HEADER: 'X-Window-Session-ID' }; }); + +describe('conversation runtime cost persistence handover', () => { + const storedSummary = (amount) => ({ + schema_version: 'llm_usage_cost_summary.v1', + unique_call_count: 1, + usage: { status: 'reported' }, + estimated_cost: { + status: 'estimated', + amount, + known_amount: amount, + currency: 'USD' + } + }); + + const snapshotPayload = (amount, observed) => ({ + schema_version: 'conversation_runtime_cost_snapshot.v1', + scope: { conversation_session_id: 'cost-session' }, + runtime: { pid: 42, started_at_utc: '2026-08-11T10:00:00Z' }, + conversation: storedSummary(amount), + since_restart: storedSummary(amount), + handover: { request_id: 'cost-request', observed } + }); + + const deferred = () => { + let resolve; + const promise = new Promise((settle) => { resolve = settle; }); + return { promise, resolve }; + }; + + test('ignores a late excluded baseline after the durable handover response', async () => { + const hadFetch = Object.prototype.hasOwnProperty.call(global, 'fetch'); + const originalFetch = global.fetch; + global.fetch = undefined; + __testOnly_setActiveChatSession('cost-session', 'Cost session'); + __testOnly_setConversationRuntimeCostLiveSummary( + { sessionId: 'cost-session', clientRequestId: 'cost-request' }, + storedSummary(0.05), + { active: false, reason: 'test_terminal_summary' } + ); + + const excluded = deferred(); + const handover = deferred(); + global.fetch = jest.fn() + .mockImplementationOnce(() => excluded.promise) + .mockImplementationOnce(() => handover.promise); + + const excludedRefresh = __testOnly_refreshConversationRuntimeCostSnapshot({ + excludeRequestId: 'cost-request', + reason: 'test_active_baseline' + }); + const handoverRefresh = __testOnly_refreshConversationRuntimeCostSnapshot({ + observeRequestId: 'cost-request', + reason: 'test_terminal_handover' + }); + + handover.resolve({ + ok: true, + json: async () => snapshotPayload(0.20, true) + }); + await handoverRefresh; + expect(__testOnly_getConversationRuntimeCostSnapshot()).toEqual( + expect.objectContaining({ + baseline: expect.objectContaining({ + conversation: expect.objectContaining({ + estimated_cost: expect.objectContaining({ amount: 0.20 }) + }) + }), + live: null, + loading: false, + error: null + }) + ); + + excluded.resolve({ + ok: true, + json: async () => snapshotPayload(0.15, false) + }); + await excludedRefresh; + const finalSnapshot = __testOnly_getConversationRuntimeCostSnapshot(); + expect(finalSnapshot.baseline.conversation.estimated_cost.amount).toBe(0.20); + expect(finalSnapshot.live).toBeNull(); + expect(global.fetch.mock.calls[0][0]).toContain('exclude_request_id=cost-request'); + expect(global.fetch.mock.calls[1][0]).toContain('observe_request_id=cost-request'); + + __testOnly_resetChatRequestState(); + if (hadFetch) global.fetch = originalFetch; + else delete global.fetch; + }); +}); jest.mock('../domUtils.js', () => ({ elements: {}, getCurrentUserConceptId: jest.fn(), diff --git a/src/frontend/web/von_interface/static/js/test/conversationRuntimeCost.test.js b/src/frontend/web/von_interface/static/js/test/conversationRuntimeCost.test.js new file mode 100644 index 000000000..2595fe390 --- /dev/null +++ b/src/frontend/web/von_interface/static/js/test/conversationRuntimeCost.test.js @@ -0,0 +1,113 @@ +import { formatConversationRuntimeCostFooter } from '../utils/conversationRuntimeCost.js'; + +function summary(status, amount, currency = 'USD', extras = {}) { + return { + estimated_cost: { + status, + ...(status === 'estimated' ? { amount } : { known_amount: amount }), + ...(currency ? { currency } : {}) + }, + unique_call_count: 1, + usage: { status: 'complete' }, + ...extras, + }; +} + +function snapshot({ conversation, sinceRestart, live = null, loading = false, error = null } = {}) { + return { + context: { conversation_session_id: 'session-1' }, + baseline: conversation || sinceRestart ? { + conversation, + since_restart: sinceRestart, + } : null, + live, + loading, + error, + }; +} + +describe('conversation runtime cost footer presentation', () => { + test('formats complete chat and since-restart estimates as USD amounts', () => { + const result = formatConversationRuntimeCostFooter(snapshot({ + conversation: summary('estimated', 0.013472), + sinceRestart: summary('estimated', 0.0428), + })); + + expect(result.visible).toBe(true); + expect(result.desktopText).toContain('Est. cost: Chat US$0.013472'); + expect(result.desktopText).toContain('Since restart US$0.0428'); + expect(result.mobileText).toContain('Chat US$0.013472'); + expect(result.mobileText).toContain('Run US$0.0428'); + }); + + test('keeps a priced baseline as a partial known subtotal when the active summary is unavailable', () => { + const result = formatConversationRuntimeCostFooter(snapshot({ + conversation: summary('estimated', 0.01), + sinceRestart: summary('estimated', 0.02), + live: { request_id: 'req-1', active: true, summary: summary('unavailable', null, null) }, + })); + + expect(result.desktopText).toContain('Chat known US$0.01 (partial)'); + expect(result.desktopText).toContain('Since restart known US$0.02 (partial)'); + expect(result.desktopText).not.toContain('US$0.0000'); + }); + + test('keeps a failed refresh baseline as a known subtotal rather than a current exact total', () => { + const result = formatConversationRuntimeCostFooter(snapshot({ + conversation: summary('estimated', 0.01), + sinceRestart: summary('estimated', 0.02), + error: 'HTTP 503', + })); + + expect(result.desktopText).toContain('Chat known US$0.01 (partial)'); + expect(result.desktopText).toContain('Since restart known US$0.02 (partial)'); + expect(result.details).toContain('Latest refresh unavailable; showing retained evidence as a known subtotal.'); + }); + + test('does not add mismatched currencies and reports the estimate as unavailable', () => { + const result = formatConversationRuntimeCostFooter(snapshot({ + conversation: summary('estimated', 0.01, 'USD'), + sinceRestart: summary('estimated', 0.02, 'USD'), + live: { request_id: 'req-1', active: false, summary: summary('estimated', 0.003, 'EUR') }, + })); + + expect(result.desktopText).toContain('Chat estimate unavailable'); + expect(result.desktopText).toContain('Since restart estimate unavailable'); + }); + + test('treats a live-only priced result as a partial subtotal until the baseline arrives', () => { + const result = formatConversationRuntimeCostFooter(snapshot({ + live: { request_id: 'req-1', active: false, summary: summary('estimated', 0.003) }, + error: 'Conversation cost summary temporarily unavailable', + })); + + expect(result.desktopText).toContain('Chat known US$0.0030 (partial)'); + expect(result.desktopText).toContain('Since restart known US$0.0030 (partial)'); + expect(result.desktopText).not.toContain('Chat US$0.0030 ·'); + }); + + test('shows calculating only for a scoped selected conversation and hides before any cost scope exists', () => { + const calculating = formatConversationRuntimeCostFooter(snapshot({ loading: true })); + expect(calculating.visible).toBe(true); + expect(calculating.desktopText).toBe('Est. cost: calculating…'); + + const hidden = formatConversationRuntimeCostFooter({ + context: { conversation_session_id: null }, + baseline: null, + loading: false, + live: null, + }); + expect(hidden.visible).toBe(false); + }); + + test('represents non-billable calls without treating missing usage as zero', () => { + const result = formatConversationRuntimeCostFooter(snapshot({ + conversation: summary('not_applicable', null), + sinceRestart: summary('not_applicable', null), + })); + + expect(result.desktopText).toContain('Chat No billable model calls'); + expect(result.desktopText).toContain('Since restart No billable model calls'); + expect(result.desktopText).not.toContain('US$0'); + }); +}); diff --git a/src/frontend/web/von_interface/static/js/utils/conversationRuntimeCost.js b/src/frontend/web/von_interface/static/js/utils/conversationRuntimeCost.js new file mode 100644 index 000000000..8af9341d2 --- /dev/null +++ b/src/frontend/web/von_interface/static/js/utils/conversationRuntimeCost.js @@ -0,0 +1,208 @@ +function normaliseString(value) { + return typeof value === 'string' && value.trim() ? value.trim() : ''; +} + +function nonNegativeNumber(value) { + if (value === null || value === undefined || value === '') return null; + const numeric = Number(value); + return Number.isFinite(numeric) && numeric >= 0 ? numeric : null; +} + +function normaliseCost(cost) { + const source = cost && typeof cost === 'object' ? cost : {}; + const status = normaliseString(source.status).toLowerCase() || 'unavailable'; + const currency = normaliseString(source.currency).toUpperCase() || null; + const amount = status === 'estimated' + ? nonNegativeNumber(source.amount) + : nonNegativeNumber(source.known_amount); + return { status, currency, amount }; +} + +function costForSummary(summary) { + if (!summary || typeof summary !== 'object') return null; + return normaliseCost(summary?.estimated_cost); +} + +function callCount(summary) { + return nonNegativeNumber(summary?.unique_call_count) ?? nonNegativeNumber(summary?.call_count); +} + +function mergeCost(base, overlay) { + const values = [base, overlay].filter(Boolean); + if (values.length === 0) return { state: 'unavailable', currency: null }; + const known = values.filter((value) => value.amount !== null); + if (known.some((value) => !value.currency)) { + return { state: 'unavailable', reason: 'currency_unavailable', currency: null }; + } + const currencies = new Set(known.map((value) => value.currency)); + if (currencies.size > 1) { + return { state: 'unavailable', reason: 'currency_mismatch' }; + } + const currency = known.find((value) => value?.currency)?.currency || null; + const hasUnavailable = values.some((value) => value.status === 'unavailable'); + const hasPartial = values.some((value) => value.status === 'partial'); + const hasEstimated = values.some((value) => value.status === 'estimated'); + const hasBillableEvidence = values.some((value) => value.status !== 'not_applicable'); + if (known.length === 0) { + if (!hasBillableEvidence) return { state: 'not_applicable', currency: null }; + return { state: hasPartial ? 'partial' : 'unavailable', currency }; + } + const amount = known.reduce((total, value) => total + value.amount, 0); + if (hasUnavailable || hasPartial) return { state: 'partial', currency, amount }; + if (hasEstimated) return { state: 'estimated', currency, amount }; + return { state: 'not_applicable', currency }; +} + +function formatAmount(amount, currency) { + if (amount === null) return ''; + try { + return new Intl.NumberFormat('en-NZ', { + style: 'currency', + currency, + minimumFractionDigits: amount > 0 && amount < 0.01 ? 4 : 2, + maximumFractionDigits: 6, + }).format(amount); + } catch (_) { + return `${currency === 'USD' ? 'US$' : `${currency} `}${amount.toFixed(6)}`; + } +} + +function describeCost(cost, { compact = false } = {}) { + if (cost.state === 'estimated') return formatAmount(cost.amount, cost.currency); + if (cost.state === 'partial') { + return cost.amount === undefined ? 'partial' : `known ${formatAmount(cost.amount, cost.currency)} (partial)`; + } + if (cost.state === 'not_applicable') return compact ? 'no billable calls' : 'No billable model calls'; + return compact ? 'unavailable' : 'estimate unavailable'; +} + +function summaryDetailLines(label, summary, mergedCost) { + const usage = summary?.usage && typeof summary.usage === 'object' ? summary.usage : {}; + const lines = [ + `${label}: ${describeCost(mergedCost)}`, + ]; + const calls = callCount(summary); + if (calls !== null) lines.push(`${label} model calls: ${calls.toLocaleString('en-NZ')}`); + const estimatedCost = summary?.estimated_cost && typeof summary.estimated_cost === 'object' + ? summary.estimated_cost + : {}; + const pricedCalls = nonNegativeNumber(estimatedCost.priced_call_count); + const partialCalls = nonNegativeNumber(estimatedCost.partial_call_count); + const unpricedCalls = nonNegativeNumber(estimatedCost.unpriced_call_count); + if (pricedCalls !== null || partialCalls !== null || unpricedCalls !== null) { + lines.push(`${label} priced/partial/unpriced calls: ${pricedCalls ?? 0}/${partialCalls ?? 0}/${unpricedCalls ?? 0}`); + } + if (normaliseString(usage.status)) lines.push(`${label} token coverage: ${usage.status}`); + const coverage = (summary?.runtime_coverage && typeof summary.runtime_coverage === 'object') + ? summary.runtime_coverage + : (summary?.coverage && typeof summary.coverage === 'object' ? summary.coverage : {}); + if (normaliseString(coverage.status)) lines.push(`${label} cost coverage: ${coverage.status}`); + const pricing = summary?.estimated_cost?.pricing && typeof summary.estimated_cost.pricing === 'object' + ? summary.estimated_cost.pricing + : {}; + if (normaliseString(pricing.version)) lines.push(`Pricing version: ${pricing.version}`); + if (normaliseString(pricing.effective_at_utc)) lines.push(`Pricing effective date: ${pricing.effective_at_utc}`); + const pricingVersions = Array.isArray(summary?.estimated_cost?.pricing_versions) + ? summary.estimated_cost.pricing_versions.map(normaliseString).filter(Boolean) + : []; + if (!normaliseString(pricing.version) && pricingVersions.length > 0) { + lines.push(`Pricing versions: ${pricingVersions.join(', ')}`); + } + return lines; +} + +/** + * Convert the actor-scoped cost snapshot emitted by chatTab into presentation + * strings. Missing evidence stays unavailable; it never becomes a zero cost. + */ +export function formatConversationRuntimeCostFooter(snapshot) { + const source = snapshot && typeof snapshot === 'object' ? snapshot : {}; + const context = source.context && typeof source.context === 'object' ? source.context : null; + const baseline = source.baseline && typeof source.baseline === 'object' ? source.baseline : null; + const live = source.live && typeof source.live === 'object' ? source.live : null; + const isLoading = source.loading === true; + const conversationBase = baseline?.conversation || null; + const restartBase = baseline?.since_restart || null; + const liveSummary = live?.summary && typeof live.summary === 'object' ? live.summary : null; + const overlay = liveSummary ? costForSummary(liveSummary) : null; + let chatCost = mergeCost(costForSummary(conversationBase), overlay); + let restartCost = mergeCost(costForSummary(restartBase), overlay); + const liveCalculating = live?.active === true && !liveSummary; + + // The live request may have a priced result before the persisted aggregate + // arrives. It proves only a subtotal: earlier conversation/run calls are + // still unknown, so never present it as the complete total. + if (!baseline && liveSummary) { + if (chatCost.state === 'estimated') chatCost = { ...chatCost, state: 'partial' }; + if (restartCost.state === 'estimated') restartCost = { ...restartCost, state: 'partial' }; + } + + // A retained baseline is useful during a transient refresh failure, but it + // is no longer proof of the current total. Keep its known subtotal visible + // while degrading complete/no-call claims rather than silently showing stale + // evidence as current. + if (baseline && source.error) { + chatCost = chatCost.state === 'estimated' + ? { ...chatCost, state: 'partial' } + : (chatCost.state === 'not_applicable' ? { state: 'unavailable', currency: null } : chatCost); + restartCost = restartCost.state === 'estimated' + ? { ...restartCost, state: 'partial' } + : (restartCost.state === 'not_applicable' ? { state: 'unavailable', currency: null } : restartCost); + } + + if (!context?.conversation_session_id) { + return { + visible: false, + state: 'unavailable', + desktopText: '', + mobileText: '', + ariaLabel: '', + title: '', + details: [], + }; + } + + if (!baseline && !isLoading && !live && !source.error) { + return { + visible: false, + state: 'unavailable', + desktopText: '', + mobileText: '', + ariaLabel: '', + title: '', + details: [], + }; + } + + if (!baseline && (isLoading || liveCalculating)) { + return { + visible: true, + state: 'calculating', + desktopText: 'Est. cost: calculating…', + mobileText: 'Cost calculating…', + ariaLabel: 'Estimated conversation cost is calculating.', + title: 'Estimated cost is calculating from provider-reported usage. It is not a provider invoice.', + details: [], + }; + } + + const desktopText = `Est. cost: Chat ${describeCost(chatCost)} · Since restart ${describeCost(restartCost)}`; + const mobileText = `Chat ${describeCost(chatCost, { compact: true })} · Run ${describeCost(restartCost, { compact: true })}`; + const details = [ + ...summaryDetailLines('Chat', conversationBase, chatCost), + ...summaryDetailLines('Since restart', restartBase, restartCost), + ]; + if (live?.request_id) details.push(`Current request: ${live.request_id}`); + if (source.runtime?.started_at_utc) details.push(`Runtime started: ${source.runtime.started_at_utc}`); + if (source.error) details.push('Latest refresh unavailable; showing retained evidence as a known subtotal.'); + details.push('Estimate from provider-reported usage; not a provider invoice.'); + return { + visible: true, + state: chatCost.state === 'estimated' && restartCost.state === 'estimated' ? 'estimated' : chatCost.state, + desktopText, + mobileText, + ariaLabel: `${desktopText}. ${details.join('. ')}`, + title: details.join('\n'), + details, + }; +} diff --git a/src/frontend/web/von_interface/static/styles.css b/src/frontend/web/von_interface/static/styles.css index 80f41494f..ce7adc4f1 100644 --- a/src/frontend/web/von_interface/static/styles.css +++ b/src/frontend/web/von_interface/static/styles.css @@ -6035,6 +6035,71 @@ footer { box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.4); } +/* Informational only: conversation cost changes arrive through a dedicated + * telemetry event and do not reload the rest of the footer. */ +.conversation-runtime-cost-button { + appearance: none; + border: 1px solid #cbd5e1; + border-radius: 12px; + background: #f8fafc; + color: #475569; + cursor: pointer; + font-size: 0.7rem; + line-height: 1.3; + padding: 2px 9px; + white-space: nowrap; +} + +.conversation-runtime-cost-button:focus-visible { + outline: none; + box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.4); +} + +.conversation-runtime-cost-mobile { + display: none; +} + +.conversation-runtime-cost-segment { + position: relative; +} + +.conversation-runtime-cost-details { + position: absolute; + z-index: 110; + left: 0; + bottom: calc(100% + 6px); + box-sizing: border-box; + width: min(360px, calc(100vw - 24px)); + padding: 8px 10px; + border: 1px solid #cbd5e1; + border-radius: 8px; + background: #ffffff; + box-shadow: 0 6px 18px rgba(15, 23, 42, 0.18); + color: #334155; + font-size: 0.75rem; + line-height: 1.45; +} + +.footer-separator:has(+ .conversation-runtime-cost-segment[hidden]), +.conversation-runtime-cost-segment[hidden] + .footer-separator { + display: none; +} + +@media (max-width: 760px) { + .conversation-runtime-cost-desktop { + display: none; + } + + .conversation-runtime-cost-mobile { + display: inline; + } + + .conversation-runtime-cost-details { + left: auto; + right: 0; + } +} + /* Settings iframe adjustments */ .settings-frame-wrapper { position: relative; diff --git a/tests/backend/test_conversation_runtime_cost_service.py b/tests/backend/test_conversation_runtime_cost_service.py new file mode 100644 index 000000000..431a168f9 --- /dev/null +++ b/tests/backend/test_conversation_runtime_cost_service.py @@ -0,0 +1,352 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +from flask import Flask + + +def _registry() -> dict: + return { + "models": [ + { + "provider": "openai", + "model_id": "gpt-test", + "pricing": { + "schema_version": "llm_model_pricing.v1", + "version": "test-v1", + "source": "test", + "effective_at_utc": "2026-08-01T00:00:00Z", + "model_id": "gpt-test", + "currency": "USD", + "unit_tokens": 1_000, + "rates": {"input_tokens": 1.0, "output_tokens": 2.0}, + }, + } + ] + } + + +def _call(call_id: str, **overrides) -> dict: + call = { + "call_id": call_id, + "provider": "openai", + "effective_model": "gpt-test", + "model_identity_source": "provider_response", + "provider_request_sent": True, + "usage": {"input_tokens": 100, "output_tokens": 10}, + "completed_at_utc": "2026-08-10T12:00:00Z", + } + call.update(overrides) + return call + + +def test_pure_snapshot_deduplicates_session_calls_and_marks_missing_runtime_timestamps(): + from src.backend.services.conversation_runtime_cost_service import ( + build_conversation_runtime_cost_snapshot, + ) + + snapshot = build_conversation_runtime_cost_snapshot( + conversation_records=[ + {"request_id": "owner-turn", "llm_calls": [_call("owner-call")]}, + { + "request_id": "invitee-turn", + "llm_calls": [_call("owner-call"), _call("invitee-call")], + }, + ], + actor_runtime_records=[ + {"request_id": "owner-turn", "llm_calls": [_call("owner-call")]}, + { + "request_id": "missing-time", + "llm_calls": [_call("missing-time-call", completed_at_utc=None)], + }, + ], + conversation_session_id="shared-session", + actor_concept_id="#V#owner", + namespace="#V#owner@org", + server_started_at=datetime(2026, 8, 10, 11, tzinfo=UTC), + server_started_at_utc="2026-08-10T11:00:00Z", + pid=42, + model_registry=_registry(), + as_of=datetime(2026, 8, 10, 12, 1, tzinfo=UTC), + ) + + assert snapshot["schema_version"] == "conversation_runtime_cost_snapshot.v1" + assert snapshot["conversation"]["unique_call_count"] == 2 + assert snapshot["conversation"]["duplicate_call_count"] == 1 + assert snapshot["since_restart"]["estimated_cost"]["status"] == "partial" + assert snapshot["since_restart"]["estimated_cost"]["amount"] is None + assert snapshot["since_restart"]["estimated_cost"]["known_amount"] == 0.12 + assert snapshot["since_restart"]["runtime_coverage"] == { + "status": "partial", + "timestamp_eligible_call_count": 1, + "missing_timestamp_call_count": 1, + } + + +def test_non_billable_call_without_timestamp_does_not_degrade_runtime_cost_coverage(): + from src.backend.services.conversation_runtime_cost_service import ( + build_conversation_runtime_cost_snapshot, + ) + + non_billable_call = _call( + "local-call", + provider="Ollama", + provider_request_sent=True, + completed_at_utc=None, + ) + snapshot = build_conversation_runtime_cost_snapshot( + conversation_records=[{"llm_calls": [non_billable_call]}], + actor_runtime_records=[{"llm_calls": [non_billable_call]}], + conversation_session_id="local-session", + actor_concept_id="#V#owner", + namespace="#V#owner@org", + server_started_at=datetime(2026, 8, 10, 11, tzinfo=UTC), + server_started_at_utc="2026-08-10T11:00:00Z", + pid=42, + model_registry=_registry(), + ) + + assert snapshot["since_restart"]["estimated_cost"]["status"] == "not_applicable" + assert snapshot["since_restart"]["runtime_coverage"] == { + "status": "complete", + "timestamp_eligible_call_count": 0, + "missing_timestamp_call_count": 0, + } + + +def test_runtime_snapshot_excludes_only_a_request_bound_to_the_selected_session(monkeypatch): + from src.backend.services import conversation_runtime_cost_service as service + + projection = service.turn_execution_cost_projection() + assert "llm_calls" not in projection + assert projection["llm_calls.usage"] == 1 + assert "llm_calls.prompt" not in projection + + records = [ + { + "request_id": "active-request", + "session_id": "selected-session", + "user_id": "#V#owner", + "namespace": "#V#owner@org", + "created_at_utc": "2026-08-10T12:00:00Z", + "llm_calls": [_call("active-call")], + }, + { + "request_id": "invitee-request", + "session_id": "selected-session", + "user_id": "#V#invitee", + "namespace": "#V#invitee@org", + "created_at_utc": "2026-08-10T12:00:00Z", + "llm_calls": [_call("invitee-call")], + }, + { + "request_id": "recovered-request", + "session_id": "other-session", + "user_id": "#V#owner", + "namespace": "#V#owner@org", + "created_at_utc": "2026-08-10T10:00:00Z", + "updated_at_utc": "2026-08-10T12:00:00Z", + "llm_calls": [_call("recovered-call")], + }, + ] + + queries: list[dict] = [] + + class Collection: + def find_one(self, query, projection): + record = next( + ( + record + for record in records + if all(record.get(key) == value for key, value in query.items()) + ), + None, + ) + if record is None: + return None + return { + key: value + for key, value in record.items() + if projection.get(key) == 1 + } + + def find(self, query, _projection): + queries.append(query) + + def matches(record): + for key, value in query.items(): + if key == "$or": + if not any( + record.get(field, "") >= clause["$gte"] + for alternative in value + for field, clause in alternative.items() + ): + return False + elif isinstance(value, dict) and "$ne" in value: + if record.get(key) == value["$ne"]: + return False + elif record.get(key) != value: + return False + return True + + return [record for record in records if matches(record)] + + monkeypatch.setattr(service, "get_turn_execution_records_collection", Collection) + snapshot = service.get_conversation_runtime_cost_snapshot( + conversation_session_id="selected-session", + actor_concept_id="#V#owner", + actor_namespace="#V#owner@org", + server_started_at=datetime(2026, 8, 10, 11, tzinfo=UTC), + server_started_at_utc="2026-08-10T11:00:00Z", + pid=42, + model_registry=_registry(), + exclude_request_id="active-request", + ) + + assert snapshot["conversation"]["unique_call_count"] == 1 + assert snapshot["since_restart"]["unique_call_count"] == 1 + assert { + "session_id": "selected-session", + "request_id": {"$ne": "active-request"}, + } in queries + assert { + "user_id": "#V#owner", + "namespace": "#V#owner@org", + "$or": [ + {"created_at_utc": {"$gte": "2026-08-10T11:00:00Z"}}, + {"updated_at_utc": {"$gte": "2026-08-10T11:00:00Z"}}, + ], + "request_id": {"$ne": "active-request"}, + } in queries + + not_yet_persisted = service.get_conversation_runtime_cost_snapshot( + conversation_session_id="selected-session", + actor_concept_id="#V#owner", + actor_namespace="#V#owner@org", + server_started_at=datetime(2026, 8, 10, 11, tzinfo=UTC), + server_started_at_utc="2026-08-10T11:00:00Z", + pid=42, + model_registry=_registry(), + exclude_request_id="not-yet-persisted", + observe_request_id="not-yet-persisted", + ) + assert not_yet_persisted["handover"] == { + "request_id": "not-yet-persisted", + "observed": False, + } + observed = service.get_conversation_runtime_cost_snapshot( + conversation_session_id="selected-session", + actor_concept_id="#V#owner", + actor_namespace="#V#owner@org", + server_started_at=datetime(2026, 8, 10, 11, tzinfo=UTC), + server_started_at_utc="2026-08-10T11:00:00Z", + pid=42, + model_registry=_registry(), + observe_request_id="active-request", + ) + assert observed["handover"] == { + "request_id": "active-request", + "observed": True, + } + + try: + service.get_conversation_runtime_cost_snapshot( + conversation_session_id="selected-session", + actor_concept_id="#V#owner", + actor_namespace="#V#owner@org", + server_started_at=datetime(2026, 8, 10, 11, tzinfo=UTC), + server_started_at_utc="2026-08-10T11:00:00Z", + pid=42, + model_registry=_registry(), + exclude_request_id="invitee-request", + ) + except ValueError as exc: + assert str(exc) == "exclude_request_id_not_owned_by_actor" + else: + raise AssertionError("another actor's request must not be excluded") + + +def test_cost_route_uses_trusted_scope_and_requires_conversation_access(monkeypatch): + from src.backend.server.routes import von_routes + + captured: dict = {} + monkeypatch.setattr( + "src.backend.security.access_control.get_effective_user_concept_id", + lambda: "#V#actor", + ) + monkeypatch.setattr( + von_routes, + "get_effective_context", + lambda *_args, **_kwargs: {"namespace": "#V#actor@trusted_org"}, + ) + monkeypatch.setattr( + von_routes, + "_resolve_shared_conversation_owner", + lambda **_kwargs: ("#V#actor", None), + ) + monkeypatch.setattr( + von_routes.chat_history_service, + "has_chat_history_session", + lambda *_args, **_kwargs: True, + ) + monkeypatch.setattr(von_routes, "get_model_registry_snapshot", _registry) + monkeypatch.setattr( + von_routes, + "get_conversation_runtime_cost_snapshot", + lambda **kwargs: captured.update(kwargs) + or {"schema_version": "conversation_runtime_cost_snapshot.v1"}, + ) + + app = Flask(__name__) + app.secret_key = "test" + app.config["SERVER_START_TIME"] = "2026-08-10T11:00:00Z" + app.register_blueprint(von_routes.von_bp, url_prefix="/von") + response = app.test_client().get( + "/von/history/cost_summary", + query_string={ + "session_id": "allowed-session", + "namespace": "#V#attacker@other_org", + "organisation_concept_id": "#V#other_org", + }, + ) + + assert response.status_code == 200 + assert captured["actor_concept_id"] == "#V#actor" + assert captured["actor_namespace"] == "#V#actor@trusted_org" + + monkeypatch.setattr( + von_routes.chat_history_service, + "has_chat_history_session", + lambda *_args, **_kwargs: False, + ) + denied = app.test_client().get( + "/von/history/cost_summary", query_string={"session_id": "forbidden"} + ) + assert denied.status_code == 403 + + +def test_runtime_cost_actor_time_indexes_are_created(monkeypatch): + from src.backend.services import turn_execution_record_service as records + + created: list[tuple[list[tuple[str, int]], str]] = [] + + class Collection: + def list_indexes(self): + return [] + + def create_index(self, keys, *, name, **_kwargs): + created.append((keys, name)) + + monkeypatch.setattr(records, "_TURN_EXECUTION_INDEXES_READY", False) + records._ensure_turn_execution_indexes(Collection()) + + assert ([ + ("user_id", records.ASCENDING), + ("namespace", records.ASCENDING), + ("created_at_utc", records.DESCENDING), + ], "user_namespace_created_desc") in created + assert ([ + ("user_id", records.ASCENDING), + ("namespace", records.ASCENDING), + ("updated_at_utc", records.DESCENDING), + ], "user_namespace_updated_desc") in created