From c43264e43c17eb15afe5d6e8938ddedba4ae192b Mon Sep 17 00:00:00 2001 From: witbrock Date: Sat, 12 Sep 2026 15:56:24 -0400 Subject: [PATCH] Show execution costs with represented viewer-scoped thresholds --- docs/design_index.md | 1 + docs/engineering/execution_cost_display.md | 89 +++++++++++++++++++ src/backend/server/routes/settings_routes.py | 38 ++++++++ .../execution_cost_display_service.py | 48 ++++++++++ .../services/llm_usage_cost_service.py | 6 +- .../vontology/code_concepts_registry.py | 1 + .../web/von_interface/static/js/chatTab.js | 17 +++- .../static/js/components/executionCost.js | 85 ++++++++++++++++++ .../static/js/test/executionCost.test.js | 51 +++++++++++ .../web/von_interface/static/styles.css | 24 ++++- tests/backend/test_execution_cost_display.py | 76 ++++++++++++++++ tests/backend/test_llm_usage_cost_service.py | 16 ++++ tests/browser/executionCost.cjs | 69 ++++++++++++++ 13 files changed, 518 insertions(+), 3 deletions(-) create mode 100644 docs/engineering/execution_cost_display.md create mode 100644 src/backend/services/execution_cost_display_service.py create mode 100644 src/frontend/web/von_interface/static/js/components/executionCost.js create mode 100644 src/frontend/web/von_interface/static/js/test/executionCost.test.js create mode 100644 tests/backend/test_execution_cost_display.py create mode 100644 tests/browser/executionCost.cjs diff --git a/docs/design_index.md b/docs/design_index.md index 81d89b228..0d8b35200 100644 --- a/docs/design_index.md +++ b/docs/design_index.md @@ -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) | diff --git a/docs/engineering/execution_cost_display.md b/docs/engineering/execution_cost_display.md new file mode 100644 index 000000000..a53864614 --- /dev/null +++ b/docs/engineering/execution_cost_display.md @@ -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. diff --git a/src/backend/server/routes/settings_routes.py b/src/backend/server/routes/settings_routes.py index 56b5e1179..697c58d02 100644 --- a/src/backend/server/routes/settings_routes.py +++ b/src/backend/server/routes/settings_routes.py @@ -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.""" diff --git a/src/backend/services/execution_cost_display_service.py b/src/backend/services/execution_cost_display_service.py new file mode 100644 index 000000000..478b9f748 --- /dev/null +++ b/src/backend/services/execution_cost_display_service.py @@ -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"} diff --git a/src/backend/services/llm_usage_cost_service.py b/src/backend/services/llm_usage_cost_service.py index e3b3e2006..5fb77f523 100644 --- a/src/backend/services/llm_usage_cost_service.py +++ b/src/backend/services/llm_usage_cost_service.py @@ -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, @@ -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 ] @@ -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, diff --git a/src/backend/vontology/code_concepts_registry.py b/src/backend/vontology/code_concepts_registry.py index b2ac27ad9..55d960be9 100644 --- a/src/backend/vontology/code_concepts_registry.py +++ b/src/backend/vontology/code_concepts_registry.py @@ -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 = [ diff --git a/src/frontend/web/von_interface/static/js/chatTab.js b/src/frontend/web/von_interface/static/js/chatTab.js index bea3232cc..fd1548e60 100644 --- a/src/frontend/web/von_interface/static/js/chatTab.js +++ b/src/frontend/web/von_interface/static/js/chatTab.js @@ -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'; @@ -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) @@ -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: '); @@ -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) { diff --git a/src/frontend/web/von_interface/static/js/components/executionCost.js b/src/frontend/web/von_interface/static/js/components/executionCost.js new file mode 100644 index 000000000..9852eeb79 --- /dev/null +++ b/src/frontend/web/von_interface/static/js/components/executionCost.js @@ -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; }); +} diff --git a/src/frontend/web/von_interface/static/js/test/executionCost.test.js b/src/frontend/web/von_interface/static/js/test/executionCost.test.js new file mode 100644 index 000000000..283d49279 --- /dev/null +++ b/src/frontend/web/von_interface/static/js/test/executionCost.test.js @@ -0,0 +1,51 @@ +import { executionCostPresentation, renderExecutionCost, updateExecutionCost } from '../components/executionCost.js'; + +const preferences = { currency: 'USD', display_above: '0.01', alert_above: '0.10' }; +const cost = amount => ({ status: 'estimated', currency: 'USD', amount_decimal: amount }); + +test.each([ + ['0', null], ['0.01', null], ['0.010000000000000001', false], + ['0.10', false], ['0.100000000000000001', true], ['1.1e-1', true] +])('exact boundary %s', (amount, high) => { + const result = executionCostPresentation(cost(amount), preferences); + if (high === null) expect(result).toBeNull(); + else expect(result.high).toBe(high); +}); + +test('loading, missing, partial and other currency costs stay blank; legacy numeric estimates work', () => { + for (const value of [null, {}, { ...cost('0.2'), status: 'partial' }, { ...cost('0.2'), currency: 'NZD' }]) { + expect(executionCostPresentation(value, preferences)).toBeNull(); + } + expect(executionCostPresentation(cost('0.2'), null)).toBeNull(); + expect(executionCostPresentation({ status: 'estimated', currency: 'USD', amount: 0.11 }, preferences).high).toBe(true); +}); + +test('delayed finalisation uses the same reserved slot and signals high cost visibly and accessibly', () => { + const slot = document.createElement('span'); + renderExecutionCost(slot, null, preferences); + expect(slot.textContent).toBe(''); + expect(slot.className).toBe('thinking-execution-cost'); + renderExecutionCost(slot, cost('0.100001'), preferences); + expect(slot.textContent).toContain('Estimated US$0.100001 · High cost'); + expect(slot.getAttribute('aria-label')).toContain('High cost'); + expect(slot.classList.contains('thinking-execution-cost-high')).toBe(true); + renderExecutionCost(slot, cost('0.1'), preferences); + expect(slot.textContent).not.toContain('High cost'); + expect(slot.classList.contains('thinking-execution-cost-high')).toBe(false); +}); + +test('resolved settings update mounted historical slots and ignore a stale scope response', async () => { + const pending = []; + global.fetch = jest.fn(() => new Promise(resolve => pending.push(resolve))); + document.body.innerHTML = ''; + const slot = document.getElementById('cost'); + updateExecutionCost(slot, cost('0.11'), { user: 'old' }, {}); + updateExecutionCost(slot, cost('0.11'), { user: 'new' }, {}); + pending[1]({ ok: true, json: async () => preferences }); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(slot.textContent).toContain('High cost'); + pending[0]({ ok: true, json: async () => ({ ...preferences, display_above: '1' }) }); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(slot.textContent).toContain('High cost'); + delete global.fetch; +}); diff --git a/src/frontend/web/von_interface/static/styles.css b/src/frontend/web/von_interface/static/styles.css index 128ec3c38..099173d0f 100644 --- a/src/frontend/web/von_interface/static/styles.css +++ b/src/frontend/web/von_interface/static/styles.css @@ -10706,8 +10706,30 @@ body[data-active-tab="settingsTab"] .tab-content-area { font-size: 0.75rem; color: #64748b; margin-left: auto; + white-space: normal; + flex-shrink: 1; + display: inline-flex; + flex-wrap: wrap; + align-items: center; + line-height: 1.5; + min-width: 0; +} + +.thinking-execution-cost { + display: inline-block; + width: 20em; + max-width: 100%; + flex: 0 1 20em; + height: 1.5em; white-space: nowrap; - flex-shrink: 0; + overflow: hidden; + text-overflow: ellipsis; + font-variant-numeric: tabular-nums; +} + +.thinking-execution-cost-high { + color: #b91c1c; + font-weight: 600; } .thinking-card-mode-switch { diff --git a/tests/backend/test_execution_cost_display.py b/tests/backend/test_execution_cost_display.py new file mode 100644 index 000000000..1ba1ca4b5 --- /dev/null +++ b/tests/backend/test_execution_cost_display.py @@ -0,0 +1,76 @@ +import json + +import pytest +from flask import Flask + +from src.backend.services import execution_cost_display_service as service + + +def test_predicate_resolves_without_database_registration(monkeypatch): + from src.backend.services import text_relation_predicate_validation_service as validation + from src.backend.vontology.code_concepts_registry import build_virtual_concept_doc + + monkeypatch.setattr(validation.concept_service, "get_concept_by_concept_id", build_virtual_concept_doc) + resolved = validation.resolve_text_relation_predicate_for_write(service.PREDICATE) + assert resolved.storage_predicate == service.PREDICATE + assert "#V#binary_text_predicate" in resolved.predicate_doc["relationships"]["is_an_instance_of"] + + +def test_scope_precedence_and_fallback(monkeypatch): + values = {} + monkeypatch.setattr(service, "get_texts_for_concept", lambda subject, **_: values.get(subject, [])) + assert service.resolve_preferences("user", "org") == {**service.DEFAULTS, "source": "default"} + org = {"currency": "USD", "display_above": "0.02", "alert_above": "0.20"} + user = {"currency": "USD", "display_above": "0.03", "alert_above": "0.30"} + values["org"] = [{"text": json.dumps(org)}] + assert service.resolve_preferences("user", "org") == {**org, "source": "organisation"} + values["user"] = [{"text": json.dumps(user)}] + assert service.resolve_preferences("user", "org") == {**user, "source": "user"} + for invalid in ({}, {"currency": "USD"}, {**user, "alert_above": 0.3}): + values["user"] = [{"text": json.dumps(invalid)}] + assert service.resolve_preferences("user", "org")["source"] == "organisation" + values["user"] *= 2 + assert service.resolve_preferences("user", None)["source"] == "default" + + +@pytest.mark.parametrize("value", [None, [], {**service.DEFAULTS, "currency": "NZD"}, + {**service.DEFAULTS, "display_above": "NaN"}, {**service.DEFAULTS, "alert_above": "-1"}, + {**service.DEFAULTS, "display_above": "0.11"}, {**service.DEFAULTS, "alert_above": 0.1}]) +def test_invalid_preferences(value): + with pytest.raises(ValueError): + service.validate_preferences(value) + + +def test_preferences_endpoint_scope_write_and_readback(monkeypatch): + from src.backend.server.routes import settings_routes as routes + from src.backend.services import ontology_mutation_command_service as governance + from src.backend.services import text_value_service + + stored = {} + monkeypatch.setattr(routes, "get_effective_user_concept_id", lambda: "#V#viewer") + monkeypatch.setattr(routes, "get_effective_context", lambda *_: {"organisation_id": "#V#org"}) + monkeypatch.setattr(service, "get_texts_for_concept", lambda subject, **_: stored.get(subject, [])) + mutations = [] + + def write(**kwargs): + mutations.append(kwargs) + stored[kwargs["subject_concept_id"]] = [{"text": kwargs["text"]}] + return {"success": True} + + monkeypatch.setattr(text_value_service, "upsert_singleton_text_relation", write) + monkeypatch.setattr(governance, "execute_governed_ontology_method", lambda **kw: kw["mutate"]()) + app = Flask(__name__) + app.secret_key = "test" + app.register_blueprint(routes.settings_bp, url_prefix="/api/settings") + client = app.test_client() + url = "/api/settings/execution_cost_display" + assert client.get(url).json["source"] == "default" + assert client.put(url, json=service.DEFAULTS).json == {**service.DEFAULTS, "source": "user"} + assert mutations[0]["subject_concept_id"] == "#V#viewer" + assert mutations[0]["predicate"] == service.PREDICATE + assert client.get(url + "?user_id=other&organisation_id=other").json["source"] == "user" + assert client.put(url, json={**service.DEFAULTS, "user_id": "other"}).status_code == 400 + assert client.put(url, json={}).json["source"] == "default" + monkeypatch.setattr(routes, "get_effective_user_concept_id", lambda: None) + assert client.get(url).status_code == 401 + assert client.put(url, json=service.DEFAULTS).status_code == 401 diff --git a/tests/backend/test_llm_usage_cost_service.py b/tests/backend/test_llm_usage_cost_service.py index b5f4479c4..0b471fdf9 100644 --- a/tests/backend/test_llm_usage_cost_service.py +++ b/tests/backend/test_llm_usage_cost_service.py @@ -62,6 +62,21 @@ def _expiring_gemini_registry() -> dict: } +def test_decimal_summary_preserves_sub_float_threshold_difference() -> None: + registry = _registry() + pricing = registry["models"][0]["pricing"] + pricing["unit_tokens"] = 1 + pricing["rates"] = {"input_tokens": "0.100000000000000001", "output_tokens": "0"} + summary = build_llm_usage_cost_summary( + [{"call_id": "precise", "provider": "openai", "effective_model": "gpt-5-test", + "model_identity_source": "provider_response", + "usage": {"prompt_tokens": 1, "completion_tokens": 0}}], + model_registry=registry, + ) + assert summary["estimated_cost"]["amount"] == 0.1 + assert summary["estimated_cost"]["amount_decimal"] == "0.100000000000000001" + + def _priced_gemini_call( *, tier: str = "standard", connection_id: str = "gemini_developer_api" ) -> dict: @@ -165,6 +180,7 @@ def test_detailed_openai_price_uses_cache_breakdown_and_context_band() -> None: ) assert short["estimated_cost"]["status"] == "estimated" assert short["estimated_cost"]["amount"] == 0.00289 + assert short["estimated_cost"]["amount_decimal"] == "0.00289" long = build_llm_usage_cost_summary( [ diff --git a/tests/browser/executionCost.cjs b/tests/browser/executionCost.cjs new file mode 100644 index 000000000..c6d19a574 --- /dev/null +++ b/tests/browser/executionCost.cjs @@ -0,0 +1,69 @@ +// Local fixture: actual template, stylesheet and chat renderer, no live services. +// PLAYWRIGHT_BROWSERS_PATH=/tmp/von-playwright node tests/browser/executionCost.cjs /tmp/cost-evidence +const fs = require('node:fs'); +const path = require('node:path'); +const http = require('node:http'); +const assert = require('node:assert/strict'); +const { chromium } = require('playwright'); +const root = path.resolve(__dirname, '../../src/frontend/web/von_interface'); +const evidence = process.argv[2] || '/tmp/execution-cost-evidence'; +const template = fs.readFileSync(path.join(root, 'templates/chat_tab.html'), 'utf8'); +const card = template.slice(template.indexOf('
${card}
+`; +const server = http.createServer((req, res) => { + if (req.url === '/') { res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.end(html); return; } + if (req.url === '/api/settings/execution_cost_display') { + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ currency: 'USD', display_above: '0.01', alert_above: '0.10' })); return; + } + const file = path.resolve(root, '.' + req.url.split('?')[0]); + if (!file.startsWith(path.join(root, 'static') + path.sep) || !fs.existsSync(file) || !fs.statSync(file).isFile()) { + res.writeHead(404); res.end(); return; + } + res.setHeader('Content-Type', file.endsWith('.js') ? 'text/javascript' : 'text/css'); + res.end(fs.readFileSync(file)); +}); +(async () => { + fs.mkdirSync(evidence, { recursive: true }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const browser = await chromium.launch(); + const results = []; + try { + for (const width of [1440, 390]) { + const page = await browser.newPage({ viewport: { width, height: 900 } }); + await page.goto(`http://127.0.0.1:${server.address().port}/`); + await page.waitForFunction(() => typeof window.showCost === 'function'); + await page.waitForTimeout(150); + const initial = await page.locator('#loadingIndicator').boundingBox(); + const initialSlot = await page.locator('.thinking-execution-cost').boundingBox(); + for (const [amount, visible, high] of [[null, false, false], ['0', false, false], ['0.01', false, false], ['0.010001', true, false], ['0.10', true, false], ['0.100001', true, true]]) { + await page.evaluate(value => window.showCost(value), amount); + const slot = page.locator('.thinking-execution-cost'); + assert.equal(Boolean(await slot.textContent()), visible); + assert.equal((await slot.getAttribute('class')).includes('thinking-execution-cost-high'), high); + if (high) assert.match(await slot.getAttribute('aria-label'), /High cost/); + if (high) assert.equal(await slot.evaluate(el => getComputedStyle(el).color), 'rgb(185, 28, 28)'); + const box = await page.locator('#loadingIndicator').boundingBox(); + assert.deepEqual(await slot.boundingBox(), initialSlot, `cost slot moved at ${width}/${amount}`); + assert.equal(box.height, initial.height, `layout shift at ${width}/${amount}`); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false); + assert.match(await page.locator('#thinkingCardMeta').textContent(), /^1m 10s/); + assert.match(await page.locator('#thinkingCardMeta').textContent(), /Last activity/); + if (high) await page.screenshot({ path: path.join(evidence, `cost-${width}.png`) }); + results.push({ width, amount, visible, high, headerHeight: box.height }); + } + await page.close(); + } + fs.writeFileSync(path.join(evidence, 'results.json'), JSON.stringify(results, null, 2)); + console.log(`${results.length} fixture browser checks passed; ${evidence}`); + } finally { await browser.close(); server.close(); } +})().catch(error => { console.error(error); server.close(); process.exitCode = 1; });