diff --git a/.agents/skills/contribute-adapter/SKILL.md b/.agents/skills/contribute-adapter/SKILL.md index 7fb17b404..9f612a2fd 100644 --- a/.agents/skills/contribute-adapter/SKILL.md +++ b/.agents/skills/contribute-adapter/SKILL.md @@ -57,7 +57,11 @@ Keep descriptor claims, implementation, focused tests, public documentation, catalog entries, and packaged metadata synchronized. Start with the narrowest truthful capability set. -For usage changes, verify invocation-local accounting through the adapter contract and consumer SDK, including warm-session deltas, unknown counters, cache semantics, and unsuccessful results. Keep generated schemas and language bindings in parity. Preserve the closed `fabric.adapter/v1alpha2` `AgentUsage` shape; carry detailed cache-write, reasoning, peak request, and per-model data in the descriptor-declared `usage.extensions["nemo_fabric.detailed_token_usage.v1"]` object until a new adapter contract revision negotiates those fields. Consumers treat any detailed field for an input or output dimension as selecting the detailed counters for that dimension, so require complete, internally consistent counters instead of relying on the top-level view as a fallback. +For usage changes, verify invocation-local accounting through the adapter contract and consumer SDK, including warm-session deltas, omitted fields, cache semantics and unsuccessful results. Keep generated schemas and language bindings in parity. Preserve the closed `fabric.adapter/v1alpha2` `AgentUsage` shape. + +For a collector using shared accounting, declare `usage.extensions["nemo_fabric.usage_accounting.v1"]` in the descriptor and use Python `usage_from_observations` or TypeScript `usageFromObservations`. Keep source parsing, deduplication and invocation ownership in the collector; core normalizes counts and applies qualified pricing. An omitted count stays unknown and a reported zero stays zero. Freeze observations at return; late usage cannot move to a later invocation. An execution failure alone does not erase settled usage. + +The shared helper also supplies the legacy `nemo_fabric.detailed_token_usage.v1` projection for older consumers. Keep its descriptor declaration and inclusion flags consistent. Detailed legacy counters select their dimension without falling back to top-level counters. Do not replace incomplete shared accounting with legacy fields, logs or Relay totals, and do not promote client estimates to provider-reported cost. For `instructions.system`, keep `config.system_instruction_modes`, planning behavior, direct adapter validation, and target-native composition synchronized. diff --git a/.github/workflows/ci_python.yml b/.github/workflows/ci_python.yml index 5d8d6bbd8..09755170e 100644 --- a/.github/workflows/ci_python.yml +++ b/.github/workflows/ci_python.yml @@ -84,6 +84,14 @@ jobs: - name: Set up uv uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8 + - name: Set up Node.js for reducer parity + if: matrix.platform == 'linux-amd64' && matrix.python-version == '3.12' + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + with: + node-version: 24 + cache: npm + cache-dependency-path: adapter-contract/typescript/package-lock.json + - name: Cache cargo build uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 with: @@ -115,6 +123,8 @@ jobs: npm run build --prefix adapters/typescript --workspace nemo-fabric-adapters-pi - name: Run pytest + env: + FABRIC_REQUIRE_TYPESCRIPT_REDUCER_PARITY: "1" run: | set -euo pipefail just --set no_uv true test-python diff --git a/Cargo.toml b/Cargo.toml index e21453375..98ccea6b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,6 @@ chrono = { version = "0.4.45", default-features = false, features = ["std"] } # Authenticate local health messages with the endpoint secret. getrandom = "0.4.3" hmac = "0.12.1" -sha2 = "0.10.9" clap = { version = "4", default-features = false, features = ["derive", "std", "help", "usage", "error-context", "suggestions"] } # Validate adapter-owned JSON Schema in Rust while preserving precise instance paths. @@ -43,6 +42,8 @@ libc = "0.2.186" schemars = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +# SHA-256 for execution-fact digests and reproducible price catalogs. +sha2 = { version = "0.10.9", default-features = false } thiserror = "2" windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_System_Diagnostics_ToolHelp", "Win32_System_JobObjects", "Win32_System_Threading"] } pyo3 = "0.28.2" diff --git a/adapter-contract/python/src/nemo_fabric_adapter_contract/models.py b/adapter-contract/python/src/nemo_fabric_adapter_contract/models.py index 5c1733ab6..88bd1165c 100644 --- a/adapter-contract/python/src/nemo_fabric_adapter_contract/models.py +++ b/adapter-contract/python/src/nemo_fabric_adapter_contract/models.py @@ -601,6 +601,136 @@ def _validate(self) -> None: ) +class UsageDimension(StrEnum): + """Raw dimensions in the declared usage-accounting extension.""" + + INPUT_TOKENS = "input_tokens" + CACHED_INPUT_TOKENS = "cached_input_tokens" + CACHE_WRITE_INPUT_TOKENS = "cache_write_input_tokens" + OUTPUT_TOKENS = "output_tokens" + REASONING_TOKENS = "reasoning_tokens" + TOTAL_TOKENS = "total_tokens" + PEAK_REQUEST_INPUT_TOKENS = "peak_request_input_tokens" + COST_USD = "cost_usd" + + +class GapReason(StrEnum): + """Why complete accounting is unavailable.""" + + UNFINISHED_WORK = "unfinished_work" + MISSING_USAGE = "missing_usage" + SCOPE_UNVERIFIED = "scope_unverified" + + +@dataclass(slots=True, kw_only=True) +class ReportedUsage(ContractModel): + """Raw provider/harness counts, retaining independent field presence.""" + + input_tokens: int | None = _optional() + cached_input_tokens: int | None = _optional() + cache_write_input_tokens: int | None = _optional() + input_tokens_include_cache: bool | None = _optional() + output_tokens: int | None = _optional() + reasoning_tokens: int | None = _optional() + output_tokens_include_reasoning: bool | None = _optional() + total_tokens: int | None = _optional() + peak_request_input_tokens: int | None = _optional() + cost_usd: float | None = _optional() + + def _validate(self) -> None: + import math + + for dimension in UsageDimension: + value = getattr(self, dimension.value) + if dimension is not UsageDimension.COST_USD: + _bounded_int(value, dimension.value, (1 << 64) - 1) + elif value is not None and ( + not math.isfinite(value) or value < 0 or value * 1e9 >= 1 << 64 + ): + raise ContractValidationError( + "cost must fit finite nonnegative nano-USD" + ) + + +@dataclass(slots=True, kw_only=True) +class UsageGap(ContractModel): + """A nonempty set of incomplete raw dimensions.""" + + dimensions: list[UsageDimension] + reason: GapReason + + def _validate(self) -> None: + if not self.dimensions or len(set(self.dimensions)) != len(self.dimensions): + raise ContractValidationError("gap dimensions must be nonempty and unique") + + +@dataclass(slots=True, kw_only=True) +class UsageRequest(ContractModel): + """One deduplicated model-call snapshot, not an aggregate.""" + + usage: ReportedUsage + gap: GapReason | None = _optional() + tokens_aligned: bool = False + + +@dataclass(slots=True, kw_only=True) +class AccountedModel(ContractModel): + """Complete and observed evidence for one literal model/provider identity.""" + + model: str + gaps: list[UsageGap] + provider: str | None = _optional() + reported: ReportedUsage = field(default_factory=ReportedUsage) + observed: ReportedUsage | None = _optional() + + observed_tokens_aligned: bool = False + requests: list[UsageRequest] = _empty_list() + requests_exhaustive: bool = False + + def _validate(self) -> None: + _nonblank(self.model, "model") + if self.requests_exhaustive and not self.requests: + raise ContractValidationError("exhaustive requests must be nonempty") + if self.provider is not None: + _nonblank(self.provider, "provider") + for gap in self.gaps: + if any( + getattr(self.reported, dimension.value) is not None + for dimension in gap.dimensions + ): + raise ContractValidationError( + "complete value conflicts with explicit gap" + ) + + +@dataclass(slots=True, kw_only=True) +class UsageAccounting(ContractModel): + """Typed nemo_fabric.usage_accounting.v1 payload. + + Declare its schema in extension_schemas.usage. This does not change the + closed AgentUsage contract. Per-model complete counts live in reported. + """ + + models_exhaustive: bool + gaps: list[UsageGap] + observed: ReportedUsage | None = _optional() + models: list[AccountedModel] = _empty_list() + + def _validate(self) -> None: + if self.models_exhaustive and not self.models and self.observed is not None: + for dimension in UsageDimension: + if dimension is UsageDimension.COST_USD: + continue + count = getattr(self.observed, dimension.value) + if count is not None and count > 0: + raise ContractValidationError( + "exhaustive empty model list conflicts with positive tokens" + ) + identities = [(model.provider, model.model) for model in self.models] + if len(set(identities)) != len(identities): + raise ContractValidationError("duplicate model identity") + + @dataclass(slots=True, kw_only=True) class AgentRunResult(AgentContractBlock): """Southbound terminal result returned by an adapter target.""" diff --git a/adapter-contract/python/src/nemo_fabric_adapter_contract/usage.py b/adapter-contract/python/src/nemo_fabric_adapter_contract/usage.py new file mode 100644 index 000000000..f296d5d21 --- /dev/null +++ b/adapter-contract/python/src/nemo_fabric_adapter_contract/usage.py @@ -0,0 +1,238 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Shared raw-evidence reduction for Python collectors. + +Normalization, aggregate/model reconciliation and pricing belong to core. +Collectors supply settled snapshots and explicit coverage gaps, never inferred +zero usage from HTTP status. One record represents one deduplicated source unit. +""" + +from __future__ import annotations + +from collections import defaultdict +from collections.abc import Iterable +from dataclasses import dataclass +import math + +from nemo_fabric_adapter_contract.models import AccountedModel +from nemo_fabric_adapter_contract.models import AgentUsage +from nemo_fabric_adapter_contract.models import GapReason +from nemo_fabric_adapter_contract.models import ReportedUsage +from nemo_fabric_adapter_contract.models import UsageAccounting +from nemo_fabric_adapter_contract.models import UsageDimension +from nemo_fabric_adapter_contract.models import UsageGap +from nemo_fabric_adapter_contract.models import UsageRequest + +ACCOUNTING_KEY = "nemo_fabric.usage_accounting.v1" +DETAIL_KEY = "nemo_fabric.detailed_token_usage.v1" + + +@dataclass(frozen=True, kw_only=True) +class UsageObservation: + """One source unit's deduplicated raw snapshot and completeness boundary.""" + + reported: ReportedUsage + model: str | None = None + provider: str | None = None + gap: GapReason | None = None + tokens_aligned: bool = False + is_model_call: bool = False + gaps: tuple[UsageGap, ...] = () + + +def _reduce( + records: list[UsageObservation], +) -> tuple[ReportedUsage, ReportedUsage, list[UsageGap]]: + complete: dict[str, object] = {} + observed: dict[str, object] = {} + gaps: dict[GapReason, list[UsageDimension]] = defaultdict(list) + for dimension in UsageDimension: + key = dimension.value + values = [getattr(record.reported, key) for record in records] + known = [value for value in values if value is not None] + total = None + if known: + if dimension is UsageDimension.COST_USD: + amount = math.fsum(known) + if math.isfinite(amount) and 0 <= amount * 1e9 < 1 << 64: + total = amount + elif dimension is UsageDimension.PEAK_REQUEST_INPUT_TOKENS: + total = max(known) + else: + amount = sum(known) + if amount <= (1 << 64) - 1: + total = amount + if total is not None: + observed[key] = total + causes = {record.gap for record in records if record.gap is not None} + causes.update( + gap.reason + for record in records + for gap in record.gaps + if dimension in gap.dimensions + ) + if len(known) != len(records): + causes.add(GapReason.MISSING_USAGE) + if known and total is None: + causes.add(GapReason.SCOPE_UNVERIFIED) + if total is not None and not causes: + complete[key] = total + elif not causes: + causes.add(GapReason.SCOPE_UNVERIFIED if not records else GapReason.MISSING_USAGE) + for cause in sorted(causes): + gaps[cause].append(dimension) + for flag, counter in ( + ("input_tokens_include_cache", "input_tokens"), + ("output_tokens_include_reasoning", "output_tokens"), + ): + flags = { + getattr(record.reported, flag) + for record in records + if getattr(record.reported, counter) is not None + } + if len(flags) == 1 and None not in flags: + observed[flag] = next(iter(flags)) + if counter in complete: + complete[flag] = observed[flag] + elif len( + [ + record + for record in records + if getattr(record.reported, counter) is not None + ] + ) == 1 and flags == {None}: + # One raw source remains useful even when its convention is unknown. + # Core withholds normalized values; do not discard literal evidence. + pass + elif counter in observed: + # Mixed conventions cannot be combined as one raw input/output count. + complete.pop(counter, None) + observed.pop(counter, None) + dimension = UsageDimension(counter) + if not all( + record.is_model_call and record.model is not None for record in records + ): + if dimension not in gaps[GapReason.SCOPE_UNVERIFIED]: + gaps[GapReason.SCOPE_UNVERIFIED].append(dimension) + return ( + ReportedUsage.from_mapping(complete), + ReportedUsage.from_mapping(observed), + [ + UsageGap(dimensions=dimensions, reason=reason) + for reason, dimensions in sorted(gaps.items()) + ], + ) + + +def _observed_tokens_aligned(records: list[UsageObservation]) -> bool: + # Shape alone never proves scope. The collector must first identify a + # coherent source snapshot; matching presence then keeps reductions aligned. + fields = [ + "input_tokens", + "cached_input_tokens", + "cache_write_input_tokens", + "output_tokens", + "peak_request_input_tokens", + ] + if any(r.reported.output_tokens_include_reasoning is False for r in records): + fields.append("reasoning_tokens") + relevant = [ + r + for r in records + if any(getattr(r.reported, field) is not None for field in fields) + ] + return ( + bool(relevant) + and all(r.tokens_aligned and not r.gaps for r in relevant) + and all( + len({getattr(r.reported, field) is not None for r in relevant}) == 1 + for field in fields + ) + ) + + +def _request_usage(record: UsageObservation) -> ReportedUsage: + # A scoped observation is not a complete per-call count for that dimension. + # Keep the literal value in model observations without allowing requests to + # re-certify it or use it as a complete pricing component. + fields = record.reported.to_mapping() + for gap in record.gaps: + for dimension in gap.dimensions: + fields.pop(dimension.value, None) + return ReportedUsage.from_mapping(fields) + + +def usage_from_observations(records: Iterable[UsageObservation]) -> AgentUsage: + """Reduce snapshots once, preserving field presence and independently known values. + + Empty source coverage is unverified rather than assumed to mean zero calls. + A collector that proves no model work occurred can report an explicit zero + snapshot. This helper does not change scheduling or wait for late work. + """ + + records = list(records) + reported, observed, gaps = _reduce(records) + groups: dict[tuple[str | None, str], list[UsageObservation]] = defaultdict(list) + for record in records: + if record.model is not None: + groups[(record.provider, record.model)].append(record) + models = [] + for (provider, model), group in sorted( + groups.items(), key=lambda item: (item[0][0] or "", item[0][1]) + ): + complete_model, observed_model, model_gaps = _reduce(group) + models.append( + AccountedModel( + model=model, + provider=provider, + reported=complete_model, + observed=observed_model, + observed_tokens_aligned=_observed_tokens_aligned(group), + gaps=model_gaps, + requests=[ + UsageRequest( + usage=_request_usage(record), + gap=record.gap, + tokens_aligned=record.tokens_aligned, + ) + for record in group + ] + if all(record.is_model_call for record in group) + else [], + requests_exhaustive=all(record.is_model_call for record in group), + ) + ) + accounting = UsageAccounting( + models_exhaustive=bool(records) + and all(record.model is not None and record.gap is None for record in records), + gaps=gaps, + observed=observed, + models=models, + ) + base_keys = { + "input_tokens", + "cached_input_tokens", + "input_tokens_include_cache", + "output_tokens", + "total_tokens", + "cost_usd", + } + complete_mapping = reported.to_mapping() + detail_keys = { + "input_tokens", + "cached_input_tokens", + "cache_write_input_tokens", + "input_tokens_include_cache", + "output_tokens", + "reasoning_tokens", + "output_tokens_include_reasoning", + "peak_request_input_tokens", + } + detail = { + key: value for key, value in complete_mapping.items() if key in detail_keys + } + return AgentUsage( + **{key: value for key, value in complete_mapping.items() if key in base_keys}, + extensions={ACCOUNTING_KEY: accounting.to_mapping(), DETAIL_KEY: detail}, + ) diff --git a/adapter-contract/typescript/package.json b/adapter-contract/typescript/package.json index 890d30af6..359108563 100644 --- a/adapter-contract/typescript/package.json +++ b/adapter-contract/typescript/package.json @@ -26,9 +26,10 @@ "build": "node scripts/clean.mjs && tsc -p tsconfig.build.json", "test:generator": "node --test test/*.test.mjs", "test:types": "tsc -p test/tsconfig.json --noEmit", + "test:usage": "tsc -p tsconfig.build.json && node --test test/usage-reducer.test.js", "test:dependencies": "node scripts/check-dependencies.mjs && npm ls --all && npm audit --audit-level=high", "pack:check": "npm run build && node scripts/check-package.mjs", - "test": "npm run generate:check && npm run test:generator && npm run test:types && npm run test:dependencies && npm run pack:check", + "test": "npm run generate:check && npm run test:generator && npm run test:types && npm run test:usage && npm run test:dependencies && npm run pack:check", "prepack": "npm run generate:check && npm run build" }, "publishConfig": { diff --git a/adapter-contract/typescript/schemas/usage-accounting.schema.json b/adapter-contract/typescript/schemas/usage-accounting.schema.json new file mode 100644 index 000000000..7bbe1d76e --- /dev/null +++ b/adapter-contract/typescript/schemas/usage-accounting.schema.json @@ -0,0 +1,329 @@ +{ + "$defs": { + "AccountedModel": { + "additionalProperties": false, + "description": "Evidence for one literal provider/model identity.", + "properties": { + "gaps": { + "description": "Gaps affecting this model.", + "items": { + "$ref": "#/$defs/UsageGap" + }, + "type": "array" + }, + "model": { + "description": "Model identity as reported by the source.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + }, + "observed": { + "anyOf": [ + { + "$ref": "#/$defs/ReportedUsage" + }, + { + "type": "null" + } + ], + "description": "Valid deduplicated observations, potentially incomplete." + }, + "observed_tokens_aligned": { + "default": false, + "description": "Every present observed token dimension describes the same source-call subset.\nFalse unless the producer establishes this, including peak and normalization components.", + "type": "boolean" + }, + "provider": { + "description": "Literal provider identity, when available.", + "minLength": 1, + "pattern": "\\S", + "type": [ + "string", + "null" + ] + }, + "reported": { + "$ref": "#/$defs/ReportedUsage", + "default": {}, + "description": "Complete per-model evidence; absent fields are unreported." + }, + "requests": { + "description": "Optional per-call evidence for exact request-tier pricing.", + "items": { + "$ref": "#/$defs/UsageRequest" + }, + "type": "array" + }, + "requests_exhaustive": { + "default": false, + "description": "The request list covers every source unit contributing to this model.", + "type": "boolean" + } + }, + "required": [ + "model", + "gaps" + ], + "type": "object" + }, + "GapReason": { + "description": "Categories that affect whether a consumer can treat a value as complete.", + "oneOf": [ + { + "const": "unfinished_work", + "description": "Owned work was still running at the return boundary.", + "type": "string" + }, + { + "const": "missing_usage", + "description": "A completed call did not provide usable usage.", + "type": "string" + }, + { + "const": "scope_unverified", + "description": "The supported source does not establish coverage of owned work.", + "type": "string" + } + ] + }, + "ReportedUsage": { + "additionalProperties": false, + "description": "Raw counts in provider/harness conventions. Missing values are never zero.", + "properties": { + "cache_write_input_tokens": { + "description": "Cache writes, independently reported.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "cached_input_tokens": { + "description": "Cache reads, independently reported.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "cost_usd": { + "description": "Provider-reported USD, never a client-side price estimate.", + "format": "double", + "minimum": 0.0, + "type": [ + "number", + "null" + ] + }, + "input_tokens": { + "description": "Raw input count.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "input_tokens_include_cache": { + "description": "Whether input includes both cache categories.", + "type": [ + "boolean", + "null" + ] + }, + "output_tokens": { + "description": "Raw output count.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "output_tokens_include_reasoning": { + "description": "Whether output includes reasoning.", + "type": [ + "boolean", + "null" + ] + }, + "peak_request_input_tokens": { + "description": "Largest request's cache-inclusive input.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "reasoning_tokens": { + "description": "Reasoning count, independently reported.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "total_tokens": { + "description": "Provider-reported total, independent of normalized input plus output.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "UsageDimension": { + "description": "A dimension in raw adapter evidence.", + "oneOf": [ + { + "const": "input_tokens", + "description": "Input tokens.", + "type": "string" + }, + { + "const": "cached_input_tokens", + "description": "Cache reads.", + "type": "string" + }, + { + "const": "cache_write_input_tokens", + "description": "Cache writes.", + "type": "string" + }, + { + "const": "output_tokens", + "description": "Output tokens.", + "type": "string" + }, + { + "const": "reasoning_tokens", + "description": "Reasoning tokens.", + "type": "string" + }, + { + "const": "total_tokens", + "description": "Provider-reported total.", + "type": "string" + }, + { + "const": "peak_request_input_tokens", + "description": "Largest request input.", + "type": "string" + }, + { + "const": "cost_usd", + "description": "Provider-reported USD.", + "type": "string" + } + ] + }, + "UsageGap": { + "additionalProperties": false, + "description": "Missing evidence for one or more raw dimensions.", + "properties": { + "dimensions": { + "description": "Nonempty, unique set of affected dimensions.", + "items": { + "$ref": "#/$defs/UsageDimension" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "reason": { + "$ref": "#/$defs/GapReason", + "description": "Why those dimensions are incomplete." + } + }, + "required": [ + "dimensions", + "reason" + ], + "type": "object" + }, + "UsageRequest": { + "additionalProperties": false, + "description": "One model call retained for request-tier pricing and evidence reconciliation.", + "properties": { + "gap": { + "anyOf": [ + { + "$ref": "#/$defs/GapReason" + }, + { + "type": "null" + } + ], + "description": "Known incompleteness of this call's source coverage." + }, + "tokens_aligned": { + "default": false, + "description": "Present observed fields describe the same request/subset.", + "type": "boolean" + }, + "usage": { + "$ref": "#/$defs/ReportedUsage", + "description": "One deduplicated model-call snapshot, never a session/model aggregate." + } + }, + "required": [ + "usage" + ], + "type": "object" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "The declared adapter transport for invocation accounting.", + "properties": { + "gaps": { + "description": "Invocation-wide gaps.", + "items": { + "$ref": "#/$defs/UsageGap" + }, + "type": "array" + }, + "models": { + "description": "One entry per literal provider/model pair.", + "items": { + "$ref": "#/$defs/AccountedModel" + }, + "type": "array" + }, + "models_exhaustive": { + "description": "Every model identity used by owned work is represented; this alone does\nnot certify that any dimension is complete.", + "type": "boolean" + }, + "observed": { + "anyOf": [ + { + "$ref": "#/$defs/ReportedUsage" + }, + { + "type": "null" + } + ], + "description": "Valid deduplicated observations, not a promised mathematical lower bound." + } + }, + "required": [ + "models_exhaustive", + "gaps" + ], + "title": "UsageAccounting", + "type": "object" +} \ No newline at end of file diff --git a/adapter-contract/typescript/scripts/check-package.mjs b/adapter-contract/typescript/scripts/check-package.mjs index 6b5163e62..75a07b71f 100644 --- a/adapter-contract/typescript/scripts/check-package.mjs +++ b/adapter-contract/typescript/scripts/check-package.mjs @@ -52,6 +52,11 @@ try { "dist/index.js", "dist/json.d.ts", "dist/json.js", + "dist/usage.js", + "dist/usage.d.ts", + "dist/generated/usage-accounting.js", + "dist/generated/usage-accounting.d.ts", + "schemas/usage-accounting.schema.json", "dist/version.d.ts", "dist/version.js", "package.json", @@ -126,12 +131,14 @@ try { ); await writeFile( join(consumerRoot, "index.ts"), - `import { ADAPTER_CONTRACT_VERSION } from "nemo-fabric-adapter-contract"; + `import { usageFromObservations, ADAPTER_CONTRACT_VERSION } from "nemo-fabric-adapter-contract"; import agentConfigSchema from "nemo-fabric-adapter-contract/schemas/agent-config" with { type: "json" }; import type { AdapterDescriptor } from "nemo-fabric-adapter-contract"; import type { AdapterTargetDescriptor } from "nemo-fabric-adapter-contract"; import type { AgentRunResult } from "nemo-fabric-adapter-contract"; +const usage = usageFromObservations([{reported: {input_tokens: 0, input_tokens_include_cache: true, output_tokens: 0, output_tokens_include_reasoning: true}}]); +if (usage.input_tokens !== 0 || usage.output_tokens !== 0) throw new Error("Reported zero did not round-trip through the installed helper"); const descriptor: AdapterDescriptor = { adapter_id: "example", adapter_kind: "process", diff --git a/adapter-contract/typescript/scripts/generate.mjs b/adapter-contract/typescript/scripts/generate.mjs index d36365973..9e6361189 100644 --- a/adapter-contract/typescript/scripts/generate.mjs +++ b/adapter-contract/typescript/scripts/generate.mjs @@ -109,6 +109,28 @@ for (const spec of schemaSpecs) { ); } +// Extensions retain the closed v1alpha2 transport and have their own canonical +// schemas. Package the same bytes and generate types alongside the base models. +const accountingBytes = await readFile(resolve(repositoryRoot, "schemas/extensions/usage-accounting.schema.json")); +pendingFiles.set(resolve(packageRoot, "schemas/usage-accounting.schema.json"), accountingBytes); +pendingFiles.set(resolve(packageRoot, "src/generated/usage-accounting.ts"), + await generateSchema(JSON.parse(accountingBytes.toString("utf8")))); + +const accountingVectors = JSON.parse(await readFile( + resolve(repositoryRoot, "tests/fixtures/usage_accounting/conformance.json"), "utf8")); +pendingFiles.set(resolve(packageRoot, "test/generated/usage-accounting-vectors.ts"), `${banner} + +import type { AgentUsage, UsageAccounting } from "../../src/index.js"; + +// Source: tests/fixtures/usage_accounting/conformance.json, shared with Rust and Python. +export const accountingVectors = ${JSON.stringify(accountingVectors, null, 2)} satisfies { + name: string; + usage: AgentUsage; + accounting: UsageAccounting; + expected: Record; +}[]; +`); + pendingFiles.set(resolve(packageRoot, "src/json.ts"), generateJsonTypes()); pendingFiles.set( resolve(packageRoot, "src/version.ts"), diff --git a/adapter-contract/typescript/src/generated/usage-accounting.ts b/adapter-contract/typescript/src/generated/usage-accounting.ts new file mode 100644 index 000000000..fcccabf10 --- /dev/null +++ b/adapter-contract/typescript/src/generated/usage-accounting.ts @@ -0,0 +1,244 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// This file is generated from the canonical adapter-contract JSON Schemas. +// Do not edit it directly; run `npm run generate` instead. + +/** + * A dimension in raw adapter evidence. + */ +export type UsageDimension = + | "input_tokens" + | "cached_input_tokens" + | "cache_write_input_tokens" + | "output_tokens" + | "reasoning_tokens" + | "total_tokens" + | "peak_request_input_tokens" + | "cost_usd"; +/** + * Categories that affect whether a consumer can treat a value as complete. + */ +export type GapReason = "unfinished_work" | "missing_usage" | "scope_unverified"; + +/** + * The declared adapter transport for invocation accounting. + */ +export interface UsageAccounting { + /** + * Invocation-wide gaps. + */ + gaps: UsageGap[]; + /** + * One entry per literal provider/model pair. + */ + models?: AccountedModel[]; + /** + * Every model identity used by owned work is represented; this alone does + * not certify that any dimension is complete. + */ + models_exhaustive: boolean; + /** + * Valid deduplicated observations, not a promised mathematical lower bound. + */ + observed?: ReportedUsage | null; +} +/** + * Missing evidence for one or more raw dimensions. + */ +export interface UsageGap { + /** + * Nonempty, unique set of affected dimensions. + * + * @minItems 1 + */ + dimensions: [UsageDimension, ...UsageDimension[]]; + /** + * Why those dimensions are incomplete. + */ + reason: "unfinished_work" | "missing_usage" | "scope_unverified"; +} +/** + * Evidence for one literal provider/model identity. + */ +export interface AccountedModel { + /** + * Gaps affecting this model. + */ + gaps: UsageGap[]; + /** + * Model identity as reported by the source. + */ + model: string; + /** + * Valid deduplicated observations, potentially incomplete. + */ + observed?: ReportedUsage | null; + /** + * Every present observed token dimension describes the same source-call subset. + * False unless the producer establishes this, including peak and normalization components. + */ + observed_tokens_aligned?: boolean; + /** + * Literal provider identity, when available. + */ + provider?: string | null; + reported?: ReportedUsage1; + /** + * Optional per-call evidence for exact request-tier pricing. + */ + requests?: UsageRequest[]; + /** + * The request list covers every source unit contributing to this model. + */ + requests_exhaustive?: boolean; +} +/** + * Raw counts in provider/harness conventions. Missing values are never zero. + */ +export interface ReportedUsage { + /** + * Cache writes, independently reported. + */ + cache_write_input_tokens?: number | null; + /** + * Cache reads, independently reported. + */ + cached_input_tokens?: number | null; + /** + * Provider-reported USD, never a client-side price estimate. + */ + cost_usd?: number | null; + /** + * Raw input count. + */ + input_tokens?: number | null; + /** + * Whether input includes both cache categories. + */ + input_tokens_include_cache?: boolean | null; + /** + * Raw output count. + */ + output_tokens?: number | null; + /** + * Whether output includes reasoning. + */ + output_tokens_include_reasoning?: boolean | null; + /** + * Largest request's cache-inclusive input. + */ + peak_request_input_tokens?: number | null; + /** + * Reasoning count, independently reported. + */ + reasoning_tokens?: number | null; + /** + * Provider-reported total, independent of normalized input plus output. + */ + total_tokens?: number | null; +} +/** + * Raw counts in provider/harness conventions. Missing values are never zero. + */ +export interface ReportedUsage1 { + /** + * Cache writes, independently reported. + */ + cache_write_input_tokens?: number | null; + /** + * Cache reads, independently reported. + */ + cached_input_tokens?: number | null; + /** + * Provider-reported USD, never a client-side price estimate. + */ + cost_usd?: number | null; + /** + * Raw input count. + */ + input_tokens?: number | null; + /** + * Whether input includes both cache categories. + */ + input_tokens_include_cache?: boolean | null; + /** + * Raw output count. + */ + output_tokens?: number | null; + /** + * Whether output includes reasoning. + */ + output_tokens_include_reasoning?: boolean | null; + /** + * Largest request's cache-inclusive input. + */ + peak_request_input_tokens?: number | null; + /** + * Reasoning count, independently reported. + */ + reasoning_tokens?: number | null; + /** + * Provider-reported total, independent of normalized input plus output. + */ + total_tokens?: number | null; +} +/** + * One model call retained for request-tier pricing and evidence reconciliation. + */ +export interface UsageRequest { + /** + * Known incompleteness of this call's source coverage. + */ + gap?: GapReason | null; + /** + * Present observed fields describe the same request/subset. + */ + tokens_aligned?: boolean; + usage: ReportedUsage2; +} +/** + * Raw counts in provider/harness conventions. Missing values are never zero. + */ +export interface ReportedUsage2 { + /** + * Cache writes, independently reported. + */ + cache_write_input_tokens?: number | null; + /** + * Cache reads, independently reported. + */ + cached_input_tokens?: number | null; + /** + * Provider-reported USD, never a client-side price estimate. + */ + cost_usd?: number | null; + /** + * Raw input count. + */ + input_tokens?: number | null; + /** + * Whether input includes both cache categories. + */ + input_tokens_include_cache?: boolean | null; + /** + * Raw output count. + */ + output_tokens?: number | null; + /** + * Whether output includes reasoning. + */ + output_tokens_include_reasoning?: boolean | null; + /** + * Largest request's cache-inclusive input. + */ + peak_request_input_tokens?: number | null; + /** + * Reasoning count, independently reported. + */ + reasoning_tokens?: number | null; + /** + * Provider-reported total, independent of normalized input plus output. + */ + total_tokens?: number | null; +} diff --git a/adapter-contract/typescript/src/index.ts b/adapter-contract/typescript/src/index.ts index ea576a271..5c68c66a2 100644 --- a/adapter-contract/typescript/src/index.ts +++ b/adapter-contract/typescript/src/index.ts @@ -15,6 +15,7 @@ export type * from "./generated/agent-config.js"; export type * from "./generated/agent-run-request.js"; export type * from "./generated/agent-run-result.js"; export type * from "./generated/runtime-context.js"; +export type * from "./generated/usage-accounting.js"; export type { JsonArray, JsonObject, @@ -41,6 +42,8 @@ export type EnvironmentOwnership = EnvironmentHandle["ownership"]; export type AgentRunStatus = AgentRunResult["status"]; export { ADAPTER_CONTRACT_VERSION }; +export { USAGE_ACCOUNTING_KEY, usageFromObservations } from "./usage.js"; +export type { UsageObservation } from "./usage.js"; export type { AdapterCheckRequest } from "./generated/adapter-check-request.js"; export type { AdapterCheckResult, CheckReason } from "./generated/adapter-check-result.js"; /** Highest requested agent-health level. */ diff --git a/adapter-contract/typescript/src/usage.ts b/adapter-contract/typescript/src/usage.ts new file mode 100644 index 000000000..bf75221c4 --- /dev/null +++ b/adapter-contract/typescript/src/usage.ts @@ -0,0 +1,193 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { AgentUsage } from "./generated/agent-run-result.js"; +import type { AccountedModel, ReportedUsage, UsageAccounting, UsageDimension, UsageGap } from "./generated/usage-accounting.js"; +import type { JsonObject } from "./json.js"; + +export const USAGE_ACCOUNTING_KEY = "nemo_fabric.usage_accounting.v1"; +const DETAIL_KEY = "nemo_fabric.detailed_token_usage.v1"; +type Reason = UsageGap["reason"]; +const dimensions: UsageDimension[] = ["input_tokens", "cached_input_tokens", "cache_write_input_tokens", "output_tokens", "reasoning_tokens", "total_tokens", "peak_request_input_tokens", "cost_usd"]; + +/** One deduplicated source snapshot. Core owns normalization and pricing. */ +export interface UsageObservation { + reported: ReportedUsage; + model?: string; + provider?: string; + gap?: Reason; + tokens_aligned?: boolean; + is_model_call?: boolean; + gaps?: UsageGap[]; +} + +function validate(record: UsageObservation): void { + for (const key of dimensions) { + const value = record.reported[key]; + if (value == null) continue; + if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || + (key === "cost_usd" ? value * 1e9 >= 2 ** 64 : !Number.isSafeInteger(value))) { + throw new RangeError(`invalid usage ${key}`); + } + } + for (const key of ["input_tokens_include_cache", "output_tokens_include_reasoning"] as const) { + if (record.reported[key] != null && typeof record.reported[key] !== "boolean") throw new TypeError(`invalid usage ${key}`); + } + for (const value of [record.model, record.provider]) { + if (value !== undefined && (typeof value !== "string" || !value.trim())) throw new TypeError("invalid usage identity"); + } + if (record.gap !== undefined && !["unfinished_work", "missing_usage", "scope_unverified"].includes(record.gap)) throw new TypeError("invalid usage gap"); + for (const gap of record.gaps ?? []) { + if (!gap.dimensions.length || new Set(gap.dimensions).size !== gap.dimensions.length || + gap.dimensions.some(dimension => !dimensions.includes(dimension)) || + !["unfinished_work", "missing_usage", "scope_unverified"].includes(gap.reason)) throw new TypeError("invalid dimension gap"); + } +} + +// Keep low-order cost contributions until the final round, matching math.fsum. +// Inputs have already passed the finite, nonnegative cost validation. +function sumCosts(values: readonly number[]): number { + const partials: number[] = []; + for (let high of values) { + let retained = 0; + for (let low of partials) { + if (Math.abs(high) < Math.abs(low)) [high, low] = [low, high]; + const sum = high + low; + const residual = low - (sum - high); + if (residual !== 0) partials[retained++] = residual; + high = sum; + } + partials.length = retained; + partials.push(high); + } + let index = partials.length; + let total = index === 0 ? 0 : partials[--index]!; + let residual = 0; + while (index > 0) { + const high = total; + const low = partials[--index]!; + total = high + low; + residual = low - (total - high); + if (residual !== 0) break; + } + // Preserve round-to-even when the remaining low-order terms share a sign. + if (index > 0 && ((residual < 0 && partials[index - 1]! < 0) || + (residual > 0 && partials[index - 1]! > 0))) { + const correction = residual * 2; + const rounded = total + correction; + if (correction === rounded - total) total = rounded; + } + return total; +} + +function reduce(records: readonly UsageObservation[]): { reported: ReportedUsage; observed: ReportedUsage; gaps: UsageGap[] } { + const reported: ReportedUsage = {}; + const observed: ReportedUsage = {}; + const gaps = new Map>(); + const gap = (reason: Reason, dimension: UsageDimension): void => { + const fields = gaps.get(reason) ?? new Set(); + fields.add(dimension); + gaps.set(reason, fields); + }; + for (const key of dimensions) { + const values = records.map(record => record.reported[key]).filter((value): value is number => value != null); + let value: number | undefined; + if (values.length) { + if (key === "cost_usd") { + const total = sumCosts(values); + if (Number.isFinite(total) && total * 1e9 < 2 ** 64) value = total; + } else if (key === "peak_request_input_tokens") { + value = values.reduce((maximum, item) => Math.max(maximum, item)); + } else { + const total = values.reduce((sum, item) => sum + BigInt(item), 0n); + if (total <= BigInt(Number.MAX_SAFE_INTEGER)) value = Number(total); + } + } + if (value !== undefined) observed[key] = value; + const causes = new Set(records.flatMap(record => record.gap === undefined ? [] : [record.gap])); + for (const record of records) for (const item of record.gaps ?? []) { + if (item.dimensions.includes(key)) causes.add(item.reason); + } + if (values.length !== records.length) causes.add("missing_usage"); + if (values.length && value === undefined) causes.add("scope_unverified"); + if (value !== undefined && !causes.size) reported[key] = value; + else if (!causes.size) causes.add(records.length ? "missing_usage" : "scope_unverified"); + for (const reason of causes) gap(reason, key); + } + for (const [flag, counter] of [["input_tokens_include_cache", "input_tokens"], ["output_tokens_include_reasoning", "output_tokens"]] as const) { + const flags = new Set(records.filter(record => record.reported[counter] != null).map(record => record.reported[flag])); + const value = flags.values().next().value; + if (flags.size === 1 && value != null) { + observed[flag] = value; + if (reported[counter] !== undefined) reported[flag] = value; + } + else if (records.filter(record => record.reported[counter] != null).length === 1 && flags.size === 1 && value == null) { + // Keep a single literal source even when its convention is unknown. + // Core decides whether a normalized value can be established. + } else if (observed[counter] !== undefined) { + delete reported[counter]; + delete observed[counter]; + if (!records.every(record => record.is_model_call === true && record.model !== undefined)) gap("scope_unverified", counter); + } + } + return { reported, observed, gaps: [...gaps].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0) + .map(([reason, fields]) => ({ reason, dimensions: [...fields] as UsageGap["dimensions"] })) }; +} + +function aligned(records: readonly UsageObservation[]): boolean { + const fields: UsageDimension[] = ["input_tokens", "cached_input_tokens", "cache_write_input_tokens", "output_tokens", "peak_request_input_tokens"]; + if (records.some(record => record.reported.output_tokens_include_reasoning === false)) fields.push("reasoning_tokens"); + const relevant = records.filter(record => fields.some(field => record.reported[field] != null)); + return relevant.length > 0 && relevant.every(record => record.tokens_aligned === true && !record.gaps?.length) && + fields.every(field => new Set(relevant.map(record => record.reported[field] != null)).size === 1); +} + +function requestUsage(record: UsageObservation): ReportedUsage { + // Retain uncertain literal counts in model observations, not complete calls. + const fields: ReportedUsage = {}; + const keys = [...dimensions, "input_tokens_include_cache", "output_tokens_include_reasoning"] as const; + for (const key of keys) { + const value = record.reported[key]; + if (value !== undefined) (fields as Record)[key] = value; + } + for (const gap of record.gaps ?? []) for (const dimension of gap.dimensions) delete fields[dimension]; + return fields; +} + +function textOrder(a: string, b: string): number { + const left = Array.from(a, value => value.codePointAt(0)!); + const right = Array.from(b, value => value.codePointAt(0)!); + for (let i = 0; i < Math.min(left.length, right.length); i++) { + if (left[i] !== right[i]) return left[i]! - right[i]!; + } + return left.length - right.length; +} + +/** Preserve independent raw dimensions; never infer zero or change scheduling. + * Collectors deduplicate source identities before calling this reducer. + */ +export function usageFromObservations(records: readonly UsageObservation[]): AgentUsage { + records.forEach(validate); + const aggregate = reduce(records); + const groups = new Map(); + for (const record of records) { + if (record.model === undefined) continue; + const key = JSON.stringify([record.provider ?? null, record.model]); + const group = groups.get(key) ?? []; + group.push(record); + groups.set(key, group); + } + const models: AccountedModel[] = [...groups.values()].sort((a, b) => + textOrder(a[0]!.provider ?? "", b[0]!.provider ?? "") || textOrder(a[0]!.model!, b[0]!.model!)) + .map(group => ({ model: group[0]!.model!, ...(group[0]!.provider === undefined ? {} : { provider: group[0]!.provider! }), + ...reduce(group), observed_tokens_aligned: aligned(group), + requests_exhaustive: group.every(record => record.is_model_call === true), + ...(group.every(record => record.is_model_call === true) ? { requests: group.map(record => ({ usage: requestUsage(record), + tokens_aligned: record.tokens_aligned === true, ...(record.gap !== undefined ? { gap: record.gap } : {}) })) } : {}) })); + const accounting: UsageAccounting = { models_exhaustive: records.length > 0 && records.every(record => record.model !== undefined && record.gap === undefined), + observed: aggregate.observed, gaps: aggregate.gaps, ...(models.length ? { models } : {}) }; + const base = ["input_tokens", "cached_input_tokens", "input_tokens_include_cache", "output_tokens", "total_tokens", "cost_usd"]; + const detail = ["input_tokens", "cached_input_tokens", "cache_write_input_tokens", "input_tokens_include_cache", "output_tokens", "reasoning_tokens", "output_tokens_include_reasoning", "peak_request_input_tokens"]; + const select = (keys: string[]): JsonObject => Object.fromEntries(Object.entries(aggregate.reported).filter(([key]) => keys.includes(key))) as JsonObject; + return { ...select(base), extensions: { [USAGE_ACCOUNTING_KEY]: accounting as unknown as JsonObject, [DETAIL_KEY]: select(detail) } }; +} diff --git a/adapter-contract/typescript/test/generated/usage-accounting-vectors.ts b/adapter-contract/typescript/test/generated/usage-accounting-vectors.ts new file mode 100644 index 000000000..cbc90c193 --- /dev/null +++ b/adapter-contract/typescript/test/generated/usage-accounting-vectors.ts @@ -0,0 +1,682 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// This file is generated from the canonical adapter-contract JSON Schemas. +// Do not edit it directly; run `npm run generate` instead. + +import type { AgentUsage, UsageAccounting } from "../../src/index.js"; + +// Source: tests/fixtures/usage_accounting/conformance.json, shared with Rust and Python. +export const accountingVectors = [ + { + "name": "reported_zero", + "usage": { + "input_tokens": 0, + "input_tokens_include_cache": true + }, + "accounting": { + "models_exhaustive": false, + "gaps": [] + }, + "expected": { + "input_tokens": { + "complete": 0, + "observed": 0 + } + } + }, + { + "name": "nonexhaustive_models_do_not_certify_aggregate", + "usage": {}, + "accounting": { + "models_exhaustive": false, + "gaps": [], + "models": [ + { + "model": "a", + "gaps": [], + "reported": { + "input_tokens": 100, + "input_tokens_include_cache": true + } + } + ] + }, + "expected": { + "input_tokens": { + "complete": null, + "observed": 100 + } + } + }, + { + "name": "contradictory_aggregate", + "usage": { + "input_tokens": 100, + "input_tokens_include_cache": true + }, + "accounting": { + "models_exhaustive": false, + "gaps": [], + "models": [ + { + "model": "a", + "gaps": [], + "reported": { + "input_tokens": 120, + "input_tokens_include_cache": true + } + } + ] + }, + "expected": { + "input_tokens": { + "complete": null, + "observed": null + } + } + }, + { + "name": "preserve_model_observations", + "usage": {}, + "accounting": { + "models_exhaustive": true, + "gaps": [], + "models": [ + { + "model": "a", + "gaps": [], + "reported": { + "input_tokens": 100, + "input_tokens_include_cache": true + } + }, + { + "model": "b", + "gaps": [ + { + "dimensions": [ + "input_tokens" + ], + "reason": "missing_usage" + } + ], + "observed": { + "input_tokens": 50, + "input_tokens_include_cache": true + } + } + ] + }, + "expected": { + "input_tokens": { + "complete": null, + "observed": 150 + } + } + }, + { + "name": "root_observation_not_added_to_models", + "usage": {}, + "accounting": { + "models_exhaustive": false, + "gaps": [], + "observed": { + "input_tokens": 100, + "input_tokens_include_cache": true + }, + "models": [ + { + "model": "a", + "gaps": [], + "observed": { + "input_tokens": 50, + "input_tokens_include_cache": true + } + } + ] + }, + "expected": { + "input_tokens": { + "complete": null, + "observed": 100 + } + } + }, + { + "name": "independent_cache_observation", + "usage": { + "cached_input_tokens": 5 + }, + "accounting": { + "models_exhaustive": false, + "gaps": [] + }, + "expected": { + "input_tokens": { + "complete": null, + "observed": null + }, + "cached_input_tokens": { + "complete": 5, + "observed": 5 + } + } + }, + { + "name": "raw_observation_scope_is_not_guessed", + "usage": {}, + "accounting": { + "models_exhaustive": false, + "gaps": [], + "observed": { + "input_tokens": 100, + "cached_input_tokens": 50, + "cache_write_input_tokens": 0, + "input_tokens_include_cache": false + } + }, + "expected": { + "input_tokens": { + "complete": null, + "observed": null + }, + "cached_input_tokens": { + "complete": null, + "observed": 50 + } + } + }, + { + "name": "normalize_all_independent_dimensions", + "usage": { + "input_tokens": 3, + "extensions": { + "nemo_fabric.detailed_token_usage.v1": { + "input_tokens": 20, + "cached_input_tokens": 5, + "cache_write_input_tokens": 3, + "input_tokens_include_cache": false, + "output_tokens": 5, + "reasoning_tokens": 4, + "output_tokens_include_reasoning": false, + "peak_request_input_tokens": 18 + } + } + }, + "accounting": { + "models_exhaustive": false, + "gaps": [] + }, + "expected": { + "input_tokens": { + "complete": 28, + "observed": 28 + }, + "cached_input_tokens": { + "complete": 5, + "observed": 5 + }, + "cache_write_input_tokens": { + "complete": 3, + "observed": 3 + }, + "output_tokens": { + "complete": 9, + "observed": 9 + }, + "reasoning_tokens": { + "complete": 4, + "observed": 4 + }, + "total_tokens": { + "complete": 37, + "observed": 37 + }, + "peak_request_input_tokens": { + "complete": 18, + "observed": 18 + } + } + }, + { + "name": "inclusive_input_cannot_be_smaller_than_its_cache_component", + "usage": { + "input_tokens": 100, + "cached_input_tokens": 120, + "input_tokens_include_cache": true, + "extensions": { + "nemo_fabric.detailed_token_usage.v1": { + "output_tokens": 5, + "output_tokens_include_reasoning": true + } + } + }, + "accounting": { + "models_exhaustive": false, + "gaps": [] + }, + "expected": { + "input_tokens": { + "complete": null, + "observed": null + }, + "cached_input_tokens": { + "complete": 120, + "observed": 120 + }, + "output_tokens": { + "complete": 5, + "observed": 5 + }, + "total_tokens": { + "complete": null, + "observed": null + } + } + }, + { + "name": "reasoning_cannot_exceed_complete_output", + "usage": { + "extensions": { + "nemo_fabric.detailed_token_usage.v1": { + "output_tokens": 5, + "reasoning_tokens": 6, + "output_tokens_include_reasoning": true + } + } + }, + "accounting": { + "models_exhaustive": false, + "gaps": [] + }, + "expected": { + "output_tokens": { + "complete": null, + "observed": null + }, + "reasoning_tokens": { + "complete": 6, + "observed": 6 + } + } + }, + { + "name": "peak_cannot_exceed_complete_input", + "usage": { + "input_tokens": 100, + "input_tokens_include_cache": true, + "extensions": { + "nemo_fabric.detailed_token_usage.v1": { + "peak_request_input_tokens": 101 + } + } + }, + "accounting": { + "models_exhaustive": false, + "gaps": [], + "observed": { + "input_tokens": 100, + "input_tokens_include_cache": true + } + }, + "expected": { + "input_tokens": { + "complete": null, + "observed": null + }, + "peak_request_input_tokens": { + "complete": 101, + "observed": 101 + } + } + }, + { + "name": "inconsistent_model_components_do_not_certify_aggregate", + "usage": {}, + "accounting": { + "models_exhaustive": true, + "gaps": [], + "models": [ + { + "model": "a", + "reported": { + "input_tokens": 5, + "cached_input_tokens": 6, + "input_tokens_include_cache": true + }, + "observed": { + "input_tokens": 5, + "input_tokens_include_cache": true + }, + "gaps": [] + } + ] + }, + "expected": { + "input_tokens": { + "complete": null, + "observed": null + }, + "cached_input_tokens": { + "complete": 6, + "observed": 6 + } + } + }, + { + "name": "aligned_exclusive_input_observations_can_be_normalized", + "usage": {}, + "accounting": { + "models_exhaustive": false, + "gaps": [], + "models": [ + { + "model": "a", + "gaps": [], + "observed_tokens_aligned": true, + "observed": { + "input_tokens": 100, + "cached_input_tokens": 20, + "cache_write_input_tokens": 0, + "input_tokens_include_cache": false, + "output_tokens": 5, + "output_tokens_include_reasoning": true, + "peak_request_input_tokens": 120 + } + } + ] + }, + "expected": { + "input_tokens": { + "complete": null, + "observed": 120 + }, + "cached_input_tokens": { + "complete": null, + "observed": 20 + }, + "output_tokens": { + "complete": null, + "observed": 5 + }, + "total_tokens": { + "complete": null, + "observed": 125 + } + } + }, + { + "name": "unconvertible_aggregate_keeps_normalizable_model_subset", + "usage": {}, + "accounting": { + "models_exhaustive": false, + "gaps": [ + { + "dimensions": [ + "input_tokens", + "output_tokens" + ], + "reason": "scope_unverified" + } + ], + "observed": { + "input_tokens": 30, + "input_tokens_include_cache": false, + "output_tokens": 3, + "output_tokens_include_reasoning": true + }, + "models": [ + { + "model": "root-model", + "reported": { + "input_tokens": 20, + "input_tokens_include_cache": false, + "cached_input_tokens": 0, + "cache_write_input_tokens": 0, + "output_tokens": 2, + "output_tokens_include_reasoning": true + }, + "gaps": [] + } + ] + }, + "expected": { + "input_tokens": { + "complete": null, + "observed": 20 + }, + "output_tokens": { + "complete": null, + "observed": 3 + }, + "total_tokens": { + "complete": null, + "observed": 23 + } + } + }, + { + "name": "unconvertible_model_observation_keeps_normalizable_request_subset", + "usage": {}, + "accounting": { + "models_exhaustive": false, + "gaps": [], + "models": [ + { + "model": "root-model", + "reported": {}, + "gaps": [], + "observed": { + "input_tokens": 30, + "input_tokens_include_cache": false + }, + "observed_tokens_aligned": false, + "requests_exhaustive": false, + "requests": [ + { + "usage": { + "input_tokens": 20, + "input_tokens_include_cache": false, + "cached_input_tokens": 0, + "cache_write_input_tokens": 0 + }, + "tokens_aligned": true + } + ] + } + ] + }, + "expected": { + "input_tokens": { + "complete": null, + "observed": 20 + } + } + }, + { + "name": "aggregate_recovered_cache_cannot_exceed_complete_input", + "usage": { + "input_tokens": 100, + "input_tokens_include_cache": true + }, + "accounting": { + "models_exhaustive": true, + "gaps": [], + "models": [ + { + "model": "cache-model", + "gaps": [], + "reported": { + "cached_input_tokens": 200, + "cache_write_input_tokens": 0, + "output_tokens": 5, + "output_tokens_include_reasoning": true + } + } + ] + }, + "expected": { + "input_tokens": { + "complete": null, + "observed": null + }, + "cached_input_tokens": { + "complete": 200, + "observed": 200 + }, + "cache_write_input_tokens": { + "complete": 0, + "observed": 0 + }, + "output_tokens": { + "complete": 5, + "observed": 5 + }, + "total_tokens": { + "complete": null, + "observed": null + } + } + }, + { + "name": "model_recovered_request_cache_cannot_exceed_complete_input", + "usage": {}, + "accounting": { + "models_exhaustive": true, + "gaps": [], + "models": [ + { + "model": "cache-model", + "gaps": [], + "reported": { + "input_tokens": 100, + "input_tokens_include_cache": true, + "output_tokens": 5, + "output_tokens_include_reasoning": true + }, + "requests_exhaustive": true, + "requests": [ + { + "usage": { + "cached_input_tokens": 200, + "cache_write_input_tokens": 0 + } + } + ] + } + ] + }, + "expected": { + "input_tokens": { + "complete": null, + "observed": null + }, + "cached_input_tokens": { + "complete": 200, + "observed": 200 + }, + "cache_write_input_tokens": { + "complete": 0, + "observed": 0 + }, + "output_tokens": { + "complete": 5, + "observed": 5 + }, + "total_tokens": { + "complete": null, + "observed": null + } + } + }, + { + "name": "model_recovered_request_peak_cannot_exceed_complete_input", + "usage": {}, + "accounting": { + "models_exhaustive": true, + "gaps": [], + "models": [ + { + "model": "peak-model", + "gaps": [], + "reported": { + "input_tokens": 100, + "input_tokens_include_cache": true, + "output_tokens": 5, + "output_tokens_include_reasoning": true + }, + "requests_exhaustive": true, + "requests": [ + { + "usage": { + "peak_request_input_tokens": 200 + } + } + ] + } + ] + }, + "expected": { + "input_tokens": { + "complete": null, + "observed": null + }, + "output_tokens": { + "complete": 5, + "observed": 5 + }, + "total_tokens": { + "complete": null, + "observed": null + } + } + }, + { + "name": "model_recovered_request_reasoning_cannot_exceed_complete_output", + "usage": {}, + "accounting": { + "models_exhaustive": true, + "gaps": [], + "models": [ + { + "model": "reasoning-model", + "gaps": [], + "reported": { + "input_tokens": 100, + "input_tokens_include_cache": true, + "output_tokens": 5, + "output_tokens_include_reasoning": true + }, + "requests_exhaustive": true, + "requests": [ + { + "usage": { + "reasoning_tokens": 10 + } + } + ] + } + ] + }, + "expected": { + "input_tokens": { + "complete": 100, + "observed": 100 + }, + "output_tokens": { + "complete": null, + "observed": null + }, + "total_tokens": { + "complete": null, + "observed": null + } + } + } +] satisfies { + name: string; + usage: AgentUsage; + accounting: UsageAccounting; + expected: Record; +}[]; diff --git a/adapter-contract/typescript/test/usage-accounting.test.mjs b/adapter-contract/typescript/test/usage-accounting.test.mjs new file mode 100644 index 000000000..41b439d90 --- /dev/null +++ b/adapter-contract/typescript/test/usage-accounting.test.mjs @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +test("published accounting schema is byte-identical to the canonical schema", async () => { + const canonical = await readFile(new URL("../../../schemas/extensions/usage-accounting.schema.json", import.meta.url)); + const packaged = await readFile(new URL("../schemas/usage-accounting.schema.json", import.meta.url)); + assert.deepEqual(packaged, canonical); +}); diff --git a/adapter-contract/typescript/test/usage-accounting.test.ts b/adapter-contract/typescript/test/usage-accounting.test.ts new file mode 100644 index 000000000..6c63edcd3 --- /dev/null +++ b/adapter-contract/typescript/test/usage-accounting.test.ts @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { UsageAccounting } from "../src/index.js"; +import { accountingVectors } from "./generated/usage-accounting-vectors.js"; + +for (const vector of accountingVectors) { + const accounting: UsageAccounting = vector.accounting; + void accounting; +} + +// @ts-expect-error a producer must state whether model identities are exhaustive +const missingCoverage: UsageAccounting = { gaps: [] }; +// @ts-expect-error unknown reasons cannot silently enter the supported contract +const invalidReason: UsageAccounting = { models_exhaustive: false, gaps: [{ dimensions: ["input_tokens"], reason: "probably_complete" }] }; +void missingCoverage; +void invalidReason; diff --git a/adapter-contract/typescript/test/usage-reducer.test.js b/adapter-contract/typescript/test/usage-reducer.test.js new file mode 100644 index 000000000..4c6e7040c --- /dev/null +++ b/adapter-contract/typescript/test/usage-reducer.test.js @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { usageFromObservations, USAGE_ACCOUNTING_KEY } from "../dist/index.js"; + +const cases = JSON.parse(await readFile(new URL("../../../tests/fixtures/usage_accounting/reducer-cases.json", import.meta.url), "utf8")); +for (const vector of cases) { + test(`shared raw reducer: ${vector.name}`, () => { + const usage = usageFromObservations(vector.records); + const accounting = usage.extensions[USAGE_ACCOUNTING_KEY]; + for (const [field, value] of Object.entries(vector.expected)) { + if (field === "gap_reason") { + assert.deepEqual(accounting.gaps.map(gap => gap.reason), [value]); + continue; + } + const actual = field === "exhaustive" ? accounting.models_exhaustive + : field.startsWith("observed_") ? accounting.observed[field.slice(9) + "_tokens"] + : field === "peak_request_input_tokens" ? usage.extensions["nemo_fabric.detailed_token_usage.v1"][field] + : usage[field]; + assert.equal(actual ?? null, value, field); + } + }); +} +test("unsafe count sums do not erase independent output", () => { + const usage = usageFromObservations([1, 2].map(() => ({ reported: { + input_tokens: Number.MAX_SAFE_INTEGER, input_tokens_include_cache: true, + output_tokens: 1, output_tokens_include_reasoning: true, + } }))); + assert.equal(usage.input_tokens, undefined); + assert.equal(usage.output_tokens, 2); +}); +test("negative and unsafe source counts are rejected", () => { + for (const input_tokens of [-1, 0.5, Number.MAX_SAFE_INTEGER + 1]) { + assert.throws(() => usageFromObservations([{ reported: { input_tokens } }]), RangeError); + } +}); + +test("scoped request gaps retain independent fields without certifying cache", () => { + const vector = cases.find(vector => vector.name === "actual_call_with_scoped_cache_gap"); + const accounting = usageFromObservations(vector.records).extensions[USAGE_ACCOUNTING_KEY]; + const model = accounting.models[0]; + assert.equal(model.observed.cached_input_tokens, 20); + assert.equal(model.reported.cached_input_tokens, undefined); + assert.equal(model.requests[0].usage.cached_input_tokens, undefined); + assert.equal(model.requests[0].usage.input_tokens, 100); + assert.equal(model.requests[0].usage.output_tokens, 5); + assert.equal(model.requests[0].gap, undefined); + assert.equal(model.requests[0].tokens_aligned, true); +}); + +test("request usage omits provider-specific keys outside the wire schema", () => { + const usage = usageFromObservations([{ model: "m", is_model_call: true, + reported: { input_tokens: 7, input_tokens_include_cache: true, prompt_tokens: 7 }, + }]); + const request = usage.extensions[USAGE_ACCOUNTING_KEY].models[0].requests[0].usage; + assert.deepEqual(request, { input_tokens: 7, input_tokens_include_cache: true }); +}); diff --git a/adapters/python/deepagents/README.md b/adapters/python/deepagents/README.md index 2090da4cd..36e8a3f71 100644 --- a/adapters/python/deepagents/README.md +++ b/adapters/python/deepagents/README.md @@ -226,12 +226,59 @@ and Agent Protocol subagents manage their own recursion limit in the remote service. The normalized result includes the final response, buffered messages and -per-step events, LangGraph thread id, token usage (and cost when the provider -reports it), and errors. Usage aggregates the current turn across the main agent -and any delegated subagents (streamed with `subgraphs=True`). Configuration and +per-step events, LangGraph thread id, token usage, and errors. Model callbacks +account for the current invocation, including summarization and in-process +subagents. The `output.usage` diagnostic remains the graph-message subtotal, +labeled `usage_source: graph_state_messages`. Configuration and preflight failures (a missing credential, an absent `deepagents` package, or an invalid MCP server) fail runtime start before an invocation is accepted. +## Token Usage + +Each invocation owns its model-call evidence and freezes it when the adapter +returns. Late callbacks cannot change that result or enter a later invocation. +The descriptor declares `nemo_fabric.usage_accounting.v1` for complete dimensions, +observations and gaps, and `nemo_fabric.detailed_token_usage.v1` for raw cache and +reasoning conventions. An omitted count stays unknown; a reported zero stays zero. + +**Upgrade note:** For providers whose usage is visible only in LangGraph +messages, `RunResult.usage.input_tokens` and `output_tokens` can now be `null`. +The previous graph-message subtotal remains in `RunResult.output.usage`, labeled +`usage_source: graph_state_messages`. It is a diagnostic subtotal, not a +complete invocation count; consumers that previously read the top-level fields +should handle unknown counts rather than substituting this subtotal. + +For adapter-created OpenAI-compatible clients, the collector observes sync and +async calls and their HTTP attempts without changing requests or retries. Normal +reported calls stay known. An actual failed or unfinished attempt without usable +usage creates a gap and retains available observations. An error status never +proves zero spend. A non-model failure keeps completed model-call usage. +Structured-output length errors retain the OpenAI SDK's terminal completion usage. + +Streaming may lose raw field presence in ChatOpenAI: positive counts remain usable, +while zero-filled counters stay unknown. Multiple usage frames are not summed +without evidence that they are deltas. Supported LangChain cache hits contribute +zero new provider tokens without changing stored cache entries. This callback +does not collect provider-reported cost or promote harness cost estimates. +Native OpenAI pricing identity requires every observed request for that call to +use `https://api.openai.com` on the standard HTTPS port. Custom endpoints, +including environment-selected endpoints, keep their token and model evidence +without native-provider pricing identity; an applicable user catalog can price +them explicitly. + +Unmanaged ChatOpenAI model overrides retain available observations with unverified +scope. Other provider clients that expose usage only through LangChain +`usage_metadata` retain graph-message diagnostics but do not yet supply normalized +token observations. Actual remote Agent Protocol dispatch creates a gap; an +unused remote configuration or polling an existing task alone does not. +A host timeout that prevents a returned adapter result remains missing evidence; +the callback cannot amend a result after return. + +Focused source tests use Deep Agents 0.7.9, LangChain Core 1.6.0, LangChain OpenAI +1.1.10 and OpenAI 2.24.0 against scripted transports. Remote dispatch coverage is a +unit test, not a remote-service end-to-end test. These checks do not establish +invoice equivalence or every provider's accounting behavior. + ## Runtime Lifecycle NeMo Fabric starts one local adapter host for every runtime. During runtime start, diff --git a/adapters/python/deepagents/deepagents.fabric-adapter.json b/adapters/python/deepagents/deepagents.fabric-adapter.json index 12eb69898..286967f69 100644 --- a/adapters/python/deepagents/deepagents.fabric-adapter.json +++ b/adapters/python/deepagents/deepagents.fabric-adapter.json @@ -9,20 +9,44 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { - "provider": {"type": "string", "minLength": 1}, - "model": {"type": "string", "minLength": 1}, - "temperature": {"type": "number"}, - "top_p": {"type": "number", "minimum": 0, "maximum": 1}, - "max_tokens": {"type": "integer", "minimum": 1}, - "api_key_env": {"type": "string", "minLength": 1}, - "base_url": {"type": "string", "minLength": 1}, + "provider": { + "type": "string", + "minLength": 1 + }, + "model": { + "type": "string", + "minLength": 1 + }, + "temperature": { + "type": "number" + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "max_tokens": { + "type": "integer", + "minimum": 1 + }, + "api_key_env": { + "type": "string", + "minLength": 1 + }, + "base_url": { + "type": "string", + "minLength": 1 + }, "settings": { "type": "object", "properties": {}, "additionalProperties": false } }, - "required": ["provider", "model"], + "required": [ + "provider", + "model" + ], "additionalProperties": false }, "settings_schema": { @@ -46,7 +70,12 @@ "minItems": 1, "items": { "type": "string", - "enum": ["approve", "edit", "reject", "respond"] + "enum": [ + "approve", + "edit", + "reject", + "respond" + ] }, "description": "Human decisions allowed for this tool call." }, @@ -59,7 +88,9 @@ "description": "JSON Schema for editable tool arguments." } }, - "required": ["allowed_decisions"], + "required": [ + "allowed_decisions" + ], "additionalProperties": false } ] @@ -105,7 +136,11 @@ "description": "JSON Schema for this subagent's structured response." } }, - "required": ["name", "description", "system_prompt"], + "required": [ + "name", + "description", + "system_prompt" + ], "additionalProperties": false }, "async_subagent": { @@ -142,7 +177,11 @@ "description": "Additional string-valued HTTP headers sent to the Agent Protocol server." } }, - "required": ["name", "description", "graph_id"], + "required": [ + "name", + "description", + "graph_id" + ], "additionalProperties": false } }, @@ -160,7 +199,9 @@ "description": "Selects the adapter-owned LocalShellBackend." } }, - "required": ["type"], + "required": [ + "type" + ], "additionalProperties": false, "description": "Optional backend selection. Local shell execution runs directly on the adapter host without process isolation." }, @@ -209,15 +250,363 @@ "mcp", "skills" ], - "system_instruction_modes": ["replace"] + "system_instruction_modes": [ + "replace" + ] }, "telemetry": { "providers": { "relay": { - "outputs": ["atif", "otel", "openinference"] + "outputs": [ + "atif", + "otel", + "openinference" + ] }, "native": { - "outputs": ["otel", "openinference"] + "outputs": [ + "otel", + "openinference" + ] + } + } + }, + "extension_schemas": { + "usage": { + "type": "object", + "properties": { + "nemo_fabric.usage_accounting.v1": { + "additionalProperties": false, + "description": "The declared adapter transport for invocation accounting.", + "properties": { + "gaps": { + "description": "Invocation-wide gaps.", + "items": { + "$ref": "#/$defs/UsageGap" + }, + "type": "array" + }, + "models": { + "description": "One entry per literal provider/model pair.", + "items": { + "$ref": "#/$defs/AccountedModel" + }, + "type": "array" + }, + "models_exhaustive": { + "description": "Every model identity used by owned work is represented; this alone does\nnot certify that any dimension is complete.", + "type": "boolean" + }, + "observed": { + "anyOf": [ + { + "$ref": "#/$defs/ReportedUsage" + }, + { + "type": "null" + } + ], + "description": "Valid deduplicated observations, not a promised mathematical lower bound." + } + }, + "required": [ + "models_exhaustive", + "gaps" + ], + "title": "UsageAccounting", + "type": "object" + }, + "nemo_fabric.detailed_token_usage.v1": { + "$ref": "#/$defs/ReportedUsage" + } + }, + "additionalProperties": false, + "$defs": { + "AccountedModel": { + "additionalProperties": false, + "description": "Evidence for one literal provider/model identity.", + "properties": { + "gaps": { + "description": "Gaps affecting this model.", + "items": { + "$ref": "#/$defs/UsageGap" + }, + "type": "array" + }, + "model": { + "description": "Model identity as reported by the source.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + }, + "observed": { + "anyOf": [ + { + "$ref": "#/$defs/ReportedUsage" + }, + { + "type": "null" + } + ], + "description": "Valid deduplicated observations, potentially incomplete." + }, + "observed_tokens_aligned": { + "default": false, + "description": "Every present observed token dimension describes the same source-call subset.\nFalse unless the producer establishes this, including peak and normalization components.", + "type": "boolean" + }, + "provider": { + "description": "Literal provider identity, when available.", + "minLength": 1, + "pattern": "\\S", + "type": [ + "string", + "null" + ] + }, + "reported": { + "$ref": "#/$defs/ReportedUsage", + "default": {}, + "description": "Complete per-model evidence; absent fields are unreported." + }, + "requests": { + "description": "Optional per-call evidence for exact request-tier pricing.", + "items": { + "$ref": "#/$defs/UsageRequest" + }, + "type": "array" + }, + "requests_exhaustive": { + "default": false, + "description": "The request list covers every source unit contributing to this model.", + "type": "boolean" + } + }, + "required": [ + "model", + "gaps" + ], + "type": "object" + }, + "GapReason": { + "description": "Categories that affect whether a consumer can treat a value as complete.", + "oneOf": [ + { + "const": "unfinished_work", + "description": "Owned work was still running at the return boundary.", + "type": "string" + }, + { + "const": "missing_usage", + "description": "A completed call did not provide usable usage.", + "type": "string" + }, + { + "const": "scope_unverified", + "description": "The supported source does not establish coverage of owned work.", + "type": "string" + } + ] + }, + "ReportedUsage": { + "additionalProperties": false, + "description": "Raw counts in provider/harness conventions. Missing values are never zero.", + "properties": { + "cache_write_input_tokens": { + "description": "Cache writes, independently reported.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "cached_input_tokens": { + "description": "Cache reads, independently reported.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "cost_usd": { + "description": "Provider-reported USD, never a client-side price estimate.", + "format": "double", + "minimum": 0.0, + "type": [ + "number", + "null" + ] + }, + "input_tokens": { + "description": "Raw input count.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "input_tokens_include_cache": { + "description": "Whether input includes both cache categories.", + "type": [ + "boolean", + "null" + ] + }, + "output_tokens": { + "description": "Raw output count.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "output_tokens_include_reasoning": { + "description": "Whether output includes reasoning.", + "type": [ + "boolean", + "null" + ] + }, + "peak_request_input_tokens": { + "description": "Largest request's cache-inclusive input.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "reasoning_tokens": { + "description": "Reasoning count, independently reported.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "total_tokens": { + "description": "Provider-reported total, independent of normalized input plus output.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "UsageDimension": { + "description": "A dimension in raw adapter evidence.", + "oneOf": [ + { + "const": "input_tokens", + "description": "Input tokens.", + "type": "string" + }, + { + "const": "cached_input_tokens", + "description": "Cache reads.", + "type": "string" + }, + { + "const": "cache_write_input_tokens", + "description": "Cache writes.", + "type": "string" + }, + { + "const": "output_tokens", + "description": "Output tokens.", + "type": "string" + }, + { + "const": "reasoning_tokens", + "description": "Reasoning tokens.", + "type": "string" + }, + { + "const": "total_tokens", + "description": "Provider-reported total.", + "type": "string" + }, + { + "const": "peak_request_input_tokens", + "description": "Largest request input.", + "type": "string" + }, + { + "const": "cost_usd", + "description": "Provider-reported USD.", + "type": "string" + } + ] + }, + "UsageGap": { + "additionalProperties": false, + "description": "Missing evidence for one or more raw dimensions.", + "properties": { + "dimensions": { + "description": "Nonempty, unique set of affected dimensions.", + "items": { + "$ref": "#/$defs/UsageDimension" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "reason": { + "$ref": "#/$defs/GapReason", + "description": "Why those dimensions are incomplete." + } + }, + "required": [ + "dimensions", + "reason" + ], + "type": "object" + }, + "UsageRequest": { + "additionalProperties": false, + "description": "One model call retained for request-tier pricing and evidence reconciliation.", + "properties": { + "gap": { + "anyOf": [ + { + "$ref": "#/$defs/GapReason" + }, + { + "type": "null" + } + ], + "description": "Known incompleteness of this call's source coverage." + }, + "tokens_aligned": { + "default": false, + "description": "Present observed fields describe the same request/subset.", + "type": "boolean" + }, + "usage": { + "$ref": "#/$defs/ReportedUsage", + "description": "One deduplicated model-call snapshot, never a session/model aggregate." + } + }, + "required": [ + "usage" + ], + "type": "object" + } } } } diff --git a/adapters/python/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py b/adapters/python/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py index 618957f1f..eab9cb633 100644 --- a/adapters/python/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py +++ b/adapters/python/deepagents/src/nemo_fabric_adapters/deepagents/adapter.py @@ -36,6 +36,10 @@ from nemo_fabric_adapter_contract.models import RuntimeContext from nemo_fabric_adapters.common import instructions as common_instructions from nemo_fabric_adapters.common import lifecycle +from nemo_fabric_adapters.deepagents.usage import AccountingCache +from nemo_fabric_adapters.deepagents.usage import ModelCallUsage +from nemo_fabric_adapters.deepagents.usage import model_metadata +from nemo_fabric_adapters.deepagents.usage import observed_http_clients import nemo_fabric_adapters.common.utils as common_utils HARNESS = "deepagents" @@ -192,7 +196,12 @@ def selected_model_name(model_config: AgentModelConfig) -> str: return model_config.model -def build_chat_model(model_config: AgentModelConfig) -> tuple[Any, str, str | None]: +def build_chat_model( + model_config: AgentModelConfig, + *, + http_clients: tuple[Any, Any] | None = None, + observer: str | None = None, +) -> tuple[Any, str, str | None]: """Build a LangChain chat model from Fabric model config. Known OpenAI-compatible providers use ``ChatOpenAI``. Other providers are @@ -235,7 +244,22 @@ def build_chat_model(model_config: AgentModelConfig) -> tuple[Any, str, str | No from langchain_openai import ChatOpenAI - kwargs = {"model": model_name, "api_key": api_key} + kwargs = {"model": model_name, "api_key": api_key, "cache": AccountingCache()} + if http_clients is not None: + kwargs.update( + http_client=http_clients[0], + http_async_client=http_clients[1], + metadata=model_metadata(observer), + ) + # Client injection would otherwise turn off LangChain's native-OpenAI + # automatic stream-usage default. Preserve its original condition. + if ( + base_url is None + and "OPENAI_BASE_URL" not in os.environ + and os.environ.get("OPENAI_API_BASE") is None + and os.environ.get("OPENAI_PROXY") is None + ): + kwargs["stream_usage"] = True if base_url: kwargs["base_url"] = base_url if temperature is not None: @@ -609,6 +633,9 @@ def __init__(self) -> None: self._callback_handler_type: Any = None self._telemetry_quarantine: str | None = None self._telemetry_quarantine_cause: str | None = None + self._usage_observer: str | None = None + self._usage_remote_names: frozenset[str] = frozenset() + self._usage_http_clients: tuple[Any, Any] | None = None async def start(self, payload: dict[str, Any]) -> None: if self._started: @@ -627,7 +654,17 @@ async def start(self, payload: dict[str, Any]) -> None: settings = agent_config.harness.settings if agent_config.harness else {} base_dir = common_utils.base_dir(payload) runtime_id = runtime_context.runtime_id - model, self._model_name, self._base_url = build_chat_model(model_config) + if ( + model_config.provider in OPENAI_COMPATIBLE_PROVIDERS + and not os.environ.get("OPENAI_PROXY") + ): + self._usage_observer = uuid.uuid4().hex + self._usage_http_clients = observed_http_clients() + model, self._model_name, self._base_url = build_chat_model( + model_config, + http_clients=self._usage_http_clients, + observer=self._usage_observer, + ) self._runtime_id = runtime_id self._thread_id = uuid.uuid4().hex @@ -659,6 +696,11 @@ async def start(self, payload: dict[str, Any]) -> None: model, settings, ) + self._usage_remote_names = frozenset( + subagent["name"] + for subagent in agent_kwargs.get("subagents", []) + if isinstance(subagent, dict) and "graph_id" in subagent + ) self._checkpointer = await open_checkpointer( checkpointer_path(runtime_context, base_dir, runtime_id) ) @@ -761,28 +803,9 @@ async def invoke( ) failed = bool(output.pop("failed", False)) reported_error = output.pop("error", None) - raw_usage = output.get("usage") - usage = raw_usage if isinstance(raw_usage, dict) else {} - token_fields = { - "input_tokens": usage.get("prompt_tokens"), - "output_tokens": usage.get("completion_tokens"), - "total_tokens": usage.get("total_tokens"), - } - tokens = { - name: value - for name, value in token_fields.items() - if isinstance(value, int) - and not isinstance(value, bool) - and 0 <= value <= (1 << 64) - 1 - } - cost = usage.get("cost") - if ( - not isinstance(cost, (int, float)) - or isinstance(cost, bool) - or not math.isfinite(cost) - or cost < 0 - ): - cost = None + # Preserve the existing output diagnostic without promoting its message + # subtotal to typed invocation accounting. + output["usage_source"] = "graph_state_messages" return AgentRunResult( status=AgentRunStatus.FAILED if failed else AgentRunStatus.SUCCEEDED, output=output, @@ -794,11 +817,7 @@ async def invoke( if failed else None ), - usage=( - AgentUsage(**tokens, cost_usd=cost) - if tokens or cost is not None - else None - ), + usage=outcome.accounted_usage, ) async def _invoke_with_telemetry( @@ -874,24 +893,34 @@ async def _invoke_agent( ) -> TurnOutcome: """Run one agent turn, normalizing an invocation failure into an error string.""" + recorder = ModelCallUsage(self._usage_observer, self._usage_remote_names) try: - result_state, events, turn_messages = await invoke_compiled_agent( - self._agent, - user_message, - self._thread_id, - callbacks=callbacks, - ) + with recorder.scope(): + result_state, events, turn_messages = await invoke_compiled_agent( + self._agent, + user_message, + self._thread_id, + callbacks=[*(callbacks or []), recorder], + ) except GraphRecursionError: return TurnOutcome( error="Deep Agents reached its graph recursion limit.", error_code="deepagents_recursion_limit_reached", + accounted_usage=recorder.finish(), ) except Exception as exc: # normalized adapter failure - return TurnOutcome(error=_error_text(exc)) + return TurnOutcome( + error=_error_text(exc), accounted_usage=recorder.finish() + ) + finally: + # Also close ownership on cancellation, while preserving cancellation + # propagation. A later callback cannot mutate any returned result. + recorder.finish() return TurnOutcome( result_state=result_state, events=events, turn_messages=turn_messages, + accounted_usage=recorder.finish(), ) def _telemetry_output( @@ -931,6 +960,9 @@ def _telemetry_output( async def stop(self) -> None: checkpointer = self._checkpointer + clients, self._usage_http_clients = self._usage_http_clients, None + self._usage_observer = None + self._usage_remote_names = frozenset() self._start_payload = None self._runtime_id = None self._model_name = None @@ -947,8 +979,15 @@ async def stop(self) -> None: self._relay_plugin_config = None self._callback_handler_type = None self._started = False - if checkpointer is not None: - await close_checkpointer(checkpointer) + try: + if checkpointer is not None: + await close_checkpointer(checkpointer) + finally: + if clients is not None: + try: + clients[0].close() + finally: + await clients[1].aclose() def _apply_callbacks( @@ -975,9 +1014,8 @@ async def invoke_compiled_agent( """Run a compiled agent and return state, events, and current-turn messages. On a later runtime invocation the final ``values`` state also replays - prior-turn messages, so usage/cost must be aggregated from the messages - emitted *this* turn — the ``updates`` deltas — rather than the full final - state. + prior-turn messages. Keep graph-message diagnostics limited to this turn's + ``updates`` deltas. Typed accounting observes model callbacks separately. ``callbacks`` (e.g. the NeMo Relay callback handler) are appended to the LangGraph run config; any callbacks already present are preserved rather than @@ -996,7 +1034,7 @@ async def invoke_compiled_agent( final: Any = None # subgraphs=True surfaces subagent (delegated ``task``) steps as 3-tuples whose # namespace identifies the subgraph. Folding their message deltas into this turn - # keeps usage/cost accurate for delegating runs; the final ``values`` state is + # keeps message diagnostics scoped to this turn; the final ``values`` state is # taken from the main graph only (empty namespace). stream = ( agent.astream(inputs, config, stream_mode=["updates", "values"], subgraphs=True) @@ -1033,6 +1071,7 @@ class TurnOutcome(NamedTuple): """ result_state: Any = None + accounted_usage: AgentUsage | None = None events: list[dict[str, Any]] | None = None turn_messages: list[dict[str, Any]] | None = None error: str | None = None @@ -1260,6 +1299,8 @@ def _final_response(messages: list[dict[str, Any]]) -> Any: def _aggregate_usage(messages: list[dict[str, Any]]) -> dict[str, Any] | None: + """Retain the historical graph-message diagnostic, without certifying scope.""" + totals: dict[str, Any] = {} cost = 0.0 has_cost = False @@ -1277,8 +1318,8 @@ def _aggregate_usage(messages: list[dict[str, Any]]) -> dict[str, Any] | None: and 0 <= value <= (1 << 64) - 1 ): totals[target] = int(totals.get(target, 0)) + value - # Cost is not part of LangChain's UsageMetadata; surface it only when a - # model/provider reports it on the usage or response metadata. + # Keep historical graph-message cost as a source diagnostic. Typed + # accounting does not consume this value or infer its pricing basis. candidate = ( usage.get("total_cost") or usage.get("cost") or _metadata_cost(message) ) diff --git a/adapters/python/deepagents/src/nemo_fabric_adapters/deepagents/usage.py b/adapters/python/deepagents/src/nemo_fabric_adapters/deepagents/usage.py new file mode 100644 index 000000000..3a079bb43 --- /dev/null +++ b/adapters/python/deepagents/src/nemo_fabric_adapters/deepagents/usage.py @@ -0,0 +1,465 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Invocation-owned LangChain observations and passive HTTP attempt evidence.""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +from contextvars import ContextVar +from copy import deepcopy +from dataclasses import dataclass, field +from threading import RLock +from typing import Any +from uuid import UUID + +from langchain_core.caches import BaseCache +from langchain_core.callbacks import BaseCallbackHandler +from langchain_core.globals import get_llm_cache +from openai import LengthFinishReasonError +from nemo_fabric_adapter_contract.models import AgentUsage, GapReason, ReportedUsage +from nemo_fabric_adapter_contract.usage import UsageObservation, usage_from_observations + +_OWNER: ContextVar[ModelCallUsage | None] = ContextVar( + "fabric_deepagents_usage_owner", default=None +) +_ACTIVE_CALL: ContextVar[tuple[ModelCallUsage, UUID] | None] = ContextVar( + "fabric_deepagents_active_call", default=None +) +_ATTEMPT = "nemo_fabric.usage_attempt" +_CACHE_HIT = "nemo_fabric.local_cache_hit" +_OBSERVER = "nemo_fabric.usage_observer" + + +@dataclass +class _Call: + attempts: list[int] = field(default_factory=list) + tracked: bool = False + openai_format: bool = False + finished: bool = False + failed: bool = False + cached: bool = False + reported: ReportedUsage = field(default_factory=ReportedUsage) + model: str | None = None + provider: str | None = None + cache_scope_unverified: bool = False + stream_updates: int = 0 + stream_observed: ReportedUsage = field(default_factory=ReportedUsage) + + +class ModelCallUsage(BaseCallbackHandler): + """One handler per invocation; returned snapshots never change afterwards. + + HTTP starts/statuses expose SDK-internal retry gaps without counting tokens + again. No HTTP status proves zero processed tokens. Callbacks on unsupported + clients remain observations with unverified scope. Local cache replay is + identified by the supported cache wrapper, not by absence of HTTP alone. + """ + + run_inline = True + + def __init__( + self, + observer: str | None, + remote_names: frozenset[str] = frozenset(), + ) -> None: + super().__init__() + self._observer = observer + self._remote_names = remote_names + self._calls: dict[UUID, _Call] = {} + self._statuses: list[int | None] = [] + self._unassigned_request = False + self._remote_used = False + self._closed = False + self._snapshot: AgentUsage | None = None + self._lock = RLock() + + @contextmanager + def scope(self) -> Iterator[None]: + """Propagate this owner into graph descendants and worker threads.""" + token = _OWNER.set(self) + call_token = _ACTIVE_CALL.set(None) + try: + yield + finally: + _ACTIVE_CALL.reset(call_token) + _OWNER.reset(token) + + def on_chat_model_start( + self, serialized: Any, messages: Any, *, run_id: UUID, **kwargs: Any + ) -> None: + metadata = kwargs.get("metadata") or {} + with self._lock: + if not self._closed: + identity = ( + serialized.get("id", []) if isinstance(serialized, dict) else [] + ) + tracked = ( + _OWNER.get() is self + and self._observer is not None + and metadata.get(_OBSERVER) == self._observer + ) + self._calls.setdefault( + run_id, + _Call( + tracked=tracked, + cache_scope_unverified=not tracked + and get_llm_cache() is not None, + openai_format=bool(identity and identity[-1] == "ChatOpenAI"), + ), + ) + if tracked: + _ACTIVE_CALL.set((self, run_id)) + + def on_llm_start( + self, serialized: Any, prompts: Any, *, run_id: UUID, **kwargs: Any + ) -> None: + self.on_chat_model_start(serialized, prompts, run_id=run_id, **kwargs) + + def on_llm_new_token( + self, token: str, *, run_id: UUID, chunk: Any = None, **kwargs: Any + ) -> None: + message = getattr(chunk, "message", None) + if not isinstance(getattr(message, "usage_metadata", None), dict): + return + with self._lock: + call = self._calls.get(run_id) + if self._closed or call is None: + return + call.stream_updates += 1 + counts = _raw_openai_usage(message, known_convention=call.openai_format) + call.stream_observed = ReportedUsage.from_mapping( + {**call.stream_observed.to_mapping(), **counts.to_mapping()} + ) + + def on_llm_end(self, response: Any, *, run_id: UUID, **kwargs: Any) -> None: + with self._lock: + if self._closed: + return + call = self._calls.setdefault(run_id, _Call()) + generations = response.generations[0] if response.generations else [] + message = getattr(generations[0], "message", None) if generations else None + metadata = getattr(message, "response_metadata", {}) or {} + call.cached = bool(metadata.get(_CACHE_HIT)) + model = metadata.get("model_name") + call.model = model if isinstance(model, str) and model.strip() else None + # Retain counters and identity only, not prompts or response bodies. + call.reported = _raw_openai_usage( + message, known_convention=call.openai_format + ) + if call.stream_updates > 1: + # The callback does not establish whether repeated usage frames + # are deltas or cumulative snapshots. Never charge their sum. + call.reported = call.stream_observed + if call.cache_scope_unverified and not call.cached: + call.reported = ReportedUsage() + call.finished = True + if _ACTIVE_CALL.get() == (self, run_id): + _ACTIVE_CALL.set(None) + + def on_llm_error( + self, error: BaseException, *, run_id: UUID, **kwargs: Any + ) -> None: + with self._lock: + if self._closed: + return + call = self._calls.setdefault(run_id, _Call()) + if call.openai_format and isinstance(error, LengthFinishReasonError): + # SDK schema parsing failed after a terminal provider response. + # Its public completion retains literal usage, including omissions. + completion = error.completion + raw = ( + completion.usage.model_dump(exclude_unset=True) + if completion.usage is not None + else {} + ) + call.reported = _openai_counts(raw, known_convention=True) + model = completion.model + call.model = model if isinstance(model, str) and model.strip() else None + call.finished = True + else: + partial = kwargs.get("response") + if partial is not None: + self.on_llm_end(partial, run_id=run_id) + call.finished = call.failed = True + if _ACTIVE_CALL.get() == (self, run_id): + _ACTIVE_CALL.set(None) + + def on_tool_start(self, serialized: Any, input_str: str, **kwargs: Any) -> None: + inputs = kwargs.get("inputs") + name = serialized.get("name") if isinstance(serialized, dict) else None + if ( + isinstance(inputs, dict) + and self._remote_names + and ( + name in {"task", "start_async_task"} + and inputs.get("subagent_type") in self._remote_names + # Updating a remote task creates another run, without a + # subagent_type argument. Checking its status does not. + or name == "update_async_task" + ) + ): + with self._lock: + if not self._closed: + self._remote_used = True + + def request_started(self, request: Any) -> None: + """Keep only an owner/index association, never request payloads or credentials.""" + with self._lock: + if not self._closed: + index = len(self._statuses) + self._statuses.append(None) + active = _ACTIVE_CALL.get() + call = ( + self._calls.get(active[1]) + if active is not None and active[0] is self + else None + ) + if call is None: + self._unassigned_request = True + else: + call.attempts.append(index) + url = getattr(request, "url", None) + provider = ( + "openai" + if getattr(url, "scheme", None) == "https" + and getattr(url, "host", None) == "api.openai.com" + and getattr(url, "port", None) in (None, 443) + else None + ) + # Configured labels establish the protocol, not billing. + # Every physical attempt must use the native endpoint. + if len(call.attempts) == 1: + call.provider = provider + elif call.provider != provider: + call.provider = None + request.extensions[_ATTEMPT] = (self, index) + + def response_received(self, index: int, status: int) -> None: + with self._lock: + if not self._closed: + self._statuses[index] = status + + def finish(self) -> AgentUsage: + """Freeze a complete/observed projection at the harness's ordinary return boundary.""" + with self._lock: + if self._snapshot is not None: + return self._snapshot + self._closed = True + root_gap = self._remote_used or self._unassigned_request + observations = [] + for call in self._calls.values(): + counts = call.reported + if call.cached: + counts = ReportedUsage( + input_tokens=0, + cached_input_tokens=0, + cache_write_input_tokens=0, + input_tokens_include_cache=True, + output_tokens=0, + reasoning_tokens=0, + output_tokens_include_reasoning=True, + total_tokens=0, + peak_request_input_tokens=0, + ) + statuses = [self._statuses[index] for index in call.attempts] + gap = ( + GapReason.UNFINISHED_WORK + if not call.finished + else GapReason.MISSING_USAGE + if call.failed + or any( + status is None or not 200 <= status < 300 for status in statuses + ) + else GapReason.SCOPE_UNVERIFIED + if not call.cached + and ( + not call.tracked + or call.stream_updates > 1 + or len(statuses) != 1 + ) + else None + ) + observations.append( + UsageObservation( + is_model_call=True, + reported=counts, + model=call.model, + provider=call.provider if call.tracked else None, + gap=gap, + tokens_aligned=call.cached + or ( + call.tracked and call.finished and call.stream_updates <= 1 + ), + ) + ) + if root_gap: + observations.append( + UsageObservation( + reported=ReportedUsage(), gap=GapReason.SCOPE_UNVERIFIED + ) + ) + self._snapshot = usage_from_observations(observations) + return self._snapshot + + +def _count(value: Any) -> int | None: + return value if type(value) is int and 0 <= value <= (1 << 64) - 1 else None + + +def _positive_count(value: Any) -> int | None: + return value if _count(value) is not None and value > 0 else None + + +def _raw_openai_usage(message: Any, *, known_convention: bool) -> ReportedUsage: + metadata = getattr(message, "response_metadata", {}) or {} + raw = metadata.get("token_usage") + if not isinstance(raw, dict): + # ChatOpenAI streaming drops raw field presence. Positive input/output + # counters still come directly from provider usage; zero can be a filled + # default. Keep independently valid positives and never claim its derived + # total is a provider-reported total. + usage = getattr(message, "usage_metadata", None) + if not known_convention or not isinstance(usage, dict): + return ReportedUsage() + input_tokens = _positive_count(usage.get("input_tokens")) + output_tokens = _positive_count(usage.get("output_tokens")) + input_details = usage.get("input_token_details") or {} + output_details = usage.get("output_token_details") or {} + return ReportedUsage( + input_tokens=input_tokens, + output_tokens=output_tokens, + input_tokens_include_cache=True if input_tokens is not None else None, + output_tokens_include_reasoning=True if output_tokens is not None else None, + cached_input_tokens=_positive_count(input_details.get("cache_read")), + reasoning_tokens=_positive_count(output_details.get("reasoning")), + peak_request_input_tokens=input_tokens, + ) + return _openai_counts(raw, known_convention=known_convention) + + +def _openai_counts(raw: dict[str, Any], *, known_convention: bool) -> ReportedUsage: + prompt = _count(raw.get("prompt_tokens")) + completion = _count(raw.get("completion_tokens")) + prompt_details = raw.get("prompt_tokens_details") + completion_details = raw.get("completion_tokens_details") + prompt_details = prompt_details if isinstance(prompt_details, dict) else {} + completion_details = ( + completion_details if isinstance(completion_details, dict) else {} + ) + return ReportedUsage( + input_tokens=prompt, + cached_input_tokens=_count(prompt_details.get("cached_tokens")), + input_tokens_include_cache=True + if prompt is not None and known_convention + else None, + output_tokens=completion, + output_tokens_include_reasoning=True + if completion is not None and known_convention + else None, + reasoning_tokens=_count(completion_details.get("reasoning_tokens")), + total_tokens=_count(raw.get("total_tokens")), + peak_request_input_tokens=prompt if known_convention else None, + ) + + +def request_hook(request: Any) -> None: + owner = _OWNER.get() + if owner is not None: + owner.request_started(request) + + +def response_hook(response: Any) -> None: + attempt = response.request.extensions.get(_ATTEMPT) + if attempt is not None: + owner, index = attempt + owner.response_received(index, response.status_code) + + +async def async_request_hook(request: Any) -> None: + request_hook(request) + + +async def async_response_hook(response: Any) -> None: + response_hook(response) + + +class AccountingCache(BaseCache): + """Delegate to the ambient cache without recharging cached historical usage. + + Returned generations are copied before marking a hit. Stored entries and + the global cache are not changed; a disabled cache remains a miss. + """ + + @staticmethod + def _mark(generations: Any) -> Any: + if not isinstance(generations, list): + return generations + result = deepcopy(generations) + for generation in result: + message = getattr(generation, "message", None) + if message is not None: + message.response_metadata[_CACHE_HIT] = True + return result + + def lookup(self, prompt: str, llm_string: str) -> Any: + cache = get_llm_cache() + return None if cache is None else self._mark(cache.lookup(prompt, llm_string)) + + async def alookup(self, prompt: str, llm_string: str) -> Any: + cache = get_llm_cache() + return ( + None + if cache is None + else self._mark(await cache.alookup(prompt, llm_string)) + ) + + def update(self, prompt: str, llm_string: str, return_val: Any) -> None: + cache = get_llm_cache() + if cache is not None: + cache.update(prompt, llm_string, return_val) + + async def aupdate(self, prompt: str, llm_string: str, return_val: Any) -> None: + cache = get_llm_cache() + if cache is not None: + await cache.aupdate(prompt, llm_string, return_val) + + def clear(self, **kwargs: Any) -> None: + cache = get_llm_cache() + if cache is not None: + cache.clear(**kwargs) + + +def model_metadata(observer: str | None) -> dict[str, str]: + """Model-local metadata is not inherited by separately constructed child models.""" + return {} if observer is None else {_OBSERVER: observer} + + +def observed_http_clients() -> tuple[Any, Any] | None: + """Use the supported OpenAI client injection with LangChain's default timeout. + + LangChain 1.1.10 supplies timeout=None to these same OpenAI default client + classes. Hooks observe dispatch only; they do not replace transports, + disable SDK retries, or consume response streams. + """ + try: + from openai import DefaultAsyncHttpxClient, DefaultHttpxClient + except ImportError: + # An older optional observer surface limits accounting scope rather than + # preventing an otherwise supported harness from running. + return None + + return ( + DefaultHttpxClient( + timeout=None, + event_hooks={"request": [request_hook], "response": [response_hook]}, + ), + DefaultAsyncHttpxClient( + timeout=None, + event_hooks={ + "request": [async_request_hook], + "response": [async_response_hook], + }, + ), + ) diff --git a/crates/fabric-cli/assets/adapters/deepagents/deepagents.fabric-adapter.json b/crates/fabric-cli/assets/adapters/deepagents/deepagents.fabric-adapter.json index 12eb69898..286967f69 100644 --- a/crates/fabric-cli/assets/adapters/deepagents/deepagents.fabric-adapter.json +++ b/crates/fabric-cli/assets/adapters/deepagents/deepagents.fabric-adapter.json @@ -9,20 +9,44 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { - "provider": {"type": "string", "minLength": 1}, - "model": {"type": "string", "minLength": 1}, - "temperature": {"type": "number"}, - "top_p": {"type": "number", "minimum": 0, "maximum": 1}, - "max_tokens": {"type": "integer", "minimum": 1}, - "api_key_env": {"type": "string", "minLength": 1}, - "base_url": {"type": "string", "minLength": 1}, + "provider": { + "type": "string", + "minLength": 1 + }, + "model": { + "type": "string", + "minLength": 1 + }, + "temperature": { + "type": "number" + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "max_tokens": { + "type": "integer", + "minimum": 1 + }, + "api_key_env": { + "type": "string", + "minLength": 1 + }, + "base_url": { + "type": "string", + "minLength": 1 + }, "settings": { "type": "object", "properties": {}, "additionalProperties": false } }, - "required": ["provider", "model"], + "required": [ + "provider", + "model" + ], "additionalProperties": false }, "settings_schema": { @@ -46,7 +70,12 @@ "minItems": 1, "items": { "type": "string", - "enum": ["approve", "edit", "reject", "respond"] + "enum": [ + "approve", + "edit", + "reject", + "respond" + ] }, "description": "Human decisions allowed for this tool call." }, @@ -59,7 +88,9 @@ "description": "JSON Schema for editable tool arguments." } }, - "required": ["allowed_decisions"], + "required": [ + "allowed_decisions" + ], "additionalProperties": false } ] @@ -105,7 +136,11 @@ "description": "JSON Schema for this subagent's structured response." } }, - "required": ["name", "description", "system_prompt"], + "required": [ + "name", + "description", + "system_prompt" + ], "additionalProperties": false }, "async_subagent": { @@ -142,7 +177,11 @@ "description": "Additional string-valued HTTP headers sent to the Agent Protocol server." } }, - "required": ["name", "description", "graph_id"], + "required": [ + "name", + "description", + "graph_id" + ], "additionalProperties": false } }, @@ -160,7 +199,9 @@ "description": "Selects the adapter-owned LocalShellBackend." } }, - "required": ["type"], + "required": [ + "type" + ], "additionalProperties": false, "description": "Optional backend selection. Local shell execution runs directly on the adapter host without process isolation." }, @@ -209,15 +250,363 @@ "mcp", "skills" ], - "system_instruction_modes": ["replace"] + "system_instruction_modes": [ + "replace" + ] }, "telemetry": { "providers": { "relay": { - "outputs": ["atif", "otel", "openinference"] + "outputs": [ + "atif", + "otel", + "openinference" + ] }, "native": { - "outputs": ["otel", "openinference"] + "outputs": [ + "otel", + "openinference" + ] + } + } + }, + "extension_schemas": { + "usage": { + "type": "object", + "properties": { + "nemo_fabric.usage_accounting.v1": { + "additionalProperties": false, + "description": "The declared adapter transport for invocation accounting.", + "properties": { + "gaps": { + "description": "Invocation-wide gaps.", + "items": { + "$ref": "#/$defs/UsageGap" + }, + "type": "array" + }, + "models": { + "description": "One entry per literal provider/model pair.", + "items": { + "$ref": "#/$defs/AccountedModel" + }, + "type": "array" + }, + "models_exhaustive": { + "description": "Every model identity used by owned work is represented; this alone does\nnot certify that any dimension is complete.", + "type": "boolean" + }, + "observed": { + "anyOf": [ + { + "$ref": "#/$defs/ReportedUsage" + }, + { + "type": "null" + } + ], + "description": "Valid deduplicated observations, not a promised mathematical lower bound." + } + }, + "required": [ + "models_exhaustive", + "gaps" + ], + "title": "UsageAccounting", + "type": "object" + }, + "nemo_fabric.detailed_token_usage.v1": { + "$ref": "#/$defs/ReportedUsage" + } + }, + "additionalProperties": false, + "$defs": { + "AccountedModel": { + "additionalProperties": false, + "description": "Evidence for one literal provider/model identity.", + "properties": { + "gaps": { + "description": "Gaps affecting this model.", + "items": { + "$ref": "#/$defs/UsageGap" + }, + "type": "array" + }, + "model": { + "description": "Model identity as reported by the source.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + }, + "observed": { + "anyOf": [ + { + "$ref": "#/$defs/ReportedUsage" + }, + { + "type": "null" + } + ], + "description": "Valid deduplicated observations, potentially incomplete." + }, + "observed_tokens_aligned": { + "default": false, + "description": "Every present observed token dimension describes the same source-call subset.\nFalse unless the producer establishes this, including peak and normalization components.", + "type": "boolean" + }, + "provider": { + "description": "Literal provider identity, when available.", + "minLength": 1, + "pattern": "\\S", + "type": [ + "string", + "null" + ] + }, + "reported": { + "$ref": "#/$defs/ReportedUsage", + "default": {}, + "description": "Complete per-model evidence; absent fields are unreported." + }, + "requests": { + "description": "Optional per-call evidence for exact request-tier pricing.", + "items": { + "$ref": "#/$defs/UsageRequest" + }, + "type": "array" + }, + "requests_exhaustive": { + "default": false, + "description": "The request list covers every source unit contributing to this model.", + "type": "boolean" + } + }, + "required": [ + "model", + "gaps" + ], + "type": "object" + }, + "GapReason": { + "description": "Categories that affect whether a consumer can treat a value as complete.", + "oneOf": [ + { + "const": "unfinished_work", + "description": "Owned work was still running at the return boundary.", + "type": "string" + }, + { + "const": "missing_usage", + "description": "A completed call did not provide usable usage.", + "type": "string" + }, + { + "const": "scope_unverified", + "description": "The supported source does not establish coverage of owned work.", + "type": "string" + } + ] + }, + "ReportedUsage": { + "additionalProperties": false, + "description": "Raw counts in provider/harness conventions. Missing values are never zero.", + "properties": { + "cache_write_input_tokens": { + "description": "Cache writes, independently reported.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "cached_input_tokens": { + "description": "Cache reads, independently reported.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "cost_usd": { + "description": "Provider-reported USD, never a client-side price estimate.", + "format": "double", + "minimum": 0.0, + "type": [ + "number", + "null" + ] + }, + "input_tokens": { + "description": "Raw input count.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "input_tokens_include_cache": { + "description": "Whether input includes both cache categories.", + "type": [ + "boolean", + "null" + ] + }, + "output_tokens": { + "description": "Raw output count.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "output_tokens_include_reasoning": { + "description": "Whether output includes reasoning.", + "type": [ + "boolean", + "null" + ] + }, + "peak_request_input_tokens": { + "description": "Largest request's cache-inclusive input.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "reasoning_tokens": { + "description": "Reasoning count, independently reported.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "total_tokens": { + "description": "Provider-reported total, independent of normalized input plus output.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "UsageDimension": { + "description": "A dimension in raw adapter evidence.", + "oneOf": [ + { + "const": "input_tokens", + "description": "Input tokens.", + "type": "string" + }, + { + "const": "cached_input_tokens", + "description": "Cache reads.", + "type": "string" + }, + { + "const": "cache_write_input_tokens", + "description": "Cache writes.", + "type": "string" + }, + { + "const": "output_tokens", + "description": "Output tokens.", + "type": "string" + }, + { + "const": "reasoning_tokens", + "description": "Reasoning tokens.", + "type": "string" + }, + { + "const": "total_tokens", + "description": "Provider-reported total.", + "type": "string" + }, + { + "const": "peak_request_input_tokens", + "description": "Largest request input.", + "type": "string" + }, + { + "const": "cost_usd", + "description": "Provider-reported USD.", + "type": "string" + } + ] + }, + "UsageGap": { + "additionalProperties": false, + "description": "Missing evidence for one or more raw dimensions.", + "properties": { + "dimensions": { + "description": "Nonempty, unique set of affected dimensions.", + "items": { + "$ref": "#/$defs/UsageDimension" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "reason": { + "$ref": "#/$defs/GapReason", + "description": "Why those dimensions are incomplete." + } + }, + "required": [ + "dimensions", + "reason" + ], + "type": "object" + }, + "UsageRequest": { + "additionalProperties": false, + "description": "One model call retained for request-tier pricing and evidence reconciliation.", + "properties": { + "gap": { + "anyOf": [ + { + "$ref": "#/$defs/GapReason" + }, + { + "type": "null" + } + ], + "description": "Known incompleteness of this call's source coverage." + }, + "tokens_aligned": { + "default": false, + "description": "Present observed fields describe the same request/subset.", + "type": "boolean" + }, + "usage": { + "$ref": "#/$defs/ReportedUsage", + "description": "One deduplicated model-call snapshot, never a session/model aggregate." + } + }, + "required": [ + "usage" + ], + "type": "object" + } } } } diff --git a/crates/fabric-cli/src/presets.rs b/crates/fabric-cli/src/presets.rs index a3ec93272..44a3fbb6c 100644 --- a/crates/fabric-cli/src/presets.rs +++ b/crates/fabric-cli/src/presets.rs @@ -308,6 +308,7 @@ fn config( telemetry: None, system_stats: Some(Default::default()), relay: None, + pricing: None, extensions: BTreeMap::new(), } } diff --git a/crates/fabric-cli/templates/rust/main.rs.tmpl b/crates/fabric-cli/templates/rust/main.rs.tmpl index 611a62b46..c281b0105 100644 --- a/crates/fabric-cli/templates/rust/main.rs.tmpl +++ b/crates/fabric-cli/templates/rust/main.rs.tmpl @@ -62,6 +62,7 @@ fn build_config() -> FabricConfig { telemetry: None, system_stats: Some(Default::default()), relay: None, + pricing: None, extensions: BTreeMap::new(), } } diff --git a/crates/fabric-core/data/model-pricing.json b/crates/fabric-core/data/model-pricing.json new file mode 100644 index 000000000..376658dda --- /dev/null +++ b/crates/fabric-core/data/model-pricing.json @@ -0,0 +1,71 @@ +{ + "version": 1, + "entries": [ + { + "provider": "anthropic", + "model_id": "claude-opus-4-5", + "aliases": ["claude-opus-4-5-20251101"], + "currency": "USD", + "unit": "per_token", + "rates": { + "input_per_million": 5.0, + "output_per_million": 25.0, + "cache_read_per_million": 0.5, + "cache_write_per_million": 6.25 + }, + "prompt_cache": {"read_accounting": "separate"}, + "pricing_as_of": "2026-10-08", + "pricing_source": "https://platform.claude.com/docs/en/about-claude/pricing" + }, + { + "provider": "anthropic", + "model_id": "claude-sonnet-4-5", + "aliases": ["claude-sonnet-4-5-20250929"], + "currency": "USD", + "unit": "per_token", + "rates": { + "input_per_million": 3.0, + "output_per_million": 15.0, + "cache_read_per_million": 0.3, + "cache_write_per_million": 3.75 + }, + "prompt_cache": {"read_accounting": "separate"}, + "pricing_as_of": "2026-10-08", + "pricing_source": "https://platform.claude.com/docs/en/about-claude/pricing" + }, + { + "provider": "openai", + "model_id": "gpt-5.4", + "aliases": ["gpt-5.4-2026-03-05"], + "currency": "USD", + "unit": "per_token", + "rate_schedule": { + "type": "prompt_token_threshold", + "applies_to": "full_request", + "tiers": [ + { + "max_prompt_tokens": 272000, + "rates": { + "input_per_million": 2.5, + "output_per_million": 15.0, + "cache_read_per_million": 0.25, + "cache_write_per_million": 2.5 + } + }, + { + "min_prompt_tokens": 272001, + "rates": { + "input_per_million": 5.0, + "output_per_million": 22.5, + "cache_read_per_million": 0.5, + "cache_write_per_million": 5.0 + } + } + ] + }, + "prompt_cache": {"read_accounting": "included_in_prompt_tokens"}, + "pricing_as_of": "2026-10-08", + "pricing_source": "https://developers.openai.com/api/docs/pricing" + } + ] +} diff --git a/crates/fabric-core/src/config.rs b/crates/fabric-core/src/config.rs index 58d282cb4..21d605969 100644 --- a/crates/fabric-core/src/config.rs +++ b/crates/fabric-core/src/config.rs @@ -23,6 +23,8 @@ pub use crate::agent_config::{ use crate::agent_execution::{AgentRunRequest, AgentRunResult}; use crate::error::{FabricError, Result}; +use crate::price_catalog::UserPriceCatalog; + /// Versioned NVIDIA NeMo Fabric agent config. /// /// NeMo Fabric-owned fields apply uniformly, while adapter-translated fields are @@ -72,11 +74,25 @@ pub struct FabricConfig { /// First-class NeMo Relay integration configuration. #[serde(default, skip_serializing_if = "Option::is_none")] pub relay: Option, + /// Optional catalog override for central token-price estimates. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pricing: Option, /// Additive fields not yet recognized by this core version. #[serde(default, flatten)] pub extensions: BTreeMap, } +/// Central token-price configuration; never changes provider requests. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct PricingConfig { + /// Relay-compatible v1 catalog, resolved relative to the config base directory. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub catalog_path: Option, + /// Additive pricing configuration, preserved for future versions. + #[serde(default, flatten)] + pub extensions: BTreeMap, +} + /// How an instruction value is applied to the selected harness. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] @@ -2449,6 +2465,8 @@ pub fn inspect_adapter_config( ); } config.discovery = None; + // Inspection returns metadata only; catalog paths belong to the execution host. + config.pricing = None; validate_config(&config)?; let path = descriptor_path .canonicalize() @@ -2597,7 +2615,9 @@ fn resolve_selected_descriptors( descriptor, adapter_target_descriptor.as_ref(), ); + let user_price_catalog = resolve_user_price_catalog(&config, &base_dir)?; Ok(RunPlan { + user_price_catalog, agent_name: config.metadata.name.clone(), base_dir, config, @@ -2612,6 +2632,33 @@ fn resolve_selected_descriptors( }) } +fn resolve_user_price_catalog( + config: &FabricConfig, + base_dir: &Path, +) -> Result> { + let Some(path) = config + .pricing + .as_ref() + .and_then(|p| p.catalog_path.as_ref()) + else { + return Ok(None); + }; + if path.as_os_str().to_string_lossy().trim().is_empty() { + return invalid_config("pricing.catalog_path", "must not be blank"); + } + let path = resolve_path(base_dir, path); + let bytes = std::fs::read(&path).map_err(|error| FabricError::InvalidConfig { + field: "pricing.catalog_path".into(), + reason: format!("cannot read {}: {error}", path.display()), + })?; + UserPriceCatalog::from_bytes(path, &bytes) + .map(Some) + .map_err(|error| FabricError::InvalidConfig { + field: "pricing.catalog_path".into(), + reason: error.to_string(), + }) +} + fn validate_capability_plan_compatibility( capability_plan: &CapabilityPlan, descriptor: Option<&AdapterDescriptor>, @@ -4026,6 +4073,9 @@ fn normalize_path(path: PathBuf) -> PathBuf { /// Resolved NeMo Fabric run plan. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct RunPlan { + /// Catalog bytes are frozen during planning. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_price_catalog: Option, /// Stable agent name. pub agent_name: String, /// Base directory used to resolve relative NeMo Fabric paths. @@ -4325,6 +4375,10 @@ mod tests { local_paths: vec![PathBuf::from("/task-only/nonexistent-descriptor")], ..Default::default() }); + config.pricing = Some(PricingConfig { + catalog_path: Some(PathBuf::from("/task-only/nonexistent-pricing-catalog.json")), + ..Default::default() + }); let inspection = inspect_adapter_config(config.clone(), &path).expect("host inspection"); assert_eq!( inspection.descriptor, diff --git a/crates/fabric-core/src/endpoint.rs b/crates/fabric-core/src/endpoint.rs new file mode 100644 index 000000000..cddabac29 --- /dev/null +++ b/crates/fabric-core/src/endpoint.rs @@ -0,0 +1,232 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Secret-free identities for configured endpoints. + +use std::net::Ipv6Addr; + +/// The endpoint identity of `url`: its lowercase scheme and host, and its +/// port as a number. An omitted port becomes the scheme's default, 80 for +/// `http` and `ws` and 443 for `https` and `wss`; other schemes keep a port +/// only when one is written. An IPv6 host keeps its brackets in canonical, +/// compressed lowercase form, such as `[2001:db8::1]`. The user info, path, +/// query and fragment never enter it, because a hosted service can carry a +/// key in any of them. +/// +/// `None` when `url` has no `scheme://host`, when an `@` follows the +/// authority (a password holding an unencoded `/`, `\`, `?` or `#` ends +/// the authority early, so the host cannot be told apart from the user +/// info), when the host holds a character other than ASCII letters, digits, +/// `-`, `.`, `_` and `~`, when a bracketed host is not an IPv6 address +/// (a scoped address with a zone ID is rejected too), or when the port is +/// not a number below 65,536. +pub(crate) fn endpoint_identity(url: &str) -> Option { + let (scheme, rest) = url.trim().split_once("://")?; + if !is_scheme(scheme) { + return None; + } + let authority_end = rest.find(['/', '\\', '?', '#']).unwrap_or(rest.len()); + let (authority, tail) = rest.split_at(authority_end); + if tail.contains('@') { + return None; + } + let host_port = authority + .rsplit_once('@') + .map_or(authority, |(_, host_port)| host_port); + let (host, port) = split_host_port(host_port)?; + let scheme = scheme.to_ascii_lowercase(); + let mut identity = format!("{scheme}://{host}"); + if let Some(port) = port.or_else(|| default_port(&scheme)) { + identity.push_str(&format!(":{port}")); + } + Some(identity) +} + +/// Whether `scheme` is a URL scheme: a letter, then letters, digits, `+`, +/// `-` or `.`. +fn is_scheme(scheme: &str) -> bool { + let mut characters = scheme.chars(); + characters + .next() + .is_some_and(|first| first.is_ascii_alphabetic()) + && characters.all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '+' | '-' | '.') + }) +} + +/// Split `host[:port]` into the lowercase host, with a bracketed IPv6 +/// address in canonical form, and the port. `None` when the host is empty, +/// holds a character no host name holds, or is bracketed but not an IPv6 +/// address, or when the port is not a number below 65,536. An empty port +/// reads as no port. +fn split_host_port(host_port: &str) -> Option<(String, Option)> { + let (host, port) = match host_port.strip_prefix('[') { + Some(bracketed) => { + let (address, after) = bracketed.split_once(']')?; + let address: Ipv6Addr = address.parse().ok()?; + let port = match after { + "" => None, + after => Some(after.strip_prefix(':')?), + }; + (format!("[{address}]"), port) + } + None => { + let (host, port) = match host_port.split_once(':') { + Some((host, port)) => (host, Some(port)), + None => (host_port, None), + }; + let host_character = |character: char| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '.' | '_' | '~') + }; + if host.is_empty() || !host.chars().all(host_character) { + return None; + } + (host.to_ascii_lowercase(), port) + } + }; + match port.filter(|port| !port.is_empty()) { + None => Some((host, None)), + Some(port) if port.bytes().all(|byte| byte.is_ascii_digit()) => { + port.parse::().ok().map(|port| (host, Some(port))) + } + Some(_) => None, + } +} + +fn default_port(scheme: &str) -> Option { + match scheme { + "http" | "ws" => Some(80), + "https" | "wss" => Some(443), + _ => None, + } +} + +/// The file name of a `stdio` server's executable: the part after the last +/// `/` or `\\`. +/// +/// `None` when the executable holds an `=` or any whitespace anywhere, or +/// when the file name is empty or holds a character other than ASCII +/// letters, digits, `.`, `_`, `+` and `-`. The plan keeps arguments in their +/// own field, so whitespace in the executable field means arguments may +/// have been written into it, and an argument can put a secret after a `/` +/// (`npx --token a/SECRET`). A leading `NAME=value` can do the same +/// (`TOKEN=a/SECRET server`). Both checks run before the folder is dropped. +/// A path with a space in a folder is rejected too. This is a conservative +/// extractor, not a shell parser. +pub(crate) fn executable_name(executable: &str) -> Option { + if executable.contains('=') || executable.chars().any(char::is_whitespace) { + return None; + } + let name = executable.rsplit(['/', '\\']).next().unwrap_or_default(); + let plain = |character: char| { + character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '+' | '-') + }; + (!name.is_empty() && name.chars().all(plain)).then(|| name.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn endpoint_identity_keeps_scheme_host_and_port_only() { + for (url, identity) in [ + ( + "HTTP://user:pw@10.0.0.5:8000/v1/?key=abc#frag", + "http://10.0.0.5:8000", + ), + ("https://API.example.com/", "https://api.example.com:443"), + ("http://[::1]/v1", "http://[::1]:80"), + ( + "https://[2001:DB8:0:0::1]:8443/", + "https://[2001:db8::1]:8443", + ), + ("https://example.com", "https://example.com:443"), + ("https://example.com:443", "https://example.com:443"), + ( + "https://actions.zapier.com/mcp/sk-ak-SECRET/sse", + "https://actions.zapier.com:443", + ), + ("https://u%40x:p%2Fq@host:8443/v1", "https://host:8443"), + ("https://host:/v1", "https://host:443"), + ("https://host:0443/v1", "https://host:443"), + ("WSS://Host.Example/s", "wss://host.example:443"), + ("grpc://host:9000", "grpc://host:9000"), + ] { + assert_eq!(endpoint_identity(url).as_deref(), Some(identity), "{url}"); + } + } + + /// A URL whose host cannot be told apart from its user info, or that is + /// not a URL, has no identity, so no part of a password becomes a host. + #[test] + fn endpoint_identity_rejects_ambiguous_or_invalid_urls() { + for url in [ + "https://user:123/SECRET@host/x", + "https://u:p#SECRET@host/x", + "https://u:p?SECRET@host/x", + "https://user:pw@host/p@x", + r"https://api.example.com\SECRET@secret-host", + "https://ärzte.example/mcp", + "https://server.example.com/@owner/server/mcp", + "https://u:p/x", + "https://host:99999", + "https://host:8a", + "https://", + "1http://host", + "http://ho st/", + "http://[::1/", + "http://[]/", + "https://[SECRET]/mcp", + "http://[::::]/", + "http://[fe80::1%25eth0]/", + "http://[fe80::1%eth0]/", + "http://[::1]x/", + "localhost:8000", + "not a url", + ] { + assert_eq!(endpoint_identity(url), None, "{url}"); + } + } + + #[test] + fn executable_name_is_the_file_name_without_its_folder() { + for (executable, name) in [ + ("db-mcp", Some("db-mcp")), + ("/home/alice/.venv/bin/python", Some("python")), + (r"C:\tools\npx.cmd", Some("npx.cmd")), + (r"C:\MCP\server.exe", Some("server.exe")), + ] { + assert_eq!(executable_name(executable).as_deref(), name, "{executable}"); + } + } + + /// An `=` or whitespace anywhere rejects the executable before its + /// folder is dropped, so no part of an assignment or an argument + /// becomes the name. + #[test] + fn executable_name_rejects_assignments_and_unplain_names() { + for executable in [ + "OPENAI_API_KEY=SECRET npx", + "TOKEN=prefix/SECRET", + r"TOKEN=prefix\SECRET", + "TOKEN=https://SECRET", + "/opt/server --token=SECRET", + "npx --api-key SECRET", + "npx -y @org/server-github --token SECRET", + "/opt/My Tools/db server", + "npx --token a/SECRET", + r"npx --token a\SECRET", + r"C:\Program Files\MCP\server.exe", + "/opt/My Tools/db-server", + "db-mcp\t", + " db-mcp", + "'quoted server'", + "/opt/tools/ärzte", + "", + "/opt/tools/", + ] { + assert_eq!(executable_name(executable), None, "{executable}"); + } + } +} diff --git a/crates/fabric-core/src/lib.rs b/crates/fabric-core/src/lib.rs index d98981c94..e97a081ee 100644 --- a/crates/fabric-core/src/lib.rs +++ b/crates/fabric-core/src/lib.rs @@ -9,14 +9,22 @@ pub mod agent_execution; pub mod check; pub mod config; pub mod doctor; +mod endpoint; mod environment; pub mod error; +mod measurement; +pub mod model_usage; +pub mod price_catalog; +pub mod pricing; pub mod resource_snapshot; pub mod runtime; pub mod schema; pub mod system_stats; mod task_command; mod time; +pub mod usage_accounting; +mod usage_normalization; +pub mod what_ran; pub use adapter_contract::{ADAPTER_CONTRACT_VERSION, AdapterExtensionPoint}; pub use agent_execution::{ @@ -40,10 +48,11 @@ pub use config::{ EnvironmentOwnership, EnvironmentPlan, FabricConfig, HarnessConfig, InstructionConfig, InstructionMode, InstructionsConfig, McpAuthenticationConfig, McpConfig, McpExposure, McpServerPlan, McpTransport, MetadataConfig, ModelConfig, OAuthTokenEndpointAuthMethod, - ResolutionStrategy, ResolveContext, ResolvedAdapterDescriptor, ResolvedAdapterTargetDescriptor, - RunPlan, RuntimeCapabilities, RuntimeConfig, SkillConfig, TelemetryConfig, TelemetryPlan, - TelemetryProvider, TelemetryProviderConfig, ToolDefinitionConfig, ToolsConfig, WorkflowConfig, - WorkflowEntrypointConfig, WorkflowTargetSpec, inspect_adapter_config, load_adapter_descriptor, + PricingConfig, ResolutionStrategy, ResolveContext, ResolvedAdapterDescriptor, + ResolvedAdapterTargetDescriptor, RunPlan, RuntimeCapabilities, RuntimeConfig, SkillConfig, + TelemetryConfig, TelemetryPlan, TelemetryProvider, TelemetryProviderConfig, + ToolDefinitionConfig, ToolsConfig, WorkflowConfig, WorkflowEntrypointConfig, + WorkflowTargetSpec, inspect_adapter_config, load_adapter_descriptor, load_adapter_target_descriptor, resolve_diagnostic_plan_from_config, resolve_diagnostic_plan_from_config_with_adapter_directories, resolve_run_plan_from_config, resolve_run_plan_from_config_with_adapter_directories, diff --git a/crates/fabric-core/src/measurement.rs b/crates/fabric-core/src/measurement.rs new file mode 100644 index 000000000..7f3c8b3d4 --- /dev/null +++ b/crates/fabric-core/src/measurement.rs @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Shared vocabulary for values and their provenance. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// One value and the context needed to interpret it. Availability alone does +/// not certify accounting coverage; read the enclosing accounting record. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct Measurement { + /// Whether a value was obtained. + pub availability: Availability, + /// Value in `unit`; measured zero is a real zero. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(max = u64::MAX))] + pub value: Option, + /// Unit of value. + pub unit: MeasurementUnit, + /// Source of value, or the source that would have supplied it. + pub source: MeasurementSource, + /// What the value describes. + pub attribution: Attribution, + /// Stable reason for an unavailable measurement. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +/// Availability of a measurement. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum Availability { + /// A value is present. + Measured, + /// Supported, but no usable value is available. + Unavailable, + /// Not supplied by this implementation. + Unsupported, + /// Preserve future values when reading old results. + #[serde(untagged)] + Other(String), +} + +/// Unit of a measurement. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum MeasurementUnit { + /// Model tokens. + Tokens, + /// Billionths of a US dollar. + NanoUsd, + /// Preserve future units. + #[serde(untagged)] + Other(String), +} + +/// Source of a measurement. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum MeasurementSource { + /// Harness or provider evidence collected by the adapter. + AdapterUsage, + /// Fabric's bundled price catalog. + FabricPriceCatalog, + /// A user-supplied price catalog. + UserPriceCatalog, + /// Preserve future sources. + #[serde(untagged)] + Other(String), +} + +/// What a measurement describes. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum Attribution { + /// Work initiated by this invocation, including its descendants. + Invocation, + /// No value is available. + None, + /// Preserve future attribution categories. + #[serde(untagged)] + Other(String), +} diff --git a/crates/fabric-core/src/model_usage.rs b/crates/fabric-core/src/model_usage.rs new file mode 100644 index 000000000..829a7ebad --- /dev/null +++ b/crates/fabric-core/src/model_usage.rs @@ -0,0 +1,281 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Invocation-owned normalized counts and separately identified costs. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::AgentUsage; +use crate::usage_accounting::{AccountingError, GapReason, UsageAccounting}; +use crate::usage_normalization as normalized; + +/// Token counts for one fixed invocation scope. Missing values never imply zero. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct TokenCounts { + /// Cache-inclusive input. + #[schemars(range(max = u64::MAX))] + pub input_tokens: Option, + /// Independently known cache reads. + #[schemars(range(max = u64::MAX))] + pub cached_input_tokens: Option, + /// Independently known cache writes. + #[schemars(range(max = u64::MAX))] + pub cache_write_input_tokens: Option, + /// Reasoning-inclusive output. + #[schemars(range(max = u64::MAX))] + pub output_tokens: Option, + /// Input plus output; the independent provider total remains raw evidence. + #[schemars(range(max = u64::MAX))] + pub total_tokens: Option, +} + +/// Dimensions whose complete values can be unavailable independently. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum UsageDimension { + /// Input tokens. + InputTokens, + /// Cache reads. + CachedInputTokens, + /// Cache writes. + CacheWriteInputTokens, + /// Output tokens. + OutputTokens, + /// Input plus output. + TotalTokens, + /// Provider-reported cost. + ReportedCost, + /// Applicable catalog estimate. + EstimatedCost, +} + +/// A completeness gap. Diagnostics never change the dimension or reason semantics. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct UsageGap { + /// Nonempty set of affected dimensions. + #[schemars(schema_with = "gap_dimensions_schema")] + pub dimensions: Vec, + /// Why complete accounting is unavailable. + pub reason: GapReason, + /// Bounded stable diagnostic; never an exception message. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(length(max = 64))] + pub code: Option, +} + +fn gap_dimensions_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + let mut schema = Vec::::json_schema(generator); + schema.insert("minItems".into(), 1.into()); + schema.insert("uniqueItems".into(), true.into()); + schema +} + +/// Complete and observed amounts in integer nano-USD. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct CostAmount { + /// Complete amount for this invocation/model scope. + #[schemars(range(max = u64::MAX))] + pub amount_nano_usd: Option, + /// Valid observed contributions, not a guaranteed bound or invoice. + #[schemars(range(max = u64::MAX))] + pub observed_nano_usd: Option, +} + +/// Provider-reported amounts stay separate from reproducible catalog estimates. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct TokenCost { + /// Provider-reported amounts, never a harness estimate. + pub reported: CostAmount, + /// Catalog estimates; incomplete tariff evidence affects only these amounts. + pub estimated: CostAmount, + /// Required basis for any estimated amount. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pricing: Option, + /// Distinct selected rate sets, rather than one duplicate snapshot per call. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub applied_rates: Vec, +} + +/// Normalized usage for one literal model/provider identity. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct PerModelUsage { + /// Source-established accounting model identity. A configured identity + /// requires proof that all included work used that model; an outer requested + /// alias alone is insufficient. This is not served-model provenance. + pub model: String, + /// Literal provider identity when established. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + /// Complete normalized token dimensions. + #[serde(flatten)] + pub tokens: TokenCounts, + /// Available deduplicated observations, possibly incomplete. + pub observed: TokenCounts, + /// Dimension-specific gaps. + pub gaps: Vec, + /// Provider-reported and catalog-estimated cost. + pub cost: TokenCost, +} + +/// Normalized accounting frozen at return. Late usage never moves to a later invocation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ModelUsage { + /// Complete normalized token dimensions. + #[serde(flatten)] + pub tokens: TokenCounts, + /// Available deduplicated observations; these are not guaranteed lower bounds. + pub observed: TokenCounts, + /// Dimension-specific completeness gaps. + pub gaps: Vec, + /// Whether all model identities used by owned work are represented. + pub models_exhaustive: bool, + /// Literal per-model breakdown, when the source establishes attribution. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub models: Vec, + /// Separately typed provider-reported amounts and catalog estimates. + pub cost: TokenCost, +} + +/// Normalize supported evidence. Legacy usage remains raw rather than gaining +/// a completeness guarantee merely because a newer core read it. +pub fn normalize_model_usage( + usage: &AgentUsage, + catalog: Option<&crate::price_catalog::UserPriceCatalog>, + bundled_pricing_allowed: bool, +) -> Result, AccountingError> { + normalize_model_usage_for_routes(usage, catalog, |_, _| bundled_pricing_allowed) +} + +pub(crate) fn normalize_model_usage_for_routes( + usage: &AgentUsage, + catalog: Option<&crate::price_catalog::UserPriceCatalog>, + bundled_pricing_allowed: impl Fn(Option<&str>, &str) -> bool, +) -> Result, AccountingError> { + let Some(evidence) = UsageAccounting::from_usage(usage)? else { + return Ok(None); + }; + let mut result = normalized::describe_model_usage(usage)?; + crate::pricing::apply_pricing_with_evidence( + &mut result, + catalog, + bundled_pricing_allowed, + Some(&evidence), + ); + Ok(Some(view(result))) +} + +fn tokens(values: &normalized::ModelMeasurements) -> TokenCounts { + TokenCounts { + input_tokens: values.input_tokens.value, + cached_input_tokens: values.cached_input_tokens.value, + cache_write_input_tokens: values.cache_write_input_tokens.value, + output_tokens: values.output_tokens.value, + total_tokens: values.total_tokens.value, + } +} + +fn observations(values: &normalized::ObservedUsage) -> TokenCounts { + TokenCounts { + input_tokens: values.input_tokens, + cached_input_tokens: values.cached_input_tokens, + cache_write_input_tokens: values.cache_write_input_tokens, + output_tokens: values.output_tokens, + total_tokens: values.total_tokens, + } +} + +fn cost(value: &normalized::TokenCost, observed: &normalized::ObservedUsage) -> TokenCost { + let mut applied_rates = Vec::new(); + for rate in value + .applied + .iter() + .chain(value.applied_calls.iter().flatten()) + { + if !applied_rates.contains(rate) { + applied_rates.push(rate.clone()); + } + } + TokenCost { + reported: CostAmount { + amount_nano_usd: value.reported.value, + observed_nano_usd: observed.reported_cost_nano_usd, + }, + estimated: CostAmount { + amount_nano_usd: value.estimated.value, + observed_nano_usd: observed.estimated_cost_nano_usd, + }, + pricing: value.pricing.clone(), + applied_rates, + } +} + +fn gaps( + raw: &[normalized::AccountingGap], + values: &normalized::ModelMeasurements, +) -> Vec { + use normalized::AccountingDimension as Raw; + let mut result = Vec::new(); + for gap in raw { + for dimension in &gap.dimensions { + let (dimension, measurement) = match dimension { + Raw::InputTokens => (UsageDimension::InputTokens, &values.input_tokens), + Raw::CachedInputTokens => ( + UsageDimension::CachedInputTokens, + &values.cached_input_tokens, + ), + Raw::CacheWriteInputTokens => ( + UsageDimension::CacheWriteInputTokens, + &values.cache_write_input_tokens, + ), + Raw::OutputTokens => (UsageDimension::OutputTokens, &values.output_tokens), + Raw::TotalTokens => (UsageDimension::TotalTokens, &values.total_tokens), + Raw::ReportedCost => (UsageDimension::ReportedCost, &values.token_cost.reported), + Raw::EstimatedCost => (UsageDimension::EstimatedCost, &values.token_cost.estimated), + Raw::ReasoningTokens | Raw::PeakRequestInputTokens => continue, + }; + let next = UsageGap { + dimensions: vec![dimension], + reason: gap.reason, + code: measurement.reason.clone(), + }; + if !result.contains(&next) { + result.push(next); + } + } + } + result +} + +fn view(value: normalized::ModelUsage) -> ModelUsage { + let measurements = normalized::ModelMeasurements { + input_tokens: value.input_tokens, + cached_input_tokens: value.cached_input_tokens, + cache_write_input_tokens: value.cache_write_input_tokens, + output_tokens: value.output_tokens, + reasoning_tokens: value.reasoning_tokens, + total_tokens: value.total_tokens, + peak_request_input_tokens: value.peak_request_input_tokens, + token_cost: value.token_cost, + }; + let accounting = value.accounting.expect("supported evidence was validated"); + ModelUsage { + tokens: tokens(&measurements), + observed: observations(&accounting.observed), + gaps: gaps(&accounting.gaps, &measurements), + models_exhaustive: accounting.models_exhaustive, + models: value + .models + .into_iter() + .map(|model| PerModelUsage { + model: model.model, + provider: model.provider, + tokens: tokens(&model.usage), + observed: observations(&model.accounting.observed), + gaps: gaps(&model.accounting.gaps, &model.usage), + cost: cost(&model.usage.token_cost, &model.accounting.observed), + }) + .collect(), + cost: cost(&measurements.token_cost, &accounting.observed), + } +} diff --git a/crates/fabric-core/src/price_catalog.rs b/crates/fabric-core/src/price_catalog.rs new file mode 100644 index 000000000..14ceb71b7 --- /dev/null +++ b/crates/fabric-core/src/price_catalog.rs @@ -0,0 +1,1256 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Model price catalogs for estimating token cost. +//! +//! A catalog uses the NeMo Relay model pricing catalog format, version 1, so +//! one file can configure both tools. NVIDIA NeMo Fabric parses the format itself and +//! does not depend on NeMo Relay. Names are trimmed and compared case-insensitively. +//! An explicit provider restricts aliases and model-path suffixes to that provider. +//! Without a provider, lookup tries provider-qualified keys from the model path +//! before model-only suffixes. A model-only key must identify one provider; +//! an ambiguous match stops lookup without trying shorter model names. +//! +//! NeMo Fabric ships one catalog, embedded at build time, with the list prices +//! of models that its presets, docs, and examples use. Each entry cites the +//! provider pricing page it was taken from and the date it was checked. A +//! caller can add a catalog of their own, whose entries take precedence. + +use std::collections::HashSet; +use std::fmt::Write as _; +use std::path::PathBuf; +use std::sync::LazyLock; + +use schemars::JsonSchema; +use serde::{Deserialize, Deserializer, Serialize, de::Error as _}; +use sha2::{Digest, Sha256}; + +/// The only catalog format version NeMo Fabric reads. +const PRICING_CATALOG_VERSION: u32 = 1; + +/// Rates must be below one million US dollars per million tokens. NeMo Relay +/// sets no upper bound; this one keeps every rate exact in nano-USD per +/// million tokens and every cost sum within 128 bits. +const MAX_RATE_PER_MILLION: f64 = 1_000_000.0; + +/// Nano-USD per US dollar. +const NANO_USD_PER_USD: f64 = 1e9; + +/// The catalog NeMo Fabric ships. +const BUNDLED_CATALOG_JSON: &str = include_str!("../data/model-pricing.json"); + +static BUNDLED_CATALOG: LazyLock = LazyLock::new(|| { + // A unit test checks that the shipped file is valid, so `None` only + // guards against a build that skipped the tests. + let catalog = PricingCatalog::from_json(BUNDLED_CATALOG_JSON.as_bytes()).ok(); + let sha256 = catalog + .as_ref() + .and_then(|catalog| catalog_fingerprint(catalog).ok()) + .unwrap_or_default(); + BundledCatalog { catalog, sha256 } +}); + +struct BundledCatalog { + catalog: Option, + sha256: String, +} + +/// A model price catalog in the NeMo Relay model pricing catalog format, +/// version 1. +/// +/// NVIDIA NeMo Fabric reads and writes the format unchanged, with one extra check: +/// every rate must be below one million US dollars per million tokens. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct PricingCatalog { + /// Catalog format version. Must be `1`. + pub version: u32, + /// Price entries with provider-qualified model identifiers and aliases. + pub entries: Vec, +} + +/// Prices of one model, in `currency` per million tokens. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ModelPricing { + /// Provider that sets the price, such as `anthropic` or `openai`. + pub provider: String, + /// Canonical model identifier. + pub model_id: String, + /// Other identifiers of the same model, such as dated snapshots. + #[serde(default)] + pub aliases: Vec, + /// ISO 4217 currency of the rates. NVIDIA NeMo Fabric estimates cost only from + /// `USD` entries. + #[serde(default = "default_currency")] + pub currency: String, + /// What a rate is charged per. NeMo Fabric estimates cost only from + /// `per_token` entries. + #[serde(default)] + pub unit: PricingUnit, + /// Flat rates. Ignored when `rate_schedule` is set. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rates: Option, + /// Rates that depend on the number of prompt tokens in a request. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rate_schedule: Option, + /// How the provider counts cached prompt tokens. NeMo Relay uses it to + /// split provider usage; NeMo Fabric prices usage that is already + /// normalized, so it does not read it. + pub prompt_cache: PromptCachePricing, + /// Date the price was taken from `pricing_source`, as `YYYY-MM-DD` in + /// the bundled catalog. + pub pricing_as_of: String, + /// Where the price was taken from, such as the provider's pricing page. + pub pricing_source: String, +} + +/// What a model price is charged per. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum PricingUnit { + /// Per token. + #[default] + PerToken, + /// Per request. + PerRequest, + /// Per second. + PerSecond, + /// Per GPU hour, for self-hosted models. + GpuHour, +} + +/// Token rates in currency units per million tokens. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TokenPricingRates { + /// Rate for input tokens that were neither read from nor written to the + /// prompt cache. + pub input_per_million: f64, + /// Rate for output tokens, including reasoning tokens. + pub output_per_million: f64, + /// Rate for input tokens read from the prompt cache. Without it, cached + /// reads cannot be priced; set `0.0` when they are free. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache_read_per_million: Option, + /// Rate for input tokens written to the prompt cache. Without it, cache + /// writes cannot be priced; set `0.0` when they are free. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache_write_per_million: Option, +} + +/// Rates that depend on the size of a request. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields, tag = "type", rename_all = "snake_case")] +pub enum TokenRateSchedule { + /// One tier's rates apply to a whole request, chosen by the request's + /// prompt tokens. + PromptTokenThreshold { + /// How the chosen tier's rates apply. + #[serde(default)] + applies_to: RateScheduleApplication, + /// Tiers in the order they are tried. The first tier whose bounds + /// contain a request's prompt tokens applies to it. + tiers: Vec, + }, +} + +/// How a chosen rate tier applies to a request. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum RateScheduleApplication { + /// The tier's rates apply to every token of the request. + #[default] + FullRequest, +} + +/// Rates for requests whose prompt tokens fall within inclusive bounds. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TokenRateTier { + /// Smallest number of prompt tokens in the tier, or no lower bound. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub min_prompt_tokens: Option, + /// Largest number of prompt tokens in the tier, or no upper bound. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_prompt_tokens: Option, + /// Rates of the tier. + pub rates: TokenPricingRates, +} + +/// How a provider counts cached prompt tokens in its usage reports. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct PromptCachePricing { + /// Whether the provider's prompt token count includes cached reads. + pub read_accounting: CacheReadAccounting, +} + +/// Whether a provider's prompt token count includes cached reads. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum CacheReadAccounting { + /// Prompt tokens include cached reads. + IncludedInPromptTokens, + /// Prompt tokens exclude cached reads. + Separate, +} + +/// A price catalog the caller supplied, read and validated when the run plan +/// is resolved. +#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)] +pub struct UserPriceCatalog { + /// Absolute path the catalog was read from. + pub path: PathBuf, + /// SHA-256 of the validated catalog serialized as JSON by NeMo Fabric. + pub sha256: String, + /// The validated catalog. + pub catalog: PricingCatalog, +} + +/// Why a catalog is invalid. The messages match NeMo Relay's. +#[derive(Debug, thiserror::Error)] +pub enum PricingCatalogError { + /// The catalog is not JSON of the catalog format. + #[error("invalid model pricing catalog JSON: {0}")] + Json(#[from] serde_json::Error), + /// The claimed catalog fingerprint does not match the validated entries. + #[error("model pricing catalog fingerprint does not match its entries")] + FingerprintMismatch, + /// The catalog format version is not 1. + #[error("unsupported model pricing catalog version {version}")] + UnsupportedVersion { + /// Version in the catalog. + version: u32, + }, + /// Two entries or aliases of one provider name the same model. + #[error("duplicate model pricing alias '{model}'")] + DuplicateModelAlias { + /// Normalized `provider/model` key. + model: String, + }, + /// A required text field is blank. + #[error("model pricing entry {entry_index} has empty {field}")] + EmptyField { + /// Zero-based entry index. + entry_index: usize, + /// Field name. + field: String, + }, + /// A rate or a tier bound is out of range. + #[error("model pricing entry {entry_index} has invalid {field}: {value}")] + InvalidRate { + /// Zero-based entry index. + entry_index: usize, + /// Field name. + field: String, + /// Invalid value. + value: f64, + }, +} + +impl UserPriceCatalog { + /// Validate bytes and retain a fingerprint of their pricing content. + pub fn from_bytes(path: PathBuf, bytes: &[u8]) -> Result { + let catalog = PricingCatalog::from_json(bytes)?; + Ok(Self { + path, + sha256: catalog_fingerprint(&catalog)?, + catalog, + }) + } + + pub(crate) fn validate_integrity(&self) -> Result<(), PricingCatalogError> { + self.catalog.validate()?; + if self.sha256 != catalog_fingerprint(&self.catalog)? { + return Err(PricingCatalogError::FingerprintMismatch); + } + Ok(()) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct UserPriceCatalogWire { + path: PathBuf, + sha256: String, + catalog: PricingCatalog, +} + +impl<'de> Deserialize<'de> for UserPriceCatalog { + fn deserialize>(deserializer: D) -> Result { + let wire = UserPriceCatalogWire::deserialize(deserializer)?; + let catalog = Self { + path: wire.path, + sha256: wire.sha256, + catalog: wire.catalog, + }; + catalog.validate_integrity().map_err(D::Error::custom)?; + Ok(catalog) + } +} + +fn catalog_fingerprint(catalog: &PricingCatalog) -> Result { + Ok(sha256_fingerprint(&serde_json::to_vec(catalog)?)) +} + +impl PricingCatalog { + /// Parse and validate a catalog. + pub fn from_json(json: &[u8]) -> Result { + let catalog: Self = serde_json::from_slice(json)?; + catalog.validate()?; + Ok(catalog) + } + + /// Find `model`, keeping an explicit provider on every alias or suffix. + /// Without a provider, try the model path's provider prefix before model-only + /// suffixes. A model-only match must identify one provider. + pub(crate) fn pricing_for(&self, provider: Option<&str>, model: &str) -> Option<&ModelPricing> { + for key in lookup_keys(provider, model) { + let mut matches = self.entries.iter().filter(|entry| entry.matches(&key)); + let Some(first) = matches.next() else { + continue; + }; + if key.provider.is_none() + && matches.any(|entry| { + normalized_provider(&entry.provider) != normalized_provider(&first.provider) + }) + { + return None; + } + return Some(first); + } + None + } + + fn validate(&self) -> Result<(), PricingCatalogError> { + if self.version != PRICING_CATALOG_VERSION { + return Err(PricingCatalogError::UnsupportedVersion { + version: self.version, + }); + } + let mut seen = HashSet::new(); + for (entry_index, entry) in self.entries.iter().enumerate() { + entry.validate(entry_index)?; + let provider = normalized_provider(&entry.provider); + for model in entry.model_keys() { + let key = format!("{provider}/{model}"); + if !seen.insert(key.clone()) { + return Err(PricingCatalogError::DuplicateModelAlias { model: key }); + } + } + } + Ok(()) + } +} + +impl ModelPricing { + pub(crate) fn rates_at(&self, prompt_tokens: u64) -> Option { + match &self.rate_schedule { + None => self.rates, + Some(TokenRateSchedule::PromptTokenThreshold { tiers, .. }) => tiers + .iter() + .find(|tier| { + tier.min_prompt_tokens.unwrap_or(0) <= prompt_tokens + && tier + .max_prompt_tokens + .is_none_or(|max| prompt_tokens <= max) + }) + .map(|tier| tier.rates), + } + } + /// Rates that apply to every request of a model whose largest request + /// had `peak_prompt_tokens` prompt tokens. `None` when the entry has a + /// rate schedule and the peak is unknown or no single tier covers every + /// request up to it. + pub(crate) fn rates_up_to(&self, peak_prompt_tokens: Option) -> Option { + match &self.rate_schedule { + Some(schedule) => schedule.rates_up_to(peak_prompt_tokens?), + None => self.rates, + } + } + + fn matches(&self, key: &LookupKey) -> bool { + key.provider + .as_deref() + .is_none_or(|provider| normalized_provider(&self.provider) == provider) + && self.model_keys().any(|model| model == key.model) + } + + /// The normalized model identifier and aliases, without blank ones. + fn model_keys(&self) -> impl Iterator + '_ { + std::iter::once(&self.model_id) + .chain(&self.aliases) + .map(|model| normalized_model(model)) + .filter(|model| !model.is_empty()) + } + + fn validate(&self, entry_index: usize) -> Result<(), PricingCatalogError> { + for (field, value) in [ + ("provider", &self.provider), + ("model_id", &self.model_id), + ("currency", &self.currency), + ("pricing_as_of", &self.pricing_as_of), + ("pricing_source", &self.pricing_source), + ] { + if value.trim().is_empty() { + return Err(empty_field(entry_index, field)); + } + } + if self.unit == PricingUnit::PerToken + && self.rates.is_none() + && self.rate_schedule.is_none() + { + return Err(empty_field(entry_index, "rates or rate_schedule")); + } + if let Some(rates) = &self.rates { + rates.validate(entry_index, "rates")?; + } + if let Some(schedule) = &self.rate_schedule { + schedule.validate(entry_index)?; + } + Ok(()) + } +} + +impl TokenPricingRates { + fn validate(&self, entry_index: usize, prefix: &str) -> Result<(), PricingCatalogError> { + for (field, value) in [ + ("input_per_million", Some(self.input_per_million)), + ("output_per_million", Some(self.output_per_million)), + ("cache_read_per_million", self.cache_read_per_million), + ("cache_write_per_million", self.cache_write_per_million), + ] { + if let Some(value) = value + && nano_usd_per_million_tokens(value).is_none() + { + return Err(PricingCatalogError::InvalidRate { + entry_index, + field: format!("{prefix}.{field}"), + value, + }); + } + } + Ok(()) + } +} + +impl TokenRateSchedule { + fn rates_up_to(&self, peak_prompt_tokens: u64) -> Option { + match self { + // NeMo Relay prices each request with the first tier whose bounds + // contain its prompt tokens. Every request had between zero and + // the peak prompt tokens, so one tier's rates apply to all of them + // exactly when the first tier that overlaps that range contains + // all of it. + Self::PromptTokenThreshold { tiers, .. } => { + let tier = tiers + .iter() + .find(|tier| tier.overlaps_up_to(peak_prompt_tokens))?; + tier.contains_up_to(peak_prompt_tokens) + .then_some(tier.rates) + } + } + } + + fn validate(&self, entry_index: usize) -> Result<(), PricingCatalogError> { + let Self::PromptTokenThreshold { tiers, .. } = self; + if tiers.is_empty() { + return Err(empty_field(entry_index, "rate_schedule.tiers")); + } + for (tier_index, tier) in tiers.iter().enumerate() { + if let (Some(min), Some(max)) = (tier.min_prompt_tokens, tier.max_prompt_tokens) + && min > max + { + return Err(PricingCatalogError::InvalidRate { + entry_index, + field: "rate_schedule.tiers.prompt_tokens".to_string(), + // NeMo Relay reports the lower bound in the same way. + value: min as f64, + }); + } + tier.rates.validate( + entry_index, + &format!("rate_schedule.tiers[{tier_index}].rates"), + )?; + } + Ok(()) + } +} + +impl TokenRateTier { + /// Whether the tier contains any prompt token count from zero to `peak`. + fn overlaps_up_to(&self, peak: u64) -> bool { + self.min_prompt_tokens.unwrap_or(0) <= peak + } + + /// Whether the tier contains every prompt token count from zero to `peak`. + fn contains_up_to(&self, peak: u64) -> bool { + self.min_prompt_tokens.unwrap_or(0) == 0 + && self.max_prompt_tokens.is_none_or(|max| peak <= max) + } +} + +fn empty_field(entry_index: usize, field: &str) -> PricingCatalogError { + PricingCatalogError::EmptyField { + entry_index, + field: field.to_string(), + } +} + +fn normalized_model(model: &str) -> String { + model.trim().to_ascii_lowercase() +} + +fn normalized_provider(provider: &str) -> String { + provider.trim().trim_matches('/').to_ascii_lowercase() +} + +/// A lookup key: a normalized model, optionally qualified by a normalized +/// provider. +#[derive(Debug, PartialEq, Eq)] +struct LookupKey { + provider: Option, + model: String, +} + +/// Prefer exact model keys, preserving an explicitly established provider. +fn lookup_keys(provider: Option<&str>, model: &str) -> Vec { + let model = normalized_model(model); + if model.is_empty() { + return Vec::new(); + } + let parts: Vec<&str> = model + .split('/') + .map(str::trim) + .filter(|part| !part.is_empty()) + .collect(); + let last = parts.last().copied().unwrap_or(model.as_str()); + let mut keys = Vec::new(); + let mut push = |provider: Option, model: String| { + let key = LookupKey { provider, model }; + if !key.model.is_empty() && !keys.contains(&key) { + keys.push(key); + } + }; + let explicit_provider = provider + .map(normalized_provider) + .filter(|provider| !provider.is_empty()); + match &explicit_provider { + Some(provider) => { + push(Some(provider.clone()), model.clone()); + push(Some(provider.clone()), last.to_string()); + } + None if parts.len() > 1 => { + push(Some(parts[..parts.len() - 1].join("/")), last.to_string()); + } + None => {} + } + for start in 0..parts.len() { + push(explicit_provider.clone(), parts[start..].join("/")); + } + keys +} + +/// Where a price came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PriceOrigin { + /// The catalog NeMo Fabric ships. + Bundled, + /// The catalog the caller supplied. + User, +} + +/// The price catalogs an estimate consults, the caller's first. +pub(crate) struct PriceCatalogs<'a> { + bundled: Option<&'a PricingCatalog>, + bundled_sha256: &'a str, + user: Option<&'a UserPriceCatalog>, +} + +impl<'a> PriceCatalogs<'a> { + /// The shipped catalog, preceded by the caller's catalog when given. + pub(crate) fn with_user_catalog(user: Option<&'a UserPriceCatalog>) -> Self { + Self::from_parts( + BUNDLED_CATALOG.catalog.as_ref(), + &BUNDLED_CATALOG.sha256, + user, + ) + } + + pub(crate) fn from_parts( + bundled: Option<&'a PricingCatalog>, + bundled_sha256: &'a str, + user: Option<&'a UserPriceCatalog>, + ) -> Self { + Self { + bundled, + bundled_sha256, + user, + } + } + + /// Fingerprint of the shipped catalog. + pub(crate) fn bundled_sha256(&self) -> &'a str { + self.bundled_sha256 + } + + /// Fingerprint of the caller's catalog, when one is configured. + pub(crate) fn user_sha256(&self) -> Option<&'a str> { + self.user.map(|user| user.sha256.as_str()) + } + + /// Whether the shipped catalog parsed. + pub(crate) fn bundled_is_valid(&self) -> bool { + self.bundled.is_some() + } + + /// The entry for a model, from the caller's catalog first. With + /// `user_only`, the shipped catalog is not consulted. + pub(crate) fn find( + &self, + provider: Option<&str>, + model: &str, + user_only: bool, + ) -> Option<(&'a ModelPricing, PriceOrigin)> { + let user = self + .user + .and_then(|user| user.catalog.pricing_for(provider, model)) + .map(|entry| (entry, PriceOrigin::User)); + if user.is_some() || user_only { + return user; + } + self.bundled + .and_then(|bundled| bundled.pricing_for(provider, model)) + .map(|entry| (entry, PriceOrigin::Bundled)) + } +} + +/// Nano-USD per million tokens of a rate in US dollars per million tokens, +/// rounded to the nearest whole number. `None` for a rate no valid catalog +/// holds: negative, not finite, or not below one million. +pub(crate) fn nano_usd_per_million_tokens(rate: f64) -> Option { + (rate.is_finite() && (0.0..MAX_RATE_PER_MILLION).contains(&rate)) + // In range, so the rounded value is a whole number below 10^15 and + // the conversion is exact. + .then(|| (rate * NANO_USD_PER_USD).round() as u128) +} + +/// `sha256:` followed by the lowercase hexadecimal SHA-256 of `bytes`. +pub(crate) fn sha256_fingerprint(bytes: &[u8]) -> String { + let digest = Sha256::digest(bytes); + let mut text = String::with_capacity("sha256:".len() + digest.len() * 2); + text.push_str("sha256:"); + for byte in digest.iter() { + // Writing to a String cannot fail. + let _ = write!(text, "{byte:02x}"); + } + text +} + +fn default_currency() -> String { + "USD".to_string() +} + +#[cfg(test)] +mod tests { + use serde_json::{Value, json}; + + use super::*; + + fn catalog(entries: Value) -> PricingCatalog { + PricingCatalog::from_json( + json!({"version": 1, "entries": entries}) + .to_string() + .as_bytes(), + ) + .expect("valid catalog") + } + + fn catalog_error(entries: Value) -> String { + PricingCatalog::from_json( + json!({"version": 1, "entries": entries}) + .to_string() + .as_bytes(), + ) + .expect_err("invalid catalog") + .to_string() + } + + fn flat_entry(provider: &str, model_id: &str, input: f64, output: f64) -> Value { + json!({ + "provider": provider, + "model_id": model_id, + "pricing_as_of": "2026-06-04", + "pricing_source": format!("https://example.test/{provider}"), + "rates": {"input_per_million": input, "output_per_million": output}, + "prompt_cache": {"read_accounting": "separate"} + }) + } + + fn threshold_entry(tiers: Value) -> Value { + json!({ + "provider": "threshold-ai", + "model_id": "threshold-model", + "pricing_as_of": "2026-06-05", + "pricing_source": "https://example.test/pricing", + "rate_schedule": {"type": "prompt_token_threshold", "tiers": tiers}, + "prompt_cache": {"read_accounting": "separate"} + }) + } + + fn rates(input: f64) -> Value { + json!({"input_per_million": input, "output_per_million": input * 2.0}) + } + + fn found<'a>( + catalog: &'a PricingCatalog, + provider: Option<&str>, + model: &str, + ) -> (&'a str, &'a str) { + let entry = catalog + .pricing_for(provider, model) + .unwrap_or_else(|| panic!("no entry for {provider:?} {model}")); + (entry.provider.as_str(), entry.model_id.as_str()) + } + + // The shipped catalog. + + #[test] + fn bundled_catalog_is_valid_and_cites_a_dated_provider_page_for_every_entry() { + let catalog = PricingCatalog::from_json(BUNDLED_CATALOG_JSON.as_bytes()) + .expect("the shipped catalog is valid"); + + assert!(!catalog.entries.is_empty()); + for entry in &catalog.entries { + let name = format!("{}/{}", entry.provider, entry.model_id); + assert_eq!(entry.currency, "USD", "{name}"); + assert_eq!(entry.unit, PricingUnit::PerToken, "{name}"); + assert!( + entry.pricing_source.starts_with("https://"), + "{name}: {}", + entry.pricing_source + ); + assert!( + is_iso_date(&entry.pricing_as_of), + "{name}: {}", + entry.pricing_as_of + ); + } + assert!(BUNDLED_CATALOG.catalog.is_some()); + } + + /// `YYYY-MM-DD` with a plausible month and day. The test never compares + /// the date with today's, so an old date cannot fail CI. + fn is_iso_date(text: &str) -> bool { + let parts: Vec<&str> = text.split('-').collect(); + let [year, month, day] = parts.as_slice() else { + return false; + }; + let number = |part: &str, digits: usize| { + (part.len() == digits && part.bytes().all(|byte| byte.is_ascii_digit())) + .then(|| part.parse::().ok()) + .flatten() + }; + matches!( + (number(year, 4), number(month, 2), number(day, 2)), + (Some(_), Some(1..=12), Some(1..=31)) + ) + } + + #[test] + fn bundled_catalog_keeps_native_provider_boundaries_for_routed_models() { + let catalog = BUNDLED_CATALOG.catalog.as_ref().expect("bundled catalog"); + assert!( + catalog + .pricing_for(Some("nvidia"), "aws/anthropic/claude-opus-4-5") + .is_none() + ); + assert!( + catalog + .pricing_for(Some("nvidia"), "azure/openai/gpt-5.4") + .is_none() + ); + assert_eq!( + found(catalog, Some("anthropic"), "aws/anthropic/claude-opus-4-5"), + ("anthropic", "claude-opus-4-5") + ); + assert_eq!( + found(catalog, Some("openai"), "azure/openai/gpt-5.4"), + ("openai", "gpt-5.4") + ); + assert_eq!( + found(catalog, Some("anthropic"), "claude-sonnet-4-5-20250929"), + ("anthropic", "claude-sonnet-4-5") + ); + } + + #[test] + fn catalog_fingerprint_identifies_validated_pricing_content() { + assert_eq!( + sha256_fingerprint(b"abc"), + "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + let fingerprint = PriceCatalogs::with_user_catalog(None).bundled_sha256(); + assert_eq!( + fingerprint, + catalog_fingerprint( + &PricingCatalog::from_json(BUNDLED_CATALOG_JSON.as_bytes()).unwrap() + ) + .unwrap() + ); + assert_eq!(fingerprint.len(), "sha256:".len() + 64); + let compact = serde_json::to_vec( + &PricingCatalog::from_json(BUNDLED_CATALOG_JSON.as_bytes()).unwrap(), + ) + .unwrap(); + assert_eq!( + fingerprint, + UserPriceCatalog::from_bytes("prices.json".into(), &compact) + .unwrap() + .sha256 + ); + } + + #[test] + fn deserialized_user_catalog_rejects_changed_rates_and_fingerprint() { + let catalog = + UserPriceCatalog::from_bytes("prices.json".into(), BUNDLED_CATALOG_JSON.as_bytes()) + .unwrap(); + let mut changed = catalog.clone(); + changed.catalog.version = 2; + assert!(changed.validate_integrity().is_err()); + changed = catalog.clone(); + changed.sha256 = "sha256:unverified".into(); + assert!(changed.validate_integrity().is_err()); + let mut serialized = serde_json::to_value(&catalog).unwrap(); + serialized["sha256"] = serde_json::json!("sha256:unverified"); + assert!(serde_json::from_value::(serialized).is_err()); + + let mut serialized = serde_json::to_value(&catalog).unwrap(); + serialized["catalog"]["entries"][0]["rates"]["input_per_million"] = serde_json::json!(4.0); + assert!(serde_json::from_value::(serialized).is_err()); + + let mut serialized = serde_json::to_value(&catalog).unwrap(); + serialized["catalog"]["version"] = serde_json::json!(2); + assert!(serde_json::from_value::(serialized).is_err()); + } + + // Lookup, mirroring NeMo Relay's model pricing tests. + + #[test] + fn lookup_matches_aliases_case_insensitively() { + let catalog = catalog(json!([{ + "provider": "configured", + "model_id": "configured-model", + "aliases": ["configured-model-2026-06-04"], + "pricing_as_of": "2026-06-04", + "pricing_source": "file:///tmp/pricing.json", + "rates": {"input_per_million": 0.15, "output_per_million": 0.60}, + "prompt_cache": {"read_accounting": "included_in_prompt_tokens"} + }])); + + let entry = catalog + .pricing_for(None, "CONFIGURED-MODEL-2026-06-04") + .expect("alias"); + + assert_eq!(entry.model_id, "configured-model"); + assert_eq!(entry.currency, "USD"); + assert_eq!(entry.unit, PricingUnit::PerToken); + } + + #[test] + fn lookup_matches_routed_model_names_by_suffix() { + let catalog = catalog(json!([flat_entry("openai", "gpt-4o-mini", 0.15, 0.6)])); + + assert_eq!( + found(&catalog, None, "azure/openai/gpt-4o-mini"), + ("openai", "gpt-4o-mini") + ); + assert_eq!( + found(&catalog, None, "openai/openai/gpt-4o-mini"), + ("openai", "gpt-4o-mini") + ); + } + + #[test] + fn lookup_prefers_the_exact_routed_model_before_a_suffix() { + let catalog = catalog(json!([ + flat_entry("openai", "gpt-4o-mini", 0.15, 0.6), + flat_entry("azure-openai", "azure/openai/gpt-4o-mini", 1.0, 2.0) + ])); + + assert_eq!( + found(&catalog, None, "azure/openai/gpt-4o-mini"), + ("azure-openai", "azure/openai/gpt-4o-mini") + ); + } + + #[test] + fn lookup_tells_apart_one_model_id_of_two_providers() { + let catalog = catalog(json!([ + flat_entry("openai", "gpt-4o-mini", 0.15, 0.6), + flat_entry("azure/openai", "gpt-4o-mini", 1.0, 2.0) + ])); + + assert_eq!( + found(&catalog, Some("openai"), "gpt-4o-mini"), + ("openai", "gpt-4o-mini") + ); + assert_eq!( + found(&catalog, Some("azure/openai"), "gpt-4o-mini"), + ("azure/openai", "gpt-4o-mini") + ); + } + + #[test] + fn lookup_normalizes_providers_and_ignores_blank_or_unknown_models() { + let catalog = catalog(json!([{ + "provider": "provider-a", + "model_id": "priced-model", + "aliases": ["alias-model"], + "pricing_as_of": "2026-06-04", + "pricing_source": "test", + "rates": {"input_per_million": 1.0, "output_per_million": 2.0}, + "prompt_cache": {"read_accounting": "included_in_prompt_tokens"} + }])); + + assert_eq!( + found(&catalog, Some("/provider-a/"), "provider-a/priced-model"), + ("provider-a", "priced-model") + ); + assert_eq!( + found(&catalog, None, " ALIAS-MODEL "), + ("provider-a", "priced-model") + ); + assert!(catalog.pricing_for(Some("other"), "priced-model").is_none()); + assert_eq!( + found(&catalog, Some("provider-a"), "path/alias-model"), + ("provider-a", "priced-model") + ); + assert!(catalog.pricing_for(None, " ").is_none()); + assert!(catalog.pricing_for(None, "missing-model").is_none()); + } + + #[test] + fn caller_catalog_takes_precedence_and_alone_prices_self_hosted_models() { + let bundled = catalog(json!([ + flat_entry("openai", "gpt-4o-mini", 0.15, 0.6), + flat_entry("openai", "gpt-4o", 2.5, 10.0) + ])); + let user = UserPriceCatalog { + path: PathBuf::from("/prices.json"), + sha256: "sha256:user".to_string(), + catalog: catalog(json!([flat_entry("openai", "gpt-4o-mini", 1.0, 2.0)])), + }; + let prices = PriceCatalogs::from_parts(Some(&bundled), "sha256:bundled", Some(&user)); + + let origin = |model: &str, user_only: bool| { + prices + .find(Some("openai"), model, user_only) + .map(|(entry, origin)| (entry.provider.as_str(), origin)) + }; + + assert_eq!( + origin("gpt-4o-mini", false), + Some(("openai", PriceOrigin::User)) + ); + assert_eq!( + origin("gpt-4o", false), + Some(("openai", PriceOrigin::Bundled)) + ); + assert_eq!(origin("gpt-4o", true), None); + assert_eq!( + origin("gpt-4o-mini", true), + Some(("openai", PriceOrigin::User)) + ); + assert_eq!(prices.bundled_sha256(), "sha256:bundled"); + assert_eq!(prices.user_sha256(), Some("sha256:user")); + } + + #[test] + fn unrelated_user_provider_cannot_shadow_matching_bundled_price() { + let bundled = catalog(json!([flat_entry("openai", "model", 1.0, 2.0)])); + let user = UserPriceCatalog { + path: PathBuf::from("/prices.json"), + sha256: "sha256:user".to_string(), + catalog: catalog(json!([flat_entry("custom-route", "model", 3.0, 4.0)])), + }; + let prices = PriceCatalogs::from_parts(Some(&bundled), "sha256:bundled", Some(&user)); + let (entry, origin) = prices.find(Some("openai"), "model", false).unwrap(); + assert_eq!(entry.provider, "openai"); + assert_eq!(origin, PriceOrigin::Bundled); + let (entry, origin) = prices.find(Some("custom-route"), "model", true).unwrap(); + assert_eq!(entry.provider, "custom-route"); + assert_eq!(origin, PriceOrigin::User); + } + + #[test] + fn providerless_lookup_rejects_cross_provider_model_and_alias_ambiguity() { + for reversed in [false, true] { + let mut aliased = flat_entry("other", "different-model", 3.0, 4.0); + aliased["aliases"] = json!(["shared-model"]); + let mut entries = vec![flat_entry("native", "shared-model", 1.0, 2.0), aliased]; + if reversed { + entries.reverse(); + } + let catalog = catalog(json!(entries)); + assert!(catalog.pricing_for(None, "shared-model").is_none()); + assert_eq!( + found(&catalog, Some("native"), "shared-model"), + ("native", "shared-model") + ); + assert_eq!( + found(&catalog, None, "native/shared-model"), + ("native", "shared-model") + ); + assert_eq!( + found(&catalog, None, "different-model"), + ("other", "different-model") + ); + } + } + + #[test] + fn ambiguous_model_key_does_not_fall_through_to_a_shorter_model() { + let catalog = catalog(json!([ + flat_entry("first", "path/shared-model", 1.0, 2.0), + flat_entry("second", "path/shared-model", 3.0, 4.0), + flat_entry("third", "shared-model", 5.0, 6.0) + ])); + assert!(catalog.pricing_for(None, "path/shared-model").is_none()); + } + + // Validation, mirroring NeMo Relay's catalog validation tests. + + #[test] + fn validation_rejects_an_unsupported_version() { + let error = PricingCatalog::from_json(br#"{"version": 2, "entries": []}"#) + .expect_err("version 2") + .to_string(); + + assert!( + error.contains("unsupported model pricing catalog version 2"), + "{error}" + ); + } + + #[test] + fn validation_rejects_duplicate_model_aliases_of_one_provider() { + let mut other = flat_entry("a", "other-model", 1.0, 1.0); + other["aliases"] = json!(["SAME-MODEL"]); + + let error = catalog_error(json!([flat_entry("a", "same-model", 1.0, 1.0), other])); + + assert!( + error.contains("duplicate model pricing alias 'a/same-model'"), + "{error}" + ); + } + + #[test] + fn validation_rejects_blank_required_fields() { + for field in [ + "provider", + "model_id", + "currency", + "pricing_as_of", + "pricing_source", + ] { + let mut entry = flat_entry("configured", "configured-model", 1.0, 2.0); + entry[field] = json!(" "); + + let error = catalog_error(json!([entry])); + + assert!( + error.contains(&format!("empty {field}")), + "{field}: {error}" + ); + } + } + + #[test] + fn validation_rejects_negative_or_too_large_rates() { + for (field, value) in [ + ("input_per_million", -0.1), + ("output_per_million", -0.1), + ("cache_read_per_million", -0.1), + ("cache_write_per_million", -0.1), + ("input_per_million", 1_000_000.0), + ] { + let mut entry = flat_entry("configured", "configured-model", 1.0, 2.0); + entry["rates"][field] = json!(value); + + let error = catalog_error(json!([entry])); + + assert!( + error.contains(&format!("rates.{field}")), + "{field}: {error}" + ); + } + } + + #[test] + fn validation_requires_rates_for_per_token_entries() { + let mut entry = flat_entry("broken", "missing-rates", 1.0, 2.0); + entry.as_object_mut().expect("entry").remove("rates"); + + let error = catalog_error(json!([entry])); + + assert!(error.contains("empty rates or rate_schedule"), "{error}"); + } + + #[test] + fn non_token_units_are_valid_without_rates() { + let mut entry = flat_entry("self-hosted", "nemotron-owned", 1.0, 2.0); + entry.as_object_mut().expect("entry").remove("rates"); + entry["unit"] = json!("gpu_hour"); + + let catalog = catalog(json!([entry])); + + assert_eq!(catalog.entries[0].unit, PricingUnit::GpuHour); + } + + #[test] + fn validation_rejects_invalid_rate_schedules() { + let empty = catalog_error(json!([threshold_entry(json!([]))])); + let reversed = catalog_error(json!([threshold_entry(json!([ + {"min_prompt_tokens": 20, "max_prompt_tokens": 10, "rates": rates(1.0)} + ]))])); + let negative = catalog_error(json!([threshold_entry(json!([{"rates": rates(-1.0)}]))])); + + assert!(empty.contains("rate_schedule.tiers"), "{empty}"); + assert!( + reversed.contains("rate_schedule.tiers.prompt_tokens"), + "{reversed}" + ); + assert!( + negative.contains("rate_schedule.tiers[0].rates.input_per_million"), + "{negative}" + ); + } + + #[test] + fn validation_rejects_unknown_fields_at_every_level() { + let entry = flat_entry("configured", "configured-model", 1.0, 2.0); + let with = |pointer: &str, field: &str| { + let mut catalog = json!({"version": 1, "entries": [entry.clone()]}); + catalog + .pointer_mut(pointer) + .and_then(Value::as_object_mut) + .expect("object") + .insert(field.to_string(), json!(true)); + catalog + }; + let mut tiers = json!({"version": 1, "entries": [threshold_entry(json!([ + {"min_prompt_tokens": 0, "rates": rates(1.0)} + ]))]}); + tiers["entries"][0]["rate_schedule"]["tierz"] = json!([]); + let tier = json!({"version": 1, "entries": [threshold_entry(json!([ + {"min_prompt_toknes": 0, "rates": rates(1.0)} + ]))]}); + + for (catalog, field) in [ + (with("", "unexpected_catalog"), "unexpected_catalog"), + (with("/entries/0", "unexpected_entry"), "unexpected_entry"), + ( + with("/entries/0/rates", "cache_writ_per_million"), + "cache_writ_per_million", + ), + ( + with("/entries/0/prompt_cache", "read_accountng"), + "read_accountng", + ), + (tiers, "tierz"), + (tier, "min_prompt_toknes"), + ] { + let error = PricingCatalog::from_json(catalog.to_string().as_bytes()) + .expect_err("unknown field") + .to_string(); + + assert!( + error.contains(&format!("unknown field `{field}`")), + "{field}: {error}" + ); + } + } + + // Rate tiers. + + #[test] + fn a_rate_schedule_resolves_only_when_one_tier_covers_every_request() { + let catalog = catalog(json!([threshold_entry(json!([ + {"max_prompt_tokens": 200_000, "rates": rates(1.0)}, + {"min_prompt_tokens": 200_001, "rates": rates(10.0)} + ]))])); + let entry = &catalog.entries[0]; + let input = + |peak: Option| entry.rates_up_to(peak).map(|rates| rates.input_per_million); + + assert_eq!(input(Some(0)), Some(1.0)); + assert_eq!(input(Some(200_000)), Some(1.0)); + assert_eq!(input(Some(200_001)), None); + assert_eq!(input(None), None); + } + + #[test] + fn a_rate_schedule_follows_tier_order_like_nemo_relay() { + let catalog = catalog(json!([threshold_entry(json!([ + {"min_prompt_tokens": 100, "rates": rates(10.0)}, + {"max_prompt_tokens": 99, "rates": rates(1.0)} + ]))])); + let gap = catalog_with_first_tier_from(1); + let entry = &catalog.entries[0]; + + assert_eq!( + entry + .rates_up_to(Some(99)) + .map(|rates| rates.input_per_million), + Some(1.0) + ); + assert_eq!(entry.rates_up_to(Some(100)), None); + assert_eq!(gap.entries[0].rates_up_to(Some(10)), None); + } + + fn catalog_with_first_tier_from(min_prompt_tokens: u64) -> PricingCatalog { + catalog(json!([threshold_entry(json!([ + {"min_prompt_tokens": min_prompt_tokens, "rates": rates(1.0)} + ]))])) + } + + #[test] + fn flat_rates_apply_whatever_the_peak() { + let catalog = catalog(json!([flat_entry("openai", "gpt-4o-mini", 0.15, 0.6)])); + + for peak in [None, Some(0), Some(u64::MAX)] { + assert_eq!( + catalog.entries[0] + .rates_up_to(peak) + .map(|rates| rates.input_per_million), + Some(0.15) + ); + } + } + + // Rate conversion. + + #[test] + fn rates_convert_to_whole_nano_usd_per_million_tokens() { + for (rate, expected) in [ + (0.0, Some(0)), + (0.075, Some(75_000_000)), + (0.3, Some(300_000_000)), + (2.5, Some(2_500_000_000)), + (3.75, Some(3_750_000_000)), + (1e-10, Some(0)), + (6e-10, Some(1)), + (999_999.999_999_999, Some(999_999_999_999_999)), + (1_000_000.0, None), + (-0.000_001, None), + (f64::NAN, None), + (f64::INFINITY, None), + ] { + assert_eq!(nano_usd_per_million_tokens(rate), expected, "rate {rate}"); + } + } +} diff --git a/crates/fabric-core/src/pricing.rs b/crates/fabric-core/src/pricing.rs new file mode 100644 index 000000000..be54618ce --- /dev/null +++ b/crates/fabric-core/src/pricing.rs @@ -0,0 +1,789 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Catalog prices are estimates, separate from provider-reported amounts. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::measurement::{ + Attribution, Availability, Measurement, MeasurementSource, MeasurementUnit, +}; +use crate::price_catalog::{ + PriceCatalogs, PriceOrigin, PricingUnit, TokenPricingRates, UserPriceCatalog, + nano_usd_per_million_tokens, +}; +use crate::usage_accounting::GapReason; +use crate::usage_normalization::{AccountingDimension, AccountingGap, ModelUsage, ObservedUsage}; + +pub(crate) const OPENAI_ENDPOINT_ENV_KEYS: [&str; 2] = ["OPENAI_BASE_URL", "OPENAI_API_BASE"]; +pub(crate) const ANTHROPIC_ENDPOINT_ENV_KEYS: [&str; 1] = ["ANTHROPIC_BASE_URL"]; + +/// Configured and inherited endpoint overrides must still identify a native +/// endpoint before provider list prices apply. An adapter can inherit URL +/// settings even when models..base_url is absent. +pub(crate) fn native_catalog_route( + model: &crate::ModelConfig, + forwarded: &std::collections::BTreeMap, + inherited: impl Fn(&str) -> Option, +) -> bool { + native_catalog_endpoint( + &model.provider, + model.base_url.as_deref(), + forwarded, + inherited, + ) +} + +fn native_catalog_endpoint( + provider: &str, + base_url: Option<&str>, + forwarded: &std::collections::BTreeMap, + inherited: impl Fn(&str) -> Option, +) -> bool { + let (keys, urls): (&[&str], &[&str]) = match provider { + "openai" => (&OPENAI_ENDPOINT_ENV_KEYS, &["https://api.openai.com/v1"]), + "anthropic" => ( + &ANTHROPIC_ENDPOINT_ENV_KEYS, + &["https://api.anthropic.com", "https://api.anthropic.com/v1"], + ), + _ => return false, + }; + let native = |url: &str| urls.contains(&url.trim_end_matches('/')); + if let Some(url) = base_url { + return native(url); + } + keys.iter().all(|key| { + forwarded + .get(*key) + .cloned() + .or_else(|| inherited(key)) + .is_none_or(|url| native(&url)) + }) +} + +/// Startup route evidence, qualified for the provider/model actually accounted. +/// An unused custom role must not disable a different model's native tariff. +#[derive(Debug, Clone)] +pub(crate) struct NativePricingRoutes { + models: Vec, + endpoints: std::collections::BTreeMap, +} + +#[derive(Debug, Clone)] +struct NativeModelRoute { + provider: String, + model: String, + native: bool, +} + +impl NativePricingRoutes { + pub(crate) fn from_models( + models: &std::collections::BTreeMap, + endpoints: &std::collections::BTreeMap, + ) -> Self { + Self { + models: models + .values() + .map(|model| NativeModelRoute { + provider: model.provider.clone(), + model: model.model.clone(), + native: native_catalog_route(model, endpoints, |_| None), + }) + .collect(), + endpoints: endpoints.clone(), + } + } + + pub(crate) fn allows(&self, provider: Option<&str>, model: &str) -> bool { + let mut exact = self + .models + .iter() + .filter(|route| Some(route.provider.as_str()) == provider && route.model == model) + .peekable(); + if exact.peek().is_some() { + return exact.all(|route| route.native); + } + // A native provider route still supports a served-model alias. Mixed + // routes for that provider cannot establish which endpoint served it. + let mut provider_routes = self + .models + .iter() + .filter(|route| Some(route.provider.as_str()) == provider) + .peekable(); + if provider_routes.peek().is_some() { + return provider_routes.all(|route| route.native); + } + // A child provider's inherited endpoint can differ from configured roles. + let accounted_endpoint_native = provider.is_none_or(|provider| { + native_catalog_endpoint(provider, None, &self.endpoints, |_| None) + }); + accounted_endpoint_native + && !self.models.is_empty() + && self.models.iter().all(|route| route.native) + } +} + +/// Fields that must agree before a report combines catalog estimates. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct PricingBasis { + /// `catalog`; provider-reported cost has a different basis. + pub basis: String, + /// `USD` in this implementation. + pub currency: String, + /// Version of normalization and monetary rounding. + pub normalization_version: String, + /// No missing count is assumed zero. + pub assumption_policy: String, + /// Exact bundled catalog bytes. + pub bundled_catalog_sha256: String, + /// Exact override bytes, when supplied. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_catalog_sha256: Option, +} + +/// Integer rates selected for a model, in nano-USD per million tokens. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RateSnapshot { + /// Uncached input rate. + #[schemars(range(max = u64::MAX))] + pub input: u64, + /// Reasoning-inclusive output rate. + #[schemars(range(max = u64::MAX))] + pub output: u64, + /// Cache read rate; absent is not zero. + #[schemars(range(max = u64::MAX))] + pub cache_read: Option, + /// Cache write rate; absent is not zero. + #[schemars(range(max = u64::MAX))] + pub cache_write: Option, +} + +/// Persisted entry and selected rates, so saved prices remain reproducible. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct AppliedPricing { + /// Exact catalog containing this entry. + pub catalog_sha256: String, + /// Entry's provider, without rewriting the observed identity. + pub provider: String, + /// Entry's model identity, which may match an explicit alias. + pub model_id: String, + /// Date recorded by the catalog author. + pub pricing_as_of: String, + /// Source recorded by the catalog author. + pub pricing_source: String, + /// One selected rate set; distinct selections are retained in `model_usage::TokenCost.applied_rates`. + pub rates: RateSnapshot, +} + +impl RateSnapshot { + pub(crate) fn from_rates(rates: TokenPricingRates) -> Result { + let convert = |rate| { + nano_usd_per_million_tokens(rate) + .and_then(|value| u64::try_from(value).ok()) + .ok_or("price_catalog_invalid") + }; + Ok(Self { + input: convert(rates.input_per_million)?, + output: convert(rates.output_per_million)?, + cache_read: rates.cache_read_per_million.map(convert).transpose()?, + cache_write: rates.cache_write_per_million.map(convert).transpose()?, + }) + } + + fn price( + &self, + input: Option, + cached: Option, + written: Option, + output: Option, + ) -> Result { + rounded_price(self.charges(input, cached, written, output)?) + } + + fn charges( + &self, + input: Option, + cached: Option, + written: Option, + output: Option, + ) -> Result { + let input = input.ok_or("pricing_input_unreported")?; + let output = output.ok_or("pricing_output_unreported")?; + // A missing cache category cannot affect the price only when it is + // charged exactly like ordinary input, or the entire input is zero. + let component = |count: Option, rate: Option| match count { + Some(count) => match rate { + Some(rate) => Ok((count, rate)), + None if count == 0 => Ok((0, 0)), + None => Err("cache_rate_unreported"), + }, + None if input == 0 || rate == Some(self.input) => Ok((0, self.input)), + None => Err("pricing_cache_unreported"), + }; + let (cached, read_rate) = component(cached, self.cache_read)?; + let (written, write_rate) = component(written, self.cache_write)?; + let uncached = input + .checked_sub(cached) + .and_then(|v| v.checked_sub(written)) + .ok_or("inconsistent_usage")?; + let charges = [ + (uncached, self.input), + (cached, read_rate), + (written, write_rate), + (output, self.output), + ]; + let sum = charges + .into_iter() + .try_fold(0_u128, |sum, (count, rate)| { + sum.checked_add(u128::from(count).checked_mul(u128::from(rate))?) + }) + .ok_or("cost_out_of_range")?; + Ok(sum) + } + + fn observed_charges( + &self, + observed: &ObservedUsage, + aligned: bool, + write_tariff_known: bool, + ) -> Option { + // Categories are disjoint. Without aligned input/cache observations, + // retain independently priceable categories instead of subtracting + // counters from different source-call subsets. + let mut charges = 0_u128; + let mut any = false; + let mut add = |count: Option, rate: Option| -> Option<()> { + if let (Some(count), Some(rate)) = (count, rate) { + charges = charges.checked_add(u128::from(count).checked_mul(u128::from(rate))?)?; + any = true; + } + Some(()) + }; + add(observed.output_tokens, Some(self.output))?; + let equal_rates = + self.cache_read == Some(self.input) && self.cache_write == Some(self.input); + if let Some(input) = observed + .input_tokens + .filter(|_| equal_rates && write_tariff_known) + { + add(Some(input), Some(self.input))?; + } else { + if aligned { + let cached = observed + .cached_input_tokens + .or((self.cache_read == Some(self.input)).then_some(0)); + let written = observed + .cache_write_input_tokens + .or((write_tariff_known && self.cache_write == Some(self.input)).then_some(0)); + if let (Some(input), Some(cached), Some(written)) = + (observed.input_tokens, cached, written) + { + add( + input + .checked_sub(cached) + .and_then(|v| v.checked_sub(written)), + Some(self.input), + )?; + } + } + add(observed.cached_input_tokens, self.cache_read)?; + if write_tariff_known { + add(observed.cache_write_input_tokens, self.cache_write)?; + } + } + any.then_some(charges) + } + + fn observed_price( + &self, + observed: &ObservedUsage, + aligned: bool, + write_tariff_known: bool, + ) -> Option { + rounded_price(self.observed_charges(observed, aligned, write_tariff_known)?).ok() + } +} + +fn rounded_price(charges: u128) -> Result { + u64::try_from(charges.checked_add(500_000).ok_or("cost_out_of_range")? / 1_000_000) + .map_err(|_| "cost_out_of_range") +} + +fn estimate(value: Result, source: MeasurementSource) -> Measurement { + Measurement { + availability: if value.is_ok() { + Availability::Measured + } else { + Availability::Unavailable + }, + value: value.ok(), + unit: MeasurementUnit::NanoUsd, + source, + attribution: if value.is_ok() { + Attribution::Invocation + } else { + Attribution::None + }, + reason: value.err().map(str::to_owned), + } +} + +fn update_price_gaps(gaps: &mut Vec, cost: &Measurement) { + gaps.retain(|gap| gap.dimensions != [AccountingDimension::EstimatedCost]); + if cost.value.is_some() { + return; + } + let mut reasons: std::collections::BTreeSet<_> = gaps + .iter() + .filter(|gap| { + gap.dimensions.iter().any(|dimension| { + matches!( + dimension, + AccountingDimension::InputTokens | AccountingDimension::OutputTokens + ) + }) + }) + .map(|gap| gap.reason) + .collect(); + if reasons.is_empty() { + reasons.insert(match cost.reason.as_deref() { + Some("model_scope_unverified" | "inconsistent_usage" | "provider_price_unverified") => { + GapReason::ScopeUnverified + } + _ => GapReason::MissingUsage, + }); + } + gaps.extend(reasons.into_iter().map(|reason| AccountingGap { + dimensions: vec![AccountingDimension::EstimatedCost], + reason, + })); +} + +/// Price validated normalized usage with a route decision for each accounted model. +/// An unqualified route permits only explicitly supplied prices. +/// +/// Price with validated original request boundaries when an aggregate spans tiers. +/// Keep this evidence explicit: normalized aggregates cannot recover request sizes. +pub(crate) fn apply_pricing_with_evidence( + usage: &mut ModelUsage, + user: Option<&UserPriceCatalog>, + bundled_allowed: impl Fn(Option<&str>, &str) -> bool, + evidence: Option<&crate::usage_accounting::UsageAccounting>, +) { + let prices = PriceCatalogs::with_user_catalog(user); + if !prices.bundled_is_valid() + || user.is_some_and(|catalog| catalog.validate_integrity().is_err()) + { + usage.token_cost.estimated = estimate( + Err("price_catalog_invalid"), + MeasurementSource::FabricPriceCatalog, + ); + return; + } + let basis = PricingBasis { + basis: "catalog".into(), + currency: "USD".into(), + normalization_version: "cache_reasoning_inclusive_v1".into(), + assumption_policy: "applicable_tariff_no_missing_counts_v1".into(), + bundled_catalog_sha256: prices.bundled_sha256().into(), + user_catalog_sha256: prices.user_sha256().map(str::to_owned), + }; + usage.token_cost.pricing = Some(basis.clone()); + if usage.accounting.is_none() { + usage.token_cost.estimated = estimate( + Err("legacy_unverified"), + MeasurementSource::FabricPriceCatalog, + ); + return; + } + let mut any_user = false; + for model in &mut usage.models { + let cost = &mut model.usage.token_cost; + cost.applied = None; + cost.applied_calls.clear(); + model.accounting.observed.estimated_cost_nano_usd = None; + cost.pricing = Some(basis.clone()); + let selected = prices.find( + model.provider.as_deref(), + &model.model, + !bundled_allowed(model.provider.as_deref(), &model.model), + ); + let Some((entry, origin)) = selected else { + cost.estimated = estimate( + Err("model_not_in_catalog"), + MeasurementSource::FabricPriceCatalog, + ); + update_price_gaps(&mut model.accounting.gaps, &cost.estimated); + continue; + }; + let source = match origin { + PriceOrigin::User => MeasurementSource::UserPriceCatalog, + PriceOrigin::Bundled => MeasurementSource::FabricPriceCatalog, + }; + any_user |= origin == PriceOrigin::User; + if origin == PriceOrigin::Bundled + && !model + .provider + .as_ref() + .is_some_and(|provider| provider.eq_ignore_ascii_case(&entry.provider)) + { + cost.estimated = estimate(Err("provider_price_unverified"), source); + update_price_gaps(&mut model.accounting.gaps, &cost.estimated); + continue; + } + let mut result = (|| { + if entry.unit != PricingUnit::PerToken || !entry.currency.eq_ignore_ascii_case("USD") { + return Err("price_currency_or_unit_unsupported"); + } + // A complete invocation's total input is also a safe upper bound + // on each request; a subset peak never prices a larger complete set. + let complete_peak = model + .usage + .peak_request_input_tokens + .value + .or(model.usage.input_tokens.value); + let complete_rates = entry + .rates_up_to(complete_peak) + .map(RateSnapshot::from_rates) + .transpose()?; + let observed_peak = if model.accounting.observed_tokens_aligned { + model + .accounting + .observed + .peak_request_input_tokens + .or(model.accounting.observed.input_tokens) + } else { + None + }; + let observed_rates = match &complete_rates { + Some(rates) => Some(rates.clone()), + None => entry + .rates_up_to(observed_peak) + .map(RateSnapshot::from_rates) + .transpose()?, + }; + let write_tariff_known = !entry.provider.eq_ignore_ascii_case("anthropic") + || model.usage.cache_write_input_tokens.value == Some(0); + let complete = match &complete_rates { + Some(rates) => { + if !write_tariff_known && model.usage.input_tokens.value != Some(0) { + Err("cache_write_tariff_unverified") + } else { + rates.price( + model.usage.input_tokens.value, + model.usage.cached_input_tokens.value, + model.usage.cache_write_input_tokens.value, + model.usage.output_tokens.value, + ) + } + } + None => Err("tiered_price_unresolvable"), + }; + let observed = complete.ok().or_else(|| { + observed_rates.as_ref().and_then(|rates| { + rates.observed_price( + &model.accounting.observed, + model.accounting.observed_tokens_aligned + || (model.usage.input_tokens.value.is_some() + && model.usage.cached_input_tokens.value.is_some() + && model.usage.cache_write_input_tokens.value.is_some()), + !entry.provider.eq_ignore_ascii_case("anthropic") + || model.accounting.observed.cache_write_input_tokens == Some(0), + ) + }) + }); + model.accounting.observed.estimated_cost_nano_usd = observed; + let rates = if complete.is_ok() { + complete_rates + } else { + observed_rates.or(complete_rates) + }; + let Some(rates) = rates else { + return complete; + }; + cost.applied = Some(AppliedPricing { + catalog_sha256: match origin { + PriceOrigin::Bundled => prices.bundled_sha256(), + PriceOrigin::User => prices.user_sha256().expect("selected user catalog"), + } + .into(), + provider: entry.provider.clone(), + model_id: entry.model_id.clone(), + pricing_as_of: entry.pricing_as_of.clone(), + pricing_source: entry.pricing_source.clone(), + rates, + }); + complete + })(); + if let Some(raw) = evidence + .and_then(|e| { + e.models + .iter() + .find(|raw| raw.model == model.model && raw.provider == model.provider) + }) + .filter(|raw| { + !raw.requests.is_empty() + && entry.unit == PricingUnit::PerToken + && entry.currency.eq_ignore_ascii_case("USD") + }) + { + let mut charges = Some(0_u128); + let mut complete = raw.requests_exhaustive; + let mut any = false; + for request in &raw.requests { + let aligned = request.gap.is_none() || request.tokens_aligned; + let normalized = + crate::usage_normalization::normalized_request(&request.usage, !aligned); + let priced: Result<(u128, RateSnapshot, bool), &'static str> = (|| { + let selected = if entry.rate_schedule.is_some() { + entry.rates_at( + normalized + .input_tokens + .value + .ok_or("pricing_input_unreported")?, + ) + } else { + entry.rates + }; + let rates = + RateSnapshot::from_rates(selected.ok_or("tiered_price_unresolvable")?)?; + let write_tariff_known = !entry.provider.eq_ignore_ascii_case("anthropic") + || normalized.cache_write_input_tokens.value == Some(0) + || normalized.input_tokens.value == Some(0); + let complete_charge = if aligned && write_tariff_known { + rates + .charges( + normalized.input_tokens.value, + normalized.cached_input_tokens.value, + normalized.cache_write_input_tokens.value, + normalized.output_tokens.value, + ) + .ok() + } else { + None + }; + let observed = ObservedUsage { + input_tokens: normalized.input_tokens.value, + cached_input_tokens: normalized.cached_input_tokens.value, + cache_write_input_tokens: normalized.cache_write_input_tokens.value, + output_tokens: normalized.output_tokens.value, + ..Default::default() + }; + let charge = complete_charge + .or_else(|| rates.observed_charges(&observed, aligned, write_tariff_known)) + .ok_or("pricing_usage_unavailable")?; + Ok((charge, rates, complete_charge.is_some())) + })(); + match priced { + Ok((charge, rates, all_parts_priced)) => { + any = true; + charges = charges.and_then(|total| total.checked_add(charge)); + cost.applied_calls.push(Some(AppliedPricing { + catalog_sha256: match origin { + PriceOrigin::Bundled => prices.bundled_sha256(), + PriceOrigin::User => { + prices.user_sha256().expect("selected catalog") + } + } + .into(), + provider: entry.provider.clone(), + model_id: entry.model_id.clone(), + pricing_as_of: entry.pricing_as_of.clone(), + pricing_source: entry.pricing_source.clone(), + rates, + })); + complete &= request.gap.is_none() && all_parts_priced; + } + Err(_) => { + complete = false; + cost.applied_calls.push(None); + } + } + } + let amount = charges + .and_then(|charges| rounded_price(charges).ok()) + .filter(|_| any); + if result.is_err() + && complete + && model.usage.input_tokens.value.is_some() + && model.usage.output_tokens.value.is_some() + { + result = amount.ok_or("cost_out_of_range"); + cost.applied = None; + } + if let Some(amount) = result.as_ref().ok().copied().or(amount) { + model.accounting.observed.estimated_cost_nano_usd = Some(amount); + } + } + cost.estimated = estimate(result, source); + update_price_gaps(&mut model.accounting.gaps, &cost.estimated); + } + let inconsistent = [ + &usage.input_tokens, + &usage.cached_input_tokens, + &usage.cache_write_input_tokens, + &usage.output_tokens, + ] + .iter() + .any(|m| m.reason.as_deref() == Some("inconsistent_usage")); + let accounting = usage.accounting.as_mut().expect("checked accounting"); + let complete = if !accounting.models_exhaustive { + Err("model_scope_unverified") + } else if inconsistent { + Err("inconsistent_usage") + } else if usage.models.is_empty() { + // An exhaustive empty model inventory and complete zero counts prove + // zero token cost without selecting a model tariff. + if usage.input_tokens.value == Some(0) && usage.output_tokens.value == Some(0) { + Ok(0) + } else { + Err("model_scope_unverified") + } + } else if usage.models.iter().any(|model| { + model.usage.token_cost.estimated.reason.as_deref() == Some("cache_write_tariff_unverified") + }) { + Err("cache_write_tariff_unverified") + } else if usage.input_tokens.value.is_none() || usage.output_tokens.value.is_none() { + Err("pricing_usage_incomplete") + } else { + // Each complete model price already checks its own tariff's required + // cache counts. A combined cache breakdown need not be available. + usage.models.iter().try_fold(0_u64, |sum, model| { + sum.checked_add( + model + .usage + .token_cost + .estimated + .value + .ok_or("unpriced_model")?, + ) + .ok_or("cost_out_of_range") + }) + }; + let observed = if inconsistent { + None + } else { + let values: Vec<_> = usage + .models + .iter() + .filter_map(|model| model.accounting.observed.estimated_cost_nano_usd) + .collect(); + if values.is_empty() { + None + } else { + values.into_iter().try_fold(0_u64, u64::checked_add) + } + }; + accounting.observed.estimated_cost_nano_usd = complete.ok().or(observed); + usage.token_cost.estimated = estimate( + complete, + if any_user { + MeasurementSource::UserPriceCatalog + } else { + MeasurementSource::FabricPriceCatalog + }, + ); + update_price_gaps(&mut accounting.gaps, &usage.token_cost.estimated); +} + +#[cfg(test)] +mod tests { + use super::native_catalog_route; + use serde_json::json; + use std::collections::BTreeMap; + + #[test] + fn native_pricing_routes_preserve_ambiguity_and_served_aliases() { + let mut models = serde_json::from_value(json!({ + "default": {"provider": "openai", "model": "gpt-5.4"}, + "auxiliary": {"provider": "openai", "model": "gpt-5.4", + "base_url": "https://custom.invalid/v1"} + })) + .unwrap(); + let routes = super::NativePricingRoutes::from_models(&models, &BTreeMap::new()); + assert!(!routes.allows(Some("openai"), "gpt-5.4")); + assert!(!routes.allows(Some("openai"), "served-alias")); + assert!(!routes.allows(Some("anthropic"), "child-model")); + + models.remove("auxiliary"); + let routes = super::NativePricingRoutes::from_models(&models, &BTreeMap::new()); + assert!(routes.allows(Some("openai"), "served-alias")); + assert!(routes.allows(Some("anthropic"), "source-qualified-child")); + + models.get_mut("default").unwrap().base_url = Some("https://custom.invalid/v1".into()); + models.insert( + "unused_native".into(), + serde_json::from_value(json!({ + "provider": "openai", "model": "other-model" + })) + .unwrap(), + ); + let routes = super::NativePricingRoutes::from_models(&models, &BTreeMap::new()); + assert!(!routes.allows(Some("openai"), "gpt-5.4")); + assert!(routes.allows(Some("openai"), "other-model")); + assert!(!routes.allows(Some("openai"), "served-alias")); + + let empty = super::NativePricingRoutes::from_models(&BTreeMap::new(), &BTreeMap::new()); + assert!(!empty.allows(Some("openai"), "gpt-5.4")); + } + + #[test] + fn child_provider_pricing_checks_its_own_endpoint() { + let models = serde_json::from_value(json!({ + "default": {"provider": "openai", "model": "gpt-5.4"} + })) + .unwrap(); + for (endpoint, expected) in [ + (None, true), + (Some("https://api.anthropic.com/"), true), + (Some("https://proxy.invalid"), false), + ] { + let endpoints = endpoint + .map(|url| BTreeMap::from([("ANTHROPIC_BASE_URL".into(), url.into())])) + .unwrap_or_default(); + let routes = super::NativePricingRoutes::from_models(&models, &endpoints); + assert_eq!( + routes.allows(Some("anthropic"), "source-qualified-child"), + expected, + "child endpoint: {endpoint:?}" + ); + assert!(routes.allows(Some("openai"), "served-alias")); + assert!(routes.allows(None, "source-qualified-child")); + } + let routes = super::NativePricingRoutes::from_models(&models, &BTreeMap::new()); + assert!(!routes.allows(Some("unknown-provider"), "source-qualified-child")); + } + + #[test] + fn inherited_and_forwarded_custom_routes_require_explicit_prices() { + for (configured, inherited, forwarded, expected) in [ + (None, None, None, true), + ( + Some("https://api.openai.com/v1/"), + Some("https://proxy.invalid/v1"), + None, + true, + ), + (Some("https://proxy.invalid/v1"), None, None, false), + (None, Some("https://proxy.invalid/v1"), None, false), + (None, None, Some("https://proxy.invalid/v1"), false), + (None, Some("https://api.openai.com/v1"), None, true), + ] { + let model = serde_json::from_value( + json!({"provider":"openai","model":"gpt-5.4","base_url":configured}), + ) + .unwrap(); + let env = forwarded + .map(|url| BTreeMap::from([("OPENAI_BASE_URL".into(), url.into())])) + .unwrap_or_default(); + assert_eq!( + native_catalog_route(&model, &env, |_| inherited.map(str::to_owned)), + expected + ); + } + } +} diff --git a/crates/fabric-core/src/runtime.rs b/crates/fabric-core/src/runtime.rs index cbceaea0d..2e4d90fc7 100644 --- a/crates/fabric-core/src/runtime.rs +++ b/crates/fabric-core/src/runtime.rs @@ -37,6 +37,8 @@ use crate::resource_snapshot::{LocalHostProcess, RuntimeResourceSnapshot, snapsh use crate::system_stats::ResourceObservation; use crate::system_stats::{CgroupStats, InvocationSystemStats, StatsSession, SystemReader}; +use crate::what_ran::WhatRan; + mod openshell; use openshell::OpenShellRuntimeAdapter; @@ -155,6 +157,9 @@ pub struct RunResult { /// Normalized invocation usage when reported by the adapter target. #[serde(default, skip_serializing_if = "Option::is_none")] pub usage: Option, + /// Complete and observed usage normalized from supported accounting evidence. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_usage: Option, /// Resource snapshot of the runtime, read once after the adapter returned this /// result. Absent when the runtime's resources could not be observed. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -175,6 +180,15 @@ pub struct RunResult { /// Adapter-specific metadata. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub metadata: BTreeMap, + /// What executed for this invocation, and how NeMo Fabric learned each + /// fact. Absent when the producing core predates it, or when this core + /// finds it malformed or of another schema or version. + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "crate::what_ran::deserialize_lenient" + )] + pub what_ran: Option, } /// Runtime completion status. @@ -877,6 +891,7 @@ struct LocalAdapterHost { relay_config: Option, // Adapter startup values stay in memory, never in a serialized handle or plan. environment_env: BTreeMap, + bundled_pricing_allowed: crate::pricing::NativePricingRoutes, } struct LocalServiceRecord { @@ -1175,6 +1190,14 @@ fn start_service( } fn validate_start_plan(plan: &RunPlan) -> Result<()> { + if let Some(catalog) = &plan.user_price_catalog { + catalog + .validate_integrity() + .map_err(|error| FabricError::InvalidConfig { + field: "pricing.catalog_path".into(), + reason: error.to_string(), + })?; + } validate_config(&plan.config)?; validate_agent_config(&plan.agent_config)?; validate_harness_settings(&plan.config, plan.adapter_descriptor.as_ref())?; @@ -2020,6 +2043,11 @@ fn run_local_host_invocation_with_timeout( request_id: request.request_id.clone(), runtime_id: runtime.runtime_id.clone(), }; + let what_ran = crate::what_ran::collect_before_request( + plan, + adapter_id(plan), + &runtime.environment.provider, + ); let host = local_hosts() .get(&runtime.runtime_id) .cloned() @@ -2182,6 +2210,7 @@ fn run_local_host_invocation_with_timeout( relay_config, fabric_home, fabric_invocation, + host_guard.bundled_pricing_allowed.clone(), )) } Err(error) => { @@ -2207,6 +2236,7 @@ fn run_local_host_invocation_with_timeout( relay_config, fabric_home, fabric_invocation, + bundled_pricing_allowed, ) = exchange_result?; let agent_result: AgentRunResult = serde_json::from_value(adapter_output).map_err(|error| { @@ -2359,6 +2389,31 @@ fn run_local_host_invocation_with_timeout( serde_json::to_value(&agent_result.extensions).map_err(FabricError::SerializeJson)?, ); } + // Accounting is observational after structural adapter validation. A + // contradictory aggregate must not replace a valid execution outcome or + // invite callers to replay work that already ran. + let model_usage = match agent_result + .usage + .as_ref() + .map(|raw| { + crate::model_usage::normalize_model_usage_for_routes( + raw, + plan.user_price_catalog.as_ref(), + |provider, model| bundled_pricing_allowed.allows(provider, model), + ) + }) + .transpose() + { + Ok(usage) => usage.flatten(), + Err(_) => { + events.push(event_with_metadata( + "model_usage_invalid", + "model accounting could not be normalized; execution outcome and raw usage were retained".to_string(), + BTreeMap::new(), + )); + None + } + }; Ok(RunResult { agent_name: plan.agent_name.clone(), harness: harness(plan), @@ -2371,6 +2426,7 @@ fn run_local_host_invocation_with_timeout( output, error, usage: agent_result.usage.as_ref().map(run_usage), + model_usage, resource_snapshot, system_stats, artifacts, @@ -2379,6 +2435,7 @@ fn run_local_host_invocation_with_timeout( .collect(), events, metadata, + what_ran: Some(what_ran), }) } @@ -2605,6 +2662,10 @@ fn spawn_local_host( if let Some(root) = artifacts.root.as_ref() { command.env("FABRIC_ARTIFACTS", root); } + let bundled_pricing_allowed = + snapshot_pricing_route(&plan.config.models, &mut command, |name| { + std::env::var_os(name) + }); // Start suspended so the host joins its job before it can start a process. #[cfg(windows)] std::os::windows::process::CommandExt::creation_flags( @@ -2703,9 +2764,46 @@ fn spawn_local_host( artifacts, relay_config, environment_env, + bundled_pricing_allowed, }) } +// Freeze endpoint inheritance on the command itself so pricing describes the +// persistent child's startup route, not a later mutation of the controller's env. +fn snapshot_pricing_route( + models: &BTreeMap, + command: &mut Command, + inherited: impl Fn(&str) -> Option, +) -> crate::pricing::NativePricingRoutes { + let mut endpoints = BTreeMap::new(); + for name in crate::pricing::OPENAI_ENDPOINT_ENV_KEYS + .into_iter() + .chain(crate::pricing::ANTHROPIC_ENDPOINT_ENV_KEYS) + { + let value = command + .get_envs() + .find_map(|(key, value)| { + let matches = if cfg!(windows) { + key.to_string_lossy().eq_ignore_ascii_case(name) + } else { + key == name + }; + matches.then(|| value.map(std::ffi::OsStr::to_owned)) + }) + .unwrap_or_else(|| inherited(name)); + match value { + Some(value) => { + endpoints.insert(name.to_string(), value.to_string_lossy().into_owned()); + command.env(name, value); + } + None => { + command.env_remove(name); + } + } + } + crate::pricing::NativePricingRoutes::from_models(models, &endpoints) +} + fn local_host_command(plan: &RunPlan, runtime: &RuntimeHandle) -> Result<(Command, String)> { match adapter_kind(plan) { AdapterKind::Process => process_local_host_command(plan, runtime), @@ -4139,6 +4237,7 @@ mod tests { "type": "object", "properties": { "provider": {"type": "string"}, + "nemo_fabric.usage_accounting.v1": {"type": "object"}, "nemo_fabric.detailed_token_usage.v1": { "type": "object", "properties": { @@ -4390,6 +4489,38 @@ for line in sys.stdin: }], "extensions": {"trace_id": "trace-1"}, } + elif MODE in ("accounted_usage_result", "accounted_partial_usage_result", "accounted_conflict_usage_result", "accounted_conflict_failed_usage_result", "accounted_undeclared_usage_result", "accounted_schema_invalid_usage_result"): + counts = { + "input_tokens": 100, "cached_input_tokens": 20, + "cache_write_input_tokens": 0, "input_tokens_include_cache": True, + "output_tokens": 5, "output_tokens_include_reasoning": True, + "peak_request_input_tokens": 100, + } + result = { + "status": "succeeded", + "output": {**output, "pricing_route": __import__("os").environ.get("OPENAI_BASE_URL")}, + "usage": {"extensions": {"nemo_fabric.usage_accounting.v1": { + "models_exhaustive": True, "gaps": [], "models": [{ + "model": "gpt-5.4", "provider": "openai", "reported": counts, "gaps": [], + }], + }}}, + } + if MODE == "accounted_partial_usage_result": + result["usage"]["extensions"]["nemo_fabric.usage_accounting.v1"]["gaps"] = [{ + "dimensions": ["input_tokens"], "reason": "unfinished_work", + }] + if MODE in ("accounted_conflict_usage_result", "accounted_conflict_failed_usage_result"): + model = result["usage"]["extensions"]["nemo_fabric.usage_accounting.v1"]["models"][0] + model["model"] = "private-accounting-identity" + model["requests_exhaustive"] = True + model["requests"] = [{"usage": {**counts, "input_tokens": 200, "peak_request_input_tokens": 200}}] + if MODE == "accounted_conflict_failed_usage_result": + result["status"] = "failed" + result["error"] = {"code": "original_failure", "message": "execution failed", "retryable": False} + if MODE == "accounted_undeclared_usage_result": + result["usage"]["extensions"]["undeclared_accounting"] = {} + if MODE == "accounted_schema_invalid_usage_result": + result["usage"]["extensions"]["nemo_fabric.usage_accounting.v1"] = "not an object" elif MODE == "detailed_usage_result": result = { "status": "succeeded", @@ -4625,6 +4756,7 @@ for line in sys.stdin: output: Value::Null, error: None, usage: None, + model_usage: None, resource_snapshot: None, system_stats: None, artifacts: ArtifactManifest::default(), @@ -4648,6 +4780,7 @@ for line in sys.stdin: ], events: Vec::new(), metadata: BTreeMap::new(), + what_ran: None, }; let encoded = serde_json::to_value(&result).expect("serialize SDK run result"); @@ -6100,6 +6233,55 @@ for line in sys.stdin: let _ = fs::remove_dir_all(root); } + #[test] + fn unused_custom_model_does_not_disable_native_call_pricing() { + let models = serde_json::from_value(serde_json::json!({ + "default": {"provider": "openai", "model": "gpt-5.4"}, + "unused": { + "provider": "openai", "model": "other-model", + "base_url": "https://custom.invalid/v1" + } + })) + .expect("model roles"); + let allowed = snapshot_pricing_route(&models, &mut Command::new("unused"), |_| None); + let raw = serde_json::from_value(serde_json::json!({ + "extensions": {"nemo_fabric.usage_accounting.v1": { + "models_exhaustive": true, "gaps": [], "models": [{ + "provider": "openai", "model": "gpt-5.4", "gaps": [], + "reported": { + "input_tokens": 100, "cached_input_tokens": 20, + "cache_write_input_tokens": 0, "input_tokens_include_cache": true, + "output_tokens": 5, "output_tokens_include_reasoning": true + } + }] + }} + })) + .expect("source-established usage"); + let usage = + crate::model_usage::normalize_model_usage_for_routes(&raw, None, |provider, model| { + allowed.allows(provider, model) + }) + .expect("valid accounting") + .expect("normalized accounting"); + assert!(usage.cost.estimated.amount_nano_usd.is_some()); + } + + #[test] + fn local_host_returns_flat_supported_accounting_with_the_normalized_result() { + let (root, plan) = local_host_plan("accounted_usage_result"); + let result = run_plan(&plan, RunRequest::text("accounted")).expect("normalized result"); + assert_eq!(result.status, RunStatus::Succeeded); + let usage = result.model_usage.expect("supported accounting"); + assert_eq!(usage.tokens.input_tokens, Some(100)); + assert_eq!(usage.tokens.cached_input_tokens, Some(20)); + assert_eq!(usage.tokens.output_tokens, Some(5)); + assert_eq!(usage.tokens.total_tokens, Some(105)); + assert!(usage.models_exhaustive); + assert_eq!(usage.models[0].model, "gpt-5.4"); + assert_eq!(usage.observed.input_tokens, Some(100)); + let _ = fs::remove_dir_all(root); + } + #[test] fn local_host_enriches_typed_usage_artifacts_and_extensions() { let (root, plan) = local_host_plan("typed_result"); @@ -6138,6 +6320,222 @@ for line in sys.stdin: let _ = fs::remove_dir_all(root); } + #[test] + fn local_host_result_carries_what_ran_without_changing_the_outcome() { + let run_result_schema = + crate::schema::generate_schema(crate::schema::SchemaName::RunResult) + .expect("run result schema"); + let validator = jsonschema::validator_for(&run_result_schema).expect("valid schema"); + let not_declared = serde_json::json!({ + "state": "unsupported", "reason": "key_not_declared", "source": "reported", + "collected_by": "adapter", "method": "adapter_report" + }); + for (mode, status) in [ + ("success", RunStatus::Succeeded), + ("adapter_reported_failure", RunStatus::Failed), + ] { + let (root, plan) = local_host_plan(mode); + + let result = run_plan(&plan, RunRequest::text("provenance")).expect("result"); + + assert_eq!(result.status, status); + let result_json = serde_json::to_value(&result).expect("result JSON"); + assert!(validator.is_valid(&result_json), "{result_json}"); + let what_ran = &result_json["what_ran"]; + assert_eq!(what_ran["schema"], "nemo.fabric.what_ran"); + assert_eq!(what_ran["version"], "v1alpha1"); + assert_eq!( + what_ran["fabric"]["core_version"]["value"], + crate::version() + ); + assert_eq!(what_ran["adapter"]["id"], "acme.fabric.local-host"); + assert_eq!(what_ran["adapter"]["package_version"], not_declared); + assert_eq!(what_ran["harness"]["name"], not_declared); + assert_eq!(what_ran["harness"]["version"], not_declared); + assert_eq!(what_ran["models"], serde_json::json!({})); + assert_eq!(what_ran["mcp"], serde_json::json!({"servers": {}})); + assert_eq!( + what_ran["environment"], + serde_json::json!({"provider": "local"}) + ); + let _ = fs::remove_dir_all(root); + } + } + + /// The `what_ran` a user gets from a normal benchmark config: two model + /// roles and three MCP servers on an adapter that accepts both, and no + /// `environment` section, so the provider defaults to `local`. + #[test] + fn local_host_what_ran_reports_a_realistic_config() { + use serde_json::json; + let (root, plan) = local_host_plan("success"); + let descriptor_path = root.join("adapters/local-host/local-host.fabric-adapter.json"); + let mut descriptor: Value = + serde_json::from_str(&fs::read_to_string(&descriptor_path).expect("read descriptor")) + .expect("descriptor JSON"); + descriptor["config"] = json!({"accepts": ["models", "mcp"]}); + fs::write(&descriptor_path, descriptor.to_string()).expect("write descriptor"); + let mut config = serde_json::to_value(&plan.config).expect("config JSON"); + config + .as_object_mut() + .expect("config") + .remove("environment"); + config["models"] = json!({ + "default": {"provider": "anthropic", "model": "anthropic/claude-sonnet-4-5"}, + "summarizer": {"provider": "nvidia", "model": "nvidia/nemotron-nano-9b-v2"}, + }); + config["mcp"] = json!({"servers": { + "filesystem": { + "transport": "stdio", + "url": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/SECRET_ARGUMENT"], + "env": {"FS_TOKEN": "SECRET_ENVIRONMENT"}, + "exposure": "harness_native" + }, + "search": { + "transport": "streamable-http", + "url": "https://mcp.example.com/mcp?api_key=SECRET_QUERY", + "custom_headers": {"Authorization": "Bearer SECRET_HEADER"}, + "exposure": "harness_native" + }, + "events": { + "transport": "sse", + "url": "http://localhost:8931/sse", + "exposure": "harness_native" + } + }}); + let config: crate::config::FabricConfig = + serde_json::from_value(config).expect("typed config"); + let plan = resolve_run_plan_from_config(config, ResolveContext::new(&root)) + .expect("resolve realistic plan"); + + let result = run_plan(&plan, RunRequest::text("provenance")).expect("result"); + + assert_eq!(result.status, RunStatus::Succeeded); + let what_ran = serde_json::to_value(result.what_ran.expect("what_ran")).expect("JSON"); + let configured = |value: &str| { + json!({"value": value, "source": "configured", "collected_by": "host_core", + "method": "resolved_plan"}) + }; + let not_declared = json!({"state": "unsupported", "reason": "key_not_declared", + "source": "reported", "collected_by": "adapter", "method": "adapter_report"}); + let model = |provider: &str, requested: &str| { + json!({"provider": provider, "requested": configured(requested), + "served": not_declared}) + }; + let server = |transport: &str, endpoint: &str| { + json!({"origin": "plan", "transport": transport, "endpoint": configured(endpoint), + "runtime": {"connected": not_declared, "protocol_version": not_declared, + "server_name": not_declared, "server_version": not_declared, + "tool_names": not_declared}}) + }; + assert_eq!( + what_ran, + json!({ + "schema": "nemo.fabric.what_ran", + "version": "v1alpha1", + "fabric": { + "core_version": {"value": crate::version(), "source": "observed", + "collected_by": "host_core", "method": "crate_version"}, + "host_core_version": {"state": "not_applicable", "reason": "local_run", + "source": "observed", "collected_by": "host_core", + "method": "crate_version"} + }, + "adapter": {"id": "acme.fabric.local-host", "package_version": not_declared}, + "harness": {"name": not_declared, "version": not_declared}, + "models": { + "default": model("anthropic", "anthropic/claude-sonnet-4-5"), + "summarizer": model("nvidia", "nvidia/nemotron-nano-9b-v2") + }, + "mcp": {"servers": { + "events": server("sse", "http://localhost:8931"), + "filesystem": server("stdio", "npx"), + "search": server("streamable-http", "https://mcp.example.com:443") + }}, + "environment": {"provider": "local"} + }) + ); + assert!(!what_ran.to_string().contains("SECRET"), "{what_ran}"); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn local_host_what_ran_bounds_keep_the_status_and_error() { + let model = |provider: &str| -> crate::config::ModelConfig { + serde_json::from_value(serde_json::json!({"provider": provider, "model": "m"})) + .expect("model config") + }; + // One `default` role keeps the adapter's single-role check satisfied. + let roles = || { + std::iter::once("default".to_string()) + .chain((0..16).map(|index| format!("role-{index:02}"))) + }; + for mode in ["success", "adapter_reported_failure"] { + let (root, plan) = local_host_plan(mode); + let baseline = run_plan(&plan, RunRequest::text("provenance")).expect("result"); + let mut accepting_models = plan.clone(); + accepting_models + .adapter_descriptor + .as_mut() + .expect("adapter descriptor") + .descriptor + .config + .accepts + .push(crate::config::AdapterConfigField::Models); + + let mut past_the_cap = accepting_models.clone(); + past_the_cap.config.models = roles().map(|role| (role, model("openai"))).collect(); + let mut past_the_budget = accepting_models.clone(); + past_the_budget.config.models = roles() + .map(|role| (role, model(&"p".repeat(9_000)))) + .collect(); + let mut minimal = plan.clone(); + minimal + .adapter_descriptor + .as_mut() + .expect("adapter descriptor") + .descriptor + .adapter_id = "a".repeat(140_000); + + for (case, bounded_plan) in [ + ("entry cap", past_the_cap), + ("byte budget", past_the_budget), + ("minimal", minimal), + ] { + let result = + run_plan(&bounded_plan, RunRequest::text("provenance")).expect("result"); + + assert_eq!(result.status, baseline.status, "{case}"); + assert_eq!( + serde_json::to_value(&result.error).expect("error JSON"), + serde_json::to_value(&baseline.error).expect("error JSON"), + "{case}" + ); + let what_ran = result.what_ran.expect("what_ran"); + let marker = |marker: Option| { + marker.map(|marker| (marker.reason, marker.dropped)) + }; + match case { + "entry cap" => assert_eq!( + marker(what_ran.models_truncated), + Some(("entry_cap".to_string(), 1)) + ), + "byte budget" => { + let (reason, dropped) = + marker(what_ran.models_truncated).expect("models marker"); + assert_eq!(reason, "byte_budget"); + assert!(dropped > 1, "{dropped}"); + } + _ => assert_eq!( + marker(what_ran.what_ran_truncated), + Some(("byte_budget".to_string(), 1)) + ), + } + } + let _ = fs::remove_dir_all(root); + } + } + #[test] fn local_host_preserves_detailed_usage_in_metadata() { let (root, plan) = local_host_plan("detailed_usage_result"); @@ -6626,4 +7024,38 @@ for line in sys.stdin: )); let _ = fs::remove_dir_all(root); } + #[test] + fn semantic_accounting_conflict_preserves_execution_and_raw_evidence() { + for (mode, status) in [ + ("accounted_conflict_usage_result", RunStatus::Succeeded), + ("accounted_conflict_failed_usage_result", RunStatus::Failed), + ] { + let (root, plan) = local_host_plan(mode); + let result = run_plan(&plan, RunRequest::text("preserve execution")) + .expect("accounting must not replace execution outcome"); + assert_eq!(result.status, status); + assert_eq!(result.output["input"], "preserve execution"); + if status == RunStatus::Failed { + assert_eq!(result.error.as_ref().unwrap().code, "original_failure"); + } else { + assert!(result.error.is_none()); + } + assert!(result.model_usage.is_none()); + let raw = &result.usage.as_ref().expect("raw usage").metadata; + assert_eq!( + raw["nemo_fabric.usage_accounting.v1"]["models"][0]["model"], + "private-accounting-identity" + ); + let events: Vec<_> = result + .events + .iter() + .filter(|event| event.kind == "model_usage_invalid") + .collect(); + assert_eq!(events.len(), 1); + let diagnostic = serde_json::to_string(events[0]).unwrap(); + assert!(!diagnostic.contains("private-accounting-identity")); + assert!(!diagnostic.contains("request totals")); + let _ = fs::remove_dir_all(root); + } + } } diff --git a/crates/fabric-core/src/runtime/openshell.rs b/crates/fabric-core/src/runtime/openshell.rs index 95d5bac8a..e758c6abc 100644 --- a/crates/fabric-core/src/runtime/openshell.rs +++ b/crates/fabric-core/src/runtime/openshell.rs @@ -148,6 +148,11 @@ fn task_config(plan: &RunPlan, environment: &EnvironmentHandle) -> Result Result, + provider: Option<&str>, + model: &str, + prices: &crate::price_catalog::PriceCatalogs<'_>, +) -> bool { + let Some(task) = task_usage else { + return false; + }; + let Some((bundled, _)) = prices.find(provider, model, false) else { + return false; + }; + let rates = match &bundled.rate_schedule { + Some(crate::price_catalog::TokenRateSchedule::PromptTokenThreshold { tiers, .. }) => { + tiers.iter().map(|tier| tier.rates).collect::>() + } + None => bundled.rates.into_iter().collect(), + }; + let canonical_rates = rates + .into_iter() + .filter_map(|rates| crate::pricing::RateSnapshot::from_rates(rates).ok()) + .collect::>(); + // A task-core tariff decision supplies sandbox-local route evidence. Host + // endpoint inheritance cannot establish which route the sandbox used. + task.models.iter().any(|entry| { + entry.provider.as_deref() == provider + && entry.model == model + && entry.cost.pricing.as_ref().is_some_and(|basis| { + basis.user_catalog_sha256.is_none() + && basis.bundled_catalog_sha256 == prices.bundled_sha256() + }) + && !entry.cost.applied_rates.is_empty() + && entry.cost.applied_rates.iter().all(|applied| { + applied.catalog_sha256 == prices.bundled_sha256() + && applied.provider == bundled.provider + && applied.model_id == bundled.model_id + && applied.pricing_as_of == bundled.pricing_as_of + && applied.pricing_source == bundled.pricing_source + && canonical_rates.contains(&applied.rates) + }) + }) +} +fn apply_host_catalog( + result: &mut RunResult, + catalog: Option<&crate::price_catalog::UserPriceCatalog>, +) { + let Some(catalog) = catalog else { + // The task core observes the sandbox's actual provider endpoints. Keep + // its independently fingerprinted bundled estimates when no override + // was supplied, rather than inferring routes from host environment. + return; + }; + let raw = result.usage.as_ref().map(|usage| AgentUsage { + input_tokens: usage.input_tokens, + cached_input_tokens: usage.cached_input_tokens, + input_tokens_include_cache: usage.input_tokens_include_cache, + output_tokens: usage.output_tokens, + total_tokens: usage.total_tokens, + cost_usd: usage.cost_usd, + extensions: usage.metadata.clone(), + }); + let task_usage = result.model_usage.as_ref(); + let prices = crate::price_catalog::PriceCatalogs::with_user_catalog(None); + result.model_usage = match raw + .as_ref() + .map(|usage| { + crate::model_usage::normalize_model_usage_for_routes( + usage, + Some(catalog), + |provider, model| task_has_bundled_tariff(task_usage, provider, model, &prices), + ) + }) + .transpose() + { + Ok(usage) => usage.flatten(), + Err(_) => { + if !result + .events + .iter() + .any(|event| event.kind == "model_usage_invalid") + { + result.events.push(event_with_metadata( + "model_usage_invalid", + "model accounting could not be normalized; execution outcome and raw usage were retained".to_string(), + BTreeMap::new(), + )); + } + None + } + }; +} fn set_state(key: &str, runtime_id: &str, state: State) { BINDINGS .lock() @@ -456,6 +552,8 @@ impl RuntimeAdapter for OpenShellRuntimeAdapter { "", )); } + apply_host_catalog(&mut result, plan.user_price_catalog.as_ref()); + adopt_task_what_ran(&mut result, &runtime.environment); for telemetry in &mut result.telemetry { telemetry.relay_enabled = telemetry .metadata @@ -539,6 +637,16 @@ impl RuntimeAdapter for OpenShellRuntimeAdapter { } } +/// Attribute the task core's `what_ran` to the task core, this host's +/// version and this host's environment provider. A task result without a +/// readable `what_ran`, from an older task core or one this host cannot +/// decode, keeps `what_ran` absent and is otherwise unchanged. +fn adopt_task_what_ran(result: &mut RunResult, environment: &EnvironmentHandle) { + if let Some(what_ran) = result.what_ran.as_mut() { + crate::what_ran::attribute_to_task_core(what_ran, &environment.provider); + } +} + pub(super) fn release(environment: &EnvironmentHandle) -> Result<()> { let key = resource_key(environment)?; let previous = { @@ -872,6 +980,313 @@ mod tests { } } + fn catalog_for_task_usage() -> crate::price_catalog::UserPriceCatalog { + crate::price_catalog::UserPriceCatalog::from_bytes( + PathBuf::from("host-only-catalog.json"), + br#"{"version":1,"entries":[{"provider":"fixture","model_id":"model","currency":"USD","unit":"per_token","rates":{"input_per_million":1.0,"output_per_million":2.0,"cache_read_per_million":0.1,"cache_write_per_million":1.25},"prompt_cache":{"read_accounting":"included_in_prompt_tokens"},"pricing_as_of":"2026-10-22","pricing_source":"test fixture"}]}"#, + ).expect("frozen test catalog") + } + + fn task_usage_result() -> RunResult { + let raw: AgentUsage = serde_json::from_value(serde_json::json!({ + "extensions": {"nemo_fabric.usage_accounting.v1": { + "models_exhaustive": true, "gaps": [], "models": [{ + "model": "model", "provider": "fixture", "gaps": [], + "reported": {"input_tokens": 100, "input_tokens_include_cache": true, + "cached_input_tokens": 0, "cache_write_input_tokens": 0, + "output_tokens": 5, "output_tokens_include_reasoning": true, + "peak_request_input_tokens": 100} + }] + }} + })) + .expect("raw task accounting"); + let mut result: RunResult = serde_json::from_value(serde_json::json!({ + "agent_name": "agent", "harness": "fixture", "adapter_kind": "process", + "runtime_id": "runtime-task", "invocation_id": "invocation-1", + "request_id": "request-1", "status": "failed", "output": "partial output", + "error": {"stage": "invoke", "code": "fixture_failure", "message": "fixture failure", "retryable": false} + })).expect("task result"); + result.usage = Some(run_usage(&raw)); + result.model_usage = crate::model_usage::normalize_model_usage(&raw, None, false) + .expect("normalized task accounting"); + result + } + + #[test] + fn task_projection_does_not_send_a_host_catalog_path_to_the_sandbox() { + let plan: RunPlan = serde_json::from_value(serde_json::json!({ + "agent_name": "agent", "base_dir": "/host", "agent_config": {}, "capabilities": {}, + "config": {"schema_version": "fabric.agent/v1alpha1", "metadata": {"name": "agent"}, + "runtime": {}, "pricing": {"catalog_path": "/host-only/catalog.json"}} + })) + .expect("projection plan"); + let projected = task_config(&plan, &environment()).expect("task configuration"); + assert!(projected.pricing.unwrap().catalog_path.is_none()); + assert_eq!( + plan.config.pricing.unwrap().catalog_path, + Some(PathBuf::from("/host-only/catalog.json")) + ); + } + + #[test] + fn task_usage_uses_the_host_frozen_catalog_without_changing_the_outcome() { + let mut result = task_usage_result(); + let original = result.clone(); + assert!( + result + .model_usage + .as_ref() + .unwrap() + .cost + .estimated + .amount_nano_usd + .is_none() + ); + let catalog = catalog_for_task_usage(); + apply_host_catalog(&mut result, Some(&catalog)); + let usage = result + .model_usage + .as_ref() + .expect("host-normalized accounting"); + assert_eq!(usage.cost.estimated.amount_nano_usd, Some(110_000)); + assert_eq!( + usage + .cost + .pricing + .as_ref() + .unwrap() + .user_catalog_sha256 + .as_deref(), + Some(catalog.sha256.as_str()) + ); + assert_eq!(result.status, original.status); + assert_eq!(result.error, original.error); + assert_eq!(result.output, original.output); + assert_eq!(result.usage, original.usage); + } + + #[test] + fn task_usage_without_a_host_override_keeps_the_task_core_route_evidence() { + let mut result = task_usage_result(); + let mut raw = AgentUsage::default(); + raw.extensions = result.usage.as_ref().unwrap().metadata.clone(); + let model = &mut raw + .extensions + .get_mut("nemo_fabric.usage_accounting.v1") + .unwrap()["models"][0]; + model["provider"] = Value::from("openai"); + model["model"] = Value::from("gpt-5.4"); + result.usage = Some(run_usage(&raw)); + result.model_usage = crate::model_usage::normalize_model_usage(&raw, None, true).unwrap(); + assert!( + result + .model_usage + .as_ref() + .unwrap() + .cost + .estimated + .amount_nano_usd + .is_some() + ); + let original = result.clone(); + apply_host_catalog(&mut result, None); + assert_eq!(result, original); + apply_host_catalog(&mut result, Some(&catalog_for_task_usage())); + assert_eq!( + result.model_usage.as_ref().unwrap().cost.estimated, + original.model_usage.as_ref().unwrap().cost.estimated, + "a partial override keeps the task-qualified bundled tariff for other models" + ); + for invalid_proof in ["provider", "fingerprint", "rates", "user_catalog"] { + let mut unqualified = original.clone(); + let entry = &mut unqualified.model_usage.as_mut().unwrap().models[0]; + match invalid_proof { + "provider" => entry.provider = Some("another-provider".into()), + "fingerprint" => { + entry.cost.pricing.as_mut().unwrap().bundled_catalog_sha256 = + "sha256:another-catalog".into() + } + "rates" => entry.cost.applied_rates[0].rates.input += 1, + "user_catalog" => { + entry.cost.pricing.as_mut().unwrap().user_catalog_sha256 = + Some("sha256:user-catalog".into()) + } + _ => unreachable!(), + } + apply_host_catalog(&mut unqualified, Some(&catalog_for_task_usage())); + assert!( + unqualified + .model_usage + .as_ref() + .unwrap() + .cost + .estimated + .amount_nano_usd + .is_none(), + "{invalid_proof}" + ); + } + } + + #[test] + fn invalid_task_accounting_with_a_host_catalog_keeps_the_execution_and_raw_evidence() { + let mut result = task_usage_result(); + result.usage.as_mut().unwrap().metadata.insert( + "nemo_fabric.usage_accounting.v1".into(), + serde_json::json!({"models_exhaustive": "invalid"}), + ); + let original = result.clone(); + apply_host_catalog(&mut result, Some(&catalog_for_task_usage())); + assert!(result.model_usage.is_none()); + assert_eq!(result.status, original.status); + assert_eq!(result.error, original.error); + assert_eq!(result.usage, original.usage); + assert!( + result + .events + .iter() + .any(|event| event.kind == "model_usage_invalid") + ); + } + + /// A task result as the task core returns it, with `what_ran` from the + /// shared reader vectors when given. + fn task_result(what_ran: Option) -> RunResult { + let mut result = serde_json::json!({ + "agent_name": "agent", "harness": "nvidia.fabric.claude", "adapter_kind": "python", + "runtime_id": "runtime-task", "invocation_id": "invocation-1", + "request_id": "request-1", "status": "succeeded", "output": "done" + }); + if let Some(what_ran) = what_ran { + result["what_ran"] = what_ran; + } + serde_json::from_value(result).expect("a task result decodes whatever what_ran holds") + } + + fn task_what_ran() -> Value { + let vectors: Value = serde_json::from_str(include_str!( + "../../../../tests/fixtures/what_ran/reader-vectors.json" + )) + .expect("reader vectors JSON"); + vectors["base"].clone() + } + + fn facts_collected_by(value: &Value, collector: &str) -> usize { + match value { + Value::Object(map) if map.contains_key("source") => { + usize::from(map.get("collected_by") == Some(&Value::from(collector))) + } + Value::Object(map) => map + .values() + .map(|value| facts_collected_by(value, collector)) + .sum(), + _ => 0, + } + } + + #[test] + fn task_what_ran_is_attributed_to_the_task_core_and_this_host() { + for task_version in [crate::version(), "0.5.0"] { + let mut what_ran = task_what_ran(); + what_ran["fabric"]["core_version"]["value"] = Value::from(task_version); + let mut result = task_result(Some(what_ran)); + + adopt_task_what_ran(&mut result, &environment()); + + let what_ran = serde_json::to_value(result.what_ran).expect("what_ran JSON"); + assert_task_facts_are_attributed(&what_ran, task_version); + } + } + + fn assert_task_facts_are_attributed(what_ran: &Value, task_version: &str) { + let host_version = serde_json::json!({"value": crate::version(), "source": "observed", + "collected_by": "host_core", "method": "crate_version"}); + let task_core_version = serde_json::json!({"value": task_version, "source": "observed", + "collected_by": "task_core", "method": "crate_version"}); + assert_eq!(what_ran["fabric"]["core_version"], task_core_version); + assert_eq!(what_ran["fabric"]["host_core_version"], host_version); + assert_eq!( + facts_collected_by(what_ran, "host_core"), + 1, + "only host_core_version is the host's fact" + ); + assert_eq!(what_ran["environment"]["provider"], "openshell"); + assert_eq!( + what_ran["models"]["default"]["requested"]["collected_by"], + "task_core" + ); + assert_eq!( + what_ran["mcp"]["servers"]["docs"]["endpoint"]["collected_by"], + "task_core" + ); + assert_eq!( + what_ran["harness"]["version"]["collected_by"], "adapter", + "adapter facts keep their collector" + ); + } + + #[test] + fn task_what_ran_over_the_byte_budget_becomes_minimal_on_this_host() { + let minimal = serde_json::json!({ + "schema": "nemo.fabric.what_ran", + "version": "v1alpha1", + "what_ran_truncated": {"reason": "byte_budget", "dropped": 1} + }); + let mut oversized = task_what_ran(); + oversized["adapter"]["id"] = Value::from("a".repeat(140_000)); + for what_ran in [oversized, minimal.clone()] { + let mut result = task_result(Some(what_ran)); + + adopt_task_what_ran(&mut result, &environment()); + + assert_eq!( + serde_json::to_value(result.what_ran).expect("what_ran JSON"), + minimal + ); + assert_eq!(result.status, RunStatus::Succeeded); + assert_eq!(result.output, Value::from("done")); + } + } + + #[test] + fn task_what_ran_past_the_entry_caps_is_bounded_on_this_host() { + let mut what_ran = task_what_ran(); + let server = what_ran["mcp"]["servers"]["docs"].clone(); + what_ran["mcp"]["servers"] = (0..35) + .map(|index| (format!("server-{index:02}"), server.clone())) + .collect::>() + .into(); + what_ran["mcp"]["servers_truncated"] = + serde_json::json!({"reason": "key_too_long", "dropped": 2}); + let mut result = task_result(Some(what_ran)); + + adopt_task_what_ran(&mut result, &environment()); + + let what_ran = serde_json::to_value(result.what_ran).expect("what_ran JSON"); + let servers = what_ran["mcp"]["servers"].as_object().expect("servers"); + let expected: Vec = (0..32).map(|index| format!("server-{index:02}")).collect(); + assert_eq!(servers.keys().cloned().collect::>(), expected); + assert_eq!( + what_ran["mcp"]["servers_truncated"], + serde_json::json!({"reason": "entry_cap", "dropped": 5}) + ); + } + + #[test] + fn task_result_without_readable_what_ran_is_kept_without_it() { + let mut undecodable = task_what_ran(); + undecodable["harness"]["version"]["state"] = Value::from("withheld"); + for what_ran in [None, Some(undecodable)] { + let mut result = task_result(what_ran); + + adopt_task_what_ran(&mut result, &environment()); + + assert_eq!(result.what_ran, None); + assert_eq!(result.status, RunStatus::Succeeded); + assert_eq!(result.output, Value::from("done")); + } + } + #[test] fn unknown_binding_survives_a_new_host_environment_handle() { let original = environment(); diff --git a/crates/fabric-core/src/schema.rs b/crates/fabric-core/src/schema.rs index b916fd755..9a88ba1d5 100644 --- a/crates/fabric-core/src/schema.rs +++ b/crates/fabric-core/src/schema.rs @@ -24,6 +24,8 @@ use crate::{AgentRunRequest, AgentRunResult}; /// Public schema snapshots generated by NeMo Fabric. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SchemaName { + /// Declared usage accounting evidence. + UsageAccounting, /// Public health request. CheckRequest, /// Public health report. @@ -82,7 +84,8 @@ pub enum SchemaName { impl SchemaName { /// All public schemas in stable output order. - pub const ALL: [Self; 27] = [ + pub const ALL: [Self; 28] = [ + Self::UsageAccounting, Self::CheckRequest, Self::CheckReport, Self::AdapterCheckRequest, @@ -115,6 +118,7 @@ impl SchemaName { /// Stable file stem for this schema. pub fn as_str(self) -> &'static str { match self { + Self::UsageAccounting => "usage-accounting", Self::CheckRequest => "check-request", Self::CheckReport => "check-report", Self::AdapterCheckRequest => "adapter-check-request", @@ -154,6 +158,7 @@ impl SchemaName { fn relative_path(self) -> PathBuf { let filename = self.filename(); match self { + Self::UsageAccounting => PathBuf::from("extensions").join(filename), Self::AdapterCheckRequest | Self::AdapterCheckResult | Self::AgentConfig @@ -172,6 +177,7 @@ impl SchemaName { /// Parse a schema name from CLI/user input. pub fn parse(value: &str) -> Result { match value { + "usage-accounting" | "usage_accounting" => Ok(Self::UsageAccounting), "check-request" | "check_request" => Ok(Self::CheckRequest), "check-report" | "check_report" => Ok(Self::CheckReport), "adapter-check-request" | "adapter_check_request" => Ok(Self::AdapterCheckRequest), @@ -219,6 +225,9 @@ impl SchemaName { /// Generate one schema as a JSON value. pub fn generate_schema(schema: SchemaName) -> Result { match schema { + SchemaName::UsageAccounting => { + to_value(schema_for!(crate::usage_accounting::UsageAccounting)) + } SchemaName::CheckRequest => to_value(schema_for!(crate::check::CheckRequest)), SchemaName::CheckReport => to_value(schema_for!(crate::check::CheckReport)), SchemaName::AdapterCheckRequest => to_value(schema_for!(crate::check::AdapterCheckRequest)), diff --git a/crates/fabric-core/src/usage_accounting.rs b/crates/fabric-core/src/usage_accounting.rs new file mode 100644 index 000000000..e563a0db5 --- /dev/null +++ b/crates/fabric-core/src/usage_accounting.rs @@ -0,0 +1,374 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Validated accounting evidence carried through the existing usage extension. + +use std::collections::BTreeSet; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::AgentUsage; + +/// Descriptor-declared transport key. This does not change the closed AgentUsage wire shape. +pub const USAGE_ACCOUNTING_EXTENSION: &str = "nemo_fabric.usage_accounting.v1"; + +/// Raw counts in provider/harness conventions. Missing values are never zero. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ReportedUsage { + /// Raw input count. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(max = u64::MAX))] + pub input_tokens: Option, + /// Cache reads, independently reported. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(max = u64::MAX))] + pub cached_input_tokens: Option, + /// Cache writes, independently reported. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(max = u64::MAX))] + pub cache_write_input_tokens: Option, + /// Whether input includes both cache categories. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_tokens_include_cache: Option, + /// Raw output count. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(max = u64::MAX))] + pub output_tokens: Option, + /// Reasoning count, independently reported. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(max = u64::MAX))] + pub reasoning_tokens: Option, + /// Whether output includes reasoning. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_tokens_include_reasoning: Option, + /// Provider-reported total, independent of normalized input plus output. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(max = u64::MAX))] + pub total_tokens: Option, + /// Largest request's cache-inclusive input. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(max = u64::MAX))] + pub peak_request_input_tokens: Option, + /// Provider-reported USD, never a client-side price estimate. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 0.0))] + pub cost_usd: Option, +} + +/// A dimension in raw adapter evidence. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, +)] +#[serde(rename_all = "snake_case")] +pub enum UsageDimension { + /// Input tokens. + InputTokens, + /// Cache reads. + CachedInputTokens, + /// Cache writes. + CacheWriteInputTokens, + /// Output tokens. + OutputTokens, + /// Reasoning tokens. + ReasoningTokens, + /// Provider-reported total. + TotalTokens, + /// Largest request input. + PeakRequestInputTokens, + /// Provider-reported USD. + CostUsd, +} + +/// Categories that affect whether a consumer can treat a value as complete. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, +)] +#[serde(rename_all = "snake_case")] +pub enum GapReason { + /// Owned work was still running at the return boundary. + UnfinishedWork, + /// A completed call did not provide usable usage. + MissingUsage, + /// The supported source does not establish coverage of owned work. + ScopeUnverified, +} + +/// Missing evidence for one or more raw dimensions. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct UsageGap { + /// Nonempty, unique set of affected dimensions. + #[schemars(schema_with = "gap_dimensions_schema")] + pub dimensions: Vec, + /// Why those dimensions are incomplete. + pub reason: GapReason, +} + +fn gap_dimensions_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + let mut schema = Vec::::json_schema(generator); + schema.insert("minItems".into(), 1.into()); + schema.insert("uniqueItems".into(), true.into()); + schema +} + +/// One model call retained for request-tier pricing and evidence reconciliation. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct UsageRequest { + /// One deduplicated model-call snapshot, never a session/model aggregate. + pub usage: ReportedUsage, + /// Known incompleteness of this call's source coverage. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gap: Option, + /// Present observed fields describe the same request/subset. + #[serde(default)] + pub tokens_aligned: bool, +} + +/// Evidence for one literal provider/model identity. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct AccountedModel { + /// Optional per-call evidence for exact request-tier pricing. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub requests: Vec, + /// The request list covers every source unit contributing to this model. + #[serde(default)] + pub requests_exhaustive: bool, + /// Model identity as reported by the source. + #[schemars(length(min = 1), regex(pattern = r"\S"))] + pub model: String, + /// Literal provider identity, when available. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1), regex(pattern = r"\S"))] + pub provider: Option, + /// Complete per-model evidence; absent fields are unreported. + #[serde(default)] + pub reported: ReportedUsage, + /// Valid deduplicated observations, potentially incomplete. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observed: Option, + /// Every present observed token dimension describes the same source-call subset. + /// False unless the producer establishes this, including peak and normalization components. + #[serde(default)] + pub observed_tokens_aligned: bool, + /// Gaps affecting this model. + pub gaps: Vec, +} + +/// The declared adapter transport for invocation accounting. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct UsageAccounting { + /// Every model identity used by owned work is represented; this alone does + /// not certify that any dimension is complete. + pub models_exhaustive: bool, + /// Valid deduplicated observations, not a promised mathematical lower bound. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observed: Option, + /// Invocation-wide gaps. + pub gaps: Vec, + /// One entry per literal provider/model pair. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub models: Vec, +} + +/// Invalid accounting evidence uses the existing invalid-result path. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[error("invalid usage accounting: {0}")] +pub struct AccountingError(pub String); + +impl UsageDimension { + pub(crate) const ALL: [Self; 8] = [ + Self::InputTokens, + Self::CachedInputTokens, + Self::CacheWriteInputTokens, + Self::OutputTokens, + Self::ReasoningTokens, + Self::TotalTokens, + Self::PeakRequestInputTokens, + Self::CostUsd, + ]; + + pub(crate) fn present(self, usage: &ReportedUsage) -> bool { + match self { + Self::InputTokens => usage.input_tokens.is_some(), + Self::CachedInputTokens => usage.cached_input_tokens.is_some(), + Self::CacheWriteInputTokens => usage.cache_write_input_tokens.is_some(), + Self::OutputTokens => usage.output_tokens.is_some(), + Self::ReasoningTokens => usage.reasoning_tokens.is_some(), + Self::TotalTokens => usage.total_tokens.is_some(), + Self::PeakRequestInputTokens => usage.peak_request_input_tokens.is_some(), + Self::CostUsd => usage.cost_usd.is_some(), + } + } +} + +impl UsageAccounting { + /// Decode and validate the typed extension, if present. Descriptor schema + /// validation remains required at the runtime boundary as well. + pub fn from_usage(usage: &AgentUsage) -> Result, AccountingError> { + let Some(value) = usage.extensions.get(USAGE_ACCOUNTING_EXTENSION) else { + return Ok(None); + }; + let evidence: Self = serde_json::from_value(value.clone()) + .map_err(|error| AccountingError(error.to_string()))?; + let reported = reported_usage(usage)?; + validate_record(&reported, evidence.observed.as_ref(), &evidence.gaps)?; + if evidence.models_exhaustive + && evidence.models.is_empty() + && std::iter::once(&reported) + .chain(evidence.observed.as_ref()) + .any(|counts| { + [ + counts.input_tokens, + counts.cached_input_tokens, + counts.cache_write_input_tokens, + counts.output_tokens, + counts.reasoning_tokens, + counts.total_tokens, + counts.peak_request_input_tokens, + ] + .into_iter() + .flatten() + .any(|count| count > 0) + }) + { + return Err(AccountingError( + "exhaustive empty model list conflicts with positive tokens".into(), + )); + } + let mut identities = BTreeSet::new(); + for model in &evidence.models { + if model.model.trim().is_empty() + || model + .provider + .as_ref() + .is_some_and(|provider| provider.trim().is_empty()) + || !identities.insert((model.provider.as_deref(), model.model.as_str())) + { + return Err(AccountingError("blank or duplicate model identity".into())); + } + validate_record(&model.reported, model.observed.as_ref(), &model.gaps)?; + if model.requests_exhaustive && model.requests.is_empty() { + return Err(AccountingError( + "exhaustive requests must be nonempty".into(), + )); + } + for request in &model.requests { + validate_record(&ReportedUsage::default(), Some(&request.usage), &[])?; + } + } + Ok(Some(evidence)) + } +} + +fn validate_record( + reported: &ReportedUsage, + observed: Option<&ReportedUsage>, + gaps: &[UsageGap], +) -> Result<(), AccountingError> { + for counts in std::iter::once(reported).chain(observed) { + if counts + .cost_usd + .is_some_and(|cost| usd_to_nano_usd(cost).is_err()) + { + return Err(AccountingError( + "cost must fit finite nonnegative nano-USD".into(), + )); + } + } + for gap in gaps { + let unique: BTreeSet<_> = gap.dimensions.iter().collect(); + if unique.is_empty() || unique.len() != gap.dimensions.len() { + return Err(AccountingError( + "gap dimensions must be nonempty and unique".into(), + )); + } + if gap + .dimensions + .iter() + .any(|dimension| dimension.present(reported)) + { + return Err(AccountingError( + "complete value conflicts with explicit gap".into(), + )); + } + } + Ok(()) +} + +/// Recover the complete raw record without changing legacy AgentUsage. A +/// detailed extension selects its whole input/output dimension, never a mix. +pub(crate) fn reported_usage(usage: &AgentUsage) -> Result { + let mut result = ReportedUsage { + input_tokens: usage.input_tokens, + cached_input_tokens: usage.cached_input_tokens, + input_tokens_include_cache: usage.input_tokens_include_cache, + output_tokens: usage.output_tokens, + total_tokens: usage.total_tokens, + cost_usd: usage.cost_usd, + ..Default::default() + }; + if let Some(detail) = usage.extensions.get("nemo_fabric.detailed_token_usage.v1") { + // Existing descriptors may declare extra detailed fields (for example + // legacy model breakdowns). Preserve those as raw evidence rather than + // rejecting a previously valid result while consuming known counters. + let detail = detail + .as_object() + .ok_or_else(|| AccountingError("detailed usage must be an object".into()))?; + let known = [ + "input_tokens", + "cached_input_tokens", + "cache_write_input_tokens", + "input_tokens_include_cache", + "output_tokens", + "reasoning_tokens", + "output_tokens_include_reasoning", + "total_tokens", + "peak_request_input_tokens", + "cost_usd", + ]; + let detail: ReportedUsage = serde_json::from_value(serde_json::Value::Object( + detail + .iter() + .filter(|(key, _)| known.contains(&key.as_str())) + .map(|(key, value)| (key.clone(), value.clone())) + .collect(), + )) + .map_err(|error| AccountingError(error.to_string()))?; + result.cost_usd = result.cost_usd.or(detail.cost_usd); + if detail.input_tokens.is_some() + || detail.cached_input_tokens.is_some() + || detail.cache_write_input_tokens.is_some() + || detail.input_tokens_include_cache.is_some() + { + result.input_tokens = detail.input_tokens; + result.cached_input_tokens = detail.cached_input_tokens; + result.cache_write_input_tokens = detail.cache_write_input_tokens; + result.input_tokens_include_cache = detail.input_tokens_include_cache; + } + if detail.output_tokens.is_some() + || detail.reasoning_tokens.is_some() + || detail.output_tokens_include_reasoning.is_some() + { + result.output_tokens = detail.output_tokens; + result.reasoning_tokens = detail.reasoning_tokens; + result.output_tokens_include_reasoning = detail.output_tokens_include_reasoning; + } + result.peak_request_input_tokens = detail.peak_request_input_tokens; + } + Ok(result) +} + +pub(crate) fn usd_to_nano_usd(usd: f64) -> Result { + let nanos = (usd * 1e9).round(); + if usd.is_finite() && usd >= 0.0 && nanos < 18_446_744_073_709_551_616.0 { + Ok(nanos as u64) + } else { + Err("cost_out_of_range") + } +} diff --git a/crates/fabric-core/src/usage_normalization.rs b/crates/fabric-core/src/usage_normalization.rs new file mode 100644 index 000000000..e7253a9d9 --- /dev/null +++ b/crates/fabric-core/src/usage_normalization.rs @@ -0,0 +1,802 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Shared normalization and completeness policy for invocation model usage. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::AgentUsage; +use crate::measurement::{ + Attribution, Availability, Measurement, MeasurementSource, MeasurementUnit, +}; +use crate::usage_accounting::{ + AccountingError, GapReason, ReportedUsage, UsageAccounting, UsageDimension, UsageGap, + reported_usage, usd_to_nano_usd, +}; + +/// Normalized invocation usage. Complete values and valid observations are +/// separate. Without `accounting`, legacy values have no new coverage guarantee. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct ModelUsage { + /// Cache-inclusive input. + pub input_tokens: Measurement, + /// Independently reported cache reads. + pub cached_input_tokens: Measurement, + /// Independently reported cache writes. + pub cache_write_input_tokens: Measurement, + /// Reasoning-inclusive output. + pub output_tokens: Measurement, + /// Independently reported reasoning tokens. + pub reasoning_tokens: Measurement, + /// Normalized input plus output, not the provider's independent raw total. + pub total_tokens: Measurement, + /// Largest request's cache-inclusive input. + pub peak_request_input_tokens: Measurement, + /// Provider-reported cost and separately identified catalog estimate. + pub token_cost: TokenCost, + /// One entry per literal model/provider identity. + #[serde(default)] + pub models: Vec, + /// Present only after validating the supported accounting extension. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub accounting: Option, +} + +/// Monetary values are checked integer nano-USD, with distinct reported and catalog sources. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct TokenCost { + /// Selected rates by source request index; null entries are unpriced. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub applied_calls: Vec>, + /// Provider-reported amount, never a harness estimate or an invoice guarantee. + pub reported: Measurement, + /// Central catalog estimate; absent when scope or price inputs are unverified. + pub estimated: Measurement, + /// Required comparison basis for catalog prices. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pricing: Option, + /// Exact selected entry and integer rates for one model. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub applied: Option, +} + +/// Complete and observed usage for one model. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct PerModelUsage { + /// Literal source model identity. + pub model: String, + /// Literal source provider identity. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + /// Normalized values for this model, excluding nested model entries. + #[serde(flatten)] + pub usage: ModelMeasurements, + /// Independently validated observations and gaps for this model. + pub accounting: ModelAccounting, +} + +/// Measurements shared by an invocation and its per-model breakdown. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct ModelMeasurements { + /// Cache-inclusive input. + pub input_tokens: Measurement, + /// Cache reads. + pub cached_input_tokens: Measurement, + /// Cache writes. + pub cache_write_input_tokens: Measurement, + /// Reasoning-inclusive output. + pub output_tokens: Measurement, + /// Reasoning tokens. + pub reasoning_tokens: Measurement, + /// Input plus output. + pub total_tokens: Measurement, + /// Largest request input. + pub peak_request_input_tokens: Measurement, + /// Costs for this model. + pub token_cost: TokenCost, +} + +/// A normalized dimension. Cost units are nano-USD, not raw floating-point USD. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum AccountingDimension { + /// Input tokens. + InputTokens, + /// Cache reads. + CachedInputTokens, + /// Cache writes. + CacheWriteInputTokens, + /// Output tokens. + OutputTokens, + /// Reasoning tokens. + ReasoningTokens, + /// Input plus output. + TotalTokens, + /// Largest request input. + PeakRequestInputTokens, + /// Provider-reported nano-USD. + ReportedCost, + /// Catalog-estimated nano-USD. + EstimatedCost, +} + +/// A normalized dimension affected by missing evidence. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct AccountingGap { + /// Affected dimensions. + pub dimensions: Vec, + /// Why these are incomplete. + pub reason: GapReason, +} + +/// Independently valid normalized observations. These are not promised bounds. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct ObservedUsage { + /// Cache-inclusive observed input. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(max = u64::MAX))] + pub input_tokens: Option, + /// Independently observed cache reads. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(max = u64::MAX))] + pub cached_input_tokens: Option, + /// Independently observed cache writes. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(max = u64::MAX))] + pub cache_write_input_tokens: Option, + /// Reasoning-inclusive observed output. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(max = u64::MAX))] + pub output_tokens: Option, + /// Independently observed reasoning. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(max = u64::MAX))] + pub reasoning_tokens: Option, + /// Sum of available normalized input and output observations. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(max = u64::MAX))] + pub total_tokens: Option, + /// Largest observed request input. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(max = u64::MAX))] + pub peak_request_input_tokens: Option, + /// Observed provider-reported cost. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(max = u64::MAX))] + pub reported_cost_nano_usd: Option, + /// Catalog price of verifiable observed contributions; not a complete cost. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(max = u64::MAX))] + pub estimated_cost_nano_usd: Option, +} + +/// Native invocation accounting. Empty gaps do not turn absent measurements into zero. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct InvocationAccounting { + /// Whether the model list covers all identities used by owned work. + pub models_exhaustive: bool, + /// Available deduplicated observations; never added to complete totals again. + pub observed: ObservedUsage, + /// Explicit and normalization-derived gaps. + pub gaps: Vec, +} + +/// Native per-model accounting. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct ModelAccounting { + /// Observed token dimensions retain one verified source-call subset. + #[serde(default)] + pub observed_tokens_aligned: bool, + /// Available deduplicated observations. + pub observed: ObservedUsage, + /// Explicit and normalization-derived gaps. + pub gaps: Vec, +} + +type Count = Result; +const INPUT: usize = UsageDimension::InputTokens as usize; +const OUTPUT: usize = UsageDimension::OutputTokens as usize; +const TOTAL: usize = UsageDimension::TotalTokens as usize; + +// Request lists may be partial, but their usable subtotal cannot exceed a +// complete model total. Exhaustive settled lists must agree; costs allow only +// numeric conversion roundoff, while token comparisons are exact. +fn validate_request_totals( + model: &crate::usage_accounting::AccountedModel, + complete: &[Count; 8], + root_complete: &[Count; 8], + requests: &[[Count; 8]], +) -> Result<(), AccountingError> { + // A request-wide gap covers every dimension. Scoped omissions instead + // leave only their affected request fields absent. + if model.requests.iter().any(|request| request.gap.is_some()) + && complete.iter().chain(root_complete).any(Result::is_ok) + { + return Err(AccountingError( + "complete value conflicts with request scope gap".into(), + )); + } + for (request, counts) in model.requests.iter().zip(requests) { + if (request.gap.is_none() || request.tokens_aligned) + && let (Some(peak), Ok(input)) = + (request.usage.peak_request_input_tokens, counts[INPUT]) + && peak != input + { + return Err(AccountingError( + "single-request peak contradicts input".into(), + )); + } + } + for (index, dimension) in UsageDimension::ALL.into_iter().enumerate() { + let Ok(aggregate) = complete[index] else { + continue; + }; + let parts: Vec<_> = requests + .iter() + .filter_map(|counts| counts[index].ok()) + .collect(); + if parts.is_empty() { + continue; + } + let subtotal = combine_values(&parts, dimension); + let exhaustive = model.requests_exhaustive + && model.requests.iter().all(|request| request.gap.is_none()) + && parts.len() == requests.len(); + let contradictory = subtotal.is_none_or(|sum| { + let tolerance = if dimension == UsageDimension::CostUsd { + request_cost_rounding_tolerance(aggregate.max(sum), &parts) + } else { + 0 + }; + sum.saturating_sub(aggregate) > tolerance + || (exhaustive && sum.abs_diff(aggregate) > tolerance) + }); + if contradictory { + return Err(AccountingError( + "request totals contradict model evidence".into(), + )); + } + } + Ok(()) +} + +// Costs are additive evidence, but each raw f64 USD value is independently +// converted to rounded nano-USD. One ULP in the rounded amount bounds its f64 +// spacing; two ULPs plus one nano conservatively cover USD scaling and nearest +// integer rounding. An n-term source sum can round n times at the total's +// magnitude. Use saturating allowances, never a percentage or billing policy. +fn request_cost_rounding_tolerance(total: u64, parts: &[u64]) -> u64 { + let allowance = |value: u64| { + let exponent = value.checked_ilog2().unwrap_or(0); + let ulp = 1_u64 << exponent.saturating_sub(52); + ulp.saturating_mul(2).saturating_add(1) + }; + let terms = u64::try_from(parts.len()).unwrap_or(u64::MAX); + parts + .iter() + .fold(allowance(total).saturating_mul(terms), |sum, part| { + sum.saturating_add(allowance(*part)) + }) +} + +/// Validate and normalize one adapter result's usage. Callers must also enforce +/// descriptor declarations at the normal runtime extension-validation boundary. +pub fn describe_model_usage(usage: &AgentUsage) -> Result { + let evidence = UsageAccounting::from_usage(usage)?; + let raw = reported_usage(usage)?; + let mut counts = normalize(&raw, false); + if evidence.is_none() { + // Legacy adapters can put client-side estimates in cost_usd. Preserve + // that raw field without relabeling it as a provider-reported amount. + counts[UsageDimension::CostUsd as usize] = Err("legacy_cost_unverified"); + } + let mut observations = evidence + .as_ref() + .and_then(|e| e.observed.as_ref()) + .map(|raw| normalize(raw, true)) + .unwrap_or([Err("not_reported"); 8]); + let gaps = evidence + .as_ref() + .map(|e| e.gaps.as_slice()) + .unwrap_or_default(); + apply_gaps(&mut counts, &raw, gaps); + let mut models = Vec::new(); + if let Some(evidence) = &evidence { + let mut source_models: Vec<_> = evidence.models.iter().collect(); + source_models.sort_by_key(|model| (&model.provider, &model.model)); + for model in source_models { + let mut complete = normalize(&model.reported, false); + apply_gaps(&mut complete, &model.reported, &model.gaps); + let requests: Vec<_> = model + .requests + .iter() + .map(|request| { + normalize_request( + &request.usage, + request.gap.is_some() && !request.tokens_aligned, + ) + }) + .collect(); + validate_request_totals(model, &complete, &counts, &requests)?; + let mut observed = model + .observed + .as_ref() + .map(|v| normalize(v, !model.observed_tokens_aligned)) + .unwrap_or([Err("not_reported"); 8]); + // Per-call conventions can differ even for one model. Normalize + // before combining them; an inexpressible raw aggregate is not an + // information loss when its complete request evidence is retained. + for (index, dimension) in UsageDimension::ALL.into_iter().enumerate() { + if requests.is_empty() { + continue; + } + let parts: Vec<_> = requests + .iter() + .filter_map(|counts| counts[index].ok()) + .collect(); + if recoverable_observation(observed[index]) && !parts.is_empty() { + observed[index] = + combine_values(&parts, dimension).ok_or("token_count_out_of_range"); + } + if complete[index] == Err("not_reported") + && model.requests_exhaustive + && model.requests.iter().all(|request| request.gap.is_none()) + && parts.len() == requests.len() + { + complete[index] = + combine_values(&parts, dimension).ok_or("token_count_out_of_range"); + } + } + validate_recovered_components(&mut complete, &mut observed); + let mut aligned = model.observed_tokens_aligned; + for (index, dimension) in UsageDimension::ALL.into_iter().enumerate() { + let tolerance = if dimension == UsageDimension::CostUsd { + let parts: Vec<_> = requests + .iter() + .filter_map(|counts| counts[index].ok()) + .collect(); + request_cost_rounding_tolerance( + complete[index] + .unwrap_or(0) + .max(observed[index].unwrap_or(0)), + &parts, + ) + .max(1) + } else { + 0 + }; + if complete[index] == Err("inconsistent_usage") + || complete[index].is_ok_and(|value| { + observed[index] + .is_ok_and(|observed| observed > value.saturating_add(tolerance)) + }) + { + complete[index] = Err("inconsistent_usage"); + observed[index] = Err("inconsistent_usage"); + } else if complete[index].is_ok() { + // Replacing a subset observation with a complete dimension + // can mix call sets, even though each value is valid alone. + if dimension != UsageDimension::CostUsd && observed[index] != complete[index] { + aligned = false; + } + observed[index] = complete[index]; + } + } + complete[TOTAL] = add(complete[INPUT], complete[OUTPUT]); + observed[TOTAL] = add(observed[INPUT], observed[OUTPUT]); + models.push((model, complete, observed, aligned)); + } + for (index, dimension) in UsageDimension::ALL.into_iter().enumerate() { + if dimension == UsageDimension::TotalTokens { + continue; + } + let complete_parts: Vec<_> = models.iter().map(|(_, c, _, _)| c[index]).collect(); + let observed_parts: Vec<_> = models + .iter() + .filter_map(|(_, _, o, _)| o[index].ok()) + .collect(); + let combined_complete = combine(&complete_parts, dimension); + let combined_observed = combine_values(&observed_parts, dimension); + let exhaustive = evidence.models_exhaustive + && !models.is_empty() + && complete_parts.iter().all(Result::is_ok); + let contradiction = counts[index] == Err("inconsistent_usage") + || complete_parts.contains(&Err("inconsistent_usage")) + || if let Ok(value) = counts[index] { + let tolerance = if dimension == UsageDimension::CostUsd { + let request_costs: Vec<_> = evidence + .models + .iter() + .flat_map(|model| &model.requests) + .filter_map(|request| { + request + .usage + .cost_usd + .and_then(|cost| usd_to_nano_usd(cost).ok()) + }) + .collect(); + let magnitude = value + .max(combined_observed.unwrap_or(0)) + .max(observations[index].unwrap_or(0)); + request_cost_rounding_tolerance(magnitude, &observed_parts) + .saturating_add(request_cost_rounding_tolerance( + magnitude, + &request_costs, + )) + .max(models.len() as u64) + } else { + 0 + }; + combined_observed.is_some_and(|sum| sum > value.saturating_add(tolerance)) + || (!observed_parts.is_empty() && combined_observed.is_none()) + || (exhaustive + && combined_complete.is_ok_and(|sum| value.abs_diff(sum) > tolerance)) + || observations[index] + .is_ok_and(|observed| observed > value.saturating_add(tolerance)) + } else { + false + }; + if contradiction { + counts[index] = Err("inconsistent_usage"); + observations[index] = Err("inconsistent_usage"); + continue; + } + if counts[index] == Err("not_reported") && exhaustive { + counts[index] = combined_complete; + } + if let Ok(value) = counts[index] { + observations[index] = Ok(value); + } else if recoverable_observation(observations[index]) && !observed_parts.is_empty() { + observations[index] = combined_observed.ok_or("token_count_out_of_range"); + } + } + } + validate_recovered_components(&mut counts, &mut observations); + counts[TOTAL] = add(counts[INPUT], counts[OUTPUT]); + observations[TOTAL] = add(observations[INPUT], observations[OUTPUT]); + let values = measurements(counts); + Ok(ModelUsage { + input_tokens: values.input_tokens, + cached_input_tokens: values.cached_input_tokens, + cache_write_input_tokens: values.cache_write_input_tokens, + output_tokens: values.output_tokens, + reasoning_tokens: values.reasoning_tokens, + total_tokens: values.total_tokens, + peak_request_input_tokens: values.peak_request_input_tokens, + token_cost: values.token_cost, + models: models + .into_iter() + .map(|(model, complete, observed, aligned)| PerModelUsage { + model: model.model.clone(), + provider: model.provider.clone(), + usage: measurements(complete), + accounting: ModelAccounting { + observed_tokens_aligned: aligned, + observed: observed_usage(observed), + gaps: native_gaps( + complete, + &model.gaps, + model.requests.iter().filter_map(|request| request.gap), + ), + }, + }) + .collect(), + accounting: evidence.map(|evidence| InvocationAccounting { + models_exhaustive: evidence.models_exhaustive, + observed: observed_usage(observations), + gaps: native_gaps( + counts, + &evidence.gaps, + evidence + .models + .iter() + .flat_map(|model| &model.requests) + .filter_map(|request| request.gap), + ), + }), + }) +} + +// Recovery combines dimensions from different source levels. Recheck the same +// complete-scope relationships normalize() enforces before publishing them. +// Independent cache and reasoning counts survive a contradictory inclusive sum. +fn validate_recovered_components(counts: &mut [Count; 8], observed: &mut [Count; 8]) { + let cache = counts[UsageDimension::CachedInputTokens as usize] + .unwrap_or(0) + .checked_add(counts[UsageDimension::CacheWriteInputTokens as usize].unwrap_or(0)); + let peak = counts[UsageDimension::PeakRequestInputTokens as usize]; + if counts[INPUT].is_ok_and(|input| { + cache.is_none_or(|cache| cache > input) || peak.is_ok_and(|peak| peak > input) + }) { + counts[INPUT] = Err("inconsistent_usage"); + observed[INPUT] = Err("inconsistent_usage"); + } + let reasoning = counts[UsageDimension::ReasoningTokens as usize]; + if counts[OUTPUT].is_ok_and(|output| reasoning.is_ok_and(|reasoning| reasoning > output)) { + counts[OUTPUT] = Err("inconsistent_usage"); + observed[OUTPUT] = Err("inconsistent_usage"); + } +} + +// An unconvertible aggregate must not erase a qualified model/request subset. +// Contradictions and overflow stay errors; these reasons only lack a usable +// normalized observation. Recovery never certifies a complete invocation. +fn recoverable_observation(value: Count) -> bool { + matches!( + value, + Err("not_reported" + | "observation_scope_unverified" + | "cache_components_unreported" + | "cache_inclusion_unknown" + | "reasoning_tokens_unreported" + | "reasoning_inclusion_unknown") + ) +} + +fn normalize(raw: &ReportedUsage, observation: bool) -> [Count; 8] { + let mut input = raw.input_tokens.ok_or("not_reported").and_then(|input| { + match raw.input_tokens_include_cache { + Some(true) => Ok(input), + Some(false) if !observation => add( + add( + Ok(input), + raw.cached_input_tokens.ok_or("cache_components_unreported"), + ), + raw.cache_write_input_tokens + .ok_or("cache_components_unreported"), + ), + Some(false) => Err("observation_scope_unverified"), + None if raw.cached_input_tokens == Some(0) + && raw.cache_write_input_tokens == Some(0) => + { + Ok(input) + } + None => Err("cache_inclusion_unknown"), + } + }); + let mut output = raw.output_tokens.ok_or("not_reported").and_then(|output| { + match raw.output_tokens_include_reasoning { + Some(true) => Ok(output), + Some(false) if !observation => add( + Ok(output), + raw.reasoning_tokens.ok_or("reasoning_tokens_unreported"), + ), + Some(false) => Err("observation_scope_unverified"), + None if raw.reasoning_tokens == Some(0) => Ok(output), + None => Err("reasoning_inclusion_unknown"), + } + }); + // Complete or explicitly aligned dimensions share scope. Other observations may cover + // different subsets, so these component comparisons cannot validate them. + if !observation { + if let Ok(value) = input { + let cache = raw + .cached_input_tokens + .unwrap_or(0) + .checked_add(raw.cache_write_input_tokens.unwrap_or(0)); + if cache.is_none_or(|cache| cache > value) + || raw + .peak_request_input_tokens + .is_some_and(|peak| peak > value) + { + input = Err("inconsistent_usage"); + } + } + if let Ok(value) = output + && raw + .reasoning_tokens + .is_some_and(|reasoning| reasoning > value) + { + output = Err("inconsistent_usage"); + } + } + [ + input, + raw.cached_input_tokens.ok_or("not_reported"), + raw.cache_write_input_tokens.ok_or("not_reported"), + output, + raw.reasoning_tokens.ok_or("not_reported"), + add(input, output), + raw.peak_request_input_tokens.ok_or("not_reported"), + raw.cost_usd.ok_or("not_reported").and_then(usd_to_nano_usd), + ] +} + +fn normalize_request(raw: &ReportedUsage, observation: bool) -> [Count; 8] { + let mut counts = normalize(raw, observation); + if raw.peak_request_input_tokens.is_none() { + counts[UsageDimension::PeakRequestInputTokens as usize] = counts[INPUT]; + } + counts +} + +pub(crate) fn normalized_request(raw: &ReportedUsage, observation: bool) -> ModelMeasurements { + measurements(normalize_request(raw, observation)) +} + +fn add(left: Count, right: Count) -> Count { + left.and_then(|left| { + right.and_then(|right| left.checked_add(right).ok_or("token_count_out_of_range")) + }) +} + +fn apply_gaps(counts: &mut [Count; 8], raw: &ReportedUsage, gaps: &[UsageGap]) { + for gap in gaps { + for dimension in &gap.dimensions { + let index = UsageDimension::ALL + .iter() + .position(|d| d == dimension) + .expect("known dimension"); + if *dimension != UsageDimension::TotalTokens { + counts[index] = Err(gap_reason(gap.reason)); + } + if matches!( + dimension, + UsageDimension::CachedInputTokens | UsageDimension::CacheWriteInputTokens + ) && raw.input_tokens_include_cache == Some(false) + { + counts[INPUT] = Err(gap_reason(gap.reason)); + } + if *dimension == UsageDimension::ReasoningTokens + && raw.output_tokens_include_reasoning == Some(false) + { + counts[OUTPUT] = Err(gap_reason(gap.reason)); + } + } + } + counts[TOTAL] = add(counts[INPUT], counts[OUTPUT]); +} + +fn gap_reason(reason: GapReason) -> &'static str { + match reason { + GapReason::UnfinishedWork => "unfinished_work", + GapReason::MissingUsage => "missing_usage", + GapReason::ScopeUnverified => "scope_unverified", + } +} + +fn combine(parts: &[Count], dimension: UsageDimension) -> Count { + let values = parts.iter().copied().collect::, _>>()?; + combine_values(&values, dimension).ok_or("token_count_out_of_range") +} + +fn combine_values(values: &[u64], dimension: UsageDimension) -> Option { + if values.is_empty() { + return None; + } + if dimension == UsageDimension::PeakRequestInputTokens { + values.iter().copied().max() + } else { + values + .iter() + .try_fold(0_u64, |sum, value| sum.checked_add(*value)) + } +} + +fn measured(count: Count, unit: MeasurementUnit) -> Measurement { + Measurement { + availability: if count.is_ok() { + Availability::Measured + } else { + Availability::Unavailable + }, + value: count.ok(), + unit, + source: MeasurementSource::AdapterUsage, + attribution: if count.is_ok() { + Attribution::Invocation + } else { + Attribution::None + }, + reason: count.err().map(str::to_owned), + } +} + +fn measurements(counts: [Count; 8]) -> ModelMeasurements { + let tokens = |index| measured(counts[index], MeasurementUnit::Tokens); + ModelMeasurements { + input_tokens: tokens(0), + cached_input_tokens: tokens(1), + cache_write_input_tokens: tokens(2), + output_tokens: tokens(3), + reasoning_tokens: tokens(4), + total_tokens: tokens(5), + peak_request_input_tokens: tokens(6), + token_cost: TokenCost { + applied_calls: Vec::new(), + reported: measured(counts[7], MeasurementUnit::NanoUsd), + pricing: None, + applied: None, + estimated: Measurement { + availability: Availability::Unavailable, + value: None, + unit: MeasurementUnit::NanoUsd, + source: MeasurementSource::FabricPriceCatalog, + attribution: Attribution::None, + reason: Some("price_catalog_not_configured".into()), + }, + }, + } +} + +fn observed_usage(counts: [Count; 8]) -> ObservedUsage { + ObservedUsage { + input_tokens: counts[0].ok(), + cached_input_tokens: counts[1].ok(), + cache_write_input_tokens: counts[2].ok(), + output_tokens: counts[3].ok(), + reasoning_tokens: counts[4].ok(), + total_tokens: counts[5].ok(), + peak_request_input_tokens: counts[6].ok(), + reported_cost_nano_usd: counts[7].ok(), + estimated_cost_nano_usd: None, + } +} + +fn native_gaps( + counts: [Count; 8], + raw: &[UsageGap], + request_reasons: impl Iterator, +) -> Vec { + let request_reasons: Vec<_> = request_reasons.collect(); + let dimensions = [ + AccountingDimension::InputTokens, + AccountingDimension::CachedInputTokens, + AccountingDimension::CacheWriteInputTokens, + AccountingDimension::OutputTokens, + AccountingDimension::ReasoningTokens, + AccountingDimension::TotalTokens, + AccountingDimension::PeakRequestInputTokens, + AccountingDimension::ReportedCost, + ]; + let mut result = Vec::new(); + for (index, dimension) in dimensions.into_iter().enumerate() { + if let Err(reason) = counts[index] { + let category = match reason { + "unfinished_work" => GapReason::UnfinishedWork, + "not_reported" + | "missing_usage" + | "cache_components_unreported" + | "reasoning_tokens_unreported" => GapReason::MissingUsage, + _ => GapReason::ScopeUnverified, + }; + let mut add_reason = |reason| { + let gap = AccountingGap { + dimensions: vec![dimension], + reason, + }; + if !result.contains(&gap) { + result.push(gap); + } + }; + // A request-wide gap explains absent complete values at both scopes. + // Preserve independent normalization or explicitly scoped reasons too. + if reason != "not_reported" || request_reasons.is_empty() { + add_reason(category); + } + for reason in &request_reasons { + add_reason(*reason); + } + // Derived total may be affected by several independently actionable gaps. + if dimension == AccountingDimension::TotalTokens { + for gap in raw { + if gap.dimensions.iter().any(|d| { + matches!( + d, + UsageDimension::InputTokens | UsageDimension::OutputTokens + ) + }) && gap.reason != category + { + add_reason(gap.reason); + } + } + } + } + } + result.push(AccountingGap { + dimensions: vec![AccountingDimension::EstimatedCost], + reason: GapReason::MissingUsage, + }); + result +} diff --git a/crates/fabric-core/src/what_ran.rs b/crates/fabric-core/src/what_ran.rs new file mode 100644 index 000000000..b6fb94f81 --- /dev/null +++ b/crates/fabric-core/src/what_ran.rs @@ -0,0 +1,1689 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! What executed for one invocation, and how NeMo Fabric learned each fact. +//! +//! [`WhatRan`] rides on [`RunResult`](crate::RunResult). Each fact is a +//! [`Fact`]: a value, or a [`FactState`] with a reason, and in both cases +//! the [`FactSource`], the component that collected it and the method it +//! used. A few names fixed by the run plan stay plain strings: the adapter +//! ID, each model role's provider, the environment provider, and each MCP +//! server's origin and transport. +//! +//! A string value longer than 256 characters, in a string fact or as an +//! element of a string-list fact, keeps its first 240 characters, then `~`, +//! then the first 15 hex digits of its SHA-256. Map keys and the plain names +//! above are never cut. +//! +//! `models` keeps at most 16 roles and `mcp.servers` at most 32 servers, in +//! sorted key order. An entry whose key is longer than 128 characters is +//! dropped. While the compact JSON of the whole object is larger than +//! 128 KiB, MCP server entries are dropped from the end of the sorted order, +//! then model roles. Dropped entries are counted in a [`TruncatedEntries`] +//! marker next to the map: `models_truncated` and `mcp.servers_truncated`. +//! If the object is still too large with no MCP server and no model role, +//! it is replaced by a minimal one that holds only the schema name, the +//! version and the `what_ran_truncated` marker. +//! +//! `what_ran` is evidence. It never decides which runs are compared. +//! +//! Compatibility rules for readers: +//! +//! - `source` and `state` are closed sets. A new value changes +//! [`WHAT_RAN_VERSION`]. +//! - `collected_by`, `method` and `reason` are open. Readers accept values +//! they do not know. +//! - Readers accept unknown properties. Adding an optional property keeps +//! [`WHAT_RAN_VERSION`]; removing one or changing its meaning changes it. +//! The Rust types drop unknown properties when they decode, so a host +//! that relays a task core's result drops them too. +//! - A fact holds `value`, or `state` and `reason`, never both. A +//! `RunResult` reader that finds a fact breaking this, any other value +//! the schema rejects, or another schema or version reads `what_ran` as +//! absent and still decodes the result. + +use std::collections::BTreeMap; + +use schemars::JsonSchema; +use serde::de::{DeserializeOwned, Error as _}; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +use crate::config::{McpServerPlan, McpTransport, ModelConfig, RunPlan}; +use crate::endpoint::{endpoint_identity, executable_name}; + +/// Schema name written into every [`WhatRan`]. +pub const WHAT_RAN_SCHEMA: &str = "nemo.fabric.what_ran"; + +/// Contract version written into every [`WhatRan`]. +pub const WHAT_RAN_VERSION: &str = "v1alpha1"; + +/// How NeMo Fabric learned a fact. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum FactSource { + /// NeMo Fabric core saw it itself. + Observed, + /// The adapter, the harness, an MCP server or a remote service said so. + Reported, + /// It comes from the resolved run plan. + Configured, +} + +/// Why a fact has no value. A collector picks the first state that matches, +/// in declaration order. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum FactState { + /// The fact cannot exist for this run. + NotApplicable, + /// The adapter or environment provider has no source for the fact. + Unsupported, + /// A source exists, but it failed, timed out, returned an invalid report + /// or never returned. + CollectionFailed, + /// NeMo Fabric holds no value and no evidence for the other states. + Unavailable, +} + +/// Pattern of the open codes `collected_by`, `method` and `reason`. +const CODE_PATTERN: &str = "^[a-z0-9_.:#+/-]{1,128}$"; + +/// Who collected a fact and how. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct FactOrigin { + /// How NeMo Fabric learned the fact. + pub source: FactSource, + /// Component that collected the fact, such as `host_core`, `task_core` + /// or `adapter`. + #[schemars(extend("pattern" = CODE_PATTERN))] + pub collected_by: String, + /// How the collector got the fact, such as `crate_version`, + /// `resolved_plan` or `adapter_report`. + #[schemars(extend("pattern" = CODE_PATTERN))] + pub method: String, +} + +/// A fact with a value. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[schemars( + rename = "KnownFact_for_{T}", + extend("not" = {"anyOf": [{"required": ["state"]}, {"required": ["reason"]}]}) +)] +pub struct KnownFact { + /// The fact itself: a string, a boolean or a list of strings. + pub value: T, + /// Who collected the fact and how. + #[serde(flatten)] + pub origin: FactOrigin, +} + +/// A fact without a value. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[schemars(extend("not" = {"required": ["value"]}))] +pub struct MissingFact { + /// Why the fact has no value. + pub state: FactState, + /// Short code that explains the state, such as `key_not_declared`. + #[schemars(extend("pattern" = CODE_PATTERN))] + pub reason: String, + /// Who would have collected the fact and how. + #[serde(flatten)] + pub origin: FactOrigin, +} + +/// One fact: a `value`, or a `state` with a `reason`, never both. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +#[serde(untagged)] +#[schemars(rename = "Fact_for_{T}")] +pub enum Fact { + /// The fact has a value. + Known(KnownFact), + /// The fact has no value. + Missing(MissingFact), +} + +impl<'de, T: DeserializeOwned> Deserialize<'de> for Fact { + /// Decode a fact, rejecting one that holds `value` together with + /// `state` or `reason`, or an open code outside its pattern. Unknown + /// properties are ignored. + fn deserialize>(deserializer: D) -> Result { + let fact = serde_json::Map::::deserialize(deserializer)?; + let fact = if fact.contains_key("value") { + if fact.contains_key("state") || fact.contains_key("reason") { + return Err(D::Error::custom( + "a fact holds `value`, or `state` and `reason`, never both", + )); + } + KnownFact::deserialize(Value::Object(fact)).map(Self::Known) + } else { + MissingFact::deserialize(Value::Object(fact)).map(Self::Missing) + } + .map_err(D::Error::custom)?; + let origin = fact.origin(); + let reason = match &fact { + Self::Known(_) => None, + Self::Missing(missing) => Some(missing.reason.as_str()), + }; + if [origin.collected_by.as_str(), origin.method.as_str()] + .into_iter() + .chain(reason) + .all(is_code) + { + Ok(fact) + } else { + Err(D::Error::custom(format!( + "`collected_by`, `method` and `reason` must match {CODE_PATTERN}" + ))) + } + } +} + +/// Whether `code` matches [`CODE_PATTERN`]. +fn is_code(code: &str) -> bool { + (1..=128).contains(&code.len()) + && code.bytes().all(|byte| { + byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || matches!(byte, b'_' | b'.' | b':' | b'#' | b'+' | b'/' | b'-') + }) +} + +/// Entries core dropped to keep `what_ran` within its bounds: next to a +/// map for the entries dropped from it, or as `what_ran_truncated` for the +/// whole object. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +pub struct TruncatedEntries { + /// Why the entries were dropped: `key_too_long` for a key longer than + /// 128 characters, `entry_cap` for an entry past the map's cap, or + /// `byte_budget` for an entry dropped to keep the whole object within + /// 128 KiB. One marker covers a map: when more than one rule dropped + /// entries from it, `reason` names the last rule applied, in that + /// order, and `dropped` counts them all. An open code like a fact's + /// `reason`. + #[schemars(extend("pattern" = CODE_PATTERN))] + pub reason: String, + /// How many entries were dropped. A JSON integer: the Rust and Python + /// readers reject `1.0`, although a JSON Schema validator may accept it. + #[schemars(range(min = 1, max = u64::MAX))] + pub dropped: u64, +} + +impl<'de> Deserialize<'de> for TruncatedEntries { + /// Decode a marker, rejecting a `reason` outside the code pattern or a + /// `dropped` count of zero. Unknown properties are ignored. + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + struct Marker { + reason: String, + dropped: u64, + } + let Marker { reason, dropped } = Marker::deserialize(deserializer)?; + if !is_code(&reason) { + return Err(D::Error::custom(format!( + "a truncation `reason` must match {CODE_PATTERN}" + ))); + } + if dropped == 0 { + return Err(D::Error::custom( + "a truncation `dropped` count is at least 1", + )); + } + Ok(Self { reason, dropped }) + } +} + +/// What executed for one invocation. +/// +/// Every section is present, unless `what_ran_truncated` is present: then +/// the object is a minimal one that the byte budget left with only its +/// schema name and version, and every section may be absent. +/// +/// Decoding a `WhatRan` directly checks the shape of every section and +/// fact, and that no section is `null`. `RunResult` also checks the schema +/// name and version, that every section is present unless +/// `what_ran_truncated` is, and that every `plan` MCP entry names its +/// transport. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[schemars(extend("anyOf" = [ + {"required": ["what_ran_truncated"]}, + {"required": ["fabric", "adapter", "harness", "models", "mcp", "environment"]} +]))] +pub struct WhatRan { + /// Schema name, `nemo.fabric.what_ran`. + #[schemars(extend("const" = WHAT_RAN_SCHEMA))] + pub schema: String, + /// Contract version, `v1alpha1`. + #[schemars(extend("const" = WHAT_RAN_VERSION))] + pub version: String, + /// NeMo Fabric core that ran the adapter. + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "FabricFacts")] + pub fabric: Option, + /// Adapter that ran. + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "AdapterFacts")] + pub adapter: Option, + /// Harness behind the adapter. + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "HarnessFacts")] + pub harness: Option, + /// Model facts keyed by the role names of the plan's `models` settings. + /// At most 16 roles, in sorted key order, each key at most 128 + /// characters. + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "BTreeMap")] + pub models: Option>, + /// Model roles dropped from `models`. Absent when none was dropped, + /// never `null`. + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "TruncatedEntries")] + pub models_truncated: Option, + /// MCP server facts. + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "McpFacts")] + pub mcp: Option, + /// Environment the runtime ran in. + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "EnvironmentFacts")] + pub environment: Option, + /// Present only on a minimal object: every section was dropped to keep + /// the object within 128 KiB, with reason `byte_budget` and a dropped + /// count of 1. + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "TruncatedEntries")] + pub what_ran_truncated: Option, +} + +/// Decode a property that may be absent but is never `null`. +fn present<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + T::deserialize(deserializer).map(Some) +} + +/// NeMo Fabric core facts. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct FabricFacts { + /// Version of the core that ran the adapter. + pub core_version: Fact, + /// Version of the host core that relayed the result from a task core + /// in another environment. `not_applicable` for a local run. + pub host_core_version: Fact, +} + +/// Adapter facts. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct AdapterFacts { + /// Adapter ID from the plan, when an adapter was resolved. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + /// Version of the adapter package, as the adapter reports it. + pub package_version: Fact, +} + +/// Harness facts. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct HarnessFacts { + /// Name of the harness program that ran, as the adapter reports it. + /// Core never derives it from the adapter ID. + pub name: Fact, + /// Version of the harness program that ran, as the adapter reports it. + pub version: Fact, +} + +/// Facts about one model role. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ModelFacts { + /// Provider from the plan. + pub provider: String, + /// Model the plan requested. + pub requested: Fact, + /// Model that served, as the harness or provider reports it. + pub served: Fact, +} + +/// MCP facts. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct McpFacts { + /// Servers keyed by name. Empty when the plan configures no MCP server + /// and the harness reports none. At most 32 servers, in sorted key + /// order, each key at most 128 characters. + pub servers: BTreeMap, + /// Servers dropped from `servers`. Absent when none was dropped, never + /// `null`. + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + #[schemars(with = "TruncatedEntries")] + pub servers_truncated: Option, +} + +/// Facts about one MCP server. +/// +/// An entry with origin `harness` is a server the harness reports and the +/// plan does not configure. It has the same shape: its `endpoint` reads +/// `not_applicable` with reason `not_configured`, and runtime facts the +/// adapter did not collect read `unavailable` with reason `not_collected`. +/// Only a `plan` entry must name its transport. An entry of any other +/// origin may leave it out. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[schemars(extend( + "if" = {"properties": {"origin": {"const": ORIGIN_PLAN}}}, + "then" = {"required": ["transport"], "properties": {"transport": {"type": "string"}}} +))] +pub struct McpServerFacts { + /// Where the entry comes from: `plan` for a server the plan configures, + /// `harness` for one only the harness reports. + pub origin: String, + /// Transport, such as `stdio` or `streamable-http`. Required for a + /// `plan` entry, and optional for any other origin. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub transport: Option, + /// Configured endpoint without secrets: the lowercase scheme and host + /// and the port for a network server, or the executable's file name for + /// a `stdio` server. + pub endpoint: Fact, + /// What the harness learned when it connected, as the adapter reports it. + pub runtime: McpRuntimeFacts, +} + +/// What the harness learned about one MCP server when it connected. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct McpRuntimeFacts { + /// Whether the harness connected to the server. + pub connected: Fact, + /// MCP protocol version negotiated with the server. + pub protocol_version: Fact, + /// Name the server reports for itself. + pub server_name: Fact, + /// Version the server reports for itself. + pub server_version: Fact, + /// Raw tool names the server lists, each bounded by the string length + /// rule. + pub tool_names: Fact>, +} + +/// Environment facts. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct EnvironmentFacts { + /// Environment provider, such as `local`. + pub provider: String, +} + +const HOST_CORE: &str = "host_core"; +const TASK_CORE: &str = "task_core"; +const ADAPTER: &str = "adapter"; +const ORIGIN_PLAN: &str = "plan"; +const METHOD_CRATE_VERSION: &str = "crate_version"; +const METHOD_RESOLVED_PLAN: &str = "resolved_plan"; +const METHOD_ADAPTER_REPORT: &str = "adapter_report"; +const REASON_KEY_NOT_DECLARED: &str = "key_not_declared"; +const REASON_LOCAL_RUN: &str = "local_run"; +const REASON_NOT_CONFIGURED: &str = "not_configured"; +const REASON_ENDPOINT_UNPARSED: &str = "endpoint_unparsed"; +const REASON_EXECUTABLE_UNPARSED: &str = "executable_unparsed"; +const REASON_ENTRY_CAP: &str = "entry_cap"; +const REASON_KEY_TOO_LONG: &str = "key_too_long"; +const REASON_BYTE_BUDGET: &str = "byte_budget"; + +/// Most model roles `what_ran` keeps. +const MAX_MODEL_ROLES: usize = 16; +/// Most MCP servers `what_ran` keeps. +const MAX_MCP_SERVERS: usize = 32; +/// Longest map key `what_ran` keeps, in characters. +const MAX_KEY_CHARS: usize = 128; +/// Largest compact JSON of a whole `what_ran`, in bytes. +const MAX_WHAT_RAN_BYTES: usize = 128 * 1024; + +/// Longest string value kept whole, in characters. +const MAX_VALUE_CHARS: usize = 256; +/// Characters a longer string value keeps before `~` and its digest prefix. +const TRUNCATED_PREFIX_CHARS: usize = 240; +/// Hex digits of the SHA-256 that end a truncated string value. +const TRUNCATED_DIGEST_HEX_DIGITS: usize = 15; +const _: () = assert!(TRUNCATED_PREFIX_CHARS + 1 + TRUNCATED_DIGEST_HEX_DIGITS == MAX_VALUE_CHARS); + +impl Fact { + /// A known string fact, with the string length rule applied. + fn text(value: &str, source: FactSource, collected_by: &str, method: &str) -> Self { + Self::Known(KnownFact { + value: bounded_text(value), + origin: FactOrigin { + source, + collected_by: collected_by.to_string(), + method: method.to_string(), + }, + }) + } +} + +impl Fact { + fn origin(&self) -> &FactOrigin { + match self { + Self::Known(known) => &known.origin, + Self::Missing(missing) => &missing.origin, + } + } + + fn origin_mut(&mut self) -> &mut FactOrigin { + match self { + Self::Known(known) => &mut known.origin, + Self::Missing(missing) => &mut missing.origin, + } + } + + fn missing( + state: FactState, + reason: &str, + source: FactSource, + collected_by: &str, + method: &str, + ) -> Self { + Self::Missing(MissingFact { + state, + reason: reason.to_string(), + origin: FactOrigin { + source, + collected_by: collected_by.to_string(), + method: method.to_string(), + }, + }) + } + + /// An adapter-reported fact while core reads no adapter report key. + fn report_key_not_declared() -> Self { + Self::missing( + FactState::Unsupported, + REASON_KEY_NOT_DECLARED, + FactSource::Reported, + ADAPTER, + METHOD_ADAPTER_REPORT, + ) + } +} + +impl WhatRan { + /// The origin of every fact, so a host can relabel the facts a task + /// core collected. + fn origins_mut(&mut self) -> Vec<&mut FactOrigin> { + let Self { + fabric, + adapter, + harness, + models, + mcp, + .. + } = self; + let mut origins = Vec::new(); + if let Some(fabric) = fabric { + origins.extend([ + fabric.core_version.origin_mut(), + fabric.host_core_version.origin_mut(), + ]); + } + if let Some(adapter) = adapter { + origins.push(adapter.package_version.origin_mut()); + } + if let Some(harness) = harness { + origins.extend([harness.name.origin_mut(), harness.version.origin_mut()]); + } + for model in models.iter_mut().flat_map(BTreeMap::values_mut) { + origins.extend([model.requested.origin_mut(), model.served.origin_mut()]); + } + for server in mcp.iter_mut().flat_map(|mcp| mcp.servers.values_mut()) { + let runtime = &mut server.runtime; + origins.extend([ + server.endpoint.origin_mut(), + runtime.connected.origin_mut(), + runtime.protocol_version.origin_mut(), + runtime.server_name.origin_mut(), + runtime.server_version.origin_mut(), + runtime.tool_names.origin_mut(), + ]); + } + origins + } + + /// Keep the compact JSON of the whole object within 128 KiB. While it + /// is larger, drop the last MCP server in sorted key order, then the + /// last model role, counting each in its map's marker with reason + /// `byte_budget`. With neither left and the object still too large, + /// replace it with [`WhatRan::minimal`]. + fn apply_byte_budget(&mut self) { + while self.serialized_len() > MAX_WHAT_RAN_BYTES { + if let Some(mcp) = self.mcp.as_mut() + && mcp.servers.pop_last().is_some() + { + mcp.servers_truncated = Some(one_more_byte_budget_drop(&mcp.servers_truncated)); + } else if let Some(models) = self.models.as_mut() + && models.pop_last().is_some() + { + self.models_truncated = Some(one_more_byte_budget_drop(&self.models_truncated)); + } else { + *self = Self::minimal(); + return; + } + } + } + + /// Apply the entry caps and the key rule to an object another core + /// collected, adding what they drop to the existing markers. + fn apply_entry_caps(&mut self) { + if let Some(models) = self.models.take() { + let (kept, dropped) = bounded_entries(&models, MAX_MODEL_ROLES, ModelFacts::clone); + self.models = Some(kept); + self.models_truncated = merged_marker(self.models_truncated.take(), dropped); + } + if let Some(mcp) = self.mcp.as_mut() { + let (kept, dropped) = + bounded_entries(&mcp.servers, MAX_MCP_SERVERS, McpServerFacts::clone); + mcp.servers = kept; + mcp.servers_truncated = merged_marker(mcp.servers_truncated.take(), dropped); + } + } + + /// The object left when every section had to go: the schema name, the + /// version and a `what_ran_truncated` marker. + fn minimal() -> Self { + Self { + schema: WHAT_RAN_SCHEMA.to_string(), + version: WHAT_RAN_VERSION.to_string(), + fabric: None, + adapter: None, + harness: None, + models: None, + models_truncated: None, + mcp: None, + environment: None, + what_ran_truncated: Some(TruncatedEntries { + reason: REASON_BYTE_BUDGET.to_string(), + dropped: 1, + }), + } + } + + fn serialized_len(&self) -> usize { + serde_json::to_vec(self) + .expect("what_ran has only string keys and is always JSON serializable") + .len() + } + + /// Whether every `plan` MCP entry names its transport. An entry of any + /// other origin may leave it out. + fn plan_servers_have_transports(&self) -> bool { + self.mcp + .iter() + .flat_map(|mcp| mcp.servers.values()) + .all(|server| server.origin != ORIGIN_PLAN || server.transport.is_some()) + } + + /// Whether every section is present, or the object is a minimal one. + fn has_every_section_unless_truncated(&self) -> bool { + self.what_ran_truncated.is_some() + || (self.fabric.is_some() + && self.adapter.is_some() + && self.harness.is_some() + && self.models.is_some() + && self.mcp.is_some() + && self.environment.is_some()) + } +} + +/// Collect the facts the core that runs the adapter knows before it sends +/// the request: its own version and the plan's adapter ID, models, MCP +/// servers and environment provider. The model and MCP server maps follow +/// the entry caps and the byte budget, and collection never fails. +/// +/// Core reads no adapter report key yet, so every adapter-reported fact +/// reads `unsupported` with reason `key_not_declared`. The collecting core +/// runs the adapter in a local environment, so `host_core_version` reads +/// `not_applicable`. A host that relays the result from a task core calls +/// [`attribute_to_task_core`]. +pub(crate) fn collect_before_request( + plan: &RunPlan, + adapter_id: Option, + environment_provider: &str, +) -> WhatRan { + let (models, models_truncated) = + bounded_entries(&plan.config.models, MAX_MODEL_ROLES, model_facts); + let (servers, servers_truncated) = bounded_entries( + &plan.capability_plan.mcp_servers, + MAX_MCP_SERVERS, + mcp_server_facts, + ); + let mut what_ran = WhatRan { + schema: WHAT_RAN_SCHEMA.to_string(), + version: WHAT_RAN_VERSION.to_string(), + fabric: Some(FabricFacts { + core_version: core_version_fact(), + host_core_version: Fact::missing( + FactState::NotApplicable, + REASON_LOCAL_RUN, + FactSource::Observed, + HOST_CORE, + METHOD_CRATE_VERSION, + ), + }), + adapter: Some(AdapterFacts { + id: adapter_id, + package_version: Fact::report_key_not_declared(), + }), + harness: Some(HarnessFacts { + name: Fact::report_key_not_declared(), + version: Fact::report_key_not_declared(), + }), + models: Some(models), + models_truncated, + mcp: Some(McpFacts { + servers, + servers_truncated, + }), + environment: Some(EnvironmentFacts { + provider: environment_provider.to_string(), + }), + what_ran_truncated: None, + }; + what_ran.apply_byte_budget(); + what_ran +} + +/// The facts of at most `cap` entries, taken in sorted key order, and a +/// marker that counts the dropped entries. +/// +/// An entry whose key is longer than 128 characters is dropped and takes no +/// place under the cap. The marker reason is `entry_cap` when the cap +/// dropped an entry, and `key_too_long` when only long keys were dropped. +/// [`BTreeMap`] iteration gives the sorted key order, whatever order the +/// plan's entries were written in. +fn bounded_entries( + entries: &BTreeMap, + cap: usize, + facts: impl Fn(&P) -> F, +) -> (BTreeMap, Option) { + let mut kept = BTreeMap::new(); + let (mut key_too_long, mut over_cap) = (0_u64, 0_u64); + for (key, entry) in entries { + if key.chars().count() > MAX_KEY_CHARS { + key_too_long += 1; + } else if kept.len() == cap { + over_cap += 1; + } else { + kept.insert(key.clone(), facts(entry)); + } + } + let reason = if over_cap > 0 { + REASON_ENTRY_CAP + } else { + REASON_KEY_TOO_LONG + }; + let dropped = key_too_long + over_cap; + let truncated = (dropped > 0).then(|| TruncatedEntries { + reason: reason.to_string(), + dropped, + }); + (kept, truncated) +} + +/// `marker` with one more dropped entry and reason `byte_budget`. +fn one_more_byte_budget_drop(marker: &Option) -> TruncatedEntries { + TruncatedEntries { + reason: REASON_BYTE_BUDGET.to_string(), + dropped: marker + .as_ref() + .map_or(0, |marker| marker.dropped) + .saturating_add(1), + } +} + +/// An `earlier` marker followed by a `later` one: the later reason, and the +/// two counts added. A task core can send any count, so the sum saturates. +fn merged_marker( + earlier: Option, + later: Option, +) -> Option { + match (earlier, later) { + (Some(earlier), Some(later)) => Some(TruncatedEntries { + reason: later.reason, + dropped: earlier.dropped.saturating_add(later.dropped), + }), + (earlier, later) => later.or(earlier), + } +} + +fn core_version_fact() -> Fact { + Fact::text( + crate::version(), + FactSource::Observed, + HOST_CORE, + METHOD_CRATE_VERSION, + ) +} + +fn model_facts(model: &ModelConfig) -> ModelFacts { + ModelFacts { + provider: model.provider.clone(), + requested: Fact::text( + &model.model, + FactSource::Configured, + HOST_CORE, + METHOD_RESOLVED_PLAN, + ), + served: Fact::report_key_not_declared(), + } +} + +fn mcp_server_facts(server: &McpServerPlan) -> McpServerFacts { + McpServerFacts { + origin: ORIGIN_PLAN.to_string(), + transport: Some(server.transport.as_str().to_string()), + endpoint: configured_endpoint(server.transport, &server.url), + runtime: McpRuntimeFacts { + connected: Fact::report_key_not_declared(), + protocol_version: Fact::report_key_not_declared(), + server_name: Fact::report_key_not_declared(), + server_version: Fact::report_key_not_declared(), + tool_names: Fact::report_key_not_declared(), + }, + } +} + +/// The configured endpoint of one MCP server, without secrets. `url` holds +/// the server URL, or the command for a `stdio` server. +/// +/// An empty `url` reads `unavailable` with reason `not_configured`. A value +/// core cannot reduce to its safe form reads `collection_failed`, and the +/// raw value never enters the fact. +fn configured_endpoint(transport: McpTransport, url: &str) -> Fact { + let missing = |state, reason| { + Fact::missing( + state, + reason, + FactSource::Configured, + HOST_CORE, + METHOD_RESOLVED_PLAN, + ) + }; + if url.trim().is_empty() { + return missing(FactState::Unavailable, REASON_NOT_CONFIGURED); + } + let endpoint = match transport { + McpTransport::Stdio => executable_name(url).ok_or(REASON_EXECUTABLE_UNPARSED), + McpTransport::Sse | McpTransport::StreamableHttp => { + endpoint_identity(url).ok_or(REASON_ENDPOINT_UNPARSED) + } + }; + match endpoint { + Ok(endpoint) => Fact::text( + &endpoint, + FactSource::Configured, + HOST_CORE, + METHOD_RESOLVED_PLAN, + ), + Err(reason) => missing(FactState::CollectionFailed, reason), + } +} + +/// Attribute a task core's `what_ran` on the host that relayed its result. +/// +/// The task core runs in a projected `local` environment and labels its +/// own facts `host_core`. The host relabels them `task_core`, records its +/// own version as `host_core_version`, and replaces the environment +/// provider with its own, such as `openshell`. A minimal object stays +/// minimal. The task core may run another Fabric version, and the host's +/// own facts can grow the object, so the entry caps, the key rule and the +/// byte budget apply again. +pub(crate) fn attribute_to_task_core(what_ran: &mut WhatRan, environment_provider: &str) { + for origin in what_ran.origins_mut() { + if origin.collected_by == HOST_CORE { + origin.collected_by = TASK_CORE.to_string(); + } + } + if let Some(fabric) = what_ran.fabric.as_mut() { + fabric.host_core_version = core_version_fact(); + } + if let Some(environment) = what_ran.environment.as_mut() { + environment.provider = environment_provider.to_string(); + } + what_ran.apply_entry_caps(); + what_ran.apply_byte_budget(); +} + +/// `text` whole when it has at most 256 characters. A longer string keeps +/// its first 240 characters, then `~`, then the first 15 hex digits of the +/// whole string's SHA-256, so two long values collide only on a 60-bit +/// hash collision. Characters are Unicode scalar values, and the digest +/// covers the UTF-8 bytes. +fn bounded_text(text: &str) -> String { + if text.chars().count() <= MAX_VALUE_CHARS { + return text.to_string(); + } + let mut bounded: String = text.chars().take(TRUNCATED_PREFIX_CHARS).collect(); + bounded.push('~'); + let digest_hex: String = Sha256::digest(text.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + bounded.push_str(&digest_hex[..TRUNCATED_DIGEST_HEX_DIGITS]); + bounded +} + +/// Decode `RunResult.what_ran`, reading it as absent when it breaks the +/// schema or names another schema or version, so the rest of the result +/// still decodes. +pub(crate) fn deserialize_lenient<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + Ok(value + .and_then(|value| serde_json::from_value::(value).ok()) + .filter(|what_ran| { + what_ran.schema == WHAT_RAN_SCHEMA + && what_ran.version == WHAT_RAN_VERSION + && what_ran.has_every_section_unless_truncated() + && what_ran.plan_servers_have_transports() + })) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use serde_json::json; + + use super::*; + use crate::config::{ + FabricConfig, ResolveContext, resolve_run_plan_from_config_with_adapter_directories, + }; + + fn repository_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") + } + + fn resolve_plan(config: Value) -> RunPlan { + let root = repository_root(); + let config: FabricConfig = serde_json::from_value(config).expect("typed config"); + resolve_run_plan_from_config_with_adapter_directories( + config, + ResolveContext::new(&root), + &[root.join("adapters")], + ) + .expect("resolve plan") + } + + fn config_for(adapter_id: &str) -> Value { + json!({ + "schema_version": "fabric.agent/v1alpha1", + "metadata": {"name": "what-ran-agent"}, + "harness": {"adapter_id": adapter_id}, + "runtime": {} + }) + } + + /// A Claude Code plan whose MCP servers hold a secret in every place a + /// configured server can: URL user info, path, query and fragment, + /// headers, and a `stdio` command's folder, arguments and environment. + fn plan_with_secrets() -> RunPlan { + let mut config = config_for("nvidia.fabric.claude"); + config["models"] = json!({ + "default": {"provider": "anthropic", "model": "claude-sonnet-4-5"}, + }); + config["mcp"] = json!({"servers": { + "github": { + "transport": "streamable-http", + "url": "https://user:SECRET_PASSWORD@API.GitHubCopilot.com/mcp/SECRET_PATH/?token=SECRET_QUERY#SECRET_FRAGMENT", + "custom_headers": {"Authorization": "Bearer SECRET_HEADER"}, + "exposure": "harness_native" + }, + "events": { + "transport": "sse", + "url": "http://localhost:8931/sse", + "exposure": "harness_native" + }, + "files": { + "transport": "stdio", + "url": "/home/SECRET_USER/.venv/bin/files-mcp", + "args": ["--api-key", "SECRET_ARGUMENT"], + "env": {"FILES_TOKEN": "SECRET_ENVIRONMENT"}, + "exposure": "harness_native" + } + }}); + resolve_plan(config) + } + + fn collect(plan: &RunPlan) -> Value { + let adapter_id = plan + .adapter_descriptor + .as_ref() + .map(|adapter| adapter.descriptor.adapter_id.clone()); + serde_json::to_value(collect_before_request(plan, adapter_id, "local")) + .expect("serialize what_ran") + } + + fn not_declared() -> Value { + json!({"state": "unsupported", "reason": "key_not_declared", "source": "reported", + "collected_by": "adapter", "method": "adapter_report"}) + } + + fn configured(value: &str) -> Value { + json!({"value": value, "source": "configured", "collected_by": "host_core", + "method": "resolved_plan"}) + } + + fn mcp_server(transport: &str, endpoint: Value) -> Value { + let missing = not_declared(); + json!({ + "origin": "plan", + "transport": transport, + "endpoint": endpoint, + "runtime": { + "connected": missing, + "protocol_version": missing, + "server_name": missing, + "server_version": missing, + "tool_names": missing + } + }) + } + + /// Every JSON object that carries `source` is a fact. + fn facts(value: &Value) -> Vec<&serde_json::Map> { + match value { + Value::Object(map) if map.contains_key("source") => vec![map], + Value::Object(map) => map.values().flat_map(facts).collect(), + _ => Vec::new(), + } + } + + #[test] + fn collected_facts_read_the_plan_and_mark_adapter_facts_key_not_declared() { + let what_ran = collect(&plan_with_secrets()); + let not_declared = not_declared(); + + assert_eq!( + what_ran, + json!({ + "schema": "nemo.fabric.what_ran", + "version": "v1alpha1", + "fabric": { + "core_version": {"value": crate::version(), "source": "observed", + "collected_by": "host_core", "method": "crate_version"}, + "host_core_version": {"state": "not_applicable", "reason": "local_run", + "source": "observed", "collected_by": "host_core", + "method": "crate_version"} + }, + "adapter": {"id": "nvidia.fabric.claude", "package_version": not_declared}, + "harness": {"name": not_declared, "version": not_declared}, + "models": {"default": { + "provider": "anthropic", + "requested": configured("claude-sonnet-4-5"), + "served": not_declared + }}, + "mcp": {"servers": { + "events": mcp_server("sse", configured("http://localhost:8931")), + "files": mcp_server("stdio", configured("files-mcp")), + "github": mcp_server( + "streamable-http", + configured("https://api.githubcopilot.com:443"), + ) + }}, + "environment": {"provider": "local"} + }) + ); + } + + #[test] + fn collected_facts_hold_no_configured_secret() { + let text = serde_json::to_string(&collect(&plan_with_secrets())).expect("JSON"); + + assert!(!text.contains("SECRET"), "{text}"); + } + + #[test] + fn every_fact_has_a_value_or_a_state_with_a_reason_never_both() { + let what_ran = collect(&plan_with_secrets()); + let facts = facts(&what_ran); + + // 2 fabric + 1 adapter + 2 harness + 2 model + 3 servers x (endpoint + 5 runtime). + assert_eq!(facts.len(), 25); + for fact in facts { + assert_ne!( + fact.contains_key("value"), + fact.contains_key("state"), + "{fact:?}" + ); + assert_eq!( + fact.contains_key("state"), + fact.contains_key("reason"), + "{fact:?}" + ); + for key in ["collected_by", "method"] { + assert!(fact.get(key).is_some_and(Value::is_string), "{fact:?}"); + } + } + } + + /// `plan_with_secrets` with `models` and MCP servers under the given + /// keys, each a copy of its `default` model or its `files` server. + fn plan_with_entries(model_roles: &[String], server_names: &[String]) -> RunPlan { + let mut plan = plan_with_secrets(); + let model = plan.config.models["default"].clone(); + let server = plan.capability_plan.mcp_servers["files"].clone(); + plan.config.models = model_roles + .iter() + .map(|role| (role.clone(), model.clone())) + .collect(); + plan.capability_plan.mcp_servers = server_names + .iter() + .map(|name| (name.clone(), server.clone())) + .collect(); + plan + } + + fn numbered_keys(prefix: &str, count: usize) -> Vec { + (0..count) + .map(|index| format!("{prefix}-{index:02}")) + .collect() + } + + fn map_keys(map: &Value) -> Vec { + map.as_object().expect("map").keys().cloned().collect() + } + + #[test] + fn models_past_16_roles_keep_the_first_in_sorted_order_and_count_the_rest() { + let mut roles = numbered_keys("role", 17); + roles.reverse(); + + let what_ran = collect(&plan_with_entries(&roles, &[])); + + assert_eq!(map_keys(&what_ran["models"]), numbered_keys("role", 16)); + assert_eq!( + what_ran["models_truncated"], + json!({"reason": "entry_cap", "dropped": 1}) + ); + assert!(what_ran["mcp"].get("servers_truncated").is_none()); + assert!(what_ran_validator().is_valid(&what_ran), "{what_ran}"); + } + + #[test] + fn mcp_servers_past_32_keep_the_first_in_sorted_order_and_count_the_rest() { + let what_ran = collect(&plan_with_entries(&[], &numbered_keys("server", 35))); + + assert_eq!( + map_keys(&what_ran["mcp"]["servers"]), + numbered_keys("server", 32) + ); + assert_eq!( + what_ran["mcp"]["servers_truncated"], + json!({"reason": "entry_cap", "dropped": 3}) + ); + assert!(what_ran.get("models_truncated").is_none()); + assert!(what_ran_validator().is_valid(&what_ran), "{what_ran}"); + } + + #[test] + fn keys_longer_than_128_characters_are_dropped_without_taking_a_place() { + let longest = "é".repeat(128); + let too_long = "é".repeat(129); + let mut roles = numbered_keys("role", 15); + roles.extend([longest.clone(), too_long.clone()]); + let mut expected_roles = numbered_keys("role", 15); + expected_roles.push(longest.clone()); + + let what_ran = collect(&plan_with_entries(&roles, &[longest, too_long])); + + assert_eq!(map_keys(&what_ran["models"]), expected_roles); + assert_eq!( + what_ran["models_truncated"], + json!({"reason": "key_too_long", "dropped": 1}) + ); + assert_eq!(map_keys(&what_ran["mcp"]["servers"]), ["é".repeat(128)]); + assert_eq!( + what_ran["mcp"]["servers_truncated"], + json!({"reason": "key_too_long", "dropped": 1}) + ); + assert!(what_ran_validator().is_valid(&what_ran), "{what_ran}"); + } + + #[test] + fn a_long_key_and_the_cap_together_count_every_dropped_entry_as_entry_cap() { + let mut roles = numbered_keys("role", 17); + roles.push("r".repeat(129)); + + let what_ran = collect(&plan_with_entries(&roles, &[])); + + assert_eq!(map_keys(&what_ran["models"]), numbered_keys("role", 16)); + assert_eq!( + what_ran["models_truncated"], + json!({"reason": "entry_cap", "dropped": 2}) + ); + } + + #[test] + fn retained_entries_do_not_depend_on_the_order_the_plan_lists_them() { + let roles = numbered_keys("role", 20); + let servers = numbered_keys("server", 40); + let mut reversed_roles = roles.clone(); + reversed_roles.reverse(); + let mut reversed_servers = servers.clone(); + reversed_servers.reverse(); + + assert_eq!( + collect(&plan_with_entries(&roles, &servers)), + collect(&plan_with_entries(&reversed_roles, &reversed_servers)) + ); + } + + /// A plan whose 16 model providers take most of the 128 KiB budget. + fn plan_near_the_byte_budget(server_names: &[String]) -> RunPlan { + let mut plan = plan_with_entries(&numbered_keys("role", 16), server_names); + for model in plan.config.models.values_mut() { + model.provider = "p".repeat(7_000); + } + plan + } + + fn json_len(value: &Value) -> usize { + serde_json::to_vec(value).expect("JSON").len() + } + + #[test] + fn mcp_servers_past_the_byte_budget_are_dropped_from_the_end_of_sorted_order() { + let servers = numbered_keys("server", 32); + + let what_ran = collect(&plan_near_the_byte_budget(&servers)); + + assert!(json_len(&what_ran) <= 128 * 1024, "{}", json_len(&what_ran)); + let kept = map_keys(&what_ran["mcp"]["servers"]); + assert!(!kept.is_empty() && kept.len() < servers.len(), "{kept:?}"); + assert_eq!(kept, servers[..kept.len()]); + assert_eq!( + what_ran["mcp"]["servers_truncated"], + json!({"reason": "byte_budget", "dropped": servers.len() - kept.len()}) + ); + assert_eq!(map_keys(&what_ran["models"]).len(), 16); + assert!(what_ran_validator().is_valid(&what_ran)); + + // Keeping the next server in sorted order would exceed the budget. + let full = collect(&plan_with_entries(&[], &servers)); + let mut one_more = what_ran.clone(); + let next = &servers[kept.len()]; + one_more["mcp"]["servers"][next] = full["mcp"]["servers"][next].clone(); + assert!(json_len(&one_more) > 128 * 1024); + } + + #[test] + fn the_byte_budget_counts_servers_the_entry_cap_already_dropped() { + let within_cap = collect(&plan_near_the_byte_budget(&numbered_keys("server", 32))); + let budget_dropped = within_cap["mcp"]["servers_truncated"]["dropped"] + .as_u64() + .expect("dropped count"); + + let what_ran = collect(&plan_near_the_byte_budget(&numbered_keys("server", 35))); + + assert_eq!(what_ran["mcp"]["servers"], within_cap["mcp"]["servers"]); + assert_eq!( + what_ran["mcp"]["servers_truncated"], + json!({"reason": "byte_budget", "dropped": budget_dropped + 3}) + ); + } + + /// A plan with the given model roles and MCP servers whose providers + /// together exceed the 128 KiB budget. + fn plan_over_the_byte_budget(roles: &[String], server_names: &[String]) -> RunPlan { + let mut plan = plan_with_entries(roles, server_names); + for model in plan.config.models.values_mut() { + model.provider = "p".repeat(9_000); + } + plan + } + + #[test] + fn model_roles_are_dropped_for_the_byte_budget_after_every_mcp_server() { + let roles = numbered_keys("role", 18); + let servers = numbered_keys("server", 3); + + let what_ran = collect(&plan_over_the_byte_budget(&roles, &servers)); + + assert!(json_len(&what_ran) <= 128 * 1024, "{}", json_len(&what_ran)); + assert_eq!(what_ran["mcp"]["servers"], json!({})); + assert_eq!( + what_ran["mcp"]["servers_truncated"], + json!({"reason": "byte_budget", "dropped": 3}) + ); + let kept = map_keys(&what_ran["models"]); + assert!(!kept.is_empty() && kept.len() < 16, "{kept:?}"); + assert_eq!(kept, roles[..kept.len()]); + // Two roles past the cap, then every role the budget dropped. + assert_eq!( + what_ran["models_truncated"], + json!({"reason": "byte_budget", "dropped": roles.len() - kept.len()}) + ); + assert!(what_ran_validator().is_valid(&what_ran)); + + // Keeping the next role in sorted order would exceed the budget. + let full = collect(&plan_with_entries(&roles, &[])); + let mut one_more = what_ran.clone(); + let next = &roles[kept.len()]; + one_more["models"][next] = full["models"][next].clone(); + one_more["models"][next]["provider"] = json!("p".repeat(9_000)); + assert!(json_len(&one_more) > 128 * 1024); + } + + #[test] + fn an_object_over_the_byte_budget_without_maps_to_drop_becomes_minimal() { + let plan = plan_with_entries(&numbered_keys("role", 2), &numbered_keys("server", 2)); + let adapter_id = "a".repeat(140_000); + + let what_ran = + serde_json::to_value(collect_before_request(&plan, Some(adapter_id), "local")) + .expect("serialize what_ran"); + + assert_eq!( + what_ran, + json!({ + "schema": "nemo.fabric.what_ran", + "version": "v1alpha1", + "what_ran_truncated": {"reason": "byte_budget", "dropped": 1} + }) + ); + assert!(what_ran_validator().is_valid(&what_ran)); + let decoded = run_result_with(what_ran) + .what_ran + .expect("a minimal what_ran reads as present"); + assert_eq!(decoded.fabric, None); + } + + #[test] + fn plan_without_models_or_mcp_servers_reads_empty_maps() { + let what_ran = collect(&resolve_plan(config_for("nvidia.fabric.claude"))); + + assert_eq!(what_ran["models"], json!({})); + assert_eq!(what_ran["mcp"], json!({"servers": {}})); + } + + /// Every adapter-reported fact in `what_ran`, by its JSON path. + fn reported_facts(value: &Value, path: &str) -> Vec<(String, Value)> { + match value { + Value::Object(map) if map.get("source") == Some(&json!("reported")) => { + vec![(path.to_string(), value.clone())] + } + Value::Object(map) => map + .iter() + .flat_map(|(key, value)| reported_facts(value, &format!("{path}.{key}"))) + .collect(), + _ => Vec::new(), + } + } + + #[test] + fn a_plan_from_each_bundled_descriptor_collects_plan_facts_and_undeclared_adapter_facts() { + let catalog_path = repository_root().join( + "sdk/python/nemo-fabric-adapter-catalog/src/nemo_fabric_adapter_catalog/catalog.json", + ); + let catalog: Value = + serde_json::from_str(&std::fs::read_to_string(catalog_path).expect("read catalog")) + .expect("catalog JSON"); + let adapters = catalog["adapters"].as_object().expect("catalog adapters"); + assert!(!adapters.is_empty()); + let validator = what_ran_validator(); + for (adapter_id, entry) in adapters { + let descriptor = &entry["descriptor"]; + let mut config = config_for(adapter_id); + for setting in descriptor["settings_schema"]["required"] + .as_array() + .into_iter() + .flatten() + { + let setting = setting.as_str().expect("setting name"); + config["harness"]["settings"][setting] = json!("https://agent.example.com"); + } + let accepts = |field: &str| { + descriptor["config"]["accepts"] + .as_array() + .is_some_and(|accepts| accepts.contains(&json!(field))) + }; + // Use the first model alternative when the adapter offers choices, + // its fixed provider, and every connection field the schema names. + let model_schema = &descriptor["model_schema"]; + let model_schema = model_schema["oneOf"] + .as_array() + .and_then(|alternatives| alternatives.first()) + .unwrap_or(model_schema); + let model_fields = &model_schema["properties"]; + let provider = model_fields["provider"]["const"] + .as_str() + .unwrap_or("nvidia"); + config["models"] = + json!({"default": {"provider": provider, "model": "nvidia/nemotron"}}); + for (field, value) in [ + ("base_url", "https://inference.example.com/v1"), + ("api_key_env", "MODEL_API_KEY"), + ] { + if model_fields.get(field).is_some() { + config["models"]["default"][field] = model_fields[field] + .get("const") + .cloned() + .unwrap_or_else(|| json!(value)); + } + } + let accepts_mcp = accepts("mcp"); + if accepts_mcp { + config["mcp"] = json!({"servers": { + "events": {"transport": "sse", "url": "http://localhost:8931/sse", + "exposure": "harness_native"}, + "filesystem": {"transport": "stdio", "url": "npx", + "args": ["-y", "@scope/server-filesystem"], + "exposure": "harness_native"}, + "search": {"transport": "streamable-http", "url": "https://mcp.example.com/mcp", + "exposure": "harness_native"} + }}); + } + + // Core discovers the repository's adapters as its bundled set. + let config: FabricConfig = serde_json::from_value(config).expect("typed config"); + let plan = crate::config::resolve_run_plan_from_config( + config, + ResolveContext::new(repository_root()), + ) + .unwrap_or_else(|error| panic!("{adapter_id}: {error}")); + let what_ran = collect(&plan); + + assert!(validator.is_valid(&what_ran), "{adapter_id}: {what_ran}"); + assert_eq!(what_ran["adapter"]["id"], json!(adapter_id)); + assert_eq!( + what_ran["fabric"]["core_version"]["value"], + crate::version() + ); + assert_eq!( + what_ran["models"]["default"]["requested"], + configured("nvidia/nemotron") + ); + let servers = if accepts_mcp { 3 } else { 0 }; + assert_eq!( + map_keys(&what_ran["mcp"]["servers"]).len(), + servers, + "{adapter_id}" + ); + // package version, harness name and version, served, 5 per server. + let reported = reported_facts(&what_ran, "what_ran"); + assert_eq!(reported.len(), 4 + 5 * servers, "{adapter_id}"); + for (path, fact) in reported { + assert_eq!(fact, not_declared(), "{adapter_id} {path}"); + } + for marker in [ + &what_ran["models_truncated"], + &what_ran["mcp"]["servers_truncated"], + &what_ran["what_ran_truncated"], + ] { + assert!(marker.is_null(), "{adapter_id}: {what_ran}"); + } + assert!(json_len(&what_ran) <= 128 * 1024, "{adapter_id}"); + } + } + + #[test] + fn both_entry_caps_with_max_length_ascii_keys_and_models_fit_the_byte_budget() { + let longest_key = |prefix: &str, index: usize| { + let key = format!("{prefix}-{index:02}-"); + format!("{key}{}", "k".repeat(128 - key.len())) + }; + let roles: Vec = (0..16).map(|index| longest_key("role", index)).collect(); + let servers: Vec = (0..32).map(|index| longest_key("server", index)).collect(); + let mut plan = plan_with_entries(&roles, &servers); + for model in plan.config.models.values_mut() { + model.model = "m".repeat(256); + } + + let what_ran = collect(&plan); + + assert_eq!(map_keys(&what_ran["models"]), roles); + assert_eq!(map_keys(&what_ran["mcp"]["servers"]), servers); + assert!(what_ran.get("models_truncated").is_none(), "{what_ran}"); + assert!( + what_ran["mcp"].get("servers_truncated").is_none(), + "{what_ran}" + ); + assert!(json_len(&what_ran) <= 128 * 1024, "{}", json_len(&what_ran)); + } + + #[test] + fn stdio_executable_with_whitespace_reads_unparsed_and_keeps_no_secret() { + let mut config = config_for("nvidia.fabric.claude"); + config["mcp"] = json!({"servers": { + "arguments": { + "transport": "stdio", + "url": "npx --token a/SECRET", + "exposure": "harness_native" + }, + "spaced": { + "transport": "stdio", + "url": "/opt/My Tools/db-server", + "exposure": "harness_native" + } + }}); + + let what_ran = collect(&resolve_plan(config)); + + let unparsed = json!({"state": "collection_failed", "reason": "executable_unparsed", + "source": "configured", "collected_by": "host_core", "method": "resolved_plan"}); + for name in ["arguments", "spaced"] { + assert_eq!( + what_ran["mcp"]["servers"][name]["endpoint"], unparsed, + "{name}" + ); + } + let text = serde_json::to_string(&what_ran).expect("JSON"); + assert!(!text.contains("SECRET"), "{text}"); + } + + #[test] + fn unparsed_or_empty_endpoint_reads_a_state_without_the_raw_value() { + for (transport, url, state, reason) in [ + ( + McpTransport::StreamableHttp, + "https://u:p/SECRET@host/x", + "collection_failed", + "endpoint_unparsed", + ), + ( + McpTransport::Stdio, + "TOKEN=prefix/SECRET", + "collection_failed", + "executable_unparsed", + ), + // Placeholders a harness expands later, not a file name. + ( + McpTransport::Stdio, + "$MCP_COMMAND", + "collection_failed", + "executable_unparsed", + ), + ( + McpTransport::Stdio, + "/opt/mcp/server-{env:MCP_VARIANT}", + "collection_failed", + "executable_unparsed", + ), + (McpTransport::Sse, " ", "unavailable", "not_configured"), + (McpTransport::Stdio, "", "unavailable", "not_configured"), + ] { + let endpoint = serde_json::to_value(configured_endpoint(transport, url)) + .expect("serialize endpoint"); + + assert_eq!( + endpoint, + json!({"state": state, "reason": reason, "source": "configured", + "collected_by": "host_core", "method": "resolved_plan"}), + "{url}" + ); + } + } + + #[test] + fn string_values_longer_than_256_characters_end_with_a_digest_prefix() { + let exact = "é".repeat(256); + assert_eq!(bounded_text(&exact), exact); + + // Expected digests: the first 15 hex digits of `sha256sum` over the + // whole value's UTF-8 bytes. + for (long, prefix, digest) in [ + (format!("{}a", "m".repeat(300)), "m", "96858eedeb3c8e6"), + ("é".repeat(300), "é", "7250b66610f8b7d"), + ] { + let bounded = bounded_text(&long); + + assert_eq!(bounded, format!("{}~{digest}", prefix.repeat(240))); + assert_eq!(bounded.chars().count(), 256); + } + } + + fn run_result_with(what_ran: Value) -> crate::RunResult { + serde_json::from_value(json!({ + "agent_name": "agent", "harness": "nvidia.fabric.codex", "adapter_kind": "python", + "runtime_id": "runtime-1", "invocation_id": "invocation-1", + "request_id": "request-1", "status": "succeeded", "what_ran": what_ran + })) + .expect("the result decodes whatever what_ran holds") + } + + /// The reader vectors the Python SDK tests share, with each case's + /// patch applied to the base object. + fn reader_vectors(group: &str) -> Vec<(String, Value)> { + let vectors: Value = serde_json::from_str(include_str!( + "../../../tests/fixtures/what_ran/reader-vectors.json" + )) + .expect("reader vectors JSON"); + vectors[group] + .as_array() + .expect("vector group") + .iter() + .map(|case| { + let mut what_ran = vectors["base"].clone(); + for operation in case["patch"].as_array().expect("patch") { + apply_patch(&mut what_ran, operation); + } + (case["name"].as_str().expect("name").to_string(), what_ran) + }) + .collect() + } + + fn apply_patch(target: &mut Value, operation: &Value) { + let path: Vec<&str> = operation["path"] + .as_array() + .expect("path") + .iter() + .map(|key| key.as_str().expect("key")) + .collect(); + let Some((last, parents)) = path.split_last() else { + *target = operation["value"].clone(); + return; + }; + let parent = parents + .iter() + .fold(target, |value, key| &mut value[*key]) + .as_object_mut() + .expect("patch parent is an object"); + if operation.get("remove").is_some() { + parent.remove(*last); + } else { + parent.insert((*last).to_string(), operation["value"].clone()); + } + } + + fn what_ran_validator() -> jsonschema::Validator { + let schema = crate::schema::generate_schema(crate::schema::SchemaName::RunResult) + .expect("run result schema"); + let mut what_ran_schema = schema["properties"]["what_ran"].clone(); + what_ran_schema["$defs"] = schema["$defs"].clone(); + jsonschema::validator_for(&what_ran_schema).expect("valid what_ran schema") + } + + #[test] + fn reader_and_schema_keep_every_valid_vector() { + let validator = what_ran_validator(); + for (name, what_ran) in reader_vectors("valid") { + assert!(validator.is_valid(&what_ran), "{name}"); + let decoded = run_result_with(what_ran.clone()).what_ran; + assert!(decoded.is_some(), "{name}"); + } + } + + #[test] + fn reader_reads_every_invalid_vector_as_absent_and_schema_rejects_it() { + let validator = what_ran_validator(); + for (name, what_ran) in reader_vectors("invalid") { + assert!(!validator.is_valid(&what_ran), "{name}"); + assert_eq!(run_result_with(what_ran).what_ran, None, "{name}"); + } + assert_eq!(run_result_with(Value::Null).what_ran, None); + } + + #[test] + fn collected_facts_match_the_schema() { + let what_ran = collect(&plan_with_secrets()); + + assert!(what_ran_validator().is_valid(&what_ran), "{what_ran}"); + } + + #[test] + fn run_result_accepts_unknown_properties_and_open_codes() { + let (_, newer) = reader_vectors("valid") + .into_iter() + .find(|(name, _)| name == "unknown properties and vendor codes") + .expect("vector"); + + let what_ran = run_result_with(newer) + .what_ran + .expect("unknown properties never fail what_ran"); + + assert_eq!( + what_ran + .fabric + .expect("fabric section") + .core_version + .origin() + .collected_by, + "acme.collector" + ); + } +} diff --git a/crates/fabric-core/tests/model_usage.rs b/crates/fabric-core/tests/model_usage.rs new file mode 100644 index 000000000..ae0c6f0be --- /dev/null +++ b/crates/fabric-core/tests/model_usage.rs @@ -0,0 +1,763 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Reduced public accounting and honest partial pricing. + +use nemo_fabric_core::AgentUsage; +use nemo_fabric_core::model_usage::normalize_model_usage; +use nemo_fabric_core::price_catalog::UserPriceCatalog; +use serde_json::{Value, json}; + +fn normalize(base: Value, accounting: Value) -> Value { + let mut usage: AgentUsage = serde_json::from_value(base).unwrap(); + usage + .extensions + .insert("nemo_fabric.usage_accounting.v1".into(), accounting); + serde_json::to_value(normalize_model_usage(&usage, None, false).unwrap().unwrap()).unwrap() +} + +#[test] +fn shared_source_vectors_keep_the_reduced_complete_and_observed_contract() { + let cases: Vec = serde_json::from_str(include_str!( + "../../../tests/fixtures/usage_accounting/conformance.json" + )) + .unwrap(); + let dimensions = [ + "input_tokens", + "cached_input_tokens", + "cache_write_input_tokens", + "output_tokens", + "total_tokens", + ]; + for case in cases { + let result = normalize(case["usage"].clone(), case["accounting"].clone()); + for (field, expected) in case["expected"].as_object().unwrap() { + if dimensions.contains(&field.as_str()) { + assert_eq!( + result[field], expected["complete"], + "{} {field}", + case["name"] + ); + assert_eq!( + result["observed"][field], expected["observed"], + "{} {field}", + case["name"] + ); + } + } + assert!(result.get("accounting").is_none()); + assert!(result.get("reasoning_tokens").is_none()); + assert!(result.get("peak_request_input_tokens").is_none()); + assert!(result["input_tokens"].is_number() || result["input_tokens"].is_null()); + } +} + +#[test] +fn legacy_usage_does_not_gain_a_completeness_guarantee() { + let usage: AgentUsage = serde_json::from_value(json!({"input_tokens": 100})).unwrap(); + assert!( + normalize_model_usage(&usage, None, false) + .unwrap() + .is_none() + ); +} + +#[test] +fn normalized_total_does_not_replace_the_independent_raw_provider_total() { + let mut usage: AgentUsage = serde_json::from_value(json!({ + "input_tokens": 100, "input_tokens_include_cache": true, + "output_tokens": 5, "total_tokens": 999, + "extensions": {"nemo_fabric.detailed_token_usage.v1": {"output_tokens": 5, "output_tokens_include_reasoning": true}} + })).unwrap(); + usage.extensions.insert( + "nemo_fabric.usage_accounting.v1".into(), + json!({"models_exhaustive": false, "gaps": []}), + ); + let normalized = normalize_model_usage(&usage, None, false).unwrap().unwrap(); + assert_eq!(normalized.tokens.total_tokens, Some(105)); + assert_eq!(usage.total_tokens, Some(999)); +} + +fn catalog(provider: &str) -> UserPriceCatalog { + catalog_with_cache_rates(provider, 0.1, 1.25) +} + +fn catalog_with_cache_rates(provider: &str, read: f64, write: f64) -> UserPriceCatalog { + UserPriceCatalog::from_bytes("test-catalog.json".into(), &serde_json::to_vec(&json!({ + "version": 1, "entries": [{"provider": provider, "model_id": "model", "currency": "USD", "unit": "per_token", + "rates": {"input_per_million": 1.0, "output_per_million": 2.0, "cache_read_per_million": read, "cache_write_per_million": write}, + "prompt_cache": {"read_accounting": "included_in_prompt_tokens"}, + "pricing_as_of": "2026-10-21", "pricing_source": "test fixture"}] + })).unwrap()).unwrap() +} + +fn priced(provider: &str, counts: Value) -> nemo_fabric_core::model_usage::ModelUsage { + let mut usage = AgentUsage::default(); + usage.extensions.insert( + "nemo_fabric.usage_accounting.v1".into(), + json!({ + "models_exhaustive": true, "gaps": [], "models": [{ + "model": "model", "provider": provider, "reported": counts, "gaps": []}] + }), + ); + normalize_model_usage(&usage, Some(&catalog(provider)), false) + .unwrap() + .unwrap() +} + +#[test] +fn nonzero_anthropic_writes_without_tariff_keep_tokens_and_only_observed_cost() { + let result = priced( + "anthropic", + json!({ + "input_tokens": 100, "cached_input_tokens": 20, "cache_write_input_tokens": 10, + "input_tokens_include_cache": true, "output_tokens": 5, "output_tokens_include_reasoning": true + }), + ); + assert_eq!(result.tokens.input_tokens, Some(100)); + assert_eq!(result.tokens.total_tokens, Some(105)); + assert_eq!(result.cost.estimated.amount_nano_usd, None); + assert_eq!(result.cost.estimated.observed_nano_usd, Some(82_000)); + assert!( + result.models[0] + .gaps + .iter() + .any(|gap| gap.code.as_deref() == Some("cache_write_tariff_unverified")) + ); +} + +#[test] +fn zero_anthropic_writes_need_no_expiry_evidence() { + let result = priced( + "anthropic", + json!({ + "input_tokens": 100, "cached_input_tokens": 20, "cache_write_input_tokens": 0, + "input_tokens_include_cache": true, "output_tokens": 5, "output_tokens_include_reasoning": true + }), + ); + assert_eq!(result.cost.estimated.amount_nano_usd, Some(92_000)); + assert_eq!(result.cost.estimated.observed_nano_usd, Some(92_000)); +} + +#[test] +fn output_only_observations_are_priceable_without_claiming_complete_spend() { + let result = priced( + "openai", + json!({"output_tokens": 20, "output_tokens_include_reasoning": true}), + ); + assert_eq!(result.tokens.output_tokens, Some(20)); + assert_eq!(result.tokens.input_tokens, None); + assert_eq!(result.cost.estimated.amount_nano_usd, None); + assert_eq!(result.cost.estimated.observed_nano_usd, Some(40_000)); +} + +#[test] +fn overflow_does_not_erase_independent_valid_counts() { + let result = normalize( + json!({}), + json!({"models_exhaustive": true, "gaps": [], "models": [{ + "model": "x", "gaps": [], "reported": {"input_tokens": u64::MAX, "input_tokens_include_cache": true, + "output_tokens": 1, "output_tokens_include_reasoning": true}}]}), + ); + assert_eq!(result["input_tokens"], json!(u64::MAX)); + assert_eq!(result["output_tokens"], 1); + assert!(result["total_tokens"].is_null()); +} + +#[test] +fn equal_catalog_rates_do_not_prove_an_unknown_anthropic_write_tariff() { + let mut usage = AgentUsage::default(); + usage.extensions.insert("nemo_fabric.usage_accounting.v1".into(), json!({ + "models_exhaustive": true, "gaps": [], "models": [{"model": "model", "provider": "anthropic", "gaps": [], + "reported": {"input_tokens": 100, "cached_input_tokens": 20, "cache_write_input_tokens": 10, + "input_tokens_include_cache": true, "output_tokens": 5, "output_tokens_include_reasoning": true}}]})); + let result = normalize_model_usage( + &usage, + Some(&catalog_with_cache_rates("anthropic", 1.0, 1.0)), + false, + ) + .unwrap() + .unwrap(); + assert_eq!(result.cost.estimated.amount_nano_usd, None); + // Uncached input 70 + reads 20 + output 5*2; writes remain unpriced. + assert_eq!(result.cost.estimated.observed_nano_usd, Some(100_000)); +} + +#[test] +fn explicit_provider_never_uses_another_providers_catalog_rate() { + let mut usage = AgentUsage::default(); + usage.extensions.insert( + "nemo_fabric.usage_accounting.v1".into(), + json!({"models_exhaustive": true, "gaps": [], "models": [{ + "model": "model", "provider": "custom-route", "gaps": [], + "reported": {"input_tokens": 100, "cached_input_tokens": 0, + "input_tokens_include_cache": true, "output_tokens": 5, + "output_tokens_include_reasoning": true} + }]}), + ); + let result = normalize_model_usage(&usage, Some(&catalog("openai")), false) + .unwrap() + .unwrap(); + assert_eq!(result.tokens.input_tokens, Some(100)); + assert_eq!(result.tokens.total_tokens, Some(105)); + assert_eq!(result.cost.estimated.amount_nano_usd, None); + assert_eq!(result.cost.estimated.observed_nano_usd, None); + assert!( + result.models[0] + .gaps + .iter() + .any(|gap| gap.code.as_deref() == Some("model_not_in_catalog")) + ); +} + +#[test] +fn unverified_route_cannot_use_native_prices_for_a_bundled_model() { + let mut usage = AgentUsage::default(); + usage.extensions.insert( + "nemo_fabric.usage_accounting.v1".into(), + json!({"models_exhaustive": true, "gaps": [], "models": [{ + "model": "gpt-5.4", "gaps": [], + "reported": {"input_tokens": 100, "cached_input_tokens": 0, + "input_tokens_include_cache": true, "output_tokens": 5, + "output_tokens_include_reasoning": true} + }]}), + ); + // Global permission for bundled prices cannot establish an individual route. + let custom = normalize_model_usage(&usage, None, true).unwrap().unwrap(); + assert_eq!(custom.tokens.input_tokens, Some(100)); + assert_eq!(custom.tokens.total_tokens, Some(105)); + assert_eq!(custom.cost.estimated.amount_nano_usd, None); + assert_eq!(custom.cost.estimated.observed_nano_usd, None); + assert!( + custom.models[0] + .gaps + .iter() + .any(|gap| gap.code.as_deref() == Some("provider_price_unverified")) + ); + + // The same bundled model is priceable when the source establishes OpenAI. + usage + .extensions + .get_mut("nemo_fabric.usage_accounting.v1") + .unwrap()["models"][0]["provider"] = json!("openai"); + let native = normalize_model_usage(&usage, None, true).unwrap().unwrap(); + assert!(native.cost.estimated.amount_nano_usd.is_some()); + assert_eq!(native.tokens.total_tokens, Some(105)); +} + +fn cost_with_requests(aggregate: f64, requests: &[Option], exhaustive: bool) -> AgentUsage { + let counts = json!({"input_tokens": 100, "input_tokens_include_cache": true, + "output_tokens": 5, "output_tokens_include_reasoning": true, "cost_usd": aggregate}); + let mut usage = AgentUsage { + input_tokens: Some(100), + input_tokens_include_cache: Some(true), + output_tokens: Some(5), + cost_usd: Some(aggregate), + ..Default::default() + }; + usage + .extensions + .insert("nemo_fabric.detailed_token_usage.v1".into(), counts.clone()); + usage.extensions.insert("nemo_fabric.usage_accounting.v1".into(), json!({ + "models_exhaustive": true, "gaps": [], "models": [{ + "provider": "test", "model": "test-model", "gaps": [], "reported": counts, + "requests_exhaustive": exhaustive, + "requests": requests.iter().map(|cost| json!({"usage": {"cost_usd": cost}})).collect::>() + }] + })); + usage +} + +#[test] +fn contradictory_request_reported_costs_are_rejected() { + for (requests, exhaustive) in [ + (vec![Some(0.2)], true), + (vec![Some(0.2)], false), + (vec![Some(0.04)], true), + (vec![Some(10_000_000_000.0); 2], true), + ] { + let usage = cost_with_requests(0.1, &requests, exhaustive); + assert!( + normalize_model_usage(&usage, None, false).is_err(), + "contradictory cost must not become complete: {requests:?}, exhaustive={exhaustive}" + ); + } +} + +#[test] +fn independently_rounded_request_costs_keep_fractional_nanos_and_large_values() { + for (aggregate, requests) in [ + (1.6e-9, vec![Some(0.4e-9); 4]), + ( + 200_000_000.72337076, + vec![Some(100_000_000.65093447), Some(100_000_000.07243629)], + ), + ] { + let usage = cost_with_requests(aggregate, &requests, true); + let result = normalize_model_usage(&usage, None, false).unwrap().unwrap(); + assert_eq!(result.tokens.input_tokens, Some(100)); + assert_eq!(result.tokens.total_tokens, Some(105)); + assert!(result.cost.reported.amount_nano_usd.is_some()); + } +} + +#[test] +fn partial_or_missing_request_costs_preserve_independent_complete_dimensions() { + for (requests, exhaustive) in [ + (vec![Some(0.04)], false), + (vec![Some(0.04), None], true), + (vec![None], true), + (vec![Some(0.0)], false), + ] { + let usage = cost_with_requests(0.1, &requests, exhaustive); + let result = normalize_model_usage(&usage, None, false).unwrap().unwrap(); + assert_eq!(result.tokens.input_tokens, Some(100)); + assert_eq!(result.tokens.output_tokens, Some(5)); + assert_eq!(result.tokens.total_tokens, Some(105)); + assert_eq!(result.cost.reported.amount_nano_usd, Some(100_000_000)); + } +} + +fn omit_model_cost(usage: &mut AgentUsage) { + usage + .extensions + .get_mut("nemo_fabric.usage_accounting.v1") + .unwrap()["models"][0]["reported"] + .as_object_mut() + .unwrap() + .remove("cost_usd"); +} + +fn omit_aggregate_cost(usage: &mut AgentUsage) { + usage.cost_usd = None; + usage + .extensions + .get_mut("nemo_fabric.detailed_token_usage.v1") + .unwrap() + .as_object_mut() + .unwrap() + .remove("cost_usd"); +} + +#[test] +fn request_costs_without_model_cost_cannot_certify_a_contradictory_root() { + let mut usage = cost_with_requests(0.1, &[Some(0.2)], true); + omit_model_cost(&mut usage); + let result = normalize_model_usage(&usage, None, false).unwrap().unwrap(); + assert_eq!(result.cost.reported.amount_nano_usd, None); + assert_eq!(result.cost.reported.observed_nano_usd, None); + assert_eq!(result.tokens.total_tokens, Some(105)); +} + +#[test] +fn exhaustive_requests_recover_cost_without_losing_legitimate_root_rounding() { + for (aggregate, requests) in [ + (0.1, vec![Some(0.04), Some(0.06)]), + ( + 200_000_000.72337076, + vec![Some(100_000_000.65093447), Some(100_000_000.07243629)], + ), + ] { + let mut usage = cost_with_requests(aggregate, &requests, true); + omit_model_cost(&mut usage); + let with_root = normalize_model_usage(&usage, None, false).unwrap().unwrap(); + assert_eq!( + with_root.cost.reported.amount_nano_usd, + Some((aggregate * 1e9).round() as u64) + ); + assert_eq!(with_root.tokens.total_tokens, Some(105)); + omit_aggregate_cost(&mut usage); + let recovered = normalize_model_usage(&usage, None, false).unwrap().unwrap(); + assert!(recovered.cost.reported.amount_nano_usd.is_some()); + assert_eq!( + recovered.cost.reported.amount_nano_usd, + recovered.models[0].cost.reported.amount_nano_usd + ); + } +} + +#[test] +fn incomplete_requests_recover_only_observed_cost_when_no_complete_cost_exists() { + for (requests, exhaustive) in [(vec![Some(0.04)], false), (vec![Some(0.04), None], true)] { + let mut usage = cost_with_requests(0.1, &requests, exhaustive); + omit_model_cost(&mut usage); + omit_aggregate_cost(&mut usage); + let result = normalize_model_usage(&usage, None, false).unwrap().unwrap(); + assert_eq!(result.cost.reported.amount_nano_usd, None); + assert_eq!(result.cost.reported.observed_nano_usd, Some(40_000_000)); + assert_eq!(result.tokens.total_tokens, Some(105)); + } +} + +#[test] +fn broad_request_gap_cannot_certify_complete_model_or_invocation_counts() { + let counts = json!({"input_tokens": 100, "input_tokens_include_cache": true, + "output_tokens": 5, "output_tokens_include_reasoning": true}); + for gap in ["unfinished_work", "missing_usage", "scope_unverified"] { + for root_complete in [false, true] { + let mut usage = AgentUsage::default(); + if root_complete { + usage.input_tokens = Some(100); + usage.input_tokens_include_cache = Some(true); + usage.output_tokens = Some(5); + usage + .extensions + .insert("nemo_fabric.detailed_token_usage.v1".into(), counts.clone()); + } + usage.extensions.insert( + "nemo_fabric.usage_accounting.v1".into(), + json!({ + "models_exhaustive": true, "gaps": [], "models": [{ + "model": "model", "gaps": [], + "reported": if root_complete { json!({}) } else { counts.clone() }, + "requests_exhaustive": true, + "requests": [{"usage": {}, "gap": gap}] + }] + }), + ); + assert!( + normalize_model_usage(&usage, None, false).is_err(), + "broad request gap must conflict with complete counts: {gap}, root={root_complete}" + ); + } + } +} + +#[test] +fn broad_request_gap_keeps_observations_when_no_complete_value_is_claimed() { + let observed = json!({"input_tokens": 100, "input_tokens_include_cache": true, + "output_tokens": 5, "output_tokens_include_reasoning": true}); + let result = normalize( + json!({}), + json!({ + "models_exhaustive": false, "gaps": [], "models": [{ + "model": "model", "gaps": [], "observed": observed, + "observed_tokens_aligned": true, "requests_exhaustive": true, + "requests": [{"usage": observed, "gap": "unfinished_work", "tokens_aligned": true}] + }] + }), + ); + assert!(result["input_tokens"].is_null()); + assert!(result["output_tokens"].is_null()); + assert_eq!(result["observed"]["input_tokens"], 100); + assert_eq!(result["observed"]["output_tokens"], 5); + for scope in [&result, &result["models"][0]] { + for dimension in [ + "input_tokens", + "cached_input_tokens", + "cache_write_input_tokens", + "output_tokens", + "total_tokens", + "reported_cost", + ] { + assert!( + scope["gaps"].as_array().unwrap().iter().any(|gap| { + gap["dimensions"] == json!([dimension]) && gap["reason"] == "unfinished_work" + }), + "request reason missing for {dimension}: {}", + scope["gaps"] + ); + } + } + + let mixed = normalize( + json!({}), + json!({ + "models_exhaustive": true, "gaps": [], "models": [{ + "model": "pending", "gaps": [{"dimensions": ["output_tokens"], "reason": "missing_usage"}], + "observed": observed, "observed_tokens_aligned": true, + "requests_exhaustive": true, + "requests": [ + {"usage": observed, "gap": "unfinished_work", "tokens_aligned": true}, + {"usage": {}, "gap": "scope_unverified"}, + {"usage": {}, "gap": "unfinished_work"} + ] + }, {"model": "settled", "reported": observed, "gaps": []}] + }), + ); + assert!(mixed["input_tokens"].is_null()); + assert_eq!(mixed["observed"]["input_tokens"], 200); + assert_eq!(mixed["observed"]["output_tokens"], 10); + assert_eq!(mixed["models"][1]["total_tokens"], 105); + assert!( + !mixed["models"][1]["gaps"] + .as_array() + .unwrap() + .iter() + .any(|gap| { + gap["reason"] == "unfinished_work" || gap["reason"] == "scope_unverified" + }) + ); + for scope in [&mixed, &mixed["models"][0]] { + let gaps = scope["gaps"].as_array().unwrap(); + for reason in ["unfinished_work", "scope_unverified"] { + assert_eq!( + gaps.iter() + .filter(|gap| { + gap["dimensions"] == json!(["output_tokens"]) && gap["reason"] == reason + }) + .count(), + 1, + "each request reason must survive once: {gaps:?}" + ); + } + } + assert!( + mixed["models"][0]["gaps"] + .as_array() + .unwrap() + .iter() + .any(|gap| { + gap["dimensions"] == json!(["output_tokens"]) && gap["reason"] == "missing_usage" + }) + ); +} + +#[test] +fn dimension_scoped_request_omission_keeps_independent_complete_output() { + let result = normalize( + json!({}), + json!({ + "models_exhaustive": true, "gaps": [], "models": [{ + "model": "model", "gaps": [{"dimensions": ["input_tokens"], "reason": "missing_usage"}], + "reported": {"output_tokens": 5, "output_tokens_include_reasoning": true}, + "requests_exhaustive": true, + "requests": [{"usage": {"output_tokens": 5, "output_tokens_include_reasoning": true}, "tokens_aligned": true}] + }] + }), + ); + assert!(result["input_tokens"].is_null()); + assert_eq!(result["output_tokens"], 5); + assert!(result["total_tokens"].is_null()); +} + +#[test] +fn detailed_reported_cost_fills_only_missing_root_cost() { + for (root_cost, expected) in [(None, 250_000_000), (Some(0.5), 500_000_000)] { + let mut usage = AgentUsage { + cost_usd: root_cost, + ..Default::default() + }; + usage.extensions.insert( + "nemo_fabric.detailed_token_usage.v1".into(), + json!({"cost_usd": 0.25}), + ); + usage.extensions.insert( + "nemo_fabric.usage_accounting.v1".into(), + json!({"models_exhaustive": false, "models": [], "gaps": []}), + ); + let result = normalize_model_usage(&usage, None, false).unwrap().unwrap(); + assert_eq!(result.cost.reported.amount_nano_usd, Some(expected)); + } +} + +#[test] +fn exhaustive_empty_model_list_rejects_positive_tokens() { + for observed in [false, true] { + for field in [ + "input_tokens", + "cached_input_tokens", + "cache_write_input_tokens", + "output_tokens", + "total_tokens", + ] { + let tokens = json!({field: 1}); + let mut usage: AgentUsage = + serde_json::from_value(if observed || field == "cache_write_input_tokens" { + json!({}) + } else { + tokens.clone() + }) + .unwrap(); + let mut accounting = json!({"models_exhaustive": true, "models": [], "gaps": []}); + if observed { + accounting["observed"] = tokens; + } else if field == "cache_write_input_tokens" { + usage + .extensions + .insert("nemo_fabric.detailed_token_usage.v1".into(), tokens); + } + usage + .extensions + .insert("nemo_fabric.usage_accounting.v1".into(), accounting); + let error = normalize_model_usage(&usage, None, false).unwrap_err(); + assert!( + error.to_string().contains("exhaustive empty model list"), + "{field}, observed={observed}: {error}" + ); + } + } +} + +#[test] +fn exhaustive_empty_model_list_preserves_unknown_and_zero_work() { + for tokens in [ + json!({}), + json!({"input_tokens": 0, "cached_input_tokens": 0, "input_tokens_include_cache": true, "output_tokens": 0, "total_tokens": 0}), + ] { + let result = normalize( + tokens.clone(), + json!({"models_exhaustive": true, "models": [], "gaps": [], "observed": tokens}), + ); + assert_eq!(result["models_exhaustive"], true); + assert!(result.get("models").is_none()); + assert_eq!(result["input_tokens"], tokens["input_tokens"]); + } +} + +#[test] +fn complete_model_prices_do_not_require_a_combined_cache_breakdown() { + for (field, cache_rate, count, first_cost) in [ + ("cached_input_tokens", 0.1, 20, 92_000), + ("cache_write_input_tokens", 1.25, 10, 112_500), + ] { + let mut first = json!({ + "input_tokens": 100, "cached_input_tokens": 0, "cache_write_input_tokens": 0, + "input_tokens_include_cache": true, "output_tokens": 5, "output_tokens_include_reasoning": true + }); + first[field] = json!(count); + let mut second = first.clone(); + second.as_object_mut().unwrap().remove(field); + let mut first_rates = json!({ + "input_per_million": 1.0, "output_per_million": 2.0, + "cache_read_per_million": 1.0, "cache_write_per_million": 1.0 + }); + first_rates[if field == "cached_input_tokens" { + "cache_read_per_million" + } else { + "cache_write_per_million" + }] = json!(cache_rate); + let entries = [ + ("first", first_rates), + ( + "second", + json!({ + "input_per_million": 1.0, "output_per_million": 2.0, + "cache_read_per_million": 1.0, "cache_write_per_million": 1.0 + }), + ), + ] + .into_iter() + .map(|(model, rates)| { + json!({ + "provider": "fixture", "model_id": model, "rates": rates, + "prompt_cache": {"read_accounting": "included_in_prompt_tokens"}, + "pricing_as_of": "2026-10-26", "pricing_source": "test fixture" + }) + }) + .collect::>(); + let catalog = UserPriceCatalog::from_bytes( + "prices.json".into(), + &serde_json::to_vec(&json!({"version": 1, "entries": entries})).unwrap(), + ) + .unwrap(); + let mut evidence = json!({"models_exhaustive": true, "gaps": [], "models": [ + {"provider": "fixture", "model": "first", "gaps": [], "reported": first}, + {"provider": "fixture", "model": "second", "gaps": [], "reported": second} + ]}); + let mut raw = AgentUsage::default(); + raw.extensions + .insert("nemo_fabric.usage_accounting.v1".into(), evidence.clone()); + let result = normalize_model_usage(&raw, Some(&catalog), false) + .unwrap() + .unwrap(); + let value = serde_json::to_value(&result).unwrap(); + assert!(value[field].is_null(), "{field}"); + assert_eq!( + result.models[0].cost.estimated.amount_nano_usd, + Some(first_cost) + ); + assert_eq!( + result.models[1].cost.estimated.amount_nano_usd, + Some(110_000) + ); + assert_eq!( + result.cost.estimated.amount_nano_usd, + Some(first_cost + 110_000), + "{field}" + ); + + // Complete model prices cannot settle an invocation-wide input gap. + evidence["gaps"] = json!([{"dimensions": ["input_tokens"], "reason": "unfinished_work"}]); + raw.extensions + .insert("nemo_fabric.usage_accounting.v1".into(), evidence); + let unsettled = normalize_model_usage(&raw, Some(&catalog), false) + .unwrap() + .unwrap(); + assert_eq!(unsettled.cost.estimated.amount_nano_usd, None); + assert_eq!( + unsettled.cost.estimated.observed_nano_usd, + Some(first_cost + 110_000) + ); + } +} + +#[test] +fn zero_token_cost_requires_explicit_zero_counts_and_exhaustive_model_scope() { + for (counts, exhaustive, expected) in [ + (json!({}), true, None), + ( + json!({"input_tokens": 0, "input_tokens_include_cache": true, + "output_tokens": 0, "output_tokens_include_reasoning": true}), + false, + None, + ), + ( + json!({"input_tokens": 0, "input_tokens_include_cache": true, + "output_tokens": 0, "output_tokens_include_reasoning": true}), + true, + Some(0), + ), + ] { + let mut raw = AgentUsage::default(); + raw.extensions + .insert("nemo_fabric.detailed_token_usage.v1".into(), counts); + raw.extensions.insert( + "nemo_fabric.usage_accounting.v1".into(), + json!({"models_exhaustive": exhaustive, "models": [], "gaps": []}), + ); + let result = normalize_model_usage(&raw, None, false).unwrap().unwrap(); + assert_eq!(result.cost.estimated.amount_nano_usd, expected); + assert_eq!(result.cost.estimated.observed_nano_usd, expected); + } +} + +#[test] +fn request_tiers_price_complete_calls_without_certifying_a_partial_inventory() { + let catalog = UserPriceCatalog::from_bytes("prices.json".into(), &serde_json::to_vec(&json!({ + "version": 1, "entries": [{ + "provider": "fixture", "model_id": "model", + "rate_schedule": {"type": "prompt_token_threshold", "tiers": [ + {"max_prompt_tokens": 100, "rates": {"input_per_million": 1.0, "output_per_million": 2.0}}, + {"min_prompt_tokens": 101, "rates": {"input_per_million": 10.0, "output_per_million": 20.0}} + ]}, + "prompt_cache": {"read_accounting": "included_in_prompt_tokens"}, + "pricing_as_of": "2026-10-26", "pricing_source": "test fixture" + }] + })).unwrap()).unwrap(); + let requests = [100, 101].into_iter().map(|input| json!({"usage": { + "input_tokens": input, "cached_input_tokens": 0, "cache_write_input_tokens": 0, + "input_tokens_include_cache": true, "output_tokens": 1, "output_tokens_include_reasoning": true + }})).collect::>(); + for exhaustive in [true, false] { + let mut raw = AgentUsage::default(); + raw.extensions.insert( + "nemo_fabric.usage_accounting.v1".into(), + json!({ + "models_exhaustive": true, "gaps": [], "models": [{ + "provider": "fixture", "model": "model", "gaps": [], + "requests_exhaustive": exhaustive, "requests": requests + }] + }), + ); + let result = normalize_model_usage(&raw, Some(&catalog), false) + .unwrap() + .unwrap(); + assert_eq!( + result.cost.estimated.amount_nano_usd, + exhaustive.then_some(1_132_000) + ); + assert_eq!(result.cost.estimated.observed_nano_usd, Some(1_132_000)); + assert_eq!(result.models[0].cost.applied_rates.len(), 2); + } +} diff --git a/docs/adapter-contract/tutorials/results.md b/docs/adapter-contract/tutorials/results.md index 7e8f6646b..805905ed2 100644 --- a/docs/adapter-contract/tutorials/results.md +++ b/docs/adapter-contract/tutorials/results.md @@ -84,6 +84,30 @@ NeMo Fabric preserves the extension at `RunResult.usage.metadata["nemo_fabric.de **Success Check**: `invoke` returns one typed `AgentRunResult` whose explicit status matches the presence or absence of an error. +#### Invocation accounting evidence + +The closed `AgentUsage` root stays unchanged. Collectors can use +`nemo_fabric_adapter_contract.usage.usage_from_observations` in Python or +`usageFromObservations` in TypeScript to construct the descriptor-declared +`nemo_fabric.usage_accounting.v1` extension. Its canonical schema is +`schemas/extensions/usage-accounting.schema.json` in the repository. Declare it +under `extension_schemas.usage` and keep packaged descriptor copies identical. + +Supply already deduplicated source units, source-established model/provider identity when known, raw inclusion flags and independent field presence. A configured client or request model can establish accounting identity when the source proves all included calls used it. An outer requested alias or role alone does not establish that allocation. Accounting identity does not replace served-model provenance. + +Collectors own source identity and invocation lifetime; the helper does not wait for late work or infer zero from an HTTP error. Complete fields cannot coexist with an explicit gap for that dimension. Use `unfinished_work`, `missing_usage` or `scope_unverified` only for actual missing evidence, not merely because a harness supports background work or retries. Retain raw observations when complete counts cannot be certified. + +A request-wide `UsageRequest.gap` affects every dimension of that call and cannot coexist with complete model or invocation totals. For a dimension-specific gap, omit the affected request fields and declare the scoped gap without setting the request-wide `gap`. Other dimensions can remain complete. + +A provider name must identify the source-established provider, not merely an API-compatible protocol. Custom endpoints and arbitrary gateways do not establish a native provider tariff. Preserve the model and counts when provider identity cannot be established; explicit user catalogs can price that evidence with labeled assumptions. + +Core normalizes cache-inclusive input and reasoning-inclusive output once and +returns the flat `RunResult.model_usage` view. Raw reasoning/peak/request evidence +can support normalization and pricing without becoming mandatory public metrics. +A model aggregate is not a single request. No adapter should fabricate call +boundaries, hide execution features or change retry settings to make usage look +complete. Costs computed from a price table never become `cost_usd`. + ### 2. Separate Failure Classes Use these failure classes consistently: diff --git a/docs/index.yml b/docs/index.yml index 281f76b93..c93acbfed 100644 --- a/docs/index.yml +++ b/docs/index.yml @@ -102,6 +102,8 @@ navigation: contents: - page: Overview path: ./reference/api/python-library-reference/index.md + - page: Model Usage Accounting + path: ./reference/api/python-library-reference/nemo_fabric.accounting.md - page: Client path: ./reference/api/python-library-reference/nemo_fabric.client.md - page: Adapter Capability Inspection diff --git a/docs/reference/api/python-library-reference/index.md b/docs/reference/api/python-library-reference/index.md index f0d2f098b..1fb5ef5c4 100644 --- a/docs/reference/api/python-library-reference/index.md +++ b/docs/reference/api/python-library-reference/index.md @@ -10,6 +10,7 @@ SPDX-License-Identifier: Apache-2.0 --> ## Modules +- [`nemo_fabric.accounting`](./nemo_fabric.accounting.md#module-nemo_fabricaccounting): Typed model accounting and invocation summaries with explicit coverage. - [`nemo_fabric.client`](./nemo_fabric.client.md#module-nemo_fabricclient): Native Python client for resolving and running NVIDIA NeMo Fabric agents. - [`nemo_fabric.capabilities`](./nemo_fabric.capabilities.md#module-nemo_fabriccapabilities): Metadata-only adapter inspection for host-side admission. - [`nemo_fabric.check`](./nemo_fabric.check.md#module-nemo_fabriccheck): Health requests and immutable health reports. @@ -23,6 +24,8 @@ SPDX-License-Identifier: Apache-2.0 --> ## Classes +- [`accounting.DimensionSummary`](./nemo_fabric.accounting.md#class-dimensionsummary): An aggregate together with its explicit invocation coverage. +- [`accounting.UsageSummary`](./nemo_fabric.accounting.md#class-usagesummary): Coverage of an explicit invocation manifest, including missing results. - [`client.Fabric`](./nemo_fabric.client.md#class-fabric): Primary Python entrypoint for NeMo Fabric. - [`capabilities.AdapterCapabilityProfile`](./nemo_fabric.capabilities.md#class-adaptercapabilityprofile): Declared support for a standalone runtime, not observed execution provenance. - [`check.CheckReport`](./nemo_fabric.check.md#class-checkreport): Immutable health check report. @@ -45,6 +48,7 @@ SPDX-License-Identifier: Apache-2.0 --> - [`models.McpServerConfig`](./nemo_fabric.models.md#class-mcpserverconfig): MCP server configuration. - [`models.MetadataConfig`](./nemo_fabric.models.md#class-metadataconfig): Human-readable agent identity. - [`models.ModelConfig`](./nemo_fabric.models.md#class-modelconfig): Configuration for one model role. +- [`models.PricingConfig`](./nemo_fabric.models.md#class-pricingconfig): Frozen Relay-compatible price-catalog override, read during planning. - [`models.RelayAtifConfig`](./nemo_fabric.models.md#class-relayatifconfig): NeMo Relay ATIF export configuration. - [`models.RelayAtofConfig`](./nemo_fabric.models.md#class-relayatofconfig): NeMo Relay ATOF export configuration. - [`models.RelayAtofFileSinkConfig`](./nemo_fabric.models.md#class-relayatoffilesinkconfig): NeMo Relay ATOF file sink configuration. @@ -68,11 +72,13 @@ SPDX-License-Identifier: Apache-2.0 --> - [`models.ToolsConfig`](./nemo_fabric.models.md#class-toolsconfig): Harness-neutral tool capability configuration. - [`models.WorkflowConfig`](./nemo_fabric.models.md#class-workflowconfig): Registered workflow target and immutable construction settings. - [`types.AdapterInfo`](./nemo_fabric.types.md#class-adapterinfo): Resolved adapter identity attached to a run plan. +- [`types.AppliedPricing`](./nemo_fabric.types.md#class-appliedpricing): Selected price entry and integer nano-USD rates per million tokens. - [`types.ArtifactManifest`](./nemo_fabric.types.md#class-artifactmanifest): Normalized collection of artifacts produced by a run. - [`types.ArtifactRef`](./nemo_fabric.types.md#class-artifactref): Reference to one artifact produced by a run. - [`types.CgroupObservation`](./nemo_fabric.types.md#class-cgroupobservation): Native cgroup snapshot or explicit unavailable/unsupported reason. - [`types.CgroupSnapshot`](./nemo_fabric.types.md#class-cgroupsnapshot): Native adapter-host cgroup measurements, separate from process RSS. - [`types.CgroupStats`](./nemo_fabric.types.md#class-cgroupstats): Cgroup endpoint observations and counter deltas over an invocation. +- [`types.CostAmount`](./nemo_fabric.types.md#class-costamount): Complete and observed amounts in integer nano-USD, never an invoice guarantee. - [`types.DoctorCheck`](./nemo_fabric.types.md#class-doctorcheck): One diagnostic check in a ``DoctorReport``. - [`types.DoctorReport`](./nemo_fabric.types.md#class-doctorreport): Aggregate preflight diagnostics for a resolved run plan. - [`types.EnvironmentHandle`](./nemo_fabric.types.md#class-environmenthandle): Durable identity and provider binding for a prepared or attached environment. @@ -82,6 +88,9 @@ SPDX-License-Identifier: Apache-2.0 --> - [`types.GaugeSummary`](./nemo_fabric.types.md#class-gaugesummary): Sampled gauge extrema, hold-last-value mean, and covered time. - [`types.InvocationSystemStats`](./nemo_fabric.types.md#class-invocationsystemstats): System measurements for one invocation in its executing runner. - [`types.Measurement`](./nemo_fabric.types.md#class-measurement): Measured numeric value, an unlimited limit, or explicit missing evidence. +- [`types.ModelUsage`](./nemo_fabric.types.md#class-modelusage): Complete normalized tokens, observations, gaps and separately typed costs. +- [`types.PerModelUsage`](./nemo_fabric.types.md#class-permodelusage): Normalized counts for a source-established accounting model/provider. +- [`types.PricingBasis`](./nemo_fabric.types.md#class-pricingbasis): Validated comparison basis for persisted catalog estimates. - [`types.ResourceObservation`](./nemo_fabric.types.md#class-resourceobservation): Adapter host process-tree reading, or an explicit collection failure. - [`types.RunOutput`](./nemo_fabric.types.md#class-runoutput): Normalized adapter output. - [`types.RunPlan`](./nemo_fabric.types.md#class-runplan): Immutable execution plan produced before a runtime is started. @@ -94,6 +103,9 @@ SPDX-License-Identifier: Apache-2.0 --> - [`types.ServiceHandle`](./nemo_fabric.types.md#class-servicehandle): Opaque identity and binding for one prepared or attached service. - [`types.ServiceReference`](./nemo_fabric.types.md#class-servicereference): Reference to an already-running caller-owned service. - [`types.TelemetryRef`](./nemo_fabric.types.md#class-telemetryref): Reference to external or persisted telemetry for a run. +- [`types.TokenCost`](./nemo_fabric.types.md#class-tokencost): Provider-reported amounts and separate catalog estimates with their basis. +- [`types.TokenCounts`](./nemo_fabric.types.md#class-tokencounts): Independently nullable tokens in one fixed invocation/model scope. +- [`types.UsageGap`](./nemo_fabric.types.md#class-usagegap): Dimension-specific incompleteness with an optional stable diagnostic. - [`errors.FabricCapabilityError`](./nemo_fabric.errors.md#class-fabriccapabilityerror): Operation rejected by resolved runtime capabilities or implementation status. - [`errors.FabricConfigError`](./nemo_fabric.errors.md#class-fabricconfigerror): Invalid SDK input, request shape, factory, or resolved config. - [`errors.FabricError`](./nemo_fabric.errors.md#class-fabricerror): Base class for structured SDK-level NeMo Fabric errors. @@ -105,6 +117,7 @@ SPDX-License-Identifier: Apache-2.0 --> ## Functions +- [`accounting.summarize_usage`](./nemo_fabric.accounting.md#function-summarize_usage): Summarize invocations against an explicit expected identity manifest. - [`capabilities.inspect_adapter`](./nemo_fabric.capabilities.md#function-inspect_adapter): Inspect a standalone runtime using descriptor metadata without loading its SDK. diff --git a/docs/reference/api/python-library-reference/nemo_fabric.accounting.md b/docs/reference/api/python-library-reference/nemo_fabric.accounting.md new file mode 100644 index 000000000..881836f85 --- /dev/null +++ b/docs/reference/api/python-library-reference/nemo_fabric.accounting.md @@ -0,0 +1,88 @@ +--- +title: "Model Usage Accounting" +slug: "/reference/api/python-library-reference/accounting" +description: "Summarize complete and observed model usage with explicit coverage." +--- + + +# module `nemo_fabric.accounting` + +Typed model accounting and invocation summaries with explicit coverage. + +This module never backfills from ATIF or interprets absent values as zero. Observed values are deduplicated evidence, not guaranteed mathematical bounds. + + +--- + + +## function `summarize_usage` + +```python +def summarize_usage( + results: Iterable[Mapping[str, Any]], + *, + expected: Iterable[tuple[str, str]], +) -> UsageSummary +``` + +Summarize invocations against an explicit expected identity manifest. + +Include failed and cancelled results. Identical duplicates count once; conflicts and unexpected results are errors. A complete total exists only when every expected invocation has a complete value. Observations remain visible alongside coverage. No observations is null, never zero. + + +--- + + +## class `DimensionSummary` + +An aggregate together with its explicit invocation coverage. + + + + + + +### Fields + +The mapping exposes the following typed fields: + +| Field | Type | +| --- | --- | +| `expected_count` | `int` | +| `complete_count` | `int` | +| `incomplete_count` | `int` | +| `missing_result_count` | `int` | +| `complete_total` | `int \| None` | +| `observed_sum` | `int \| None` | +| `reasons` | `dict[str, int]` | +| `aggregation_error` | `NotRequired[str]` | + +--- + + +## class `UsageSummary` + +Coverage of an explicit invocation manifest, including missing results. + + + + + + + + +### Fields + +The mapping exposes the following typed fields: + +| Field | Type | +| --- | --- | +| `unit` | `str` | +| `dimensions` | `dict[str, nemo_fabric.accounting.DimensionSummary]` | +| `pricing` | `NotRequired[dict[str, Any]]` | +| `pricing_groups` | `NotRequired[list[dict[str, Any]]]` | + +--- + +_This file was automatically generated via [lazydocs](https://github.com/ml-tooling/lazydocs)._ diff --git a/docs/reference/api/python-library-reference/nemo_fabric.capabilities.md b/docs/reference/api/python-library-reference/nemo_fabric.capabilities.md index 3e1912db0..f1f6301a5 100644 --- a/docs/reference/api/python-library-reference/nemo_fabric.capabilities.md +++ b/docs/reference/api/python-library-reference/nemo_fabric.capabilities.md @@ -17,10 +17,10 @@ Metadata-only adapter inspection for host-side admission. ## function `inspect_adapter` ```python -inspect_adapter( - config: 'FabricConfig', - descriptor: 'Mapping[str, Any] | None' -) → AdapterCapabilityProfile +def inspect_adapter( + config: FabricConfig, + descriptor: Mapping[str, Any] | None, +) -> AdapterCapabilityProfile ``` Inspect a standalone runtime using descriptor metadata without loading its SDK. diff --git a/docs/reference/api/python-library-reference/nemo_fabric.models.md b/docs/reference/api/python-library-reference/nemo_fabric.models.md index 3aa659030..03e7e0335 100644 --- a/docs/reference/api/python-library-reference/nemo_fabric.models.md +++ b/docs/reference/api/python-library-reference/nemo_fabric.models.md @@ -16,7 +16,6 @@ The Rust core remains the source of truth for persisted schema snapshots. These --- - ## class `FabricBaseModel` Base class for SDK-facing Pydantic models. @@ -54,7 +53,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -65,7 +63,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -77,7 +74,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `MetadataConfig` Human-readable agent identity. @@ -125,7 +121,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -136,7 +131,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -148,7 +142,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `HarnessConfig` Harness adapter selection plus adapter-owned settings. @@ -197,7 +190,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -208,7 +200,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -220,7 +211,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `WorkflowConfig` Registered workflow target and immutable construction settings. @@ -268,7 +258,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -279,7 +268,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -291,7 +279,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `DiscoveryConfig` Explicit local descriptor discovery paths. @@ -338,7 +325,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -349,7 +335,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -361,7 +346,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `InstructionConfig` One portable instruction value. @@ -409,7 +393,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -420,7 +403,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -432,7 +414,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `InstructionsConfig` Harness-neutral agent instructions. @@ -479,7 +460,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -490,7 +470,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -502,7 +481,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `RuntimeConfig` Invocation runtime contract. @@ -553,7 +531,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -564,7 +541,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -576,7 +552,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `EnvironmentConfig` Execution environment configuration supplied by the consumer. @@ -634,7 +609,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -645,7 +619,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -657,7 +630,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `ModelConfig` Configuration for one model role. @@ -711,7 +683,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -722,7 +693,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -734,7 +704,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `SkillConfig` Skill capability configuration. @@ -781,7 +750,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### method `add_path` ```python @@ -792,7 +760,6 @@ Add a skill path if absent. --- - ### classmethod `from_mapping` ```python @@ -803,7 +770,6 @@ Validate a mapping using this Pydantic model. --- - ### method `remove_path` ```python @@ -814,7 +780,6 @@ Remove a skill path if present. --- - ### method `to_mapping` ```python @@ -826,7 +791,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `McpAuthenticationConfig` MCP server authentication configuration. @@ -883,7 +847,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -894,7 +857,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -906,7 +868,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `McpServerConfig` MCP server configuration. @@ -961,7 +922,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -972,7 +932,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -984,7 +943,6 @@ Return the server mapping without collapsing an explicit empty allowlist. --- - ## class `McpConfig` MCP capability configuration. @@ -1031,7 +989,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### method `add_server` ```python @@ -1057,7 +1014,6 @@ For stdio, set ``url`` to the executable and pass each command-line argument as --- - ### classmethod `from_mapping` ```python @@ -1068,7 +1024,6 @@ Validate a mapping using this Pydantic model. --- - ### method `remove_server` ```python @@ -1079,7 +1034,6 @@ Remove a named MCP server if present. --- - ### method `to_mapping` ```python @@ -1091,7 +1045,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `RelayConfigPolicy` NVIDIA NeMo Relay config validation policy. @@ -1140,7 +1093,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -1151,7 +1103,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -1163,7 +1114,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `RelayAtofFileSinkConfig` NeMo Relay ATOF file sink configuration. @@ -1213,7 +1163,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -1224,7 +1173,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -1236,7 +1184,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `RelayAtofStreamSinkConfig` NeMo Relay ATOF stream sink configuration. @@ -1290,7 +1237,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -1301,7 +1247,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -1313,7 +1258,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `RelayAtofConfig` NeMo Relay ATOF export configuration. @@ -1361,7 +1305,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -1372,7 +1315,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -1384,7 +1326,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `RelayS3StorageConfig` NeMo Relay ATIF S3 storage configuration. @@ -1439,7 +1380,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -1450,7 +1390,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -1462,7 +1401,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `RelayHttpStorageConfig` NeMo Relay ATIF HTTP storage configuration. @@ -1513,7 +1451,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -1524,7 +1461,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -1536,7 +1472,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `RelayAtifConfig` NeMo Relay ATIF export configuration. @@ -1591,7 +1526,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -1602,7 +1536,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -1614,7 +1547,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `RelayOpenTelemetryEndpointConfig` One typed NeMo Relay OpenTelemetry destination. @@ -1674,7 +1606,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -1685,7 +1616,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -1697,7 +1627,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `RelayOpenTelemetryConfig` NeMo Relay typed OpenTelemetry destination configuration. @@ -1745,7 +1674,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -1756,7 +1684,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -1768,7 +1695,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `RelayObservabilityConfig` NeMo Relay observability component configuration. @@ -1820,7 +1746,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -1831,7 +1756,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -1843,7 +1767,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `RelayComponentConfig` Generic NeMo Relay plugin component configuration. @@ -1892,7 +1815,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -1903,7 +1825,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -1915,7 +1836,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `RelayConfig` First-class NeMo Relay integration configuration. @@ -1966,7 +1886,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -1977,7 +1896,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -1989,7 +1907,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `TelemetryProviderConfig` Provider-specific telemetry configuration. @@ -2036,7 +1953,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -2047,7 +1963,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -2059,7 +1974,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `TelemetryConfig` Telemetry configuration. @@ -2106,7 +2020,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### method `enable_native` ```python @@ -2117,7 +2030,6 @@ Let the selected adapter handle telemetry natively. --- - ### method `enable_relay` ```python @@ -2128,7 +2040,6 @@ Enable NeMo Relay telemetry for subsequently started runtimes. --- - ### classmethod `from_mapping` ```python @@ -2139,7 +2050,6 @@ Validate a mapping using this Pydantic model. --- - ### method `remove_provider` ```python @@ -2150,7 +2060,6 @@ Remove a configured telemetry provider. --- - ### method `to_mapping` ```python @@ -2162,7 +2071,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `ToolDefinitionConfig` One named normalized tool or tool-group definition. @@ -2211,7 +2119,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -2222,7 +2129,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -2234,7 +2140,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `ToolsConfig` Harness-neutral tool capability configuration. @@ -2283,7 +2188,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### method `add_definition` ```python @@ -2301,7 +2205,6 @@ Add or replace one named definition and return this tools config. --- - ### classmethod `from_mapping` ```python @@ -2312,7 +2215,6 @@ Validate a mapping using this Pydantic model. --- - ### method `remove_definition` ```python @@ -2323,7 +2225,6 @@ Remove one named definition and return this tools config. --- - ### method `to_mapping` ```python @@ -2335,7 +2236,6 @@ Return the tool mapping without collapsing an explicit empty policy. --- - ## class `SystemStatsSamplingConfig` Opt-in periodic collection; bounds include both endpoint samples. @@ -2383,7 +2283,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -2394,7 +2293,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -2406,7 +2304,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `SystemStatsConfig` Native invocation stats selection, independent of Relay and recording. @@ -2456,7 +2353,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -2467,7 +2363,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -2479,6 +2374,72 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- +## class `PricingConfig` + +Frozen Relay-compatible price-catalog override, read during planning. + + + +### Fields + +The model defines the following fields: + +| Field | Type | Required | Default | Constraints | Description | +| --- | --- | --- | --- | --- | --- | +| `catalog_path` | `str \| Path \| None` | No | `None` | — | — | + +--- + +### property extra_fields + +Return fields preserved by the extension point for this model. + +--- + +### property model_extra + +Get extra fields set during validation. + + + +**Returns:** + A dictionary of extra fields, or `None` if `config.extra` is not set to `"allow"`. + +--- + +### property model_fields_set + +Returns the set of fields that have been explicitly set on this model instance. + + + +**Returns:** + A set of strings representing the fields that have been set, i.e. that were not filled from defaults. + + + +--- + +### classmethod `from_mapping` + +```python +def from_mapping(value: Mapping[str, Any]) -> Self +``` + +Validate a mapping using this Pydantic model. + +--- + +### method `to_mapping` + +```python +def to_mapping() -> dict[str, Any] +``` + +Return a detached JSON-compatible mapping for Rust/core calls. + + +--- ## class `FabricConfig` @@ -2508,6 +2469,7 @@ The model defines the following fields: | `system_stats` | `SystemStatsConfig \| None` | No | `SystemStatsConfig()` | — | — | | `telemetry` | `TelemetryConfig \| None` | No | `None` | — | — | | `relay` | `RelayConfig \| None` | No | `None` | — | — | +| `pricing` | `PricingConfig \| None` | No | `None` | — | — | | `tools` | `ToolsConfig \| None` | No | `None` | — | — | --- @@ -2542,7 +2504,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### method `add_mcp_server` ```python @@ -2568,7 +2529,6 @@ For stdio, set ``url`` to the executable and pass each command-line argument as --- - ### method `add_skill_path` ```python @@ -2579,7 +2539,6 @@ Add a skill path and return this config. --- - ### method `add_tool_definition` ```python @@ -2597,7 +2556,6 @@ Add or replace one named tool definition and return this config. --- - ### method `block_tools` ```python @@ -2608,7 +2566,6 @@ Block adapter-native tool names and return this config. --- - ### method `enable_relay` ```python @@ -2626,7 +2583,6 @@ Enable NeMo Relay telemetry and return this config. --- - ### classmethod `from_mapping` ```python @@ -2637,7 +2593,6 @@ Validate the public agent config mapping shape. --- - ### method `remove_mcp_server` ```python @@ -2648,7 +2603,6 @@ Remove a named MCP server and return this config. --- - ### method `remove_skill_path` ```python @@ -2659,7 +2613,6 @@ Remove a skill path and return this config. --- - ### method `remove_tool_definition` ```python @@ -2670,7 +2623,6 @@ Remove one named tool definition and return this config. --- - ### method `to_mapping` ```python @@ -2682,7 +2634,6 @@ Return a detached mapping matching the Rust ``FabricConfig`` schema. --- - ## class `RunRequest` One validated NeMo Fabric invocation request. @@ -2733,7 +2684,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -2744,7 +2694,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python diff --git a/docs/reference/api/python-library-reference/nemo_fabric.types.md b/docs/reference/api/python-library-reference/nemo_fabric.types.md index d23e2f6b6..4d687670b 100644 --- a/docs/reference/api/python-library-reference/nemo_fabric.types.md +++ b/docs/reference/api/python-library-reference/nemo_fabric.types.md @@ -14,7 +14,6 @@ Public data contracts for the NeMo Fabric Python SDK. --- - ## class `AdapterInfo` Resolved adapter identity attached to a run plan. @@ -28,7 +27,6 @@ Resolved adapter identity attached to a run plan. - `metadata`: Adapter-specific, JSON-compatible metadata. - ### Fields The mapping exposes the following typed fields: @@ -60,7 +58,6 @@ Return an immutable view of preserved extension fields. --- - ### classmethod `from_mapping` ```python @@ -71,7 +68,6 @@ Validate and copy a mapping into the requested typed model. --- - ### method `to_dict` ```python @@ -82,7 +78,6 @@ Return the same detached representation as ``to_mapping()``. --- - ### method `to_mapping` ```python @@ -94,7 +89,6 @@ Return a detached, JSON-compatible mapping for serialization. --- - ## class `RuntimeCapabilities` Operations declared by the resolved runtime and adapter. @@ -112,7 +106,6 @@ Capabilities describe what the selected runtime can support; callers should stil - `metadata`: Additional capability details. - ### Fields The mapping exposes the following typed fields: @@ -146,7 +139,6 @@ Return an immutable view of preserved extension fields. --- - ### classmethod `from_mapping` ```python @@ -157,7 +149,6 @@ Validate and copy a mapping into the requested typed model. --- - ### method `to_dict` ```python @@ -168,7 +159,6 @@ Return the same detached representation as ``to_mapping()``. --- - ### method `to_mapping` ```python @@ -180,7 +170,6 @@ Return a detached, JSON-compatible mapping for serialization. --- - ## class `RunPlan` Immutable execution plan produced before a runtime is started. @@ -196,7 +185,6 @@ Immutable execution plan produced before a runtime is started. - `capabilities`: Operations declared by the resolved runtime. - ### Fields The mapping exposes the following typed fields: @@ -230,7 +218,6 @@ Return an immutable view of preserved extension fields. --- - ### classmethod `from_mapping` ```python @@ -241,7 +228,6 @@ Validate and copy a mapping into the requested typed model. --- - ### method `to_dict` ```python @@ -252,7 +238,6 @@ Return the same detached representation as ``to_mapping()``. --- - ### method `to_mapping` ```python @@ -264,7 +249,6 @@ Return a detached, JSON-compatible mapping for serialization. --- - ## class `DoctorCheck` One diagnostic check in a ``DoctorReport``. @@ -279,7 +263,6 @@ One diagnostic check in a ``DoctorReport``. - `metadata`: Structured check details. - ### Fields The mapping exposes the following typed fields: @@ -312,7 +295,6 @@ Return an immutable view of preserved extension fields. --- - ### classmethod `from_mapping` ```python @@ -323,7 +305,6 @@ Validate and copy a mapping into the requested typed model. --- - ### method `to_dict` ```python @@ -334,7 +315,6 @@ Return the same detached representation as ``to_mapping()``. --- - ### method `to_mapping` ```python @@ -346,7 +326,6 @@ Return a detached, JSON-compatible mapping for serialization. --- - ## class `DoctorReport` Aggregate preflight diagnostics for a resolved run plan. @@ -360,7 +339,6 @@ Aggregate preflight diagnostics for a resolved run plan. - `checks`: Ordered ``DoctorCheck`` results. - ### Fields The mapping exposes the following typed fields: @@ -392,7 +370,6 @@ Return an immutable view of preserved extension fields. --- - ### classmethod `from_mapping` ```python @@ -403,7 +380,6 @@ Validate and copy a mapping into the requested typed model. --- - ### method `to_dict` ```python @@ -414,7 +390,6 @@ Return the same detached representation as ``to_mapping()``. --- - ### method `to_mapping` ```python @@ -426,7 +401,6 @@ Return a detached, JSON-compatible mapping for serialization. --- - ## class `ErrorInfo` Structured failure returned inside a normalized ``RunResult``. @@ -442,7 +416,6 @@ Structured failure returned inside a normalized ``RunResult``. - `metadata`: Adapter- or runtime-specific details. - ### Fields The mapping exposes the following typed fields: @@ -476,7 +449,6 @@ Return an immutable view of preserved extension fields. --- - ### classmethod `from_mapping` ```python @@ -487,7 +459,6 @@ Validate and copy a mapping into the requested typed model. --- - ### method `to_dict` ```python @@ -498,7 +469,6 @@ Return the same detached representation as ``to_mapping()``. --- - ### method `to_mapping` ```python @@ -510,7 +480,6 @@ Return a detached, JSON-compatible mapping for serialization. --- - ## class `ArtifactRef` Reference to one artifact produced by a run. @@ -526,7 +495,6 @@ Reference to one artifact produced by a run. - `metadata`: Artifact-specific details. - ### Fields The mapping exposes the following typed fields: @@ -560,7 +528,6 @@ Return an immutable view of preserved extension fields. --- - ### classmethod `from_mapping` ```python @@ -571,7 +538,6 @@ Validate and copy a mapping into the requested typed model. --- - ### method `to_dict` ```python @@ -582,7 +548,6 @@ Return the same detached representation as ``to_mapping()``. --- - ### method `to_mapping` ```python @@ -594,7 +559,6 @@ Return a detached, JSON-compatible mapping for serialization. --- - ## class `ArtifactManifest` Normalized collection of artifacts produced by a run. @@ -607,7 +571,6 @@ Normalized collection of artifacts produced by a run. - `artifacts`: Ordered ``ArtifactRef`` entries. - ### Fields The mapping exposes the following typed fields: @@ -638,7 +601,6 @@ Return an immutable view of preserved extension fields. --- - ### classmethod `from_mapping` ```python @@ -649,7 +611,6 @@ Validate and copy a mapping into the requested typed model. --- - ### method `to_dict` ```python @@ -660,7 +621,6 @@ Return the same detached representation as ``to_mapping()``. --- - ### method `to_mapping` ```python @@ -672,7 +632,6 @@ Return a detached, JSON-compatible mapping for serialization. --- - ## class `TelemetryRef` Reference to external or persisted telemetry for a run. @@ -688,7 +647,6 @@ Reference to external or persisted telemetry for a run. - `metadata`: Provider-specific details. - ### Fields The mapping exposes the following typed fields: @@ -722,7 +680,6 @@ Return an immutable view of preserved extension fields. --- - ### classmethod `from_mapping` ```python @@ -733,7 +690,6 @@ Validate and copy a mapping into the requested typed model. --- - ### method `to_dict` ```python @@ -744,7 +700,6 @@ Return the same detached representation as ``to_mapping()``. --- - ### method `to_mapping` ```python @@ -756,7 +711,6 @@ Return a detached, JSON-compatible mapping for serialization. --- - ## class `FabricEvent` One normalized lifecycle or invocation event. @@ -772,7 +726,6 @@ One normalized lifecycle or invocation event. - `metadata`: Event-specific structured details. - ### Fields The mapping exposes the following typed fields: @@ -806,7 +759,6 @@ Return an immutable view of preserved extension fields. --- - ### classmethod `from_mapping` ```python @@ -817,7 +769,6 @@ Validate and copy a mapping into the requested typed model. --- - ### method `to_dict` ```python @@ -828,7 +779,6 @@ Return the same detached representation as ``to_mapping()``. --- - ### method `to_mapping` ```python @@ -840,7 +790,6 @@ Return a detached, JSON-compatible mapping for serialization. --- - ## class `EnvironmentReference` Provider-specific identity for an existing execution environment. @@ -855,7 +804,6 @@ Pass this reference to ``Fabric.attach_environment()``. The provider verifies th - `resource`: Provider-specific resource identity. - ### Fields The mapping exposes the following typed fields: @@ -886,7 +834,6 @@ Return an immutable view of preserved extension fields. --- - ### classmethod `from_mapping` ```python @@ -897,7 +844,6 @@ Validate and copy a mapping into the requested typed model. --- - ### method `to_dict` ```python @@ -908,7 +854,6 @@ Return the same detached representation as ``to_mapping()``. --- - ### method `to_mapping` ```python @@ -920,7 +865,6 @@ Return a detached, JSON-compatible mapping for serialization. --- - ## class `EnvironmentHandle` Durable identity and provider binding for a prepared or attached environment. @@ -942,7 +886,6 @@ Environment handles are independent of runtime sessions. Runtime and reuse limit - `metadata`: Provider-specific metadata. - ### Fields The mapping exposes the following typed fields: @@ -980,7 +923,6 @@ Return an immutable view of preserved extension fields. --- - ### classmethod `from_mapping` ```python @@ -991,7 +933,6 @@ Validate and copy a mapping into the requested typed model. --- - ### method `to_dict` ```python @@ -1002,7 +943,6 @@ Return the same detached representation as ``to_mapping()``. --- - ### method `to_mapping` ```python @@ -1014,7 +954,6 @@ Return a detached, JSON-compatible mapping for serialization. --- - ## class `RuntimeHandle` Opaque identity and binding for one started runtime. @@ -1035,7 +974,6 @@ Applications should treat ``runtime_binding`` as opaque. NeMo Fabric validates t - `environment`: Prepared environment snapshot. - ### Fields The mapping exposes the following typed fields: @@ -1072,7 +1010,6 @@ Return an immutable view of preserved extension fields. --- - ### classmethod `from_mapping` ```python @@ -1083,7 +1020,6 @@ Validate and copy a mapping into the requested typed model. --- - ### method `to_dict` ```python @@ -1094,7 +1030,6 @@ Return the same detached representation as ``to_mapping()``. --- - ### method `to_mapping` ```python @@ -1106,7 +1041,6 @@ Return a detached, JSON-compatible mapping for serialization. --- - ## class `ServiceReference` Reference to an already-running caller-owned service. @@ -1116,7 +1050,6 @@ Reference to an already-running caller-owned service. ``connection`` contains endpoints and credential references such as an environment-variable name. It must not contain credential values. - ### Fields The mapping exposes the following typed fields: @@ -1149,7 +1082,6 @@ Return an immutable view of preserved extension fields. --- - ### classmethod `from_mapping` ```python @@ -1160,7 +1092,6 @@ Validate and copy a mapping into the requested typed model. --- - ### method `to_dict` ```python @@ -1171,7 +1102,6 @@ Return the same detached representation as ``to_mapping()``. --- - ### method `to_mapping` ```python @@ -1183,7 +1113,6 @@ Return a detached, JSON-compatible mapping for serialization. --- - ## class `ServiceHandle` Opaque identity and binding for one prepared or attached service. @@ -1191,7 +1120,6 @@ Opaque identity and binding for one prepared or attached service. ``service_id`` identifies the process-local Fabric service lifecycle, not the underlying remote deployment. ``service_binding`` is opaque and must be returned unchanged when the handle is used. ``adapter_id`` identifies the adapter that created or attached to the service, and ``service_type`` is that adapter's stable service kind. ``ownership`` is ``fabric_owned`` or ``caller_owned``. ``connection`` is a sanitized summary that never contains credential values, while ``metadata`` contains adapter-reported version and readiness information. - ### Fields The mapping exposes the following typed fields: @@ -1227,7 +1155,6 @@ Return an immutable view of preserved extension fields. --- - ### classmethod `from_mapping` ```python @@ -1238,7 +1165,6 @@ Validate and copy a mapping into the requested typed model. --- - ### method `to_dict` ```python @@ -1249,7 +1175,6 @@ Return the same detached representation as ``to_mapping()``. --- - ### method `to_mapping` ```python @@ -1261,7 +1186,6 @@ Return a detached, JSON-compatible mapping for serialization. --- - ## class `RunOutput` Normalized adapter output. @@ -1269,7 +1193,6 @@ Normalized adapter output. ``response`` is a known adapter response field whose value follows the core NeMo Fabric JSON contract. Other keys are adapter-specific extensions. - ### Fields The mapping exposes the following typed fields: @@ -1305,7 +1228,6 @@ Return the raw ``response`` JSON value, or ``None`` when absent. --- - ### classmethod `from_mapping` ```python @@ -1316,7 +1238,6 @@ Validate and copy a mapping into the requested typed model. --- - ### method `to_dict` ```python @@ -1327,7 +1248,6 @@ Return the same detached representation as ``to_mapping()``. --- - ### method `to_mapping` ```python @@ -1339,7 +1259,6 @@ Return a detached, JSON-compatible mapping for serialization. --- - ## class `RunUsage` Normalized invocation-local usage reported by an adapter target. @@ -1347,7 +1266,6 @@ Normalized invocation-local usage reported by an adapter target. Missing counters and cost are unknown, not zero. ``cached_input_tokens`` records cache usage; ``input_tokens_include_cache`` declares whether input already includes that count. An absent flag means the semantics are unknown. Descriptor-declared detailed usage is preserved in ``metadata``. - ### Fields The mapping exposes the following typed fields: @@ -1383,7 +1301,6 @@ Return an immutable view of preserved extension fields. --- - ### classmethod `from_mapping` ```python @@ -1394,7 +1311,6 @@ Validate and copy a mapping into the requested typed model. --- - ### method `to_dict` ```python @@ -1405,7 +1321,6 @@ Return the same detached representation as ``to_mapping()``. --- - ### method `to_mapping` ```python @@ -1417,7 +1332,6 @@ Return a detached, JSON-compatible mapping for serialization. --- - ## class `RuntimeResourceSnapshot` Timestamped resource usage of one runtime's local adapter host process tree. @@ -1436,7 +1350,6 @@ Memory, process count, and CPU time cover the adapter host process and its live - `cpu_time_seconds`: CPU time used since the adapter host started, including exited descendants. It can drop when a descendant leaves the tree. - ### Fields The mapping exposes the following typed fields: @@ -1471,7 +1384,6 @@ Return an immutable view of preserved extension fields. --- - ### classmethod `from_mapping` ```python @@ -1482,7 +1394,6 @@ Validate and copy a mapping into the requested typed model. --- - ### method `to_dict` ```python @@ -1493,7 +1404,6 @@ Return the same detached representation as ``to_mapping()``. --- - ### method `to_mapping` ```python @@ -1505,7 +1415,6 @@ Return a detached, JSON-compatible mapping for serialization. --- - ## class `ResourceObservation` Adapter host process-tree reading, or an explicit collection failure. @@ -1519,7 +1428,6 @@ Adapter host process-tree reading, or an explicit collection failure. - `reason`: Present only when unavailable or unsupported. - ### Fields The mapping exposes the following typed fields: @@ -1561,7 +1469,6 @@ Return the reading when available, otherwise None. --- - ### classmethod `from_mapping` ```python @@ -1572,7 +1479,6 @@ Validate and copy a mapping into the requested typed model. --- - ### method `to_dict` ```python @@ -1583,7 +1489,6 @@ Return the same detached representation as ``to_mapping()``. --- - ### method `to_mapping` ```python @@ -1595,7 +1500,6 @@ Return a detached, JSON-compatible mapping for serialization. --- - ## class `Measurement` Measured numeric value, an unlimited limit, or explicit missing evidence. @@ -1609,7 +1513,6 @@ Measured numeric value, an unlimited limit, or explicit missing evidence. - `reason`: Collection failure/capability reason, otherwise None. - ### Fields The mapping exposes the following typed fields: @@ -1651,7 +1554,6 @@ Return an available reading, otherwise None. --- - ### classmethod `from_mapping` ```python @@ -1662,7 +1564,6 @@ Validate and copy a mapping into the requested typed model. --- - ### method `to_dict` ```python @@ -1673,7 +1574,6 @@ Return the same detached representation as ``to_mapping()``. --- - ### method `to_mapping` ```python @@ -1685,7 +1585,6 @@ Return a detached, JSON-compatible mapping for serialization. --- - ## class `CgroupSnapshot` Native adapter-host cgroup measurements, separate from process RSS. @@ -1693,7 +1592,6 @@ Native adapter-host cgroup measurements, separate from process RSS. Counters cover this cgroup and its descendants, potentially including other workloads. Limits are visible ancestor bounds; hidden ancestors and actual exclusive capacity are unknown. memory_peak_bytes covers cgroup lifetime or its last external reset, never this invocation's peak. - ### Fields The mapping exposes the following typed fields: @@ -1735,7 +1633,6 @@ Return an immutable view of preserved extension fields. --- - ### classmethod `from_mapping` ```python @@ -1746,7 +1643,6 @@ Validate and copy a mapping into the requested typed model. --- - ### method `to_dict` ```python @@ -1757,7 +1653,6 @@ Return the same detached representation as ``to_mapping()``. --- - ### method `to_mapping` ```python @@ -1769,13 +1664,11 @@ Return a detached, JSON-compatible mapping for serialization. --- - ## class `CgroupObservation` Native cgroup snapshot or explicit unavailable/unsupported reason. - ### Fields The mapping exposes the following typed fields: @@ -1817,7 +1710,6 @@ Return the cgroup reading, otherwise None. --- - ### classmethod `from_mapping` ```python @@ -1828,7 +1720,6 @@ Validate and copy a mapping into the requested typed model. --- - ### method `to_dict` ```python @@ -1839,7 +1730,6 @@ Return the same detached representation as ``to_mapping()``. --- - ### method `to_mapping` ```python @@ -1851,13 +1741,11 @@ Return a detached, JSON-compatible mapping for serialization. --- - ## class `CgroupStats` Cgroup endpoint observations and counter deltas over an invocation. - ### Fields The mapping exposes the following typed fields: @@ -1891,7 +1779,6 @@ Return an immutable view of preserved extension fields. --- - ### classmethod `from_mapping` ```python @@ -1902,7 +1789,6 @@ Validate and copy a mapping into the requested typed model. --- - ### method `to_dict` ```python @@ -1913,7 +1799,6 @@ Return the same detached representation as ``to_mapping()``. --- - ### method `to_mapping` ```python @@ -1925,7 +1810,6 @@ Return a detached, JSON-compatible mapping for serialization. --- - ## class `GaugeSummary` Sampled gauge extrema, hold-last-value mean, and covered time. @@ -1933,7 +1817,6 @@ Sampled gauge extrema, hold-last-value mean, and covered time. Sampled peak is not true high-water. Means exclude missing readings and gaps longer than twice the requested cadence. covered_fraction discloses how much of the invocation contributes to the mean. - ### Fields The mapping exposes the following typed fields: @@ -1970,7 +1853,6 @@ Return an immutable view of preserved extension fields. --- - ### classmethod `from_mapping` ```python @@ -1981,7 +1863,6 @@ Validate and copy a mapping into the requested typed model. --- - ### method `to_dict` ```python @@ -1992,7 +1873,6 @@ Return the same detached representation as ``to_mapping()``. --- - ### method `to_mapping` ```python @@ -2004,13 +1884,11 @@ Return a detached, JSON-compatible mapping for serialization. --- - ## class `SampleSummary` Retained native series, reductions, and collection-quality diagnostics. - ### Fields The mapping exposes the following typed fields: @@ -2053,7 +1931,6 @@ Return an immutable view of preserved extension fields. --- - ### classmethod `from_mapping` ```python @@ -2064,7 +1941,6 @@ Validate and copy a mapping into the requested typed model. --- - ### method `to_dict` ```python @@ -2075,7 +1951,6 @@ Return the same detached representation as ``to_mapping()``. --- - ### method `to_mapping` ```python @@ -2087,7 +1962,6 @@ Return a detached, JSON-compatible mapping for serialization. --- - ## class `InvocationSystemStats` System measurements for one invocation in its executing runner. @@ -2106,7 +1980,6 @@ Observations cover the adapter host process tree, excluding the orchestrator and - `cpu_time_seconds_estimate`: Nonnegative CPU counter difference, or None if an endpoint is missing, identities differ, or the counter fell. Process reaping and reparenting can make this underestimate work. - ### Fields The mapping exposes the following typed fields: @@ -2147,7 +2020,6 @@ Return an immutable view of preserved extension fields. --- - ### classmethod `from_mapping` ```python @@ -2158,7 +2030,6 @@ Validate and copy a mapping into the requested typed model. --- - ### method `to_dict` ```python @@ -2169,7 +2040,6 @@ Return the same detached representation as ``to_mapping()``. --- - ### method `to_mapping` ```python @@ -2181,35 +2051,85 @@ Return a detached, JSON-compatible mapping for serialization. --- +## class `PricingBasis` -## class `RunResult` +Validated comparison basis for persisted catalog estimates. -Normalized terminal result from one NeMo Fabric invocation. -The model is both attribute-accessible and mapping-compatible. A harness failure can be represented by ``status`` and ``error`` without raising when the adapter successfully returns a normalized result. +### Fields +The mapping exposes the following typed fields: +| Field | Type | +| --- | --- | +| `basis` | `str` | +| `currency` | `str` | +| `normalization_version` | `str` | +| `assumption_policy` | `str` | +| `bundled_catalog_sha256` | `str` | -**Attributes:** +### method `__init__` + +```python +def __init__(mapping: Mapping[str, Any]) -> None +``` + + + + + + +--- + +### property extra_fields + +Return an immutable view of preserved extension fields. + + + +--- + +### property user_catalog_sha256 + +Return the user catalog fingerprint when supplied, otherwise None. + + +--- + +### classmethod `from_mapping` + +```python +def from_mapping(mapping: Mapping[str, Any]) -> Self +``` + +Validate and copy a mapping into the requested typed model. + +--- + +### method `to_dict` + +```python +def to_dict() -> dict[str, Any] +``` + +Return the same detached representation as ``to_mapping()``. + +--- + +### method `to_mapping` + +```python +def to_mapping() -> dict[str, Any] +``` + +Return a detached, JSON-compatible mapping for serialization. - - `agent_name`: Resolved agent name. - - `harness`: Stable harness identifier. - - `adapter_kind`: Adapter execution mechanism. - - `adapter_id`: NeMo Fabric adapter identifier. - - `runtime_id`: Runtime lifecycle identifier. - - `invocation_id`: Identifier for this invocation. - - `request_id`: Correlated request identifier. - - `status`: Terminal invocation status. - - `output`: Object-shaped adapter output as ``RunOutput``; non-object values are preserved as-is. - - `error`: Structured failure, or ``None`` on success. - - `usage`: Normalized invocation usage, or ``None`` when unavailable. - - `resource_snapshot`: Runtime resource snapshot read once after the adapter returned this result, or ``None`` when the runtime's resources could not be observed. - - `system_stats`: Invocation duration and endpoint observations, or None for older results or execution backends without collection. - - `artifacts`: Normalized artifact manifest. - - `telemetry`: Ordered telemetry references. - - `events`: Ordered lifecycle and invocation events. - - `metadata`: Result-specific structured details. +--- + +## class `AppliedPricing` + +Selected price entry and integer nano-USD rates per million tokens. ### Fields @@ -2218,23 +2138,12 @@ The mapping exposes the following typed fields: | Field | Type | | --- | --- | -| `agent_name` | `str` | -| `harness` | `str` | -| `adapter_kind` | `str` | -| `adapter_id` | `str` | -| `runtime_id` | `str` | -| `invocation_id` | `str` | -| `request_id` | `str` | -| `status` | `str` | -| `output` | `RunOutput \| JSONValue` | -| `error` | `ErrorInfo \| None` | -| `usage` | `RunUsage \| None` | -| `system_stats` | `InvocationSystemStats \| None` | -| `resource_snapshot` | `RuntimeResourceSnapshot \| None` | -| `artifacts` | `ArtifactManifest` | -| `telemetry` | `Sequence[TelemetryRef]` | -| `events` | `Sequence[FabricEvent]` | -| `metadata` | `Mapping[str, Any]` | +| `catalog_sha256` | `str` | +| `provider` | `str` | +| `model_id` | `str` | +| `pricing_as_of` | `str` | +| `pricing_source` | `str` | +| `rates` | `dict[str, int \| None]` | ### method `__init__` @@ -2257,7 +2166,6 @@ Return an immutable view of preserved extension fields. --- - ### classmethod `from_mapping` ```python @@ -2268,7 +2176,6 @@ Validate and copy a mapping into the requested typed model. --- - ### method `to_dict` ```python @@ -2279,6 +2186,542 @@ Return the same detached representation as ``to_mapping()``. --- +### method `to_mapping` + +```python +def to_mapping() -> dict[str, Any] +``` + +Return a detached, JSON-compatible mapping for serialization. + + +--- + +## class `TokenCounts` + +Independently nullable tokens in one fixed invocation/model scope. + + +### Fields + +The mapping exposes the following typed fields: + +| Field | Type | +| --- | --- | +| `input_tokens` | `int \| None` | +| `cached_input_tokens` | `int \| None` | +| `cache_write_input_tokens` | `int \| None` | +| `output_tokens` | `int \| None` | +| `total_tokens` | `int \| None` | + +### method `__init__` + +```python +def __init__(mapping: Mapping[str, Any]) -> None +``` + + + + + + +--- + +### property extra_fields + +Return an immutable view of preserved extension fields. + + + +--- + +### classmethod `from_mapping` + +```python +def from_mapping(mapping: Mapping[str, Any]) -> Self +``` + +Validate and copy a mapping into the requested typed model. + +--- + +### method `to_dict` + +```python +def to_dict() -> dict[str, Any] +``` + +Return the same detached representation as ``to_mapping()``. + +--- + +### method `to_mapping` + +```python +def to_mapping() -> dict[str, Any] +``` + +Return a detached, JSON-compatible mapping for serialization. + + +--- + +## class `UsageGap` + +Dimension-specific incompleteness with an optional stable diagnostic. + + +### Fields + +The mapping exposes the following typed fields: + +| Field | Type | +| --- | --- | +| `dimensions` | `tuple[str, ...]` | +| `reason` | `str` | +| `code` | `str \| None` | + +### method `__init__` + +```python +def __init__(mapping: Mapping[str, Any]) -> None +``` + + + + + + +--- + +### property extra_fields + +Return an immutable view of preserved extension fields. + + + +--- + +### classmethod `from_mapping` + +```python +def from_mapping(mapping: Mapping[str, Any]) -> Self +``` + +Validate and copy a mapping into the requested typed model. + +--- + +### method `to_dict` + +```python +def to_dict() -> dict[str, Any] +``` + +Return the same detached representation as ``to_mapping()``. + +--- + +### method `to_mapping` + +```python +def to_mapping() -> dict[str, Any] +``` + +Return a detached, JSON-compatible mapping for serialization. + + +--- + +## class `CostAmount` + +Complete and observed amounts in integer nano-USD, never an invoice guarantee. + + +### Fields + +The mapping exposes the following typed fields: + +| Field | Type | +| --- | --- | +| `amount_nano_usd` | `int \| None` | +| `observed_nano_usd` | `int \| None` | + +### method `__init__` + +```python +def __init__(mapping: Mapping[str, Any]) -> None +``` + + + + + + +--- + +### property extra_fields + +Return an immutable view of preserved extension fields. + + + +--- + +### classmethod `from_mapping` + +```python +def from_mapping(mapping: Mapping[str, Any]) -> Self +``` + +Validate and copy a mapping into the requested typed model. + +--- + +### method `to_dict` + +```python +def to_dict() -> dict[str, Any] +``` + +Return the same detached representation as ``to_mapping()``. + +--- + +### method `to_mapping` + +```python +def to_mapping() -> dict[str, Any] +``` + +Return a detached, JSON-compatible mapping for serialization. + + +--- + +## class `TokenCost` + +Provider-reported amounts and separate catalog estimates with their basis. + + +### Fields + +The mapping exposes the following typed fields: + +| Field | Type | +| --- | --- | +| `reported` | `CostAmount` | +| `estimated` | `CostAmount` | +| `pricing` | `PricingBasis \| None` | +| `applied_rates` | `tuple[AppliedPricing, ...]` | + +### method `__init__` + +```python +def __init__(mapping: Mapping[str, Any]) -> None +``` + + + + + + +--- + +### property extra_fields + +Return an immutable view of preserved extension fields. + + + +--- + +### classmethod `from_mapping` + +```python +def from_mapping(mapping: Mapping[str, Any]) -> Self +``` + +Validate and copy a mapping into the requested typed model. + +--- + +### method `to_dict` + +```python +def to_dict() -> dict[str, Any] +``` + +Return the same detached representation as ``to_mapping()``. + +--- + +### method `to_mapping` + +```python +def to_mapping() -> dict[str, Any] +``` + +Return a detached, JSON-compatible mapping for serialization. + + +--- + +## class `PerModelUsage` + +Normalized counts for a source-established accounting model/provider. + +A configured identity requires source proof that the included calls used it. An outer requested alias alone does not establish accounting attribution. This identity does not replace served-model provenance. + + +### Fields + +The mapping exposes the following typed fields: + +| Field | Type | +| --- | --- | +| `model` | `str` | +| `input_tokens` | `int \| None` | +| `cached_input_tokens` | `int \| None` | +| `cache_write_input_tokens` | `int \| None` | +| `output_tokens` | `int \| None` | +| `total_tokens` | `int \| None` | +| `observed` | `TokenCounts` | +| `gaps` | `tuple[UsageGap, ...]` | +| `cost` | `TokenCost` | + +### method `__init__` + +```python +def __init__(mapping: Mapping[str, Any]) -> None +``` + + + + + + +--- + +### property extra_fields + +Return an immutable view of preserved extension fields. + + + +--- + +### property provider + +Return the accounting provider when reported, otherwise None. + + +--- + +### classmethod `from_mapping` + +```python +def from_mapping(mapping: Mapping[str, Any]) -> Self +``` + +Validate and copy a mapping into the requested typed model. + +--- + +### method `to_dict` + +```python +def to_dict() -> dict[str, Any] +``` + +Return the same detached representation as ``to_mapping()``. + +--- + +### method `to_mapping` + +```python +def to_mapping() -> dict[str, Any] +``` + +Return a detached, JSON-compatible mapping for serialization. + + +--- + +## class `ModelUsage` + +Complete normalized tokens, observations, gaps and separately typed costs. + +Input includes cache and output includes reasoning. All counts belong to this invocation, including descendants. Late work never moves to another invocation. Observations are not guaranteed mathematical lower bounds. + + +### Fields + +The mapping exposes the following typed fields: + +| Field | Type | +| --- | --- | +| `input_tokens` | `int \| None` | +| `cached_input_tokens` | `int \| None` | +| `cache_write_input_tokens` | `int \| None` | +| `output_tokens` | `int \| None` | +| `total_tokens` | `int \| None` | +| `observed` | `TokenCounts` | +| `gaps` | `tuple[UsageGap, ...]` | +| `models_exhaustive` | `bool` | +| `models` | `tuple[PerModelUsage, ...]` | +| `cost` | `TokenCost` | + +### method `__init__` + +```python +def __init__(mapping: Mapping[str, Any]) -> None +``` + + + + + + +--- + +### property extra_fields + +Return an immutable view of preserved extension fields. + + + +--- + +### classmethod `from_mapping` + +```python +def from_mapping(mapping: Mapping[str, Any]) -> Self +``` + +Validate and copy a mapping into the requested typed model. + +--- + +### method `to_dict` + +```python +def to_dict() -> dict[str, Any] +``` + +Return the same detached representation as ``to_mapping()``. + +--- + +### method `to_mapping` + +```python +def to_mapping() -> dict[str, Any] +``` + +Return a detached, JSON-compatible mapping for serialization. + + +--- + +## class `RunResult` + +Normalized terminal result from one NeMo Fabric invocation. + +The model is both attribute-accessible and mapping-compatible. A harness failure can be represented by ``status`` and ``error`` without raising when the adapter successfully returns a normalized result. + + + +**Attributes:** + + - `agent_name`: Resolved agent name. + - `harness`: Stable harness identifier. + - `adapter_kind`: Adapter execution mechanism. + - `adapter_id`: NeMo Fabric adapter identifier. + - `runtime_id`: Runtime lifecycle identifier. + - `invocation_id`: Identifier for this invocation. + - `request_id`: Correlated request identifier. + - `status`: Terminal invocation status. + - `output`: Object-shaped adapter output as ``RunOutput``; non-object values are preserved as-is. + - `error`: Structured failure, or ``None`` on success. + - `usage`: Normalized invocation usage, or ``None`` when unavailable. + - `model_usage`: Complete counts, observations and accounting gaps, or ``None`` when supported accounting evidence is unavailable. + - `resource_snapshot`: Runtime resource snapshot read once after the adapter returned this result, or ``None`` when the runtime's resources could not be observed. + - `system_stats`: Invocation duration and endpoint observations, or None for older results or execution backends without collection. + - `artifacts`: Normalized artifact manifest. + - `telemetry`: Ordered telemetry references. + - `events`: Ordered lifecycle and invocation events. + - `metadata`: Result-specific structured details. + - `what_ran`: What executed for this invocation, schema ``nemo.fabric.what_ran`` version ``v1alpha1``, as a JSON mapping. Each fact holds ``value``, or ``state`` and ``reason``, plus ``source``, ``collected_by`` and ``method``. A minimal object that the byte budget left holds only ``schema``, ``version`` and ``what_ran_truncated``, and reads as present. ``None`` when the producing core predates it, or when the value is not a ``what_ran`` object of that schema and version with every present section and fact well formed, as the Rust core reads it. + + +### Fields + +The mapping exposes the following typed fields: + +| Field | Type | +| --- | --- | +| `agent_name` | `str` | +| `harness` | `str` | +| `adapter_kind` | `str` | +| `adapter_id` | `str` | +| `runtime_id` | `str` | +| `invocation_id` | `str` | +| `request_id` | `str` | +| `status` | `str` | +| `output` | `RunOutput \| JSONValue` | +| `error` | `ErrorInfo \| None` | +| `usage` | `RunUsage \| None` | +| `model_usage` | `ModelUsage \| None` | +| `system_stats` | `InvocationSystemStats \| None` | +| `resource_snapshot` | `RuntimeResourceSnapshot \| None` | +| `artifacts` | `ArtifactManifest` | +| `telemetry` | `Sequence[TelemetryRef]` | +| `events` | `Sequence[FabricEvent]` | +| `metadata` | `Mapping[str, Any]` | +| `what_ran` | `Mapping[str, Any] \| None` | + +### method `__init__` + +```python +def __init__(mapping: Mapping[str, Any]) -> None +``` + + + + + + +--- + +### property extra_fields + +Return an immutable view of preserved extension fields. + + + +--- + +### classmethod `from_mapping` + +```python +def from_mapping(mapping: Mapping[str, Any]) -> Self +``` + +Validate and copy a mapping into the requested typed model. + +--- + +### method `to_dict` + +```python +def to_dict() -> dict[str, Any] +``` + +Return the same detached representation as ``to_mapping()``. + +--- ### method `to_mapping` diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/adapter-contract/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/adapter-contract/index.mdx index 2e5a5fe6f..8d4a1c519 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/adapter-contract/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/adapter-contract/index.mdx @@ -3,7 +3,7 @@ title: "Module adapter_contract" sidebar-title: "adapter_contract" slug: "/reference/api/rust-library-reference/nemo-fabric-core/adapter_contract" description: "Shared southbound adapter contract metadata." -position: 161 +position: 162 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/agent-config/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/agent-config/index.mdx index 4dda6c293..147bf4df9 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/agent-config/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/agent-config/index.mdx @@ -3,7 +3,7 @@ title: "Module agent_config" sidebar-title: "agent_config" slug: "/reference/api/rust-library-reference/nemo-fabric-core/agent_config" description: "Configuration projected southbound to an adapter target." -position: 162 +position: 163 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/agent-execution/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/agent-execution/index.mdx index 79551b9e4..5cd38623e 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/agent-execution/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/agent-execution/index.mdx @@ -3,7 +3,7 @@ title: "Module agent_execution" sidebar-title: "agent_execution" slug: "/reference/api/rust-library-reference/nemo-fabric-core/agent_execution" description: "Request and result structures exchanged with an adapter target." -position: 163 +position: 164 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/check/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/check/index.mdx index 7befb2c5e..65b259e40 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/check/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/check/index.mdx @@ -2,7 +2,7 @@ title: "Module check" sidebar-title: "check" description: "Runtime health checks and shared contracts." -position: 164 +position: 165 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitykind.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitykind.mdx index 40e387ace..65c0f3eaf 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitykind.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitykind.mdx @@ -2,7 +2,7 @@ title: "Enum Capability Kind" sidebar-title: "CapabilityKind" description: "Capability kind." -position: 66 +position: 67 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitytarget.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitytarget.mdx index e121e6b73..010851195 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitytarget.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-capabilitytarget.mdx @@ -2,7 +2,7 @@ title: "Enum Capability Target" sidebar-title: "CapabilityTarget" description: "Component responsible for executing a configured capability." -position: 67 +position: 68 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatifstorageconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatifstorageconfig.mdx index 74dc754f8..b1e6d19ac 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatifstorageconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatifstorageconfig.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Atif Storage Config" sidebar-title: "RelayAtifStorageConfig" description: "Relay ATIF remote storage configuration." -position: 76 +position: 77 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofmode.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofmode.mdx index bbf8ff9e5..2e4357780 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofmode.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofmode.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Atof Mode" sidebar-title: "RelayAtofMode" description: "Relay ATOF file mode." -position: 77 +position: 78 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofsinkconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofsinkconfig.mdx index d53f00f8d..d699b5db0 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofsinkconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofsinkconfig.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Atof Sink Config" sidebar-title: "RelayAtofSinkConfig" description: "Relay ATOF sink configuration." -position: 78 +position: 79 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofstreamfieldnamepolicy.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofstreamfieldnamepolicy.mdx index 892413a9e..9ee176668 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofstreamfieldnamepolicy.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofstreamfieldnamepolicy.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Atof Stream Field Name Policy" sidebar-title: "RelayAtofStreamFieldNamePolicy" description: "Relay ATOF stream field-name policy." -position: 79 +position: 80 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofstreamtransport.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofstreamtransport.mdx index ea48753dd..a90cec5f3 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofstreamtransport.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayatofstreamtransport.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Atof Stream Transport" sidebar-title: "RelayAtofStreamTransport" description: "Relay ATOF stream transport." -position: 80 +position: 81 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayopentelemetryendpointtype.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayopentelemetryendpointtype.mdx index beb3da6eb..ba9321f6c 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayopentelemetryendpointtype.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayopentelemetryendpointtype.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Open Telemetry Endpoint Type" sidebar-title: "RelayOpenTelemetryEndpointType" description: "Relay OpenTelemetry span projection." -position: 81 +position: 82 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayopentelemetrymarkprojection.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayopentelemetrymarkprojection.mdx index da13057aa..9b78e9a7e 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayopentelemetrymarkprojection.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayopentelemetrymarkprojection.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Open Telemetry Mark Projection" sidebar-title: "RelayOpenTelemetryMarkProjection" description: "Relay OpenTelemetry mark projection policy." -position: 82 +position: 83 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayotlptransport.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayotlptransport.mdx index 8db6e0872..a73f2ad1b 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayotlptransport.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayotlptransport.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Otlp Transport" sidebar-title: "RelayOtlpTransport" description: "Relay OTLP transport." -position: 83 +position: 84 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayunsupportedbehavior.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayunsupportedbehavior.mdx index 95ec26616..506a2c506 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayunsupportedbehavior.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-relayunsupportedbehavior.mdx @@ -2,7 +2,7 @@ title: "Enum Relay Unsupported Behavior" sidebar-title: "RelayUnsupportedBehavior" description: "Relay unsupported/unknown config handling." -position: 84 +position: 85 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-resolutionstrategy.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-resolutionstrategy.mdx index d6660e039..f648d0a25 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-resolutionstrategy.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-resolutionstrategy.mdx @@ -2,7 +2,7 @@ title: "Enum Resolution Strategy" sidebar-title: "ResolutionStrategy" description: "Adapter install or availability strategy." -position: 73 +position: 74 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-telemetryprovider.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-telemetryprovider.mdx index b8b147e34..c469c5bc7 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-telemetryprovider.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/enum-telemetryprovider.mdx @@ -2,7 +2,7 @@ title: "Enum Telemetry Provider" sidebar-title: "TelemetryProvider" description: "Telemetry runtime provider." -position: 83 +position: 84 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-load-adapter-descriptor.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-load-adapter-descriptor.mdx index a065134f8..771f41e95 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-load-adapter-descriptor.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-load-adapter-descriptor.mdx @@ -2,7 +2,7 @@ title: "Function load_adapter_descriptor" sidebar-title: "load_adapter_descriptor" description: "Load an adapter descriptor from JSON package metadata." -position: 90 +position: 91 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-load-adapter-target-descriptor.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-load-adapter-target-descriptor.mdx index 5a2da278f..380f348da 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-load-adapter-target-descriptor.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-load-adapter-target-descriptor.mdx @@ -2,7 +2,7 @@ title: "Function load_adapter_target_descriptor" sidebar-title: "load_adapter_target_descriptor" description: "Load an Adapter Target Descriptor from declarative JSON metadata." -position: 91 +position: 92 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-resolve-run-plan-from-config.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-resolve-run-plan-from-config.mdx index 040c00886..ab974b263 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-resolve-run-plan-from-config.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/fn-resolve-run-plan-from-config.mdx @@ -2,7 +2,7 @@ title: "Function resolve_run_plan_from_config" sidebar-title: "resolve_run_plan_from_config" description: "Resolve a typed NVIDIA NeMo Fabric config into a runnable plan." -position: 92 +position: 93 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/index.mdx index 8e7bcd2eb..e1a423c2a 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/index.mdx @@ -2,7 +2,7 @@ title: "Module config" sidebar-title: "config" description: "NeMo Fabric config models and loading helpers." -position: 165 +position: 166 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} @@ -53,6 +53,7 @@ NeMo Fabric config models and loading helpers. - [McpServerPlan](struct-mcpserverplan.mdx): Resolved MCP server exposure. - [MetadataConfig](struct-metadataconfig.mdx): Human-readable metadata. - [ModelConfig](struct-modelconfig.mdx): Model configuration. +- [PricingConfig](struct-pricingconfig.mdx): Central token-price configuration; never changes provider requests. - [RelayAtifConfig](struct-relayatifconfig.mdx): Relay ATIF export configuration. - [RelayAtofConfig](struct-relayatofconfig.mdx): Relay ATOF export configuration. - [RelayComponentConfig](struct-relaycomponentconfig.mdx): Generic NeMo Relay plugin component configuration. diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-fabricconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-fabricconfig.mdx index 23905de60..b9fa8bbbf 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-fabricconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-fabricconfig.mdx @@ -9,7 +9,7 @@ SPDX-License-Identifier: Apache-2.0 */} Generated from `cargo doc --no-deps -p nemo-fabric-core`. -
String,\n    pub metadata: MetadataConfig,\n    pub harness: Option<HarnessConfig>,\n    pub workflow: Option<WorkflowConfig>,\n    pub discovery: Option<DiscoveryConfig>,\n    pub models: BTreeMap<String, ModelConfig>,\n    pub instructions: Option<InstructionsConfig>,\n    pub runtime: RuntimeConfig,\n    pub environment: Option<EnvironmentConfig>,\n    pub tools: Option<ToolsConfig>,\n    pub skills: Option<SkillConfig>,\n    pub mcp: Option<McpConfig>,\n    pub telemetry: Option<TelemetryConfig>,\n    pub system_stats: Option<SystemStatsConfig>,\n    pub relay: Option<RelayConfig>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+
String,\n    pub metadata: MetadataConfig,\n    pub harness: Option<HarnessConfig>,\n    pub workflow: Option<WorkflowConfig>,\n    pub discovery: Option<DiscoveryConfig>,\n    pub models: BTreeMap<String, ModelConfig>,\n    pub instructions: Option<InstructionsConfig>,\n    pub runtime: RuntimeConfig,\n    pub environment: Option<EnvironmentConfig>,\n    pub tools: Option<ToolsConfig>,\n    pub skills: Option<SkillConfig>,\n    pub mcp: Option<McpConfig>,\n    pub telemetry: Option<TelemetryConfig>,\n    pub system_stats: Option<SystemStatsConfig>,\n    pub relay: Option<RelayConfig>,\n    pub pricing: Option<PricingConfig>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
Versioned NVIDIA NeMo Fabric agent config. @@ -77,6 +77,10 @@ Invocation stats: omitted means all endpoint groups, explicit null disables. First-class NeMo Relay integration configuration. +### `pricing: Option` + +Optional catalog override for central token-price estimates. + ### `extensions: BTreeMap` Additive fields not yet recognized by this core version. diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-pricingconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-pricingconfig.mdx new file mode 100644 index 000000000..560649bbc --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-pricingconfig.mdx @@ -0,0 +1,106 @@ +--- +title: "Struct Pricing Config" +sidebar-title: "PricingConfig" +description: "Central token-price configuration; never changes provider requests." +position: 73 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
Option<PathBuf>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+ +Central token-price configuration; never changes provider requests. + +## Fields + +### `catalog_path: Option` + +Relay-compatible v1 catalog, resolved relative to the config base directory. + +### `extensions: BTreeMap` + +Additive pricing configuration, preserved for future versions. + +## Trait Implementations + +### `impl Clone for PricingConfig` + +
Clone for PricingConfig"}} />
+ +#### `clone` + +
clone(&self) -> PricingConfig"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for PricingConfig` + +
Debug for PricingConfig"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl Default for PricingConfig` + +
Default for PricingConfig"}} />
+ +#### `default` + +
default() -> PricingConfig"}} />
+ +### `impl<'de> Deserialize<'de> for PricingConfig` + +
Deserialize<'de> for PricingConfig"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for PricingConfig` + +
PricingConfig"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for PricingConfig` + +
PartialEq for PricingConfig"}} />
+ +#### `eq` + +
eq(&self, other: &PricingConfig) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for PricingConfig` + +
Serialize for PricingConfig"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl StructuralPartialEq for PricingConfig` + +
StructuralPartialEq for PricingConfig"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayatifconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayatifconfig.mdx index 4a2989ac6..030a4323a 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayatifconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayatifconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Relay Atif Config" sidebar-title: "RelayAtifConfig" description: "Relay ATIF export configuration." -position: 38 +position: 39 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayatofconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayatofconfig.mdx index 4ff5d7de9..d595a140a 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayatofconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayatofconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Relay Atof Config" sidebar-title: "RelayAtofConfig" description: "Relay ATOF export configuration." -position: 39 +position: 40 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relaycomponentconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relaycomponentconfig.mdx index 56259d7a5..4c490558c 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relaycomponentconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relaycomponentconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Relay Component Config" sidebar-title: "RelayComponentConfig" description: "Generic NeMo Relay plugin component configuration." -position: 40 +position: 41 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayconfig.mdx index 570ef8d8e..ffc52a18c 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Relay Config" sidebar-title: "RelayConfig" description: "NeMo Relay integration configuration." -position: 41 +position: 42 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayconfigpolicy.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayconfigpolicy.mdx index 712c72795..8d9e4a386 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayconfigpolicy.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayconfigpolicy.mdx @@ -2,7 +2,7 @@ title: "Struct Relay Config Policy" sidebar-title: "RelayConfigPolicy" description: "Relay validation policy." -position: 42 +position: 43 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayobservabilityconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayobservabilityconfig.mdx index a01db8716..4db7528da 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayobservabilityconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayobservabilityconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Relay Observability Config" sidebar-title: "RelayObservabilityConfig" description: "NeMo Relay observability component configuration." -position: 43 +position: 44 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayopentelemetryconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayopentelemetryconfig.mdx index 4bade1462..922156d9f 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayopentelemetryconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayopentelemetryconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Relay Open Telemetry Config" sidebar-title: "RelayOpenTelemetryConfig" description: "Relay OpenTelemetry export section." -position: 44 +position: 45 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayopentelemetryendpointconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayopentelemetryendpointconfig.mdx index da8138f4e..6bb243f6c 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayopentelemetryendpointconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-relayopentelemetryendpointconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Relay Open Telemetry Endpoint Config" sidebar-title: "RelayOpenTelemetryEndpointConfig" description: "One typed Relay OpenTelemetry OTLP destination." -position: 45 +position: 46 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvecontext.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvecontext.mdx index bf70f64b3..e8823bacc 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvecontext.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvecontext.mdx @@ -2,7 +2,7 @@ title: "Struct Resolve Context" sidebar-title: "ResolveContext" description: "Source context used when resolving an in-memory NeMo Fabric config." -position: 74 +position: 75 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvedadapterdescriptor.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvedadapterdescriptor.mdx index 45d15648d..fe97a401c 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvedadapterdescriptor.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvedadapterdescriptor.mdx @@ -2,7 +2,7 @@ title: "Struct Resolved Adapter Descriptor" sidebar-title: "ResolvedAdapterDescriptor" description: "Adapter descriptor selected for a run plan." -position: 75 +position: 76 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvedadaptertargetdescriptor.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvedadaptertargetdescriptor.mdx index fffc24599..656050d94 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvedadaptertargetdescriptor.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-resolvedadaptertargetdescriptor.mdx @@ -2,7 +2,7 @@ title: "Struct Resolved Adapter Target Descriptor" sidebar-title: "ResolvedAdapterTargetDescriptor" description: "Adapter target descriptor selected for a run plan." -position: 76 +position: 77 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runplan.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runplan.mdx index 49fbd25e5..8fba030f0 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runplan.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runplan.mdx @@ -2,19 +2,23 @@ title: "Struct RunPlan" sidebar-title: "RunPlan" description: "Resolved NeMo Fabric run plan." -position: 77 +position: 78 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} Generated from `cargo doc --no-deps -p nemo-fabric-core`. -
String,\n    pub base_dir: PathBuf,\n    pub config: FabricConfig,\n    pub agent_config: AgentConfig,\n    pub adapter_descriptor: Option<ResolvedAdapterDescriptor>,\n    pub adapter_target_descriptor: Option<ResolvedAdapterTargetDescriptor>,\n    pub resolution: Option<ResolutionStrategy>,\n    pub environment_plan: Option<EnvironmentPlan>,\n    pub capability_plan: CapabilityPlan,\n    pub capabilities: RuntimeCapabilities,\n    pub telemetry_plan: Option<TelemetryPlan>,\n}"}} />
+
Option<UserPriceCatalog>,\n    pub agent_name: String,\n    pub base_dir: PathBuf,\n    pub config: FabricConfig,\n    pub agent_config: AgentConfig,\n    pub adapter_descriptor: Option<ResolvedAdapterDescriptor>,\n    pub adapter_target_descriptor: Option<ResolvedAdapterTargetDescriptor>,\n    pub resolution: Option<ResolutionStrategy>,\n    pub environment_plan: Option<EnvironmentPlan>,\n    pub capability_plan: CapabilityPlan,\n    pub capabilities: RuntimeCapabilities,\n    pub telemetry_plan: Option<TelemetryPlan>,\n}"}} />
Resolved NeMo Fabric run plan. ## Fields +### `user_price_catalog: Option` + +Catalog bytes are frozen during planning. + ### `agent_name: String` Stable agent name. diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimecapabilities.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimecapabilities.mdx index 208e98812..cdf90245a 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimecapabilities.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimecapabilities.mdx @@ -2,7 +2,7 @@ title: "Struct Runtime Capabilities" sidebar-title: "RuntimeCapabilities" description: "Lifecycle behavior implemented by a resolved runtime path." -position: 78 +position: 79 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimeconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimeconfig.mdx index ad38ed633..e08d74557 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimeconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-runtimeconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Runtime Config" sidebar-title: "RuntimeConfig" description: "Invocation runtime contract." -position: 79 +position: 80 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-skillconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-skillconfig.mdx index 983e0cfb0..beaf859fb 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-skillconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-skillconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Skill Config" sidebar-title: "SkillConfig" description: "Skill capability configuration." -position: 80 +position: 81 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryconfig.mdx index f9de3041b..d9742d216 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Telemetry Config" sidebar-title: "TelemetryConfig" description: "Telemetry configuration." -position: 81 +position: 82 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryplan.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryplan.mdx index d0e36269d..300d27972 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryplan.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryplan.mdx @@ -2,7 +2,7 @@ title: "Struct Telemetry Plan" sidebar-title: "TelemetryPlan" description: "Resolved telemetry plan." -position: 82 +position: 83 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryproviderconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryproviderconfig.mdx index d697615cd..1c8e0cecf 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryproviderconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-telemetryproviderconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Telemetry Provider Config" sidebar-title: "TelemetryProviderConfig" description: "Provider-specific telemetry configuration." -position: 84 +position: 85 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-tooldefinitionconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-tooldefinitionconfig.mdx index 7f692c549..2b6cae506 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-tooldefinitionconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-tooldefinitionconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Tool Definition Config" sidebar-title: "ToolDefinitionConfig" description: "One named normalized tool or tool-group definition." -position: 85 +position: 86 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsconfig.mdx index b162806a8..3b41dd2f2 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Tools Config" sidebar-title: "ToolsConfig" description: "Harness-neutral tool capability configuration." -position: 86 +position: 87 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsplan.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsplan.mdx index d2b51fe8b..ffe368f07 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsplan.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-toolsplan.mdx @@ -2,7 +2,7 @@ title: "Struct Tools Plan" sidebar-title: "ToolsPlan" description: "Normalized tool policy for a run." -position: 58 +position: 59 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-workflowconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-workflowconfig.mdx index 9e8ceecec..9063b9e88 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-workflowconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-workflowconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Workflow Config" sidebar-title: "WorkflowConfig" description: "Registered workflow target and immutable construction settings." -position: 87 +position: 88 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-workflowentrypointconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-workflowentrypointconfig.mdx index 2e0f21452..0e5f8edb1 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-workflowentrypointconfig.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-workflowentrypointconfig.mdx @@ -2,7 +2,7 @@ title: "Struct Workflow Entrypoint Config" sidebar-title: "WorkflowEntrypointConfig" description: "Adapter-owned workflow entry point supplied by an Adapter Target Descriptor." -position: 88 +position: 89 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-workflowtargetspec.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-workflowtargetspec.mdx index b7d212906..dd15a99a3 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-workflowtargetspec.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-workflowtargetspec.mdx @@ -2,7 +2,7 @@ title: "Struct Workflow Target Spec" sidebar-title: "WorkflowTargetSpec" description: "Workflow-specific Adapter Target Descriptor fields." -position: 89 +position: 90 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/enum-doctorstatus.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/enum-doctorstatus.mdx index b96f6000a..42f3e97eb 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/enum-doctorstatus.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/enum-doctorstatus.mdx @@ -2,7 +2,7 @@ title: "Enum Doctor Status" sidebar-title: "DoctorStatus" description: "Diagnostic status." -position: 95 +position: 96 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/fn-doctor-plan.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/fn-doctor-plan.mdx index d55dea1aa..16f0b6603 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/fn-doctor-plan.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/fn-doctor-plan.mdx @@ -2,7 +2,7 @@ title: "Function doctor_plan" sidebar-title: "doctor_plan" description: "Inspect a resolved run plan without mutating the environment." -position: 96 +position: 97 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/index.mdx index 5dbfbb2bd..0ae9dfa20 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/index.mdx @@ -2,7 +2,7 @@ title: "Module doctor" sidebar-title: "doctor" description: "Plan diagnostics for NeMo Fabric." -position: 166 +position: 167 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorcheck.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorcheck.mdx index 67f2f2311..44c9ea471 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorcheck.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorcheck.mdx @@ -2,7 +2,7 @@ title: "Struct Doctor Check" sidebar-title: "DoctorCheck" description: "Diagnostic check result." -position: 93 +position: 94 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorreport.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorreport.mdx index 70e98b43a..1a8233a35 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorreport.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/struct-doctorreport.mdx @@ -2,7 +2,7 @@ title: "Struct Doctor Report" sidebar-title: "DoctorReport" description: "Diagnostic report for a resolved run plan." -position: 94 +position: 95 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/error/enum-fabricerror.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/error/enum-fabricerror.mdx index 18ad7379a..52b15b93a 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/error/enum-fabricerror.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/error/enum-fabricerror.mdx @@ -2,7 +2,7 @@ title: "Enum Fabric Error" sidebar-title: "FabricError" description: "Errors raised by NeMo Fabric config loading and validation." -position: 97 +position: 98 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/error/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/error/index.mdx index 3b94b0d4f..e7cc80fb2 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/error/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/error/index.mdx @@ -2,7 +2,7 @@ title: "Module error" sidebar-title: "error" description: "Error types for NeMo Fabric core." -position: 167 +position: 168 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/error/type-result.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/error/type-result.mdx index ba2368fdd..b2d7e90a5 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/error/type-result.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/error/type-result.mdx @@ -2,7 +2,7 @@ title: "Type Alias Result" sidebar-title: "Result" description: "Core NeMo Fabric result type." -position: 98 +position: 99 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/fn-version.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/fn-version.mdx index d7449e870..763e6e69c 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/fn-version.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/fn-version.mdx @@ -2,7 +2,7 @@ title: "Function version" sidebar-title: "version" description: "Returns the crate version compiled into this build." -position: 172 +position: 178 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/index.mdx index f58d82358..67d3ee061 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/index.mdx @@ -85,6 +85,7 @@ Core config and runtime contract for NeMo Fabric. - `pub use config::MetadataConfig;` - `pub use config::ModelConfig;` - `pub use config::OAuthTokenEndpointAuthMethod;` +- `pub use config::PricingConfig;` - `pub use config::ResolutionStrategy;` - `pub use config::ResolveContext;` - `pub use config::ResolvedAdapterDescriptor;` @@ -183,10 +184,15 @@ Core config and runtime contract for NeMo Fabric. - [config](config/index.mdx): NeMo Fabric config models and loading helpers. - [doctor](doctor/index.mdx): Plan diagnostics for NeMo Fabric. - [error](error/index.mdx): Error types for NeMo Fabric core. +- [model_usage](model-usage/index.mdx): Invocation-owned normalized counts and separately identified costs. +- [price_catalog](price-catalog/index.mdx): Model price catalogs for estimating token cost. +- [pricing](pricing/index.mdx): Catalog prices are estimates, separate from provider-reported amounts. - [resource_snapshot](resource-snapshot/index.mdx): Resource snapshots of a runtime's local adapter host and its descendants. - [runtime](runtime/index.mdx): Runtime invocation helpers. - [schema](schema/index.mdx): JSON Schema generation for the public NeMo Fabric contract. - [system_stats](system-stats/index.mdx): Invocation measurements collected by NeMo Fabric in the executing runtime. +- [usage_accounting](usage-accounting/index.mdx): Validated accounting evidence carried through the existing usage extension. +- [what_ran](what-ran/index.mdx): What executed for one invocation, and how NeMo Fabric learned each fact. ## Functions diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/model-usage/enum-usagedimension.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/model-usage/enum-usagedimension.mdx new file mode 100644 index 000000000..420c36500 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/model-usage/enum-usagedimension.mdx @@ -0,0 +1,151 @@ +--- +title: "Enum Usage Dimension" +sidebar-title: "UsageDimension" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/model_usage/enum-usagedimension" +description: "Dimensions whose complete values can be unavailable independently." +position: 7 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +```rust +pub enum UsageDimension { + InputTokens, + CachedInputTokens, + CacheWriteInputTokens, + OutputTokens, + TotalTokens, + ReportedCost, + EstimatedCost, +} +``` + +Dimensions whose complete values can be unavailable independently. + +## Variants + +### `InputTokens` + +
+ +Input tokens. + +### `CachedInputTokens` + +
+ +Cache reads. + +### `CacheWriteInputTokens` + +
+ +Cache writes. + +### `OutputTokens` + +
+ +Output tokens. + +### `TotalTokens` + +
+ +Input plus output. + +### `ReportedCost` + +
+ +Provider-reported cost. + +### `EstimatedCost` + +
+ +Applicable catalog estimate. + +## Trait Implementations + +### `impl Clone for UsageDimension` + +
Clone for UsageDimension"}} />
+ +#### `clone` + +
clone(&self) -> UsageDimension"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for UsageDimension` + +
Debug for UsageDimension"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for UsageDimension` + +
Deserialize<'de> for UsageDimension"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for UsageDimension` + +
UsageDimension"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for UsageDimension` + +
PartialEq for UsageDimension"}} />
+ +#### `eq` + +
eq(&self, other: &UsageDimension) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for UsageDimension` + +
Serialize for UsageDimension"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Copy for UsageDimension` + +
Copy for UsageDimension"}} />
+ +### `impl Eq for UsageDimension` + +
Eq for UsageDimension"}} />
+ +### `impl StructuralPartialEq for UsageDimension` + +
StructuralPartialEq for UsageDimension"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/model-usage/fn-normalize-model-usage.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/model-usage/fn-normalize-model-usage.mdx new file mode 100644 index 000000000..14fe87912 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/model-usage/fn-normalize-model-usage.mdx @@ -0,0 +1,15 @@ +--- +title: "Function normalize_model_usage" +sidebar-title: "normalize_model_usage" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/model_usage/fn-normalize-model-usage" +description: "Normalize supported evidence. Legacy usage remains raw rather than gaining a completeness guarantee merely because a newer core read it." +position: 8 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
AgentUsage,\n    catalog: Option<&UserPriceCatalog>,\n    bundled_pricing_allowed: bool,\n) -> Result<Option<ModelUsage>, AccountingError>"}} />
+ +Normalize supported evidence. Legacy usage remains raw rather than gaining a completeness guarantee merely because a newer core read it. diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/model-usage/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/model-usage/index.mdx new file mode 100644 index 000000000..d57a991cd --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/model-usage/index.mdx @@ -0,0 +1,30 @@ +--- +title: "Module model_usage" +sidebar-title: "model_usage" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/model_usage" +description: "Invocation-owned normalized counts and separately identified costs." +position: 169 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +Invocation-owned normalized counts and separately identified costs. + +## Structs + +- [CostAmount](struct-costamount.mdx): Complete and observed amounts in integer nano-USD. +- [ModelUsage](struct-modelusage.mdx): Normalized accounting frozen at return. Late usage never moves to a later invocation. +- [PerModelUsage](struct-permodelusage.mdx): Normalized usage for one literal model/provider identity. +- [TokenCost](struct-tokencost.mdx): Provider-reported amounts stay separate from reproducible catalog estimates. +- [TokenCounts](struct-tokencounts.mdx): Token counts for one fixed invocation scope. Missing values never imply zero. +- [UsageGap](struct-usagegap.mdx): A completeness gap. Diagnostics never change the dimension or reason semantics. + +## Enums + +- [UsageDimension](enum-usagedimension.mdx): Dimensions whose complete values can be unavailable independently. + +## Functions + +- [normalize_model_usage](fn-normalize-model-usage.mdx): Normalize supported evidence. Legacy usage remains raw rather than gaining a completeness guarantee merely because a newer core read it. diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/model-usage/struct-costamount.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/model-usage/struct-costamount.mdx new file mode 100644 index 000000000..6b8c0b698 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/model-usage/struct-costamount.mdx @@ -0,0 +1,111 @@ +--- +title: "Struct Cost Amount" +sidebar-title: "CostAmount" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/model_usage/struct-costamount" +description: "Complete and observed amounts in integer nano-USD." +position: 1 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
Option<u64>,\n    pub observed_nano_usd: Option<u64>,\n}"}} />
+ +Complete and observed amounts in integer nano-USD. + +## Fields + +### `amount_nano_usd: Option` + +Complete amount for this invocation/model scope. + +### `observed_nano_usd: Option` + +Valid observed contributions, not a guaranteed bound or invoice. + +## Trait Implementations + +### `impl Clone for CostAmount` + +
Clone for CostAmount"}} />
+ +#### `clone` + +
clone(&self) -> CostAmount"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for CostAmount` + +
Debug for CostAmount"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl Default for CostAmount` + +
Default for CostAmount"}} />
+ +#### `default` + +
default() -> CostAmount"}} />
+ +### `impl<'de> Deserialize<'de> for CostAmount` + +
Deserialize<'de> for CostAmount"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for CostAmount` + +
CostAmount"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for CostAmount` + +
PartialEq for CostAmount"}} />
+ +#### `eq` + +
eq(&self, other: &CostAmount) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for CostAmount` + +
Serialize for CostAmount"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Eq for CostAmount` + +
Eq for CostAmount"}} />
+ +### `impl StructuralPartialEq for CostAmount` + +
StructuralPartialEq for CostAmount"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/model-usage/struct-modelusage.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/model-usage/struct-modelusage.mdx new file mode 100644 index 000000000..acdbc84d3 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/model-usage/struct-modelusage.mdx @@ -0,0 +1,119 @@ +--- +title: "Struct Model Usage" +sidebar-title: "ModelUsage" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/model_usage/struct-modelusage" +description: "Normalized accounting frozen at return. Late usage never moves to a later invocation." +position: 2 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
TokenCounts,\n    pub observed: TokenCounts,\n    pub gaps: Vec<UsageGap>,\n    pub models_exhaustive: bool,\n    pub models: Vec<PerModelUsage>,\n    pub cost: TokenCost,\n}"}} />
+ +Normalized accounting frozen at return. Late usage never moves to a later invocation. + +## Fields + +### `tokens: TokenCounts` + +Complete normalized token dimensions. + +### `observed: TokenCounts` + +Available deduplicated observations; these are not guaranteed lower bounds. + +### `gaps: Vec` + +Dimension-specific completeness gaps. + +### `models_exhaustive: bool` + +Whether all model identities used by owned work are represented. + +### `models: Vec` + +Literal per-model breakdown, when the source establishes attribution. + +### `cost: TokenCost` + +Separately typed provider-reported amounts and catalog estimates. + +## Trait Implementations + +### `impl Clone for ModelUsage` + +
Clone for ModelUsage"}} />
+ +#### `clone` + +
clone(&self) -> ModelUsage"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for ModelUsage` + +
Debug for ModelUsage"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for ModelUsage` + +
Deserialize<'de> for ModelUsage"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for ModelUsage` + +
ModelUsage"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for ModelUsage` + +
PartialEq for ModelUsage"}} />
+ +#### `eq` + +
eq(&self, other: &ModelUsage) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for ModelUsage` + +
Serialize for ModelUsage"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Eq for ModelUsage` + +
Eq for ModelUsage"}} />
+ +### `impl StructuralPartialEq for ModelUsage` + +
StructuralPartialEq for ModelUsage"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/model-usage/struct-permodelusage.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/model-usage/struct-permodelusage.mdx new file mode 100644 index 000000000..7d3c5bc19 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/model-usage/struct-permodelusage.mdx @@ -0,0 +1,119 @@ +--- +title: "Struct PerModel Usage" +sidebar-title: "PerModelUsage" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/model_usage/struct-permodelusage" +description: "Normalized usage for one literal model/provider identity." +position: 3 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
String,\n    pub provider: Option<String>,\n    pub tokens: TokenCounts,\n    pub observed: TokenCounts,\n    pub gaps: Vec<UsageGap>,\n    pub cost: TokenCost,\n}"}} />
+ +Normalized usage for one literal model/provider identity. + +## Fields + +### `model: String` + +Source-established accounting model identity. A configured identity requires proof that all included work used that model; an outer requested alias alone is insufficient. This is not served-model provenance. + +### `provider: Option` + +Literal provider identity when established. + +### `tokens: TokenCounts` + +Complete normalized token dimensions. + +### `observed: TokenCounts` + +Available deduplicated observations, possibly incomplete. + +### `gaps: Vec` + +Dimension-specific gaps. + +### `cost: TokenCost` + +Provider-reported and catalog-estimated cost. + +## Trait Implementations + +### `impl Clone for PerModelUsage` + +
Clone for PerModelUsage"}} />
+ +#### `clone` + +
clone(&self) -> PerModelUsage"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for PerModelUsage` + +
Debug for PerModelUsage"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for PerModelUsage` + +
Deserialize<'de> for PerModelUsage"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for PerModelUsage` + +
PerModelUsage"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for PerModelUsage` + +
PartialEq for PerModelUsage"}} />
+ +#### `eq` + +
eq(&self, other: &PerModelUsage) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for PerModelUsage` + +
Serialize for PerModelUsage"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Eq for PerModelUsage` + +
Eq for PerModelUsage"}} />
+ +### `impl StructuralPartialEq for PerModelUsage` + +
StructuralPartialEq for PerModelUsage"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/model-usage/struct-tokencost.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/model-usage/struct-tokencost.mdx new file mode 100644 index 000000000..f921a8086 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/model-usage/struct-tokencost.mdx @@ -0,0 +1,111 @@ +--- +title: "Struct Token Cost" +sidebar-title: "TokenCost" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/model_usage/struct-tokencost" +description: "Provider-reported amounts stay separate from reproducible catalog estimates." +position: 4 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
CostAmount,\n    pub estimated: CostAmount,\n    pub pricing: Option<PricingBasis>,\n    pub applied_rates: Vec<AppliedPricing>,\n}"}} />
+ +Provider-reported amounts stay separate from reproducible catalog estimates. + +## Fields + +### `reported: CostAmount` + +Provider-reported amounts, never a harness estimate. + +### `estimated: CostAmount` + +Catalog estimates; incomplete tariff evidence affects only these amounts. + +### `pricing: Option` + +Required basis for any estimated amount. + +### `applied_rates: Vec` + +Distinct selected rate sets, rather than one duplicate snapshot per call. + +## Trait Implementations + +### `impl Clone for TokenCost` + +
Clone for TokenCost"}} />
+ +#### `clone` + +
clone(&self) -> TokenCost"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for TokenCost` + +
Debug for TokenCost"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for TokenCost` + +
Deserialize<'de> for TokenCost"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for TokenCost` + +
TokenCost"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for TokenCost` + +
PartialEq for TokenCost"}} />
+ +#### `eq` + +
eq(&self, other: &TokenCost) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for TokenCost` + +
Serialize for TokenCost"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Eq for TokenCost` + +
Eq for TokenCost"}} />
+ +### `impl StructuralPartialEq for TokenCost` + +
StructuralPartialEq for TokenCost"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/model-usage/struct-tokencounts.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/model-usage/struct-tokencounts.mdx new file mode 100644 index 000000000..6b1a8a4e7 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/model-usage/struct-tokencounts.mdx @@ -0,0 +1,123 @@ +--- +title: "Struct Token Counts" +sidebar-title: "TokenCounts" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/model_usage/struct-tokencounts" +description: "Token counts for one fixed invocation scope. Missing values never imply zero." +position: 5 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
Option<u64>,\n    pub cached_input_tokens: Option<u64>,\n    pub cache_write_input_tokens: Option<u64>,\n    pub output_tokens: Option<u64>,\n    pub total_tokens: Option<u64>,\n}"}} />
+ +Token counts for one fixed invocation scope. Missing values never imply zero. + +## Fields + +### `input_tokens: Option` + +Cache-inclusive input. + +### `cached_input_tokens: Option` + +Independently known cache reads. + +### `cache_write_input_tokens: Option` + +Independently known cache writes. + +### `output_tokens: Option` + +Reasoning-inclusive output. + +### `total_tokens: Option` + +Input plus output; the independent provider total remains raw evidence. + +## Trait Implementations + +### `impl Clone for TokenCounts` + +
Clone for TokenCounts"}} />
+ +#### `clone` + +
clone(&self) -> TokenCounts"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for TokenCounts` + +
Debug for TokenCounts"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl Default for TokenCounts` + +
Default for TokenCounts"}} />
+ +#### `default` + +
default() -> TokenCounts"}} />
+ +### `impl<'de> Deserialize<'de> for TokenCounts` + +
Deserialize<'de> for TokenCounts"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for TokenCounts` + +
TokenCounts"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for TokenCounts` + +
PartialEq for TokenCounts"}} />
+ +#### `eq` + +
eq(&self, other: &TokenCounts) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for TokenCounts` + +
Serialize for TokenCounts"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Eq for TokenCounts` + +
Eq for TokenCounts"}} />
+ +### `impl StructuralPartialEq for TokenCounts` + +
StructuralPartialEq for TokenCounts"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/model-usage/struct-usagegap.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/model-usage/struct-usagegap.mdx new file mode 100644 index 000000000..916607b06 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/model-usage/struct-usagegap.mdx @@ -0,0 +1,107 @@ +--- +title: "Struct Usage Gap" +sidebar-title: "UsageGap" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/model_usage/struct-usagegap" +description: "A completeness gap. Diagnostics never change the dimension or reason semantics." +position: 6 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
Vec<UsageDimension>,\n    pub reason: GapReason,\n    pub code: Option<String>,\n}"}} />
+ +A completeness gap. Diagnostics never change the dimension or reason semantics. + +## Fields + +### `dimensions: Vec` + +Nonempty set of affected dimensions. + +### `reason: GapReason` + +Why complete accounting is unavailable. + +### `code: Option` + +Bounded stable diagnostic; never an exception message. + +## Trait Implementations + +### `impl Clone for UsageGap` + +
Clone for UsageGap"}} />
+ +#### `clone` + +
clone(&self) -> UsageGap"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for UsageGap` + +
Debug for UsageGap"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for UsageGap` + +
Deserialize<'de> for UsageGap"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for UsageGap` + +
UsageGap"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for UsageGap` + +
PartialEq for UsageGap"}} />
+ +#### `eq` + +
eq(&self, other: &UsageGap) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for UsageGap` + +
Serialize for UsageGap"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Eq for UsageGap` + +
Eq for UsageGap"}} />
+ +### `impl StructuralPartialEq for UsageGap` + +
StructuralPartialEq for UsageGap"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/price-catalog/enum-cachereadaccounting.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/price-catalog/enum-cachereadaccounting.mdx new file mode 100644 index 000000000..1f26a95a4 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/price-catalog/enum-cachereadaccounting.mdx @@ -0,0 +1,116 @@ +--- +title: "Enum Cache Read Accounting" +sidebar-title: "CacheReadAccounting" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/price_catalog/enum-cachereadaccounting" +description: "Whether a provider's prompt token count includes cached reads." +position: 7 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +```rust +pub enum CacheReadAccounting { + IncludedInPromptTokens, + Separate, +} +``` + +Whether a provider's prompt token count includes cached reads. + +## Variants + +### `IncludedInPromptTokens` + +
+ +Prompt tokens include cached reads. + +### `Separate` + +
+ +Prompt tokens exclude cached reads. + +## Trait Implementations + +### `impl Clone for CacheReadAccounting` + +
Clone for CacheReadAccounting"}} />
+ +#### `clone` + +
clone(&self) -> CacheReadAccounting"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for CacheReadAccounting` + +
Debug for CacheReadAccounting"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for CacheReadAccounting` + +
Deserialize<'de> for CacheReadAccounting"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for CacheReadAccounting` + +
CacheReadAccounting"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for CacheReadAccounting` + +
PartialEq for CacheReadAccounting"}} />
+ +#### `eq` + +
eq(&self, other: &CacheReadAccounting) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for CacheReadAccounting` + +
Serialize for CacheReadAccounting"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Copy for CacheReadAccounting` + +
Copy for CacheReadAccounting"}} />
+ +### `impl Eq for CacheReadAccounting` + +
Eq for CacheReadAccounting"}} />
+ +### `impl StructuralPartialEq for CacheReadAccounting` + +
StructuralPartialEq for CacheReadAccounting"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/price-catalog/enum-pricingcatalogerror.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/price-catalog/enum-pricingcatalogerror.mdx new file mode 100644 index 000000000..7a3101729 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/price-catalog/enum-pricingcatalogerror.mdx @@ -0,0 +1,135 @@ +--- +title: "Enum Pricing Catalog Error" +sidebar-title: "PricingCatalogError" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/price_catalog/enum-pricingcatalogerror" +description: "Why a catalog is invalid. The messages match NeMo Relay's." +position: 8 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
Error),\n    FingerprintMismatch,\n    UnsupportedVersion {\n        version: u32,\n    },\n    DuplicateModelAlias {\n        model: String,\n    },\n    EmptyField {\n        entry_index: usize,\n        field: String,\n    },\n    InvalidRate {\n        entry_index: usize,\n        field: String,\n        value: f64,\n    },\n}"}} />
+ +Why a catalog is invalid. The messages match NeMo Relay's. + +## Variants + +### `Json(Error)` + +
Error)"}} />
+ +The catalog is not JSON of the catalog format. + +### `FingerprintMismatch` + +
+ +The claimed catalog fingerprint does not match the validated entries. + +### `UnsupportedVersion` + +
+ +The catalog format version is not 1. + +#### Fields + +##### `version: u32` + +Version in the catalog. + +### `DuplicateModelAlias` + +
+ +Two entries or aliases of one provider name the same model. + +#### Fields + +##### `model: String` + +Normalized `provider/model` key. + +### `EmptyField` + +
+ +A required text field is blank. + +#### Fields + +##### `entry_index: usize` + +Zero-based entry index. + +##### `field: String` + +Field name. + +### `InvalidRate` + +
+ +A rate or a tier bound is out of range. + +#### Fields + +##### `entry_index: usize` + +Zero-based entry index. + +##### `field: String` + +Field name. + +##### `value: f64` + +Invalid value. + +## Trait Implementations + +### `impl Debug for PricingCatalogError` + +
Debug for PricingCatalogError"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl Display for PricingCatalogError` + +
Display for PricingCatalogError"}} />
+ +#### `fmt` + +
fmt(&self, __formatter: &mut Formatter<'_>) -> Result"}} />
+ +### `impl Error for PricingCatalogError` + +
Error for PricingCatalogError"}} />
+ +#### `source` + +
source(&self) -> Option<&(dyn Error + 'static)>"}} />
+ +#### `description` + +
description(&self) -> &str"}} />
+ +#### `cause` + +
cause(&self) -> Option<&dyn Error>"}} />
+ +#### `provide` + +
provide<'a>(&'a self, request: &mut Request<'a>)"}} />
+ +### `impl From< Error> for PricingCatalogError` + +
From<Error> for PricingCatalogError"}} />
+ +#### `from` + +
from(source: Error) -> Self"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/price-catalog/enum-pricingunit.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/price-catalog/enum-pricingunit.mdx new file mode 100644 index 000000000..5f88531de --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/price-catalog/enum-pricingunit.mdx @@ -0,0 +1,138 @@ +--- +title: "Enum Pricing Unit" +sidebar-title: "PricingUnit" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/price_catalog/enum-pricingunit" +description: "What a model price is charged per." +position: 9 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +```rust +pub enum PricingUnit { + PerToken, + PerRequest, + PerSecond, + GpuHour, +} +``` + +What a model price is charged per. + +## Variants + +### `PerToken` + +
+ +Per token. + +### `PerRequest` + +
+ +Per request. + +### `PerSecond` + +
+ +Per second. + +### `GpuHour` + +
+ +Per GPU hour, for self-hosted models. + +## Trait Implementations + +### `impl Clone for PricingUnit` + +
Clone for PricingUnit"}} />
+ +#### `clone` + +
clone(&self) -> PricingUnit"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for PricingUnit` + +
Debug for PricingUnit"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl Default for PricingUnit` + +
Default for PricingUnit"}} />
+ +#### `default` + +
default() -> PricingUnit"}} />
+ +### `impl<'de> Deserialize<'de> for PricingUnit` + +
Deserialize<'de> for PricingUnit"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for PricingUnit` + +
PricingUnit"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for PricingUnit` + +
PartialEq for PricingUnit"}} />
+ +#### `eq` + +
eq(&self, other: &PricingUnit) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for PricingUnit` + +
Serialize for PricingUnit"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Copy for PricingUnit` + +
Copy for PricingUnit"}} />
+ +### `impl Eq for PricingUnit` + +
Eq for PricingUnit"}} />
+ +### `impl StructuralPartialEq for PricingUnit` + +
StructuralPartialEq for PricingUnit"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/price-catalog/enum-ratescheduleapplication.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/price-catalog/enum-ratescheduleapplication.mdx new file mode 100644 index 000000000..e2a414c7a --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/price-catalog/enum-ratescheduleapplication.mdx @@ -0,0 +1,117 @@ +--- +title: "Enum Rate Schedule Application" +sidebar-title: "RateScheduleApplication" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/price_catalog/enum-ratescheduleapplication" +description: "How a chosen rate tier applies to a request." +position: 10 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +```rust +pub enum RateScheduleApplication { + FullRequest, +} +``` + +How a chosen rate tier applies to a request. + +## Variants + +### `FullRequest` + +
+ +The tier's rates apply to every token of the request. + +## Trait Implementations + +### `impl Clone for RateScheduleApplication` + +
Clone for RateScheduleApplication"}} />
+ +#### `clone` + +
clone(&self) -> RateScheduleApplication"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for RateScheduleApplication` + +
Debug for RateScheduleApplication"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl Default for RateScheduleApplication` + +
Default for RateScheduleApplication"}} />
+ +#### `default` + +
default() -> RateScheduleApplication"}} />
+ +### `impl<'de> Deserialize<'de> for RateScheduleApplication` + +
Deserialize<'de> for RateScheduleApplication"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for RateScheduleApplication` + +
RateScheduleApplication"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for RateScheduleApplication` + +
PartialEq for RateScheduleApplication"}} />
+ +#### `eq` + +
eq(&self, other: &RateScheduleApplication) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for RateScheduleApplication` + +
Serialize for RateScheduleApplication"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Copy for RateScheduleApplication` + +
Copy for RateScheduleApplication"}} />
+ +### `impl Eq for RateScheduleApplication` + +
Eq for RateScheduleApplication"}} />
+ +### `impl StructuralPartialEq for RateScheduleApplication` + +
StructuralPartialEq for RateScheduleApplication"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/price-catalog/enum-tokenrateschedule.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/price-catalog/enum-tokenrateschedule.mdx new file mode 100644 index 000000000..650ab79b5 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/price-catalog/enum-tokenrateschedule.mdx @@ -0,0 +1,107 @@ +--- +title: "Enum Token Rate Schedule" +sidebar-title: "TokenRateSchedule" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/price_catalog/enum-tokenrateschedule" +description: "Rates that depend on the size of a request." +position: 11 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
RateScheduleApplication,\n        tiers: Vec<TokenRateTier>,\n    },\n}"}} />
+ +Rates that depend on the size of a request. + +## Variants + +### `PromptTokenThreshold` + +
+ +One tier's rates apply to a whole request, chosen by the request's prompt tokens. + +#### Fields + +##### `applies_to: RateScheduleApplication` + +How the chosen tier's rates apply. + +##### `tiers: Vec` + +Tiers in the order they are tried. The first tier whose bounds contain a request's prompt tokens applies to it. + +## Trait Implementations + +### `impl Clone for TokenRateSchedule` + +
Clone for TokenRateSchedule"}} />
+ +#### `clone` + +
clone(&self) -> TokenRateSchedule"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for TokenRateSchedule` + +
Debug for TokenRateSchedule"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for TokenRateSchedule` + +
Deserialize<'de> for TokenRateSchedule"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for TokenRateSchedule` + +
TokenRateSchedule"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for TokenRateSchedule` + +
PartialEq for TokenRateSchedule"}} />
+ +#### `eq` + +
eq(&self, other: &TokenRateSchedule) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for TokenRateSchedule` + +
Serialize for TokenRateSchedule"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl StructuralPartialEq for TokenRateSchedule` + +
StructuralPartialEq for TokenRateSchedule"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/price-catalog/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/price-catalog/index.mdx new file mode 100644 index 000000000..89e4a19e8 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/price-catalog/index.mdx @@ -0,0 +1,34 @@ +--- +title: "Module price_catalog" +sidebar-title: "price_catalog" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/price_catalog" +description: "Model price catalogs for estimating token cost." +position: 170 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +Model price catalogs for estimating token cost. + +A catalog uses the NeMo Relay model pricing catalog format, version 1, so one file can configure both tools. NVIDIA NeMo Fabric parses the format itself and does not depend on NeMo Relay. Names are trimmed and compared case-insensitively. An explicit provider restricts aliases and model-path suffixes to that provider. Without a provider, lookup tries provider-qualified keys from the model path before model-only suffixes. A model-only key must identify one provider; an ambiguous match stops lookup without trying shorter model names. + +NeMo Fabric ships one catalog, embedded at build time, with the list prices of models that its presets, docs, and examples use. Each entry cites the provider pricing page it was taken from and the date it was checked. A caller can add a catalog of their own, whose entries take precedence. + +## Structs + +- [ModelPricing](struct-modelpricing.mdx): Prices of one model, in `currency` per million tokens. +- [PricingCatalog](struct-pricingcatalog.mdx): A model price catalog in the NeMo Relay model pricing catalog format, version 1. +- [PromptCachePricing](struct-promptcachepricing.mdx): How a provider counts cached prompt tokens in its usage reports. +- [TokenPricingRates](struct-tokenpricingrates.mdx): Token rates in currency units per million tokens. +- [TokenRateTier](struct-tokenratetier.mdx): Rates for requests whose prompt tokens fall within inclusive bounds. +- [UserPriceCatalog](struct-userpricecatalog.mdx): A price catalog the caller supplied, read and validated when the run plan is resolved. + +## Enums + +- [CacheReadAccounting](enum-cachereadaccounting.mdx): Whether a provider's prompt token count includes cached reads. +- [PricingCatalogError](enum-pricingcatalogerror.mdx): Why a catalog is invalid. The messages match NeMo Relay's. +- [PricingUnit](enum-pricingunit.mdx): What a model price is charged per. +- [RateScheduleApplication](enum-ratescheduleapplication.mdx): How a chosen rate tier applies to a request. +- [TokenRateSchedule](enum-tokenrateschedule.mdx): Rates that depend on the size of a request. diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/price-catalog/struct-modelpricing.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/price-catalog/struct-modelpricing.mdx new file mode 100644 index 000000000..f764975e8 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/price-catalog/struct-modelpricing.mdx @@ -0,0 +1,131 @@ +--- +title: "Struct Model Pricing" +sidebar-title: "ModelPricing" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/price_catalog/struct-modelpricing" +description: "Prices of one model, in `currency` per million tokens." +position: 1 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
String,\n    pub model_id: String,\n    pub aliases: Vec<String>,\n    pub currency: String,\n    pub unit: PricingUnit,\n    pub rates: Option<TokenPricingRates>,\n    pub rate_schedule: Option<TokenRateSchedule>,\n    pub prompt_cache: PromptCachePricing,\n    pub pricing_as_of: String,\n    pub pricing_source: String,\n}"}} />
+ +Prices of one model, in `currency` per million tokens. + +## Fields + +### `provider: String` + +Provider that sets the price, such as `anthropic` or `openai`. + +### `model_id: String` + +Canonical model identifier. + +### `aliases: Vec` + +Other identifiers of the same model, such as dated snapshots. + +### `currency: String` + +ISO 4217 currency of the rates. NVIDIA NeMo Fabric estimates cost only from `USD` entries. + +### `unit: PricingUnit` + +What a rate is charged per. NeMo Fabric estimates cost only from `per_token` entries. + +### `rates: Option` + +Flat rates. Ignored when `rate_schedule` is set. + +### `rate_schedule: Option` + +Rates that depend on the number of prompt tokens in a request. + +### `prompt_cache: PromptCachePricing` + +How the provider counts cached prompt tokens. NeMo Relay uses it to split provider usage; NeMo Fabric prices usage that is already normalized, so it does not read it. + +### `pricing_as_of: String` + +Date the price was taken from `pricing_source`, as `YYYY-MM-DD` in the bundled catalog. + +### `pricing_source: String` + +Where the price was taken from, such as the provider's pricing page. + +## Trait Implementations + +### `impl Clone for ModelPricing` + +
Clone for ModelPricing"}} />
+ +#### `clone` + +
clone(&self) -> ModelPricing"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for ModelPricing` + +
Debug for ModelPricing"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for ModelPricing` + +
Deserialize<'de> for ModelPricing"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for ModelPricing` + +
ModelPricing"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for ModelPricing` + +
PartialEq for ModelPricing"}} />
+ +#### `eq` + +
eq(&self, other: &ModelPricing) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for ModelPricing` + +
Serialize for ModelPricing"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl StructuralPartialEq for ModelPricing` + +
StructuralPartialEq for ModelPricing"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/price-catalog/struct-pricingcatalog.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/price-catalog/struct-pricingcatalog.mdx new file mode 100644 index 000000000..54da6458c --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/price-catalog/struct-pricingcatalog.mdx @@ -0,0 +1,113 @@ +--- +title: "Struct Pricing Catalog" +sidebar-title: "PricingCatalog" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/price_catalog/struct-pricingcatalog" +description: "A model price catalog in the NeMo Relay model pricing catalog format, version 1." +position: 2 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
u32,\n    pub entries: Vec<ModelPricing>,\n}"}} />
+ +A model price catalog in the NeMo Relay model pricing catalog format, version 1. + +NVIDIA NeMo Fabric reads and writes the format unchanged, with one extra check: every rate must be below one million US dollars per million tokens. + +## Fields + +### `version: u32` + +Catalog format version. Must be `1`. + +### `entries: Vec` + +Price entries with provider-qualified model identifiers and aliases. + +## Implementations + +### `impl PricingCatalog` + +
PricingCatalog"}} />
+ +#### `from_json` + +
u8]) -> Result<Self, PricingCatalogError>"}} />
+ +Parse and validate a catalog. + +## Trait Implementations + +### `impl Clone for PricingCatalog` + +
Clone for PricingCatalog"}} />
+ +#### `clone` + +
clone(&self) -> PricingCatalog"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for PricingCatalog` + +
Debug for PricingCatalog"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for PricingCatalog` + +
Deserialize<'de> for PricingCatalog"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for PricingCatalog` + +
PricingCatalog"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for PricingCatalog` + +
PartialEq for PricingCatalog"}} />
+ +#### `eq` + +
eq(&self, other: &PricingCatalog) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for PricingCatalog` + +
Serialize for PricingCatalog"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl StructuralPartialEq for PricingCatalog` + +
StructuralPartialEq for PricingCatalog"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/price-catalog/struct-promptcachepricing.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/price-catalog/struct-promptcachepricing.mdx new file mode 100644 index 000000000..edaf90239 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/price-catalog/struct-promptcachepricing.mdx @@ -0,0 +1,103 @@ +--- +title: "Struct Prompt Cache Pricing" +sidebar-title: "PromptCachePricing" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/price_catalog/struct-promptcachepricing" +description: "How a provider counts cached prompt tokens in its usage reports." +position: 3 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
CacheReadAccounting,\n}"}} />
+ +How a provider counts cached prompt tokens in its usage reports. + +## Fields + +### `read_accounting: CacheReadAccounting` + +Whether the provider's prompt token count includes cached reads. + +## Trait Implementations + +### `impl Clone for PromptCachePricing` + +
Clone for PromptCachePricing"}} />
+ +#### `clone` + +
clone(&self) -> PromptCachePricing"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for PromptCachePricing` + +
Debug for PromptCachePricing"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for PromptCachePricing` + +
Deserialize<'de> for PromptCachePricing"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for PromptCachePricing` + +
PromptCachePricing"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for PromptCachePricing` + +
PartialEq for PromptCachePricing"}} />
+ +#### `eq` + +
eq(&self, other: &PromptCachePricing) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for PromptCachePricing` + +
Serialize for PromptCachePricing"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Copy for PromptCachePricing` + +
Copy for PromptCachePricing"}} />
+ +### `impl Eq for PromptCachePricing` + +
Eq for PromptCachePricing"}} />
+ +### `impl StructuralPartialEq for PromptCachePricing` + +
StructuralPartialEq for PromptCachePricing"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/price-catalog/struct-tokenpricingrates.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/price-catalog/struct-tokenpricingrates.mdx new file mode 100644 index 000000000..1a24d5b02 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/price-catalog/struct-tokenpricingrates.mdx @@ -0,0 +1,111 @@ +--- +title: "Struct Token Pricing Rates" +sidebar-title: "TokenPricingRates" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/price_catalog/struct-tokenpricingrates" +description: "Token rates in currency units per million tokens." +position: 4 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
f64,\n    pub output_per_million: f64,\n    pub cache_read_per_million: Option<f64>,\n    pub cache_write_per_million: Option<f64>,\n}"}} />
+ +Token rates in currency units per million tokens. + +## Fields + +### `input_per_million: f64` + +Rate for input tokens that were neither read from nor written to the prompt cache. + +### `output_per_million: f64` + +Rate for output tokens, including reasoning tokens. + +### `cache_read_per_million: Option` + +Rate for input tokens read from the prompt cache. Without it, cached reads cannot be priced; set `0.0` when they are free. + +### `cache_write_per_million: Option` + +Rate for input tokens written to the prompt cache. Without it, cache writes cannot be priced; set `0.0` when they are free. + +## Trait Implementations + +### `impl Clone for TokenPricingRates` + +
Clone for TokenPricingRates"}} />
+ +#### `clone` + +
clone(&self) -> TokenPricingRates"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for TokenPricingRates` + +
Debug for TokenPricingRates"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for TokenPricingRates` + +
Deserialize<'de> for TokenPricingRates"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for TokenPricingRates` + +
TokenPricingRates"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for TokenPricingRates` + +
PartialEq for TokenPricingRates"}} />
+ +#### `eq` + +
eq(&self, other: &TokenPricingRates) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for TokenPricingRates` + +
Serialize for TokenPricingRates"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Copy for TokenPricingRates` + +
Copy for TokenPricingRates"}} />
+ +### `impl StructuralPartialEq for TokenPricingRates` + +
StructuralPartialEq for TokenPricingRates"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/price-catalog/struct-tokenratetier.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/price-catalog/struct-tokenratetier.mdx new file mode 100644 index 000000000..98ffe8935 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/price-catalog/struct-tokenratetier.mdx @@ -0,0 +1,107 @@ +--- +title: "Struct Token Rate Tier" +sidebar-title: "TokenRateTier" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/price_catalog/struct-tokenratetier" +description: "Rates for requests whose prompt tokens fall within inclusive bounds." +position: 5 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
Option<u64>,\n    pub max_prompt_tokens: Option<u64>,\n    pub rates: TokenPricingRates,\n}"}} />
+ +Rates for requests whose prompt tokens fall within inclusive bounds. + +## Fields + +### `min_prompt_tokens: Option` + +Smallest number of prompt tokens in the tier, or no lower bound. + +### `max_prompt_tokens: Option` + +Largest number of prompt tokens in the tier, or no upper bound. + +### `rates: TokenPricingRates` + +Rates of the tier. + +## Trait Implementations + +### `impl Clone for TokenRateTier` + +
Clone for TokenRateTier"}} />
+ +#### `clone` + +
clone(&self) -> TokenRateTier"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for TokenRateTier` + +
Debug for TokenRateTier"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for TokenRateTier` + +
Deserialize<'de> for TokenRateTier"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for TokenRateTier` + +
TokenRateTier"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for TokenRateTier` + +
PartialEq for TokenRateTier"}} />
+ +#### `eq` + +
eq(&self, other: &TokenRateTier) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for TokenRateTier` + +
Serialize for TokenRateTier"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Copy for TokenRateTier` + +
Copy for TokenRateTier"}} />
+ +### `impl StructuralPartialEq for TokenRateTier` + +
StructuralPartialEq for TokenRateTier"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/price-catalog/struct-userpricecatalog.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/price-catalog/struct-userpricecatalog.mdx new file mode 100644 index 000000000..0809ce870 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/price-catalog/struct-userpricecatalog.mdx @@ -0,0 +1,115 @@ +--- +title: "Struct User Price Catalog" +sidebar-title: "UserPriceCatalog" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/price_catalog/struct-userpricecatalog" +description: "A price catalog the caller supplied, read and validated when the run plan is resolved." +position: 6 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
PathBuf,\n    pub sha256: String,\n    pub catalog: PricingCatalog,\n}"}} />
+ +A price catalog the caller supplied, read and validated when the run plan is resolved. + +## Fields + +### `path: PathBuf` + +Absolute path the catalog was read from. + +### `sha256: String` + +SHA-256 of the validated catalog serialized as JSON by NeMo Fabric. + +### `catalog: PricingCatalog` + +The validated catalog. + +## Implementations + +### `impl UserPriceCatalog` + +
UserPriceCatalog"}} />
+ +#### `from_bytes` + +
PathBuf,\n    bytes: &[u8],\n) -> Result<Self, PricingCatalogError>"}} />
+ +Validate bytes and retain a fingerprint of their pricing content. + +## Trait Implementations + +### `impl Clone for UserPriceCatalog` + +
Clone for UserPriceCatalog"}} />
+ +#### `clone` + +
clone(&self) -> UserPriceCatalog"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for UserPriceCatalog` + +
Debug for UserPriceCatalog"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for UserPriceCatalog` + +
Deserialize<'de> for UserPriceCatalog"}} />
+ +#### `deserialize` + +
deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error>"}} />
+ +### `impl JsonSchema for UserPriceCatalog` + +
UserPriceCatalog"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for UserPriceCatalog` + +
PartialEq for UserPriceCatalog"}} />
+ +#### `eq` + +
eq(&self, other: &UserPriceCatalog) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for UserPriceCatalog` + +
Serialize for UserPriceCatalog"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl StructuralPartialEq for UserPriceCatalog` + +
StructuralPartialEq for UserPriceCatalog"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/pricing/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/pricing/index.mdx new file mode 100644 index 000000000..b2c8fe610 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/pricing/index.mdx @@ -0,0 +1,18 @@ +--- +title: "Module pricing" +sidebar-title: "pricing" +description: "Catalog prices are estimates, separate from provider-reported amounts." +position: 171 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +Catalog prices are estimates, separate from provider-reported amounts. + +## Structs + +- [AppliedPricing](struct-appliedpricing.mdx): Persisted entry and selected rates, so saved prices remain reproducible. +- [PricingBasis](struct-pricingbasis.mdx): Fields that must agree before a report combines catalog estimates. +- [RateSnapshot](struct-ratesnapshot.mdx): Integer rates selected for a model, in nano-USD per million tokens. diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/pricing/struct-appliedpricing.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/pricing/struct-appliedpricing.mdx new file mode 100644 index 000000000..a96ef4e5a --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/pricing/struct-appliedpricing.mdx @@ -0,0 +1,118 @@ +--- +title: "Struct Applied Pricing" +sidebar-title: "AppliedPricing" +description: "Persisted entry and selected rates, so saved prices remain reproducible." +position: 1 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
String,\n    pub provider: String,\n    pub model_id: String,\n    pub pricing_as_of: String,\n    pub pricing_source: String,\n    pub rates: RateSnapshot,\n}"}} />
+ +Persisted entry and selected rates, so saved prices remain reproducible. + +## Fields + +### `catalog_sha256: String` + +Exact catalog containing this entry. + +### `provider: String` + +Entry's provider, without rewriting the observed identity. + +### `model_id: String` + +Entry's model identity, which may match an explicit alias. + +### `pricing_as_of: String` + +Date recorded by the catalog author. + +### `pricing_source: String` + +Source recorded by the catalog author. + +### `rates: RateSnapshot` + +One selected rate set; distinct selections are retained in `model_usage::TokenCost.applied_rates`. + +## Trait Implementations + +### `impl Clone for AppliedPricing` + +
Clone for AppliedPricing"}} />
+ +#### `clone` + +
clone(&self) -> AppliedPricing"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for AppliedPricing` + +
Debug for AppliedPricing"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for AppliedPricing` + +
Deserialize<'de> for AppliedPricing"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for AppliedPricing` + +
AppliedPricing"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for AppliedPricing` + +
PartialEq for AppliedPricing"}} />
+ +#### `eq` + +
eq(&self, other: &AppliedPricing) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for AppliedPricing` + +
Serialize for AppliedPricing"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Eq for AppliedPricing` + +
Eq for AppliedPricing"}} />
+ +### `impl StructuralPartialEq for AppliedPricing` + +
StructuralPartialEq for AppliedPricing"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/pricing/struct-pricingbasis.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/pricing/struct-pricingbasis.mdx new file mode 100644 index 000000000..2296a6e6b --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/pricing/struct-pricingbasis.mdx @@ -0,0 +1,118 @@ +--- +title: "Struct Pricing Basis" +sidebar-title: "PricingBasis" +description: "Fields that must agree before a report combines catalog estimates." +position: 2 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
String,\n    pub currency: String,\n    pub normalization_version: String,\n    pub assumption_policy: String,\n    pub bundled_catalog_sha256: String,\n    pub user_catalog_sha256: Option<String>,\n}"}} />
+ +Fields that must agree before a report combines catalog estimates. + +## Fields + +### `basis: String` + +`catalog`; provider-reported cost has a different basis. + +### `currency: String` + +`USD` in this implementation. + +### `normalization_version: String` + +Version of normalization and monetary rounding. + +### `assumption_policy: String` + +No missing count is assumed zero. + +### `bundled_catalog_sha256: String` + +Exact bundled catalog bytes. + +### `user_catalog_sha256: Option` + +Exact override bytes, when supplied. + +## Trait Implementations + +### `impl Clone for PricingBasis` + +
Clone for PricingBasis"}} />
+ +#### `clone` + +
clone(&self) -> PricingBasis"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for PricingBasis` + +
Debug for PricingBasis"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for PricingBasis` + +
Deserialize<'de> for PricingBasis"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for PricingBasis` + +
PricingBasis"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for PricingBasis` + +
PartialEq for PricingBasis"}} />
+ +#### `eq` + +
eq(&self, other: &PricingBasis) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for PricingBasis` + +
Serialize for PricingBasis"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Eq for PricingBasis` + +
Eq for PricingBasis"}} />
+ +### `impl StructuralPartialEq for PricingBasis` + +
StructuralPartialEq for PricingBasis"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/pricing/struct-ratesnapshot.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/pricing/struct-ratesnapshot.mdx new file mode 100644 index 000000000..204b09fad --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/pricing/struct-ratesnapshot.mdx @@ -0,0 +1,110 @@ +--- +title: "Struct Rate Snapshot" +sidebar-title: "RateSnapshot" +description: "Integer rates selected for a model, in nano-USD per million tokens." +position: 3 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
u64,\n    pub output: u64,\n    pub cache_read: Option<u64>,\n    pub cache_write: Option<u64>,\n}"}} />
+ +Integer rates selected for a model, in nano-USD per million tokens. + +## Fields + +### `input: u64` + +Uncached input rate. + +### `output: u64` + +Reasoning-inclusive output rate. + +### `cache_read: Option` + +Cache read rate; absent is not zero. + +### `cache_write: Option` + +Cache write rate; absent is not zero. + +## Trait Implementations + +### `impl Clone for RateSnapshot` + +
Clone for RateSnapshot"}} />
+ +#### `clone` + +
clone(&self) -> RateSnapshot"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for RateSnapshot` + +
Debug for RateSnapshot"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for RateSnapshot` + +
Deserialize<'de> for RateSnapshot"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for RateSnapshot` + +
RateSnapshot"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for RateSnapshot` + +
PartialEq for RateSnapshot"}} />
+ +#### `eq` + +
eq(&self, other: &RateSnapshot) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for RateSnapshot` + +
Serialize for RateSnapshot"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Eq for RateSnapshot` + +
Eq for RateSnapshot"}} />
+ +### `impl StructuralPartialEq for RateSnapshot` + +
StructuralPartialEq for RateSnapshot"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/resource-snapshot/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/resource-snapshot/index.mdx index 7ef2e8fcf..f95480199 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/resource-snapshot/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/resource-snapshot/index.mdx @@ -3,7 +3,7 @@ title: "Module resource_snapshot" sidebar-title: "resource_snapshot" slug: "/reference/api/rust-library-reference/nemo-fabric-core/resource_snapshot" description: "Resource snapshots of a runtime's local adapter host and its descendants." -position: 168 +position: 172 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/index.mdx index 1002006b4..d27e32d47 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/index.mdx @@ -2,7 +2,7 @@ title: "Module runtime" sidebar-title: "runtime" description: "Runtime invocation helpers." -position: 169 +position: 173 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/struct-runresult.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/struct-runresult.mdx index dd663b6f1..781b2a5b9 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/struct-runresult.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/runtime/struct-runresult.mdx @@ -9,7 +9,7 @@ SPDX-License-Identifier: Apache-2.0 */} Generated from `cargo doc --no-deps -p nemo-fabric-core`. -
String,\n    pub harness: String,\n    pub adapter_kind: AdapterKind,\n    pub adapter_id: Option<String>,\n    pub runtime_id: String,\n    pub invocation_id: String,\n    pub request_id: String,\n    pub status: RunStatus,\n    pub output: Value,\n    pub error: Option<ErrorInfo>,\n    pub usage: Option<RunUsage>,\n    pub resource_snapshot: Option<RuntimeResourceSnapshot>,\n    pub system_stats: Option<InvocationSystemStats>,\n    pub artifacts: ArtifactManifest,\n    pub telemetry: Vec<TelemetryRef>,\n    pub events: Vec<FabricEvent>,\n    pub metadata: BTreeMap<String, Value>,\n}"}} />
+
String,\n    pub harness: String,\n    pub adapter_kind: AdapterKind,\n    pub adapter_id: Option<String>,\n    pub runtime_id: String,\n    pub invocation_id: String,\n    pub request_id: String,\n    pub status: RunStatus,\n    pub output: Value,\n    pub error: Option<ErrorInfo>,\n    pub usage: Option<RunUsage>,\n    pub model_usage: Option<ModelUsage>,\n    pub resource_snapshot: Option<RuntimeResourceSnapshot>,\n    pub system_stats: Option<InvocationSystemStats>,\n    pub artifacts: ArtifactManifest,\n    pub telemetry: Vec<TelemetryRef>,\n    pub events: Vec<FabricEvent>,\n    pub metadata: BTreeMap<String, Value>,\n    pub what_ran: Option<WhatRan>,\n}"}} />
Result from a NeMo Fabric-managed harness invocation. @@ -59,6 +59,10 @@ Error metadata when applicable. Normalized invocation usage when reported by the adapter target. +### `model_usage: Option` + +Complete and observed usage normalized from supported accounting evidence. + ### `resource_snapshot: Option` Resource snapshot of the runtime, read once after the adapter returned this result. Absent when the runtime's resources could not be observed. @@ -83,6 +87,10 @@ NeMo Fabric lifecycle/progress events emitted during the run. Adapter-specific metadata. +### `what_ran: Option` + +What executed for this invocation, and how NeMo Fabric learned each fact. Absent when the producing core predates it, or when this core finds it malformed or of another schema or version. + ## Trait Implementations ### `impl Clone for RunResult` diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/schema/enum-schemaname.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/schema/enum-schemaname.mdx index 77eeaf24c..b031bcd66 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/schema/enum-schemaname.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/schema/enum-schemaname.mdx @@ -11,6 +11,7 @@ Generated from `cargo doc --no-deps -p nemo-fabric-core`. ```rust pub enum SchemaName { + UsageAccounting, CheckRequest, CheckReport, AdapterCheckRequest, @@ -45,6 +46,12 @@ Public schema snapshots generated by NeMo Fabric. ## Variants +### `UsageAccounting` + +
+ +Declared usage accounting evidence. + ### `CheckRequest`
@@ -215,7 +222,7 @@ Runtime resource snapshot schema. #### `ALL` -
27]"}} />
+
28]"}} />
All public schemas in stable output order. diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/schema/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/schema/index.mdx index 01555ff5f..df698aa41 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/schema/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/schema/index.mdx @@ -2,7 +2,7 @@ title: "Module schema" sidebar-title: "schema" description: "JSON Schema generation for the public NeMo Fabric contract." -position: 170 +position: 174 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/system-stats/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/system-stats/index.mdx index b042781d8..0c984714c 100644 --- a/docs/reference/api/rust-library-reference/nemo-fabric-core/system-stats/index.mdx +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/system-stats/index.mdx @@ -3,7 +3,7 @@ title: "Module system_stats" sidebar-title: "system_stats" slug: "/reference/api/rust-library-reference/nemo-fabric-core/system_stats" description: "Invocation measurements collected by NeMo Fabric in the executing runtime." -position: 171 +position: 175 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/usage-accounting/constant-usage-accounting-extension.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/usage-accounting/constant-usage-accounting-extension.mdx new file mode 100644 index 000000000..afb9158da --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/usage-accounting/constant-usage-accounting-extension.mdx @@ -0,0 +1,15 @@ +--- +title: "Constant USAGE_ACCOUNTING_EXTENSION" +sidebar-title: "USAGE_ACCOUNTING_EXTENSION" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/usage_accounting/constant-usage-accounting-extension" +description: "Descriptor-declared transport key. This does not change the closed AgentUsage wire shape." +position: 9 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
str = \"nemo_fabric.usage_accounting.v1\";"}} />
+ +Descriptor-declared transport key. This does not change the closed AgentUsage wire shape. diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/usage-accounting/enum-gapreason.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/usage-accounting/enum-gapreason.mdx new file mode 100644 index 000000000..6e51e873e --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/usage-accounting/enum-gapreason.mdx @@ -0,0 +1,167 @@ +--- +title: "Enum GapReason" +sidebar-title: "GapReason" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/usage_accounting/enum-gapreason" +description: "Categories that affect whether a consumer can treat a value as complete." +position: 7 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +```rust +pub enum GapReason { + UnfinishedWork, + MissingUsage, + ScopeUnverified, +} +``` + +Categories that affect whether a consumer can treat a value as complete. + +## Variants + +### `UnfinishedWork` + +
+ +Owned work was still running at the return boundary. + +### `MissingUsage` + +
+ +A completed call did not provide usable usage. + +### `ScopeUnverified` + +
+ +The supported source does not establish coverage of owned work. + +## Trait Implementations + +### `impl Clone for GapReason` + +
Clone for GapReason"}} />
+ +#### `clone` + +
clone(&self) -> GapReason"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for GapReason` + +
Debug for GapReason"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for GapReason` + +
Deserialize<'de> for GapReason"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for GapReason` + +
GapReason"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl Ord for GapReason` + +
Ord for GapReason"}} />
+ +#### `cmp` + +
cmp(&self, other: &GapReason) -> Ordering"}} />
+ +#### `max` + +
max(self, other: Self) -> Selfwhere\n    Self: Sized,"}} />
+ +#### `min` + +
min(self, other: Self) -> Selfwhere\n    Self: Sized,"}} />
+ +#### `clamp` + +
clamp(self, min: Self, max: Self) -> Selfwhere\n    Self: Sized,"}} />
+ +### `impl PartialEq for GapReason` + +
PartialEq for GapReason"}} />
+ +#### `eq` + +
eq(&self, other: &GapReason) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl PartialOrd for GapReason` + +
PartialOrd for GapReason"}} />
+ +#### `partial_cmp` + +
partial_cmp(&self, other: &GapReason) -> Option<Ordering>"}} />
+ +#### `lt` + +
lt(&self, other: &Rhs) -> bool"}} />
+ +#### `le` + +
le(&self, other: &Rhs) -> bool"}} />
+ +#### `gt` + +
gt(&self, other: &Rhs) -> bool"}} />
+ +#### `ge` + +
ge(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for GapReason` + +
Serialize for GapReason"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Copy for GapReason` + +
Copy for GapReason"}} />
+ +### `impl Eq for GapReason` + +
Eq for GapReason"}} />
+ +### `impl StructuralPartialEq for GapReason` + +
StructuralPartialEq for GapReason"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/usage-accounting/enum-usagedimension.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/usage-accounting/enum-usagedimension.mdx new file mode 100644 index 000000000..e7c9cb4be --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/usage-accounting/enum-usagedimension.mdx @@ -0,0 +1,202 @@ +--- +title: "Enum Usage Dimension" +sidebar-title: "UsageDimension" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/usage_accounting/enum-usagedimension" +description: "A dimension in raw adapter evidence." +position: 8 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +```rust +pub enum UsageDimension { + InputTokens, + CachedInputTokens, + CacheWriteInputTokens, + OutputTokens, + ReasoningTokens, + TotalTokens, + PeakRequestInputTokens, + CostUsd, +} +``` + +A dimension in raw adapter evidence. + +## Variants + +### `InputTokens` + +
+ +Input tokens. + +### `CachedInputTokens` + +
+ +Cache reads. + +### `CacheWriteInputTokens` + +
+ +Cache writes. + +### `OutputTokens` + +
+ +Output tokens. + +### `ReasoningTokens` + +
+ +Reasoning tokens. + +### `TotalTokens` + +
+ +Provider-reported total. + +### `PeakRequestInputTokens` + +
+ +Largest request input. + +### `CostUsd` + +
+ +Provider-reported USD. + +## Trait Implementations + +### `impl Clone for UsageDimension` + +
Clone for UsageDimension"}} />
+ +#### `clone` + +
clone(&self) -> UsageDimension"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for UsageDimension` + +
Debug for UsageDimension"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for UsageDimension` + +
Deserialize<'de> for UsageDimension"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for UsageDimension` + +
UsageDimension"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl Ord for UsageDimension` + +
Ord for UsageDimension"}} />
+ +#### `cmp` + +
cmp(&self, other: &UsageDimension) -> Ordering"}} />
+ +#### `max` + +
max(self, other: Self) -> Selfwhere\n    Self: Sized,"}} />
+ +#### `min` + +
min(self, other: Self) -> Selfwhere\n    Self: Sized,"}} />
+ +#### `clamp` + +
clamp(self, min: Self, max: Self) -> Selfwhere\n    Self: Sized,"}} />
+ +### `impl PartialEq for UsageDimension` + +
PartialEq for UsageDimension"}} />
+ +#### `eq` + +
eq(&self, other: &UsageDimension) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl PartialOrd for UsageDimension` + +
PartialOrd for UsageDimension"}} />
+ +#### `partial_cmp` + +
partial_cmp(&self, other: &UsageDimension) -> Option<Ordering>"}} />
+ +#### `lt` + +
lt(&self, other: &Rhs) -> bool"}} />
+ +#### `le` + +
le(&self, other: &Rhs) -> bool"}} />
+ +#### `gt` + +
gt(&self, other: &Rhs) -> bool"}} />
+ +#### `ge` + +
ge(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for UsageDimension` + +
Serialize for UsageDimension"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Copy for UsageDimension` + +
Copy for UsageDimension"}} />
+ +### `impl Eq for UsageDimension` + +
Eq for UsageDimension"}} />
+ +### `impl StructuralPartialEq for UsageDimension` + +
StructuralPartialEq for UsageDimension"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/usage-accounting/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/usage-accounting/index.mdx new file mode 100644 index 000000000..13b9ebd71 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/usage-accounting/index.mdx @@ -0,0 +1,31 @@ +--- +title: "Module usage_accounting" +sidebar-title: "usage_accounting" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/usage_accounting" +description: "Validated accounting evidence carried through the existing usage extension." +position: 176 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +Validated accounting evidence carried through the existing usage extension. + +## Structs + +- [AccountedModel](struct-accountedmodel.mdx): Evidence for one literal provider/model identity. +- [AccountingError](struct-accountingerror.mdx): Invalid accounting evidence uses the existing invalid-result path. +- [ReportedUsage](struct-reportedusage.mdx): Raw counts in provider/harness conventions. Missing values are never zero. +- [UsageAccounting](struct-usageaccounting.mdx): The declared adapter transport for invocation accounting. +- [UsageGap](struct-usagegap.mdx): Missing evidence for one or more raw dimensions. +- [UsageRequest](struct-usagerequest.mdx): One model call retained for request-tier pricing and evidence reconciliation. + +## Enums + +- [GapReason](enum-gapreason.mdx): Categories that affect whether a consumer can treat a value as complete. +- [UsageDimension](enum-usagedimension.mdx): A dimension in raw adapter evidence. + +## Constants + +- [USAGE_ACCOUNTING_EXTENSION](constant-usage-accounting-extension.mdx): Descriptor-declared transport key. This does not change the closed AgentUsage wire shape. diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/usage-accounting/struct-accountedmodel.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/usage-accounting/struct-accountedmodel.mdx new file mode 100644 index 000000000..b5accb1c7 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/usage-accounting/struct-accountedmodel.mdx @@ -0,0 +1,123 @@ +--- +title: "Struct Accounted Model" +sidebar-title: "AccountedModel" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/usage_accounting/struct-accountedmodel" +description: "Evidence for one literal provider/model identity." +position: 1 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
Vec<UsageRequest>,\n    pub requests_exhaustive: bool,\n    pub model: String,\n    pub provider: Option<String>,\n    pub reported: ReportedUsage,\n    pub observed: Option<ReportedUsage>,\n    pub observed_tokens_aligned: bool,\n    pub gaps: Vec<UsageGap>,\n}"}} />
+ +Evidence for one literal provider/model identity. + +## Fields + +### `requests: Vec` + +Optional per-call evidence for exact request-tier pricing. + +### `requests_exhaustive: bool` + +The request list covers every source unit contributing to this model. + +### `model: String` + +Model identity as reported by the source. + +### `provider: Option` + +Literal provider identity, when available. + +### `reported: ReportedUsage` + +Complete per-model evidence; absent fields are unreported. + +### `observed: Option` + +Valid deduplicated observations, potentially incomplete. + +### `observed_tokens_aligned: bool` + +Every present observed token dimension describes the same source-call subset. False unless the producer establishes this, including peak and normalization components. + +### `gaps: Vec` + +Gaps affecting this model. + +## Trait Implementations + +### `impl Clone for AccountedModel` + +
Clone for AccountedModel"}} />
+ +#### `clone` + +
clone(&self) -> AccountedModel"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for AccountedModel` + +
Debug for AccountedModel"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for AccountedModel` + +
Deserialize<'de> for AccountedModel"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for AccountedModel` + +
AccountedModel"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for AccountedModel` + +
PartialEq for AccountedModel"}} />
+ +#### `eq` + +
eq(&self, other: &AccountedModel) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for AccountedModel` + +
Serialize for AccountedModel"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl StructuralPartialEq for AccountedModel` + +
StructuralPartialEq for AccountedModel"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/usage-accounting/struct-accountingerror.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/usage-accounting/struct-accountingerror.mdx new file mode 100644 index 000000000..e72b43474 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/usage-accounting/struct-accountingerror.mdx @@ -0,0 +1,89 @@ +--- +title: "Struct Accounting Error" +sidebar-title: "AccountingError" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/usage_accounting/struct-accountingerror" +description: "Invalid accounting evidence uses the existing invalid-result path." +position: 2 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
String);"}} />
+ +Invalid accounting evidence uses the existing invalid-result path. + +## Tuple Fields + +### `0: String` + +## Trait Implementations + +### `impl Clone for AccountingError` + +
Clone for AccountingError"}} />
+ +#### `clone` + +
clone(&self) -> AccountingError"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for AccountingError` + +
Debug for AccountingError"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl Display for AccountingError` + +
Display for AccountingError"}} />
+ +#### `fmt` + +
fmt(&self, __formatter: &mut Formatter<'_>) -> Result"}} />
+ +### `impl Error for AccountingError` + +
Error for AccountingError"}} />
+ +#### `source` + +
source(&self) -> Option<&(dyn Error + 'static)>"}} />
+ +#### `description` + +
description(&self) -> &str"}} />
+ +#### `cause` + +
cause(&self) -> Option<&dyn Error>"}} />
+ +#### `provide` + +
provide<'a>(&'a self, request: &mut Request<'a>)"}} />
+ +### `impl PartialEq for AccountingError` + +
PartialEq for AccountingError"}} />
+ +#### `eq` + +
eq(&self, other: &AccountingError) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Eq for AccountingError` + +
Eq for AccountingError"}} />
+ +### `impl StructuralPartialEq for AccountingError` + +
StructuralPartialEq for AccountingError"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/usage-accounting/struct-reportedusage.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/usage-accounting/struct-reportedusage.mdx new file mode 100644 index 000000000..a366c8bb0 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/usage-accounting/struct-reportedusage.mdx @@ -0,0 +1,139 @@ +--- +title: "Struct Reported Usage" +sidebar-title: "ReportedUsage" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/usage_accounting/struct-reportedusage" +description: "Raw counts in provider/harness conventions. Missing values are never zero." +position: 3 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
Option<u64>,\n    pub cached_input_tokens: Option<u64>,\n    pub cache_write_input_tokens: Option<u64>,\n    pub input_tokens_include_cache: Option<bool>,\n    pub output_tokens: Option<u64>,\n    pub reasoning_tokens: Option<u64>,\n    pub output_tokens_include_reasoning: Option<bool>,\n    pub total_tokens: Option<u64>,\n    pub peak_request_input_tokens: Option<u64>,\n    pub cost_usd: Option<f64>,\n}"}} />
+ +Raw counts in provider/harness conventions. Missing values are never zero. + +## Fields + +### `input_tokens: Option` + +Raw input count. + +### `cached_input_tokens: Option` + +Cache reads, independently reported. + +### `cache_write_input_tokens: Option` + +Cache writes, independently reported. + +### `input_tokens_include_cache: Option` + +Whether input includes both cache categories. + +### `output_tokens: Option` + +Raw output count. + +### `reasoning_tokens: Option` + +Reasoning count, independently reported. + +### `output_tokens_include_reasoning: Option` + +Whether output includes reasoning. + +### `total_tokens: Option` + +Provider-reported total, independent of normalized input plus output. + +### `peak_request_input_tokens: Option` + +Largest request's cache-inclusive input. + +### `cost_usd: Option` + +Provider-reported USD, never a client-side price estimate. + +## Trait Implementations + +### `impl Clone for ReportedUsage` + +
Clone for ReportedUsage"}} />
+ +#### `clone` + +
clone(&self) -> ReportedUsage"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for ReportedUsage` + +
Debug for ReportedUsage"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl Default for ReportedUsage` + +
Default for ReportedUsage"}} />
+ +#### `default` + +
default() -> ReportedUsage"}} />
+ +### `impl<'de> Deserialize<'de> for ReportedUsage` + +
Deserialize<'de> for ReportedUsage"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for ReportedUsage` + +
ReportedUsage"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for ReportedUsage` + +
PartialEq for ReportedUsage"}} />
+ +#### `eq` + +
eq(&self, other: &ReportedUsage) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for ReportedUsage` + +
Serialize for ReportedUsage"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl StructuralPartialEq for ReportedUsage` + +
StructuralPartialEq for ReportedUsage"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/usage-accounting/struct-usageaccounting.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/usage-accounting/struct-usageaccounting.mdx new file mode 100644 index 000000000..8e5d6d11c --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/usage-accounting/struct-usageaccounting.mdx @@ -0,0 +1,119 @@ +--- +title: "Struct Usage Accounting" +sidebar-title: "UsageAccounting" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/usage_accounting/struct-usageaccounting" +description: "The declared adapter transport for invocation accounting." +position: 4 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
bool,\n    pub observed: Option<ReportedUsage>,\n    pub gaps: Vec<UsageGap>,\n    pub models: Vec<AccountedModel>,\n}"}} />
+ +The declared adapter transport for invocation accounting. + +## Fields + +### `models_exhaustive: bool` + +Every model identity used by owned work is represented; this alone does not certify that any dimension is complete. + +### `observed: Option` + +Valid deduplicated observations, not a promised mathematical lower bound. + +### `gaps: Vec` + +Invocation-wide gaps. + +### `models: Vec` + +One entry per literal provider/model pair. + +## Implementations + +### `impl UsageAccounting` + +
UsageAccounting"}} />
+ +#### `from_usage` + +
AgentUsage) -> Result<Option<Self>, AccountingError>"}} />
+ +Decode and validate the typed extension, if present. Descriptor schema validation remains required at the runtime boundary as well. + +## Trait Implementations + +### `impl Clone for UsageAccounting` + +
Clone for UsageAccounting"}} />
+ +#### `clone` + +
clone(&self) -> UsageAccounting"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for UsageAccounting` + +
Debug for UsageAccounting"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for UsageAccounting` + +
Deserialize<'de> for UsageAccounting"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for UsageAccounting` + +
UsageAccounting"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for UsageAccounting` + +
PartialEq for UsageAccounting"}} />
+ +#### `eq` + +
eq(&self, other: &UsageAccounting) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for UsageAccounting` + +
Serialize for UsageAccounting"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl StructuralPartialEq for UsageAccounting` + +
StructuralPartialEq for UsageAccounting"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/usage-accounting/struct-usagegap.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/usage-accounting/struct-usagegap.mdx new file mode 100644 index 000000000..afa7275bd --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/usage-accounting/struct-usagegap.mdx @@ -0,0 +1,99 @@ +--- +title: "Struct Usage Gap" +sidebar-title: "UsageGap" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/usage_accounting/struct-usagegap" +description: "Missing evidence for one or more raw dimensions." +position: 5 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
Vec<UsageDimension>,\n    pub reason: GapReason,\n}"}} />
+ +Missing evidence for one or more raw dimensions. + +## Fields + +### `dimensions: Vec` + +Nonempty, unique set of affected dimensions. + +### `reason: GapReason` + +Why those dimensions are incomplete. + +## Trait Implementations + +### `impl Clone for UsageGap` + +
Clone for UsageGap"}} />
+ +#### `clone` + +
clone(&self) -> UsageGap"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for UsageGap` + +
Debug for UsageGap"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for UsageGap` + +
Deserialize<'de> for UsageGap"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for UsageGap` + +
UsageGap"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for UsageGap` + +
PartialEq for UsageGap"}} />
+ +#### `eq` + +
eq(&self, other: &UsageGap) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for UsageGap` + +
Serialize for UsageGap"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl StructuralPartialEq for UsageGap` + +
StructuralPartialEq for UsageGap"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/usage-accounting/struct-usagerequest.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/usage-accounting/struct-usagerequest.mdx new file mode 100644 index 000000000..476f49f11 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/usage-accounting/struct-usagerequest.mdx @@ -0,0 +1,103 @@ +--- +title: "Struct Usage Request" +sidebar-title: "UsageRequest" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/usage_accounting/struct-usagerequest" +description: "One model call retained for request-tier pricing and evidence reconciliation." +position: 6 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
ReportedUsage,\n    pub gap: Option<GapReason>,\n    pub tokens_aligned: bool,\n}"}} />
+ +One model call retained for request-tier pricing and evidence reconciliation. + +## Fields + +### `usage: ReportedUsage` + +One deduplicated model-call snapshot, never a session/model aggregate. + +### `gap: Option` + +Known incompleteness of this call's source coverage. + +### `tokens_aligned: bool` + +Present observed fields describe the same request/subset. + +## Trait Implementations + +### `impl Clone for UsageRequest` + +
Clone for UsageRequest"}} />
+ +#### `clone` + +
clone(&self) -> UsageRequest"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for UsageRequest` + +
Debug for UsageRequest"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for UsageRequest` + +
Deserialize<'de> for UsageRequest"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for UsageRequest` + +
UsageRequest"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for UsageRequest` + +
PartialEq for UsageRequest"}} />
+ +#### `eq` + +
eq(&self, other: &UsageRequest) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for UsageRequest` + +
Serialize for UsageRequest"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl StructuralPartialEq for UsageRequest` + +
StructuralPartialEq for UsageRequest"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/constant-what-ran-schema.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/constant-what-ran-schema.mdx new file mode 100644 index 000000000..3b95258d3 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/constant-what-ran-schema.mdx @@ -0,0 +1,15 @@ +--- +title: "Constant WHAT_RAN_SCHEMA" +sidebar-title: "WHAT_RAN_SCHEMA" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/what_ran/constant-what-ran-schema" +description: "Schema name written into every `WhatRan`." +position: 17 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
str = \"nemo.fabric.what_ran\";"}} />
+ +Schema name written into every [`WhatRan`](struct-whatran.mdx). diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/constant-what-ran-version.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/constant-what-ran-version.mdx new file mode 100644 index 000000000..f517163e1 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/constant-what-ran-version.mdx @@ -0,0 +1,15 @@ +--- +title: "Constant WHAT_RAN_VERSION" +sidebar-title: "WHAT_RAN_VERSION" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/what_ran/constant-what-ran-version" +description: "Contract version written into every `WhatRan`." +position: 18 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
str = \"v1alpha1\";"}} />
+ +Contract version written into every [`WhatRan`](struct-whatran.mdx). diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/enum-fact.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/enum-fact.mdx new file mode 100644 index 000000000..027e467d6 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/enum-fact.mdx @@ -0,0 +1,109 @@ +--- +title: "Enum Fact" +sidebar-title: "Fact" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/what_ran/enum-fact" +description: "One fact: a `value`, or a `state` with a `reason`, never both." +position: 14 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
KnownFact<T>),\n    Missing(MissingFact),\n}"}} />
+ +One fact: a `value`, or a `state` with a `reason`, never both. + +## Variants + +### `Known(KnownFact)` + +
KnownFact<T>)"}} />
+ +The fact has a value. + +### `Missing(MissingFact)` + +
MissingFact)"}} />
+ +The fact has no value. + +## Trait Implementations + +### `impl Clone for Fact` + +
Clone> Clone for Fact<T>"}} />
+ +#### `clone` + +
clone(&self) -> Fact<T>"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for Fact` + +
Debug> Debug for Fact<T>"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de, T: DeserializeOwned> Deserialize<'de> for Fact` + +
DeserializeOwned> Deserialize<'de> for Fact<T>"}} />
+ +#### `deserialize` + +
deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error>"}} />
+ +Decode a fact, rejecting one that holds `value` together with `state` or `reason`, or an open code outside its pattern. Unknown properties are ignored. + +### `impl JsonSchema for Factwhere T: JsonSchema,` + +
Fact<T>where\n    T: JsonSchema,"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for Fact` + +
PartialEq> PartialEq for Fact<T>"}} />
+ +#### `eq` + +
eq(&self, other: &Fact<T>) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for Factwhere T: Serialize,` + +
Serialize for Fact<T>where\n    T: Serialize,"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Eq for Fact` + +
Eq> Eq for Fact<T>"}} />
+ +### `impl StructuralPartialEq for Fact` + +
StructuralPartialEq for Fact<T>"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/enum-factsource.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/enum-factsource.mdx new file mode 100644 index 000000000..fde659009 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/enum-factsource.mdx @@ -0,0 +1,123 @@ +--- +title: "Enum Fact Source" +sidebar-title: "FactSource" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/what_ran/enum-factsource" +description: "How NeMo Fabric learned a fact." +position: 15 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +```rust +pub enum FactSource { + Observed, + Reported, + Configured, +} +``` + +How NeMo Fabric learned a fact. + +## Variants + +### `Observed` + +
+ +NeMo Fabric core saw it itself. + +### `Reported` + +
+ +The adapter, the harness, an MCP server or a remote service said so. + +### `Configured` + +
+ +It comes from the resolved run plan. + +## Trait Implementations + +### `impl Clone for FactSource` + +
Clone for FactSource"}} />
+ +#### `clone` + +
clone(&self) -> FactSource"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for FactSource` + +
Debug for FactSource"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for FactSource` + +
Deserialize<'de> for FactSource"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for FactSource` + +
FactSource"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for FactSource` + +
PartialEq for FactSource"}} />
+ +#### `eq` + +
eq(&self, other: &FactSource) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for FactSource` + +
Serialize for FactSource"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Copy for FactSource` + +
Copy for FactSource"}} />
+ +### `impl Eq for FactSource` + +
Eq for FactSource"}} />
+ +### `impl StructuralPartialEq for FactSource` + +
StructuralPartialEq for FactSource"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/enum-factstate.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/enum-factstate.mdx new file mode 100644 index 000000000..b91461c16 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/enum-factstate.mdx @@ -0,0 +1,130 @@ +--- +title: "Enum Fact State" +sidebar-title: "FactState" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/what_ran/enum-factstate" +description: "Why a fact has no value. A collector picks the first state that matches, in declaration order." +position: 16 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +```rust +pub enum FactState { + NotApplicable, + Unsupported, + CollectionFailed, + Unavailable, +} +``` + +Why a fact has no value. A collector picks the first state that matches, in declaration order. + +## Variants + +### `NotApplicable` + +
+ +The fact cannot exist for this run. + +### `Unsupported` + +
+ +The adapter or environment provider has no source for the fact. + +### `CollectionFailed` + +
+ +A source exists, but it failed, timed out, returned an invalid report or never returned. + +### `Unavailable` + +
+ +NeMo Fabric holds no value and no evidence for the other states. + +## Trait Implementations + +### `impl Clone for FactState` + +
Clone for FactState"}} />
+ +#### `clone` + +
clone(&self) -> FactState"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for FactState` + +
Debug for FactState"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for FactState` + +
Deserialize<'de> for FactState"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for FactState` + +
FactState"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for FactState` + +
PartialEq for FactState"}} />
+ +#### `eq` + +
eq(&self, other: &FactState) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for FactState` + +
Serialize for FactState"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Copy for FactState` + +
Copy for FactState"}} />
+ +### `impl Eq for FactState` + +
Eq for FactState"}} />
+ +### `impl StructuralPartialEq for FactState` + +
StructuralPartialEq for FactState"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/index.mdx new file mode 100644 index 000000000..c1cc9bb63 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/index.mdx @@ -0,0 +1,55 @@ +--- +title: "Module what_ran" +sidebar-title: "what_ran" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/what_ran" +description: "What executed for one invocation, and how NeMo Fabric learned each fact." +position: 177 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +What executed for one invocation, and how NeMo Fabric learned each fact. + +[`WhatRan`](struct-whatran.mdx) rides on [`RunResult`](../runtime/struct-runresult.mdx). Each fact is a [`Fact`](enum-fact.mdx): a value, or a [`FactState`](enum-factstate.mdx) with a reason, and in both cases the [`FactSource`](enum-factsource.mdx), the component that collected it and the method it used. A few names fixed by the run plan stay plain strings: the adapter ID, each model role's provider, the environment provider, and each MCP server's origin and transport. + +A string value longer than 256 characters, in a string fact or as an element of a string-list fact, keeps its first 240 characters, then `~`, then the first 15 hex digits of its SHA-256. Map keys and the plain names above are never cut. + +`models` keeps at most 16 roles and `mcp.servers` at most 32 servers, in sorted key order. An entry whose key is longer than 128 characters is dropped. While the compact JSON of the whole object is larger than 128 KiB, MCP server entries are dropped from the end of the sorted order, then model roles. Dropped entries are counted in a [`TruncatedEntries`](struct-truncatedentries.mdx) marker next to the map: `models_truncated` and `mcp.servers_truncated`. If the object is still too large with no MCP server and no model role, it is replaced by a minimal one that holds only the schema name, the version and the `what_ran_truncated` marker. + +`what_ran` is evidence. It never decides which runs are compared. + +Compatibility rules for readers: + +- `source` and `state` are closed sets. A new value changes [`WHAT_RAN_VERSION`](constant-what-ran-version.mdx). +- `collected_by`, `method` and `reason` are open. Readers accept values they do not know. +- Readers accept unknown properties. Adding an optional property keeps [`WHAT_RAN_VERSION`](constant-what-ran-version.mdx); removing one or changing its meaning changes it. The Rust types drop unknown properties when they decode, so a host that relays a task core's result drops them too. +- A fact holds `value`, or `state` and `reason`, never both. A `RunResult` reader that finds a fact breaking this, any other value the schema rejects, or another schema or version reads `what_ran` as absent and still decodes the result. + +## Structs + +- [AdapterFacts](struct-adapterfacts.mdx): Adapter facts. +- [EnvironmentFacts](struct-environmentfacts.mdx): Environment facts. +- [FabricFacts](struct-fabricfacts.mdx): NeMo Fabric core facts. +- [FactOrigin](struct-factorigin.mdx): Who collected a fact and how. +- [HarnessFacts](struct-harnessfacts.mdx): Harness facts. +- [KnownFact](struct-knownfact.mdx): A fact with a value. +- [McpFacts](struct-mcpfacts.mdx): MCP facts. +- [McpRuntimeFacts](struct-mcpruntimefacts.mdx): What the harness learned about one MCP server when it connected. +- [McpServerFacts](struct-mcpserverfacts.mdx): Facts about one MCP server. +- [MissingFact](struct-missingfact.mdx): A fact without a value. +- [ModelFacts](struct-modelfacts.mdx): Facts about one model role. +- [TruncatedEntries](struct-truncatedentries.mdx): Entries core dropped to keep `what_ran` within its bounds: next to a map for the entries dropped from it, or as `what_ran_truncated` for the whole object. +- [WhatRan](struct-whatran.mdx): What executed for one invocation. + +## Enums + +- [Fact](enum-fact.mdx): One fact: a `value`, or a `state` with a `reason`, never both. +- [FactSource](enum-factsource.mdx): How NeMo Fabric learned a fact. +- [FactState](enum-factstate.mdx): Why a fact has no value. A collector picks the first state that matches, in declaration order. + +## Constants + +- [WHAT_RAN_SCHEMA](constant-what-ran-schema.mdx): Schema name written into every [`WhatRan`](struct-whatran.mdx). +- [WHAT_RAN_VERSION](constant-what-ran-version.mdx): Contract version written into every [`WhatRan`](struct-whatran.mdx). diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-adapterfacts.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-adapterfacts.mdx new file mode 100644 index 000000000..dcd1f6145 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-adapterfacts.mdx @@ -0,0 +1,103 @@ +--- +title: "Struct Adapter Facts" +sidebar-title: "AdapterFacts" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/what_ran/struct-adapterfacts" +description: "Adapter facts." +position: 1 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
Option<String>,\n    pub package_version: Fact<String>,\n}"}} />
+ +Adapter facts. + +## Fields + +### `id: Option` + +Adapter ID from the plan, when an adapter was resolved. + +### `package_version: Fact` + +Version of the adapter package, as the adapter reports it. + +## Trait Implementations + +### `impl Clone for AdapterFacts` + +
Clone for AdapterFacts"}} />
+ +#### `clone` + +
clone(&self) -> AdapterFacts"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for AdapterFacts` + +
Debug for AdapterFacts"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for AdapterFacts` + +
Deserialize<'de> for AdapterFacts"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for AdapterFacts` + +
AdapterFacts"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for AdapterFacts` + +
PartialEq for AdapterFacts"}} />
+ +#### `eq` + +
eq(&self, other: &AdapterFacts) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for AdapterFacts` + +
Serialize for AdapterFacts"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Eq for AdapterFacts` + +
Eq for AdapterFacts"}} />
+ +### `impl StructuralPartialEq for AdapterFacts` + +
StructuralPartialEq for AdapterFacts"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-environmentfacts.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-environmentfacts.mdx new file mode 100644 index 000000000..f4b0a06b0 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-environmentfacts.mdx @@ -0,0 +1,99 @@ +--- +title: "Struct Environment Facts" +sidebar-title: "EnvironmentFacts" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/what_ran/struct-environmentfacts" +description: "Environment facts." +position: 2 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
String,\n}"}} />
+ +Environment facts. + +## Fields + +### `provider: String` + +Environment provider, such as `local`. + +## Trait Implementations + +### `impl Clone for EnvironmentFacts` + +
Clone for EnvironmentFacts"}} />
+ +#### `clone` + +
clone(&self) -> EnvironmentFacts"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for EnvironmentFacts` + +
Debug for EnvironmentFacts"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for EnvironmentFacts` + +
Deserialize<'de> for EnvironmentFacts"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for EnvironmentFacts` + +
EnvironmentFacts"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for EnvironmentFacts` + +
PartialEq for EnvironmentFacts"}} />
+ +#### `eq` + +
eq(&self, other: &EnvironmentFacts) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for EnvironmentFacts` + +
Serialize for EnvironmentFacts"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Eq for EnvironmentFacts` + +
Eq for EnvironmentFacts"}} />
+ +### `impl StructuralPartialEq for EnvironmentFacts` + +
StructuralPartialEq for EnvironmentFacts"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-fabricfacts.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-fabricfacts.mdx new file mode 100644 index 000000000..eea91521c --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-fabricfacts.mdx @@ -0,0 +1,103 @@ +--- +title: "Struct Fabric Facts" +sidebar-title: "FabricFacts" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/what_ran/struct-fabricfacts" +description: "NeMo Fabric core facts." +position: 3 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
Fact<String>,\n    pub host_core_version: Fact<String>,\n}"}} />
+ +NeMo Fabric core facts. + +## Fields + +### `core_version: Fact` + +Version of the core that ran the adapter. + +### `host_core_version: Fact` + +Version of the host core that relayed the result from a task core in another environment. `not_applicable` for a local run. + +## Trait Implementations + +### `impl Clone for FabricFacts` + +
Clone for FabricFacts"}} />
+ +#### `clone` + +
clone(&self) -> FabricFacts"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for FabricFacts` + +
Debug for FabricFacts"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for FabricFacts` + +
Deserialize<'de> for FabricFacts"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for FabricFacts` + +
FabricFacts"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for FabricFacts` + +
PartialEq for FabricFacts"}} />
+ +#### `eq` + +
eq(&self, other: &FabricFacts) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for FabricFacts` + +
Serialize for FabricFacts"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Eq for FabricFacts` + +
Eq for FabricFacts"}} />
+ +### `impl StructuralPartialEq for FabricFacts` + +
StructuralPartialEq for FabricFacts"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-factorigin.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-factorigin.mdx new file mode 100644 index 000000000..513cb6af9 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-factorigin.mdx @@ -0,0 +1,107 @@ +--- +title: "Struct Fact Origin" +sidebar-title: "FactOrigin" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/what_ran/struct-factorigin" +description: "Who collected a fact and how." +position: 4 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
FactSource,\n    pub collected_by: String,\n    pub method: String,\n}"}} />
+ +Who collected a fact and how. + +## Fields + +### `source: FactSource` + +How NeMo Fabric learned the fact. + +### `collected_by: String` + +Component that collected the fact, such as `host_core`, `task_core` or `adapter`. + +### `method: String` + +How the collector got the fact, such as `crate_version`, `resolved_plan` or `adapter_report`. + +## Trait Implementations + +### `impl Clone for FactOrigin` + +
Clone for FactOrigin"}} />
+ +#### `clone` + +
clone(&self) -> FactOrigin"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for FactOrigin` + +
Debug for FactOrigin"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for FactOrigin` + +
Deserialize<'de> for FactOrigin"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for FactOrigin` + +
FactOrigin"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for FactOrigin` + +
PartialEq for FactOrigin"}} />
+ +#### `eq` + +
eq(&self, other: &FactOrigin) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for FactOrigin` + +
Serialize for FactOrigin"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Eq for FactOrigin` + +
Eq for FactOrigin"}} />
+ +### `impl StructuralPartialEq for FactOrigin` + +
StructuralPartialEq for FactOrigin"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-harnessfacts.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-harnessfacts.mdx new file mode 100644 index 000000000..a683a0d5f --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-harnessfacts.mdx @@ -0,0 +1,103 @@ +--- +title: "Struct Harness Facts" +sidebar-title: "HarnessFacts" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/what_ran/struct-harnessfacts" +description: "Harness facts." +position: 5 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
Fact<String>,\n    pub version: Fact<String>,\n}"}} />
+ +Harness facts. + +## Fields + +### `name: Fact` + +Name of the harness program that ran, as the adapter reports it. Core never derives it from the adapter ID. + +### `version: Fact` + +Version of the harness program that ran, as the adapter reports it. + +## Trait Implementations + +### `impl Clone for HarnessFacts` + +
Clone for HarnessFacts"}} />
+ +#### `clone` + +
clone(&self) -> HarnessFacts"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for HarnessFacts` + +
Debug for HarnessFacts"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for HarnessFacts` + +
Deserialize<'de> for HarnessFacts"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for HarnessFacts` + +
HarnessFacts"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for HarnessFacts` + +
PartialEq for HarnessFacts"}} />
+ +#### `eq` + +
eq(&self, other: &HarnessFacts) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for HarnessFacts` + +
Serialize for HarnessFacts"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Eq for HarnessFacts` + +
Eq for HarnessFacts"}} />
+ +### `impl StructuralPartialEq for HarnessFacts` + +
StructuralPartialEq for HarnessFacts"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-knownfact.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-knownfact.mdx new file mode 100644 index 000000000..8e559977a --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-knownfact.mdx @@ -0,0 +1,103 @@ +--- +title: "Struct Known Fact" +sidebar-title: "KnownFact" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/what_ran/struct-knownfact" +description: "A fact with a value." +position: 6 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
FactOrigin,\n}"}} />
+ +A fact with a value. + +## Fields + +### `value: T` + +The fact itself: a string, a boolean or a list of strings. + +### `origin: FactOrigin` + +Who collected the fact and how. + +## Trait Implementations + +### `impl Clone for KnownFact` + +
Clone> Clone for KnownFact<T>"}} />
+ +#### `clone` + +
clone(&self) -> KnownFact<T>"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for KnownFact` + +
Debug> Debug for KnownFact<T>"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de, T> Deserialize<'de> for KnownFactwhere T: Deserialize<'de>,` + +
Deserialize<'de> for KnownFact<T>where\n    T: Deserialize<'de>,"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for KnownFactwhere T: JsonSchema,` + +
KnownFact<T>where\n    T: JsonSchema,"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for KnownFact` + +
PartialEq> PartialEq for KnownFact<T>"}} />
+ +#### `eq` + +
eq(&self, other: &KnownFact<T>) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for KnownFactwhere T: Serialize,` + +
Serialize for KnownFact<T>where\n    T: Serialize,"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Eq for KnownFact` + +
Eq> Eq for KnownFact<T>"}} />
+ +### `impl StructuralPartialEq for KnownFact` + +
StructuralPartialEq for KnownFact<T>"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-mcpfacts.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-mcpfacts.mdx new file mode 100644 index 000000000..a5628947d --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-mcpfacts.mdx @@ -0,0 +1,103 @@ +--- +title: "Struct McpFacts" +sidebar-title: "McpFacts" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/what_ran/struct-mcpfacts" +description: "MCP facts." +position: 7 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
BTreeMap<String, McpServerFacts>,\n    pub servers_truncated: Option<TruncatedEntries>,\n}"}} />
+ +MCP facts. + +## Fields + +### `servers: BTreeMap` + +Servers keyed by name. Empty when the plan configures no MCP server and the harness reports none. At most 32 servers, in sorted key order, each key at most 128 characters. + +### `servers_truncated: Option` + +Servers dropped from `servers`. Absent when none was dropped, never `null`. + +## Trait Implementations + +### `impl Clone for McpFacts` + +
Clone for McpFacts"}} />
+ +#### `clone` + +
clone(&self) -> McpFacts"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for McpFacts` + +
Debug for McpFacts"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for McpFacts` + +
Deserialize<'de> for McpFacts"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for McpFacts` + +
McpFacts"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for McpFacts` + +
PartialEq for McpFacts"}} />
+ +#### `eq` + +
eq(&self, other: &McpFacts) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for McpFacts` + +
Serialize for McpFacts"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Eq for McpFacts` + +
Eq for McpFacts"}} />
+ +### `impl StructuralPartialEq for McpFacts` + +
StructuralPartialEq for McpFacts"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-mcpruntimefacts.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-mcpruntimefacts.mdx new file mode 100644 index 000000000..afa1bece5 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-mcpruntimefacts.mdx @@ -0,0 +1,115 @@ +--- +title: "Struct McpRuntime Facts" +sidebar-title: "McpRuntimeFacts" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/what_ran/struct-mcpruntimefacts" +description: "What the harness learned about one MCP server when it connected." +position: 8 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
Fact<bool>,\n    pub protocol_version: Fact<String>,\n    pub server_name: Fact<String>,\n    pub server_version: Fact<String>,\n    pub tool_names: Fact<Vec<String>>,\n}"}} />
+ +What the harness learned about one MCP server when it connected. + +## Fields + +### `connected: Fact` + +Whether the harness connected to the server. + +### `protocol_version: Fact` + +MCP protocol version negotiated with the server. + +### `server_name: Fact` + +Name the server reports for itself. + +### `server_version: Fact` + +Version the server reports for itself. + +### `tool_names: Fact>` + +Raw tool names the server lists, each bounded by the string length rule. + +## Trait Implementations + +### `impl Clone for McpRuntimeFacts` + +
Clone for McpRuntimeFacts"}} />
+ +#### `clone` + +
clone(&self) -> McpRuntimeFacts"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for McpRuntimeFacts` + +
Debug for McpRuntimeFacts"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for McpRuntimeFacts` + +
Deserialize<'de> for McpRuntimeFacts"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for McpRuntimeFacts` + +
McpRuntimeFacts"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for McpRuntimeFacts` + +
PartialEq for McpRuntimeFacts"}} />
+ +#### `eq` + +
eq(&self, other: &McpRuntimeFacts) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for McpRuntimeFacts` + +
Serialize for McpRuntimeFacts"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Eq for McpRuntimeFacts` + +
Eq for McpRuntimeFacts"}} />
+ +### `impl StructuralPartialEq for McpRuntimeFacts` + +
StructuralPartialEq for McpRuntimeFacts"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-mcpserverfacts.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-mcpserverfacts.mdx new file mode 100644 index 000000000..ad6c67371 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-mcpserverfacts.mdx @@ -0,0 +1,113 @@ +--- +title: "Struct McpServer Facts" +sidebar-title: "McpServerFacts" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/what_ran/struct-mcpserverfacts" +description: "Facts about one MCP server." +position: 9 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
String,\n    pub transport: Option<String>,\n    pub endpoint: Fact<String>,\n    pub runtime: McpRuntimeFacts,\n}"}} />
+ +Facts about one MCP server. + +An entry with origin `harness` is a server the harness reports and the plan does not configure. It has the same shape: its `endpoint` reads `not_applicable` with reason `not_configured`, and runtime facts the adapter did not collect read `unavailable` with reason `not_collected`. Only a `plan` entry must name its transport. An entry of any other origin may leave it out. + +## Fields + +### `origin: String` + +Where the entry comes from: `plan` for a server the plan configures, `harness` for one only the harness reports. + +### `transport: Option` + +Transport, such as `stdio` or `streamable-http`. Required for a `plan` entry, and optional for any other origin. + +### `endpoint: Fact` + +Configured endpoint without secrets: the lowercase scheme and host and the port for a network server, or the executable's file name for a `stdio` server. + +### `runtime: McpRuntimeFacts` + +What the harness learned when it connected, as the adapter reports it. + +## Trait Implementations + +### `impl Clone for McpServerFacts` + +
Clone for McpServerFacts"}} />
+ +#### `clone` + +
clone(&self) -> McpServerFacts"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for McpServerFacts` + +
Debug for McpServerFacts"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for McpServerFacts` + +
Deserialize<'de> for McpServerFacts"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for McpServerFacts` + +
McpServerFacts"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for McpServerFacts` + +
PartialEq for McpServerFacts"}} />
+ +#### `eq` + +
eq(&self, other: &McpServerFacts) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for McpServerFacts` + +
Serialize for McpServerFacts"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Eq for McpServerFacts` + +
Eq for McpServerFacts"}} />
+ +### `impl StructuralPartialEq for McpServerFacts` + +
StructuralPartialEq for McpServerFacts"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-missingfact.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-missingfact.mdx new file mode 100644 index 000000000..04a2c23d3 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-missingfact.mdx @@ -0,0 +1,107 @@ +--- +title: "Struct Missing Fact" +sidebar-title: "MissingFact" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/what_ran/struct-missingfact" +description: "A fact without a value." +position: 10 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
FactState,\n    pub reason: String,\n    pub origin: FactOrigin,\n}"}} />
+ +A fact without a value. + +## Fields + +### `state: FactState` + +Why the fact has no value. + +### `reason: String` + +Short code that explains the state, such as `key_not_declared`. + +### `origin: FactOrigin` + +Who would have collected the fact and how. + +## Trait Implementations + +### `impl Clone for MissingFact` + +
Clone for MissingFact"}} />
+ +#### `clone` + +
clone(&self) -> MissingFact"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for MissingFact` + +
Debug for MissingFact"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for MissingFact` + +
Deserialize<'de> for MissingFact"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for MissingFact` + +
MissingFact"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for MissingFact` + +
PartialEq for MissingFact"}} />
+ +#### `eq` + +
eq(&self, other: &MissingFact) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for MissingFact` + +
Serialize for MissingFact"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Eq for MissingFact` + +
Eq for MissingFact"}} />
+ +### `impl StructuralPartialEq for MissingFact` + +
StructuralPartialEq for MissingFact"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-modelfacts.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-modelfacts.mdx new file mode 100644 index 000000000..e42753677 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-modelfacts.mdx @@ -0,0 +1,107 @@ +--- +title: "Struct Model Facts" +sidebar-title: "ModelFacts" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/what_ran/struct-modelfacts" +description: "Facts about one model role." +position: 11 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
String,\n    pub requested: Fact<String>,\n    pub served: Fact<String>,\n}"}} />
+ +Facts about one model role. + +## Fields + +### `provider: String` + +Provider from the plan. + +### `requested: Fact` + +Model the plan requested. + +### `served: Fact` + +Model that served, as the harness or provider reports it. + +## Trait Implementations + +### `impl Clone for ModelFacts` + +
Clone for ModelFacts"}} />
+ +#### `clone` + +
clone(&self) -> ModelFacts"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for ModelFacts` + +
Debug for ModelFacts"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for ModelFacts` + +
Deserialize<'de> for ModelFacts"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for ModelFacts` + +
ModelFacts"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for ModelFacts` + +
PartialEq for ModelFacts"}} />
+ +#### `eq` + +
eq(&self, other: &ModelFacts) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for ModelFacts` + +
Serialize for ModelFacts"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Eq for ModelFacts` + +
Eq for ModelFacts"}} />
+ +### `impl StructuralPartialEq for ModelFacts` + +
StructuralPartialEq for ModelFacts"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-truncatedentries.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-truncatedentries.mdx new file mode 100644 index 000000000..062b10bd0 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-truncatedentries.mdx @@ -0,0 +1,105 @@ +--- +title: "Struct Truncated Entries" +sidebar-title: "TruncatedEntries" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/what_ran/struct-truncatedentries" +description: "Entries core dropped to keep `what_ran` within its bounds: next to a map for the entries dropped from it, or as `what_ran_truncated` for the whole object." +position: 12 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
String,\n    pub dropped: u64,\n}"}} />
+ +Entries core dropped to keep `what_ran` within its bounds: next to a map for the entries dropped from it, or as `what_ran_truncated` for the whole object. + +## Fields + +### `reason: String` + +Why the entries were dropped: `key_too_long` for a key longer than 128 characters, `entry_cap` for an entry past the map's cap, or `byte_budget` for an entry dropped to keep the whole object within 128 KiB. One marker covers a map: when more than one rule dropped entries from it, `reason` names the last rule applied, in that order, and `dropped` counts them all. An open code like a fact's `reason`. + +### `dropped: u64` + +How many entries were dropped. A JSON integer: the Rust and Python readers reject `1.0`, although a JSON Schema validator may accept it. + +## Trait Implementations + +### `impl Clone for TruncatedEntries` + +
Clone for TruncatedEntries"}} />
+ +#### `clone` + +
clone(&self) -> TruncatedEntries"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for TruncatedEntries` + +
Debug for TruncatedEntries"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for TruncatedEntries` + +
Deserialize<'de> for TruncatedEntries"}} />
+ +#### `deserialize` + +
deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error>"}} />
+ +Decode a marker, rejecting a `reason` outside the code pattern or a `dropped` count of zero. Unknown properties are ignored. + +### `impl JsonSchema for TruncatedEntries` + +
TruncatedEntries"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for TruncatedEntries` + +
PartialEq for TruncatedEntries"}} />
+ +#### `eq` + +
eq(&self, other: &TruncatedEntries) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for TruncatedEntries` + +
Serialize for TruncatedEntries"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Eq for TruncatedEntries` + +
Eq for TruncatedEntries"}} />
+ +### `impl StructuralPartialEq for TruncatedEntries` + +
StructuralPartialEq for TruncatedEntries"}} />
diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-whatran.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-whatran.mdx new file mode 100644 index 000000000..2873628a8 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/what-ran/struct-whatran.mdx @@ -0,0 +1,139 @@ +--- +title: "Struct WhatRan" +sidebar-title: "WhatRan" +slug: "/reference/api/rust-library-reference/nemo-fabric-core/what_ran/struct-whatran" +description: "What executed for one invocation." +position: 13 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Generated from `cargo doc --no-deps -p nemo-fabric-core`. + +
String,\n    pub version: String,\n    pub fabric: Option<FabricFacts>,\n    pub adapter: Option<AdapterFacts>,\n    pub harness: Option<HarnessFacts>,\n    pub models: Option<BTreeMap<String, ModelFacts>>,\n    pub models_truncated: Option<TruncatedEntries>,\n    pub mcp: Option<McpFacts>,\n    pub environment: Option<EnvironmentFacts>,\n    pub what_ran_truncated: Option<TruncatedEntries>,\n}"}} />
+ +What executed for one invocation. + +Every section is present, unless `what_ran_truncated` is present: then the object is a minimal one that the byte budget left with only its schema name and version, and every section may be absent. + +Decoding a `WhatRan` directly checks the shape of every section and fact, and that no section is `null`. `RunResult` also checks the schema name and version, that every section is present unless `what_ran_truncated` is, and that every `plan` MCP entry names its transport. + +## Fields + +### `schema: String` + +Schema name, `nemo.fabric.what_ran`. + +### `version: String` + +Contract version, `v1alpha1`. + +### `fabric: Option` + +NeMo Fabric core that ran the adapter. + +### `adapter: Option` + +Adapter that ran. + +### `harness: Option` + +Harness behind the adapter. + +### `models: Option>` + +Model facts keyed by the role names of the plan's `models` settings. At most 16 roles, in sorted key order, each key at most 128 characters. + +### `models_truncated: Option` + +Model roles dropped from `models`. Absent when none was dropped, never `null`. + +### `mcp: Option` + +MCP server facts. + +### `environment: Option` + +Environment the runtime ran in. + +### `what_ran_truncated: Option` + +Present only on a minimal object: every section was dropped to keep the object within 128 KiB, with reason `byte_budget` and a dropped count of 1. + +## Trait Implementations + +### `impl Clone for WhatRan` + +
Clone for WhatRan"}} />
+ +#### `clone` + +
clone(&self) -> WhatRan"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for WhatRan` + +
Debug for WhatRan"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl<'de> Deserialize<'de> for WhatRan` + +
Deserialize<'de> for WhatRan"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for WhatRan` + +
WhatRan"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for WhatRan` + +
PartialEq for WhatRan"}} />
+ +#### `eq` + +
eq(&self, other: &WhatRan) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for WhatRan` + +
Serialize for WhatRan"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Eq for WhatRan` + +
Eq for WhatRan"}} />
+ +### `impl StructuralPartialEq for WhatRan` + +
StructuralPartialEq for WhatRan"}} />
diff --git a/docs/sdk/python.mdx b/docs/sdk/python.mdx index baa97b1b0..a1018bc23 100644 --- a/docs/sdk/python.mdx +++ b/docs/sdk/python.mdx @@ -1266,6 +1266,7 @@ Important fields: | `status` | Terminal invocation status such as success, failure, or cancellation. | | `output` | Harness output normalized to the configured output schema. | | `error` | Structured failure metadata when available. | +| `model_usage` | Optional normalized complete counts, observations, gaps and separately reported/estimated cost. | | `usage` | Optional `RunUsage` with invocation-local input, cached-input, output, and total token counts, reported cost in USD, and provider metadata. | | `system_stats` | Optional `InvocationSystemStats`: monotonic adapter exchange duration, before/after observations with availability, and an optional CPU counter difference estimate. | | `resource_snapshot` | Optional `RuntimeResourceSnapshot` of the runtime, read once after the adapter returned the result. | @@ -1273,6 +1274,7 @@ Important fields: | `telemetry` | References to NeMo Relay or other telemetry streams produced by the run. | | `events` | Ordered normalized lifecycle and invocation events. | | `metadata` | Result-specific structured metadata. | +| `what_ran` | What executed for this invocation, and how NeMo Fabric learned each fact. | | `runtime_id`, `invocation_id`, `request_id` | IDs for correlation across runtimes, logs, telemetry, and artifacts. | These are structured correlation fields, not interchangeable metadata: @@ -1302,6 +1304,147 @@ If NeMo Fabric cannot resolve config, start a runtime, or obtain a normalized re the SDK raises a `FabricError` subclass instead of returning a partial `RunResult`. +### Complete counts and useful observations + +`result.model_usage` is present when the adapter supplies valid `nemo_fabric.usage_accounting.v1` evidence and NeMo Fabric successfully normalizes it. Its token fields are independently nullable counts: input includes cache reads and writes, output includes reasoning, and total requires known input plus output. The independent raw provider total remains in `result.usage`. + +Typed access to `model_usage` requires an updated SDK. Permissive readers can retain or ignore the new field; closed validators must use the updated result schema. Current readers still accept older results without this field. + +```python +from nemo_fabric import summarize_usage + +if result.model_usage is not None: + print(result.model_usage.input_tokens) # Complete count, or None. + print(result.model_usage.observed.input_tokens) # Available observations. + print(result.model_usage.gaps) # Affected dimensions/reasons. + print(result.model_usage.cost.estimated.amount_nano_usd) + +summary = summarize_usage( + [result], expected=[(result.runtime_id, result.invocation_id)] +) +print(summary["dimensions"]["input_tokens"]) +``` + +A complete count covers work initiated by the invocation, including descendants, +at return. Owned unfinished work or missing usage leaves only the affected +complete counts unknown. Failure alone is not a gap. Observations remain visible +but are not guaranteed mathematical lower bounds. Returned evidence is frozen; +late usage never transfers to a later invocation. If accounting passes the adapter contract but cannot be normalized, `model_usage` is absent and a sanitized +`model_usage_invalid` event records the accounting failure. The execution status, +output and raw usage remain available. Undeclared or structurally invalid adapter +extensions still fail the adapter contract. Legacy usage without supported +accounting remains available in `result.usage` and is not certified complete by +`summarize_usage`. + +`cost.reported` contains only provider-reported amounts; `cost.estimated` contains +catalog estimates. Both distinguish `amount_nano_usd` from `observed_nano_usd` +in integer nano-USD. +Estimates retain their pricing basis and distinct selected rate snapshots. +Set `FabricConfig(pricing=PricingConfig(catalog_path="prices.json"))` for a Relay-compatible catalog override. Planning freezes the catalog's contents. +The recorded fingerprint covers the validated prices. Changes to file formatting alone do not change it. A plan with catalog data that does not match its fingerprint is rejected. + +When usage identifies a provider, the matching catalog entry must name that provider. Aliases and shorter model paths do not permit another provider's prices. Without a provider identity, an explicit user catalog can match a model name that identifies one provider. An ambiguous match leaves the estimate unavailable; lookup does not continue to a shorter model name. Bundled prices also require evidence of a native provider route. + +Missing model, cache-price or request-tier evidence affects estimates without erasing tokens. Nonzero Anthropic cache writes currently lack expiry-price evidence, so their complete estimate remains unknown; independently priceable parts remain observed. Known zero writes need no expiry evidence. + +Complete per-model estimates can be combined even when the invocation's combined cache breakdown is unavailable. An exhaustive empty model list with complete zero input and output counts has zero estimated token cost. Estimates are not invoices. + +`summarize_usage` requires an explicit expected invocation inventory. It retains +missing results in coverage, keeps complete totals separate from observed sums, +checks uint64 overflow and separates incompatible pricing bases. It never adds +legacy, log or Relay/ATIF values to native accounting. Do not rank partial spend +as cheaper or silently remove timed-out attempts from comparisons. + + +### What Ran + +`what_ran` records what executed for the invocation, as schema +`nemo.fabric.what_ran` version `v1alpha1`. Each fact is an object with a +`value`, or with a `state` and a `reason` when it has no value. Both forms +carry `source`, `collected_by`, and `method`: + +- `source` is `observed` when NeMo Fabric core saw the fact itself, `reported` + when the adapter, harness, an MCP server, or a remote service said so, and + `configured` when it comes from the resolved plan. +- `state` is `not_applicable` when the fact cannot exist for this run, + `unsupported` when there is no source for it, `collection_failed` when the + source failed, and `unavailable` when NeMo Fabric holds no value and no + evidence for the other states. +- `collected_by`, `method`, and `reason` are open codes that match + `^[a-z0-9_.:#+/-]{1,128}$`. Accept values you do not recognize. + `collected_by` is `task_core` for facts a core inside an OpenShell sandbox + collected. `method` names the technique, such as `crate_version`, + `resolved_plan`, or `adapter_report`. + +| Part | Facts | +| --- | --- | +| `fabric` | `core_version` of the core that ran the adapter. `host_core_version` is `not_applicable` with reason `local_run` in a local run, and the host core's version in an OpenShell run. | +| `adapter` | `id` from the plan, and the reported `package_version`. | +| `harness` | The reported `name` and `version` of the harness program. | +| `models.` | `provider` and the `requested` model from the plan, and the reported `served` model. | +| `mcp.servers.` | `origin`, `transport`, the configured `endpoint`, and a `runtime` block with the reported `connected`, `protocol_version`, `server_name`, `server_version`, and `tool_names`. | +| `environment` | The environment `provider`, such as `local` or `openshell`. | + +NeMo Fabric reads no adapter-reported facts yet, so every reported fact has +state `unsupported` with reason `key_not_declared` and method +`adapter_report`. + +Today every MCP entry has `origin` `plan`. An entry with `origin` `harness` +describes a server the harness reports but the plan does not configure. It has +the same shape, except that `endpoint` has state `not_applicable` with reason +`not_configured`. A `plan` entry always has `transport`. An entry of any other +origin can leave it out. + +`endpoint` keeps no secret. A network server's endpoint is its lowercase scheme +and host and its port. A URL without a port gets 80 for `http` and `ws` and 443 +for `https` and `wss`, so `https://example.com` and `https://example.com:443` +give the same endpoint. An IPv6 host keeps its brackets, in compressed lowercase +form such as `[2001:db8::1]`. A `stdio` server's endpoint is the file name of +its executable, such as `server.exe` for `C:\MCP\server.exe`. +User info, paths, queries, fragments, headers, arguments, and environment values +are never copied. + +An endpoint NeMo Fabric cannot reduce safely has state `collection_failed` with +reason `endpoint_unparsed` or `executable_unparsed`. Examples are a URL with an +`@` after the host, a bracketed host that is not an IPv6 address, and an +executable that contains `=` or any whitespace, even in a folder name. An empty +URL or executable has state `unavailable` with reason `not_configured`. + +A string value longer than 256 characters keeps its first 240 characters, then +`~`, then the first 15 hex digits of its SHA-256. The rule applies to each +element of a list value too. + +`models` keeps at most 16 roles and `mcp.servers` at most 32 servers, taken in +sorted key order. An entry whose key is longer than 128 characters is dropped. +While the whole `what_ran` is larger than 128 KiB of compact JSON, MCP servers +are dropped from the end of the sorted order, and then model roles. A map that +lost entries has a marker next to it, `models_truncated` or +`mcp.servers_truncated`, with the `reason` (`key_too_long`, `entry_cap`, or +`byte_budget`) and the `dropped` count. Each map has one marker: when more than +one rule dropped entries, `reason` names the last rule applied, in that order, +and `dropped` counts every dropped entry. `dropped` is a JSON integer. The Rust +core and the Python SDK reject `1.0`, although a JSON Schema validator may +accept it. If `what_ran` is still too large with no MCP server and no model +role left, it holds only `schema`, `version`, and `what_ran_truncated` with +reason `byte_budget` and `dropped` 1. Check for `what_ran_truncated` before you +read a section. Dropping entries never changes the run's status or error. + +`what_ran` is `None` when the core that produced the result predates it. The +Rust core and the Python SDK apply the same structural checks. A `what_ran` +that is not an object, names another schema or version, or has a malformed +section or fact reads as `None`, and the rest of the result is unchanged. A +fact with both `value` and `state` is malformed. Unknown properties are +ignored. Treat `what_ran` as evidence, not as a key for grouping runs. + +```python +what_ran = result.what_ran +if what_ran is not None and "what_ran_truncated" not in what_ran: + core = what_ran["fabric"]["core_version"] + print(core.get("value"), core["source"]) + for role, model in what_ran["models"].items(): + print(role, model["requested"].get("value"), model["served"].get("state")) +``` + ## Events Each `RunResult` includes the normalized events collected for that invocation. diff --git a/pyproject.toml b/pyproject.toml index ff8521cde..77a6b1c63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -161,6 +161,7 @@ nemo-fabric-adapters-openclaw = { path = "adapters/python/openclaw", editable = nemo-fabric-adapters-remote-agent = { path = "adapters/python/remote-agent", editable = true } [tool.pytest.ini_options] +markers = ["integration: verifies behavior across component or language boundaries"] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "session" testpaths = ["tests"] diff --git a/schemas/SCHEMA.md b/schemas/SCHEMA.md index 353c1dff2..626c8062d 100644 --- a/schemas/SCHEMA.md +++ b/schemas/SCHEMA.md @@ -26,6 +26,8 @@ schemas/ │ ├── agent.schema.json │ ├── run-plan.schema.json │ └── ... +├── extensions/ # Descriptor-declared extension payloads +│ └── usage-accounting.schema.json └── adapter-contract/ # Southbound adapter-facing contract ├── adapter-descriptor.schema.json ├── adapter-target-descriptor.schema.json @@ -40,15 +42,18 @@ schemas/ └── openai-stream-record.schema.json ``` -An adapter author can treat `adapter-contract/` as the complete schema entry -point. Contract versions remain explicit fields in the wire format rather than -directory names. +`adapter-contract/` defines the base adapter wire objects. Adapters returning +supported extension payloads must also declare the applicable schema from +`extensions/`. Contract versions remain explicit fields in the wire format +rather than directory names. Start with the [adapter contract overview](https://github.com/NVIDIA/NeMo-Fabric/blob/main/docs/adapter-contract/README.md) for the implementation sequence. Use these generated schemas for exact wire shapes; do not reconstruct the contract from examples. +`extensions/usage-accounting.schema.json` defines the optional `nemo_fabric.usage_accounting.v1` evidence carried in adapter usage. An adapter must declare this key in its descriptor before returning it. Core reduces supported evidence into northbound `sdk/run-result.model_usage`; legacy usage alone does not establish completeness. + The language bindings preserve this boundary: - Python adapters use `nemo-fabric-adapter-contract` for dependency-free @@ -135,6 +140,8 @@ descriptor schema. - `sdk/runtime-resource-snapshot`: timestamped memory, process count, and CPU time of a runtime's local adapter host and its descendants. +`model_usage` separates independently nullable complete token counts, available observations, dimension-specific gaps, provider-reported cost, and catalog-estimated cost. Observations are not guaranteed lower bounds. Results freeze at invocation return; late usage never transfers to a later invocation. Typed access requires an updated SDK; closed validators must use the updated result schema. The [Python SDK guide](../docs/sdk/python.mdx) documents the result and coverage-aware summary helper. + ### Deferred Core Objects The MVP core object pass intentionally defers normalized trajectory structures @@ -174,6 +181,10 @@ just generate-typescript-contract Run `just test-typescript` to check generated-file drift, strict compile-time fixtures, package contents, and clean-consumer imports. +### Invocation Provenance + +`what_ran` in `sdk/run-result` is optional provenance with schema `nemo.fabric.what_ran` and version `v1alpha1`. Rust and Python readers treat malformed provenance or an unsupported schema or version as absent while preserving the rest of the result. Fact `source` and `state` values are closed sets; collector, method, and reason codes are open. Unknown properties are accepted. A `what_ran_truncated` marker permits sections to be absent. This northbound field does not change the adapter result contract. See the [SDK guide](../docs/sdk/python.mdx#what-ran) for facts, bounds, and truncation rules. + ### Invocation System Measurements `system_stats` in `sdk/run-result` contains a monotonic adapter exchange duration, before/after observations of the executing adapter host process tree, and an optional CPU counter difference estimate. Its `ResourceObservation` definition is tagged by `status`: `available` carries a snapshot; `unavailable` and `unsupported` carry a reason. Endpoint memory is not peak memory. A missing estimate is unknown, not zero. The compatible `resource_snapshot` field repeats the final available snapshot. These northbound additions do not change the adapter result contract. diff --git a/schemas/extensions/usage-accounting.schema.json b/schemas/extensions/usage-accounting.schema.json new file mode 100644 index 000000000..7bbe1d76e --- /dev/null +++ b/schemas/extensions/usage-accounting.schema.json @@ -0,0 +1,329 @@ +{ + "$defs": { + "AccountedModel": { + "additionalProperties": false, + "description": "Evidence for one literal provider/model identity.", + "properties": { + "gaps": { + "description": "Gaps affecting this model.", + "items": { + "$ref": "#/$defs/UsageGap" + }, + "type": "array" + }, + "model": { + "description": "Model identity as reported by the source.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + }, + "observed": { + "anyOf": [ + { + "$ref": "#/$defs/ReportedUsage" + }, + { + "type": "null" + } + ], + "description": "Valid deduplicated observations, potentially incomplete." + }, + "observed_tokens_aligned": { + "default": false, + "description": "Every present observed token dimension describes the same source-call subset.\nFalse unless the producer establishes this, including peak and normalization components.", + "type": "boolean" + }, + "provider": { + "description": "Literal provider identity, when available.", + "minLength": 1, + "pattern": "\\S", + "type": [ + "string", + "null" + ] + }, + "reported": { + "$ref": "#/$defs/ReportedUsage", + "default": {}, + "description": "Complete per-model evidence; absent fields are unreported." + }, + "requests": { + "description": "Optional per-call evidence for exact request-tier pricing.", + "items": { + "$ref": "#/$defs/UsageRequest" + }, + "type": "array" + }, + "requests_exhaustive": { + "default": false, + "description": "The request list covers every source unit contributing to this model.", + "type": "boolean" + } + }, + "required": [ + "model", + "gaps" + ], + "type": "object" + }, + "GapReason": { + "description": "Categories that affect whether a consumer can treat a value as complete.", + "oneOf": [ + { + "const": "unfinished_work", + "description": "Owned work was still running at the return boundary.", + "type": "string" + }, + { + "const": "missing_usage", + "description": "A completed call did not provide usable usage.", + "type": "string" + }, + { + "const": "scope_unverified", + "description": "The supported source does not establish coverage of owned work.", + "type": "string" + } + ] + }, + "ReportedUsage": { + "additionalProperties": false, + "description": "Raw counts in provider/harness conventions. Missing values are never zero.", + "properties": { + "cache_write_input_tokens": { + "description": "Cache writes, independently reported.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "cached_input_tokens": { + "description": "Cache reads, independently reported.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "cost_usd": { + "description": "Provider-reported USD, never a client-side price estimate.", + "format": "double", + "minimum": 0.0, + "type": [ + "number", + "null" + ] + }, + "input_tokens": { + "description": "Raw input count.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "input_tokens_include_cache": { + "description": "Whether input includes both cache categories.", + "type": [ + "boolean", + "null" + ] + }, + "output_tokens": { + "description": "Raw output count.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "output_tokens_include_reasoning": { + "description": "Whether output includes reasoning.", + "type": [ + "boolean", + "null" + ] + }, + "peak_request_input_tokens": { + "description": "Largest request's cache-inclusive input.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "reasoning_tokens": { + "description": "Reasoning count, independently reported.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "total_tokens": { + "description": "Provider-reported total, independent of normalized input plus output.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "UsageDimension": { + "description": "A dimension in raw adapter evidence.", + "oneOf": [ + { + "const": "input_tokens", + "description": "Input tokens.", + "type": "string" + }, + { + "const": "cached_input_tokens", + "description": "Cache reads.", + "type": "string" + }, + { + "const": "cache_write_input_tokens", + "description": "Cache writes.", + "type": "string" + }, + { + "const": "output_tokens", + "description": "Output tokens.", + "type": "string" + }, + { + "const": "reasoning_tokens", + "description": "Reasoning tokens.", + "type": "string" + }, + { + "const": "total_tokens", + "description": "Provider-reported total.", + "type": "string" + }, + { + "const": "peak_request_input_tokens", + "description": "Largest request input.", + "type": "string" + }, + { + "const": "cost_usd", + "description": "Provider-reported USD.", + "type": "string" + } + ] + }, + "UsageGap": { + "additionalProperties": false, + "description": "Missing evidence for one or more raw dimensions.", + "properties": { + "dimensions": { + "description": "Nonempty, unique set of affected dimensions.", + "items": { + "$ref": "#/$defs/UsageDimension" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "reason": { + "$ref": "#/$defs/GapReason", + "description": "Why those dimensions are incomplete." + } + }, + "required": [ + "dimensions", + "reason" + ], + "type": "object" + }, + "UsageRequest": { + "additionalProperties": false, + "description": "One model call retained for request-tier pricing and evidence reconciliation.", + "properties": { + "gap": { + "anyOf": [ + { + "$ref": "#/$defs/GapReason" + }, + { + "type": "null" + } + ], + "description": "Known incompleteness of this call's source coverage." + }, + "tokens_aligned": { + "default": false, + "description": "Present observed fields describe the same request/subset.", + "type": "boolean" + }, + "usage": { + "$ref": "#/$defs/ReportedUsage", + "description": "One deduplicated model-call snapshot, never a session/model aggregate." + } + }, + "required": [ + "usage" + ], + "type": "object" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "The declared adapter transport for invocation accounting.", + "properties": { + "gaps": { + "description": "Invocation-wide gaps.", + "items": { + "$ref": "#/$defs/UsageGap" + }, + "type": "array" + }, + "models": { + "description": "One entry per literal provider/model pair.", + "items": { + "$ref": "#/$defs/AccountedModel" + }, + "type": "array" + }, + "models_exhaustive": { + "description": "Every model identity used by owned work is represented; this alone does\nnot certify that any dimension is complete.", + "type": "boolean" + }, + "observed": { + "anyOf": [ + { + "$ref": "#/$defs/ReportedUsage" + }, + { + "type": "null" + } + ], + "description": "Valid deduplicated observations, not a promised mathematical lower bound." + } + }, + "required": [ + "models_exhaustive", + "gaps" + ], + "title": "UsageAccounting", + "type": "object" +} \ No newline at end of file diff --git a/schemas/sdk/agent.schema.json b/schemas/sdk/agent.schema.json index cfd2150de..2cb00c651 100644 --- a/schemas/sdk/agent.schema.json +++ b/schemas/sdk/agent.schema.json @@ -558,6 +558,20 @@ } ] }, + "PricingConfig": { + "additionalProperties": true, + "description": "Central token-price configuration; never changes provider requests.", + "properties": { + "catalog_path": { + "description": "Relay-compatible v1 catalog, resolved relative to the config base directory.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, "RelayAtifConfig": { "additionalProperties": true, "description": "Relay ATIF export configuration.", @@ -1611,6 +1625,17 @@ "description": "Named model roles.", "type": "object" }, + "pricing": { + "anyOf": [ + { + "$ref": "#/$defs/PricingConfig" + }, + { + "type": "null" + } + ], + "description": "Optional catalog override for central token-price estimates." + }, "relay": { "anyOf": [ { diff --git a/schemas/sdk/run-plan.schema.json b/schemas/sdk/run-plan.schema.json index e183bca75..a21834095 100644 --- a/schemas/sdk/run-plan.schema.json +++ b/schemas/sdk/run-plan.schema.json @@ -894,6 +894,21 @@ ], "type": "object" }, + "CacheReadAccounting": { + "description": "Whether a provider's prompt token count includes cached reads.", + "oneOf": [ + { + "const": "included_in_prompt_tokens", + "description": "Prompt tokens include cached reads.", + "type": "string" + }, + { + "const": "separate", + "description": "Prompt tokens exclude cached reads.", + "type": "string" + } + ] + }, "CapabilityKind": { "description": "Capability kind.", "oneOf": [ @@ -1341,6 +1356,17 @@ "description": "Named model roles.", "type": "object" }, + "pricing": { + "anyOf": [ + { + "$ref": "#/$defs/PricingConfig" + }, + { + "type": "null" + } + ], + "description": "Optional catalog override for central token-price estimates." + }, "relay": { "anyOf": [ { @@ -1925,6 +1951,80 @@ ], "type": "object" }, + "ModelPricing": { + "additionalProperties": false, + "description": "Prices of one model, in `currency` per million tokens.", + "properties": { + "aliases": { + "default": [], + "description": "Other identifiers of the same model, such as dated snapshots.", + "items": { + "type": "string" + }, + "type": "array" + }, + "currency": { + "default": "USD", + "description": "ISO 4217 currency of the rates. NVIDIA NeMo Fabric estimates cost only from\n`USD` entries.", + "type": "string" + }, + "model_id": { + "description": "Canonical model identifier.", + "type": "string" + }, + "pricing_as_of": { + "description": "Date the price was taken from `pricing_source`, as `YYYY-MM-DD` in\nthe bundled catalog.", + "type": "string" + }, + "pricing_source": { + "description": "Where the price was taken from, such as the provider's pricing page.", + "type": "string" + }, + "prompt_cache": { + "$ref": "#/$defs/PromptCachePricing", + "description": "How the provider counts cached prompt tokens. NeMo Relay uses it to\nsplit provider usage; NeMo Fabric prices usage that is already\nnormalized, so it does not read it." + }, + "provider": { + "description": "Provider that sets the price, such as `anthropic` or `openai`.", + "type": "string" + }, + "rate_schedule": { + "anyOf": [ + { + "$ref": "#/$defs/TokenRateSchedule" + }, + { + "type": "null" + } + ], + "description": "Rates that depend on the number of prompt tokens in a request." + }, + "rates": { + "anyOf": [ + { + "$ref": "#/$defs/TokenPricingRates" + }, + { + "type": "null" + } + ], + "description": "Flat rates. Ignored when `rate_schedule` is set." + }, + "unit": { + "$ref": "#/$defs/PricingUnit", + "default": "per_token", + "description": "What a rate is charged per. NeMo Fabric estimates cost only from\n`per_token` entries." + } + }, + "required": [ + "provider", + "model_id", + "prompt_cache", + "pricing_as_of", + "pricing_source" + ], + "type": "object" + }, "OAuthTokenEndpointAuthMethod": { "description": "OAuth client authentication method used at the token endpoint.", "oneOf": [ @@ -1945,6 +2045,93 @@ } ] }, + "PricingCatalog": { + "additionalProperties": false, + "description": "A model price catalog in the NeMo Relay model pricing catalog format,\nversion 1.\n\nNVIDIA NeMo Fabric reads and writes the format unchanged, with one extra check:\nevery rate must be below one million US dollars per million tokens.", + "properties": { + "entries": { + "description": "Price entries with provider-qualified model identifiers and aliases.", + "items": { + "$ref": "#/$defs/ModelPricing" + }, + "type": "array" + }, + "version": { + "description": "Catalog format version. Must be `1`.", + "format": "uint32", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "version", + "entries" + ], + "type": "object" + }, + "PricingConfig": { + "additionalProperties": true, + "description": "Central token-price configuration; never changes provider requests.", + "properties": { + "catalog_path": { + "description": "Relay-compatible v1 catalog, resolved relative to the config base directory.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "PricingUnit": { + "description": "What a model price is charged per.", + "oneOf": [ + { + "const": "per_token", + "description": "Per token.", + "type": "string" + }, + { + "const": "per_request", + "description": "Per request.", + "type": "string" + }, + { + "const": "per_second", + "description": "Per second.", + "type": "string" + }, + { + "const": "gpu_hour", + "description": "Per GPU hour, for self-hosted models.", + "type": "string" + } + ] + }, + "PromptCachePricing": { + "additionalProperties": false, + "description": "How a provider counts cached prompt tokens in its usage reports.", + "properties": { + "read_accounting": { + "$ref": "#/$defs/CacheReadAccounting", + "description": "Whether the provider's prompt token count includes cached reads." + } + }, + "required": [ + "read_accounting" + ], + "type": "object" + }, + "RateScheduleApplication": { + "description": "How a chosen rate tier applies to a request.", + "oneOf": [ + { + "const": "full_request", + "description": "The tier's rates apply to every token of the request.", + "type": "string" + } + ] + }, "RelayAtifConfig": { "additionalProperties": true, "description": "Relay ATIF export configuration.", @@ -2985,6 +3172,107 @@ }, "type": "object" }, + "TokenPricingRates": { + "additionalProperties": false, + "description": "Token rates in currency units per million tokens.", + "properties": { + "cache_read_per_million": { + "description": "Rate for input tokens read from the prompt cache. Without it, cached\nreads cannot be priced; set `0.0` when they are free.", + "format": "double", + "type": [ + "number", + "null" + ] + }, + "cache_write_per_million": { + "description": "Rate for input tokens written to the prompt cache. Without it, cache\nwrites cannot be priced; set `0.0` when they are free.", + "format": "double", + "type": [ + "number", + "null" + ] + }, + "input_per_million": { + "description": "Rate for input tokens that were neither read from nor written to the\nprompt cache.", + "format": "double", + "type": "number" + }, + "output_per_million": { + "description": "Rate for output tokens, including reasoning tokens.", + "format": "double", + "type": "number" + } + }, + "required": [ + "input_per_million", + "output_per_million" + ], + "type": "object" + }, + "TokenRateSchedule": { + "description": "Rates that depend on the size of a request.", + "oneOf": [ + { + "additionalProperties": false, + "description": "One tier's rates apply to a whole request, chosen by the request's\nprompt tokens.", + "properties": { + "applies_to": { + "$ref": "#/$defs/RateScheduleApplication", + "default": "full_request", + "description": "How the chosen tier's rates apply." + }, + "tiers": { + "description": "Tiers in the order they are tried. The first tier whose bounds\ncontain a request's prompt tokens applies to it.", + "items": { + "$ref": "#/$defs/TokenRateTier" + }, + "type": "array" + }, + "type": { + "const": "prompt_token_threshold", + "type": "string" + } + }, + "required": [ + "type", + "tiers" + ], + "type": "object" + } + ] + }, + "TokenRateTier": { + "additionalProperties": false, + "description": "Rates for requests whose prompt tokens fall within inclusive bounds.", + "properties": { + "max_prompt_tokens": { + "description": "Largest number of prompt tokens in the tier, or no upper bound.", + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "min_prompt_tokens": { + "description": "Smallest number of prompt tokens in the tier, or no lower bound.", + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "rates": { + "$ref": "#/$defs/TokenPricingRates", + "description": "Rates of the tier." + } + }, + "required": [ + "rates" + ], + "type": "object" + }, "ToolDefinitionConfig": { "additionalProperties": true, "description": "One named normalized tool or tool-group definition.", @@ -3074,6 +3362,29 @@ }, "type": "object" }, + "UserPriceCatalog": { + "description": "A price catalog the caller supplied, read and validated when the run plan\nis resolved.", + "properties": { + "catalog": { + "$ref": "#/$defs/PricingCatalog", + "description": "The validated catalog." + }, + "path": { + "description": "Absolute path the catalog was read from.", + "type": "string" + }, + "sha256": { + "description": "SHA-256 of the validated catalog serialized as JSON by NeMo Fabric.", + "type": "string" + } + }, + "required": [ + "path", + "sha256", + "catalog" + ], + "type": "object" + }, "WorkflowConfig": { "additionalProperties": true, "description": "Registered workflow target and immutable construction settings.", @@ -3235,6 +3546,17 @@ } ], "description": "Resolved telemetry pass-through plan." + }, + "user_price_catalog": { + "anyOf": [ + { + "$ref": "#/$defs/UserPriceCatalog" + }, + { + "type": "null" + } + ], + "description": "Catalog bytes are frozen during planning." } }, "required": [ diff --git a/schemas/sdk/run-result.schema.json b/schemas/sdk/run-result.schema.json index 63a384352..338700280 100644 --- a/schemas/sdk/run-result.schema.json +++ b/schemas/sdk/run-result.schema.json @@ -1,5 +1,25 @@ { "$defs": { + "AdapterFacts": { + "description": "Adapter facts.", + "properties": { + "id": { + "description": "Adapter ID from the plan, when an adapter was resolved.", + "type": [ + "string", + "null" + ] + }, + "package_version": { + "$ref": "#/$defs/Fact_for_string", + "description": "Version of the adapter package, as the adapter reports it." + } + }, + "required": [ + "package_version" + ], + "type": "object" + }, "AdapterKind": { "description": "Adapter implementation kind.", "oneOf": [ @@ -25,6 +45,45 @@ } ] }, + "AppliedPricing": { + "additionalProperties": false, + "description": "Persisted entry and selected rates, so saved prices remain reproducible.", + "properties": { + "catalog_sha256": { + "description": "Exact catalog containing this entry.", + "type": "string" + }, + "model_id": { + "description": "Entry's model identity, which may match an explicit alias.", + "type": "string" + }, + "pricing_as_of": { + "description": "Date recorded by the catalog author.", + "type": "string" + }, + "pricing_source": { + "description": "Source recorded by the catalog author.", + "type": "string" + }, + "provider": { + "description": "Entry's provider, without rewriting the observed identity.", + "type": "string" + }, + "rates": { + "$ref": "#/$defs/RateSnapshot", + "description": "One selected rate set; distinct selections are retained in `model_usage::TokenCost.applied_rates`." + } + }, + "required": [ + "catalog_sha256", + "provider", + "model_id", + "pricing_as_of", + "pricing_source", + "rates" + ], + "type": "object" + }, "ArtifactManifest": { "additionalProperties": false, "description": "Manifest of run artifacts.", @@ -264,6 +323,45 @@ ], "type": "object" }, + "CostAmount": { + "description": "Complete and observed amounts in integer nano-USD.", + "properties": { + "amount_nano_usd": { + "description": "Complete amount for this invocation/model scope.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "observed_nano_usd": { + "description": "Valid observed contributions, not a guaranteed bound or invoice.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "EnvironmentFacts": { + "description": "Environment facts.", + "properties": { + "provider": { + "description": "Environment provider, such as `local`.", + "type": "string" + } + }, + "required": [ + "provider" + ], + "type": "object" + }, "ErrorInfo": { "description": "Normalized error metadata.", "properties": { @@ -377,6 +475,128 @@ ], "type": "object" }, + "FabricFacts": { + "description": "NeMo Fabric core facts.", + "properties": { + "core_version": { + "$ref": "#/$defs/Fact_for_string", + "description": "Version of the core that ran the adapter." + }, + "host_core_version": { + "$ref": "#/$defs/Fact_for_string", + "description": "Version of the host core that relayed the result from a task core\nin another environment. `not_applicable` for a local run." + } + }, + "required": [ + "core_version", + "host_core_version" + ], + "type": "object" + }, + "FactSource": { + "description": "How NeMo Fabric learned a fact.", + "oneOf": [ + { + "const": "observed", + "description": "NeMo Fabric core saw it itself.", + "type": "string" + }, + { + "const": "reported", + "description": "The adapter, the harness, an MCP server or a remote service said so.", + "type": "string" + }, + { + "const": "configured", + "description": "It comes from the resolved run plan.", + "type": "string" + } + ] + }, + "FactState": { + "description": "Why a fact has no value. A collector picks the first state that matches,\nin declaration order.", + "oneOf": [ + { + "const": "not_applicable", + "description": "The fact cannot exist for this run.", + "type": "string" + }, + { + "const": "unsupported", + "description": "The adapter or environment provider has no source for the fact.", + "type": "string" + }, + { + "const": "collection_failed", + "description": "A source exists, but it failed, timed out, returned an invalid report\nor never returned.", + "type": "string" + }, + { + "const": "unavailable", + "description": "NeMo Fabric holds no value and no evidence for the other states.", + "type": "string" + } + ] + }, + "Fact_for_Array_of_string": { + "anyOf": [ + { + "$ref": "#/$defs/KnownFact_for_Array_of_string", + "description": "The fact has a value." + }, + { + "$ref": "#/$defs/MissingFact", + "description": "The fact has no value." + } + ], + "description": "One fact: a `value`, or a `state` with a `reason`, never both." + }, + "Fact_for_boolean": { + "anyOf": [ + { + "$ref": "#/$defs/KnownFact_for_boolean", + "description": "The fact has a value." + }, + { + "$ref": "#/$defs/MissingFact", + "description": "The fact has no value." + } + ], + "description": "One fact: a `value`, or a `state` with a `reason`, never both." + }, + "Fact_for_string": { + "anyOf": [ + { + "$ref": "#/$defs/KnownFact_for_string", + "description": "The fact has a value." + }, + { + "$ref": "#/$defs/MissingFact", + "description": "The fact has no value." + } + ], + "description": "One fact: a `value`, or a `state` with a `reason`, never both." + }, + "GapReason": { + "description": "Categories that affect whether a consumer can treat a value as complete.", + "oneOf": [ + { + "const": "unfinished_work", + "description": "Owned work was still running at the return boundary.", + "type": "string" + }, + { + "const": "missing_usage", + "description": "A completed call did not provide usable usage.", + "type": "string" + }, + { + "const": "scope_unverified", + "description": "The supported source does not establish coverage of owned work.", + "type": "string" + } + ] + }, "GaugeSummary": { "description": "A sampled gauge over the invocation window. Min/max cover retained valid\nsamples. Mean holds the previous value only across adjacent valid samples\nno more than twice the requested cadence apart. Missing intervals never\ncontribute zero. No sampled maximum claims a true process high-water mark.", "properties": { @@ -444,6 +664,24 @@ ], "type": "object" }, + "HarnessFacts": { + "description": "Harness facts.", + "properties": { + "name": { + "$ref": "#/$defs/Fact_for_string", + "description": "Name of the harness program that ran, as the adapter reports it.\nCore never derives it from the adapter ID." + }, + "version": { + "$ref": "#/$defs/Fact_for_string", + "description": "Version of the harness program that ran, as the adapter reports it." + } + }, + "required": [ + "name", + "version" + ], + "type": "object" + }, "InvocationSystemStats": { "description": "System measurements around one invocation, collected inside the executing\nrunner. Process-tree observations cover its adapter host and descendants,\nexcluding the orchestrator and remote model/service processes. The separate\ncgroup observations may include other workloads in the same cgroup.\n\nEndpoints are instantaneous readings, not peaks or time averages. CPU time\nis cumulative since runtime startup; its difference is only an estimate\nbecause process reaping and reparenting can change which work is visible.", "properties": { @@ -513,6 +751,241 @@ ], "type": "object" }, + "KnownFact_for_Array_of_string": { + "description": "A fact with a value.", + "not": { + "anyOf": [ + { + "required": [ + "state" + ] + }, + { + "required": [ + "reason" + ] + } + ] + }, + "properties": { + "collected_by": { + "description": "Component that collected the fact, such as `host_core`, `task_core`\nor `adapter`.", + "pattern": "^[a-z0-9_.:#+/-]{1,128}$", + "type": "string" + }, + "method": { + "description": "How the collector got the fact, such as `crate_version`,\n`resolved_plan` or `adapter_report`.", + "pattern": "^[a-z0-9_.:#+/-]{1,128}$", + "type": "string" + }, + "source": { + "$ref": "#/$defs/FactSource", + "description": "How NeMo Fabric learned the fact." + }, + "value": { + "description": "The fact itself: a string, a boolean or a list of strings.", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "value", + "source", + "collected_by", + "method" + ], + "type": "object" + }, + "KnownFact_for_boolean": { + "description": "A fact with a value.", + "not": { + "anyOf": [ + { + "required": [ + "state" + ] + }, + { + "required": [ + "reason" + ] + } + ] + }, + "properties": { + "collected_by": { + "description": "Component that collected the fact, such as `host_core`, `task_core`\nor `adapter`.", + "pattern": "^[a-z0-9_.:#+/-]{1,128}$", + "type": "string" + }, + "method": { + "description": "How the collector got the fact, such as `crate_version`,\n`resolved_plan` or `adapter_report`.", + "pattern": "^[a-z0-9_.:#+/-]{1,128}$", + "type": "string" + }, + "source": { + "$ref": "#/$defs/FactSource", + "description": "How NeMo Fabric learned the fact." + }, + "value": { + "description": "The fact itself: a string, a boolean or a list of strings.", + "type": "boolean" + } + }, + "required": [ + "value", + "source", + "collected_by", + "method" + ], + "type": "object" + }, + "KnownFact_for_string": { + "description": "A fact with a value.", + "not": { + "anyOf": [ + { + "required": [ + "state" + ] + }, + { + "required": [ + "reason" + ] + } + ] + }, + "properties": { + "collected_by": { + "description": "Component that collected the fact, such as `host_core`, `task_core`\nor `adapter`.", + "pattern": "^[a-z0-9_.:#+/-]{1,128}$", + "type": "string" + }, + "method": { + "description": "How the collector got the fact, such as `crate_version`,\n`resolved_plan` or `adapter_report`.", + "pattern": "^[a-z0-9_.:#+/-]{1,128}$", + "type": "string" + }, + "source": { + "$ref": "#/$defs/FactSource", + "description": "How NeMo Fabric learned the fact." + }, + "value": { + "description": "The fact itself: a string, a boolean or a list of strings.", + "type": "string" + } + }, + "required": [ + "value", + "source", + "collected_by", + "method" + ], + "type": "object" + }, + "McpFacts": { + "description": "MCP facts.", + "properties": { + "servers": { + "additionalProperties": { + "$ref": "#/$defs/McpServerFacts" + }, + "description": "Servers keyed by name. Empty when the plan configures no MCP server\nand the harness reports none. At most 32 servers, in sorted key\norder, each key at most 128 characters.", + "type": "object" + }, + "servers_truncated": { + "$ref": "#/$defs/TruncatedEntries", + "description": "Servers dropped from `servers`. Absent when none was dropped, never\n`null`." + } + }, + "required": [ + "servers" + ], + "type": "object" + }, + "McpRuntimeFacts": { + "description": "What the harness learned about one MCP server when it connected.", + "properties": { + "connected": { + "$ref": "#/$defs/Fact_for_boolean", + "description": "Whether the harness connected to the server." + }, + "protocol_version": { + "$ref": "#/$defs/Fact_for_string", + "description": "MCP protocol version negotiated with the server." + }, + "server_name": { + "$ref": "#/$defs/Fact_for_string", + "description": "Name the server reports for itself." + }, + "server_version": { + "$ref": "#/$defs/Fact_for_string", + "description": "Version the server reports for itself." + }, + "tool_names": { + "$ref": "#/$defs/Fact_for_Array_of_string", + "description": "Raw tool names the server lists, each bounded by the string length\nrule." + } + }, + "required": [ + "connected", + "protocol_version", + "server_name", + "server_version", + "tool_names" + ], + "type": "object" + }, + "McpServerFacts": { + "description": "Facts about one MCP server.\n\nAn entry with origin `harness` is a server the harness reports and the\nplan does not configure. It has the same shape: its `endpoint` reads\n`not_applicable` with reason `not_configured`, and runtime facts the\nadapter did not collect read `unavailable` with reason `not_collected`.\nOnly a `plan` entry must name its transport. An entry of any other\norigin may leave it out.", + "if": { + "properties": { + "origin": { + "const": "plan" + } + } + }, + "properties": { + "endpoint": { + "$ref": "#/$defs/Fact_for_string", + "description": "Configured endpoint without secrets: the lowercase scheme and host\nand the port for a network server, or the executable's file name for\na `stdio` server." + }, + "origin": { + "description": "Where the entry comes from: `plan` for a server the plan configures,\n`harness` for one only the harness reports.", + "type": "string" + }, + "runtime": { + "$ref": "#/$defs/McpRuntimeFacts", + "description": "What the harness learned when it connected, as the adapter reports it." + }, + "transport": { + "description": "Transport, such as `stdio` or `streamable-http`. Required for a\n`plan` entry, and optional for any other origin.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "origin", + "endpoint", + "runtime" + ], + "then": { + "properties": { + "transport": { + "type": "string" + } + }, + "required": [ + "transport" + ] + }, + "type": "object" + }, "Measurement": { "description": "A measured value or its availability. Unlimited is distinct from unknown\nand is used only for limits. No missing measurement is replaced with zero.", "oneOf": [ @@ -686,28 +1159,354 @@ } ] }, - "ResourceObservation": { - "description": "One attempt to observe the adapter host process tree. Missing readings are\nexplicit; an unavailable observation never stands for zero resource usage.", - "oneOf": [ - { - "description": "The process group was explicitly excluded from collection.", - "properties": { - "status": { - "const": "not_collected", - "type": "string" - } - }, - "required": [ - "status" - ], - "type": "object" + "MissingFact": { + "description": "A fact without a value.", + "not": { + "required": [ + "value" + ] + }, + "properties": { + "collected_by": { + "description": "Component that collected the fact, such as `host_core`, `task_core`\nor `adapter`.", + "pattern": "^[a-z0-9_.:#+/-]{1,128}$", + "type": "string" }, - { - "description": "The process tree was read successfully.", - "properties": { - "snapshot": { - "$ref": "#/$defs/RuntimeResourceSnapshot", - "description": "Timestamped cumulative counters and instantaneous gauges." + "method": { + "description": "How the collector got the fact, such as `crate_version`,\n`resolved_plan` or `adapter_report`.", + "pattern": "^[a-z0-9_.:#+/-]{1,128}$", + "type": "string" + }, + "reason": { + "description": "Short code that explains the state, such as `key_not_declared`.", + "pattern": "^[a-z0-9_.:#+/-]{1,128}$", + "type": "string" + }, + "source": { + "$ref": "#/$defs/FactSource", + "description": "How NeMo Fabric learned the fact." + }, + "state": { + "$ref": "#/$defs/FactState", + "description": "Why the fact has no value." + } + }, + "required": [ + "state", + "reason", + "source", + "collected_by", + "method" + ], + "type": "object" + }, + "ModelFacts": { + "description": "Facts about one model role.", + "properties": { + "provider": { + "description": "Provider from the plan.", + "type": "string" + }, + "requested": { + "$ref": "#/$defs/Fact_for_string", + "description": "Model the plan requested." + }, + "served": { + "$ref": "#/$defs/Fact_for_string", + "description": "Model that served, as the harness or provider reports it." + } + }, + "required": [ + "provider", + "requested", + "served" + ], + "type": "object" + }, + "ModelUsage": { + "description": "Normalized accounting frozen at return. Late usage never moves to a later invocation.", + "properties": { + "cache_write_input_tokens": { + "description": "Independently known cache writes.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "cached_input_tokens": { + "description": "Independently known cache reads.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "cost": { + "$ref": "#/$defs/TokenCost", + "description": "Separately typed provider-reported amounts and catalog estimates." + }, + "gaps": { + "description": "Dimension-specific completeness gaps.", + "items": { + "$ref": "#/$defs/UsageGap" + }, + "type": "array" + }, + "input_tokens": { + "description": "Cache-inclusive input.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "models": { + "description": "Literal per-model breakdown, when the source establishes attribution.", + "items": { + "$ref": "#/$defs/PerModelUsage" + }, + "type": "array" + }, + "models_exhaustive": { + "description": "Whether all model identities used by owned work are represented.", + "type": "boolean" + }, + "observed": { + "$ref": "#/$defs/TokenCounts", + "description": "Available deduplicated observations; these are not guaranteed lower bounds." + }, + "output_tokens": { + "description": "Reasoning-inclusive output.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "total_tokens": { + "description": "Input plus output; the independent provider total remains raw evidence.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "observed", + "gaps", + "models_exhaustive", + "cost" + ], + "type": "object" + }, + "PerModelUsage": { + "description": "Normalized usage for one literal model/provider identity.", + "properties": { + "cache_write_input_tokens": { + "description": "Independently known cache writes.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "cached_input_tokens": { + "description": "Independently known cache reads.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "cost": { + "$ref": "#/$defs/TokenCost", + "description": "Provider-reported and catalog-estimated cost." + }, + "gaps": { + "description": "Dimension-specific gaps.", + "items": { + "$ref": "#/$defs/UsageGap" + }, + "type": "array" + }, + "input_tokens": { + "description": "Cache-inclusive input.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "model": { + "description": "Source-established accounting model identity. A configured identity\nrequires proof that all included work used that model; an outer requested\nalias alone is insufficient. This is not served-model provenance.", + "type": "string" + }, + "observed": { + "$ref": "#/$defs/TokenCounts", + "description": "Available deduplicated observations, possibly incomplete." + }, + "output_tokens": { + "description": "Reasoning-inclusive output.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "provider": { + "description": "Literal provider identity when established.", + "type": [ + "string", + "null" + ] + }, + "total_tokens": { + "description": "Input plus output; the independent provider total remains raw evidence.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "model", + "observed", + "gaps", + "cost" + ], + "type": "object" + }, + "PricingBasis": { + "additionalProperties": false, + "description": "Fields that must agree before a report combines catalog estimates.", + "properties": { + "assumption_policy": { + "description": "No missing count is assumed zero.", + "type": "string" + }, + "basis": { + "description": "`catalog`; provider-reported cost has a different basis.", + "type": "string" + }, + "bundled_catalog_sha256": { + "description": "Exact bundled catalog bytes.", + "type": "string" + }, + "currency": { + "description": "`USD` in this implementation.", + "type": "string" + }, + "normalization_version": { + "description": "Version of normalization and monetary rounding.", + "type": "string" + }, + "user_catalog_sha256": { + "description": "Exact override bytes, when supplied.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "basis", + "currency", + "normalization_version", + "assumption_policy", + "bundled_catalog_sha256" + ], + "type": "object" + }, + "RateSnapshot": { + "additionalProperties": false, + "description": "Integer rates selected for a model, in nano-USD per million tokens.", + "properties": { + "cache_read": { + "description": "Cache read rate; absent is not zero.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "cache_write": { + "description": "Cache write rate; absent is not zero.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "input": { + "description": "Uncached input rate.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + }, + "output": { + "description": "Reasoning-inclusive output rate.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "input", + "output" + ], + "type": "object" + }, + "ResourceObservation": { + "description": "One attempt to observe the adapter host process tree. Missing readings are\nexplicit; an unavailable observation never stands for zero resource usage.", + "oneOf": [ + { + "description": "The process group was explicitly excluded from collection.", + "properties": { + "status": { + "const": "not_collected", + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + { + "description": "The process tree was read successfully.", + "properties": { + "snapshot": { + "$ref": "#/$defs/RuntimeResourceSnapshot", + "description": "Timestamped cumulative counters and instantaneous gauges." }, "status": { "const": "available", @@ -1065,6 +1864,263 @@ } }, "type": "object" + }, + "TokenCost": { + "description": "Provider-reported amounts stay separate from reproducible catalog estimates.", + "properties": { + "applied_rates": { + "description": "Distinct selected rate sets, rather than one duplicate snapshot per call.", + "items": { + "$ref": "#/$defs/AppliedPricing" + }, + "type": "array" + }, + "estimated": { + "$ref": "#/$defs/CostAmount", + "description": "Catalog estimates; incomplete tariff evidence affects only these amounts." + }, + "pricing": { + "anyOf": [ + { + "$ref": "#/$defs/PricingBasis" + }, + { + "type": "null" + } + ], + "description": "Required basis for any estimated amount." + }, + "reported": { + "$ref": "#/$defs/CostAmount", + "description": "Provider-reported amounts, never a harness estimate." + } + }, + "required": [ + "reported", + "estimated" + ], + "type": "object" + }, + "TokenCounts": { + "description": "Token counts for one fixed invocation scope. Missing values never imply zero.", + "properties": { + "cache_write_input_tokens": { + "description": "Independently known cache writes.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "cached_input_tokens": { + "description": "Independently known cache reads.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "input_tokens": { + "description": "Cache-inclusive input.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "output_tokens": { + "description": "Reasoning-inclusive output.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "total_tokens": { + "description": "Input plus output; the independent provider total remains raw evidence.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "TruncatedEntries": { + "description": "Entries core dropped to keep `what_ran` within its bounds: next to a\nmap for the entries dropped from it, or as `what_ran_truncated` for the\nwhole object.", + "properties": { + "dropped": { + "description": "How many entries were dropped. A JSON integer: the Rust and Python\nreaders reject `1.0`, although a JSON Schema validator may accept it.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 1, + "type": "integer" + }, + "reason": { + "description": "Why the entries were dropped: `key_too_long` for a key longer than\n128 characters, `entry_cap` for an entry past the map's cap, or\n`byte_budget` for an entry dropped to keep the whole object within\n128 KiB. One marker covers a map: when more than one rule dropped\nentries from it, `reason` names the last rule applied, in that\norder, and `dropped` counts them all. An open code like a fact's\n`reason`.", + "pattern": "^[a-z0-9_.:#+/-]{1,128}$", + "type": "string" + } + }, + "required": [ + "reason", + "dropped" + ], + "type": "object" + }, + "UsageDimension": { + "description": "Dimensions whose complete values can be unavailable independently.", + "oneOf": [ + { + "const": "input_tokens", + "description": "Input tokens.", + "type": "string" + }, + { + "const": "cached_input_tokens", + "description": "Cache reads.", + "type": "string" + }, + { + "const": "cache_write_input_tokens", + "description": "Cache writes.", + "type": "string" + }, + { + "const": "output_tokens", + "description": "Output tokens.", + "type": "string" + }, + { + "const": "total_tokens", + "description": "Input plus output.", + "type": "string" + }, + { + "const": "reported_cost", + "description": "Provider-reported cost.", + "type": "string" + }, + { + "const": "estimated_cost", + "description": "Applicable catalog estimate.", + "type": "string" + } + ] + }, + "UsageGap": { + "description": "A completeness gap. Diagnostics never change the dimension or reason semantics.", + "properties": { + "code": { + "description": "Bounded stable diagnostic; never an exception message.", + "maxLength": 64, + "type": [ + "string", + "null" + ] + }, + "dimensions": { + "description": "Nonempty set of affected dimensions.", + "items": { + "$ref": "#/$defs/UsageDimension" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "reason": { + "$ref": "#/$defs/GapReason", + "description": "Why complete accounting is unavailable." + } + }, + "required": [ + "dimensions", + "reason" + ], + "type": "object" + }, + "WhatRan": { + "anyOf": [ + { + "required": [ + "what_ran_truncated" + ] + }, + { + "required": [ + "fabric", + "adapter", + "harness", + "models", + "mcp", + "environment" + ] + } + ], + "description": "What executed for one invocation.\n\nEvery section is present, unless `what_ran_truncated` is present: then\nthe object is a minimal one that the byte budget left with only its\nschema name and version, and every section may be absent.\n\nDecoding a `WhatRan` directly checks the shape of every section and\nfact, and that no section is `null`. `RunResult` also checks the schema\nname and version, that every section is present unless\n`what_ran_truncated` is, and that every `plan` MCP entry names its\ntransport.", + "properties": { + "adapter": { + "$ref": "#/$defs/AdapterFacts", + "description": "Adapter that ran." + }, + "environment": { + "$ref": "#/$defs/EnvironmentFacts", + "description": "Environment the runtime ran in." + }, + "fabric": { + "$ref": "#/$defs/FabricFacts", + "description": "NeMo Fabric core that ran the adapter." + }, + "harness": { + "$ref": "#/$defs/HarnessFacts", + "description": "Harness behind the adapter." + }, + "mcp": { + "$ref": "#/$defs/McpFacts", + "description": "MCP server facts." + }, + "models": { + "additionalProperties": { + "$ref": "#/$defs/ModelFacts" + }, + "description": "Model facts keyed by the role names of the plan's `models` settings.\nAt most 16 roles, in sorted key order, each key at most 128\ncharacters.", + "type": "object" + }, + "models_truncated": { + "$ref": "#/$defs/TruncatedEntries", + "description": "Model roles dropped from `models`. Absent when none was dropped,\nnever `null`." + }, + "schema": { + "const": "nemo.fabric.what_ran", + "description": "Schema name, `nemo.fabric.what_ran`.", + "type": "string" + }, + "version": { + "const": "v1alpha1", + "description": "Contract version, `v1alpha1`.", + "type": "string" + }, + "what_ran_truncated": { + "$ref": "#/$defs/TruncatedEntries", + "description": "Present only on a minimal object: every section was dropped to keep\nthe object within 128 KiB, with reason `byte_budget` and a dropped\ncount of 1." + } + }, + "required": [ + "schema", + "version" + ], + "type": "object" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -1121,6 +2177,17 @@ "description": "Adapter-specific metadata.", "type": "object" }, + "model_usage": { + "anyOf": [ + { + "$ref": "#/$defs/ModelUsage" + }, + { + "type": "null" + } + ], + "description": "Complete and observed usage normalized from supported accounting evidence." + }, "output": { "default": null, "description": "Primary output." @@ -1176,6 +2243,17 @@ } ], "description": "Normalized invocation usage when reported by the adapter target." + }, + "what_ran": { + "anyOf": [ + { + "$ref": "#/$defs/WhatRan" + }, + { + "type": "null" + } + ], + "description": "What executed for this invocation, and how NeMo Fabric learned each\nfact. Absent when the producing core predates it, or when this core\nfinds it malformed or of another schema or version." } }, "required": [ diff --git a/scripts/contracts/generate_usage_accounting.py b/scripts/contracts/generate_usage_accounting.py new file mode 100644 index 000000000..935b9ade3 --- /dev/null +++ b/scripts/contracts/generate_usage_accounting.py @@ -0,0 +1,53 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Embed the canonical usage-accounting schema in declaring adapter descriptors.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +KEY = "nemo_fabric.usage_accounting.v1" + + +def synchronize(*, check: bool) -> None: + schema = json.loads( + (ROOT / "schemas/extensions/usage-accounting.schema.json").read_text() + ) + definitions = schema.pop("$defs", {}) + schema.pop("$schema", None) + descriptors = [ + *ROOT.glob("adapters/python/*/*.fabric-adapter.json"), + *ROOT.glob("adapters/typescript/*/*.fabric-adapter.json"), + *ROOT.glob("crates/fabric-cli/assets/adapters/*/*.fabric-adapter.json"), + ] + for path in descriptors: + original = path.read_text() + document = json.loads(original) + extensions = document.get("extension_schemas", {}) + usage = extensions.get("usage", {}) + if KEY not in usage.get("properties", {}): + continue + # This generator owns the accounting definitions at the usage-schema root. + # Replace them so a removed canonical definition cannot survive in a descriptor. + usage["$defs"] = definitions + usage["properties"][KEY] = schema + # Replace just this object; retain unrelated descriptor formatting. + start = original.index('"extension_schemas":') + len('"extension_schemas":') + start += len(original[start:]) - len(original[start:].lstrip()) + _, end = json.JSONDecoder().raw_decode(original[start:]) + replacement = json.dumps(extensions, indent=2).replace("\n", "\n ") + updated = original[:start] + replacement + original[start + end :] + if original != updated: + if check: + raise SystemExit(f"accounting schema drift: {path.relative_to(ROOT)}") + path.write_text(updated) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true") + synchronize(check=parser.parse_args().check) diff --git a/scripts/docs/enhance_python_api_reference.py b/scripts/docs/enhance_python_api_reference.py index 940f77035..fd5caca64 100644 --- a/scripts/docs/enhance_python_api_reference.py +++ b/scripts/docs/enhance_python_api_reference.py @@ -11,11 +11,12 @@ import re from enum import Enum from pathlib import Path -from typing import Any +from typing import Any, get_type_hints, is_typeddict from pydantic import BaseModel MODULE_NAMES = ( + "nemo_fabric.accounting", "nemo_fabric.client", "nemo_fabric.capabilities", "nemo_fabric.check", @@ -28,6 +29,7 @@ "nemo_fabric.errors", ) CLASS_HEADING = re.compile(r"^## class `([^`]+)`$") +FUNCTION_HEADING = re.compile(r"^## function `([^`]+)`$") MEMBER_HEADING = re.compile(r"^### (?:method|classmethod) `([^`]+)`$") @@ -128,11 +130,16 @@ def _replace_signature_fences(text: str, module: Any) -> str: class_name = class_match.group(1) pending_member = None + function_match = FUNCTION_HEADING.match(line) + if function_match: + class_name = None + pending_member = function_match.group(1) + member_match = MEMBER_HEADING.match(line) if member_match: pending_member = member_match.group(1) - if line == "```python" and class_name and pending_member: + if line == "```python" and pending_member: closing = index + 1 while closing < len(lines) and lines[closing] != "```": closing += 1 @@ -141,8 +148,8 @@ def _replace_signature_fences(text: str, module: Any) -> str: f"unterminated signature fence for {class_name}.{pending_member}" ) - exported_class = getattr(module, class_name) - member = getattr(exported_class, pending_member) + owner = getattr(module, class_name) if class_name else module + member = getattr(owner, pending_member) output.extend( [ "```python", @@ -208,7 +215,11 @@ def _model_fields_table(model: type[BaseModel]) -> str: def _typed_fields_table(exported_class: type[Any]) -> str: - annotations = inspect.get_annotations(exported_class, eval_str=False) + annotations = ( + get_type_hints(exported_class, include_extras=True) + if is_typeddict(exported_class) + else inspect.get_annotations(exported_class, eval_str=False) + ) if not annotations: return "" @@ -267,7 +278,10 @@ def _class_supplement(exported_class: type[Any]) -> str: sections.append(_inheritance_section(exported_class)) if issubclass(exported_class, BaseModel): sections.append(_model_fields_table(exported_class)) - elif exported_class.__module__ in ("nemo_fabric.types", "nemo_fabric.check"): + elif exported_class.__module__ in ( + "nemo_fabric.types", + "nemo_fabric.check", + ) or is_typeddict(exported_class): sections.append(_typed_fields_table(exported_class)) if issubclass(exported_class, Enum): sections.append(_enum_values_table(exported_class)) diff --git a/scripts/generate_api_docs.sh b/scripts/generate_api_docs.sh index 76269be0f..f4b9c3d45 100755 --- a/scripts/generate_api_docs.sh +++ b/scripts/generate_api_docs.sh @@ -22,6 +22,7 @@ mkdir -p "$out" PYTHONPATH="sdk/python/nemo-fabric-runtime/src" lazydocs \ --output-path "$out" \ --overview-file "index.md" \ + "nemo_fabric.accounting" \ "nemo_fabric.client" \ "nemo_fabric.capabilities" \ "nemo_fabric.check" \ @@ -79,6 +80,11 @@ add_frontmatter \ "Python SDK Reference" \ "Complete reference for the public NeMo Fabric Python SDK." \ "/reference/api/python-library-reference" +add_frontmatter \ + "$out/nemo_fabric.accounting.md" \ + "Model Usage Accounting" \ + "Summarize complete and observed model usage with explicit coverage." \ + "/reference/api/python-library-reference/accounting" add_frontmatter \ "$out/nemo_fabric.client.md" \ "Client" \ diff --git a/sdk/python/nemo-fabric-adapter-catalog/src/nemo_fabric_adapter_catalog/catalog.json b/sdk/python/nemo-fabric-adapter-catalog/src/nemo_fabric_adapter_catalog/catalog.json index efc7b06a5..d9d6b8bec 100644 --- a/sdk/python/nemo-fabric-adapter-catalog/src/nemo_fabric_adapter_catalog/catalog.json +++ b/sdk/python/nemo-fabric-adapter-catalog/src/nemo_fabric_adapter_catalog/catalog.json @@ -1017,6 +1017,345 @@ ] }, "contract_version": "fabric.adapter/v1alpha2", + "extension_schemas": { + "usage": { + "$defs": { + "AccountedModel": { + "additionalProperties": false, + "description": "Evidence for one literal provider/model identity.", + "properties": { + "gaps": { + "description": "Gaps affecting this model.", + "items": { + "$ref": "#/$defs/UsageGap" + }, + "type": "array" + }, + "model": { + "description": "Model identity as reported by the source.", + "minLength": 1, + "pattern": "\\S", + "type": "string" + }, + "observed": { + "anyOf": [ + { + "$ref": "#/$defs/ReportedUsage" + }, + { + "type": "null" + } + ], + "description": "Valid deduplicated observations, potentially incomplete." + }, + "observed_tokens_aligned": { + "default": false, + "description": "Every present observed token dimension describes the same source-call subset.\nFalse unless the producer establishes this, including peak and normalization components.", + "type": "boolean" + }, + "provider": { + "description": "Literal provider identity, when available.", + "minLength": 1, + "pattern": "\\S", + "type": [ + "string", + "null" + ] + }, + "reported": { + "$ref": "#/$defs/ReportedUsage", + "default": {}, + "description": "Complete per-model evidence; absent fields are unreported." + }, + "requests": { + "description": "Optional per-call evidence for exact request-tier pricing.", + "items": { + "$ref": "#/$defs/UsageRequest" + }, + "type": "array" + }, + "requests_exhaustive": { + "default": false, + "description": "The request list covers every source unit contributing to this model.", + "type": "boolean" + } + }, + "required": [ + "model", + "gaps" + ], + "type": "object" + }, + "GapReason": { + "description": "Categories that affect whether a consumer can treat a value as complete.", + "oneOf": [ + { + "const": "unfinished_work", + "description": "Owned work was still running at the return boundary.", + "type": "string" + }, + { + "const": "missing_usage", + "description": "A completed call did not provide usable usage.", + "type": "string" + }, + { + "const": "scope_unverified", + "description": "The supported source does not establish coverage of owned work.", + "type": "string" + } + ] + }, + "ReportedUsage": { + "additionalProperties": false, + "description": "Raw counts in provider/harness conventions. Missing values are never zero.", + "properties": { + "cache_write_input_tokens": { + "description": "Cache writes, independently reported.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "cached_input_tokens": { + "description": "Cache reads, independently reported.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "cost_usd": { + "description": "Provider-reported USD, never a client-side price estimate.", + "format": "double", + "minimum": 0.0, + "type": [ + "number", + "null" + ] + }, + "input_tokens": { + "description": "Raw input count.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "input_tokens_include_cache": { + "description": "Whether input includes both cache categories.", + "type": [ + "boolean", + "null" + ] + }, + "output_tokens": { + "description": "Raw output count.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "output_tokens_include_reasoning": { + "description": "Whether output includes reasoning.", + "type": [ + "boolean", + "null" + ] + }, + "peak_request_input_tokens": { + "description": "Largest request's cache-inclusive input.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "reasoning_tokens": { + "description": "Reasoning count, independently reported.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "total_tokens": { + "description": "Provider-reported total, independent of normalized input plus output.", + "format": "uint64", + "maximum": 18446744073709551615, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "UsageDimension": { + "description": "A dimension in raw adapter evidence.", + "oneOf": [ + { + "const": "input_tokens", + "description": "Input tokens.", + "type": "string" + }, + { + "const": "cached_input_tokens", + "description": "Cache reads.", + "type": "string" + }, + { + "const": "cache_write_input_tokens", + "description": "Cache writes.", + "type": "string" + }, + { + "const": "output_tokens", + "description": "Output tokens.", + "type": "string" + }, + { + "const": "reasoning_tokens", + "description": "Reasoning tokens.", + "type": "string" + }, + { + "const": "total_tokens", + "description": "Provider-reported total.", + "type": "string" + }, + { + "const": "peak_request_input_tokens", + "description": "Largest request input.", + "type": "string" + }, + { + "const": "cost_usd", + "description": "Provider-reported USD.", + "type": "string" + } + ] + }, + "UsageGap": { + "additionalProperties": false, + "description": "Missing evidence for one or more raw dimensions.", + "properties": { + "dimensions": { + "description": "Nonempty, unique set of affected dimensions.", + "items": { + "$ref": "#/$defs/UsageDimension" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "reason": { + "$ref": "#/$defs/GapReason", + "description": "Why those dimensions are incomplete." + } + }, + "required": [ + "dimensions", + "reason" + ], + "type": "object" + }, + "UsageRequest": { + "additionalProperties": false, + "description": "One model call retained for request-tier pricing and evidence reconciliation.", + "properties": { + "gap": { + "anyOf": [ + { + "$ref": "#/$defs/GapReason" + }, + { + "type": "null" + } + ], + "description": "Known incompleteness of this call's source coverage." + }, + "tokens_aligned": { + "default": false, + "description": "Present observed fields describe the same request/subset.", + "type": "boolean" + }, + "usage": { + "$ref": "#/$defs/ReportedUsage", + "description": "One deduplicated model-call snapshot, never a session/model aggregate." + } + }, + "required": [ + "usage" + ], + "type": "object" + } + }, + "additionalProperties": false, + "properties": { + "nemo_fabric.detailed_token_usage.v1": { + "$ref": "#/$defs/ReportedUsage" + }, + "nemo_fabric.usage_accounting.v1": { + "additionalProperties": false, + "description": "The declared adapter transport for invocation accounting.", + "properties": { + "gaps": { + "description": "Invocation-wide gaps.", + "items": { + "$ref": "#/$defs/UsageGap" + }, + "type": "array" + }, + "models": { + "description": "One entry per literal provider/model pair.", + "items": { + "$ref": "#/$defs/AccountedModel" + }, + "type": "array" + }, + "models_exhaustive": { + "description": "Every model identity used by owned work is represented; this alone does\nnot certify that any dimension is complete.", + "type": "boolean" + }, + "observed": { + "anyOf": [ + { + "$ref": "#/$defs/ReportedUsage" + }, + { + "type": "null" + } + ], + "description": "Valid deduplicated observations, not a promised mathematical lower bound." + } + }, + "required": [ + "models_exhaustive", + "gaps" + ], + "title": "UsageAccounting", + "type": "object" + } + }, + "type": "object" + } + }, "model_schema": { "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, @@ -1274,7 +1613,7 @@ "name": "nemo-fabric-adapters-deepagents", "version": "0.6.0" }, - "sha256": "819b1f9747af83011c98d5889c56dac9a735008b25e93bbc94c1cfde3050a44e", + "sha256": "9e6272ba5196168dec13499f9a8d79fc7fa68860eb16330cb6f64f7209f0be73", "source": "adapters/python/deepagents/deepagents.fabric-adapter.json" }, "nvidia.fabric.mini-swe-agent": { diff --git a/sdk/python/nemo-fabric-runtime/src/nemo_fabric/__init__.py b/sdk/python/nemo-fabric-runtime/src/nemo_fabric/__init__.py index 38cebd94c..9fe45c1b6 100644 --- a/sdk/python/nemo-fabric-runtime/src/nemo_fabric/__init__.py +++ b/sdk/python/nemo-fabric-runtime/src/nemo_fabric/__init__.py @@ -3,6 +3,16 @@ """Python SDK surface for NeMo Fabric.""" +from nemo_fabric.accounting import summarize_usage +from nemo_fabric.models import PricingConfig +from nemo_fabric.types import ModelUsage +from nemo_fabric.types import PerModelUsage +from nemo_fabric.types import PricingBasis +from nemo_fabric.types import AppliedPricing +from nemo_fabric.types import TokenCounts +from nemo_fabric.types import TokenCost +from nemo_fabric.types import CostAmount +from nemo_fabric.types import UsageGap from nemo_fabric.client import Fabric from nemo_fabric.check import CheckReport, CheckRequest from nemo_fabric.capabilities import AdapterCapabilityProfile @@ -84,6 +94,16 @@ from nemo_fabric.types import TelemetryRef __all__ = [ + "summarize_usage", + "PricingConfig", + "ModelUsage", + "PerModelUsage", + "PricingBasis", + "AppliedPricing", + "TokenCounts", + "TokenCost", + "CostAmount", + "UsageGap", "CheckReport", "CheckRequest", "AdapterCapabilityProfile", diff --git a/sdk/python/nemo-fabric-runtime/src/nemo_fabric/accounting.py b/sdk/python/nemo-fabric-runtime/src/nemo_fabric/accounting.py new file mode 100644 index 000000000..672f352b7 --- /dev/null +++ b/sdk/python/nemo-fabric-runtime/src/nemo_fabric/accounting.py @@ -0,0 +1,190 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Typed model accounting and invocation summaries with explicit coverage. + +This module never backfills from ATIF or interprets absent values as zero. +Observed values are deduplicated evidence, not guaranteed mathematical bounds. +""" + +from __future__ import annotations + +from collections import Counter +from collections.abc import Iterable, Mapping +from typing import Any, TypedDict +from typing import NotRequired + +from nemo_fabric.types import FabricMapping +from nemo_fabric.types import ModelUsage +from nemo_fabric.types import ( + _MODEL_USAGE_DIMENSIONS, + _usage_value, +) + + +class DimensionSummary(TypedDict): + """An aggregate together with its explicit invocation coverage.""" + + expected_count: int + complete_count: int + incomplete_count: int + missing_result_count: int + complete_total: int | None + observed_sum: int | None + reasons: dict[str, int] + aggregation_error: NotRequired[str] + + +class UsageSummary(TypedDict): + """Coverage of an explicit invocation manifest, including missing results.""" + + unit: str + dimensions: dict[str, DimensionSummary] + pricing: NotRequired[dict[str, Any]] + pricing_groups: NotRequired[list[dict[str, Any]]] + + +def _supported_catalog_basis(basis: Mapping[str, Any] | None) -> bool: + return basis is not None and all( + basis.get(key) == value + for key, value in { + "basis": "catalog", + "currency": "USD", + "normalization_version": "cache_reasoning_inclusive_v1", + "assumption_policy": "applicable_tariff_no_missing_counts_v1", + }.items() + ) + + +def summarize_usage( + results: Iterable[Mapping[str, Any]], + *, + expected: Iterable[tuple[str, str]], +) -> UsageSummary: + """Summarize invocations against an explicit expected identity manifest. + + Include failed and cancelled results. Identical duplicates count once; + conflicts and unexpected results are errors. A complete total exists only + when every expected invocation has a complete value. Observations remain + visible alongside coverage. No observations is null, never zero. + """ + + identities = list(expected) + if any( + not isinstance(key, tuple) + or len(key) != 2 + or any(not isinstance(v, str) or not v for v in key) + for key in identities + ): + raise ValueError( + "expected identities must be (runtime_id, invocation_id) string tuples" + ) + expected_set = set(identities) + if len(expected_set) != len(identities): + raise ValueError("duplicate expected identity") + unique: dict[tuple[str, str], dict[str, Any]] = {} + for result in results: + record = ( + result.to_mapping() if isinstance(result, FabricMapping) else dict(result) + ) + key = (record.get("runtime_id"), record.get("invocation_id")) + if key not in expected_set: + raise ValueError(f"unexpected invocation identity: {key!r}") + if key in unique and unique[key] != record: + raise ValueError(f"conflicting duplicate invocation: {key!r}") + unique[key] = record + parsed = { + key: ModelUsage.from_mapping(record["model_usage"]) + if record.get("model_usage") is not None + else None + for key, record in unique.items() + } + price_groups: dict[tuple[Any, ...], list[tuple[str, str]]] = {} + contributing_bases: set[tuple[Any, ...]] = set() + for identity, usage in parsed.items(): + if usage is not None: + basis = usage.cost.pricing + if basis is not None and _supported_catalog_basis(basis): + key = tuple(basis.get(field) for field in sorted(basis._fields)) + price_groups.setdefault(key, []).append(identity) + estimate = usage.cost.estimated + if ( + estimate.amount_nano_usd is not None + or estimate.observed_nano_usd is not None + ): + contributing_bases.add(key) + # An absent estimate adds no incompatible amount. Retain unpriced members + # of contributing groups so their incomplete coverage remains visible. + price_groups = { + key: group for key, group in price_groups.items() if key in contributing_bases + } + dimensions: dict[str, DimensionSummary] = {} + for dimension in _MODEL_USAGE_DIMENSIONS: + complete: list[int] = [] + observed: list[int] = [] + reasons: Counter[str] = Counter() + for model_usage in parsed.values(): + if model_usage is None: + reasons["legacy_unverified"] += 1 + continue + if dimension == "estimated_cost": + if not _supported_catalog_basis(model_usage.cost.pricing): + reasons["pricing_unverified"] += 1 + continue + value = _usage_value(model_usage, dimension) + if value is not None: + complete.append(value) + observed.append(value) + else: + reasons.update( + { + gap.reason + for gap in model_usage.gaps + if dimension in gap.dimensions + } + or {"missing_usage"} + ) + observation = _usage_value(model_usage, dimension, observed=True) + if observation is not None: + observed.append(observation) + complete_total = ( + sum(complete) if identities and len(complete) == len(identities) else None + ) + observed_sum = sum(observed) if observed else None + overflow = any( + value is not None and value > (1 << 64) - 1 + for value in (complete_total, observed_sum) + ) + if complete_total is not None and complete_total > (1 << 64) - 1: + complete_total = None + if observed_sum is not None and observed_sum > (1 << 64) - 1: + observed_sum = None + missing = len(identities) - len(unique) + if missing: + reasons["missing_result"] = missing + dimensions[dimension] = { + "expected_count": len(identities), + "complete_count": len(complete), + "incomplete_count": len(unique) - len(complete), + "missing_result_count": missing, + "complete_total": complete_total, + "observed_sum": observed_sum, + "reasons": dict(sorted(reasons.items())), + } + if overflow: + dimensions[dimension]["aggregation_error"] = "uint64_overflow" + result: UsageSummary = {"unit": "invocation", "dimensions": dimensions} + if len(price_groups) == 1: + identity = next(iter(price_groups.values()))[0] + result["pricing"] = parsed[identity].cost.pricing.to_mapping() + elif len(price_groups) > 1: + # Each value can be complete within its own basis, while their sum is + # not a comparable catalog amount. Keep coverage and expose groups. + dimension = dimensions["estimated_cost"] + dimension["complete_total"] = dimension["observed_sum"] = None + dimension["aggregation_error"] = "incompatible_pricing" + result["pricing_groups"] = [ + summarize_usage([unique[key] for key in group], expected=group) + for group in price_groups.values() + ] + return result diff --git a/sdk/python/nemo-fabric-runtime/src/nemo_fabric/models.py b/sdk/python/nemo-fabric-runtime/src/nemo_fabric/models.py index 7a9bbd627..fa3f6c6ce 100644 --- a/sdk/python/nemo-fabric-runtime/src/nemo_fabric/models.py +++ b/sdk/python/nemo-fabric-runtime/src/nemo_fabric/models.py @@ -1118,6 +1118,19 @@ def _validate_collect(cls, value: list[str]) -> list[str]: return value +class PricingConfig(FabricBaseModel): + """Frozen Relay-compatible price-catalog override, read during planning.""" + + catalog_path: str | Path | None = None + + @field_validator("catalog_path") + @classmethod + def _validate_catalog_path(cls, value: str | Path | None) -> str | Path | None: + if value is not None and not str(value).strip(): + raise ValueError("pricing catalog path must not be empty") + return value + + class FabricConfig(FabricBaseModel): """SDK-facing typed NeMo Fabric agent configuration. @@ -1141,6 +1154,7 @@ class FabricConfig(FabricBaseModel): system_stats: SystemStatsConfig | None = Field(default_factory=SystemStatsConfig) telemetry: TelemetryConfig | None = None relay: RelayConfig | None = None + pricing: PricingConfig | None = None tools: ToolsConfig | None = None @model_serializer(mode="wrap") diff --git a/sdk/python/nemo-fabric-runtime/src/nemo_fabric/types.py b/sdk/python/nemo-fabric-runtime/src/nemo_fabric/types.py index 3a87a4300..4d35375e3 100644 --- a/sdk/python/nemo-fabric-runtime/src/nemo_fabric/types.py +++ b/sdk/python/nemo-fabric-runtime/src/nemo_fabric/types.py @@ -7,6 +7,7 @@ import math import re +from collections.abc import Callable from collections.abc import Iterator from collections.abc import Mapping from collections.abc import Sequence @@ -2518,6 +2519,583 @@ def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]: data[field] = float(value) return data +_WHAT_RAN_SCHEMA = "nemo.fabric.what_ran" +_WHAT_RAN_VERSION = "v1alpha1" +_FACT_SOURCES = frozenset({"observed", "reported", "configured"}) +_FACT_STATES = frozenset( + {"not_applicable", "unsupported", "collection_failed", "unavailable"} +) +_FACT_CODE = re.compile(r"[a-z0-9_.:#+/-]{1,128}") +_MAX_U64 = 2**64 - 1 + + +def _is_text(value: Any) -> bool: + return isinstance(value, str) + + +def _is_flag(value: Any) -> bool: + return isinstance(value, bool) + + +def _is_text_list(value: Any) -> bool: + return isinstance(value, list) and all(isinstance(item, str) for item in value) + + +def _is_code(value: Any) -> bool: + return isinstance(value, str) and _FACT_CODE.fullmatch(value) is not None + + +def _is_fact(fact: Any, is_value: Callable[[Any], bool]) -> bool: + """Whether ``fact`` holds ``value``, or ``state`` and ``reason``, never both.""" + + if not isinstance(fact, Mapping): + return False + source = fact.get("source") + if not (isinstance(source, str) and source in _FACT_SOURCES): + return False + if not (_is_code(fact.get("collected_by")) and _is_code(fact.get("method"))): + return False + if "value" in fact: + return "state" not in fact and "reason" not in fact and is_value(fact["value"]) + state = fact.get("state") + return ( + isinstance(state, str) + and state in _FACT_STATES + and _is_code(fact.get("reason")) + ) + + +def _has_facts(section: Any, facts: Mapping[str, Callable[[Any], bool]]) -> bool: + return isinstance(section, Mapping) and all( + _is_fact(section.get(name), is_value) for name, is_value in facts.items() + ) + + +def _is_model_entry(model: Any) -> bool: + return _has_facts( + model, {"requested": _is_text, "served": _is_text} + ) and isinstance(model.get("provider"), str) + + +def _is_mcp_server_entry(server: Any) -> bool: + if not _has_facts(server, {"endpoint": _is_text}): + return False + origin = server.get("origin") + transport = server.get("transport") + # A `plan` entry requires `transport`. An entry of any other origin, + # including an origin this SDK does not know, may leave it out. + transport_ok = isinstance(transport, str) or ( + transport is None and origin != "plan" + ) + runtime_facts = { + "connected": _is_flag, + "protocol_version": _is_text, + "server_name": _is_text, + "server_version": _is_text, + "tool_names": _is_text_list, + } + return ( + isinstance(origin, str) + and transport_ok + and _has_facts(server.get("runtime"), runtime_facts) + ) + + +def _is_truncation_marker(marker: Any) -> bool: + """Whether ``marker`` counts the entries dropped from a map or section.""" + + if not isinstance(marker, Mapping): + return False + dropped = marker.get("dropped") + return ( + _is_code(marker.get("reason")) + and isinstance(dropped, int) + and not isinstance(dropped, bool) + and 1 <= dropped <= _MAX_U64 + ) + + +def _has_valid_marker(section: Mapping[str, Any], name: str) -> bool: + """Whether marker ``name`` is absent from ``section`` or well formed. + + A marker is never ``null``. + """ + + return name not in section or _is_truncation_marker(section[name]) + + +def _is_fabric_section(fabric: Any) -> bool: + return _has_facts(fabric, {"core_version": _is_text, "host_core_version": _is_text}) + + +def _is_adapter_section(adapter: Any) -> bool: + return _has_facts(adapter, {"package_version": _is_text}) and ( + adapter.get("id") is None or isinstance(adapter.get("id"), str) + ) + + +def _is_harness_section(harness: Any) -> bool: + return _has_facts(harness, {"name": _is_text, "version": _is_text}) + + +def _is_models_section(models: Any) -> bool: + return isinstance(models, Mapping) and all( + _is_model_entry(model) for model in models.values() + ) + + +def _is_mcp_section(mcp: Any) -> bool: + if not isinstance(mcp, Mapping): + return False + servers = mcp.get("servers") + return ( + isinstance(servers, Mapping) + and all(_is_mcp_server_entry(server) for server in servers.values()) + and _has_valid_marker(mcp, "servers_truncated") + ) + + +def _is_environment_section(environment: Any) -> bool: + return isinstance(environment, Mapping) and isinstance( + environment.get("provider"), str + ) + + +_WHAT_RAN_SECTIONS: Mapping[str, Callable[[Any], bool]] = { + "fabric": _is_fabric_section, + "adapter": _is_adapter_section, + "harness": _is_harness_section, + "models": _is_models_section, + "mcp": _is_mcp_section, + "environment": _is_environment_section, +} + + +def _is_readable_what_ran(value: Any) -> bool: + """Whether ``value`` is a ``what_ran`` object this SDK reads. + + Mirrors the Rust core's reader: the schema and version match, every + present section and fact is well formed, no section is ``null``, and + unknown properties are ignored. Every section is required unless + ``what_ran_truncated`` is present, which marks a minimal object that + the byte budget left with only its schema name and version. + """ + + if not isinstance(value, Mapping): + return False + if ( + value.get("schema") != _WHAT_RAN_SCHEMA + or value.get("version") != _WHAT_RAN_VERSION + ): + return False + minimal = "what_ran_truncated" in value + if minimal and not _is_truncation_marker(value["what_ran_truncated"]): + return False + for name, is_section in _WHAT_RAN_SECTIONS.items(): + if name in value: + if not is_section(value[name]): + return False + elif not minimal: + return False + return _has_valid_marker(value, "models_truncated") + + +def _readable_what_ran(value: Any) -> Mapping[str, Any] | None: + """Return ``what_ran``, or ``None`` when this SDK cannot read it. + + As in the Rust core, a value that is absent, not a JSON object, names + another schema or version, or breaks the section's structure reads as + absent instead of failing the result. + """ + + return value if _is_readable_what_ran(value) else None + + +class PricingBasis(FabricMapping): + """Validated comparison basis for persisted catalog estimates.""" + + basis: str + currency: str + normalization_version: str + assumption_policy: str + bundled_catalog_sha256: str + _fields = frozenset( + { + "basis", + "currency", + "normalization_version", + "assumption_policy", + "bundled_catalog_sha256", + "user_catalog_sha256", + } + ) + + @property + def user_catalog_sha256(self) -> str | None: + """Return the user catalog fingerprint when supplied, otherwise None.""" + return self._data.get("user_catalog_sha256") + + @classmethod + def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]: + if set(data).difference(cls._fields) or any( + not isinstance(data.get(k), str) or not data[k] + for k in ("basis", "currency", "normalization_version", "assumption_policy") + ): + raise FabricConfigError("unsupported or incomplete pricing basis") + for key in ("bundled_catalog_sha256", "user_catalog_sha256"): + value = data.get(key) + if key == "user_catalog_sha256" and value is None: + continue + if ( + not isinstance(value, str) + or re.fullmatch(r"sha256:[0-9a-f]{64}", value) is None + ): + raise FabricConfigError("pricing basis requires catalog fingerprints") + return data + + +class AppliedPricing(FabricMapping): + """Selected price entry and integer nano-USD rates per million tokens.""" + + catalog_sha256: str + provider: str + model_id: str + pricing_as_of: str + pricing_source: str + rates: dict[str, int | None] + _fields = frozenset( + { + "catalog_sha256", + "provider", + "model_id", + "pricing_as_of", + "pricing_source", + "rates", + } + ) + _json_fields = frozenset({"rates"}) + + @classmethod + def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]: + if set(data) != cls._fields: + raise FabricConfigError("incomplete applied pricing") + for key in cls._fields - {"rates"}: + _required_text(data.get(key), "pricing " + key) + if re.fullmatch(r"sha256:[0-9a-f]{64}", data["catalog_sha256"]) is None: + raise FabricConfigError("invalid applied pricing fingerprint") + rates = _mapping(data["rates"], "pricing rates") + allowed = {"input", "output", "cache_read", "cache_write"} + if not {"input", "output"} <= set(rates) <= allowed: + raise FabricConfigError("incomplete pricing rates") + rates.setdefault("cache_read", None) + rates.setdefault("cache_write", None) + for key, value in rates.items(): + if value is not None or key in {"input", "output"}: + _usage_counter(value, "pricing rate") + data["rates"] = rates + return data + + +_MODEL_USAGE_TOKEN_FIELDS = frozenset( + { + "input_tokens", + "cached_input_tokens", + "cache_write_input_tokens", + "output_tokens", + "total_tokens", + } +) +_MODEL_USAGE_DIMENSIONS = ( + *sorted(_MODEL_USAGE_TOKEN_FIELDS), + "reported_cost", + "estimated_cost", +) +_USAGE_GAP_REASONS = frozenset({"unfinished_work", "missing_usage", "scope_unverified"}) + + +def _usage_counter(value: Any, name: str) -> int: + if type(value) is not int or not 0 <= value <= _UINT64_MAX: + raise FabricConfigError(f"{name} must be a nonnegative uint64 integer") + return value + + +class TokenCounts(FabricMapping): + """Independently nullable tokens in one fixed invocation/model scope.""" + + input_tokens: int | None + cached_input_tokens: int | None + cache_write_input_tokens: int | None + output_tokens: int | None + total_tokens: int | None + _fields = _MODEL_USAGE_TOKEN_FIELDS + + @classmethod + def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]: + for field in cls._fields: + value = data.setdefault(field, None) + if value is not None: + _usage_counter(value, field) + return data + + +class UsageGap(FabricMapping): + """Dimension-specific incompleteness with an optional stable diagnostic.""" + + dimensions: tuple[str, ...] + reason: str + code: str | None + _fields = frozenset({"dimensions", "reason", "code"}) + + @classmethod + def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]: + dimensions = data.get("dimensions") + if ( + not isinstance(dimensions, list) + or not dimensions + or any( + not isinstance(d, str) or d not in _MODEL_USAGE_DIMENSIONS + for d in dimensions + ) + or len(set(dimensions)) != len(dimensions) + ): + raise FabricConfigError("gap dimensions must be nonempty, known and unique") + if data.get("reason") not in _USAGE_GAP_REASONS: + raise FabricConfigError("unsupported usage gap reason") + code = data.setdefault("code", None) + if code is not None and ( + not isinstance(code, str) or not code or len(code) > 64 + ): + raise FabricConfigError("usage diagnostic must be 1 to 64 characters") + data["dimensions"] = tuple(dimensions) + return data + + +class CostAmount(FabricMapping): + """Complete and observed amounts in integer nano-USD, never an invoice guarantee.""" + + amount_nano_usd: int | None + observed_nano_usd: int | None + _fields = frozenset({"amount_nano_usd", "observed_nano_usd"}) + + @classmethod + def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]: + for field in cls._fields: + value = data.setdefault(field, None) + if value is not None: + _usage_counter(value, field) + complete = data["amount_nano_usd"] + observed = data["observed_nano_usd"] + if complete is not None and observed is not None and observed > complete: + raise FabricConfigError("cost observation exceeds complete amount") + return data + + +class TokenCost(FabricMapping): + """Provider-reported amounts and separate catalog estimates with their basis.""" + + reported: CostAmount + estimated: CostAmount + pricing: PricingBasis | None + applied_rates: tuple[AppliedPricing, ...] + _fields = frozenset({"reported", "estimated", "pricing", "applied_rates"}) + + @classmethod + def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]: + for name in ("reported", "estimated"): + data[name] = CostAmount.from_mapping(data.get(name, {})) + data["pricing"] = ( + None + if data.get("pricing") is None + else PricingBasis.from_mapping(data["pricing"]) + ) + rates = data.get("applied_rates", []) + if not isinstance(rates, list): + raise FabricConfigError("applied_rates must be an array") + data["applied_rates"] = tuple( + AppliedPricing.from_mapping(rate) for rate in rates + ) + for rate in data["applied_rates"]: + basis = data["pricing"] + if basis is None or rate.catalog_sha256 not in { + basis.bundled_catalog_sha256, + basis.get("user_catalog_sha256"), + }: + raise FabricConfigError( + "applied pricing must belong to the persisted basis" + ) + return data + + +def _usage_value( + data: Mapping[str, Any], dimension: str, *, observed: bool = False +) -> int | None: + if dimension in _MODEL_USAGE_TOKEN_FIELDS: + return data["observed"].get(dimension) if observed else data.get(dimension) + amount = data["cost"]["reported" if dimension == "reported_cost" else "estimated"] + return amount.observed_nano_usd if observed else amount.amount_nano_usd + + +def _normalize_model_usage(data: dict[str, Any]) -> dict[str, Any]: + for field in _MODEL_USAGE_TOKEN_FIELDS: + value = data.setdefault(field, None) + if value is not None: + _usage_counter(value, field) + data["observed"] = TokenCounts.from_mapping(data.get("observed", {})) + raw_gaps = data.get("gaps") + if not isinstance(raw_gaps, list): + raise FabricConfigError("gaps must be an array") + data["gaps"] = tuple(UsageGap.from_mapping(gap) for gap in raw_gaps) + data["cost"] = TokenCost.from_mapping(data.get("cost", {})) + for gap in data["gaps"]: + if any( + _usage_value(data, dimension) is not None for dimension in gap.dimensions + ): + raise FabricConfigError("complete count conflicts with accounting gap") + total = data["total_tokens"] + if total is not None and ( + data["input_tokens"] is None + or data["output_tokens"] is None + or total != data["input_tokens"] + data["output_tokens"] + ): + raise FabricConfigError("normalized total must equal known input plus output") + # Complete cache dimensions share the invocation's inclusive input scope. + # Compare only the known subset; missing cache counters remain unknown. + known_cache = sum( + count + for count in (data["cached_input_tokens"], data["cache_write_input_tokens"]) + if count is not None + ) + if data["input_tokens"] is not None and known_cache > data["input_tokens"]: + raise FabricConfigError("complete cache counts exceed inclusive input") + observed = data["observed"] + observed_total = observed.total_tokens + if observed_total is not None and ( + observed.input_tokens is None + or observed.output_tokens is None + or observed_total != observed.input_tokens + observed.output_tokens + ): + raise FabricConfigError("observed total must equal known input plus output") + for field in _MODEL_USAGE_TOKEN_FIELDS: + complete = data[field] + observation = observed.get(field) + if complete is not None and observation is not None and observation > complete: + raise FabricConfigError("token observation exceeds complete count") + return data + + +class PerModelUsage(FabricMapping): + """Normalized counts for a source-established accounting model/provider. + + A configured identity requires source proof that the included calls used it. + An outer requested alias alone does not establish accounting attribution. + This identity does not replace served-model provenance. + """ + + model: str + input_tokens: int | None + cached_input_tokens: int | None + cache_write_input_tokens: int | None + output_tokens: int | None + total_tokens: int | None + observed: TokenCounts + gaps: tuple[UsageGap, ...] + cost: TokenCost + _fields = frozenset( + {*_MODEL_USAGE_TOKEN_FIELDS, "model", "provider", "observed", "gaps", "cost"} + ) + + @property + def provider(self) -> str | None: + """Return the accounting provider when reported, otherwise None.""" + return self._data.get("provider") + + @classmethod + def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]: + data["model"] = _required_text(data.get("model"), "model") + if data.get("provider") is not None: + _required_text(data["provider"], "provider") + return _normalize_model_usage(data) + + +class ModelUsage(FabricMapping): + """Complete normalized tokens, observations, gaps and separately typed costs. + + Input includes cache and output includes reasoning. All counts belong to + this invocation, including descendants. Late work never moves to another + invocation. Observations are not guaranteed mathematical lower bounds. + """ + + input_tokens: int | None + cached_input_tokens: int | None + cache_write_input_tokens: int | None + output_tokens: int | None + total_tokens: int | None + observed: TokenCounts + gaps: tuple[UsageGap, ...] + models_exhaustive: bool + models: tuple[PerModelUsage, ...] + cost: TokenCost + _fields = frozenset( + { + *_MODEL_USAGE_TOKEN_FIELDS, + "observed", + "gaps", + "models_exhaustive", + "models", + "cost", + } + ) + + @classmethod + def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]: + _normalize_model_usage(data) + if type(data.get("models_exhaustive")) is not bool: + raise FabricConfigError("models_exhaustive must be boolean") + models = data.get("models", []) + if not isinstance(models, list): + raise FabricConfigError("models must be an array") + data["models"] = tuple(PerModelUsage.from_mapping(model) for model in models) + if ( + data["models_exhaustive"] + and not data["models"] + and any( + (data[field] or 0) > 0 or (data["observed"].get(field) or 0) > 0 + for field in _MODEL_USAGE_TOKEN_FIELDS + ) + ): + raise FabricConfigError( + "exhaustive empty model list conflicts with positive tokens" + ) + identities = [(model.get("provider"), model.model) for model in data["models"]] + if len(set(identities)) != len(identities): + raise FabricConfigError("duplicate provider/model identity") + for field in _MODEL_USAGE_TOKEN_FIELDS: + complete = data[field] + if complete is None: + continue + parts = [ + model[field] if model[field] is not None else model.observed.get(field) + for model in data["models"] + ] + subtotal = sum(value for value in parts if value is not None) + complete_parts = [ + model[field] for model in data["models"] if model[field] is not None + ] + if ( + subtotal > _UINT64_MAX + or subtotal > complete + or ( + data["models_exhaustive"] + and data["models"] + and len(complete_parts) == len(data["models"]) + and sum(complete_parts) != complete + ) + ): + raise FabricConfigError( + f"model token totals contradict invocation {field}" + ) + return data + class RunResult(FabricMapping): """Normalized terminal result from one NeMo Fabric invocation. @@ -2539,6 +3117,8 @@ class RunResult(FabricMapping): are preserved as-is. error: Structured failure, or ``None`` on success. usage: Normalized invocation usage, or ``None`` when unavailable. + model_usage: Complete counts, observations and accounting gaps, or + ``None`` when supported accounting evidence is unavailable. resource_snapshot: Runtime resource snapshot read once after the adapter returned this result, or ``None`` when the runtime's resources could not be observed. @@ -2548,6 +3128,15 @@ class RunResult(FabricMapping): telemetry: Ordered telemetry references. events: Ordered lifecycle and invocation events. metadata: Result-specific structured details. + what_ran: What executed for this invocation, schema + ``nemo.fabric.what_ran`` version ``v1alpha1``, as a JSON mapping. + Each fact holds ``value``, or ``state`` and ``reason``, plus + ``source``, ``collected_by`` and ``method``. A minimal object + that the byte budget left holds only ``schema``, ``version`` and + ``what_ran_truncated``, and reads as present. ``None`` when the + producing core predates it, or when the value is not a + ``what_ran`` object of that schema and version with every present + section and fact well formed, as the Rust core reads it. """ agent_name: str @@ -2561,12 +3150,14 @@ class RunResult(FabricMapping): output: RunOutput | JSONValue error: ErrorInfo | None usage: RunUsage | None + model_usage: ModelUsage | None system_stats: InvocationSystemStats | None resource_snapshot: RuntimeResourceSnapshot | None artifacts: ArtifactManifest telemetry: Sequence[TelemetryRef] events: Sequence[FabricEvent] metadata: Mapping[str, Any] + what_ran: Mapping[str, Any] | None _fields = frozenset( { "agent_name", @@ -2580,15 +3171,17 @@ class RunResult(FabricMapping): "output", "error", "usage", + "model_usage", "resource_snapshot", "system_stats", "artifacts", "telemetry", "events", "metadata", + "what_ran", } ) - _json_fields = frozenset({"metadata"}) + _json_fields = frozenset({"metadata", "what_ran"}) @classmethod def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]: @@ -2608,6 +3201,11 @@ def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]: data["usage"] = ( None if data.get("usage") is None else RunUsage.from_mapping(data["usage"]) ) + data["model_usage"] = ( + None + if data.get("model_usage") is None + else ModelUsage.from_mapping(data["model_usage"]) + ) data["system_stats"] = ( None if data.get("system_stats") is None @@ -2634,6 +3232,7 @@ def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]: FabricEvent.from_mapping(event) for event in data.get("events", []) ) data["metadata"] = _mapping(data.get("metadata", {}), "result metadata") + data["what_ran"] = _readable_what_ran(data.get("what_ran")) raw_output = data.get("output") if isinstance(raw_output, RunOutput): data["output"] = raw_output diff --git a/skills/nemo-fabric-build-adapter/SKILL.md b/skills/nemo-fabric-build-adapter/SKILL.md index 503448cba..7b014b3a7 100644 --- a/skills/nemo-fabric-build-adapter/SKILL.md +++ b/skills/nemo-fabric-build-adapter/SKILL.md @@ -230,6 +230,9 @@ never share mutable continuation state. Normalize usage per invocation in `AgentUsage`. If the target reports cumulative session totals, retain a session-local baseline and difference successive observed counters rather than summing cumulative snapshots or using only the last model response. Missing or reset counters remain unknown. Keep the closed `fabric.adapter/v1alpha2` usage fields unchanged. Put cache-write, reasoning, peak request, and per-model detail in `usage.extensions["nemo_fabric.detailed_token_usage.v1"]`, and declare that object with `extension_schemas.usage` in the adapter descriptor. The detailed object repeats the normalized token counters so its inclusion flags are self-contained; `input_tokens_include_cache` covers both cached reads and cache writes there. NeMo Fabric preserves the object as `RunResult.usage.metadata["nemo_fabric.detailed_token_usage.v1"]`. Consumers treat any detailed field for an input or output dimension as selecting the detailed counters for that dimension without falling back to its top-level counters. Report the primary counter, inclusion flag, and every excluded component together so consumers can calculate an inclusive total. Preserve available usage on unsuccessful terminal results. Do not infer missing cost or promote estimates to `cost_usd`. + +Preserve field presence: an omitted count stays unknown and a provider-reported zero stays zero. If the target fills missing fields with zero, recover original report presence before certifying a complete count. Capture each call's invocation owner when the call starts, including descendant work. Freeze its usage snapshot when `invoke` returns; late reports cannot amend that result or transfer into a later invocation. A failed execution alone is not an accounting gap: keep settled usage, and distinguish valid observations from complete totals when owned work is missing or unfinished. Use the shared Python `usage_from_observations` or TypeScript `usageFromObservations` reducer with the declared `nemo_fabric.usage_accounting.v1` extension for this evidence. Keep source-specific parsing and lifecycle ownership in the adapter; let core normalize counts. See the [results contract](https://github.com/NVIDIA/NeMo-Fabric/blob/main/docs/adapter-contract/tutorials/results.md) for the extension and compatibility boundary. + Test observable continuation rather than merely calling `invoke` twice: make the second result depend on the first turn without caller-side replay, then prove another runtime cannot observe that context. Refer to the diff --git a/skills/nemo-fabric-integrate/references/results-and-errors.md b/skills/nemo-fabric-integrate/references/results-and-errors.md index 1c8cc99e5..104c4eef9 100644 --- a/skills/nemo-fabric-integrate/references/results-and-errors.md +++ b/skills/nemo-fabric-integrate/references/results-and-errors.md @@ -17,14 +17,18 @@ Every invocation that reaches the adapter boundary returns a normalized | `error` | Structured failure metadata when available. Branch on `status`, not on this field. | | `output` | Harness output normalized to the configured output schema. | | `usage` | Optional `RunUsage` with invocation-local input, cached-input, output, and total token counts, reported cost in USD, and provider metadata. | +| `model_usage` | Optional normalized accounting: independently nullable complete counts, observations, dimension-specific gaps, provider-reported cost, and separately labeled catalog estimates. | | `system_stats` | Optional `InvocationSystemStats`: adapter exchange duration, before/after process-tree observations, and an optional CPU counter difference estimate. | | `resource_snapshot` | Optional `RuntimeResourceSnapshot` of the runtime, read once after the adapter returned the result. `None` when the read failed. | | `artifacts` | Output files, logs, patches, and other materialized references. | | `telemetry` | References to NVIDIA NeMo Relay or other telemetry streams from the run. | | `events` | Ordered normalized lifecycle and invocation events. | | `metadata` | Result-specific structured metadata. | +| `what_ran` | Optional core and resolved-plan evidence. Facts identify their source, collector, and collection method. | | `runtime_id`, `invocation_id`, `request_id` | Correlation IDs across runtimes, logs, telemetry, and artifacts. | +`what_ran` is `None` for older results or provenance the reader cannot decode. Check for `what_ran_truncated` before reading sections, because a byte-budget marker permits sections to be absent. A configured model or MCP endpoint does not establish the served model or a successful connection. Recorded conditions alone do not prove that runs are equivalent. + Handle a result with this status-first pattern: ```python @@ -34,7 +38,24 @@ else: handle_failure(result.status, result.error, result.events) # failed, cancelled, ... ``` -Read usage only when the target reports it: +Prefer normalized accounting when the target reports it: + +```python +from nemo_fabric import summarize_usage + +if result.model_usage is not None: + complete_input = result.model_usage.input_tokens + observed_input = result.model_usage.observed.input_tokens + gaps = result.model_usage.gaps + +summary = summarize_usage( + [result], expected=[(result.runtime_id, result.invocation_id)] +) +``` + +Complete counts cover invocation-owned work at return. Observations can be incomplete and are not guaranteed lower bounds. Failure alone does not invalidate settled usage, and late usage never moves to a later invocation. `summarize_usage` requires the expected invocation inventory so missing results remain visible in coverage; list every expected invocation when aggregating multiple results. It does not certify legacy usage or fill missing values from logs or Relay. Keep provider-reported cost separate from estimates. Typed access requires an updated SDK; closed validators must use the updated result schema. Current readers accept older results without this field. See the [Python SDK accounting guide](https://github.com/NVIDIA/NeMo-Fabric/blob/main/docs/sdk/python.mdx#complete-counts-and-useful-observations). + +Legacy usage remains available when a target has no supported accounting evidence: ```python if result.usage is not None: @@ -46,7 +67,7 @@ if result.usage is not None: ) ``` -Treat missing counters and cost as unknown, not zero. `cached_input_tokens` reports cached prompt tokens; `input_tokens_include_cache` declares whether `input_tokens` includes them (`True`), excludes them (`False`), or has unknown semantics (absent). Do not add cache to an inclusive input count. Adapters can put cache-write, reasoning, peak request, and per-model detail in the descriptor-declared `usage.extensions["nemo_fabric.detailed_token_usage.v1"]` object. NeMo Fabric preserves that object at `result.usage["metadata"]["nemo_fabric.detailed_token_usage.v1"]`. Treat detailed input and output independently: any detailed accounting field for a dimension makes that dimension authoritative, with no fallback to its top-level counters. The views can diverge. Calculate an inclusive total only when the selected detailed counters provide enough information; otherwise leave the total unknown. Keep estimated costs separate in provider metadata; `cost_usd` is reported cost, not an estimate. +Treat missing counters and cost as unknown, not zero. `cached_input_tokens` reports cached prompt tokens; `input_tokens_include_cache` declares whether `input_tokens` includes them (`True`), excludes them (`False`), or has unknown semantics (absent). Do not add cache to an inclusive input count. Adapters can put cache-write, reasoning, peak request, and per-model detail in the descriptor-declared `usage.extensions["nemo_fabric.detailed_token_usage.v1"]` object. NeMo Fabric preserves that object at `result.usage["metadata"]["nemo_fabric.detailed_token_usage.v1"]`. Treat detailed input and output independently: any detailed accounting field for a dimension makes that dimension authoritative, with no fallback to its top-level counters. The views can diverge. Calculate an inclusive total only when the selected detailed counters provide enough information; otherwise leave the total unknown. `usage.cost_usd` is provider-reported cost, not an estimate. Core catalog estimates appear in `model_usage.cost.estimated` with their pricing basis. ## Runtime Resource Snapshots diff --git a/skills/nemo-fabric-integrate/references/sdk-api-inventory.md b/skills/nemo-fabric-integrate/references/sdk-api-inventory.md index 10b20072d..6b83beb23 100644 --- a/skills/nemo-fabric-integrate/references/sdk-api-inventory.md +++ b/skills/nemo-fabric-integrate/references/sdk-api-inventory.md @@ -90,6 +90,10 @@ another turn. For native OpenAI streaming, `result()` also finalizes and discards unread chunks. Relay `InvokeStream.result()` does not consume unread ATOF records. +## Usage Summaries + +`summarize_usage(results, *, expected=...)` reduces normalized `RunResult.model_usage` with an explicit inventory of expected `(runtime_id, invocation_id)` pairs. It returns complete totals, observed sums and coverage without certifying legacy counters or silently dropping missing results. See [results and errors](results-and-errors.md) for field semantics and a single-invocation example. + ## Execution Model NVIDIA NeMo Fabric separates configuration, planning, runtime lifecycle, and diff --git a/tests/adapter_contract/test_agent_execution.py b/tests/adapter_contract/test_agent_execution.py index d0e706480..89aa75cd8 100644 --- a/tests/adapter_contract/test_agent_execution.py +++ b/tests/adapter_contract/test_agent_execution.py @@ -22,6 +22,7 @@ from nemo_fabric_adapter_contract.models import EnvironmentHandle from nemo_fabric_adapter_contract.models import RuntimeContext from nemo_fabric_adapter_contract.models import RuntimeTelemetryContext +from nemo_fabric_adapter_contract.models import UsageAccounting from nemo_fabric_adapter_contract.codec import ContractValidationError from nemo_fabric_adapter_contract.pydantic_support import type_adapter @@ -54,6 +55,52 @@ def test_usage_rejects_invalid_cache_semantics(value): AgentUsage(input_tokens_include_cache=value) +@pytest.mark.parametrize( + "field", + [ + "input_tokens", + "cached_input_tokens", + "cache_write_input_tokens", + "output_tokens", + "reasoning_tokens", + "total_tokens", + "peak_request_input_tokens", + ], +) +def test_exhaustive_empty_models_reject_positive_observed_tokens(field): + with pytest.raises(ContractValidationError, match="exhaustive empty model list"): + UsageAccounting.from_mapping( + { + "models_exhaustive": True, + "models": [], + "gaps": [], + "observed": {field: 1}, + } + ) + + +@pytest.mark.parametrize("observed", [{}, {"input_tokens": 0}, {"cost_usd": 0.25}]) +def test_exhaustive_empty_models_preserve_zero_unknown_and_cost(observed): + accounting = UsageAccounting.from_mapping( + {"models_exhaustive": True, "models": [], "gaps": [], "observed": observed} + ) + assert accounting.observed is not None + assert accounting.observed.to_mapping() == observed + + +def test_nonexhaustive_empty_models_can_observe_positive_tokens(): + accounting = UsageAccounting.from_mapping( + { + "models_exhaustive": False, + "models": [], + "gaps": [], + "observed": {"input_tokens": 1}, + } + ) + assert accounting.observed is not None + assert accounting.observed.input_tokens == 1 + + def test_agent_run_request_contains_only_southbound_request_fields(): request = AgentRunRequest( input={"messages": [{"role": "user", "content": "hello"}]}, diff --git a/tests/adapters/test_deepagents.py b/tests/adapters/test_deepagents.py index 23304be4d..702f269ea 100644 --- a/tests/adapters/test_deepagents.py +++ b/tests/adapters/test_deepagents.py @@ -5,8 +5,9 @@ These tests stub the ``deepagents``/``langchain``/``langgraph`` SDKs so they run without the real harness installed; they assert the normalized Fabric result and -the live runtime's thread continuity. The real SDK is exercised by the opt-in -integration test in ``tests/e2e/test_deepagents.py``. +the live runtime's thread continuity. Pinned model/graph boundaries also run in +``test_deepagents_accounting.py`` against scripted transports. Credentialed +integration coverage lives in ``tests/e2e/test_deepagents.py``. """ from __future__ import annotations @@ -23,6 +24,7 @@ from typing import Any from unittest.mock import AsyncMock from unittest.mock import MagicMock +from unittest.mock import ANY import pytest from langchain_openai import ChatOpenAI as InstalledChatOpenAI @@ -1445,7 +1447,7 @@ def test_native_telemetry_requires_mapping(tmp_path, make_payload): ) -async def test_relay_disabled_adds_no_scope_or_callbacks( +async def test_relay_disabled_adds_no_relay_scope_or_callbacks( tmp_path, make_payload, fake_sdks ): # With telemetry disabled the invocation runs without a Relay scope, callback @@ -1454,7 +1456,9 @@ async def test_relay_disabled_adds_no_scope_or_callbacks( assert output["completed"] is True assert "telemetry" not in output - assert (fake_sdks["config"] or {}).get("callbacks") is None + callbacks = fake_sdks["config"]["callbacks"] + assert len(callbacks) == 1 + assert isinstance(callbacks[0], adapter.ModelCallUsage) assert "relay-mw" not in (fake_sdks["create_kwargs"].get("middleware") or []) @@ -2111,8 +2115,8 @@ def test_aggregate_usage_discards_tokens_larger_than_uint64(): async def test_replayed_state_usage_counts_current_turn_only( tmp_path, make_payload, monkeypatch ): - # On a later turn the final state replays prior messages; usage and - # cost must reflect only the message emitted this turn, not the replayed one. + # Existing-behavior coverage: the graph-message diagnostic excludes replayed + # messages. Model-call accounting is independent of this diagnostic. import deepagents prior = { @@ -2428,8 +2432,8 @@ def test_deepagents_passthrough_rejects_fabric_owned_option(): async def test_subagent_usage_folded_from_subgraph(tmp_path, make_payload, monkeypatch): - # Usage from a delegated subagent is emitted under a subgraph namespace; folding - # it into this turn keeps usage/cost accurate. Duplicate ids are counted once. + # Existing-behavior coverage: graph-message diagnostics include subgraph + # messages. Duplicate message ids are counted once. import deepagents sub_ai = { @@ -2549,6 +2553,10 @@ async def test_openai_compatible_model_receives_normalized_sampling( temperature=0.2, top_p=0.8, max_completion_tokens=256, + http_client=ANY, + http_async_client=ANY, + metadata=ANY, + cache=ANY, ) @@ -2599,6 +2607,10 @@ async def test_unset_sampling_preserves_chat_model_defaults( model="nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", api_key="test123", base_url="https://integrate.api.nvidia.com/v1", + http_client=ANY, + http_async_client=ANY, + metadata=ANY, + cache=ANY, ) diff --git a/tests/adapters/test_deepagents_accounting.py b/tests/adapters/test_deepagents_accounting.py new file mode 100644 index 000000000..4b674815a --- /dev/null +++ b/tests/adapters/test_deepagents_accounting.py @@ -0,0 +1,879 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Real pinned ChatOpenAI boundaries against scripted, in-memory HTTP transports.""" + +import asyncio +from copy import deepcopy +import json +import os +from uuid import uuid4 + +import httpx +import pytest +from langchain_core.caches import InMemoryCache +from langchain_core.globals import get_llm_cache, set_llm_cache +from langchain_core.messages import AIMessage +from langchain_core.outputs import ChatGeneration, LLMResult +from langchain_openai import ChatOpenAI + +from nemo_fabric_adapters.deepagents.usage import ( + AccountingCache, + ModelCallUsage, + async_request_hook, + async_response_hook, + model_metadata, + request_hook, + response_hook, +) +from nemo_fabric_adapters.deepagents import adapter +from nemo_fabric_adapter_contract.models import ( + AgentConfig, + AgentRunRequest, + RuntimeContext, +) + +KEY = "nemo_fabric.usage_accounting.v1" + + +def _reply(*, model="model", usage=None): + return { + "id": "scripted", + "object": "chat.completion", + "created": 1, + "model": model, + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "done"}, + "finish_reason": "stop", + } + ], + "usage": usage + if usage is not None + else {"prompt_tokens": 100, "completion_tokens": 2, "total_tokens": 102}, + } + + +@pytest.fixture(name="make_model") +async def make_model_fixture(): + clients = [] + old_cache = get_llm_cache() + set_llm_cache(None) + + def make( + *, + statuses=(200,), + usage=None, + hooks=True, + transport_error=False, + observer="observer", + served_model="model", + model_field="reported", + finish_reason="stop", + base_url="https://example.invalid/v1", + ): + requests = [] + remaining = iter(statuses) + + def respond(request): + requests.append(json.loads(request.content)) + if transport_error: + raise httpx.ConnectError("scripted connection failure", request=request) + status = next(remaining) + body = _reply(model=served_model, usage=usage) + body["choices"][0]["finish_reason"] = finish_reason + if model_field == "omitted": + body.pop("model") + elif model_field == "null": + body["model"] = None + return httpx.Response( + status, + json=body + if status == 200 + else {"error": {"message": "scripted", "type": "api_error"}}, + headers={"retry-after-ms": "1"}, + ) + + transport = httpx.MockTransport(respond) + sync = httpx.Client( + transport=transport, + event_hooks={"request": [request_hook], "response": [response_hook]} + if hooks + else {}, + ) + async_client = httpx.AsyncClient( + transport=transport, + event_hooks={ + "request": [async_request_hook], + "response": [async_response_hook], + } + if hooks + else {}, + ) + clients.append((sync, async_client)) + model = ChatOpenAI( + model="model", + api_key="scripted-unused", + base_url=base_url, + http_client=sync, + http_async_client=async_client, + metadata=model_metadata(observer), + cache=AccountingCache(), + ) + return model, requests + + yield make + set_llm_cache(old_cache) + for sync, async_client in clients: + sync.close() + await async_client.aclose() + + +@pytest.mark.parametrize("sync", [False, True]) +@pytest.mark.parametrize("model_field", ["reported", "omitted", "null"]) +async def test_model_identity_requires_a_reported_name(make_model, sync, model_field): + model, requests = make_model( + served_model="literal-served-model", model_field=model_field + ) + recorder = ModelCallUsage("observer") + with recorder.scope(): + if sync: + model.invoke("task", config={"callbacks": [recorder]}) + else: + await model.ainvoke("task", config={"callbacks": [recorder]}) + usage = recorder.finish() + assert usage.input_tokens == 100 + assert usage.output_tokens == 2 + assert usage.total_tokens == 102 + models = usage.extensions[KEY].get("models", []) + assert [entry["model"] for entry in models] == ( + ["literal-served-model"] if model_field == "reported" else [] + ) + assert len(requests) == 1 + + +@pytest.mark.parametrize("sync", [False, True]) +async def test_sync_and_async_keep_raw_presence_and_reported_zero(make_model, sync): + model, requests = make_model(usage={"prompt_tokens": 0}) + recorder = ModelCallUsage("observer") + with recorder.scope(): + if sync: + model.invoke("task", config={"callbacks": [recorder]}) + else: + await model.ainvoke("task", config={"callbacks": [recorder]}) + usage = recorder.finish() + assert usage.input_tokens == 0 + assert usage.output_tokens is None + assert usage.total_tokens is None + assert len(requests) == 1 + + +@pytest.mark.parametrize( + "operation", ["unused", "start_async_task", "update_async_task", "check_async_task"] +) +@pytest.mark.parametrize("foreground_call", [False, True]) +async def test_remote_tool_callback_dispatch_marks_new_work_only_unit( + make_model, operation, foreground_call +): + from deepagents.middleware.async_subagents import ( + StartAsyncTaskSchema, + UpdateAsyncTaskSchema, + CheckAsyncTaskSchema, + ) + from langchain_core.tools import StructuredTool + + model, _ = make_model() + recorder = ModelCallUsage("observer", remote_names=frozenset({"researcher"})) + schemas = { + "start_async_task": StartAsyncTaskSchema, + "update_async_task": UpdateAsyncTaskSchema, + "check_async_task": CheckAsyncTaskSchema, + } + inputs = { + "start_async_task": {"description": "research", "subagent_type": "researcher"}, + "update_async_task": {"task_id": "prior-task", "message": "continue"}, + "check_async_task": {"task_id": "prior-task"}, + } + with recorder.scope(): + if foreground_call: + await model.ainvoke("task", config={"callbacks": [recorder]}) + if operation != "unused": + tool = StructuredTool.from_function( + func=lambda **values: "fixture result", + name=operation, + description="Callback-dispatch unit fixture", + args_schema=schemas[operation], + ) + tool.invoke(inputs[operation], config={"callbacks": [recorder]}) + result = recorder.finish() + dispatched = operation in {"start_async_task", "update_async_task"} + assert result.input_tokens == (100 if foreground_call and not dispatched else None) + reasons = {gap["reason"] for gap in result.extensions[KEY]["gaps"]} + # With no foreground call, an empty callback list cannot prove zero usage. + assert ("scope_unverified" in reasons) is (dispatched or not foreground_call) + if foreground_call: + assert result.extensions[KEY]["observed"]["input_tokens"] == 100 + elif dispatched: + assert "input_tokens" not in (result.extensions[KEY].get("observed") or {}) + + +@pytest.mark.parametrize("status", [429, 503]) +async def test_sdk_internal_retry_retains_observation_without_certifying_total( + make_model, status +): + model, requests = make_model(statuses=(status, 200)) + recorder = ModelCallUsage("observer") + with recorder.scope(): + await model.ainvoke("task", config={"callbacks": [recorder]}) + usage = recorder.finish() + assert len(requests) == 2 + assert usage.input_tokens is None + assert usage.extensions[KEY]["observed"]["input_tokens"] == 100 + assert any( + gap["reason"] == "missing_usage" for gap in usage.extensions[KEY]["gaps"] + ) + + +async def test_instrumentation_preserves_request_bodies_counts_and_response(make_model): + observed, observed_requests = make_model(statuses=(503, 200)) + baseline, baseline_requests = make_model(statuses=(503, 200), hooks=False) + baseline_result = await baseline.ainvoke("task") + recorder = ModelCallUsage("observer") + with recorder.scope(): + observed_result = await observed.ainvoke( + "task", config={"callbacks": [recorder]} + ) + assert baseline_requests == observed_requests + assert baseline_result.content == observed_result.content + assert baseline_result.usage_metadata == observed_result.usage_metadata + + +async def test_owned_direct_sdk_call_without_a_callback_is_not_silently_omitted( + make_model, +): + model, requests = make_model(statuses=(200, 200)) + recorder = ModelCallUsage("observer") + with recorder.scope(): + await model.ainvoke("visible", config={"callbacks": [recorder]}) + await model.root_async_client.chat.completions.create( + model="model", messages=[{"role": "user", "content": "auxiliary"}] + ) + result = recorder.finish() + assert len(requests) == 2 + assert result.input_tokens is None + assert result.extensions[KEY]["observed"]["input_tokens"] == 100 + + +async def test_worker_without_transport_owner_retains_observation_with_a_scope_gap( + make_model, +): + model, _ = make_model() + recorder = ModelCallUsage("observer") + with recorder.scope(): + # Plain run_in_executor does not copy ContextVars; LangChain's own + # context-preserving executor and asyncio.to_thread do. + await asyncio.get_running_loop().run_in_executor( + None, lambda: model.invoke("task", config={"callbacks": [recorder]}) + ) + result = recorder.finish() + assert result.input_tokens is None + assert result.extensions[KEY]["observed"]["input_tokens"] == 100 + + +async def test_local_cache_replay_does_not_recharge_historical_tokens(make_model): + cache = InMemoryCache() + set_llm_cache(cache) + model, requests = make_model() + totals = [] + for _ in range(2): + recorder = ModelCallUsage("observer") + with recorder.scope(): + await model.ainvoke("identical task", config={"callbacks": [recorder]}) + totals.append(recorder.finish().input_tokens) + assert len(requests) == 1 + assert totals == [100, 0] + + +async def test_observer_identity_does_not_change_cross_model_cache_reuse(make_model): + set_llm_cache(InMemoryCache()) + first_model, first_requests = make_model(observer="first") + second_model, second_requests = make_model(observer="second") + amounts = [] + for model, name in [(first_model, "first"), (second_model, "second")]: + owner = ModelCallUsage(name) + with owner.scope(): + await model.ainvoke("identical", config={"callbacks": [owner]}) + amounts.append(owner.finish().input_tokens) + assert len(first_requests) + len(second_requests) == 1 + assert amounts == [100, 0] + + +async def test_concurrent_invocation_owners_and_late_callbacks_stay_isolated( + make_model, +): + model, requests = make_model(statuses=(200, 200)) + first, second = ModelCallUsage("observer"), ModelCallUsage("observer") + barrier = asyncio.Event() + + async def invoke(recorder, prompt): + with recorder.scope(): + await barrier.wait() + await model.ainvoke(prompt, config={"callbacks": [recorder]}) + return recorder.finish() + + a = asyncio.create_task(invoke(first, "first")) + b = asyncio.create_task(invoke(second, "second")) + barrier.set() + results = await asyncio.gather(a, b) + assert [result.input_tokens for result in results] == [100, 100] + frozen = deepcopy(results[0].to_mapping()) + late = uuid4() + first.on_chat_model_start({}, [], run_id=late) + first.on_llm_end( + LLMResult(generations=[[ChatGeneration(message=AIMessage(content="late"))]]), + run_id=late, + ) + assert first.finish().to_mapping() == frozen + assert second.finish().input_tokens == 100 + assert len(requests) == 2 + + +async def test_terminal_failure_is_not_a_zero_and_does_not_poison_the_next_owner( + make_model, +): + model, requests = make_model(statuses=(400, 200)) + failed = ModelCallUsage("observer") + with failed.scope(), pytest.raises(Exception, match="scripted"): + await model.ainvoke("first", config={"callbacks": [failed]}) + assert failed.finish().input_tokens is None + next_owner = ModelCallUsage("observer") + with next_owner.scope(): + await model.ainvoke("next", config={"callbacks": [next_owner]}) + assert next_owner.finish().input_tokens == 100 + assert len(requests) == 2 + + +async def test_non_model_failure_keeps_completed_call_usage(make_model): + model, _ = make_model() + recorder = ModelCallUsage("observer") + with recorder.scope(), pytest.raises(ValueError, match="tool failed"): + await model.ainvoke("task", config={"callbacks": [recorder]}) + raise ValueError("tool failed") + assert recorder.finish().input_tokens == 100 + + +@pytest.fixture(name="make_runtime") +async def make_runtime_fixture(tmp_path, monkeypatch): + runtimes = [] + monkeypatch.setenv("SCRIPTED_MODEL_KEY", "unused-scripted") + monkeypatch.delenv("OPENAI_PROXY", raising=False) + + async def make(replies, *, identity="runtime", profile=None, barrier=None): + ledger = [] + remaining = iter(replies) + + async def respond(request): + ledger.append(json.loads(request.content)) + if barrier is not None: + await barrier() + reply = next(remaining) + status, body = reply if isinstance(reply, tuple) else (200, reply) + return httpx.Response(status, json=body, headers={"retry-after-ms": "1"}) + + def clients(): + transport = httpx.MockTransport(respond) + return ( + httpx.Client( + transport=transport, + timeout=None, + event_hooks={ + "request": [request_hook], + "response": [response_hook], + }, + ), + httpx.AsyncClient( + transport=transport, + timeout=None, + event_hooks={ + "request": [async_request_hook], + "response": [async_response_hook], + }, + ), + ) + + monkeypatch.setattr(adapter, "observed_http_clients", clients) + build = adapter.build_chat_model + if profile is not None: + + def profiled_model(config, **kwargs): + model, name, url = build(config, **kwargs) + model.profile = profile + return model, name, url + + monkeypatch.setattr(adapter, "build_chat_model", profiled_model) + workspace = tmp_path / identity + workspace.mkdir() + context = RuntimeContext.from_mapping( + { + "runtime_id": identity, + "invocation_id": "invocation", + "request_id": "request", + "environment": { + "environment_id": "environment", + "provider": "local", + "control_location": "in_env_control", + "workspace": str(workspace), + "ownership": "caller_owned", + }, + "artifacts": {}, + } + ) + config = AgentConfig.from_mapping( + { + "harness": {"settings": {}}, + "models": { + "default": { + "provider": "openai", + "model": "model", + "api_key_env": "SCRIPTED_MODEL_KEY", + "base_url": "https://example.invalid/v1", + } + }, + } + ) + runtime = adapter.DeepAgentsRuntime() + runtimes.append(runtime) + await runtime.start( + { + "config": config, + "runtime_context": context.to_mapping(), + "base_dir": str(workspace), + } + ) + return runtime, context, ledger + + yield make + for runtime in runtimes: + await runtime.stop() + + +async def test_two_actual_runtimes_preserve_concurrent_graph_ownership(make_runtime): + arrived = 0 + both = asyncio.Event() + + async def barrier(): + nonlocal arrived + arrived += 1 + if arrived == 2: + both.set() + await both.wait() + + a, context_a, ledger_a = await make_runtime( + [ + _reply( + usage={ + "prompt_tokens": 100, + "completion_tokens": 2, + "total_tokens": 102, + } + ) + ], + identity="a", + barrier=barrier, + ) + b, context_b, ledger_b = await make_runtime( + [ + _reply( + usage={ + "prompt_tokens": 200, + "completion_tokens": 3, + "total_tokens": 203, + } + ) + ], + identity="b", + barrier=barrier, + ) + results = await asyncio.wait_for( + asyncio.gather( + a.invoke(AgentRunRequest(input="first"), context_a), + b.invoke(AgentRunRequest(input="second"), context_b), + ), + timeout=20, + ) + assert [result.usage.input_tokens for result in results] == [100, 200] + assert [len(ledger_a), len(ledger_b)] == [1, 1] + + +async def test_actual_summarization_is_counted_outside_graph_messages(make_runtime): + replies = [ + _reply( + usage={"prompt_tokens": i, "completion_tokens": o, "total_tokens": i + o} + ) + for i, o in [(1500, 10), (1400, 60), (300, 5)] + ] + runtime, context, ledger = await make_runtime( + replies, profile={"max_input_tokens": 1000} + ) + first = await runtime.invoke(AgentRunRequest(input="word " * 6000), context) + second = await runtime.invoke(AgentRunRequest(input="continue"), context) + assert first.usage.input_tokens == 1500 + assert second.usage.input_tokens == 1700 + assert second.usage.output_tokens == 65 + assert len(ledger) == 3 + assert "tools" not in ledger[1] + assert "tools" in ledger[2] + + +async def test_actual_parallel_task_subagents_count_each_call_once(make_runtime): + first = _reply( + usage={"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2} + ) + first["choices"][0]["message"]["tool_calls"] = [ + { + "id": f"task-{index}", + "type": "function", + "function": { + "name": "task", + "arguments": json.dumps( + { + "description": f"Echo {index}.", + "subagent_type": "general-purpose", + } + ), + }, + } + for index in (1, 2) + ] + first["choices"][0]["finish_reason"] = "tool_calls" + replies = [ + first, + *[ + _reply( + usage={ + "prompt_tokens": count, + "completion_tokens": count, + "total_tokens": count * 2, + } + ) + for count in (10, 100, 1000) + ], + ] + runtime, context, ledger = await make_runtime(replies) + result = await runtime.invoke( + AgentRunRequest(input="Delegate two tasks, then answer."), context + ) + assert result.usage.input_tokens == 1111 + assert result.usage.output_tokens == 1111 + assert len(ledger) == 4 + + +@pytest.mark.parametrize("sync", [False, True]) +@pytest.mark.parametrize("completion", [2, 0]) +@pytest.mark.parametrize("extra_frame", [False, True]) +@pytest.mark.parametrize("model_field", ["reported", "omitted", "null"]) +async def test_real_streaming_usage_is_preserved_when_the_source_reports_it( + sync, completion, extra_frame, model_field +): + chunks = [ + { + "id": "stream", + "object": "chat.completion.chunk", + "created": 1, + "model": "model", + "choices": [ + { + "index": 0, + "delta": {"role": "assistant", "content": "done"}, + "finish_reason": "stop", + } + ], + }, + { + "id": "stream", + "object": "chat.completion.chunk", + "created": 1, + "model": "model", + "choices": [], + "usage": { + "prompt_tokens": 100, + "completion_tokens": completion, + "total_tokens": 100 + completion, + }, + }, + ] + for chunk in chunks: + if model_field == "omitted": + chunk.pop("model") + elif model_field == "null": + chunk["model"] = None + if extra_frame: + earlier = deepcopy(chunks[-1]) + earlier["usage"] = { + "prompt_tokens": 90, + "completion_tokens": 1, + "total_tokens": 91, + } + chunks.insert(1, earlier) + sse = ( + "".join(f"data: {json.dumps(chunk)}\n\n" for chunk in chunks) + + "data: [DONE]\n\n" + ) + transport = httpx.MockTransport( + lambda request: httpx.Response( + 200, text=sse, headers={"content-type": "text/event-stream"} + ) + ) + with httpx.Client( + transport=transport, + event_hooks={"request": [request_hook], "response": [response_hook]}, + ) as client: + async with httpx.AsyncClient( + transport=transport, + event_hooks={ + "request": [async_request_hook], + "response": [async_response_hook], + }, + ) as async_client: + model = ChatOpenAI( + model="configured-fallback", + api_key="scripted-unused", + base_url="https://example.invalid/v1", + http_client=client, + http_async_client=async_client, + metadata=model_metadata("observer"), + stream_usage=True, + ) + recorder = ModelCallUsage("observer") + with recorder.scope(): + if sync: + list(model.stream("task", config={"callbacks": [recorder]})) + else: + async for _ in model.astream( + "task", config={"callbacks": [recorder]} + ): + pass + result = recorder.finish() + if extra_frame: + assert result.input_tokens is None + assert result.extensions[KEY]["observed"]["input_tokens"] == 100 + else: + assert result.input_tokens == 100 + assert result.output_tokens == (completion if completion > 0 else None) + assert result.total_tokens is None + models = result.extensions[KEY].get("models", []) + assert [entry["model"] for entry in models] == ( + ["model"] if model_field == "reported" else [] + ) + + +async def test_late_http_completion_never_moves_to_next_invocation(): + started, release = asyncio.Event(), asyncio.Event() + + async def respond(request): + prompt = json.loads(request.content)["messages"][-1]["content"] + if prompt == "late": + started.set() + await release.wait() + return httpx.Response(200, json=_reply()) + + async with httpx.AsyncClient( + transport=httpx.MockTransport(respond), + event_hooks={ + "request": [async_request_hook], + "response": [async_response_hook], + }, + ) as client: + model = ChatOpenAI( + model="model", + api_key="scripted-unused", + base_url="https://example.invalid/v1", + http_async_client=client, + metadata=model_metadata("observer"), + ) + old_owner = ModelCallUsage("observer") + with old_owner.scope(): + late = asyncio.create_task( + model.ainvoke("late", config={"callbacks": [old_owner]}) + ) + await asyncio.wait_for(started.wait(), timeout=10) + frozen = old_owner.finish().to_mapping() + next_owner = ModelCallUsage("observer") + try: + with next_owner.scope(): + await model.ainvoke("next", config={"callbacks": [next_owner]}) + assert next_owner.finish().input_tokens == 100 + finally: + release.set() + await asyncio.wait_for(late, timeout=10) + assert old_owner.finish().to_mapping() == frozen + assert frozen.get("input_tokens") is None + + +def test_failed_call_partial_response_is_retained_as_an_observation_unit(): + recorder = ModelCallUsage("observer") + identity = uuid4() + recorder.on_chat_model_start( + {"id": ["ChatOpenAI"]}, [], run_id=identity, metadata=model_metadata("observer") + ) + response = LLMResult( + generations=[ + [ + ChatGeneration( + message=AIMessage( + content="partial", + response_metadata={ + "model_name": "model", + "token_usage": { + "prompt_tokens": 100, + "completion_tokens": 2, + "total_tokens": 102, + }, + }, + ) + ) + ] + ] + ) + recorder.on_llm_error( + RuntimeError("stream interrupted"), run_id=identity, response=response + ) + result = recorder.finish() + assert result.input_tokens is None + assert result.extensions[KEY]["observed"]["input_tokens"] == 100 + + +async def test_one_models_retry_does_not_erase_another_models_complete_evidence( + make_model, +): + clean, _ = make_model(served_model="clean") + retried, _ = make_model(served_model="retried", statuses=(503, 200)) + recorder = ModelCallUsage("observer") + with recorder.scope(): + await clean.ainvoke("task", config={"callbacks": [recorder]}) + await retried.ainvoke("task", config={"callbacks": [recorder]}) + usage = recorder.finish() + models = {model["model"]: model for model in usage.extensions[KEY]["models"]} + assert usage.input_tokens is None + assert models["clean"]["reported"]["input_tokens"] == 100 + assert "input_tokens" not in models["retried"]["reported"] + assert models["retried"]["observed"]["input_tokens"] == 100 + + +@pytest.mark.parametrize("sync", [False, True]) +@pytest.mark.parametrize("partial", [False, True]) +async def test_structured_output_length_error_keeps_reported_usage( + make_model, sync, partial +): + from openai import LengthFinishReasonError + from pydantic import BaseModel + + class Answer(BaseModel): + answer: str + + model, requests = make_model( + finish_reason="length", usage={"prompt_tokens": 100} if partial else None + ) + structured = model.with_structured_output(Answer, method="json_schema") + recorder = ModelCallUsage("observer") + with recorder.scope(), pytest.raises(LengthFinishReasonError): + if sync: + structured.invoke("task", config={"callbacks": [recorder]}) + else: + await structured.ainvoke("task", config={"callbacks": [recorder]}) + usage = recorder.finish() + assert usage.input_tokens == 100 + assert usage.output_tokens == (None if partial else 2) + assert usage.total_tokens == (None if partial else 102) + assert len(requests) == 1 + + +def test_unknown_input_convention_does_not_invent_an_inclusive_peak(): + recorder = ModelCallUsage(None) + identity = uuid4() + recorder.on_chat_model_start({}, [], run_id=identity) + recorder.on_llm_end( + LLMResult( + generations=[ + [ + ChatGeneration( + message=AIMessage( + content="done", + response_metadata={"token_usage": {"prompt_tokens": 100}}, + ) + ) + ] + ] + ), + run_id=identity, + ) + observed = recorder.finish().extensions[KEY]["observed"] + assert observed["input_tokens"] == 100 + assert "peak_request_input_tokens" not in observed + + +@pytest.mark.parametrize("sync", [False, True]) +@pytest.mark.parametrize( + ("base_url", "environment", "provider"), + [ + (None, {}, "openai"), + ("https://api.openai.com/v1", {}, "openai"), + ("https://example.invalid/v1", {}, None), + (None, {"OPENAI_BASE_URL": "https://example.invalid/v1"}, None), + (None, {"OPENAI_BASE_URL": "https://api.openai.com/v1"}, "openai"), + (None, {"OPENAI_API_BASE": "https://example.invalid/v1"}, None), + ( + "https://api.openai.com/v1", + {"OPENAI_BASE_URL": "https://example.invalid/v1"}, + "openai", + ), + ( + "https://example.invalid/v1", + {"OPENAI_BASE_URL": "https://api.openai.com/v1"}, + None, + ), + ("https://api.openai.com:8443/v1", {}, None), + ("http://api.openai.com/v1", {}, None), + ], +) +async def test_actual_request_endpoint_controls_accounting_provider( + make_model, monkeypatch, sync, base_url, environment, provider +): + for name in ("OPENAI_BASE_URL", "OPENAI_API_BASE"): + monkeypatch.delenv(name, raising=False) + for name, value in environment.items(): + monkeypatch.setenv(name, value) + model, requests = make_model(base_url=base_url, served_model="literal-served-model") + recorder = ModelCallUsage("observer") + with recorder.scope(): + if sync: + model.invoke("task", config={"callbacks": [recorder]}) + else: + await model.ainvoke("task", config={"callbacks": [recorder]}) + usage = recorder.finish() + assert len(requests) == 1 + assert usage.input_tokens == 100 + assert usage.output_tokens == 2 + assert usage.total_tokens == 102 + accounted = usage.extensions[KEY]["models"] + assert len(accounted) == 1 + assert accounted[0]["model"] == "literal-served-model" + assert accounted[0].get("provider") == provider + + +async def test_parallel_calls_keep_independent_native_and_custom_routes(make_model): + native, _ = make_model(base_url="https://api.openai.com/v1", served_model="native") + custom, _ = make_model(base_url="https://example.invalid/v1", served_model="custom") + recorder = ModelCallUsage("observer") + with recorder.scope(): + await asyncio.gather( + native.ainvoke("one", config={"callbacks": [recorder]}), + custom.ainvoke("two", config={"callbacks": [recorder]}), + ) + result = recorder.finish() + models = {model["model"]: model for model in result.extensions[KEY]["models"]} + assert result.input_tokens == 200 + assert result.output_tokens == 4 + assert models["native"]["provider"] == "openai" + assert "provider" not in models["custom"] diff --git a/tests/adapters/test_deepagents_usage_descriptor.py b/tests/adapters/test_deepagents_usage_descriptor.py new file mode 100644 index 000000000..8767236aa --- /dev/null +++ b/tests/adapters/test_deepagents_usage_descriptor.py @@ -0,0 +1,88 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""The packaged descriptor accepts the collector's shared usage evidence.""" + +import json +import importlib.util +from pathlib import Path + +from jsonschema import Draft202012Validator +from nemo_fabric_adapter_contract.models import ReportedUsage +from nemo_fabric_adapter_contract.usage import UsageObservation, usage_from_observations + + +def test_deepagents_usage_declaration_matches_packaged_copy(): + root = Path(__file__).resolve().parents[2] + canonical = root / "adapters/python/deepagents/deepagents.fabric-adapter.json" + packaged = ( + root + / "crates/fabric-cli/assets/adapters/deepagents/deepagents.fabric-adapter.json" + ) + assert canonical.read_bytes() == packaged.read_bytes() + schema = json.loads(canonical.read_text())["extension_schemas"]["usage"] + canonical_schema = json.loads( + (root / "schemas/extensions/usage-accounting.schema.json").read_text() + ) + definitions = canonical_schema.pop("$defs") + canonical_schema.pop("$schema") + assert schema["properties"]["nemo_fabric.usage_accounting.v1"] == canonical_schema + assert schema["$defs"] == definitions + Draft202012Validator.check_schema(schema) + usage = usage_from_observations( + [ + UsageObservation( + reported=ReportedUsage( + input_tokens=100, + input_tokens_include_cache=True, + cached_input_tokens=0, + output_tokens=2, + output_tokens_include_reasoning=True, + total_tokens=102, + ), + provider="openai", + model="reported-model", + tokens_aligned=True, + is_model_call=True, + ) + ] + ) + Draft202012Validator(schema).validate(usage.extensions) + + +def test_usage_schema_generator_removes_obsolete_definitions(tmp_path, monkeypatch): + root = Path(__file__).resolve().parents[2] + source = root / "scripts/contracts/generate_usage_accounting.py" + spec = importlib.util.spec_from_file_location("generate_usage_accounting", source) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + monkeypatch.setattr(module, "ROOT", tmp_path) + + schema = json.loads( + (root / "schemas/extensions/usage-accounting.schema.json").read_text() + ) + canonical = tmp_path / "schemas/extensions/usage-accounting.schema.json" + canonical.parent.mkdir(parents=True) + canonical.write_text(json.dumps(schema)) + + descriptor = tmp_path / "adapters/python/example/example.fabric-adapter.json" + descriptor.parent.mkdir(parents=True) + descriptor.write_text( + json.dumps( + { + "extension_schemas": { + "usage": { + "$defs": {**schema["$defs"], "Obsolete": {"type": "string"}}, + "properties": {module.KEY: {}, "other": {"type": "object"}}, + } + } + }, + indent=2, + ) + ) + + module.synchronize(check=False) + usage = json.loads(descriptor.read_text())["extension_schemas"]["usage"] + assert usage["$defs"] == schema["$defs"] + assert usage["properties"]["other"] == {"type": "object"} diff --git a/tests/docs/test_python_api_docs.py b/tests/docs/test_python_api_docs.py index 76ce40e7f..3df0b295b 100644 --- a/tests/docs/test_python_api_docs.py +++ b/tests/docs/test_python_api_docs.py @@ -24,6 +24,7 @@ signature, ) from pathlib import Path +from typing import get_type_hints, is_typeddict import nemo_fabric from pydantic import BaseModel @@ -36,6 +37,7 @@ QUICKSTART_PAGE = ROOT / "docs" / "getting-started" / "quickstart.mdx" NAVIGATION = ROOT / "docs" / "index.yml" MODULE_SLUGS = { + "nemo_fabric.accounting": "/reference/api/python-library-reference/accounting", "nemo_fabric.client": "/reference/api/python-library-reference/client", "nemo_fabric.capabilities": "/reference/api/python-library-reference/capabilities", "nemo_fabric.check": "/reference/api/python-library-reference/check", @@ -54,6 +56,11 @@ r"```python\n(.*?)\n```", flags=re.MULTILINE | re.DOTALL, ) +FUNCTION_SIGNATURE = re.compile( + r"^## function `([^`]+)`\n\n" + r"```python\n(.*?)\n```", + flags=re.MULTILINE | re.DOTALL, +) MODEL_FIELD_ROW = re.compile( r"^\| `(?P[^`]+)` \| `(?P[^`]*)` \| " r"(?PYes|No) \| (?P—|`[^`]*`) \| " @@ -75,6 +82,15 @@ def _exported_classes_by_module() -> dict[str, set[str]]: return dict(exports) +def _exported_functions_by_module() -> dict[str, set[str]]: + exports: dict[str, set[str]] = defaultdict(set) + for name in nemo_fabric.__all__: + exported = getattr(nemo_fabric, name) + if isfunction(exported): + exports[exported.__module__].add(name) + return dict(exports) + + def _documented_classes(page: Path) -> set[str]: return set( re.findall( @@ -150,7 +166,8 @@ def _documented_method_names(exported_class: type[object]) -> list[str]: def test_python_reference_exactly_covers_exported_sdk_classes() -> None: exports = _exported_classes_by_module() - expected_pages = {f"{module}.md" for module in exports} + functions = _exported_functions_by_module() + expected_pages = {f"{module}.md" for module in exports.keys() | functions.keys()} actual_pages = {page.name for page in REFERENCE_DIR.glob("nemo_fabric.*.md")} assert actual_pages == expected_pages @@ -158,6 +175,13 @@ def test_python_reference_exactly_covers_exported_sdk_classes() -> None: page = REFERENCE_DIR / f"{module}.md" assert _documented_classes(page) == names assert f'slug: "{MODULE_SLUGS[module]}"' in page.read_text(encoding="utf-8") + for module, names in functions.items(): + page = REFERENCE_DIR / f"{module}.md" + documented = { + match.group(1) for match in FUNCTION_SIGNATURE.finditer(page.read_text(encoding="utf-8")) + } + assert names <= documented + assert f'slug: "{MODULE_SLUGS[module]}"' in page.read_text(encoding="utf-8") def test_exported_sdk_classes_and_public_members_have_docstrings() -> None: @@ -246,6 +270,19 @@ def test_generated_signatures_preserve_python_callable_semantics(): if has_keyword_only and not has_var_positional: assert re.search(r"(?:\(\*,|, \*,|\n \*,)", rendered) + for module_name, function_names in _exported_functions_by_module().items(): + module = importlib.import_module(module_name) + page = REFERENCE_DIR / f"{module_name}.md" + rendered = { + match.group(1): match.group(2) + for match in FUNCTION_SIGNATURE.finditer(page.read_text(encoding="utf-8")) + } + for function_name in function_names: + function = getattr(module, function_name) + prefix = "async def " if iscoroutinefunction(function) else "def " + expected = f"{prefix}{function_name}{signature(function)}" + assert _definition_ast(rendered[function_name]) == _definition_ast(expected) + assert signature_count > 0 assert async_count > 0 @@ -311,6 +348,27 @@ def test_generated_reference_documents_typed_mapping_fields(): assert "from_mapping(mapping: Mapping[str, Any]) -> Self" in section +def test_accounting_reference_documents_typed_summary_fields(): + module = importlib.import_module("nemo_fabric.accounting") + page = REFERENCE_DIR / "nemo_fabric.accounting.md" + summary_types = { + name: summary_type + for name, summary_type in getmembers(module, is_typeddict) + if summary_type.__module__ == module.__name__ + } + assert get_type_hints(module.summarize_usage)["return"] in summary_types.values() + for name, summary_type in summary_types.items(): + section = _class_section(page, name) + rows = { + match.group("name"): match.group("annotation") + for match in TYPED_FIELD_ROW.finditer(section) + } + annotations = get_type_hints(summary_type, include_extras=True) + assert set(rows) == set(annotations) + for field_name, annotation in annotations.items(): + assert rows[field_name] == _annotation_text(annotation) + + def test_generated_reference_documents_error_bases_and_enum_values(): for export_name in nemo_fabric.__all__: exported = getattr(nemo_fabric, export_name) diff --git a/tests/fixtures/usage_accounting/conformance.json b/tests/fixtures/usage_accounting/conformance.json new file mode 100644 index 000000000..b5e026d61 --- /dev/null +++ b/tests/fixtures/usage_accounting/conformance.json @@ -0,0 +1,668 @@ +[ + { + "name": "reported_zero", + "usage": { + "input_tokens": 0, + "input_tokens_include_cache": true + }, + "accounting": { + "models_exhaustive": false, + "gaps": [] + }, + "expected": { + "input_tokens": { + "complete": 0, + "observed": 0 + } + } + }, + { + "name": "nonexhaustive_models_do_not_certify_aggregate", + "usage": {}, + "accounting": { + "models_exhaustive": false, + "gaps": [], + "models": [ + { + "model": "a", + "gaps": [], + "reported": { + "input_tokens": 100, + "input_tokens_include_cache": true + } + } + ] + }, + "expected": { + "input_tokens": { + "complete": null, + "observed": 100 + } + } + }, + { + "name": "contradictory_aggregate", + "usage": { + "input_tokens": 100, + "input_tokens_include_cache": true + }, + "accounting": { + "models_exhaustive": false, + "gaps": [], + "models": [ + { + "model": "a", + "gaps": [], + "reported": { + "input_tokens": 120, + "input_tokens_include_cache": true + } + } + ] + }, + "expected": { + "input_tokens": { + "complete": null, + "observed": null + } + } + }, + { + "name": "preserve_model_observations", + "usage": {}, + "accounting": { + "models_exhaustive": true, + "gaps": [], + "models": [ + { + "model": "a", + "gaps": [], + "reported": { + "input_tokens": 100, + "input_tokens_include_cache": true + } + }, + { + "model": "b", + "gaps": [ + { + "dimensions": [ + "input_tokens" + ], + "reason": "missing_usage" + } + ], + "observed": { + "input_tokens": 50, + "input_tokens_include_cache": true + } + } + ] + }, + "expected": { + "input_tokens": { + "complete": null, + "observed": 150 + } + } + }, + { + "name": "root_observation_not_added_to_models", + "usage": {}, + "accounting": { + "models_exhaustive": false, + "gaps": [], + "observed": { + "input_tokens": 100, + "input_tokens_include_cache": true + }, + "models": [ + { + "model": "a", + "gaps": [], + "observed": { + "input_tokens": 50, + "input_tokens_include_cache": true + } + } + ] + }, + "expected": { + "input_tokens": { + "complete": null, + "observed": 100 + } + } + }, + { + "name": "independent_cache_observation", + "usage": { + "cached_input_tokens": 5 + }, + "accounting": { + "models_exhaustive": false, + "gaps": [] + }, + "expected": { + "input_tokens": { + "complete": null, + "observed": null + }, + "cached_input_tokens": { + "complete": 5, + "observed": 5 + } + } + }, + { + "name": "raw_observation_scope_is_not_guessed", + "usage": {}, + "accounting": { + "models_exhaustive": false, + "gaps": [], + "observed": { + "input_tokens": 100, + "cached_input_tokens": 50, + "cache_write_input_tokens": 0, + "input_tokens_include_cache": false + } + }, + "expected": { + "input_tokens": { + "complete": null, + "observed": null + }, + "cached_input_tokens": { + "complete": null, + "observed": 50 + } + } + }, + { + "name": "normalize_all_independent_dimensions", + "usage": { + "input_tokens": 3, + "extensions": { + "nemo_fabric.detailed_token_usage.v1": { + "input_tokens": 20, + "cached_input_tokens": 5, + "cache_write_input_tokens": 3, + "input_tokens_include_cache": false, + "output_tokens": 5, + "reasoning_tokens": 4, + "output_tokens_include_reasoning": false, + "peak_request_input_tokens": 18 + } + } + }, + "accounting": { + "models_exhaustive": false, + "gaps": [] + }, + "expected": { + "input_tokens": { + "complete": 28, + "observed": 28 + }, + "cached_input_tokens": { + "complete": 5, + "observed": 5 + }, + "cache_write_input_tokens": { + "complete": 3, + "observed": 3 + }, + "output_tokens": { + "complete": 9, + "observed": 9 + }, + "reasoning_tokens": { + "complete": 4, + "observed": 4 + }, + "total_tokens": { + "complete": 37, + "observed": 37 + }, + "peak_request_input_tokens": { + "complete": 18, + "observed": 18 + } + } + }, + { + "name": "inclusive_input_cannot_be_smaller_than_its_cache_component", + "usage": { + "input_tokens": 100, + "cached_input_tokens": 120, + "input_tokens_include_cache": true, + "extensions": { + "nemo_fabric.detailed_token_usage.v1": { + "output_tokens": 5, + "output_tokens_include_reasoning": true + } + } + }, + "accounting": { + "models_exhaustive": false, + "gaps": [] + }, + "expected": { + "input_tokens": { + "complete": null, + "observed": null + }, + "cached_input_tokens": { + "complete": 120, + "observed": 120 + }, + "output_tokens": { + "complete": 5, + "observed": 5 + }, + "total_tokens": { + "complete": null, + "observed": null + } + } + }, + { + "name": "reasoning_cannot_exceed_complete_output", + "usage": { + "extensions": { + "nemo_fabric.detailed_token_usage.v1": { + "output_tokens": 5, + "reasoning_tokens": 6, + "output_tokens_include_reasoning": true + } + } + }, + "accounting": { + "models_exhaustive": false, + "gaps": [] + }, + "expected": { + "output_tokens": { + "complete": null, + "observed": null + }, + "reasoning_tokens": { + "complete": 6, + "observed": 6 + } + } + }, + { + "name": "peak_cannot_exceed_complete_input", + "usage": { + "input_tokens": 100, + "input_tokens_include_cache": true, + "extensions": { + "nemo_fabric.detailed_token_usage.v1": { + "peak_request_input_tokens": 101 + } + } + }, + "accounting": { + "models_exhaustive": false, + "gaps": [], + "observed": { + "input_tokens": 100, + "input_tokens_include_cache": true + } + }, + "expected": { + "input_tokens": { + "complete": null, + "observed": null + }, + "peak_request_input_tokens": { + "complete": 101, + "observed": 101 + } + } + }, + { + "name": "inconsistent_model_components_do_not_certify_aggregate", + "usage": {}, + "accounting": { + "models_exhaustive": true, + "gaps": [], + "models": [ + { + "model": "a", + "reported": { + "input_tokens": 5, + "cached_input_tokens": 6, + "input_tokens_include_cache": true + }, + "observed": { + "input_tokens": 5, + "input_tokens_include_cache": true + }, + "gaps": [] + } + ] + }, + "expected": { + "input_tokens": { + "complete": null, + "observed": null + }, + "cached_input_tokens": { + "complete": 6, + "observed": 6 + } + } + }, + { + "name": "aligned_exclusive_input_observations_can_be_normalized", + "usage": {}, + "accounting": { + "models_exhaustive": false, + "gaps": [], + "models": [ + { + "model": "a", + "gaps": [], + "observed_tokens_aligned": true, + "observed": { + "input_tokens": 100, + "cached_input_tokens": 20, + "cache_write_input_tokens": 0, + "input_tokens_include_cache": false, + "output_tokens": 5, + "output_tokens_include_reasoning": true, + "peak_request_input_tokens": 120 + } + } + ] + }, + "expected": { + "input_tokens": { + "complete": null, + "observed": 120 + }, + "cached_input_tokens": { + "complete": null, + "observed": 20 + }, + "output_tokens": { + "complete": null, + "observed": 5 + }, + "total_tokens": { + "complete": null, + "observed": 125 + } + } + }, + { + "name": "unconvertible_aggregate_keeps_normalizable_model_subset", + "usage": {}, + "accounting": { + "models_exhaustive": false, + "gaps": [ + { + "dimensions": [ + "input_tokens", + "output_tokens" + ], + "reason": "scope_unverified" + } + ], + "observed": { + "input_tokens": 30, + "input_tokens_include_cache": false, + "output_tokens": 3, + "output_tokens_include_reasoning": true + }, + "models": [ + { + "model": "root-model", + "reported": { + "input_tokens": 20, + "input_tokens_include_cache": false, + "cached_input_tokens": 0, + "cache_write_input_tokens": 0, + "output_tokens": 2, + "output_tokens_include_reasoning": true + }, + "gaps": [] + } + ] + }, + "expected": { + "input_tokens": { + "complete": null, + "observed": 20 + }, + "output_tokens": { + "complete": null, + "observed": 3 + }, + "total_tokens": { + "complete": null, + "observed": 23 + } + } + }, + { + "name": "unconvertible_model_observation_keeps_normalizable_request_subset", + "usage": {}, + "accounting": { + "models_exhaustive": false, + "gaps": [], + "models": [ + { + "model": "root-model", + "reported": {}, + "gaps": [], + "observed": { + "input_tokens": 30, + "input_tokens_include_cache": false + }, + "observed_tokens_aligned": false, + "requests_exhaustive": false, + "requests": [ + { + "usage": { + "input_tokens": 20, + "input_tokens_include_cache": false, + "cached_input_tokens": 0, + "cache_write_input_tokens": 0 + }, + "tokens_aligned": true + } + ] + } + ] + }, + "expected": { + "input_tokens": { + "complete": null, + "observed": 20 + } + } + }, + { + "name": "aggregate_recovered_cache_cannot_exceed_complete_input", + "usage": { + "input_tokens": 100, + "input_tokens_include_cache": true + }, + "accounting": { + "models_exhaustive": true, + "gaps": [], + "models": [ + { + "model": "cache-model", + "gaps": [], + "reported": { + "cached_input_tokens": 200, + "cache_write_input_tokens": 0, + "output_tokens": 5, + "output_tokens_include_reasoning": true + } + } + ] + }, + "expected": { + "input_tokens": { + "complete": null, + "observed": null + }, + "cached_input_tokens": { + "complete": 200, + "observed": 200 + }, + "cache_write_input_tokens": { + "complete": 0, + "observed": 0 + }, + "output_tokens": { + "complete": 5, + "observed": 5 + }, + "total_tokens": { + "complete": null, + "observed": null + } + } + }, + { + "name": "model_recovered_request_cache_cannot_exceed_complete_input", + "usage": {}, + "accounting": { + "models_exhaustive": true, + "gaps": [], + "models": [ + { + "model": "cache-model", + "gaps": [], + "reported": { + "input_tokens": 100, + "input_tokens_include_cache": true, + "output_tokens": 5, + "output_tokens_include_reasoning": true + }, + "requests_exhaustive": true, + "requests": [ + { + "usage": { + "cached_input_tokens": 200, + "cache_write_input_tokens": 0 + } + } + ] + } + ] + }, + "expected": { + "input_tokens": { + "complete": null, + "observed": null + }, + "cached_input_tokens": { + "complete": 200, + "observed": 200 + }, + "cache_write_input_tokens": { + "complete": 0, + "observed": 0 + }, + "output_tokens": { + "complete": 5, + "observed": 5 + }, + "total_tokens": { + "complete": null, + "observed": null + } + } + }, + { + "name": "model_recovered_request_peak_cannot_exceed_complete_input", + "usage": {}, + "accounting": { + "models_exhaustive": true, + "gaps": [], + "models": [ + { + "model": "peak-model", + "gaps": [], + "reported": { + "input_tokens": 100, + "input_tokens_include_cache": true, + "output_tokens": 5, + "output_tokens_include_reasoning": true + }, + "requests_exhaustive": true, + "requests": [ + { + "usage": { + "peak_request_input_tokens": 200 + } + } + ] + } + ] + }, + "expected": { + "input_tokens": { + "complete": null, + "observed": null + }, + "output_tokens": { + "complete": 5, + "observed": 5 + }, + "total_tokens": { + "complete": null, + "observed": null + } + } + }, + { + "name": "model_recovered_request_reasoning_cannot_exceed_complete_output", + "usage": {}, + "accounting": { + "models_exhaustive": true, + "gaps": [], + "models": [ + { + "model": "reasoning-model", + "gaps": [], + "reported": { + "input_tokens": 100, + "input_tokens_include_cache": true, + "output_tokens": 5, + "output_tokens_include_reasoning": true + }, + "requests_exhaustive": true, + "requests": [ + { + "usage": { + "reasoning_tokens": 10 + } + } + ] + } + ] + }, + "expected": { + "input_tokens": { + "complete": 100, + "observed": 100 + }, + "output_tokens": { + "complete": null, + "observed": null + }, + "total_tokens": { + "complete": null, + "observed": null + } + } + } +] diff --git a/tests/fixtures/usage_accounting/reducer-cases.json b/tests/fixtures/usage_accounting/reducer-cases.json new file mode 100644 index 000000000..39b4c4959 --- /dev/null +++ b/tests/fixtures/usage_accounting/reducer-cases.json @@ -0,0 +1,487 @@ +[ + { + "name": "empty_observations", + "records": [], + "expected": { + "input_tokens": null, + "output_tokens": null, + "exhaustive": false, + "gap_reason": "scope_unverified" + } + }, + { + "name": "complete_calls", + "records": [ + { + "model": "fixture", + "provider": "fixture", + "tokens_aligned": true, + "reported": { + "input_tokens": 100, + "cached_input_tokens": 10, + "input_tokens_include_cache": true, + "output_tokens": 2, + "output_tokens_include_reasoning": true, + "total_tokens": 102, + "peak_request_input_tokens": 100 + }, + "is_model_call": true + }, + { + "model": "fixture", + "provider": "fixture", + "tokens_aligned": true, + "reported": { + "input_tokens": 200, + "cached_input_tokens": 20, + "input_tokens_include_cache": true, + "output_tokens": 3, + "output_tokens_include_reasoning": true, + "total_tokens": 203, + "peak_request_input_tokens": 200 + }, + "is_model_call": true + } + ], + "expected": { + "input_tokens": 300, + "cached_input_tokens": 30, + "output_tokens": 5, + "total_tokens": 305, + "peak_request_input_tokens": 200, + "observed_input": 300, + "observed_output": 5, + "exhaustive": true + } + }, + { + "name": "partial_output_independent_total", + "records": [ + { + "model": "fixture", + "provider": "fixture", + "tokens_aligned": true, + "reported": { + "input_tokens": 100, + "cached_input_tokens": 10, + "input_tokens_include_cache": true, + "output_tokens": 2, + "output_tokens_include_reasoning": true, + "total_tokens": 102, + "peak_request_input_tokens": 100 + }, + "is_model_call": true + }, + { + "model": "fixture", + "provider": "fixture", + "tokens_aligned": true, + "reported": { + "input_tokens": 200, + "cached_input_tokens": 20, + "input_tokens_include_cache": true, + "total_tokens": 203, + "peak_request_input_tokens": 200 + }, + "is_model_call": true + } + ], + "expected": { + "input_tokens": 300, + "cached_input_tokens": 30, + "output_tokens": null, + "total_tokens": 305, + "observed_input": 300, + "observed_output": 2, + "exhaustive": true + } + }, + { + "name": "explicit_zero", + "records": [ + { + "model": "fixture", + "provider": "fixture", + "tokens_aligned": true, + "reported": { + "input_tokens": 0, + "cached_input_tokens": 0, + "input_tokens_include_cache": true, + "output_tokens": 0, + "output_tokens_include_reasoning": true, + "total_tokens": 0, + "peak_request_input_tokens": 0 + }, + "is_model_call": true + } + ], + "expected": { + "input_tokens": 0, + "cached_input_tokens": 0, + "output_tokens": 0, + "total_tokens": 0, + "observed_input": 0, + "observed_output": 0, + "exhaustive": true + } + }, + { + "name": "missing_attempt", + "records": [ + { + "model": "fixture", + "provider": "fixture", + "tokens_aligned": true, + "reported": { + "input_tokens": 100, + "cached_input_tokens": 10, + "input_tokens_include_cache": true, + "output_tokens": 2, + "output_tokens_include_reasoning": true, + "total_tokens": 102, + "peak_request_input_tokens": 100 + }, + "gap": "missing_usage", + "is_model_call": true + } + ], + "expected": { + "input_tokens": null, + "output_tokens": null, + "total_tokens": null, + "observed_input": 100, + "observed_output": 2, + "exhaustive": false + } + }, + { + "name": "unfinished_work", + "records": [ + { + "model": "fixture", + "provider": "fixture", + "tokens_aligned": true, + "reported": { + "input_tokens": 100, + "cached_input_tokens": 10, + "input_tokens_include_cache": true, + "output_tokens": 2, + "output_tokens_include_reasoning": true, + "total_tokens": 102, + "peak_request_input_tokens": 100 + } + }, + { + "reported": {}, + "gap": "unfinished_work" + } + ], + "expected": { + "input_tokens": null, + "output_tokens": null, + "observed_input": 100, + "observed_output": 2, + "exhaustive": false + } + }, + { + "name": "mixed_input_conventions", + "records": [ + { + "model": "fixture", + "provider": "fixture", + "tokens_aligned": true, + "reported": { + "input_tokens": 100, + "cached_input_tokens": 10, + "input_tokens_include_cache": true, + "output_tokens": 2, + "output_tokens_include_reasoning": true, + "total_tokens": 102, + "peak_request_input_tokens": 100 + } + }, + { + "model": "fixture", + "provider": "fixture", + "tokens_aligned": true, + "reported": { + "input_tokens": 100, + "cached_input_tokens": 10, + "input_tokens_include_cache": false, + "output_tokens": 2, + "output_tokens_include_reasoning": true, + "total_tokens": 102, + "peak_request_input_tokens": 100 + } + } + ], + "expected": { + "input_tokens": null, + "cached_input_tokens": 20, + "output_tokens": 4, + "total_tokens": 204, + "observed_input": null, + "observed_output": 4, + "exhaustive": true + } + }, + { + "name": "independent_cache", + "records": [ + { + "model": "fixture", + "provider": "fixture", + "tokens_aligned": true, + "reported": { + "cached_input_tokens": 7, + "output_tokens": 2, + "output_tokens_include_reasoning": true + } + } + ], + "expected": { + "input_tokens": null, + "cached_input_tokens": 7, + "output_tokens": 2, + "total_tokens": null, + "observed_input": null, + "observed_output": 2, + "exhaustive": true + } + }, + { + "name": "empty_coverage", + "records": [], + "expected": { + "input_tokens": null, + "output_tokens": null, + "total_tokens": null, + "observed_input": null, + "observed_output": null, + "exhaustive": false + } + }, + { + "name": "reported_cost", + "records": [ + { + "model": "fixture", + "provider": "fixture", + "tokens_aligned": true, + "reported": { + "cost_usd": 1.25 + } + }, + { + "model": "fixture", + "provider": "fixture", + "tokens_aligned": true, + "reported": { + "cost_usd": 2.5 + } + } + ], + "expected": { + "input_tokens": null, + "cost_usd": 3.75, + "observed_input": null, + "observed_output": null, + "exhaustive": true + } + }, + { + "name": "mixed_actual_call_conventions", + "records": [ + { + "model": "fixture", + "provider": "fixture", + "tokens_aligned": true, + "reported": { + "input_tokens": 100, + "cached_input_tokens": 10, + "input_tokens_include_cache": true, + "output_tokens": 2, + "output_tokens_include_reasoning": true, + "total_tokens": 102, + "peak_request_input_tokens": 100, + "cache_write_input_tokens": 0 + }, + "is_model_call": true + }, + { + "model": "fixture", + "provider": "fixture", + "tokens_aligned": true, + "reported": { + "input_tokens": 100, + "cached_input_tokens": 10, + "input_tokens_include_cache": false, + "output_tokens": 2, + "output_tokens_include_reasoning": true, + "total_tokens": 102, + "peak_request_input_tokens": 110, + "cache_write_input_tokens": 0 + }, + "is_model_call": true + } + ], + "expected": { + "input_tokens": null, + "cached_input_tokens": 20, + "output_tokens": 4, + "total_tokens": 204, + "observed_input": null, + "observed_output": 4, + "exhaustive": true + } + }, + { + "name": "one_raw_unknown_convention", + "records": [ + { + "reported": { + "input_tokens": 40, + "output_tokens": 8 + }, + "gap": "scope_unverified" + } + ], + "expected": { + "input_tokens": null, + "output_tokens": null, + "observed_input": 40, + "observed_output": 8, + "exhaustive": false + } + }, + { + "name": "scoped_observation_does_not_label_complete_input", + "records": [ + { + "model": "fixture", + "provider": "fixture", + "tokens_aligned": true, + "is_model_call": true, + "gap": "scope_unverified", + "reported": { + "input_tokens": 100, + "input_tokens_include_cache": true, + "output_tokens": 2, + "output_tokens_include_reasoning": true + } + } + ], + "expected": { + "input_tokens": null, + "input_tokens_include_cache": null, + "output_tokens": null, + "observed_input": 100, + "observed_output": 2, + "exhaustive": false + } + }, + { + "name": "dimension_gap_keeps_independent_counts", + "records": [ + { + "reported": { + "input_tokens": 100, + "cached_input_tokens": 20, + "cache_write_input_tokens": 0, + "input_tokens_include_cache": true, + "output_tokens": 5, + "output_tokens_include_reasoning": true + }, + "model": "model", + "provider": "openai", + "gaps": [ + { + "dimensions": [ + "cached_input_tokens" + ], + "reason": "scope_unverified" + } + ] + } + ], + "expected": { + "input_tokens": 100, + "cached_input_tokens": null, + "output_tokens": 5, + "observed_input": 100, + "exhaustive": true + } + }, + { + "name": "actual_call_with_scoped_cache_gap", + "records": [ + { + "reported": { + "input_tokens": 100, + "cached_input_tokens": 20, + "cache_write_input_tokens": 0, + "input_tokens_include_cache": true, + "output_tokens": 5, + "output_tokens_include_reasoning": true, + "peak_request_input_tokens": 100 + }, + "model": "model", + "provider": "openai", + "tokens_aligned": true, + "is_model_call": true, + "gaps": [ + { + "dimensions": [ + "cached_input_tokens" + ], + "reason": "scope_unverified" + } + ] + } + ], + "expected": { + "input_tokens": 100, + "cached_input_tokens": null, + "output_tokens": 5, + "observed_input": 100, + "exhaustive": true + } + }, + { + "name": "reported_cost_keeps_fractional_sum_precision", + "records": [ + { + "model": "fixture", + "provider": "fixture", + "tokens_aligned": true, + "reported": { + "cost_usd": 0.1 + } + }, + { + "model": "fixture", + "provider": "fixture", + "tokens_aligned": true, + "reported": { + "cost_usd": 0.2000000005 + } + }, + { + "model": "fixture", + "provider": "fixture", + "tokens_aligned": true, + "reported": { + "cost_usd": 0.1 + } + } + ], + "expected": { + "input_tokens": null, + "cost_usd": 0.4000000005, + "observed_input": null, + "observed_output": null, + "exhaustive": true + } + } +] diff --git a/tests/fixtures/what_ran/reader-vectors.json b/tests/fixtures/what_ran/reader-vectors.json new file mode 100644 index 000000000..8866d76fe --- /dev/null +++ b/tests/fixtures/what_ran/reader-vectors.json @@ -0,0 +1,1023 @@ +{ + "description": "Shared what_ran reader vectors. Each case applies its patch to base: a path with value sets that member (an empty path replaces the whole object), and a path with remove deletes it. Rust and Python readers keep every valid case and read every invalid case as absent; the generated run-result schema accepts and rejects the same cases.", + "base": { + "schema": "nemo.fabric.what_ran", + "version": "v1alpha1", + "fabric": { + "core_version": { + "value": "0.6.0", + "source": "observed", + "collected_by": "host_core", + "method": "crate_version" + }, + "host_core_version": { + "state": "not_applicable", + "reason": "local_run", + "source": "observed", + "collected_by": "host_core", + "method": "crate_version" + } + }, + "adapter": { + "id": "nvidia.fabric.claude", + "package_version": { + "state": "unsupported", + "reason": "key_not_declared", + "source": "reported", + "collected_by": "adapter", + "method": "adapter_report" + } + }, + "harness": { + "name": { + "state": "unsupported", + "reason": "key_not_declared", + "source": "reported", + "collected_by": "adapter", + "method": "adapter_report" + }, + "version": { + "state": "unsupported", + "reason": "key_not_declared", + "source": "reported", + "collected_by": "adapter", + "method": "adapter_report" + } + }, + "models": { + "default": { + "provider": "anthropic", + "requested": { + "value": "claude-sonnet-4-5", + "source": "configured", + "collected_by": "host_core", + "method": "resolved_plan" + }, + "served": { + "state": "unsupported", + "reason": "key_not_declared", + "source": "reported", + "collected_by": "adapter", + "method": "adapter_report" + } + } + }, + "mcp": { + "servers": { + "docs": { + "origin": "plan", + "transport": "streamable-http", + "endpoint": { + "value": "https://docs.example.com:443", + "source": "configured", + "collected_by": "host_core", + "method": "resolved_plan" + }, + "runtime": { + "connected": { + "state": "unsupported", + "reason": "key_not_declared", + "source": "reported", + "collected_by": "adapter", + "method": "adapter_report" + }, + "protocol_version": { + "state": "unsupported", + "reason": "key_not_declared", + "source": "reported", + "collected_by": "adapter", + "method": "adapter_report" + }, + "server_name": { + "state": "unsupported", + "reason": "key_not_declared", + "source": "reported", + "collected_by": "adapter", + "method": "adapter_report" + }, + "server_version": { + "state": "unsupported", + "reason": "key_not_declared", + "source": "reported", + "collected_by": "adapter", + "method": "adapter_report" + }, + "tool_names": { + "state": "unsupported", + "reason": "key_not_declared", + "source": "reported", + "collected_by": "adapter", + "method": "adapter_report" + } + } + }, + "files": { + "origin": "plan", + "transport": "stdio", + "endpoint": { + "value": "files-mcp", + "source": "configured", + "collected_by": "host_core", + "method": "resolved_plan" + }, + "runtime": { + "connected": { + "state": "unsupported", + "reason": "key_not_declared", + "source": "reported", + "collected_by": "adapter", + "method": "adapter_report" + }, + "protocol_version": { + "state": "unsupported", + "reason": "key_not_declared", + "source": "reported", + "collected_by": "adapter", + "method": "adapter_report" + }, + "server_name": { + "state": "unsupported", + "reason": "key_not_declared", + "source": "reported", + "collected_by": "adapter", + "method": "adapter_report" + }, + "server_version": { + "state": "unsupported", + "reason": "key_not_declared", + "source": "reported", + "collected_by": "adapter", + "method": "adapter_report" + }, + "tool_names": { + "state": "unsupported", + "reason": "key_not_declared", + "source": "reported", + "collected_by": "adapter", + "method": "adapter_report" + } + } + } + } + }, + "environment": { + "provider": "local" + } + }, + "valid": [ + { + "name": "local run as core collects it", + "patch": [] + }, + { + "name": "known values of every value type", + "patch": [ + { + "path": [ + "harness", + "version" + ], + "value": { + "value": "2.1.0", + "source": "reported", + "collected_by": "adapter", + "method": "harness_init_message" + } + }, + { + "path": [ + "mcp", + "servers", + "docs", + "runtime", + "connected" + ], + "value": { + "value": true, + "source": "reported", + "collected_by": "adapter", + "method": "mcp_initialize" + } + }, + { + "path": [ + "mcp", + "servers", + "docs", + "runtime", + "tool_names" + ], + "value": { + "value": [ + "search", + "fetch" + ], + "source": "reported", + "collected_by": "adapter", + "method": "mcp_initialize" + } + } + ] + }, + { + "name": "task core result relayed by an OpenShell host", + "patch": [ + { + "path": [ + "fabric", + "core_version", + "collected_by" + ], + "value": "task_core" + }, + { + "path": [ + "fabric", + "host_core_version" + ], + "value": { + "value": "0.6.0", + "source": "observed", + "collected_by": "host_core", + "method": "crate_version" + } + }, + { + "path": [ + "environment", + "provider" + ], + "value": "openshell" + } + ] + }, + { + "name": "harness-origin MCP entry without a transport", + "patch": [ + { + "path": [ + "mcp", + "servers", + "plugin" + ], + "value": { + "origin": "harness", + "endpoint": { + "state": "not_applicable", + "reason": "not_configured", + "source": "configured", + "collected_by": "host_core", + "method": "resolved_plan" + }, + "runtime": { + "connected": { + "value": true, + "source": "reported", + "collected_by": "adapter", + "method": "harness_status" + }, + "protocol_version": { + "state": "unavailable", + "reason": "not_collected", + "source": "reported", + "collected_by": "adapter", + "method": "harness_status" + }, + "server_name": { + "state": "unavailable", + "reason": "not_collected", + "source": "reported", + "collected_by": "adapter", + "method": "harness_status" + }, + "server_version": { + "state": "unavailable", + "reason": "not_collected", + "source": "reported", + "collected_by": "adapter", + "method": "harness_status" + }, + "tool_names": { + "state": "unavailable", + "reason": "not_collected", + "source": "reported", + "collected_by": "adapter", + "method": "harness_status" + } + } + } + } + ] + }, + { + "name": "unknown properties and vendor codes", + "patch": [ + { + "path": [ + "workspace" + ], + "value": { + "base_commit": { + "value": "abc" + } + } + }, + { + "path": [ + "fabric", + "core_version", + "confidence" + ], + "value": "high" + }, + { + "path": [ + "fabric", + "core_version", + "collected_by" + ], + "value": "acme.collector" + }, + { + "path": [ + "harness", + "version", + "reason" + ], + "value": "acme:withheld#1" + }, + { + "path": [ + "harness", + "version", + "method" + ], + "value": "acme.lookup/v2#names" + } + ] + }, + { + "name": "no adapter ID, models or MCP servers", + "patch": [ + { + "path": [ + "adapter", + "id" + ], + "remove": true + }, + { + "path": [ + "models" + ], + "value": {} + }, + { + "path": [ + "mcp", + "servers" + ], + "value": {} + } + ] + }, + { + "name": "128-character code", + "patch": [ + { + "path": [ + "harness", + "version", + "reason" + ], + "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ] + }, + { + "name": "truncation markers next to models and MCP servers", + "patch": [ + { + "path": [ + "models_truncated" + ], + "value": { + "reason": "entry_cap", + "dropped": 1 + } + }, + { + "path": [ + "mcp", + "servers_truncated" + ], + "value": { + "reason": "byte_budget", + "dropped": 3 + } + } + ] + }, + { + "name": "vendor truncation reason", + "patch": [ + { + "path": [ + "models_truncated" + ], + "value": { + "reason": "acme.quota", + "dropped": 2 + } + } + ] + }, + { + "name": "minimal section left by the byte budget", + "patch": [ + { + "path": [], + "value": { + "schema": "nemo.fabric.what_ran", + "version": "v1alpha1", + "what_ran_truncated": { + "reason": "byte_budget", + "dropped": 1 + } + } + } + ] + } + ], + "invalid": [ + { + "name": "value with state", + "patch": [ + { + "path": [ + "harness", + "version", + "value" + ], + "value": "2.1.0" + } + ] + }, + { + "name": "value with reason only", + "patch": [ + { + "path": [ + "fabric", + "core_version", + "reason" + ], + "value": "local_run" + } + ] + }, + { + "name": "neither value nor state", + "patch": [ + { + "path": [ + "harness", + "version", + "state" + ], + "remove": true + }, + { + "path": [ + "harness", + "version", + "reason" + ], + "remove": true + } + ] + }, + { + "name": "state without reason", + "patch": [ + { + "path": [ + "harness", + "version", + "reason" + ], + "remove": true + } + ] + }, + { + "name": "unknown state", + "patch": [ + { + "path": [ + "harness", + "version", + "state" + ], + "value": "withheld" + } + ] + }, + { + "name": "unknown source", + "patch": [ + { + "path": [ + "harness", + "version", + "source" + ], + "value": "guessed" + } + ] + }, + { + "name": "number for a string value", + "patch": [ + { + "path": [ + "fabric", + "core_version", + "value" + ], + "value": 6 + } + ] + }, + { + "name": "null value", + "patch": [ + { + "path": [ + "fabric", + "core_version", + "value" + ], + "value": null + } + ] + }, + { + "name": "string for a boolean value", + "patch": [ + { + "path": [ + "mcp", + "servers", + "docs", + "runtime", + "connected" + ], + "value": { + "value": "true", + "source": "reported", + "collected_by": "adapter", + "method": "mcp_initialize" + } + } + ] + }, + { + "name": "non-string tool name", + "patch": [ + { + "path": [ + "mcp", + "servers", + "docs", + "runtime", + "tool_names" + ], + "value": { + "value": [ + "search", + 1 + ], + "source": "reported", + "collected_by": "adapter", + "method": "mcp_initialize" + } + } + ] + }, + { + "name": "method outside the code pattern", + "patch": [ + { + "path": [ + "models", + "default", + "requested", + "method" + ], + "value": "models.Default.model" + } + ] + }, + { + "name": "empty reason", + "patch": [ + { + "path": [ + "harness", + "version", + "reason" + ], + "value": "" + } + ] + }, + { + "name": "129-character code", + "patch": [ + { + "path": [ + "harness", + "version", + "reason" + ], + "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } + ] + }, + { + "name": "non-ASCII code", + "patch": [ + { + "path": [ + "harness", + "version", + "reason" + ], + "value": "rĂ©ason" + } + ] + }, + { + "name": "code with a trailing newline", + "patch": [ + { + "path": [ + "harness", + "version", + "reason" + ], + "value": "key_not_declared\n" + } + ] + }, + { + "name": "number for a boolean value", + "patch": [ + { + "path": [ + "mcp", + "servers", + "docs", + "runtime", + "connected" + ], + "value": { + "value": 1, + "source": "reported", + "collected_by": "adapter", + "method": "mcp_initialize" + } + } + ] + }, + { + "name": "missing collected_by", + "patch": [ + { + "path": [ + "fabric", + "core_version", + "collected_by" + ], + "remove": true + } + ] + }, + { + "name": "missing section", + "patch": [ + { + "path": [ + "harness" + ], + "remove": true + } + ] + }, + { + "name": "missing harness name", + "patch": [ + { + "path": [ + "harness", + "name" + ], + "remove": true + } + ] + }, + { + "name": "fact that is not an object", + "patch": [ + { + "path": [ + "adapter", + "package_version" + ], + "value": "1.0.0" + } + ] + }, + { + "name": "model without provider", + "patch": [ + { + "path": [ + "models", + "default", + "provider" + ], + "remove": true + } + ] + }, + { + "name": "plan MCP entry without a transport", + "patch": [ + { + "path": [ + "mcp", + "servers", + "docs", + "transport" + ], + "remove": true + } + ] + }, + { + "name": "plan MCP entry with a null transport", + "patch": [ + { + "path": [ + "mcp", + "servers", + "docs", + "transport" + ], + "value": null + } + ] + }, + { + "name": "MCP entry without runtime facts", + "patch": [ + { + "path": [ + "mcp", + "servers", + "docs", + "runtime" + ], + "remove": true + } + ] + }, + { + "name": "non-string environment provider", + "patch": [ + { + "path": [ + "environment", + "provider" + ], + "value": null + } + ] + }, + { + "name": "another schema", + "patch": [ + { + "path": [ + "schema" + ], + "value": "acme.what_ran" + } + ] + }, + { + "name": "another version", + "patch": [ + { + "path": [ + "version" + ], + "value": "v1beta1" + } + ] + }, + { + "name": "missing version", + "patch": [ + { + "path": [ + "version" + ], + "remove": true + } + ] + }, + { + "name": "string instead of an object", + "patch": [ + { + "path": [], + "value": "not an object" + } + ] + }, + { + "name": "array instead of an object", + "patch": [ + { + "path": [], + "value": [ + "fact" + ] + } + ] + }, + { + "name": "truncation marker without a dropped count", + "patch": [ + { + "path": [ + "models_truncated" + ], + "value": { + "reason": "entry_cap" + } + } + ] + }, + { + "name": "truncation marker with a zero dropped count", + "patch": [ + { + "path": [ + "mcp", + "servers_truncated" + ], + "value": { + "reason": "byte_budget", + "dropped": 0 + } + } + ] + }, + { + "name": "truncation marker with a string dropped count", + "patch": [ + { + "path": [ + "models_truncated" + ], + "value": { + "reason": "entry_cap", + "dropped": "1" + } + } + ] + }, + { + "name": "truncation reason outside the code pattern", + "patch": [ + { + "path": [ + "mcp", + "servers_truncated" + ], + "value": { + "reason": "Entry Cap", + "dropped": 1 + } + } + ] + }, + { + "name": "truncation marker that is not an object", + "patch": [ + { + "path": [ + "models_truncated" + ], + "value": "entry_cap" + } + ] + }, + { + "name": "schema and version only, without the minimal marker", + "patch": [ + { + "path": [], + "value": { + "schema": "nemo.fabric.what_ran", + "version": "v1alpha1" + } + } + ] + }, + { + "name": "minimal section with a null marker", + "patch": [ + { + "path": [], + "value": { + "schema": "nemo.fabric.what_ran", + "version": "v1alpha1", + "what_ran_truncated": null + } + } + ] + }, + { + "name": "minimal section with a zero dropped count", + "patch": [ + { + "path": [], + "value": { + "schema": "nemo.fabric.what_ran", + "version": "v1alpha1", + "what_ran_truncated": { + "reason": "byte_budget", + "dropped": 0 + } + } + } + ] + }, + { + "name": "minimal section with a malformed section", + "patch": [ + { + "path": [], + "value": { + "schema": "nemo.fabric.what_ran", + "version": "v1alpha1", + "what_ran_truncated": { + "reason": "byte_budget", + "dropped": 1 + }, + "harness": "codex" + } + } + ] + }, + { + "name": "null section", + "patch": [ + { + "path": [ + "harness" + ], + "value": null + } + ] + }, + { + "name": "null models_truncated", + "patch": [ + { + "path": [ + "models_truncated" + ], + "value": null + } + ] + }, + { + "name": "null servers_truncated", + "patch": [ + { + "path": [ + "mcp", + "servers_truncated" + ], + "value": null + } + ] + } + ] +} diff --git a/tests/python/conftest.py b/tests/python/conftest.py index 8eada5243..40a104a58 100644 --- a/tests/python/conftest.py +++ b/tests/python/conftest.py @@ -3,8 +3,82 @@ import json import sys +from pathlib import Path + import pytest -from nemo_fabric import FabricConfig +from nemo_fabric import Fabric, FabricConfig, PricingConfig + + +@pytest.fixture(name="accounting_catalog") +def accounting_catalog_fixture(): + return None + + +@pytest.fixture(name="accounting_runtime") +async def accounting_runtime_fixture(tmp_path, accounting_catalog): + """Normalize controlled adapter evidence through the native public runtime.""" + root = Path(__file__).resolve().parents[2] + accounting_schema = json.loads( + (root / "schemas/extensions/usage-accounting.schema.json").read_text() + ) + definitions = accounting_schema.pop("$defs") + descriptor = { + "contract_version": "fabric.adapter/v1alpha2", + "adapter_id": "test.accounting", + "adapter_kind": "process", + "runner": {"command": sys.executable, "script": "adapter.py"}, + "settings_schema": {"type": "object", "additionalProperties": False}, + "extension_schemas": { + "usage": { + "type": "object", + "properties": { + "nemo_fabric.usage_accounting.v1": accounting_schema, + "nemo_fabric.detailed_token_usage.v1": { + "$ref": "#/$defs/ReportedUsage" + }, + }, + "additionalProperties": False, + "$defs": definitions, + } + }, + } + (tmp_path / "accounting.fabric-adapter.json").write_text(json.dumps(descriptor)) + (tmp_path / "adapter.py").write_text(""" +import json +import sys + +for line in sys.stdin: + message = json.loads(line) + operation = message["operation"] + output = None + if operation == "invoke": + output = { + "status": "succeeded", + "output": None, + "usage": message["payload"]["request"]["input"], + } + print(json.dumps({ + "operation": operation, + "outcome": {"status": "succeeded", "output": output}, + }), flush=True) + if operation == "stop": + break +""") + config = FabricConfig.from_mapping( + { + "metadata": {"name": "accounting"}, + "harness": {"adapter_id": "test.accounting"}, + "discovery": {"local_paths": ["accounting.fabric-adapter.json"]}, + "environment": {"provider": "local", "workspace": "."}, + "runtime": {"artifacts": "./artifacts"}, + } + ) + if accounting_catalog is not None: + catalog_path = tmp_path / "catalog.json" + catalog_path.write_text(json.dumps(accounting_catalog)) + config.pricing = PricingConfig(catalog_path=catalog_path) + async with await Fabric().start_runtime(config, base_dir=tmp_path) as runtime: + yield runtime @pytest.fixture(name="blocking_adapter_config") diff --git a/tests/python/test_model_usage.py b/tests/python/test_model_usage.py new file mode 100644 index 000000000..8bd256da1 --- /dev/null +++ b/tests/python/test_model_usage.py @@ -0,0 +1,585 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from copy import deepcopy +import json +from pathlib import Path +from typing import get_type_hints + +import pytest + +from nemo_fabric.accounting import summarize_usage +from nemo_fabric.errors import FabricConfigError +from nemo_fabric.types import AppliedPricing, ModelUsage, PerModelUsage, TokenCounts + + +def test_per_model_complete_counts_have_the_public_token_types(): + counts = get_type_hints(TokenCounts) + per_model = get_type_hints(PerModelUsage) + for field in ( + "input_tokens", + "cached_input_tokens", + "cache_write_input_tokens", + "output_tokens", + "total_tokens", + ): + assert per_model.get(field) == counts[field] == int | None, field + + +@pytest.fixture(name="usage") +def usage_fixture(): + return { + "input_tokens": 100, + "cached_input_tokens": 0, + "cache_write_input_tokens": 0, + "output_tokens": 5, + "total_tokens": 105, + "observed": {}, + "gaps": [], + "models_exhaustive": False, + "cost": {"reported": {}, "estimated": {}}, + } + + +def test_flat_counts_preserve_reported_zero_and_independent_unknown(usage): + usage["cached_input_tokens"] = None + parsed = ModelUsage.from_mapping(usage) + assert parsed.input_tokens == 100 + assert parsed.cached_input_tokens is None + assert parsed.cache_write_input_tokens == 0 + assert parsed.to_mapping()["input_tokens"] == 100 + + +@pytest.mark.parametrize("value", [-1, True, 1.5, 1 << 64, {"value": 100}]) +def test_invalid_or_old_measurement_count_is_rejected(usage, value): + usage["input_tokens"] = value + with pytest.raises(FabricConfigError): + ModelUsage.from_mapping(usage) + + +def test_gap_conflict_and_independent_provider_total_are_rejected(usage): + usage["gaps"] = [{"dimensions": ["input_tokens"], "reason": "missing_usage"}] + with pytest.raises(FabricConfigError, match="conflicts"): + ModelUsage.from_mapping(usage) + usage["gaps"] = [] + usage["total_tokens"] = 999 + with pytest.raises(FabricConfigError, match="input plus output"): + ModelUsage.from_mapping(usage) + + +def test_complete_partial_and_missing_preserve_coverage_and_observations(usage): + partial = deepcopy(usage) + partial.update(input_tokens=None, total_tokens=None) + partial["observed"] = {"input_tokens": 50} + partial["gaps"] = [ + {"dimensions": ["input_tokens", "total_tokens"], "reason": "unfinished_work"} + ] + rows = [ + {"runtime_id": "r", "invocation_id": name, "model_usage": value} + for name, value in [("complete", usage), ("partial", partial)] + ] + result = summarize_usage( + rows, expected=[("r", "complete"), ("r", "partial"), ("r", "missing")] + ) + assert result["dimensions"]["input_tokens"] == { + "expected_count": 3, + "complete_count": 1, + "incomplete_count": 1, + "missing_result_count": 1, + "complete_total": None, + "observed_sum": 150, + "reasons": {"missing_result": 1, "unfinished_work": 1}, + } + assert result["dimensions"]["output_tokens"]["observed_sum"] == 10 + + +def test_unsupported_pricing_preserves_valid_token_accounting(usage): + usage["cost"]["estimated"] = {"amount_nano_usd": 200, "observed_nano_usd": 200} + usage["cost"]["pricing"] = { + "basis": "future_basis", + "currency": "USD", + "normalization_version": "future", + "assumption_policy": "future", + "bundled_catalog_sha256": "sha256:" + "a" * 64, + } + dimensions = summarize_usage( + [{"runtime_id": "r", "invocation_id": "i", "model_usage": usage}], + expected=[("r", "i")], + )["dimensions"] + assert dimensions["input_tokens"]["complete_total"] == 100 + assert dimensions["estimated_cost"]["complete_total"] is None + assert dimensions["estimated_cost"]["observed_sum"] is None + assert dimensions["estimated_cost"]["reasons"] == {"pricing_unverified": 1} + + +@pytest.fixture(name="priced_usage") +def priced_usage_fixture(usage): + usage["cost"]["estimated"] = { + "amount_nano_usd": 14_000, + "observed_nano_usd": 14_000, + } + usage["cost"]["pricing"] = { + "basis": "catalog", + "currency": "USD", + "normalization_version": "cache_reasoning_inclusive_v1", + "assumption_policy": "applicable_tariff_no_missing_counts_v1", + "bundled_catalog_sha256": "sha256:" + "a" * 64, + } + return usage + + +@pytest.mark.parametrize("observed_only", [False, True]) +def test_unpriced_different_catalog_preserves_cost_and_full_coverage( + priced_usage, observed_only +): + unpriced = deepcopy(priced_usage) + unpriced["cost"]["pricing"]["bundled_catalog_sha256"] = "sha256:" + "b" * 64 + unpriced["cost"]["estimated"] = {} + unpriced["gaps"] = [{"dimensions": ["estimated_cost"], "reason": "missing_usage"}] + if observed_only: + priced_usage["cost"]["estimated"]["amount_nano_usd"] = None + priced_usage["gaps"] = [ + {"dimensions": ["estimated_cost"], "reason": "unfinished_work"} + ] + summary = summarize_usage( + [ + {"runtime_id": "r", "invocation_id": name, "model_usage": value} + for name, value in [("priced", priced_usage), ("unpriced", unpriced)] + ], + expected=[("r", "priced"), ("r", "unpriced"), ("r", "missing")], + ) + cost = summary["dimensions"]["estimated_cost"] + assert cost["expected_count"] == 3 + assert cost["complete_count"] == (0 if observed_only else 1) + assert cost["incomplete_count"] == (2 if observed_only else 1) + assert cost["missing_result_count"] == 1 + assert cost["complete_total"] is None + assert cost["observed_sum"] == 14_000 + expected_reasons = {"missing_usage": 1, "missing_result": 1} + if observed_only: + expected_reasons["unfinished_work"] = 1 + assert cost["reasons"] == expected_reasons + assert "aggregation_error" not in cost + assert summary["pricing"] == priced_usage["cost"]["pricing"] + assert "pricing_groups" not in summary + assert summary["dimensions"]["input_tokens"]["observed_sum"] == 200 + + +def test_two_contributing_catalogs_withhold_sum_and_keep_unpriced_group_coverage( + priced_usage, +): + other = deepcopy(priced_usage) + other["cost"]["pricing"]["bundled_catalog_sha256"] = "sha256:" + "b" * 64 + other["cost"]["estimated"] = { + "amount_nano_usd": 20_000, + "observed_nano_usd": 20_000, + } + unpriced = deepcopy(priced_usage) + unpriced["cost"]["estimated"] = {} + unpriced["gaps"] = [{"dimensions": ["estimated_cost"], "reason": "missing_usage"}] + rows = [ + {"runtime_id": "r", "invocation_id": name, "model_usage": value} + for name, value in [ + ("first", priced_usage), + ("second", other), + ("unpriced", unpriced), + ] + ] + summary = summarize_usage( + rows, expected=[("r", row["invocation_id"]) for row in rows] + ) + cost = summary["dimensions"]["estimated_cost"] + assert cost["complete_total"] is None + assert cost["observed_sum"] is None + assert cost["aggregation_error"] == "incompatible_pricing" + assert cost["expected_count"] == 3 + assert cost["complete_count"] == 2 + assert cost["incomplete_count"] == 1 + assert cost["reasons"] == {"missing_usage": 1} + groups = { + group["pricing"]["bundled_catalog_sha256"]: group["dimensions"][ + "estimated_cost" + ] + for group in summary["pricing_groups"] + } + first = groups["sha256:" + "a" * 64] + assert first["expected_count"] == 2 + assert first["incomplete_count"] == 1 + assert first["complete_total"] is None + assert first["observed_sum"] == 14_000 + assert first["reasons"] == {"missing_usage": 1} + assert groups["sha256:" + "b" * 64]["complete_total"] == 20_000 + assert summary["dimensions"]["input_tokens"]["complete_total"] == 300 + + +@pytest.mark.integration +async def test_native_runtime_reaches_flat_sdk_and_summary(accounting_runtime): + root = Path(__file__).resolve().parents[2] + cases = json.loads( + (root / "tests/fixtures/usage_accounting/conformance.json").read_text() + ) + fields = { + "input_tokens", + "cached_input_tokens", + "cache_write_input_tokens", + "output_tokens", + "total_tokens", + } + for case in cases: + raw = { + **case["usage"], + "extensions": { + **case["usage"].get("extensions", {}), + "nemo_fabric.usage_accounting.v1": case["accounting"], + }, + } + result = await accounting_runtime.invoke(input=raw) + assert result.status == "succeeded", case["name"] + parsed = result.model_usage + assert isinstance(parsed, ModelUsage), case["name"] + summary = summarize_usage( + [result], expected=[(result.runtime_id, result.invocation_id)] + ) + for field, expected in case["expected"].items(): + if field in fields: + assert parsed[field] == expected["complete"], (case["name"], field) + assert parsed.observed.get(field) == expected["observed"], ( + case["name"], + field, + ) + assert ( + summary["dimensions"][field]["complete_total"] + == expected["complete"] + ), (case["name"], field) + assert ( + summary["dimensions"][field]["observed_sum"] == expected["observed"] + ), (case["name"], field) + + +@pytest.mark.parametrize("scope", ["invocation", "model"]) +@pytest.mark.parametrize( + "field,observed", + [("input_tokens", 101), ("cached_input_tokens", 1), ("output_tokens", 6)], +) +def test_saved_observations_cannot_exceed_a_complete_dimension( + usage, scope, field, observed +): + node = usage + if scope == "model": + node = { + key: deepcopy(value) + for key, value in usage.items() + if key != "models_exhaustive" + } + node["model"] = "fixture-model" + usage["models"] = [node] + node["observed"] = {field: observed} + with pytest.raises(FabricConfigError, match="observation exceeds complete"): + ModelUsage.from_mapping(usage) + + +@pytest.mark.parametrize( + "observed", + [ + {"input_tokens": 1, "output_tokens": 2, "total_tokens": 999}, + {"input_tokens": 1, "total_tokens": 3}, + {"input_tokens": (1 << 64) - 1, "output_tokens": 1, "total_tokens": 0}, + ], +) +def test_saved_observed_total_requires_matching_known_components(usage, observed): + usage.update(input_tokens=None, output_tokens=None, total_tokens=None) + usage["observed"] = observed + with pytest.raises(FabricConfigError, match="observed total"): + ModelUsage.from_mapping(usage) + + +@pytest.mark.parametrize("kind", ["reported", "estimated"]) +def test_saved_observed_cost_cannot_exceed_complete_cost(usage, kind): + usage["cost"][kind] = {"amount_nano_usd": 10, "observed_nano_usd": 11} + with pytest.raises(FabricConfigError, match="observation exceeds complete"): + ModelUsage.from_mapping(usage) + + +def test_saved_subset_observations_are_valid_without_claiming_a_lower_bound(usage): + usage["observed"] = {"input_tokens": 50, "output_tokens": 2, "total_tokens": 52} + usage["cost"]["reported"] = {"amount_nano_usd": 10, "observed_nano_usd": 5} + parsed = ModelUsage.from_mapping(usage) + assert parsed.input_tokens == 100 + assert parsed.observed.input_tokens == 50 + assert parsed.cost.reported.observed_nano_usd == 5 + + +@pytest.mark.parametrize("per_model", [False, True]) +@pytest.mark.parametrize("cached,written", [(11, None), (None, 11), (7, 4)]) +def test_complete_cache_subsets_cannot_exceed_inclusive_input( + usage, per_model, cached, written +): + usage.update( + input_tokens=10, + output_tokens=5, + total_tokens=15, + cached_input_tokens=cached, + cache_write_input_tokens=written, + ) + if per_model: + from nemo_fabric.types import PerModelUsage + + usage.pop("models_exhaustive") + usage["model"] = "test-model" + reader = PerModelUsage.from_mapping + else: + reader = ModelUsage.from_mapping + with pytest.raises(FabricConfigError, match="cache.*input"): + reader(usage) + + +@pytest.mark.parametrize("per_model", [False, True]) +def test_independent_cache_counts_survive_unknown_input(usage, per_model): + usage.update( + input_tokens=None, + total_tokens=None, + cached_input_tokens=100, + cache_write_input_tokens=200, + ) + if per_model: + from nemo_fabric.types import PerModelUsage + + usage.pop("models_exhaustive") + usage["model"] = "test-model" + parsed = PerModelUsage.from_mapping(usage) + else: + parsed = ModelUsage.from_mapping(usage) + assert parsed.input_tokens is None + assert parsed.cached_input_tokens == 100 + assert parsed.cache_write_input_tokens == 200 + + +def test_observed_cache_dimensions_do_not_imply_a_shared_subset(usage): + usage.update( + input_tokens=10, + output_tokens=5, + total_tokens=15, + cached_input_tokens=None, + cache_write_input_tokens=None, + ) + usage["observed"] = {"input_tokens": 1, "cached_input_tokens": 100} + parsed = ModelUsage.from_mapping(usage) + assert parsed.observed.cached_input_tokens == 100 + assert parsed.observed.input_tokens == 1 + + +@pytest.fixture(name="per_model_usage") +def per_model_usage_fixture(usage): + model = { + key: deepcopy(value) + for key, value in usage.items() + if key != "models_exhaustive" + } + model["model"] = "fixture-model" + return model + + +@pytest.mark.parametrize( + "exhaustive,complete,observed", + [(False, 101, None), (True, 101, None), (True, 90, None), (False, None, 101)], +) +def test_model_input_contradicting_complete_invocation_is_rejected( + usage, per_model_usage, exhaustive, complete, observed +): + usage["models_exhaustive"] = exhaustive + per_model_usage.update( + input_tokens=complete, + total_tokens=None if complete is None else complete + 5, + ) + if observed is not None: + per_model_usage["observed"] = {"input_tokens": observed} + usage["models"] = [per_model_usage] + with pytest.raises(FabricConfigError, match="model token"): + ModelUsage.from_mapping(usage) + + +@pytest.mark.parametrize( + "field,value", + [("cached_input_tokens", 1), ("cache_write_input_tokens", 1), ("output_tokens", 6)], +) +def test_independent_model_dimension_exceeding_invocation_is_rejected( + usage, per_model_usage, field, value +): + per_model_usage[field] = value + if field == "output_tokens": + per_model_usage["total_tokens"] = 100 + value + usage["models"] = [per_model_usage] + with pytest.raises(FabricConfigError, match="model token"): + ModelUsage.from_mapping(usage) + + +@pytest.mark.parametrize( + "root,parts", [(100, [70, 40]), ((1 << 64) - 1, [(1 << 64) - 1, 1])] +) +def test_summed_model_tokens_cannot_exceed_complete_invocation_or_uint64( + usage, per_model_usage, root, parts +): + usage.update(input_tokens=root, output_tokens=None, total_tokens=None) + usage["models"] = [] + for index, value in enumerate(parts): + model = deepcopy(per_model_usage) + model.update( + model=f"model-{index}", + input_tokens=value, + output_tokens=None, + total_tokens=None, + ) + usage["models"].append(model) + with pytest.raises(FabricConfigError, match="model token"): + ModelUsage.from_mapping(usage) + + +@pytest.mark.parametrize("exhaustive", [False, True]) +def test_incomplete_model_dimensions_do_not_invalidate_independent_complete_root( + usage, per_model_usage, exhaustive +): + usage["models_exhaustive"] = exhaustive + per_model_usage.update(input_tokens=None, total_tokens=None) + per_model_usage["observed"] = {"input_tokens": 50} + usage["models"] = [per_model_usage] + assert ModelUsage.from_mapping(usage).input_tokens == 100 + + +def test_unknown_root_input_keeps_independent_cache_dimensions(usage, per_model_usage): + usage.update( + input_tokens=None, + total_tokens=None, + cached_input_tokens=100, + cache_write_input_tokens=200, + ) + per_model_usage.update( + input_tokens=None, + total_tokens=None, + cached_input_tokens=90, + cache_write_input_tokens=150, + ) + usage["models"] = [per_model_usage] + parsed = ModelUsage.from_mapping(usage) + assert parsed.cached_input_tokens == 100 + assert parsed.cache_write_input_tokens == 200 + + +def test_independently_rounded_provider_cost_is_not_a_token_reconciliation_error( + usage, per_model_usage +): + usage["models_exhaustive"] = True + usage["cost"]["reported"] = {"amount_nano_usd": 10} + per_model_usage["cost"]["reported"] = {"amount_nano_usd": 9} + usage["models"] = [per_model_usage] + assert ModelUsage.from_mapping(usage).cost.reported.amount_nano_usd == 10 + + +@pytest.mark.parametrize("cache_rates", [{}, {"cache_read": 0}, {"cache_write": 0}]) +def test_applied_pricing_accepts_schema_optional_cache_rates(cache_rates): + parsed = AppliedPricing.from_mapping( + { + "catalog_sha256": "sha256:" + "a" * 64, + "provider": "fixture-provider", + "model_id": "fixture-model", + "pricing_as_of": "2026-10-22", + "pricing_source": "fixture-catalog", + "rates": {"input": 10, "output": 20, **cache_rates}, + } + ) + expected = { + "input": 10, + "output": 20, + "cache_read": cache_rates.get("cache_read"), + "cache_write": cache_rates.get("cache_write"), + } + rates = parsed.rates + assert json.loads(json.dumps(rates)) == expected + rates["input"] = 999 + assert parsed.rates == expected + + +@pytest.mark.parametrize( + "provider", [{}, {"provider": None}, {"provider": "fixture-provider"}] +) +def test_per_model_optional_provider_attribute_preserves_source_presence( + per_model_usage, provider +): + from nemo_fabric.types import PerModelUsage + + per_model_usage.update(provider) + parsed = PerModelUsage.from_mapping(per_model_usage) + assert parsed.provider == provider.get("provider") + serialized = parsed.to_mapping() + assert ("provider" in serialized) == ("provider" in provider) + if "provider" in provider: + assert serialized["provider"] == provider["provider"] + + +@pytest.mark.parametrize( + "user_catalog", + [{}, {"user_catalog_sha256": None}, {"user_catalog_sha256": "sha256:" + "b" * 64}], +) +def test_pricing_basis_optional_user_catalog_attribute_preserves_source_presence( + user_catalog, +): + from nemo_fabric.types import PricingBasis + + source = { + "basis": "catalog", + "currency": "USD", + "normalization_version": "cache_reasoning_inclusive_v1", + "assumption_policy": "applicable_tariff_no_missing_counts_v1", + "bundled_catalog_sha256": "sha256:" + "a" * 64, + **user_catalog, + } + parsed = PricingBasis.from_mapping(source) + assert parsed.user_catalog_sha256 == user_catalog.get("user_catalog_sha256") + assert parsed.to_mapping() == source + + +@pytest.mark.parametrize( + "field", + [ + "input_tokens", + "cached_input_tokens", + "cache_write_input_tokens", + "output_tokens", + "total_tokens", + ], +) +@pytest.mark.parametrize("observed", [False, True]) +def test_exhaustive_empty_model_list_rejects_positive_tokens(field, observed): + usage = {"models_exhaustive": True, "models": [], "gaps": [], "cost": {}} + values = {field: 1} + if field == "total_tokens": + values.update(input_tokens=1, output_tokens=0) + if observed: + usage["observed"] = values + else: + usage.update(values) + with pytest.raises(FabricConfigError, match="exhaustive empty model list"): + ModelUsage.from_mapping(usage) + + +@pytest.mark.parametrize("value", [None, 0]) +def test_exhaustive_empty_model_list_preserves_unknown_and_zero_work(value): + fields = [ + "input_tokens", + "cached_input_tokens", + "cache_write_input_tokens", + "output_tokens", + "total_tokens", + ] + usage = { + "models_exhaustive": True, + "models": [], + "gaps": [], + "cost": {}, + **dict.fromkeys(fields, value), + } + usage["observed"] = dict.fromkeys(fields, value) + parsed = ModelUsage.from_mapping(usage) + assert parsed.models_exhaustive is True + assert parsed.models == () + assert parsed.input_tokens == value + assert parsed.observed.input_tokens == value diff --git a/tests/python/test_native_sdk.py b/tests/python/test_native_sdk.py index f571159b7..eca831ece 100644 --- a/tests/python/test_native_sdk.py +++ b/tests/python/test_native_sdk.py @@ -32,6 +32,17 @@ async def test_native_sdk(hermes_shim_agent_dir: Path): await smoke(Fabric(), hermes_shim_agent_dir) +async def test_native_one_shot_exposes_core_and_plan_facts(hermes_shim_agent_dir): + result = await Fabric().run( + hermes_shim_config(), base_dir=hermes_shim_agent_dir, input="hello native" + ) + assert result.status == "succeeded" + assert result.what_ran["fabric"]["core_version"]["value"] == native.version() + assert result.what_ran["adapter"]["id"] == "test.fabric.hermes_shim" + assert result.what_ran["harness"]["version"]["reason"] == "key_not_declared" + assert result.what_ran["environment"] == {"provider": "local"} + + @pytest.mark.parametrize("new_value", ["rotated-secret", None]) async def test_named_environment_changes_require_runtime_restart( hermes_shim_agent_dir, new_value diff --git a/tests/python/test_sdk_contract.py b/tests/python/test_sdk_contract.py index b71bea32d..403fb9fa8 100644 --- a/tests/python/test_sdk_contract.py +++ b/tests/python/test_sdk_contract.py @@ -8,6 +8,7 @@ import asyncio import json import threading +from copy import deepcopy from inspect import signature from pathlib import Path from typing import Any @@ -2001,6 +2002,79 @@ def test_run_result_wraps_normalized_usage(): assert result.usage.metadata == {"provider": "test"} +_WHAT_RAN_VECTORS = json.loads( + ( + Path(__file__).resolve().parents[1] + / "fixtures" + / "what_ran" + / "reader-vectors.json" + ).read_text(encoding="utf-8") +) + + +def _what_ran_vector(case: dict[str, Any]) -> Any: + """Apply one shared reader vector's patch to the base ``what_ran``.""" + + what_ran = deepcopy(_WHAT_RAN_VECTORS["base"]) + for operation in case["patch"]: + path = operation["path"] + if not path: + what_ran = deepcopy(operation["value"]) + continue + *parents, last = path + parent = what_ran + for key in parents: + parent = parent[key] + if operation.get("remove"): + del parent[last] + else: + parent[last] = deepcopy(operation["value"]) + return what_ran + + +def test_run_result_exposes_what_ran_as_a_detached_json_mapping(): + what_ran = _what_ran_vector(_WHAT_RAN_VECTORS["valid"][0]) + result = RunResult.from_mapping(_run_result(what_ran=what_ran)) + + detached = result.what_ran + detached["fabric"]["core_version"]["value"] = "mutated" + + assert result.what_ran == what_ran + assert result["what_ran"] == what_ran + assert result.to_mapping()["what_ran"] == what_ran + assert "what_ran" not in result.extra_fields + + +@pytest.mark.parametrize( + "case", _WHAT_RAN_VECTORS["valid"], ids=lambda case: case["name"] +) +def test_run_result_keeps_valid_what_ran_vector(case): + what_ran = _what_ran_vector(case) + + result = RunResult.from_mapping(_run_result(what_ran=what_ran)) + + assert result.what_ran == what_ran + + +@pytest.mark.parametrize( + "case", _WHAT_RAN_VECTORS["invalid"], ids=lambda case: case["name"] +) +def test_run_result_reads_invalid_what_ran_vector_as_none(case): + result = RunResult.from_mapping(_run_result(what_ran=_what_ran_vector(case))) + + assert result.what_ran is None + assert result.to_mapping()["what_ran"] is None + assert result.status == "succeeded" + + +@pytest.mark.parametrize("mapping", [_run_result(), _run_result(what_ran=None)]) +def test_run_result_without_what_ran_reads_none(mapping): + result = RunResult.from_mapping(mapping) + + assert result.what_ran is None + assert result.to_mapping()["what_ran"] is None + + @pytest.mark.parametrize( "field", ["input_tokens", "cached_input_tokens", "output_tokens", "total_tokens"] ) diff --git a/tests/python/test_usage_reducer_parity.py b/tests/python/test_usage_reducer_parity.py new file mode 100644 index 000000000..2e971812f --- /dev/null +++ b/tests/python/test_usage_reducer_parity.py @@ -0,0 +1,172 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Reducer parity requires Node and a compiled TypeScript adapter contract. + +From adapter-contract/typescript, run: + npm exec tsc -- -p tsconfig.build.json +""" + +import json +import os +from pathlib import Path +import shutil +import subprocess + +import pytest + +from nemo_fabric_adapter_contract.models import GapReason, ReportedUsage, UsageGap +from nemo_fabric_adapter_contract.usage import UsageObservation, usage_from_observations + +ROOT = Path(__file__).resolve().parents[2] +VECTORS = json.loads( + (ROOT / "tests/fixtures/usage_accounting/reducer-cases.json").read_text() +) +KEY = "nemo_fabric.usage_accounting.v1" + + +@pytest.fixture(name="reduced") +def reduced_fixture(): + return [ + usage_from_observations( + [ + UsageObservation( + reported=ReportedUsage.from_mapping(record["reported"]), + model=record.get("model"), + provider=record.get("provider"), + gap=GapReason(record["gap"]) if "gap" in record else None, + tokens_aligned=record.get("tokens_aligned", False), + is_model_call=record.get("is_model_call", False), + gaps=tuple( + UsageGap.from_mapping(gap) for gap in record.get("gaps", []) + ), + ) + for record in vector["records"] + ] + ).to_mapping() + for vector in VECTORS + ] + + +def test_python_reducer_matches_shared_independent_expectations(reduced): + for vector, usage in zip(VECTORS, reduced, strict=True): + accounting = usage["extensions"][KEY] + for field, value in vector["expected"].items(): + if field == "gap_reason": + assert [gap["reason"] for gap in accounting["gaps"]] == [value] + continue + if field == "exhaustive": + actual = accounting["models_exhaustive"] + elif field.startswith("observed_"): + actual = accounting["observed"].get(field[9:] + "_tokens") + elif field == "peak_request_input_tokens": + actual = usage["extensions"]["nemo_fabric.detailed_token_usage.v1"].get( + field + ) + else: + actual = usage.get(field) + assert actual == value, (vector["name"], field) + + +def test_python_and_typescript_reducers_emit_the_same_wire_evidence(reduced): + node = shutil.which("node") + module = ROOT / "adapter-contract/typescript/dist/usage.js" + if node is None or not module.exists(): + if os.environ.get("FABRIC_REQUIRE_TYPESCRIPT_REDUCER_PARITY") == "1": + pytest.fail("TypeScript reducer parity requires Node and the built contract") + pytest.skip("build the TypeScript contract to run cross-binding reducer parity") + source = """ +import { pathToFileURL } from 'node:url'; +import { readFileSync } from 'node:fs'; +const { usageFromObservations } = await import(pathToFileURL(process.argv[1]).href); +console.log(JSON.stringify(JSON.parse(readFileSync(0, 'utf8')).map(v => usageFromObservations(v.records)))); +""" + result = subprocess.run( + [node, "--input-type=module", "-e", source, str(module)], + input=json.dumps(VECTORS), + capture_output=True, + text=True, + check=True, + ) + assert json.loads(result.stdout) == reduced + + +@pytest.mark.integration +async def test_actual_calls_with_mixed_conventions_recover_complete_counts_in_core( + reduced, accounting_runtime +): + index = next( + i + for i, vector in enumerate(VECTORS) + if vector["name"] == "mixed_actual_call_conventions" + ) + result = await accounting_runtime.invoke(input=reduced[index]) + assert result.status == "succeeded" + usage = result.model_usage + assert usage.input_tokens == 210 + assert usage.cached_input_tokens == 20 + assert usage.output_tokens == 4 + assert usage.total_tokens == 214 + + +@pytest.mark.integration +@pytest.mark.parametrize( + "accounting_catalog, complete_cost", + [ + ( + { + "version": 1, + "entries": [ + { + "provider": "openai", + "model_id": "model", + "currency": "USD", + "unit": "per_token", + "rates": { + "input_per_million": 1.0, + "output_per_million": 2.0, + "cache_read_per_million": cache_rate, + "cache_write_per_million": 1.25, + }, + "prompt_cache": { + "read_accounting": "included_in_prompt_tokens" + }, + "pricing_as_of": "2026-10-21", + "pricing_source": "test fixture", + } + ], + }, + complete_cost, + ) + for cache_rate, complete_cost in [(0.1, None), (1.0, 110_000)] + ], +) +async def test_scoped_request_gap_preserves_cache_uncertainty_and_independent_pricing( + reduced, accounting_runtime, complete_cost +): + index = next( + i + for i, vector in enumerate(VECTORS) + if vector["name"] == "actual_call_with_scoped_cache_gap" + ) + evidence = reduced[index]["extensions"][KEY] + model = evidence["models"][0] + request = model["requests"][0] + assert model["observed"]["cached_input_tokens"] == 20 + assert "cached_input_tokens" not in model["reported"] + assert "cached_input_tokens" not in request["usage"] + assert request["usage"]["input_tokens"] == 100 + assert request["usage"]["output_tokens"] == 5 + assert request["tokens_aligned"] is True + assert "gap" not in request + + result = await accounting_runtime.invoke(input=reduced[index]) + assert result.status == "succeeded" + usage = result.model_usage + assert usage.input_tokens == 100 + assert usage.output_tokens == 5 + assert usage.total_tokens == 105 + assert usage.cached_input_tokens is None + assert usage.observed.cached_input_tokens == 20 + assert usage.models[0].cached_input_tokens is None + assert usage.cost.estimated.amount_nano_usd == complete_cost