Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/design_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ live.
| Reusing external ontology concepts with source identity and attribution | [KnowKat ontology sources and governed adoption](engineering/knowkat_ontology_sources.md) |
| Importing external human/agent transcripts into conversation carriers | [External conversation import](engineering/external_conversation_import.md) |
| Unified conversation discovery, message exchanges, read state or participant avatars | [Unified conversations and participant profiles](engineering/unified_conversations_and_participant_profiles.md) |
| Execution-summary cost display and user/organisation thresholds | [Execution cost display](engineering/execution_cost_display.md) |
| Durable assertions, text/logical assertion typing, propositions, context-sensitive retrieval, hypotheses, publication or promotion, or user-, organisation-, project-, source-, theory-, or time-relative knowledge | [Assertion and propositional-sentence ontology](engineering/assertion_and_propositional_sentence_ontology.md), then [contextual knowledge evolution](engineering/contextual_knowledge_evolution.md); for canonical ontology publication or scope change, the active [ontology publication authority boundary](engineering/ontology_publication_authority.md) |
| Evaluation, benchmarks, or research-sensitive architecture | [agent evaluation and research uptake](engineering/agent_evaluation_and_research_uptake.md) |
| Minimal imposition, elicitation, or write policy | [minimal-imposition principle](engineering/minimal_imposition_design_principle.md) |
Expand Down
89 changes: 89 additions & 0 deletions docs/engineering/execution_cost_display.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Execution summary cost preferences

**Kind:** implementation manual
**Lifecycle:** active
**Authority scope:** execution-summary presentation and its preference record
**Owner:** Von maintainers
**Last reviewed:** 2026-09-12
**Review trigger:** changes to cost telemetry, viewer scope or preference storage

The Thinking/execution summary shows an estimated USD cost immediately after
duration. Comparisons are strictly greater than: the default display threshold
is US$0.01 and the default high-cost threshold is US$0.10. High costs use red
text and the visible and accessible words “High cost”. This is a display
preference, not a spending limit or an authority grant.

## Represented configuration

`#V#hasExecutionCostDisplayPreferences` is a singleton JSON text relation in
`en-NZ`, attached directly to the relevant user concept for a personal override,
or to an organisation concept for that organisation's default:

```json
{"currency":"USD","display_above":"0.01","alert_above":"0.10"}
```

Both amounts must be non-negative decimal strings (up to 12 integer and 18
fractional digits); the alert threshold must be at least the display threshold.
The pair is atomic: do not combine one user's threshold with one organisation
threshold. Resolution is valid viewer/user record, then valid current
organisation record, then the server defaults above. Empty `{}`, missing,
ambiguous, inaccessible or invalid records inherit the next scope. No record
is created by a read. The authenticated viewer's server-bound window context
selects the organisation, including when viewing someone else's shared history;
neither the conversation owner's preferences nor query-string scope applies.

`GET /api/settings/execution_cost_display` returns the effective pair with
`source` equal to `user`, `organisation` or `default`. Responses are not cached.
`PUT` to the same endpoint replaces the authenticated user's record through
the existing governed singleton-text mutation service. Supply the JSON above,
or `{}` to restore inheritance. It cannot target another user or organisation.
Existing authorised canonical ontology tools can maintain the organisation's
same singleton text relation; their normal publication/organisation authority
applies. No new organisation administration surface is introduced.

The predicate is registered in the existing read-only virtual code-concept
registry as a binary text predicate. Canonical discovery and write validation
can therefore resolve it without a database bootstrap or migration. Preference
values themselves are persisted Vontology text relations, not code constants.
This delivery does not change live user/organisation records. Such represented
changes require canonical tools, read-back and the existing authority context;
direct database writes are not part of this work.

## Telemetry and rendering

The existing per-execution `llm_usage_cost_summary.estimated_cost` is the source,
for both live and retained historical Thinking cards. Newly calculated costs
carry `amount_decimal` and `known_amount_decimal` strings from Decimal arithmetic;
existing numeric fields remain for compatibility. Summary aggregation prefers
the exact strings. Browser comparisons use scaled integers before formatting.
Older numeric-only history uses the stored number's decimal spelling; lost
historical precision cannot be recovered. The UI preserves fractional precision
so a just-over-threshold amount does not appear equal to its threshold.

Loading preferences, missing/unknown cost, partial pricing, non-USD currency,
zero and costs at or below the display threshold leave a reserved slot blank.
They never show a misleading zero or a complete total based on partial pricing.
Delayed final telemetry is rendered on the normal Thinking-card update path.
Preference arrival updates already-mounted historical slots. Context changes
clear cached preferences, and stale responses cannot update a newer context.
Active updates refresh preferences at most once per minute; reloading the page
also refreshes settings. No currency conversion or additional model call occurs.

The slot has a fixed preferred width, remains within the responsive header, and
retains its height during finalisation. Exceptionally long amounts may be
ellipsised; their complete estimate and alert are available in the tooltip and
accessible label. Duration, completion state, activity, diagnostic controls and
the existing detailed cost breakdown retain their existing data sources.

## Validation and delivery boundaries

Targeted Python tests cover preference precedence, invalid/absent configuration,
actor-bound read/write, canonical service integration and exact cost strings.
Frontend tests cover both strict boundaries, historical numeric compatibility,
missing/loading/partial values, accessible high-cost signalling and late scope
responses. `tests/browser/executionCost.cjs` uses the actual chat template,
stylesheet and renderer with local fixture telemetry at desktop/mobile widths;
it checks layout stability, overflow and six cost states. It makes no model
requests and does not use live user data. This fixture evidence does not claim
authenticated live Vontology activation or public deployment.
38 changes: 38 additions & 0 deletions src/backend/server/routes/settings_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -958,6 +958,44 @@ def get_llm_info():
return jsonify({"error": "Failed to retrieve LLM info."}), 500


@settings_bp.route("/execution_cost_display", methods=["GET", "PUT"])
def execution_cost_display():
"""Read viewer-scoped thresholds or replace the authenticated user's pair."""
from ...services.execution_cost_display_service import (
PREDICATE, resolve_preferences, validate_preferences,
)

actor = get_effective_user_concept_id()
if not actor:
return _jsonify_no_store({"error": "authenticated_actor_context_required"}, 401)
effective = get_effective_context(
request.headers.get("X-Von-Window-Session"), dict(session), actor,
)
if request.method == "PUT":
import json
from ...services.ontology_mutation_command_service import execute_governed_ontology_method
from ...services.text_value_service import upsert_singleton_text_relation

try:
value = validate_preferences(request.get_json(silent=True))
except ValueError as exc:
return _jsonify_no_store({"error": str(exc)}, 400)
text = json.dumps(value, sort_keys=True)
provenance = {"source": "execution_cost_display_preferences", "actor_concept_id": actor}
result = execute_governed_ontology_method(
method_name="upsert_singleton_text_relation",
arguments={"concept_id": actor, "predicate": PREDICATE, "text": text,
"language": "en-NZ", "provenance": provenance},
mutate=lambda: upsert_singleton_text_relation(
subject_concept_id=actor, predicate=PREDICATE, text=text,
lang="en-NZ", provenance=provenance,
),
)
if result.get("success") is False:
return _jsonify_no_store(result, 503)
return _jsonify_no_store(resolve_preferences(actor, effective.get("organisation_id")))


@settings_bp.route("/llm/cost_summary", methods=["GET"])
def get_llm_cost_summary():
"""Return a bounded persisted cost summary for the current actor/model."""
Expand Down
48 changes: 48 additions & 0 deletions src/backend/services/execution_cost_display_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""Represented display preferences; no billing or spending authority."""

import json
import logging
import re
from decimal import Decimal

from .text_value_service import get_texts_for_concept

PREDICATE = "#V#hasExecutionCostDisplayPreferences"
DEFAULTS = {"currency": "USD", "display_above": "0.01", "alert_above": "0.10"}
logger = logging.getLogger(__name__)


def validate_preferences(value):
"""An atomic pair of non-negative USD decimal strings, or {} to inherit."""
if value == {}:
return {}
if not isinstance(value, dict) or set(value) != set(DEFAULTS):
raise ValueError("execution_cost_preferences_require_currency_and_both_thresholds")
if value["currency"] != "USD":
raise ValueError("execution_cost_preferences_require_USD")
for key in ("display_above", "alert_above"):
amount = value[key]
if not isinstance(amount, str) or not re.fullmatch(r"\d{1,12}(?:\.\d{1,18})?", amount):
raise ValueError("execution_cost_threshold_requires_non_negative_decimal_string")
if Decimal(value["alert_above"]) < Decimal(value["display_above"]):
raise ValueError("execution_cost_alert_must_not_precede_display")
return dict(value)


def resolve_preferences(user_concept_id, organisation_concept_id=None):
"""Trusted actor/current organisation only; invalid records inherit as a pair."""
for scope, subject in (("user", user_concept_id), ("organisation", organisation_concept_id)):
if not subject:
continue
try:
rows = get_texts_for_concept(subject, predicate=PREDICATE, limit=2)
if len(rows) != 1:
continue
value = validate_preferences(json.loads(rows[0]["text"]))
if value:
return {**value, "source": scope}
except (ValueError, TypeError, KeyError):
logger.warning("Invalid execution cost display preference; inheriting")
except Exception:
logger.warning("Execution cost display preference unavailable; inheriting")
return {**DEFAULTS, "source": "default"}
6 changes: 5 additions & 1 deletion src/backend/services/llm_usage_cost_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,8 @@ def _estimate_call_cost(
"status": status,
"amount": rendered_amount,
"known_amount": rendered_known_amount,
"amount_decimal": str(amount) if rendered_amount is not None else None,
"known_amount_decimal": str(known_amount) if rendered_known_amount is not None else None,
"currency": currency,
"reason": reason,
"pricing": metadata,
Expand Down Expand Up @@ -658,7 +660,7 @@ def _cost_summary(calls: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
}
currency = next(iter(currencies)) if len(currencies) == 1 else None
known_values = [
_decimal(cost.get("known_amount"))
_decimal(cost.get("known_amount_decimal") or cost.get("known_amount"))
for cost in costs
if currency and cost.get("currency") == currency
]
Expand Down Expand Up @@ -706,6 +708,8 @@ def _cost_summary(calls: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
"status": status,
"amount": _amount(known_amount) if status == "estimated" else None,
"known_amount": _amount(known_amount),
"amount_decimal": str(known_amount) if status == "estimated" else None,
"known_amount_decimal": str(known_amount) if known_amount is not None else None,
"currency": currency,
"priced_call_count": priced,
"partial_call_count": partial,
Expand Down
1 change: 1 addition & 0 deletions src/backend/vontology/code_concepts_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ def _unique_ids(values: Iterable[str]) -> list[str]:
"#V#has_constitutive_relation_requirements_json",
"#V#hasVonOrgRole",
"#V#hasVonLoginEmail",
"#V#hasExecutionCostDisplayPreferences",
]

_FILE_METADATA_PREDICATE_IDS = [
Expand Down
17 changes: 16 additions & 1 deletion src/frontend/web/von_interface/static/js/chatTab.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { initialiseCompactChatComposer, isCompactComposer, resizeCompactDraft, shouldSubmitComposerKey } from './components/compactComposer.js';
import { updateExecutionCost } from './components/executionCost.js';
import { canUseWorkflowStudio } from './workflowStudioAccess.js';
import { createChatSteeringControls } from './components/chatSteeringControls.js';
import { directConversationRows, activeMessageConversationId, showChatConversation, resetConversationCatalogue, renderMessageConversationRow, mountCatalogueControls, initialiseConversationCatalogue, selectMessageConversation, filterCatalogueRows } from './components/conversationCatalogue.js';
Expand Down Expand Up @@ -9282,6 +9283,8 @@ function updateThinkingCardMeta(request, progress, cardRoot = null) {
bits.push(formatThinkingDuration(elapsedMs));
}

const durationBitCount = bits.length;

// Tool count
const done = effectiveProgress && Number.isFinite(effectiveProgress.tool_calls_done)
? Number(effectiveProgress.tool_calls_done)
Expand Down Expand Up @@ -9318,7 +9321,6 @@ function updateThinkingCardMeta(request, progress, cardRoot = null) {
bits.push(lastActivityText);
}

metaEl.textContent = bits.length > 0 ? bits.join(' \u00b7 ') : '';
const lastActivityMs = resolveLastActivityTimestampMs(effectiveProgress);
if (Number.isFinite(lastActivityMs)) {
setUnambiguousTimestampTooltip(metaEl, new Date(lastActivityMs).toISOString(), 'Last activity: ');
Expand All @@ -9327,6 +9329,19 @@ function updateThinkingCardMeta(request, progress, cardRoot = null) {
metaEl.removeAttribute('aria-label');
metaEl.removeAttribute('data-original-title');
}
// Keep the timestamp tooltip but do not let its aria-label mask the cost.
metaEl.removeAttribute('aria-label');
const costSlot = metaEl.querySelector('.thinking-execution-cost') || document.createElement('span');
metaEl.replaceChildren(document.createTextNode(bits.slice(0, durationBitCount).join(' · ')));
metaEl.appendChild(costSlot);
const remaining = bits.slice(durationBitCount).join(' · ');
if (remaining) metaEl.appendChild(document.createTextNode(` · ${remaining}`));
updateExecutionCost(
costSlot,
resolveThinkingLlmUsageCostSummary(request)?.estimated_cost,
buildConversationRuntimeCostContext(),
buildChatFetchHeaders(),
);
}

function recordToolUseHistory(request, progress) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Decimal strings are compared before formatting. Never round to cents first.
function decimal(value) {
if (typeof value !== 'string' && typeof value !== 'number') return null;
const match = String(value).match(/^(\d{1,30})(?:\.(\d{1,30}))?(?:[eE]([+-]?\d{1,3}))?$/);
if (!match) return null;
const scale = (match[2] || '').length - Number(match[3] || 0);
if (Math.abs(scale) > 100) return null;
return { units: BigInt(match[1] + (match[2] || '')), scale };
}

function above(a, b) {
const scale = Math.max(a.scale, b.scale);
return a.units * (10n ** BigInt(scale - a.scale)) > b.units * (10n ** BigInt(scale - b.scale));
}

function amountText(amount) {
// Preserve sub-cent differences so an alert never appears to equal its threshold.
let digits = amount.units.toString();
if (amount.scale <= 0) return `${digits}${'0'.repeat(-amount.scale)}.00`;
digits = digits.padStart(amount.scale + 1, '0');
const whole = digits.slice(0, -amount.scale);
const fraction = digits.slice(-amount.scale).replace(/0+$/, '').padEnd(2, '0');
return `${whole}.${fraction}`;
}

export function executionCostPresentation(cost, preferences) {
if (!preferences || preferences.currency !== 'USD' || cost?.currency !== 'USD'
|| cost.status !== 'estimated') return null;
const amount = decimal(cost.amount_decimal ?? cost.amount);
const display = decimal(preferences.display_above);
const alert = decimal(preferences.alert_above);
if (!amount || !display || !alert || !above(amount, display)) return null;
const high = above(amount, alert);
return { high, text: `Estimated US$${amountText(amount)}${high ? ' · High cost' : ''}` };
}

export function renderExecutionCost(element, cost, preferences) {
element.className = 'thinking-execution-cost';
const presentation = executionCostPresentation(cost, preferences);
element.textContent = presentation ? ` · ${presentation.text}` : '';
element.title = presentation?.text || '';
if (presentation) element.setAttribute('aria-label', presentation.text);
else element.removeAttribute('aria-label');
element.classList.toggle('thinking-execution-cost-high', presentation?.high === true);
// The slot stays allocated during loading, missing telemetry and finalisation.
element.setAttribute('aria-live', 'polite');
element.setAttribute('aria-atomic', 'true');
}

let scope = null;
let preferences = null;
let pending = null;
let fetchedAt = 0;
let generation = 0;

export function updateExecutionCost(element, cost, context, headers) {
const signature = JSON.stringify(context);
if (signature !== scope) {
generation += 1;
scope = signature;
preferences = null;
pending = null;
fetchedAt = 0;
document.querySelectorAll('[data-execution-cost]').forEach(slot => renderExecutionCost(slot, null, null));
}
element.dataset.executionCost = JSON.stringify(cost || null);
element.dataset.executionCostScope = signature;
renderExecutionCost(element, cost, preferences);
if (pending || Date.now() - fetchedAt < 60000 || typeof fetch !== 'function') return;
fetchedAt = Date.now();
const requestGeneration = generation;
pending = fetch('/api/settings/execution_cost_display', { headers, cache: 'no-store' })
.then(response => response.ok ? response.json() : null)
.then(value => {
if (generation !== requestGeneration) return;
preferences = value;
document.querySelectorAll('[data-execution-cost]').forEach(slot => {
if (slot.dataset.executionCostScope === signature) {
renderExecutionCost(slot, JSON.parse(slot.dataset.executionCost), preferences);
}
});
})
.catch(() => { /* Unknown preferences do not invent an effective threshold. */ })
.finally(() => { if (generation === requestGeneration) pending = null; });
}
Loading
Loading