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/Cargo.lock b/Cargo.lock index 9e6be276f..8eaa11a9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1345,6 +1345,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "sha2", "thiserror 2.0.18", "windows-sys 0.61.2", ] diff --git a/Cargo.toml b/Cargo.toml index 76ee54284..f7dff9b70 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,8 @@ libc = "0.2.186" schemars = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +# Standard digest for reproducible price-catalog snapshots; already in the lockfile. +sha2 = { version = "0.10", 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/README.md b/README.md index c4fb1cdc6..4642a7d98 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,13 @@ NeMo Fabric includes adapters for the following harnesses and execution targets. ✅ Supported · — Not supported. Support may come through normalized configuration, native harness behavior, or a specific target. Refer to each adapter guide or the [complete compatibility matrix](adapters/README.md#configuration-compatibility) for details and limitations. +Opt in to local run evidence with `runtime.record.enabled` and an artifact root, +or use `nemo-fabric run --preset scripted --record --artifacts ./runs`. +Recording saves a bounded invocation record and the finalized full result. +Inspect a saved result with `python -m nemo_fabric.reporting `; +add `--details` for resources and pricing evidence or `--json` for exact values. +See [run recording](docs/sdk/python.mdx#opt-in-run-recording) for boundaries and limitations. + ## NVIDIA Ecosystem NeMo Fabric is an execution layer for NVIDIA products that build, evaluate, deploy, and improve agents. The ecosystem includes shipped integrations with [NVIDIA NeMo Helix](https://github.com/NVIDIA-NeMo/nemo-helix), concrete integrations with [NVIDIA NemoClaw](https://github.com/NVIDIA/NemoClaw/) and [NVIDIA NeMo Gym](https://github.com/NVIDIA-NeMo/gym), and emerging rollout integrations such as [NVIDIA Polar](https://github.com/NVIDIA-NeMo/ProRL-Agent-Server). [Explore the NeMo Fabric ecosystem](docs/about-nemo-fabric/ecosystem.mdx). 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 04e48218c..68104e1ab 100644 --- a/adapter-contract/python/src/nemo_fabric_adapter_contract/models.py +++ b/adapter-contract/python/src/nemo_fabric_adapter_contract/models.py @@ -521,6 +521,127 @@ 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: + 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 e0b4c67e9..1d9782882 100644 --- a/adapter-contract/typescript/scripts/check-package.mjs +++ b/adapter-contract/typescript/scripts/check-package.mjs @@ -48,6 +48,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", @@ -120,12 +125,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 1410b3c2e..191790175 100644 --- a/adapter-contract/typescript/scripts/generate.mjs +++ b/adapter-contract/typescript/scripts/generate.mjs @@ -107,6 +107,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 cc93cb7cb..8027bc352 100644 --- a/adapter-contract/typescript/src/index.ts +++ b/adapter-contract/typescript/src/index.ts @@ -13,6 +13,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, @@ -39,3 +40,5 @@ 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"; 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/claude/README.md b/adapters/python/claude/README.md index a6b8ac271..bfa13847c 100644 --- a/adapters/python/claude/README.md +++ b/adapters/python/claude/README.md @@ -169,7 +169,7 @@ config.enable_relay( ``` For each Relay-enabled Claude runtime, NeMo Fabric starts one `nemo-relay` gateway, -waits for its health endpoint, and stops it with the runtime. NeMo Fabric passes the +waits up to 30 seconds for its health endpoint, and stops it with the runtime. NeMo Fabric passes the gateway URL to the connected Claude Code process through `ANTHROPIC_BASE_URL` and `NEMO_RELAY_GATEWAY_URL`, and passes the selected explicit model endpoint to the gateway as its Anthropic upstream. It also stages a runtime-scoped Claude diff --git a/adapters/python/codex/README.md b/adapters/python/codex/README.md index 3cf1de779..ba7eab1b7 100644 --- a/adapters/python/codex/README.md +++ b/adapters/python/codex/README.md @@ -154,7 +154,9 @@ Codex adapter does not provide a separate `relay` extra; its `harness` and Enable Relay with `FabricConfig.enable_relay(...)`. The adapter starts the installed `nemo-relay` CLI as a supervised sidecar; do not start the gateway -separately. +separately. Gateway readiness has a 30-second budget covering configuration +validation and required worker initialization. Startup fails on early process exit +or an expired budget, and the gateway log is retained for diagnosis. NeMo Fabric routes the selected Responses-compatible provider through the gateway and passes its explicit `base_url` to Relay as the upstream endpoint. diff --git a/adapters/python/common/README.md b/adapters/python/common/README.md index 17e537cd5..df9e8aa93 100644 --- a/adapters/python/common/README.md +++ b/adapters/python/common/README.md @@ -122,6 +122,22 @@ state during `start`. Adapter stdout is reserved for the protocol; diagnostics are redirected to stderr. A host crash or protocol timeout terminates that runtime. +## Relay Gateway Readiness + +The Claude and Codex adapters wait up to 30 seconds for the supervised Relay +gateway's HTTP health endpoint. This budget includes configuration validation +and required worker initialization. Process exit is checked before each health +probe and again before accepting a successful response. An exit during an +unsuccessful probe is detected at the next poll. A gateway that never becomes +healthy fails when the budget expires and is stopped. +The adapters retain its log for diagnosis. Local Python hosts separately enforce +a 90-second adapter startup limit. + +Relay 0.10.3 includes an +[upstream startup-memory fix](https://github.com/NVIDIA/NeMo-Relay/pull/1375). +Use that release to obtain the fix on 0.10 installations. NeMo Fabric continues to +accept the supported `>=0.9,<0.11` CLI range. + ## Relay Request Correlation In-process Relay SDK adapters that own their Agent scope can use diff --git a/adapters/python/common/src/nemo_fabric_adapters/common/relay_gateway.py b/adapters/python/common/src/nemo_fabric_adapters/common/relay_gateway.py index 45cf04faa..f0e6d5cd3 100644 --- a/adapters/python/common/src/nemo_fabric_adapters/common/relay_gateway.py +++ b/adapters/python/common/src/nemo_fabric_adapters/common/relay_gateway.py @@ -20,7 +20,10 @@ from nemo_fabric_adapters.common.relay_version import is_supported_relay_version -RELAY_HEALTH_TIMEOUT_SECONDS = 10.0 +# Readiness includes plugin preflight and worker startup before health is served. +# This operational budget is separate from Relay's inner request timeouts and +# Fabric's outer adapter-start limit. +RELAY_STARTUP_TIMEOUT_SECONDS = 30.0 RELAY_STOP_TIMEOUT_SECONDS = 5.0 RELAY_VERSION_TIMEOUT_SECONDS = 5.0 @@ -110,24 +113,39 @@ def wait_for_relay_gateway( process: subprocess.Popen[Any], health_url: str, *, - timeout: float = RELAY_HEALTH_TIMEOUT_SECONDS, + timeout: float = RELAY_STARTUP_TIMEOUT_SECONDS, ) -> None: """Wait until the Relay health endpoint succeeds or startup fails.""" deadline = time.monotonic() + timeout - while time.monotonic() < deadline: + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + break returncode = process.poll() if returncode is not None: raise RelayGatewayError( f"NeMo Relay gateway exited with status {returncode} before becoming ready" ) try: - with urllib.request.urlopen(health_url, timeout=1) as response: + with urllib.request.urlopen( + health_url, timeout=min(1.0, remaining) + ) as response: if 200 <= response.status < 300: + returncode = process.poll() + if returncode is not None: + raise RelayGatewayError( + f"NeMo Relay gateway exited with status {returncode} before becoming ready" + ) + if time.monotonic() >= deadline: + break return except (OSError, urllib.error.URLError): pass - time.sleep(0.1) + remaining = deadline - time.monotonic() + if remaining <= 0: + break + time.sleep(min(0.1, remaining)) raise RelayGatewayError(f"NeMo Relay gateway did not become ready at {health_url}") 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/app.rs b/crates/fabric-cli/src/app.rs index 032df62eb..54a41d38b 100644 --- a/crates/fabric-cli/src/app.rs +++ b/crates/fabric-cli/src/app.rs @@ -7,7 +7,7 @@ use clap::error::ErrorKind; use clap::{ArgGroup, Args, Parser, Subcommand}; use nemo_fabric_core::{ ResolveContext, RunRequest, RunStatus, doctor_plan, resolve_diagnostic_plan_from_config, - resolve_run_plan_from_config, run_plan, + resolve_run_plan_from_config, run_plan, run_record_folder, }; use crate::examples; @@ -87,6 +87,35 @@ enum ExampleCommand { }, } +/// Config switches that turn on the run record. +#[derive(Debug, Default, Args)] +struct RecordSwitches { + /// Write a run record for each invocation (sets `runtime.record.enabled`). + /// Needs `--artifacts` unless the example sets an artifact root. + #[arg(long)] + record: bool, + /// Artifact root, resolved against the current folder (sets `runtime.artifacts`). + #[arg(long, value_name = "DIR")] + artifacts: Option, +} + +impl RecordSwitches { + /// Apply the switches to the selected config. + fn apply(&self, config: &mut nemo_fabric_core::FabricConfig) -> std::io::Result<()> { + if self.record { + config + .runtime + .record + .get_or_insert_with(nemo_fabric_core::RecordConfig::default) + .enabled = true; + } + if let Some(artifacts) = &self.artifacts { + config.runtime.artifacts = Some(std::path::absolute(artifacts)?); + } + Ok(()) + } +} + #[derive(Debug, Args)] #[command(group( ArgGroup::new("source") @@ -125,6 +154,8 @@ struct Selector { value_parser = parse_temperature )] temperature: Option, + #[command(flatten)] + record: RecordSwitches, } fn parse_temperature(value: &str) -> Result { @@ -150,6 +181,13 @@ impl SelectedSource { } } + fn config_mut(&mut self) -> &mut nemo_fabric_core::FabricConfig { + match self { + Self::Preset(selected) => &mut selected.config, + Self::Example(selected) => &mut selected.config, + } + } + fn base_dir(&self) -> &std::path::Path { match self { Self::Preset(selected) => selected.base_dir(), @@ -251,6 +289,9 @@ fn run(cli: Cli) -> Result<(), Box> { selected.config().clone(), ResolveContext::new(selected.base_dir()), )?; + if let Some(folder) = run_record_folder(&plan) { + eprintln!("nemo-fabric: run record under {}", folder.display()); + } let result = match run_plan(&plan, RunRequest::text(input)) { Ok(result) => result, Err(error) => { @@ -267,6 +308,13 @@ fn run(cli: Cli) -> Result<(), Box> { return Err(error.into()); } }; + for event in result + .events + .iter() + .filter(|event| event.kind == "record_write_failed") + { + eprintln!("nemo-fabric: recording failed: {}", event.message); + } println!("{}", serde_json::to_string_pretty(&result)?); require_successful_run(result.status)?; } @@ -284,17 +332,17 @@ fn require_successful_run(status: RunStatus) -> Result<(), String> { } fn select_source(selector: &Selector) -> Result> { - if let Some(name) = &selector.preset { + let mut selected = if let Some(name) = &selector.preset { let mut selected = find_preset(name)?.stage()?; apply_model_overrides(&mut selected.config, selector)?; - return Ok(SelectedSource::Preset(selected)); - } - if let Some(name) = &selector.example { - return Ok(SelectedSource::Example( - find_example(name)?.select(selector.variant.as_deref())?, - )); - } - unreachable!("Clap requires exactly one source selector") + SelectedSource::Preset(selected) + } else if let Some(name) = &selector.example { + SelectedSource::Example(find_example(name)?.select(selector.variant.as_deref())?) + } else { + unreachable!("Clap requires exactly one source selector") + }; + selector.record.apply(selected.config_mut())?; + Ok(selected) } fn apply_model_overrides( @@ -381,6 +429,7 @@ mod tests { variant: None, model: Some("meta/llama-3.3-70b-instruct".to_string()), temperature: Some(0.2), + record: RecordSwitches::default(), }; let selected = select_source(&selector).expect("select preset"); let model = selected.config().models.get("default").expect("model"); @@ -411,6 +460,7 @@ mod tests { variant: None, model: Some("other-model".to_string()), temperature: None, + record: RecordSwitches::default(), }; assert_eq!( select_source(&selector) @@ -477,4 +527,44 @@ mod tests { fn plan_requires_a_preset() { assert!(Cli::try_parse_from(["nemo-fabric", "plan"]).is_err()); } + #[test] + fn record_switches_parse_on_plan_doctor_and_run_and_set_the_config() { + let selectors: Vec = ["plan", "doctor", "run"] + .into_iter() + .map(|command| { + let cli = Cli::try_parse_from([ + "nemo-fabric", + command, + "--preset", + "scripted", + "--record", + "--artifacts", + "runs", + ]) + .expect("parse record switches"); + match cli.command { + Command::Plan(selector) | Command::Doctor(selector) => selector, + Command::Run { selector, .. } => selector, + _ => panic!("selector command expected"), + } + }) + .collect(); + for selector in &selectors { + assert!(selector.record.record); + } + + let selected = select_source(&selectors[0]).expect("select"); + + let config = selected.config(); + assert!( + config + .runtime + .record + .as_ref() + .is_some_and(|record| record.enabled) + ); + let artifacts = config.runtime.artifacts.as_ref().expect("artifacts"); + assert!(artifacts.is_absolute()); + assert!(artifacts.ends_with("runs")); + } } diff --git a/crates/fabric-cli/src/presets.rs b/crates/fabric-cli/src/presets.rs index a3ec93272..c7f0888b3 100644 --- a/crates/fabric-cli/src/presets.rs +++ b/crates/fabric-cli/src/presets.rs @@ -282,6 +282,7 @@ fn config( .unwrap_or_default(), instructions: None, runtime: RuntimeConfig { + record: None, input_schema: "text".to_string(), output_schema: "message".to_string(), artifacts: None, @@ -308,6 +309,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..38f81d347 100644 --- a/crates/fabric-cli/templates/rust/main.rs.tmpl +++ b/crates/fabric-cli/templates/rust/main.rs.tmpl @@ -33,6 +33,7 @@ fn build_config() -> FabricConfig { models: {{MODELS}}, instructions: {{INSTRUCTIONS}}, runtime: RuntimeConfig { + record: None, input_schema: "text".to_string(), output_schema: "message".to_string(), artifacts: None, @@ -62,6 +63,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/Cargo.toml b/crates/fabric-core/Cargo.toml index 440de80fa..1572f92e3 100644 --- a/crates/fabric-core/Cargo.toml +++ b/crates/fabric-core/Cargo.toml @@ -18,6 +18,7 @@ jsonschema.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true +sha2.workspace = true thiserror.workspace = true [target.'cfg(unix)'.dependencies] 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..360d6e23b 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")] @@ -1060,11 +1076,34 @@ pub struct RuntimeConfig { #[serde(default, skip_serializing_if = "Option::is_none")] #[schemars(range(min = 1, max = u32::MAX))] pub max_turns: Option, + /// Opt-in per-invocation run record. Core-only; never sent to adapters. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub record: Option, /// Additive normalized runtime fields. #[serde(default, flatten)] pub extensions: BTreeMap, } +impl RuntimeConfig { + /// Whether `runtime.record.enabled` is set. + pub(crate) fn record_enabled(&self) -> bool { + self.record.as_ref().is_some_and(|record| record.enabled) + } +} + +/// Per-invocation run record settings. +/// +/// When enabled, core appends a start line and an end line for each local +/// invocation to `/.fabric///run-record.jsonl`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RecordConfig { + /// Whether to write a run record for each invocation. Requires an + /// artifact root, the `local` environment provider, and a `process` or + /// `python` adapter. + pub enabled: bool, +} + fn default_input_schema() -> String { "text".to_string() } @@ -1934,6 +1973,7 @@ pub(crate) fn validate_config(config: &FabricConfig) -> Result<()> { "must be a finite number greater than zero", ); } + validate_record_config(config)?; if let Some(system) = config .instructions .as_ref() @@ -2385,6 +2425,52 @@ fn validate_names(field: &str, names: &[String]) -> Result<()> { Ok(()) } +/// A record needs a host-side artifact root and a local adapter host, so an +/// enabled record that could never be written fails validation instead of +/// silently writing nothing. +fn validate_record_config(config: &FabricConfig) -> Result<()> { + if !config.runtime.record_enabled() { + return Ok(()); + } + let environment = config.environment.as_ref(); + if environment.is_some_and(|environment| environment.provider != "local") { + return invalid_config( + "runtime.record.enabled", + "supported only with the local environment provider", + ); + } + let has_artifact_root = config.runtime.artifacts.is_some() + || environment.is_some_and(|environment| environment.artifacts.is_some()); + if !has_artifact_root { + return invalid_config( + "runtime.record.enabled", + "requires runtime.artifacts or an environment artifact root", + ); + } + Ok(()) +} + +/// Core writes the record from its local adapter host path, so an adapter +/// that runs elsewhere would get an enabled record that is never written. +fn validate_record_adapter( + config: &FabricConfig, + descriptor: Option<&AdapterDescriptor>, +) -> Result<()> { + let local_host = descriptor.is_none_or(|descriptor| { + matches!( + descriptor.adapter_kind, + AdapterKind::Process | AdapterKind::Python + ) + }); + if config.runtime.record_enabled() && !local_host { + return invalid_config( + "runtime.record.enabled", + "supported only with a process or python adapter", + ); + } + Ok(()) +} + fn invalid_config(field: impl Into, reason: impl Into) -> Result { Err(FabricError::InvalidConfig { field: field.into(), @@ -2449,6 +2535,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() @@ -2585,6 +2673,7 @@ fn resolve_selected_descriptors( let resolution = resolve_resolution(&config, descriptor)?; let environment_plan = resolve_environment_plan(&config, &base_dir); validate_control_location(descriptor, environment_plan.as_ref())?; + validate_record_adapter(&config, descriptor)?; let capability_plan = resolve_capability_plan(&config, &base_dir, adapter_descriptor.as_ref()); if enforce_compatibility { validate_capability_plan_compatibility(&capability_plan, descriptor)?; @@ -2597,7 +2686,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 +2703,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 +4144,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 +4446,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, @@ -7532,4 +7657,100 @@ mod tests { ); } } + fn record_error(config: &FabricConfig) -> Option<(String, String)> { + match validate_config(config) { + Err(FabricError::InvalidConfig { field, reason }) => Some((field, reason)), + Err(error) => panic!("unexpected error: {error}"), + Ok(()) => None, + } + } + + #[test] + fn record_enabled_without_an_artifact_root_fails_validation() { + let mut config = typed_config("nvidia.fabric.hermes"); + config.runtime.record = Some(RecordConfig { enabled: true }); + + assert_eq!( + record_error(&config), + Some(( + "runtime.record.enabled".to_string(), + "requires runtime.artifacts or an environment artifact root".to_string() + )) + ); + config.environment.as_mut().expect("environment").artifacts = Some(PathBuf::from("runs")); + assert_eq!(record_error(&config), None); + config.environment.as_mut().expect("environment").artifacts = None; + config.runtime.artifacts = Some(PathBuf::from("runs")); + assert_eq!(record_error(&config), None); + } + + #[test] + fn record_enabled_with_a_non_local_provider_fails_validation() { + for provider in ["docker", "openshell"] { + let mut config = typed_config("nvidia.fabric.hermes"); + config.runtime.artifacts = Some(PathBuf::from("runs")); + config.runtime.record = Some(RecordConfig { enabled: true }); + let environment = config.environment.as_mut().expect("environment"); + environment.provider = provider.to_string(); + // Valid OpenShell task paths, so only the record check can fail. + environment.workspace = Some(PathBuf::from("/sandbox/workspace")); + environment.artifacts = Some(PathBuf::from("/sandbox/artifacts")); + + assert_eq!( + record_error(&config), + Some(( + "runtime.record.enabled".to_string(), + "supported only with the local environment provider".to_string() + )), + "{provider}" + ); + } + } + + #[test] + fn record_enabled_with_an_adapter_outside_the_local_host_fails_validation() { + let path = repository_root().join("adapters/python/claude/claude.fabric-adapter.json"); + let mut descriptor = load_adapter_descriptor(&path).expect("Claude descriptor"); + let mut config = typed_config("nvidia.fabric.claude"); + config.runtime.artifacts = Some(PathBuf::from("runs")); + config.runtime.record = Some(RecordConfig { enabled: true }); + assert!(validate_record_adapter(&config, Some(&descriptor)).is_ok()); + + for kind in [AdapterKind::Http, AdapterKind::NativePlugin] { + descriptor.adapter_kind = kind; + assert!(matches!( + validate_record_adapter(&config, Some(&descriptor)), + Err(FabricError::InvalidConfig { field, reason }) + if field == "runtime.record.enabled" + && reason == "supported only with a process or python adapter" + )); + } + config.runtime.record = None; + assert!(validate_record_adapter(&config, Some(&descriptor)).is_ok()); + } + + #[test] + fn record_disabled_or_absent_needs_no_artifact_root() { + let mut config = typed_config("nvidia.fabric.hermes"); + assert_eq!(record_error(&config), None); + config.runtime.record = Some(RecordConfig { enabled: false }); + assert_eq!(record_error(&config), None); + } + + #[test] + fn record_block_requires_enabled_and_rejects_unknown_fields() { + for runtime in [ + serde_json::json!({"record": {}}), + serde_json::json!({"record": {"enabled": true, "durable": true}}), + serde_json::json!({"record": true}), + ] { + let mut value = + serde_json::to_value(typed_config("nvidia.fabric.hermes")).expect("config JSON"); + value["runtime"] = runtime.clone(); + assert!( + serde_json::from_value::(value).is_err(), + "{runtime}" + ); + } + } } diff --git a/crates/fabric-core/src/lib.rs b/crates/fabric-core/src/lib.rs index 306bf48ed..97f5d589b 100644 --- a/crates/fabric-core/src/lib.rs +++ b/crates/fabric-core/src/lib.rs @@ -10,12 +10,20 @@ pub mod config; pub mod doctor; mod environment; pub mod error; +mod measurement; +pub mod model_usage; +pub mod price_catalog; +pub mod pricing; +mod record; 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 use record::run_record_folder; pub use adapter_contract::{ADAPTER_CONTRACT_VERSION, AdapterExtensionPoint}; pub use agent_execution::{ @@ -33,10 +41,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, RecordConfig, 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..97a646139 --- /dev/null +++ b/crates/fabric-core/src/price_catalog.rs @@ -0,0 +1,1130 @@ +// 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. For each candidate key, the first matching +//! catalog entry wins. +//! +//! 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, Serialize}; +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(|| BundledCatalog { + // A unit test checks that the shipped file is valid, so `None` only + // guards against a build that skipped the tests. + catalog: PricingCatalog::from_json(BUNDLED_CATALOG_JSON.as_bytes()).ok(), + sha256: sha256_fingerprint(BUNDLED_CATALOG_JSON.as_bytes()), +}); + +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. For each lookup key, the first matching entry wins. + 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, Deserialize, JsonSchema)] +pub struct UserPriceCatalog { + /// Absolute path the catalog was read from. + pub path: PathBuf, + /// `sha256:` followed by the lowercase hexadecimal SHA-256 of the file. + 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 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 their exact catalog fingerprint. + pub fn from_bytes(path: PathBuf, bytes: &[u8]) -> Result { + Ok(Self { + path, + sha256: sha256_fingerprint(bytes), + catalog: PricingCatalog::from_json(bytes)?, + }) + } +} + +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. The first matching entry in listed order wins for each key. + pub(crate) fn pricing_for(&self, provider: Option<&str>, model: &str) -> Option<&ModelPricing> { + lookup_keys(provider, model) + .iter() + .find_map(|key| self.entries.iter().find(|entry| entry.matches(key))) + } + + 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_is_the_sha256_of_the_file_bytes() { + assert_eq!( + sha256_fingerprint(b"abc"), + "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + let fingerprint = PriceCatalogs::with_user_catalog(None).bundled_sha256(); + assert_eq!( + fingerprint, + sha256_fingerprint(BUNDLED_CATALOG_JSON.as_bytes()) + ); + assert_eq!(fingerprint.len(), "sha256:".len() + 64); + } + + // 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); + } + + // 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..f3eb8fe12 --- /dev/null +++ b/crates/fabric-core/src/pricing.rs @@ -0,0 +1,791 @@ +// 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() { + 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; + let mut root_cache_read_required = false; + let mut root_cache_write_required = 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) => { + root_cache_read_required |= rates.cache_read != Some(rates.input); + root_cache_write_required |= rates.cache_write != Some(rates.input); + 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)); + root_cache_read_required |= rates.cache_read != Some(rates.input); + root_cache_write_required |= rates.cache_write != Some(rates.input); + 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 || usage.models.is_empty() { + Err("model_scope_unverified") + } else if inconsistent { + Err("inconsistent_usage") + } 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() + || (root_cache_read_required + && usage.cached_input_tokens.value.is_none() + && usage.input_tokens.value != Some(0)) + || (root_cache_write_required + && usage.cache_write_input_tokens.value.is_none() + && usage.input_tokens.value != Some(0)) + { + Err("pricing_usage_incomplete") + } else { + 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/record/invocation.rs b/crates/fabric-core/src/record/invocation.rs new file mode 100644 index 000000000..11df9c900 --- /dev/null +++ b/crates/fabric-core/src/record/invocation.rs @@ -0,0 +1,698 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! One invocation's record: the start line before the request, the end line +//! after the invocation. +//! +//! Record I/O errors do not replace the invocation result. A failed create, +//! write or sync stops the record for that invocation and adds a +//! `record_write_failed` event to the result; nothing here returns a record +//! error to the caller. + +use std::collections::BTreeMap; +use std::io; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant, SystemTime}; + +use serde_json::Value; + +use super::lines::{ + ContextValue, EndArtifact, EndError, EndLine, EndUsage, LineBody, ModelName, NotRecorded, + RecordLine, RecordSchema, RecordVersion, StartLine, cut_text, recorded_code, +}; +use super::writer::{RECORD_FILE_NAME, RecordWriter}; +use crate::config::RunPlan; +use crate::error::{FabricError, Result}; +use crate::runtime::{ + ArtifactManifest, ArtifactRef, ErrorInfo, ErrorStage, INVALID_AGENT_RUN_RESULT, + InvocationHandle, RunRequest, RunResult, RunStatus, RunUsage, RuntimeHandle, + event_with_metadata, unique_artifact_name, +}; +use crate::time::rfc3339_millis; + +/// Name of the result artifact that points at the record. +const RECORD_ARTIFACT_NAME: &str = "record"; +/// Kind of the result artifact that points at the record. +const RECORD_ARTIFACT_KIND: &str = "run_record"; +/// Event added to the result when the record could not be written. +const RECORD_WRITE_FAILED: &str = "record_write_failed"; +/// `RunRequest.context` keys whose string or integer values the start line +/// keeps: task identity and the Harbor trial link. +const RECORDED_CONTEXT_KEYS: [&str; 7] = [ + "task_id", + "task_revision", + "attempt", + "step", + "harbor_session_id", + "harbor_context_id", + "harbor_trial_name", +]; +/// End-line error code for a failed Fabric file write: `adapter-invocation.json` +/// before the request, or a result artifact after the adapter returned. +const ARTIFACT_WRITE_FAILED: &str = "artifact_write_failed"; +/// End-line error code for any other Fabric error after the record started. +const FABRIC_ERROR: &str = "fabric_error"; + +/// What the start line needs from core's invocation path. +pub(crate) struct RecordStart<'a> { + pub plan: &'a RunPlan, + pub runtime: &'a RuntimeHandle, + pub invocation: &'a InvocationHandle, + pub request: &'a RunRequest, + /// The invocation folder, `/.fabric//`. + pub fabric_home: &'a Path, + /// The host timeout that applies to this invocation. + pub timeout: Duration, +} + +/// The record of one running invocation. +#[derive(Debug)] +pub(crate) struct InvocationRecorder { + runtime_id: String, + invocation_id: String, + request_id: String, + path: PathBuf, + started: Instant, + writer: Option, + wrote_start: bool, + failure: Option, +} + +impl InvocationRecorder { + /// Create the record and write its start line. + pub(crate) fn start(start: RecordStart<'_>) -> Self { + let path = start.fabric_home.join(RECORD_FILE_NAME); + let mut recorder = Self { + runtime_id: start.invocation.runtime_id.clone(), + invocation_id: start.invocation.invocation_id.clone(), + request_id: start.invocation.request_id.clone(), + started: Instant::now(), + writer: None, + wrote_start: false, + failure: None, + path, + }; + match RecordWriter::create(&recorder.path) { + Ok(writer) => { + recorder.writer = Some(writer); + recorder.append(LineBody::Start(start_line(&start))); + recorder.wrote_start = recorder.failure.is_none(); + } + Err(error) => recorder.failure = Some(error), + } + recorder + } + + /// Write and sync the end line for `outcome`, then return it. A result + /// gains the `record` artifact when the start line was written, and a + /// `record_write_failed` event when any write or the sync failed. + pub(crate) fn finish(mut self, outcome: Result) -> Result { + let elapsed_ms = u64::try_from(self.started.elapsed().as_millis()).unwrap_or(u64::MAX); + let end = match &outcome { + Ok(result) => result_end_line(result, elapsed_ms), + Err(error) => error_end_line(error, elapsed_ms), + }; + self.append(LineBody::End(Box::new(end))); + if let Some(writer) = &self.writer + && let Err(error) = writer.sync() + { + self.failure = Some(error); + } + if outcome.is_err() + && let Some(error) = &self.failure + { + // There is no returned result to carry the recording event. + eprintln!("nemo-fabric: invocation recording failed: {error}"); + } + let mut result = outcome?; + if self.wrote_start { + let name = unique_artifact_name(&result.artifacts, RECORD_ARTIFACT_NAME); + result.artifacts.artifacts.push(ArtifactRef { + name, + kind: RECORD_ARTIFACT_KIND.to_string(), + path: self.path.clone(), + media_type: Some("application/jsonl".to_string()), + metadata: BTreeMap::new(), + }); + } + if let Some(error) = &self.failure { + result.events.push(event_with_metadata( + RECORD_WRITE_FAILED, + format!("run record write failed; the record stops here: {error}"), + BTreeMap::from([ + ( + "runtime_id".to_string(), + Value::from(self.runtime_id.as_str()), + ), + ( + "invocation_id".to_string(), + Value::from(self.invocation_id.as_str()), + ), + ]), + )); + } + Ok(result) + } + + /// Append one line. After the first failure the record stops. + fn append(&mut self, body: LineBody) { + let Some(writer) = self.writer.as_mut() else { + return; + }; + let line = RecordLine { + schema: RecordSchema::RunRecord, + version: RecordVersion::V1Alpha1, + runtime_id: self.runtime_id.clone(), + invocation_id: self.invocation_id.clone(), + request_id: self.request_id.clone(), + time: rfc3339_millis(SystemTime::now()), + body, + }; + if let Err(error) = writer.append(&line) { + self.failure = Some(error); + self.writer = None; + } + } +} + +fn start_line(start: &RecordStart<'_>) -> StartLine { + StartLine { + agent_name: cut_text(&start.runtime.agent_name), + harness: cut_text(&start.runtime.harness), + adapter_id: start.runtime.adapter_id.as_deref().map(cut_text), + models: start + .plan + .config + .models + .iter() + .map(|(role, model)| { + ( + role.clone(), + ModelName { + provider: cut_text(&model.provider), + model: cut_text(&model.model), + }, + ) + }) + .collect(), + fabric_version: crate::version().to_string(), + timeout_ms: u64::try_from(start.timeout.as_millis()).unwrap_or(u64::MAX), + context: start + .request + .context + .iter() + .map(|(key, value)| (key.clone(), context_value(key, value))) + .collect(), + override_keys: override_keys(start.request.overrides.as_ref()), + } +} + +/// The sorted top-level key names of `overrides`; empty unless it is an object. +fn override_keys(overrides: Option<&Value>) -> Vec { + let Some(Value::Object(overrides)) = overrides else { + return Vec::new(); + }; + let mut keys: Vec = overrides.keys().map(|key| cut_text(key)).collect(); + keys.sort(); + keys +} + +/// An allowlisted key keeps a string (cut) or integer value; any other key, +/// or an allowlisted key with another value type, is recorded by name only. +fn context_value(key: &str, value: &Value) -> ContextValue { + if !RECORDED_CONTEXT_KEYS.contains(&key) { + return ContextValue::NotRecorded(NotRecorded::VALUE); + } + match value { + Value::String(text) => ContextValue::Text(cut_text(text)), + Value::Number(number) => number + .as_i64() + .map_or(ContextValue::NotRecorded(NotRecorded::VALUE), |integer| { + ContextValue::Integer(integer) + }), + _ => ContextValue::NotRecorded(NotRecorded::VALUE), + } +} + +fn result_end_line(result: &RunResult, elapsed_ms: u64) -> EndLine { + EndLine { + outcome_scope: "invocation", + result: Some("run-result.json".to_string()), + status: result.status, + elapsed_ms, + error: result.error.as_ref().map(result_error), + usage: result.usage.as_ref().map(end_usage), + model_usage: result.model_usage.clone(), + artifacts: end_artifacts(&result.artifacts), + } +} + +fn error_end_line(error: &FabricError, elapsed_ms: u64) -> EndLine { + let (code, stage) = match error { + FabricError::AdapterLifecycleOperation { code, .. } => (code.as_str(), ErrorStage::Invoke), + // After the record starts, the check of the adapter's result + // extensions raises these: an extension the descriptor does not + // declare, or one that breaks its declared schema. Both are the same + // fault as a rejected result. + FabricError::AdapterCompatibility { .. } | FabricError::InvalidAdapterExtension { .. } => { + (INVALID_AGENT_RUN_RESULT, ErrorStage::Invoke) + } + FabricError::Write { .. } => (ARTIFACT_WRITE_FAILED, ErrorStage::Artifact), + _ => (FABRIC_ERROR, ErrorStage::Invoke), + }; + EndLine { + outcome_scope: "invocation", + result: None, + status: RunStatus::Failed, + elapsed_ms, + error: Some(EndError { + code: recorded_code(code), + stage, + retryable: false, + }), + usage: None, + model_usage: None, + artifacts: Vec::new(), + } +} + +fn result_error(error: &ErrorInfo) -> EndError { + EndError { + code: recorded_code(&error.code), + stage: error.stage, + retryable: error.retryable, + } +} + +fn end_usage(usage: &RunUsage) -> EndUsage { + EndUsage { + 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, + } +} + +fn end_artifacts(manifest: &ArtifactManifest) -> Vec { + manifest + .artifacts + .iter() + .map(|artifact| EndArtifact { + name: cut_text(&artifact.name), + kind: cut_text(&artifact.kind), + media_type: artifact.media_type.as_deref().map(cut_text), + path: manifest + .root + .as_deref() + .and_then(|root| artifact.path.strip_prefix(root).ok()) + .map(|relative| relative.to_string_lossy().into_owned()), + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{FabricConfig, ResolveContext, resolve_run_plan_from_config}; + use crate::record::test_hooks::{self, Fault}; + + #[test] + fn rejected_result_extensions_read_invalid_agent_run_result() { + let undeclared = FabricError::AdapterCompatibility { + adapter_id: "demo".to_string(), + field: "result.extensions".to_string(), + reason: "not declared".to_string(), + }; + let schema_invalid = FabricError::InvalidAdapterExtension { + adapter_id: "demo".to_string(), + descriptor_source: crate::config::DescriptorSource::Bundled, + descriptor_path: std::path::PathBuf::from("adapter.json"), + extension_path: "result.extensions.demo".to_string(), + reason: "breaks the declared schema".to_string(), + }; + for error in [undeclared, schema_invalid] { + let line = error_end_line(&error, 1); + assert_eq!( + line.error.expect("end line error").code, + INVALID_AGENT_RUN_RESULT, + "{error}" + ); + } + } + + fn record_test_fixture() -> (RunPlan, RuntimeHandle, InvocationHandle) { + let config: FabricConfig = serde_json::from_value(serde_json::json!({ + "schema_version": "fabric.agent/v1alpha1", + "metadata": {"name": "demo"}, + "harness": {"adapter_id": "nvidia.fabric.hermes", "resolution": "preinstalled"}, + "runtime": {}, + "models": {"default": {"provider": "nvidia", "model": "nemotron"}}, + })) + .expect("config"); + let plan = resolve_run_plan_from_config(config, ResolveContext::new("/tmp/fabric-base")) + .expect("plan"); + let runtime: RuntimeHandle = serde_json::from_value(serde_json::json!({ + "runtime_id": "rt", "runtime_binding": "binding", "agent_name": "demo", + "harness": "nvidia.fabric.hermes", "adapter_kind": "python", + "adapter_id": "nvidia.fabric.hermes", + "environment": { + "environment_id": "env", "provider": "local", + "control_location": "external_control", "ownership": "caller_owned", + }, + })) + .expect("runtime handle"); + let invocation = InvocationHandle { + invocation_id: "inv".to_string(), + request_id: "req".to_string(), + runtime_id: "rt".to_string(), + }; + (plan, runtime, invocation) + } + + #[test] + fn start_line_keeps_override_and_unknown_context_key_names_only() { + let (plan, runtime, invocation) = record_test_fixture(); + let mut request = RunRequest::text("input"); + request.context = BTreeMap::from([ + ("task_id".to_string(), Value::from("t".repeat(300))), + ("task_revision".to_string(), Value::from(1.5)), + ( + "headers".to_string(), + serde_json::json!({"authorization": "Bearer abc"}), + ), + ]); + request.overrides = Some(serde_json::json!({"zeta": 1, "acme": {"api_key": "sk-123"}})); + let start = RecordStart { + plan: &plan, + runtime: &runtime, + invocation: &invocation, + request: &request, + fabric_home: Path::new("/unused"), + timeout: Duration::from_secs(5), + }; + + let line = serde_json::to_value(RecordLine { + schema: RecordSchema::RunRecord, + version: RecordVersion::V1Alpha1, + runtime_id: "rt".to_string(), + invocation_id: "inv".to_string(), + request_id: "req".to_string(), + time: "2026-10-14T18:01:10.205Z".to_string(), + body: LineBody::Start(start_line(&start)), + }) + .expect("start line"); + + let schema = crate::schema::generate_schema(crate::schema::SchemaName::RunRecordLine) + .expect("record line schema"); + let validator = jsonschema::validator_for(&schema).expect("valid schema"); + assert!(validator.is_valid(&line), "{line}"); + assert_eq!(line["override_keys"], serde_json::json!(["acme", "zeta"])); + assert_eq!( + line["context"]["headers"], + serde_json::json!({"recorded": false}) + ); + assert_eq!( + line["context"]["task_revision"], + serde_json::json!({"recorded": false}) + ); + assert_eq!( + line["context"]["task_id"] + .as_str() + .expect("task id") + .chars() + .count(), + 256 + ); + assert_eq!(line["timeout_ms"], 5_000); + assert_eq!( + line["models"], + serde_json::json!({"default": {"provider": "nvidia", "model": "nemotron"}}) + ); + let text = line.to_string(); + assert!( + !text.contains("sk-123") && !text.contains("Bearer"), + "{text}" + ); + } + + #[test] + fn start_line_keeps_long_role_and_context_key_names_apart() { + let (mut plan, runtime, invocation) = record_test_fixture(); + let shared = "k".repeat(300); + let model = plan.config.models["default"].clone(); + for suffix in ["a", "b"] { + plan.config + .models + .insert(format!("{shared}{suffix}"), model.clone()); + } + let mut request = RunRequest::text("input"); + request.context = BTreeMap::from([ + (format!("{shared}a"), Value::from(1)), + (format!("{shared}b"), Value::from(2)), + ]); + let start = RecordStart { + plan: &plan, + runtime: &runtime, + invocation: &invocation, + request: &request, + fabric_home: Path::new("/unused"), + timeout: Duration::from_secs(5), + }; + + let line = start_line(&start); + + assert_eq!(line.models.len(), 3); + assert_eq!(line.context.len(), 2); + for suffix in ["a", "b"] { + assert!(line.models.contains_key(&format!("{shared}{suffix}"))); + assert!(line.context.contains_key(&format!("{shared}{suffix}"))); + } + } + + fn temp_folder(name: &str) -> PathBuf { + let folder = std::env::temp_dir().join(format!( + "fabric-recorder-{name}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&folder).expect("folder"); + folder + } + + fn succeeded_result(invocation: &InvocationHandle) -> RunResult { + serde_json::from_value(serde_json::json!({ + "agent_name": "a", "harness": "h", "adapter_kind": "python", + "runtime_id": invocation.runtime_id, "invocation_id": invocation.invocation_id, + "request_id": invocation.request_id, "status": "succeeded", "output": "done", + })) + .expect("result") + } + + #[test] + fn finish_names_the_record_artifact_apart_from_an_adapter_artifact_named_record() { + let (plan, runtime, invocation) = record_test_fixture(); + let folder = temp_folder("unique-name"); + let request = RunRequest::text("input"); + let recorder = InvocationRecorder::start(RecordStart { + plan: &plan, + runtime: &runtime, + invocation: &invocation, + request: &request, + fabric_home: &folder, + timeout: Duration::from_secs(5), + }); + let mut result = succeeded_result(&invocation); + result.artifacts.artifacts.push(ArtifactRef { + name: RECORD_ARTIFACT_NAME.to_string(), + kind: "report".to_string(), + path: folder.join("report.json"), + media_type: None, + metadata: BTreeMap::new(), + }); + + let finished = recorder.finish(Ok(result)).expect("result"); + + let names: Vec<&str> = finished + .artifacts + .artifacts + .iter() + .map(|artifact| artifact.name.as_str()) + .collect(); + assert_eq!(names, ["record", "record_2"]); + assert_eq!(finished.artifacts.artifacts[1].kind, RECORD_ARTIFACT_KIND); + } + + #[test] + fn finish_after_a_failed_create_adds_the_event_and_no_record_artifact() { + let (plan, runtime, invocation) = record_test_fixture(); + let folder = temp_folder("create-failure"); + std::fs::write(folder.join(RECORD_FILE_NAME), "planted").expect("planted file"); + let request = RunRequest::text("input"); + let recorder = InvocationRecorder::start(RecordStart { + plan: &plan, + runtime: &runtime, + invocation: &invocation, + request: &request, + fabric_home: &folder, + timeout: Duration::from_secs(5), + }); + let result = succeeded_result(&invocation); + + let finished = recorder.finish(Ok(result.clone())).expect("result"); + + assert_eq!(finished.artifacts, result.artifacts); + assert_eq!(finished.events.len(), 1); + assert_eq!(finished.events[0].kind, RECORD_WRITE_FAILED); + assert_eq!(finished.status, RunStatus::Succeeded); + assert_eq!( + std::fs::read_to_string(folder.join(RECORD_FILE_NAME)).expect("read"), + "planted" + ); + } + + fn failed_result_with_usage(invocation: &InvocationHandle) -> RunResult { + serde_json::from_value(serde_json::json!({ + "agent_name": "a", "harness": "h", "adapter_kind": "python", + "runtime_id": invocation.runtime_id, "invocation_id": invocation.invocation_id, + "request_id": invocation.request_id, "status": "failed", "output": "partial", + "error": { + "stage": "invoke", "code": "adapter_failure", + "message": "adapter gave up", "retryable": true, + }, + "usage": {"input_tokens": 3, "output_tokens": 5, "cost_usd": 0.25}, + })) + .expect("result") + } + + /// Start and finish one record with `fault` injected, and return the + /// finished outcome with the record file's lines. + fn finish_with_fault( + name: &str, + fault: Fault, + outcome: Result, + ) -> (Result, Vec) { + let (plan, runtime, invocation) = record_test_fixture(); + let folder = temp_folder(name); + let request = RunRequest::text("input"); + test_hooks::FAULT.with(|slot| slot.set(Some(fault))); + let recorder = InvocationRecorder::start(RecordStart { + plan: &plan, + runtime: &runtime, + invocation: &invocation, + request: &request, + fabric_home: &folder, + timeout: Duration::from_secs(5), + }); + let finished = recorder.finish(outcome); + test_hooks::FAULT.with(|slot| slot.set(None)); + let lines = std::fs::read_to_string(folder.join(RECORD_FILE_NAME)) + .expect("read record") + .lines() + .map(|line| serde_json::from_str(line).expect("record line")) + .collect(); + (finished, lines) + } + + /// `finished` equals `original` apart from the artifacts and events that + /// recording adds, and carries one `record_write_failed` event. + fn assert_result_kept_with_failure_event(finished: &RunResult, original: &RunResult) { + let mut without_record = finished.clone(); + without_record.artifacts = original.artifacts.clone(); + without_record.events = original.events.clone(); + assert_eq!(&without_record, original); + let failures: Vec<_> = finished + .events + .iter() + .filter(|event| event.kind == RECORD_WRITE_FAILED) + .collect(); + assert_eq!(failures.len(), 1, "{:?}", finished.events); + assert_eq!( + failures[0].metadata["runtime_id"], + original.runtime_id.as_str() + ); + assert_eq!( + failures[0].metadata["invocation_id"], + original.invocation_id.as_str() + ); + } + + fn has_record_artifact(result: &RunResult) -> bool { + result + .artifacts + .artifacts + .iter() + .any(|artifact| artifact.kind == RECORD_ARTIFACT_KIND) + } + + #[test] + fn finish_after_a_failed_start_line_keeps_the_result_and_writes_nothing() { + let (_, _, invocation) = record_test_fixture(); + let original = failed_result_with_usage(&invocation); + + let (finished, lines) = + finish_with_fault("start-append", Fault::StartAppend, Ok(original.clone())); + + let finished = finished.expect("result"); + assert_result_kept_with_failure_event(&finished, &original); + assert!(!has_record_artifact(&finished)); + assert!(lines.is_empty(), "{lines:?}"); + } + + #[test] + fn finish_after_a_failed_end_line_keeps_the_result_and_the_start_line() { + let (_, _, invocation) = record_test_fixture(); + let original = failed_result_with_usage(&invocation); + + let (finished, lines) = + finish_with_fault("end-append", Fault::EndAppend, Ok(original.clone())); + + let finished = finished.expect("result"); + assert_result_kept_with_failure_event(&finished, &original); + assert!(has_record_artifact(&finished)); + let kinds: Vec<&Value> = lines.iter().map(|line| &line["kind"]).collect(); + assert_eq!(kinds, ["start"]); + } + + #[test] + fn finish_after_a_failed_sync_keeps_the_result_and_both_lines() { + let (_, _, invocation) = record_test_fixture(); + let original = failed_result_with_usage(&invocation); + + let (finished, lines) = finish_with_fault("sync", Fault::Sync, Ok(original.clone())); + + let finished = finished.expect("result"); + assert_result_kept_with_failure_event(&finished, &original); + assert!(has_record_artifact(&finished)); + let kinds: Vec<&Value> = lines.iter().map(|line| &line["kind"]).collect(); + assert_eq!(kinds, ["start", "end"]); + assert_eq!(lines[1]["usage"]["input_tokens"], 3); + } + + #[test] + fn finish_returns_the_invocation_error_when_recording_also_fails() { + for fault in [Fault::StartAppend, Fault::EndAppend, Fault::Sync] { + let error = FabricError::AdapterLifecycleOperation { + operation: "invoke", + runtime_id: "rt".to_string(), + code: "host_timeout".to_string(), + message: "adapter host did not answer".to_string(), + diagnostics: String::new(), + }; + let expected = error.to_string(); + + let (finished, _) = finish_with_fault("error", fault, Err(error)); + + let error = finished.expect_err("invocation error"); + assert_eq!(error.to_string(), expected, "{fault:?}"); + assert!( + matches!(&error, FabricError::AdapterLifecycleOperation { code, .. } if code == "host_timeout"), + "{fault:?}: {error}" + ); + } + } +} diff --git a/crates/fabric-core/src/record/lines.rs b/crates/fabric-core/src/record/lines.rs new file mode 100644 index 000000000..9ba4aba10 --- /dev/null +++ b/crates/fabric-core/src/record/lines.rs @@ -0,0 +1,275 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Line types of `run-record.jsonl`, schema `nemo.fabric.run_record` version +//! `v1alpha1`. +//! +//! Every field is typed. A line holds identifiers, configuration names, normalized +//! accounting, resource observations, closed codes and allowlisted request context +//! values. It never captures prompts, output, exception messages or arbitrary +//! adapter metadata. + +use std::collections::BTreeMap; + +use schemars::JsonSchema; +use serde::Serialize; + +use crate::runtime::{ErrorStage, RunStatus}; + +/// Longest value a line keeps from config or the request, in characters. +/// Model role and context key names are kept whole, so map keys stay distinct. +const MAX_VALUE_CHARS: usize = 256; +/// Longest adapter error code a line keeps. +const MAX_CODE_CHARS: usize = 128; +/// Error code written in place of a code outside `^[a-z0-9_.:-]{1,128}$`. +const UNRECOGNIZED_CODE: &str = "unrecognized"; + +/// One line of a run record. +#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)] +#[schemars(title = "RunRecordLine")] +pub(crate) struct RecordLine { + /// Always `nemo.fabric.run_record`. + pub schema: RecordSchema, + /// Line format version. `v1alpha1` may change until a record reader ships. + pub version: RecordVersion, + /// Runtime id. + pub runtime_id: String, + /// Invocation id. + pub invocation_id: String, + /// Request id. + pub request_id: String, + /// UTC wall-clock time the line was written, RFC 3339 with milliseconds. + #[schemars(extend("format" = "date-time"))] + pub time: String, + /// Line kind and its fields. + #[serde(flatten)] + pub body: LineBody, +} + +/// The record's schema name. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)] +pub(crate) enum RecordSchema { + /// `nemo.fabric.run_record`. + #[serde(rename = "nemo.fabric.run_record")] + RunRecord, +} + +/// The record's line format version. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)] +pub(crate) enum RecordVersion { + /// `v1alpha1`. + #[serde(rename = "v1alpha1")] + V1Alpha1, +} + +/// A line's kind, with the fields that kind carries. +#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub(crate) enum LineBody { + /// Written once, before core sends the request to the adapter host. + Start(StartLine), + /// Written once, after the invocation returned a result or failed. + End(Box), +} + +/// Fields of the start line. +#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)] +pub(crate) struct StartLine { + /// Config name (`metadata.name`). + pub agent_name: String, + /// Harness identifier. + pub harness: String, + /// Adapter implementation id. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub adapter_id: Option, + /// Configured models by role: provider and model name only. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub models: BTreeMap, + /// NeMo Fabric version that wrote the record. + pub fabric_version: String, + /// Host invocation timeout that applied, in milliseconds. + pub timeout_ms: u64, + /// `RunRequest.context`. Allowlisted identity and Harbor link keys keep a + /// string or integer value; every other key is listed by name only. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub context: BTreeMap, + /// Top-level key names of `RunRequest.overrides`, sorted. Values are never + /// recorded. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub override_keys: Vec, +} + +/// One configured model. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +pub(crate) struct ModelName { + /// Model provider name. + pub provider: String, + /// Provider model identifier. + pub model: String, +} + +/// A request context value as the start line keeps it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +#[serde(untagged)] +pub(crate) enum ContextValue { + /// An allowlisted key's string value, cut to 256 characters. + Text(String), + /// An allowlisted key's integer value. + Integer(i64), + /// Any other key or value: the key name only. + NotRecorded(NotRecorded), +} + +/// Marks a key whose value the record leaves out. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)] +pub(crate) struct NotRecorded { + /// Always `false`. + #[schemars(extend("const" = false))] + pub recorded: bool, +} + +impl NotRecorded { + pub(crate) const VALUE: Self = Self { recorded: false }; +} + +/// Fields of the end line. +#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)] +pub(crate) struct EndLine { + /// This line observes the invocation, before one-shot runtime shutdown. + #[schemars(extend("const" = "invocation"))] + pub outcome_scope: &'static str, + /// Expected full-result file next to this record; absence means not saved. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: Option, + /// Final status. A Fabric lifecycle error, such as a host timeout, reads `failed`. + pub status: RunStatus, + /// Monotonic time from the start line to the end line, in milliseconds. + pub elapsed_ms: u64, + /// Error code, stage and retryable flag, without the message. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + /// Numeric usage counters, without adapter metadata. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub usage: Option, + /// Validated normalized accounting frozen at the invocation boundary. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_usage: Option, + /// Result artifacts, with paths relative to the artifact root. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub artifacts: Vec, +} + +/// The end line's error. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +pub(crate) struct EndError { + /// Error code matching `^[a-z0-9_.:-]{1,128}$`, else `unrecognized`. + #[schemars(regex(pattern = r"^[a-z0-9_.:-]{1,128}$"))] + pub code: String, + /// Lifecycle stage where the failure surfaced. + pub stage: ErrorStage, + /// Whether a consumer-level retry is considered safe. + pub retryable: bool, +} + +/// The end line's usage: the numeric `RunUsage` fields. +#[derive(Debug, Clone, Default, PartialEq, Serialize, JsonSchema)] +pub(crate) struct EndUsage { + /// Input tokens. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_tokens: Option, + /// Cached input tokens. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cached_input_tokens: Option, + /// Whether `input_tokens` includes `cached_input_tokens`; absent means unknown. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_tokens_include_cache: Option, + /// Output tokens. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_tokens: Option, + /// Total tokens reported by the provider. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub total_tokens: Option, + /// Reported cost in US dollars. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 0.0))] + pub cost_usd: Option, +} + +/// One result artifact on the end line. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +pub(crate) struct EndArtifact { + /// Logical artifact name. + pub name: String, + /// Artifact kind. + pub kind: String, + /// Media type. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub media_type: Option, + /// Path relative to the artifact root; absent for a path outside it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, +} + +/// Cut `text` to [`MAX_VALUE_CHARS`] characters: 255 plus "…". +pub(crate) fn cut_text(text: &str) -> String { + if text.chars().count() <= MAX_VALUE_CHARS { + return text.to_string(); + } + let mut cut: String = text.chars().take(MAX_VALUE_CHARS - 1).collect(); + cut.push('…'); + cut +} + +/// An error code as the end line keeps it: unchanged when it matches +/// `^[a-z0-9_.:-]{1,128}$`, else [`UNRECOGNIZED_CODE`], because a code outside +/// that pattern may be a message. +pub(crate) fn recorded_code(code: &str) -> String { + let valid = (1..=MAX_CODE_CHARS).contains(&code.len()) + && code.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"_.:-".contains(&byte) + }); + if valid { + code.to_string() + } else { + UNRECOGNIZED_CODE.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cut_text_over_the_limit_keeps_255_chars_and_an_ellipsis() { + let cut = cut_text(&"é".repeat(1000)); + + assert_eq!(cut.chars().count(), MAX_VALUE_CHARS); + assert!(cut.ends_with('…')); + assert_eq!(cut_text("short"), "short"); + assert_eq!(cut_text(&"a".repeat(256)), "a".repeat(256)); + } + + #[test] + fn recorded_code_keeps_only_codes_in_the_pattern() { + for code in [ + "timeout", + "host_timeout", + "provider.rate_limit", + "http:429", + "a-b", + ] { + assert_eq!(recorded_code(code), code); + } + for code in [ + "", + "Timeout", + "rate limit", + "error: key sk-123", + "über", + &"a".repeat(129), + ] { + assert_eq!(recorded_code(code), UNRECOGNIZED_CODE, "{code}"); + } + assert_eq!(recorded_code(&"a".repeat(128)), "a".repeat(128)); + } +} diff --git a/crates/fabric-core/src/record/mod.rs b/crates/fabric-core/src/record/mod.rs new file mode 100644 index 000000000..e7eb54c2d --- /dev/null +++ b/crates/fabric-core/src/record/mod.rs @@ -0,0 +1,27 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The opt-in per-invocation run record, `run-record.jsonl`. +//! +//! When `runtime.record.enabled` is set, core appends one start line before it +//! sends the request to the adapter host and one end line after the invocation +//! returns or fails. Recording is synchronous and best-effort: record I/O +//! errors do not replace the invocation result. A failed write stops the +//! record and becomes a `record_write_failed` event on the result, and slow +//! storage delays the invocation. +//! +//! Everything here is crate-private. The line format is public only through +//! the `run-record-line` JSON Schema, version `v1alpha1`. + +mod invocation; +mod lines; +mod writer; + +pub(crate) use invocation::{InvocationRecorder, RecordStart}; +pub(crate) use lines::RecordLine; +#[cfg(test)] +pub(crate) use writer::test_hooks; + +mod results; +pub use results::run_record_folder; +pub(crate) use results::{RequestRecord, RequestRecorder, RequestStage, persist_result}; diff --git a/crates/fabric-core/src/record/results.rs b/crates/fabric-core/src/record/results.rs new file mode 100644 index 000000000..aa3ac2335 --- /dev/null +++ b/crates/fabric-core/src/record/results.rs @@ -0,0 +1,527 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Full finalized results and request-level evidence before a runtime exists. +//! These files may contain result output; the bounded invocation record does not. + +use std::collections::BTreeMap; +use std::fs::{File, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +use schemars::JsonSchema; +use serde::Serialize; +use serde_json::Value; + +use crate::config::RunPlan; +use crate::runtime::{ + ArtifactRef, RunRequest, RunResult, event_with_metadata, new_id, unique_artifact_name, +}; +use crate::time::rfc3339_millis; + +pub(crate) const RESULT_FILE_NAME: &str = "run-result.json"; +const WRITE_FAILED: &str = "record_write_failed"; +const MAX_REQUEST_RECORD_BYTES: usize = 64 * 1024; + +/// The recording root. This is known before startup and requires no inner IDs. +pub fn run_record_folder(plan: &RunPlan) -> Option { + if !plan.config.runtime.record_enabled() { + return None; + } + plan.config + .runtime + .artifacts + .as_ref() + .map(|path| { + if path.is_absolute() { + path.clone() + } else { + plan.base_dir.join(path) + } + }) + .or_else(|| { + plan.environment_plan + .as_ref() + .and_then(|environment| environment.artifacts.clone()) + }) + .map(|root| root.join(".fabric")) +} + +fn private_new_file(path: &Path) -> io::Result { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options.open(path) +} + +/// Publish a complete JSON file without replacing a file or a symlink. +/// A failed write may leave an explicitly named staging file, never a final file. +fn publish(path: &Path, value: &impl serde::Serialize) -> io::Result<()> { + let bytes = serde_json::to_vec_pretty(value).map_err(io::Error::other)?; + publish_bytes(path, &bytes) +} + +fn publish_request(path: &Path, value: &RequestRecord) -> io::Result<()> { + let bytes = serde_json::to_vec_pretty(value).map_err(io::Error::other)?; + if bytes.len() > MAX_REQUEST_RECORD_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("request record exceeds {MAX_REQUEST_RECORD_BYTES} bytes"), + )); + } + publish_bytes(path, &bytes) +} + +fn publish_bytes(path: &Path, bytes: &[u8]) -> io::Result<()> { + let pending = path.with_extension(format!("{}.pending", new_id("write"))); + let mut file = private_new_file(&pending)?; + let published = (|| { + file.write_all(bytes)?; + file.sync_all()?; + // Unlike rename, this cannot replace an existing file or symlink. + std::fs::hard_link(&pending, path) + })(); + drop(file); + // Only this operation's newly created staging path can be removed. + if let Err(error) = cleanup_staging(&pending) { + // Publication has its own outcome. A private staging-link cleanup + // failure must not invalidate or mutate an already frozen result. + eprintln!( + "nemo-fabric: could not clean recording staging file {}: {error}", + pending.display() + ); + } + published +} + +fn cleanup_staging(path: &Path) -> io::Result<()> { + #[cfg(test)] + if CLEANUP_FAILURE.with(std::cell::Cell::get) { + return Err(io::Error::other("injected staging cleanup failure")); + } + std::fs::remove_file(path) +} + +#[cfg(test)] +thread_local! { + static CLEANUP_FAILURE: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +fn add_failure(result: &mut RunResult, error: &io::Error, artifact: &str) { + result.events.push(event_with_metadata( + WRITE_FAILED, + format!("could not save {artifact}: {error}"), + BTreeMap::from([("artifact".to_string(), Value::from(artifact))]), + )); +} + +/// Save exactly the returned result after its invocation or one-shot lifecycle. +pub(crate) fn persist_result(plan: &RunPlan, mut result: RunResult, boundary: &str) -> RunResult { + let Some(root) = run_record_folder(plan) else { + return result; + }; + let directory = root.join(&result.runtime_id).join(&result.invocation_id); + let path = directory.join(RESULT_FILE_NAME); + let name = unique_artifact_name(&result.artifacts, "run_result"); + let artifact = ArtifactRef { + name: name.clone(), + kind: "run_result".to_string(), + path: path.clone(), + media_type: Some("application/json".to_string()), + metadata: BTreeMap::from([("boundary".to_string(), Value::from(boundary))]), + }; + result.artifacts.artifacts.push(artifact); + let saved = std::fs::create_dir_all(&directory).and_then(|()| publish(&path, &result)); + if let Err(error) = saved { + result + .artifacts + .artifacts + .retain(|artifact| artifact.name != name); + add_failure(&mut result, &error, RESULT_FILE_NAME); + } + result +} + +/// A request record does not imply a runtime or invocation was created. +#[derive(Debug, Serialize, JsonSchema)] +pub(crate) struct RequestRecord { + schema: RequestSchema, + version: super::lines::RecordVersion, + request_id: String, + #[schemars(extend("format" = "date-time"))] + time: String, + #[serde(flatten)] + body: RequestBody, +} + +#[derive(Debug, Serialize, JsonSchema)] +enum RequestSchema { + #[serde(rename = "nemo.fabric.request_record")] + RequestRecord, +} + +#[derive(Debug, Serialize, JsonSchema)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum RequestBody { + Start, + End { + #[serde(flatten)] + outcome: RequestOutcome, + }, +} + +#[derive(Debug, Serialize, JsonSchema)] +#[serde(tag = "state", rename_all = "snake_case")] +enum RequestOutcome { + Returned { + runtime_id: String, + invocation_id: String, + result: String, + }, + NoResult { + stage: RequestStage, + }, +} + +#[derive(Debug, Serialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub(crate) enum RequestStage { + Start, + Invoke, +} + +/// Bounded outer evidence for a request that can fail before inner IDs exist. +pub(crate) struct RequestRecorder { + directory: Option, + request_id: String, + failure: Option, +} + +impl RequestRecorder { + pub(crate) fn start(plan: &RunPlan, request: &RunRequest) -> Self { + let directory = + run_record_folder(plan).map(|root| root.join("requests").join(new_id("dispatch"))); + let mut recorder = Self { + directory, + request_id: request.request_id.clone(), + failure: None, + }; + if let Some(directory) = &recorder.directory { + let value = recorder.line(RequestBody::Start); + recorder.failure = std::fs::create_dir_all(directory) + .and_then(|()| publish_request(&directory.join("request-start.json"), &value)) + .err(); + } + recorder + } + + pub(crate) fn returned(mut self, result: &mut RunResult) { + self.finish(RequestOutcome::Returned { + runtime_id: result.runtime_id.clone(), + invocation_id: result.invocation_id.clone(), + result: format!( + "{}/{}/{}", + result.runtime_id, result.invocation_id, RESULT_FILE_NAME + ), + }); + if let Some(error) = self.failure { + add_failure(result, &error, "request record"); + } + } + + pub(crate) fn no_result(mut self, stage: RequestStage) { + self.finish(RequestOutcome::NoResult { stage }); + // No result exists to carry an event. Keep the execution error intact + // and make the independent recording failure visible on stderr. + if let Some(error) = self.failure { + eprintln!("nemo-fabric: request recording failed: {error}"); + } + } + + fn line(&self, body: RequestBody) -> RequestRecord { + RequestRecord { + schema: RequestSchema::RequestRecord, + version: super::lines::RecordVersion::V1Alpha1, + request_id: self.request_id.clone(), + time: rfc3339_millis(SystemTime::now()), + body, + } + } + + fn finish(&mut self, outcome: RequestOutcome) { + let Some(directory) = &self.directory else { + return; + }; + let value = self.line(RequestBody::End { outcome }); + if let Err(error) = publish_request(&directory.join("request-end.json"), &value) { + self.failure.get_or_insert(error); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{FabricConfig, ResolveContext, resolve_run_plan_from_config}; + use serde_json::json; + + fn fixture() -> (PathBuf, RunPlan, RunResult) { + let root = std::env::temp_dir().join(new_id("recording-result-test")); + std::fs::create_dir_all(&root).expect("test root"); + let config: FabricConfig = serde_json::from_value(json!({ + "schema_version":"fabric.agent/v1alpha1", "metadata":{"name":"demo"}, + "harness":{"adapter_id":"nvidia.fabric.hermes","resolution":"preinstalled"}, + "runtime":{"artifacts":root,"record":{"enabled":true}} + })) + .expect("config"); + let plan = resolve_run_plan_from_config(config, ResolveContext::new(&root)).expect("plan"); + let result = serde_json::from_value(json!({ + "agent_name":"demo","harness":"nvidia.fabric.hermes","adapter_kind":"python", + "runtime_id":"rt","invocation_id":"inv","request_id":"req", + "status":"failed","output":{"answer":"kept"}, + "metadata":{"existing":"kept"},"usage":{"input_tokens":42,"output_tokens":0} + })) + .expect("result"); + (root, plan, result) + } + + #[test] + fn recorded_full_result_is_exact_and_keeps_failed_usage() { + let (_, plan, result) = fixture(); + let returned = persist_result(&plan, result, "one_shot_lifecycle"); + let artifact = returned + .artifacts + .artifacts + .iter() + .find(|item| item.kind == "run_result") + .expect("saved result artifact"); + let saved: Value = + serde_json::from_slice(&std::fs::read(&artifact.path).expect("result file")) + .expect("JSON"); + assert_eq!( + saved, + serde_json::to_value(&returned).expect("returned result") + ); + assert_eq!(saved["usage"]["input_tokens"], 42); + assert_eq!(saved["usage"]["output_tokens"], 0); + assert_eq!(artifact.metadata["boundary"], "one_shot_lifecycle"); + assert!( + std::fs::read_dir(artifact.path.parent().unwrap()) + .expect("directory") + .all(|entry| !entry + .unwrap() + .file_name() + .to_string_lossy() + .ends_with(".pending")) + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&artifact.path) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + } + } + + #[test] + fn published_result_remains_exact_when_owned_staging_cleanup_fails() { + let (_, plan, result) = fixture(); + CLEANUP_FAILURE.with(|slot| slot.set(true)); + let returned = persist_result(&plan, result, "invocation"); + CLEANUP_FAILURE.with(|slot| slot.set(false)); + let artifact = returned + .artifacts + .artifacts + .iter() + .find(|item| item.kind == "run_result") + .expect("publication remains successful"); + let saved: Value = serde_json::from_slice(&std::fs::read(&artifact.path).unwrap()).unwrap(); + assert_eq!(saved, serde_json::to_value(&returned).unwrap()); + assert!( + returned + .events + .iter() + .all(|event| event.kind != WRITE_FAILED) + ); + assert_eq!( + std::fs::read_dir(artifact.path.parent().unwrap()) + .unwrap() + .count(), + 2 + ); + } + + #[test] + fn existing_full_result_is_not_overwritten_and_execution_is_kept() { + let (root, plan, result) = fixture(); + let folder = root.join(".fabric/rt/inv"); + std::fs::create_dir_all(&folder).expect("folder"); + let path = folder.join(RESULT_FILE_NAME); + std::fs::write(&path, b"existing evidence").expect("existing file"); + let original = result.clone(); + let returned = persist_result(&plan, result, "invocation"); + assert_eq!(returned.status, original.status); + assert_eq!(returned.usage, original.usage); + assert_eq!(returned.output, original.output); + assert_eq!(std::fs::read(&path).unwrap(), b"existing evidence"); + assert!( + returned + .artifacts + .artifacts + .iter() + .all(|item| item.kind != "run_result") + ); + assert!( + returned + .events + .iter() + .any(|event| event.kind == WRITE_FAILED) + ); + assert_eq!(std::fs::read_dir(folder).unwrap().count(), 1); + } + + #[test] + fn outer_start_failure_keeps_request_identity_without_inner_ids_or_input() { + let (root, plan, _) = fixture(); + let mut request = RunRequest::text("private input must not be recorded"); + request.request_id = "../../caller-id".to_string(); + let recorder = RequestRecorder::start(&plan, &request); + recorder.no_result(RequestStage::Start); + let folders: Vec<_> = std::fs::read_dir(root.join(".fabric/requests")) + .unwrap() + .collect(); + assert_eq!(folders.len(), 1); + let folder = folders[0].as_ref().unwrap().path(); + let start: Value = + serde_json::from_slice(&std::fs::read(folder.join("request-start.json")).unwrap()) + .unwrap(); + let end: Value = + serde_json::from_slice(&std::fs::read(folder.join("request-end.json")).unwrap()) + .unwrap(); + assert_eq!(start["request_id"], "../../caller-id"); + assert_eq!(end["state"], "no_result"); + assert_eq!(end["stage"], "start"); + assert!(start.get("runtime_id").is_none()); + assert!(end.get("invocation_id").is_none()); + assert!(end.get("usage").is_none()); + assert!(start.get("input").is_none()); + } + + #[test] + fn request_records_accept_the_byte_limit_and_reject_one_byte_more() { + let (_, plan, _) = fixture(); + let empty = RequestRecorder { + directory: None, + request_id: String::new(), + failure: None, + }; + let overhead = serde_json::to_vec_pretty(&empty.line(RequestBody::Start)) + .unwrap() + .len(); + let mut request = RunRequest::text("input"); + request.request_id = "r".repeat(64 * 1024 - overhead); + let recorder = RequestRecorder::start(&plan, &request); + assert!(recorder.failure.is_none()); + let path = recorder.directory.unwrap().join("request-start.json"); + assert_eq!(std::fs::metadata(path).unwrap().len(), 64 * 1024); + + request.request_id.push('r'); + let recorder = RequestRecorder::start(&plan, &request); + assert!(recorder.failure.is_some()); + assert_eq!( + std::fs::read_dir(recorder.directory.unwrap()) + .unwrap() + .count(), + 0 + ); + } + + #[test] + fn returned_keeps_the_first_recording_failure_when_end_publication_also_fails() { + let (root, _, mut result) = fixture(); + let directory = root.join("request"); + std::fs::create_dir_all(&directory).expect("request directory"); + let end = directory.join("request-end.json"); + std::fs::write(&end, "existing end record").expect("existing evidence"); + let original = result.clone(); + let recorder = RequestRecorder { + directory: Some(directory), + request_id: result.request_id.clone(), + failure: Some(io::Error::new( + io::ErrorKind::PermissionDenied, + "start record permission denied", + )), + }; + + recorder.returned(&mut result); + + assert_eq!(result.events.len(), original.events.len() + 1); + let failure = result.events.last().expect("recording diagnostic"); + assert_eq!(failure.kind, WRITE_FAILED); + assert_eq!( + failure.message, + "could not save request record: start record permission denied" + ); + assert_eq!(failure.metadata["artifact"], "request record"); + let mut expected = original; + expected.events.clone_from(&result.events); + assert_eq!( + serde_json::to_value(&result).unwrap(), + serde_json::to_value(expected).unwrap() + ); + assert_eq!(std::fs::read_to_string(end).unwrap(), "existing end record"); + } + + #[test] + fn oversized_request_records_preserve_the_result_and_recording_diagnostic() { + let (root, plan, mut result) = fixture(); + let mut request = RunRequest::text("private input"); + request.request_id = "r".repeat(16 * 1024 * 1024 + 1); + result.request_id.clone_from(&request.request_id); + let original = result.clone(); + let recorder = RequestRecorder::start(&plan, &request); + let directory = recorder.directory.clone().expect("request directory"); + assert!( + recorder.failure.is_some(), + "oversized request record must be rejected" + ); + recorder.returned(&mut result); + assert_eq!(std::fs::read_dir(&directory).unwrap().count(), 0); + assert_eq!(result.status, original.status); + assert_eq!(result.error, original.error); + assert_eq!(result.usage, original.usage); + assert_eq!(result.output, original.output); + assert_eq!(result.request_id, original.request_id); + assert!(result.events.iter().any(|event| event.kind == WRITE_FAILED)); + + let returned = persist_result(&plan, result, "one_shot_lifecycle"); + let path = root.join(".fabric/rt/inv").join(RESULT_FILE_NAME); + let saved: Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + assert_eq!(saved, serde_json::to_value(returned).unwrap()); + } + + #[test] + #[cfg(unix)] + fn atomic_publication_refuses_symlink_and_cleans_only_owned_stage() { + let root = std::env::temp_dir().join(new_id("recording-link-test")); + std::fs::create_dir_all(&root).unwrap(); + let original = root.join("original.json"); + std::fs::write(&original, "unchanged").unwrap(); + let target = root.join("result.json"); + std::os::unix::fs::symlink(&original, &target).unwrap(); + assert!(publish(&target, &json!({"new":"content"})).is_err()); + assert_eq!(std::fs::read_to_string(original).unwrap(), "unchanged"); + assert!(target.is_symlink()); + assert_eq!(std::fs::read_dir(root).unwrap().count(), 2); + } +} diff --git a/crates/fabric-core/src/record/writer.rs b/crates/fabric-core/src/record/writer.rs new file mode 100644 index 000000000..b8240ba33 --- /dev/null +++ b/crates/fabric-core/src/record/writer.rs @@ -0,0 +1,199 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The run record file: created once, owner-only, appended one line per write. +//! +//! Each line uses `write_all` on an unbuffered file. If the Fabric process +//! terminates, lines whose writes completed remain in the file. The end line +//! is synced once. Durability after a machine crash or power loss is not +//! guaranteed because the containing directory is not synced. + +use std::fs::{File, OpenOptions}; +use std::io::{self, Write}; +use std::path::Path; + +use super::lines::RecordLine; + +/// File name of the record inside the invocation folder. +pub(crate) const RECORD_FILE_NAME: &str = "run-record.jsonl"; +/// Maximum serialized JSON size of one line, excluding its newline. +const MAX_LINE_BYTES: usize = 64 * 1024; + +/// Appends lines to one invocation's record. +#[derive(Debug)] +pub(crate) struct RecordWriter { + file: File, +} + +impl RecordWriter { + /// Create the record at `path`. `create_new` refuses an existing file or + /// symbolic link at `path`, and on Unix the file is mode 0600. + pub(crate) fn create(path: &Path) -> io::Result { + #[cfg(test)] + std::thread::sleep(test_hooks::CREATE_DELAY.with(std::cell::Cell::get)); + let mut options = OpenOptions::new(); + options.append(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + Ok(Self { + file: options.open(path)?, + }) + } + + /// Append `line` and its newline with one write. + pub(crate) fn append(&mut self, line: &RecordLine) -> io::Result<()> { + #[cfg(test)] + test_hooks::fail_append(&line.body)?; + let mut bytes = serde_json::to_vec(line).map_err(io::Error::other)?; + if bytes.len() > MAX_LINE_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("run record line exceeds {MAX_LINE_BYTES} bytes"), + )); + } + bytes.push(b'\n'); + self.file.write_all(&bytes) + } + + /// Flush the file's data and metadata to stable storage. + pub(crate) fn sync(&self) -> io::Result<()> { + #[cfg(test)] + test_hooks::fail_if(test_hooks::Fault::Sync)?; + self.file.sync_all() + } +} + +/// Per-thread hooks that make record I/O slow or fail in tests. +#[cfg(test)] +pub(crate) mod test_hooks { + use std::cell::Cell; + use std::io; + use std::time::Duration; + + use super::super::lines::LineBody; + + /// A record operation that a test makes fail. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub(crate) enum Fault { + StartAppend, + EndAppend, + Sync, + } + + thread_local! { + /// Delay added before each record file is created on this thread. + pub(crate) static CREATE_DELAY: Cell = const { Cell::new(Duration::ZERO) }; + /// Operation that fails on this thread. + pub(crate) static FAULT: Cell> = const { Cell::new(None) }; + } + + pub(super) fn fail_append(body: &LineBody) -> io::Result<()> { + match body { + LineBody::Start(_) => fail_if(Fault::StartAppend), + LineBody::End(_) => fail_if(Fault::EndAppend), + } + } + + pub(super) fn fail_if(fault: Fault) -> io::Result<()> { + if FAULT.with(Cell::get) == Some(fault) { + return Err(io::Error::other(format!("injected {fault:?} failure"))); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_folder(name: &str) -> std::path::PathBuf { + let folder = std::env::temp_dir().join(format!( + "fabric-record-writer-{name}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&folder).expect("create temp folder"); + folder + } + + #[test] + fn record_writer_refuses_an_existing_file_or_a_symlink() { + let folder = temp_folder("excl"); + let path = folder.join(RECORD_FILE_NAME); + std::fs::write(&path, "old").expect("seed file"); + + let error = RecordWriter::create(&path).expect_err("existing file"); + + assert_eq!(error.kind(), io::ErrorKind::AlreadyExists); + assert_eq!(std::fs::read_to_string(&path).expect("read"), "old"); + #[cfg(unix)] + { + let linked = folder.join("linked"); + std::fs::create_dir_all(&linked).expect("linked folder"); + let target = folder.join("target"); + std::os::unix::fs::symlink(&target, linked.join(RECORD_FILE_NAME)).expect("symlink"); + assert!(RecordWriter::create(&linked.join(RECORD_FILE_NAME)).is_err()); + assert!(!target.exists()); + } + } + + #[cfg(unix)] + #[test] + fn record_writer_new_file_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + let folder = temp_folder("mode"); + let path = folder.join(RECORD_FILE_NAME); + + RecordWriter::create(&path).expect("create"); + let mode = std::fs::metadata(&path).expect("stat").permissions().mode(); + + assert_eq!(mode & 0o777, 0o600); + } + + #[test] + fn line_byte_limit_accepts_exact_boundary_and_rejects_oversize_without_appending() { + use crate::record::lines::{EndLine, LineBody, RecordSchema, RecordVersion}; + use crate::runtime::RunStatus; + + let folder = temp_folder("line-size"); + let path = folder.join(RECORD_FILE_NAME); + let mut writer = RecordWriter::create(&path).expect("writer"); + let mut line = RecordLine { + schema: RecordSchema::RunRecord, + version: RecordVersion::V1Alpha1, + runtime_id: "rt".to_string(), + invocation_id: "inv".to_string(), + request_id: "req".to_string(), + time: "2026-10-21T00:00:00.000Z".to_string(), + body: LineBody::End(Box::new(EndLine { + outcome_scope: "invocation", + result: None, + status: RunStatus::Succeeded, + elapsed_ms: 0, + error: None, + usage: None, + model_usage: None, + artifacts: Vec::new(), + })), + }; + let overhead = serde_json::to_vec(&line).expect("line JSON").len(); + line.request_id + .push_str(&"a".repeat(MAX_LINE_BYTES - overhead)); + assert_eq!(serde_json::to_vec(&line).unwrap().len(), MAX_LINE_BYTES); + writer.append(&line).expect("exact limit is valid"); + let before = std::fs::read(&path).expect("written line"); + assert_eq!(before.len(), MAX_LINE_BYTES + 1); + assert_eq!(before.last(), Some(&b'\n')); + + line.request_id.push('a'); + let error = writer.append(&line).expect_err("one byte too large"); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert_eq!(std::fs::read(&path).unwrap(), before); + } +} diff --git a/crates/fabric-core/src/runtime.rs b/crates/fabric-core/src/runtime.rs index 042777da9..5e6702669 100644 --- a/crates/fabric-core/src/runtime.rs +++ b/crates/fabric-core/src/runtime.rs @@ -32,6 +32,7 @@ use crate::config::{ }; use crate::environment::resolve_environment_provider; use crate::error::{FabricError, Result}; +use crate::record::{InvocationRecorder, RecordStart}; use crate::resource_snapshot::{LocalHostProcess, RuntimeResourceSnapshot, snapshot_local_host}; #[cfg(test)] use crate::system_stats::ResourceObservation; @@ -41,6 +42,8 @@ mod openshell; use openshell::OpenShellRuntimeAdapter; static NEXT_ID: AtomicU64 = AtomicU64::new(1); +/// Lifecycle code for a result rejected by core. +pub(crate) const INVALID_AGENT_RUN_RESULT: &str = "invalid_agent_run_result"; const ADAPTER_PYTHON_ENV: &str = "ADAPTER_PYTHON"; const VIRTUAL_ENV_ENV: &str = "VIRTUAL_ENV"; const LOCAL_HOST_START_TIMEOUT: Duration = Duration::from_secs(90); @@ -148,6 +151,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")] @@ -870,6 +876,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 { @@ -891,12 +898,32 @@ enum LocalServiceState { /// Invoke a NeMo Fabric run plan. pub fn run_plan(plan: &RunPlan, request: RunRequest) -> Result { - let runtime = start_runtime(plan)?; - let mut result = match invoke_runtime(plan, &runtime, request) { + run_plan_with_stage(plan, request).map_err(|(error, _)| error) +} + +/// Preserve the phase of a one-shot failure for language bindings. +#[doc(hidden)] +pub fn run_plan_with_stage( + plan: &RunPlan, + mut request: RunRequest, +) -> std::result::Result { + if request.request_id.is_empty() { + request.request_id = new_id("request"); + } + let outer_record = crate::record::RequestRecorder::start(plan, &request); + let runtime = match start_runtime_staged(plan) { + Ok(runtime) => runtime, + Err((error, stage)) => { + outer_record.no_result(crate::record::RequestStage::Start); + return Err((error, stage)); + } + }; + let mut result = match invoke_runtime_unpersisted(plan, &runtime, request) { Ok(result) => result, Err(error) => { let _ = stop_runtime(plan, &runtime); - return Err(error); + outer_record.no_result(crate::record::RequestStage::Invoke); + return Err((error, ErrorStage::Invoke)); } }; match stop_runtime(plan, &runtime) { @@ -913,7 +940,12 @@ pub fn run_plan(plan: &RunPlan, request: RunRequest) -> Result { } Err(_) => {} } - Ok(result) + outer_record.returned(&mut result); + Ok(crate::record::persist_result( + plan, + result, + "one_shot_lifecycle", + )) } /// Prepare an execution environment for a run plan. @@ -1171,15 +1203,24 @@ fn validate_start_plan(plan: &RunPlan) -> Result<()> { /// For a non-local provider, prepare or attach to the environment explicitly /// and call [`start_runtime_in`] with the returned handle. pub fn start_runtime(plan: &RunPlan) -> Result { - validate_start_plan(plan)?; + start_runtime_staged(plan).map_err(|(error, _)| error) +} + +fn start_runtime_staged( + plan: &RunPlan, +) -> std::result::Result { + validate_start_plan(plan).map_err(|error| (error, ErrorStage::Plan))?; let provider = planned_environment_provider(plan); if provider != "local" { - return Err(FabricError::EnvironmentHandleRequired { - provider: provider.to_string(), - }); + return Err(( + FabricError::EnvironmentHandleRequired { + provider: provider.to_string(), + }, + ErrorStage::Prepare, + )); } - let environment = prepare_environment(plan)?; - start_runtime_in_validated(plan, &environment) + let environment = prepare_environment(plan).map_err(|error| (error, ErrorStage::Prepare))?; + start_runtime_in_validated(plan, &environment).map_err(|error| (error, ErrorStage::Start)) } /// Invoke a started harness runtime. @@ -1187,6 +1228,15 @@ pub fn invoke_runtime( plan: &RunPlan, runtime: &RuntimeHandle, request: RunRequest, +) -> Result { + invoke_runtime_unpersisted(plan, runtime, request) + .map(|result| crate::record::persist_result(plan, result, "invocation")) +} + +fn invoke_runtime_unpersisted( + plan: &RunPlan, + runtime: &RuntimeHandle, + request: RunRequest, ) -> Result { validate_adapter_compatibility(plan)?; validate_runtime_handle(plan, runtime)?; @@ -1267,7 +1317,9 @@ pub fn invoke_openai_stream( if runtime.environment.provider == "openshell" { return OpenShellRuntimeAdapter.invoke_openai_stream(plan, runtime, request, transport); } - return LocalHostAdapter.invoke_openai_stream(plan, runtime, request, transport); + return LocalHostAdapter + .invoke_openai_stream(plan, runtime, request, transport) + .map(|result| crate::record::persist_result(plan, result, "invocation")); } Err(FabricError::UnsupportedRuntimeAdapter { harness: harness(plan), @@ -1961,11 +2013,34 @@ impl LocalHostInvocation { } fn run_local_host_invocation_with_timeout( + plan: &RunPlan, + runtime: &RuntimeHandle, + request: RunRequest, + invocation_kind: LocalHostInvocation, + invoke_timeout: Duration, +) -> Result { + let mut recorder = None; + let outcome = local_host_invocation_result( + plan, + runtime, + request, + invocation_kind, + invoke_timeout, + &mut recorder, + ); + match recorder { + Some(recorder) => recorder.finish(outcome), + None => outcome, + } +} + +fn local_host_invocation_result( plan: &RunPlan, runtime: &RuntimeHandle, mut request: RunRequest, invocation_kind: LocalHostInvocation, invoke_timeout: Duration, + recorder: &mut Option, ) -> Result { let operation = invocation_kind.operation(); if request.request_id.is_empty() { @@ -2007,6 +2082,17 @@ fn run_local_host_invocation_with_timeout( &host_guard.environment_env, &adapter_invocation.runtime_context.environment.env, )?; + // Record preparation precedes the adapter deadline, preserving its budget. + if plan.config.runtime.record_enabled() && artifacts.root.is_some() { + *recorder = Some(InvocationRecorder::start(RecordStart { + plan, + runtime, + invocation: &invocation, + request: &request, + fabric_home: &fabric_home, + timeout: invoke_timeout, + })); + } adapter_invocation.runtime_context.deadline_millis = adapter_deadline_millis(invoke_timeout); let (lifecycle_request, adapter_payload) = match invocation_kind { @@ -2138,6 +2224,7 @@ fn run_local_host_invocation_with_timeout( relay_config, fabric_home, fabric_invocation, + host_guard.bundled_pricing_allowed.clone(), )) } Err(error) => { @@ -2163,6 +2250,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| { @@ -2315,6 +2403,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), @@ -2327,6 +2440,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, @@ -2561,6 +2675,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( @@ -2659,9 +2777,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), @@ -3650,7 +3805,7 @@ fn relay_artifact_media_type(kind: &str, format: Option<&str>) -> Option<&'stati } } -fn unique_artifact_name(manifest: &ArtifactManifest, base: &str) -> String { +pub(crate) fn unique_artifact_name(manifest: &ArtifactManifest, base: &str) -> String { if !manifest .artifacts .iter() @@ -4015,7 +4170,7 @@ fn telemetry_ref( }) } -fn event_with_metadata( +pub(crate) fn event_with_metadata( kind: impl Into, message: impl Into, metadata: BTreeMap, @@ -4095,6 +4250,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": { @@ -4313,12 +4469,13 @@ for line in sys.stdin: "context_env": invocation["runtime_context"]["environment"].get("env", {}), "deadline_millis": invocation["runtime_context"].get("deadline_millis"), } - if MODE in {"adapter_reported_failure", "blank_error_code"}: + if MODE in {"adapter_reported_failure", "blank_error_code", "free_text_error_code"}: + codes = {"blank_error_code": " \t", "free_text_error_code": "Rate limit for key sk-live-123 exceeded"} result = { "status": "failed", "output": output, "error": { - "code": " \t" if MODE == "blank_error_code" else "fake_adapter_failure", + "code": codes.get(MODE, "fake_adapter_failure"), "message": "adapter rejected the invocation", "retryable": True, "extensions": {"source": "fake-host"}, @@ -4346,6 +4503,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", @@ -4581,6 +4770,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(), @@ -5286,7 +5476,8 @@ for line in sys.stdin: "peak was lost after worker exit: {summary:?}" ); assert!(summary.sample_count >= 3); - assert!(gauge.covered_fraction > 0.0); + // A spike sample need not have a neighbor within twice the interval. + assert!((0.0..=1.0).contains(&gauge.covered_fraction)); let path = &summary.samples.as_ref().unwrap().path; let rows: Vec = fs::read_to_string(path) .unwrap() @@ -5589,7 +5780,9 @@ for line in sys.stdin: fn local_host_start_failure_preserves_stage_and_diagnostics() { let (root, plan) = local_host_plan("start_failure"); - let error = start_runtime(&plan).expect_err("start must fail"); + let (error, stage) = + run_plan_with_stage(&plan, RunRequest::text("input")).expect_err("start must fail"); + assert_eq!(stage, ErrorStage::Start); let message = error.to_string(); assert!(message.contains("lifecycle start"), "{message}"); assert!(message.contains("fake_start"), "{message}"); @@ -5598,6 +5791,25 @@ for line in sys.stdin: let _ = fs::remove_dir_all(root); } + #[test] + fn one_shot_reports_prepare_stage_when_environment_is_required() { + let (root, mut plan) = local_host_plan("success"); + plan.environment_plan + .as_mut() + .expect("resolved environment plan") + .provider = "docker".to_string(); + + let (error, stage) = run_plan_with_stage(&plan, RunRequest::text("input")) + .expect_err("non-local provider needs an environment handle"); + assert!(matches!( + error, + FabricError::EnvironmentHandleRequired { .. } + )); + assert_eq!(stage, ErrorStage::Prepare); + + let _ = fs::remove_dir_all(root); + } + #[test] fn local_host_supplies_fresh_adapter_deadlines() { let (root, mut plan) = local_host_plan("success"); @@ -6055,6 +6267,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"); @@ -6581,4 +6842,432 @@ for line in sys.stdin: )); let _ = fs::remove_dir_all(root); } + fn record_plan(mode: &str) -> (PathBuf, RunPlan) { + let (root, mut plan) = local_host_plan(mode); + plan.config.runtime.record = Some(crate::config::RecordConfig { enabled: true }); + (root, plan) + } + + fn record_files(root: &Path) -> Vec { + let mut records = Vec::new(); + let mut folders = vec![root.join("artifacts/.fabric")]; + while let Some(folder) = folders.pop() { + let Ok(entries) = fs::read_dir(&folder) else { + continue; + }; + for path in entries.flatten().map(|entry| entry.path()) { + if path.is_dir() { + folders.push(path); + } else if path.ends_with("run-record.jsonl") { + records.push(path); + } + } + } + records + } + + fn record_line_validator() -> jsonschema::Validator { + let schema = crate::schema::generate_schema(crate::schema::SchemaName::RunRecordLine) + .expect("record line schema"); + jsonschema::validator_for(&schema).expect("valid schema") + } + + /// The only record under `root`, with every line checked against the + /// published line schema. + fn only_record(root: &Path) -> (String, Vec) { + let records = record_files(root); + assert_eq!(records.len(), 1, "{records:?}"); + let text = fs::read_to_string(&records[0]).expect("read record"); + let validator = record_line_validator(); + let lines: Vec = text + .lines() + .map(|line| serde_json::from_str(line).expect("record line is JSON")) + .collect(); + for line in &lines { + assert!(validator.is_valid(line), "{line}"); + } + (text, lines) + } + + /// The one line of `kind`. Lookup by kind keeps these tests independent + /// of other line kinds written between the start and end lines. + fn line_of_kind<'a>(lines: &'a [Value], kind: &str) -> &'a Value { + let mut matching = lines.iter().filter(|line| line["kind"] == kind); + let line = matching.next().unwrap_or_else(|| panic!("no {kind} line")); + assert!(matching.next().is_none(), "more than one {kind} line"); + line + } + + #[test] + fn record_off_with_an_artifact_root_writes_no_record() { + let (root, plan) = local_host_plan("typed_result"); + assert!(plan.config.runtime.artifacts.is_some()); + + let result = run_plan(&plan, RunRequest::text("no record")).expect("run"); + + assert!(record_files(&root).is_empty()); + assert!( + result + .artifacts + .artifacts + .iter() + .all(|artifact| artifact.kind != "run_record") + ); + assert!( + result + .events + .iter() + .all(|event| event.kind != "record_write_failed") + ); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn record_on_writes_a_start_line_and_an_end_line() { + let (root, plan) = record_plan("typed_result"); + let mut request = RunRequest::text("record"); + request.context = BTreeMap::from([ + ("task_id".to_string(), serde_json::json!("repo-io-fix")), + ("attempt".to_string(), serde_json::json!(2)), + ( + "harbor_trial_name".to_string(), + serde_json::json!("trial-1"), + ), + ("step".to_string(), serde_json::json!({"nested": "value"})), + ("api_key".to_string(), serde_json::json!("sk-live-SECRET")), + ]); + + let result = run_plan(&plan, request).expect("run"); + let (text, lines) = only_record(&root); + + let (start, end) = (line_of_kind(&lines, "start"), line_of_kind(&lines, "end")); + assert_eq!(lines.first(), Some(start), "{text}"); + assert_eq!(lines.last(), Some(end), "{text}"); + for line in &lines { + assert_eq!(line["schema"], "nemo.fabric.run_record"); + assert_eq!(line["version"], "v1alpha1"); + assert_eq!(line["runtime_id"], result.runtime_id.as_str()); + assert_eq!(line["invocation_id"], result.invocation_id.as_str()); + assert_eq!(line["request_id"], result.request_id.as_str()); + } + assert_eq!(start["agent_name"], "local-host-test-agent"); + assert_eq!(start["adapter_id"], "acme.fabric.local-host"); + assert_eq!(start["timeout_ms"], 3_600_000); + assert_eq!( + start["context"], + serde_json::json!({ + "api_key": {"recorded": false}, + "attempt": 2, + "harbor_trial_name": "trial-1", + "step": {"recorded": false}, + "task_id": "repo-io-fix", + }) + ); + assert!(!text.contains("SECRET"), "{text}"); + assert_eq!(end["status"], "succeeded"); + assert!(end["elapsed_ms"].is_u64()); + assert_eq!( + end["usage"], + serde_json::json!({ + "input_tokens": 3, + "cached_input_tokens": 1, + "input_tokens_include_cache": true, + "output_tokens": 5, + "total_tokens": 8, + "cost_usd": 0.25, + }) + ); + let report = end["artifacts"] + .as_array() + .expect("artifacts") + .iter() + .find(|artifact| artifact["name"] == "report") + .expect("report artifact"); + assert_eq!( + report, + &serde_json::json!({ + "name": "report", + "kind": "report", + "media_type": "application/json", + "path": "reports/result.json", + }) + ); + for marker in ["fake-host", "\"fake\"", "trace-1"] { + assert!(!text.contains(marker), "{marker} in {text}"); + } + let record = result + .artifacts + .artifacts + .iter() + .find(|artifact| artifact.name == "record") + .expect("record artifact"); + assert_eq!(record.kind, "run_record"); + assert_eq!(record.media_type.as_deref(), Some("application/jsonl")); + assert_eq!(record.path, record_files(&root)[0]); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn record_end_line_keeps_the_error_code_and_drops_the_message() { + let (root, plan) = record_plan("adapter_reported_failure"); + + let result = run_plan(&plan, RunRequest::text("fail")).expect("failed result"); + let (text, lines) = only_record(&root); + + assert_eq!(result.status, RunStatus::Failed); + assert_eq!( + line_of_kind(&lines, "end")["error"], + serde_json::json!({"code": "fake_adapter_failure", "stage": "invoke", "retryable": true}) + ); + assert!(!text.contains("adapter rejected"), "{text}"); + let mut wrong_kind = line_of_kind(&lines, "end").clone(); + wrong_kind["kind"] = serde_json::json!("start"); + assert!(!record_line_validator().is_valid(&wrong_kind)); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn record_end_line_replaces_a_free_text_error_code() { + let (root, plan) = record_plan("free_text_error_code"); + + let result = run_plan(&plan, RunRequest::text("fail")).expect("failed result"); + let (text, lines) = only_record(&root); + + assert!(result.error.expect("error").code.contains("sk-live-123")); + assert_eq!(line_of_kind(&lines, "end")["error"]["code"], "unrecognized"); + assert!(!text.contains("sk-live-123"), "{text}"); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn record_lifecycle_error_writes_a_failed_end_line() { + let (root, plan) = record_plan("invoke_timeout"); + let runtime = start_runtime(&plan).expect("start local host"); + + let error = run_local_host_adapter_with_timeout( + &plan, + &runtime, + RunRequest::text("hang"), + Duration::from_millis(100), + ) + .expect_err("timeout raises"); + let (_, lines) = only_record(&root); + + assert!(error.to_string().contains("host_timeout"), "{error}"); + let end = line_of_kind(&lines, "end"); + assert_eq!(line_of_kind(&lines, "start")["timeout_ms"], 100); + assert_eq!(end["status"], "failed"); + assert_eq!( + end["error"], + serde_json::json!({"code": "host_timeout", "stage": "invoke", "retryable": false}) + ); + let _ = stop_runtime(&plan, &runtime); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn record_preparation_does_not_shorten_the_adapter_deadline() { + let (root, mut plan) = record_plan("success"); + plan.config.runtime.timeout_seconds = Some(60.0); + let runtime = start_runtime(&plan).expect("start local host"); + let delay = Duration::from_millis(500); + crate::record::test_hooks::CREATE_DELAY.with(|slot| slot.set(delay)); + + let before = now_millis(); + let result = invoke_runtime(&plan, &runtime, RunRequest::text("slow record storage")); + let after = now_millis(); + crate::record::test_hooks::CREATE_DELAY.with(|slot| slot.set(Duration::ZERO)); + + let result = result.expect("invoke"); + let deadline = u128::from(result.output["deadline_millis"].as_u64().expect("deadline")); + // The adapter's 50-second budget starts after the record is created, + // so slow record storage does not eat into it. + assert!( + (before + delay.as_millis() + 50_000..=after + 50_000).contains(&deadline), + "deadline {deadline}, before {before}, after {after}" + ); + assert_eq!(record_files(&root).len(), 1); + stop_runtime(&plan, &runtime).expect("stop local host"); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn record_warm_runtime_writes_one_record_per_invocation() { + let (root, plan) = record_plan("success"); + let runtime = start_runtime(&plan).expect("start local host"); + + let first = invoke_runtime(&plan, &runtime, RunRequest::text("one")).expect("first"); + let second = invoke_runtime(&plan, &runtime, RunRequest::text("two")).expect("second"); + stop_runtime(&plan, &runtime).expect("stop"); + + let records = record_files(&root); + assert_eq!(records.len(), 2); + for result in [&first, &second] { + assert!(records.iter().any(|path| path.ends_with(format!( + ".fabric/{}/{}/run-record.jsonl", + result.runtime_id, result.invocation_id + )))); + } + 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); + } + } + #[test] + fn recorded_accounting_projection_matches_returned_normalized_result() { + for mode in ["accounted_usage_result", "accounted_partial_usage_result"] { + let (root, plan) = record_plan(mode); + let result = run_plan(&plan, RunRequest::text("accounted")).expect("result"); + let (_, rows) = only_record(&root); + let end = line_of_kind(&rows, "end"); + let usage = result.model_usage.expect("normalized accounting"); + assert_eq!(end["model_usage"], serde_json::to_value(&usage).unwrap()); + assert_eq!(usage.tokens.output_tokens, Some(5)); + if mode == "accounted_partial_usage_result" { + assert_eq!(usage.tokens.input_tokens, None); + assert_eq!(usage.observed.input_tokens, Some(100)); + } else { + assert_eq!(usage.tokens.input_tokens, Some(100)); + } + let (record, _) = only_record(&root); + assert!(!record.contains("peak_request_input_tokens")); + assert!( + result + .artifacts + .artifacts + .iter() + .any(|a| a.kind == "run_record") + ); + let _ = fs::remove_dir_all(root); + } + } + + #[test] + fn recorded_native_stream_publishes_the_returned_result() { + let (root, mut plan) = record_plan("success"); + plan.capabilities.streaming = true; + plan.adapter_descriptor + .as_mut() + .expect("resolved descriptor") + .descriptor + .capabilities + .streaming = true; + let runtime = start_runtime(&plan).expect("start local host"); + let (transport, capture) = openai_stream_listener("recorded-stream-secret"); + let result = invoke_openai_stream(&plan, &runtime, RunRequest::text("stream"), transport) + .expect("stream invocation"); + assert_eq!(capture.join().expect("stream capture").len(), 3); + stop_runtime(&plan, &runtime).expect("stop local host"); + let saved = result + .artifacts + .artifacts + .iter() + .find(|artifact| artifact.kind == "run_result") + .expect("streaming saves the full result"); + let decoded: RunResult = + serde_json::from_slice(&fs::read(&saved.path).expect("saved result")) + .expect("typed saved result"); + assert_eq!(decoded, result); + assert!(saved.path.with_file_name("run-record.jsonl").is_file()); + let _ = fs::remove_dir_all(root); + } + #[test] + fn recorded_start_failure_keeps_outer_request_without_fake_inner_result() { + let (_, plan) = record_plan("start_failure"); + let mut request = RunRequest::text("private input"); + request.request_id = "caller-request".to_string(); + let error = run_plan(&plan, request).expect_err("startup error"); + assert!(error.to_string().contains("fake_start")); + let root = crate::run_record_folder(&plan).expect("recording root"); + let folders: Vec<_> = fs::read_dir(root.join("requests")).unwrap().collect(); + assert_eq!(folders.len(), 1); + let end: Value = serde_json::from_slice( + &fs::read(folders[0].as_ref().unwrap().path().join("request-end.json")).unwrap(), + ) + .unwrap(); + assert_eq!(end["request_id"], "caller-request"); + assert_eq!(end["state"], "no_result"); + assert_eq!(end["stage"], "start"); + assert!(end.get("runtime_id").is_none()); + assert!(end.get("usage").is_none()); + } + + #[test] + fn recorded_prepare_failure_keeps_specific_error_stage_and_request_boundary() { + let (root, mut plan) = record_plan("success"); + plan.environment_plan + .as_mut() + .expect("resolved environment plan") + .provider = "docker".to_string(); + + let (error, stage) = run_plan_with_stage(&plan, RunRequest::text("input")) + .expect_err("non-local provider needs an environment handle"); + assert!(matches!( + error, + FabricError::EnvironmentHandleRequired { .. } + )); + assert_eq!(stage, ErrorStage::Prepare); + + let requests = crate::run_record_folder(&plan) + .expect("recording root") + .join("requests"); + let folder = fs::read_dir(&requests) + .expect("request folder") + .next() + .expect("request record") + .expect("request entry") + .path(); + let end: Value = serde_json::from_slice( + &fs::read(folder.join("request-end.json")).expect("request end record"), + ) + .expect("valid request record"); + assert_eq!(end["state"], "no_result"); + assert_eq!(end["stage"], "start"); + let _ = fs::remove_dir_all(root); + } + + #[test] + fn recorded_start_error_survives_independent_request_storage_failure() { + let (_, plan) = record_plan("start_failure"); + let root = crate::run_record_folder(&plan).expect("recording root"); + fs::create_dir_all(&root).expect("root"); + fs::write(root.join("requests"), "existing file").expect("blocked request directory"); + let error = run_plan(&plan, RunRequest::text("input")).expect_err("original start failure"); + assert!(error.to_string().contains("fake_start"), "{error}"); + assert_eq!( + fs::read_to_string(root.join("requests")).unwrap(), + "existing file" + ); + } } diff --git a/crates/fabric-core/src/runtime/openshell.rs b/crates/fabric-core/src/runtime/openshell.rs index 75dc78bc3..38e3403c1 100644 --- a/crates/fabric-core/src/runtime/openshell.rs +++ b/crates/fabric-core/src/runtime/openshell.rs @@ -129,6 +129,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() @@ -433,6 +529,7 @@ impl RuntimeAdapter for OpenShellRuntimeAdapter { "", )); } + apply_host_catalog(&mut result, plan.user_price_catalog.as_ref()); for telemetry in &mut result.telemetry { telemetry.relay_enabled = telemetry .metadata @@ -847,6 +944,175 @@ 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") + ); + } + #[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 02f2d0880..af3d28a87 100644 --- a/crates/fabric-core/src/schema.rs +++ b/crates/fabric-core/src/schema.rs @@ -13,6 +13,7 @@ use crate::config::{ AdapterDescriptor, AdapterTargetDescriptor, AgentConfig, FabricConfig, RunPlan, }; use crate::error::{FabricError, Result}; +use crate::record::{RecordLine, RequestRecord}; use crate::resource_snapshot::RuntimeResourceSnapshot; use crate::runtime::{ AdapterInvocation, ArtifactManifest, EnvironmentHandle, EnvironmentReference, ErrorInfo, @@ -24,6 +25,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, /// Complete typed NeMo Fabric config schema. Agent, /// Adapter-facing projected agent config schema. @@ -70,11 +73,16 @@ pub enum SchemaName { FabricEvent, /// Runtime resource snapshot schema. RuntimeResourceSnapshot, + /// One bounded start/end invocation record line. + RunRecordLine, + /// Outer request evidence, including failures before an invocation exists. + RequestRecord, } impl SchemaName { /// All public schemas in stable output order. - pub const ALL: [Self; 23] = [ + pub const ALL: [Self; 26] = [ + Self::UsageAccounting, Self::Agent, Self::AgentConfig, Self::AgentRunRequest, @@ -98,11 +106,14 @@ impl SchemaName { Self::ErrorInfo, Self::FabricEvent, Self::RuntimeResourceSnapshot, + Self::RunRecordLine, + Self::RequestRecord, ]; /// Stable file stem for this schema. pub fn as_str(self) -> &'static str { match self { + Self::UsageAccounting => "usage-accounting", Self::Agent => "agent", Self::AgentConfig => "agent-config", Self::AgentRunRequest => "agent-run-request", @@ -126,6 +137,8 @@ impl SchemaName { Self::ErrorInfo => "error-info", Self::FabricEvent => "fabric-event", Self::RuntimeResourceSnapshot => "runtime-resource-snapshot", + Self::RunRecordLine => "run-record-line", + Self::RequestRecord => "request-record", } } @@ -138,6 +151,7 @@ impl SchemaName { fn relative_path(self) -> PathBuf { let filename = self.filename(); match self { + Self::UsageAccounting => PathBuf::from("extensions").join(filename), Self::AgentConfig | Self::AgentRunRequest | Self::AgentRunResult @@ -154,6 +168,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), "agent" => Ok(Self::Agent), "agent-config" | "agent_config" => Ok(Self::AgentConfig), "agent-run-request" | "agent_run_request" => Ok(Self::AgentRunRequest), @@ -183,6 +198,8 @@ impl SchemaName { "runtime-resource-snapshot" | "runtime_resource_snapshot" => { Ok(Self::RuntimeResourceSnapshot) } + "run-record-line" | "run_record_line" => Ok(Self::RunRecordLine), + "request-record" | "request_record" => Ok(Self::RequestRecord), _ => Err(FabricError::UnknownSchema { schema: value.to_string(), available: Self::ALL @@ -197,6 +214,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::Agent => to_value(schema_for!(FabricConfig)), SchemaName::AgentConfig => to_value(schema_for!(AgentConfig)), SchemaName::AgentRunRequest => to_value(schema_for!(AgentRunRequest)), @@ -220,6 +240,8 @@ pub fn generate_schema(schema: SchemaName) -> Result { SchemaName::ErrorInfo => to_value(schema_for!(ErrorInfo)), SchemaName::FabricEvent => to_value(schema_for!(FabricEvent)), SchemaName::RuntimeResourceSnapshot => to_value(schema_for!(RuntimeResourceSnapshot)), + SchemaName::RunRecordLine => to_value(schema_for!(RecordLine)), + SchemaName::RequestRecord => to_value(schema_for!(RequestRecord)), } } diff --git a/crates/fabric-core/src/usage_accounting.rs b/crates/fabric-core/src/usage_accounting.rs new file mode 100644 index 000000000..c9cfa2fa6 --- /dev/null +++ b/crates/fabric-core/src/usage_accounting.rs @@ -0,0 +1,353 @@ +// 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()))?; + validate_record( + &reported_usage(usage)?, + evidence.observed.as_ref(), + &evidence.gaps, + )?; + 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()))?; + 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/tests/model_usage.rs b/crates/fabric-core/tests/model_usage.rs new file mode 100644 index 000000000..21e8396df --- /dev/null +++ b/crates/fabric-core/tests/model_usage.rs @@ -0,0 +1,521 @@ +// 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"); +} + +#[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)); + usage.cost_usd = None; + 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); + usage.cost_usd = None; + 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()); +} diff --git a/crates/fabric-python/src/lib.rs b/crates/fabric-python/src/lib.rs index cd247795e..a6e013d7f 100644 --- a/crates/fabric-python/src/lib.rs +++ b/crates/fabric-python/src/lib.rs @@ -10,9 +10,9 @@ use std::thread; use std::time::{Duration, Instant}; use nemo_fabric_core::{ - EnvironmentHandle, EnvironmentReference, FabricConfig, FabricError, OpenAiStreamTransport, - ResolveContext, RunPlan, RunRequest, RuntimeHandle, ServiceHandle, ServiceReference, - doctor_plan, inspect_adapter_config, + EnvironmentHandle, EnvironmentReference, ErrorStage, FabricConfig, FabricError, + OpenAiStreamTransport, ResolveContext, RunPlan, RunRequest, RuntimeHandle, ServiceHandle, + ServiceReference, doctor_plan, inspect_adapter_config, resolve_diagnostic_plan_from_config_with_adapter_directories, resolve_run_plan_from_config_with_adapter_directories, run_plan, }; @@ -152,6 +152,34 @@ fn run_config( to_json(&result) } +/// Execute an already resolved one-shot plan, including final result recording. +#[pyfunction(name = "run_plan")] +fn run_resolved_plan(py: Python<'_>, plan_json: String, request_json: String) -> PyResult { + let plan = parse_run_plan(plan_json)?; + let request = parse_run_request(request_json)?; + let result = match py.detach(|| nemo_fabric_core::runtime::run_plan_with_stage(&plan, request)) + { + Ok(result) => result, + Err((error, stage)) => { + let stage = match stage { + ErrorStage::Config => "config", + ErrorStage::Plan => "plan", + ErrorStage::Prepare => "prepare", + ErrorStage::Start => "start", + ErrorStage::Invoke => "invoke", + ErrorStage::Stop => "stop", + ErrorStage::Release => "release", + ErrorStage::Artifact => "artifact", + }; + let failure = to_py_error(error); + // Preserve lifecycle stage without parsing diagnostic text. + failure.value(py).setattr("fabric_stage", stage)?; + return Err(failure); + } + }; + to_json(&result) +} + /// Start a runtime for a resolved run plan and return its RuntimeHandle JSON. #[pyfunction] fn start_runtime(py: Python<'_>, plan_json: String) -> PyResult { @@ -338,6 +366,7 @@ fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(inspect_adapter_metadata, m)?)?; m.add_function(wrap_pyfunction!(doctor_config, m)?)?; m.add_function(wrap_pyfunction!(run_config, m)?)?; + m.add_function(wrap_pyfunction!(run_resolved_plan, m)?)?; m.add_function(wrap_pyfunction!(start_runtime, m)?)?; m.add_function(wrap_pyfunction!(prepare_environment, m)?)?; m.add_function(wrap_pyfunction!(attach_environment, m)?)?; 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 0e6ee7828..90765f0e7 100644 --- a/docs/index.yml +++ b/docs/index.yml @@ -98,6 +98,10 @@ 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: Run Evidence Reporting + path: ./reference/api/python-library-reference/nemo_fabric.reporting.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 2f92b1855..3fd5ff0e4 100644 --- a/docs/reference/api/python-library-reference/index.md +++ b/docs/reference/api/python-library-reference/index.md @@ -10,6 +10,8 @@ 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.reporting`](./nemo_fabric.reporting.md#module-nemo_fabricreporting): Inspect saved invocation evidence without a benchmark manifest or trace import. - [`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.runtime`](./nemo_fabric.runtime.md#module-nemo_fabricruntime): Runtime lifecycle support for the NVIDIA NeMo Fabric Python SDK. @@ -22,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. - [`runtime.Runtime`](./nemo_fabric.runtime.md#class-runtime): One logical, stateful harness execution. @@ -42,6 +46,8 @@ 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.RecordConfig`](./nemo_fabric.models.md#class-recordconfig): Opt-in per-invocation run record. - [`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. @@ -65,11 +71,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. @@ -79,6 +87,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. @@ -91,6 +102,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. @@ -102,6 +116,16 @@ 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. +- [`reporting.main`](./nemo_fabric.reporting.md#function-main): Inspect a saved invocation result as a concise summary or JSON. +- [`reporting.read_evidence`](./nemo_fabric.reporting.md#function-read_evidence): Read a full result, invocation record or outer request record; never follow references. +- [`reporting.read_invocation_record`](./nemo_fabric.reporting.md#function-read_invocation_record): Read bounded start/end evidence; a truncated tail never creates a result. +- [`reporting.read_result`](./nemo_fabric.reporting.md#function-read_result): Read one saved full result without requiring task labels or a manifest. +- [`reporting.render_invocation_record_summary`](./nemo_fabric.reporting.md#function-render_invocation_record_summary): Describe the invocation interval without assuming whole-runtime success. +- [`reporting.render_request_summary`](./nemo_fabric.reporting.md#function-render_request_summary): Explain missing execution evidence without assigning zero spend. +- [`reporting.render_result_summary`](./nemo_fabric.reporting.md#function-render_result_summary): Render a concise overview; details add resource evidence. +- [`reporting.summarize_request`](./nemo_fabric.reporting.md#function-summarize_request): Describe outer dispatch evidence without fabricating an invocation result. +- [`reporting.summarize_result`](./nemo_fabric.reporting.md#function-summarize_result): Describe one returned invocation, preserving independent usable evidence. - [`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.client.md b/docs/reference/api/python-library-reference/nemo_fabric.client.md index 01717ead2..9656ae7ab 100644 --- a/docs/reference/api/python-library-reference/nemo_fabric.client.md +++ b/docs/reference/api/python-library-reference/nemo_fabric.client.md @@ -14,7 +14,6 @@ Native Python client for resolving and running NVIDIA NeMo Fabric agents. --- - ## class `Fabric` Primary Python entrypoint for NeMo Fabric. @@ -25,7 +24,6 @@ Every config-dependent lifecycle method accepts a complete, typed ``FabricConfig See the Getting Started overview for runnable single-invocation, typed-config, and multi-turn examples. - ### method `__init__` ```python @@ -41,7 +39,6 @@ def __init__() -> None --- - ### method `attach_environment` ```python @@ -80,7 +77,6 @@ Attachment does not create the provider resource and does not grant Fabric delet --- - ### method `attach_service` ```python @@ -119,7 +115,6 @@ The adapter validates the reference against the normalized configuration and wri --- - ### method `doctor` ```python @@ -155,7 +150,6 @@ Doctor checks the resolved adapter, capability mappings, and declared environmen --- - ### method `plan` ```python @@ -191,7 +185,6 @@ Planning resolves the selected adapter and reports optional runtime capabilities --- - ### method `prepare_environment` ```python @@ -228,7 +221,6 @@ The returned local handle is independent of any runtime session. The caller owns --- - ### method `prepare_service` ```python @@ -265,7 +257,6 @@ The selected adapter maps the normalized configuration into its service configur --- - ### method `release_environment` ```python @@ -292,7 +283,6 @@ Local and externally owned environments detach without deletion. Provider-manage --- - ### method `run` ```python @@ -335,7 +325,6 @@ Execute one complete start, invoke, and stop lifecycle. --- - ### method `start_runtime` ```python @@ -384,7 +373,6 @@ Each call starts a new logical runtime. Runtime-scoped overrides are recursively --- - ### method `start_runtime_in` ```python @@ -400,7 +388,7 @@ async def start_runtime_in( Start one stateful runtime in an explicitly prepared or attached environment. -Starting or stopping the runtime does not release ``environment``. Runtime and reuse limits depend on the provider. Release the environment separately after stopping the runtime and collecting results. Runtime startup is currently implemented for local environments. +Starting or stopping the runtime does not release ``environment``. Runtime and reuse limits depend on the provider. Release the environment separately after stopping the runtime and collecting results. Runtime startup is supported by local environments and the OpenShell command bridge. OpenShell invocation is buffered and supports multiple runtime IDs per sandbox. 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..cfc3fb4a8 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,6 +481,74 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- +## class `RecordConfig` + +Opt-in per-invocation run record. + +When ``enabled`` is true, NeMo Fabric writes ``/.fabric///run-record.jsonl`` for each invocation. It requires an artifact root, the ``local`` environment provider, and a ``process`` or ``python`` adapter. + + + +### Fields + +The model defines the following fields: + +| Field | Type | Required | Default | Constraints | Description | +| --- | --- | --- | --- | --- | --- | +| `enabled` | `StrictBool` | Yes | — | `Strict(strict=True)` | — | + +--- + +### 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 `RuntimeConfig` @@ -520,6 +567,7 @@ The model defines the following fields: | `artifacts` | `str \| Path \| None` | No | `None` | — | — | | `timeout_seconds` | `float \| None` | No | `None` | `Gt(gt=0), _PydanticGeneralMetadata(allow_inf_nan=False)` | — | | `max_turns` | `int \| None` | No | `None` | `Gt(gt=0), Le(le=4294967295)` | — | +| `record` | `RecordConfig \| None` | No | `None` | — | — | --- @@ -553,7 +601,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -564,7 +611,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -576,7 +622,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `EnvironmentConfig` Execution environment configuration supplied by the consumer. @@ -634,7 +679,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -645,7 +689,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -657,7 +700,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `ModelConfig` Configuration for one model role. @@ -711,7 +753,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -722,7 +763,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -734,7 +774,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `SkillConfig` Skill capability configuration. @@ -781,7 +820,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### method `add_path` ```python @@ -792,7 +830,6 @@ Add a skill path if absent. --- - ### classmethod `from_mapping` ```python @@ -803,7 +840,6 @@ Validate a mapping using this Pydantic model. --- - ### method `remove_path` ```python @@ -814,7 +850,6 @@ Remove a skill path if present. --- - ### method `to_mapping` ```python @@ -826,7 +861,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `McpAuthenticationConfig` MCP server authentication configuration. @@ -883,7 +917,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -894,7 +927,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -906,7 +938,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `McpServerConfig` MCP server configuration. @@ -961,7 +992,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -972,7 +1002,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -984,7 +1013,6 @@ Return the server mapping without collapsing an explicit empty allowlist. --- - ## class `McpConfig` MCP capability configuration. @@ -1031,7 +1059,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### method `add_server` ```python @@ -1057,7 +1084,6 @@ For stdio, set ``url`` to the executable and pass each command-line argument as --- - ### classmethod `from_mapping` ```python @@ -1068,7 +1094,6 @@ Validate a mapping using this Pydantic model. --- - ### method `remove_server` ```python @@ -1079,7 +1104,6 @@ Remove a named MCP server if present. --- - ### method `to_mapping` ```python @@ -1091,7 +1115,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `RelayConfigPolicy` NVIDIA NeMo Relay config validation policy. @@ -1140,7 +1163,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -1151,7 +1173,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -1163,7 +1184,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `RelayAtofFileSinkConfig` NeMo Relay ATOF file sink configuration. @@ -1213,7 +1233,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -1224,7 +1243,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -1236,7 +1254,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `RelayAtofStreamSinkConfig` NeMo Relay ATOF stream sink configuration. @@ -1290,7 +1307,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -1301,7 +1317,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -1313,7 +1328,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `RelayAtofConfig` NeMo Relay ATOF export configuration. @@ -1361,7 +1375,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -1372,7 +1385,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -1384,7 +1396,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `RelayS3StorageConfig` NeMo Relay ATIF S3 storage configuration. @@ -1439,7 +1450,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -1450,7 +1460,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -1462,7 +1471,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `RelayHttpStorageConfig` NeMo Relay ATIF HTTP storage configuration. @@ -1513,7 +1521,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -1524,7 +1531,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -1536,7 +1542,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `RelayAtifConfig` NeMo Relay ATIF export configuration. @@ -1591,7 +1596,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -1602,7 +1606,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -1614,7 +1617,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `RelayOpenTelemetryEndpointConfig` One typed NeMo Relay OpenTelemetry destination. @@ -1674,7 +1676,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -1685,7 +1686,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -1697,7 +1697,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `RelayOpenTelemetryConfig` NeMo Relay typed OpenTelemetry destination configuration. @@ -1745,7 +1744,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -1756,7 +1754,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -1768,7 +1765,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `RelayObservabilityConfig` NeMo Relay observability component configuration. @@ -1820,7 +1816,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -1831,7 +1826,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -1843,7 +1837,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `RelayComponentConfig` Generic NeMo Relay plugin component configuration. @@ -1892,7 +1885,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -1903,7 +1895,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -1915,7 +1906,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `RelayConfig` First-class NeMo Relay integration configuration. @@ -1966,7 +1956,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -1977,7 +1966,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -1989,7 +1977,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `TelemetryProviderConfig` Provider-specific telemetry configuration. @@ -2036,7 +2023,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -2047,7 +2033,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -2059,7 +2044,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `TelemetryConfig` Telemetry configuration. @@ -2106,7 +2090,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### method `enable_native` ```python @@ -2117,7 +2100,6 @@ Let the selected adapter handle telemetry natively. --- - ### method `enable_relay` ```python @@ -2128,7 +2110,6 @@ Enable NeMo Relay telemetry for subsequently started runtimes. --- - ### classmethod `from_mapping` ```python @@ -2139,7 +2120,6 @@ Validate a mapping using this Pydantic model. --- - ### method `remove_provider` ```python @@ -2150,7 +2130,6 @@ Remove a configured telemetry provider. --- - ### method `to_mapping` ```python @@ -2162,7 +2141,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `ToolDefinitionConfig` One named normalized tool or tool-group definition. @@ -2211,7 +2189,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -2222,7 +2199,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -2234,7 +2210,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `ToolsConfig` Harness-neutral tool capability configuration. @@ -2283,7 +2258,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### method `add_definition` ```python @@ -2301,7 +2275,6 @@ Add or replace one named definition and return this tools config. --- - ### classmethod `from_mapping` ```python @@ -2312,7 +2285,6 @@ Validate a mapping using this Pydantic model. --- - ### method `remove_definition` ```python @@ -2323,7 +2295,6 @@ Remove one named definition and return this tools config. --- - ### method `to_mapping` ```python @@ -2335,7 +2306,6 @@ Return the tool mapping without collapsing an explicit empty policy. --- - ## class `SystemStatsSamplingConfig` Opt-in periodic collection; bounds include both endpoint samples. @@ -2383,7 +2353,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -2394,7 +2363,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -2406,7 +2374,6 @@ Return a detached JSON-compatible mapping for Rust/core calls. --- - ## class `SystemStatsConfig` Native invocation stats selection, independent of Relay and recording. @@ -2456,7 +2423,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -2467,7 +2433,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python @@ -2479,6 +2444,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 +2539,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 +2574,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### method `add_mcp_server` ```python @@ -2568,7 +2599,6 @@ For stdio, set ``url`` to the executable and pass each command-line argument as --- - ### method `add_skill_path` ```python @@ -2579,7 +2609,6 @@ Add a skill path and return this config. --- - ### method `add_tool_definition` ```python @@ -2597,7 +2626,6 @@ Add or replace one named tool definition and return this config. --- - ### method `block_tools` ```python @@ -2608,7 +2636,6 @@ Block adapter-native tool names and return this config. --- - ### method `enable_relay` ```python @@ -2626,7 +2653,6 @@ Enable NeMo Relay telemetry and return this config. --- - ### classmethod `from_mapping` ```python @@ -2637,7 +2663,6 @@ Validate the public agent config mapping shape. --- - ### method `remove_mcp_server` ```python @@ -2648,7 +2673,6 @@ Remove a named MCP server and return this config. --- - ### method `remove_skill_path` ```python @@ -2659,7 +2683,6 @@ Remove a skill path and return this config. --- - ### method `remove_tool_definition` ```python @@ -2670,7 +2693,6 @@ Remove one named tool definition and return this config. --- - ### method `to_mapping` ```python @@ -2682,7 +2704,6 @@ Return a detached mapping matching the Rust ``FabricConfig`` schema. --- - ## class `RunRequest` One validated NeMo Fabric invocation request. @@ -2733,7 +2754,6 @@ Returns the set of fields that have been explicitly set on this model instance. --- - ### classmethod `from_mapping` ```python @@ -2744,7 +2764,6 @@ Validate a mapping using this Pydantic model. --- - ### method `to_mapping` ```python diff --git a/docs/reference/api/python-library-reference/nemo_fabric.reporting.md b/docs/reference/api/python-library-reference/nemo_fabric.reporting.md new file mode 100644 index 000000000..8a6ea68d3 --- /dev/null +++ b/docs/reference/api/python-library-reference/nemo_fabric.reporting.md @@ -0,0 +1,133 @@ +--- +title: "Run Evidence Reporting" +slug: "/reference/api/python-library-reference/reporting" +description: "Inspect saved invocation results, accounting coverage, and diagnostic references." +--- + + +# module `nemo_fabric.reporting` + +Inspect saved invocation evidence without a benchmark manifest or trace import. + +**Global Variables** +--------------- +- **MAX_RESULT_BYTES** +- **RESOURCE_SCOPE** + +--- + +## function `summarize_result` + +```python +def summarize_result(result: Mapping[str, Any]) -> dict[str, Any] +``` + +Describe one returned invocation, preserving independent usable evidence. + +This is not a task verdict or proof of a complete scheduled run. Normalized accounting uses the shared reducer. Legacy usage remains labeled diagnostics; logs and Relay/ATIF totals never fill missing normalized dimensions. + + +--- + +## function `read_result` + +```python +def read_result(path: Path) -> dict[str, Any] +``` + +Read one saved full result without requiring task labels or a manifest. + + +--- + +## function `summarize_request` + +```python +def summarize_request(document: Mapping[str, Any]) -> dict[str, Any] +``` + +Describe outer dispatch evidence without fabricating an invocation result. + +A start alone establishes dispatch registration, not a known outcome. A returned reference identifies the expected result location; this reader does not follow paths from evidence or infer that publication succeeded. + + +--- + +## function `read_evidence` + +```python +def read_evidence(path: Path) -> dict[str, Any] +``` + +Read a full result, invocation record or outer request record; never follow references. + + +--- + +## function `render_request_summary` + +```python +def render_request_summary(summary: Mapping[str, Any]) -> str +``` + +Explain missing execution evidence without assigning zero spend. + + +--- + +## function `read_invocation_record` + +```python +def read_invocation_record(path: Path) -> dict[str, Any] +``` + +Read bounded start/end evidence; a truncated tail never creates a result. + +This reports the core invocation window before one-shot shutdown. Only the typed model_usage projection supplies normalized accounting; legacy counters never gain a completeness guarantee. + + +--- + +## function `render_invocation_record_summary` + +```python +def render_invocation_record_summary( + summary: Mapping[str, Any], + *, + details: bool = False, +) -> str +``` + +Describe the invocation interval without assuming whole-runtime success. + + +--- + +## function `render_result_summary` + +```python +def render_result_summary(summary: Mapping[str, Any], *, details: bool = False) -> str +``` + +Render a concise overview; details add resource evidence. + +Token and cost completeness remain separate. JSON quantities and accounting semantics are unchanged by this presentation. + + +--- + +## function `main` + +```python +def main() -> None +``` + +Inspect a saved invocation result as a concise summary or JSON. + + + + +--- + +_This file was automatically generated via [lazydocs](https://github.com/ml-tooling/lazydocs)._ 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..b930369db 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,540 @@ 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. + + +### 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]` | + +### 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 8a789e763..006ee11d9 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: 142 +position: 144 --- {/* 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 359d1c117..0f396c1ad 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: 143 +position: 145 --- {/* 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 6bbdf7d45..87adbfaaf 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: 144 +position: 146 --- {/* 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..30a552025 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: 68 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} @@ -43,23 +43,23 @@ MCP server. ### `impl Clone for CapabilityKind` -
Clone for CapabilityKind"}} />
+
Clone for CapabilityKind"}} />
#### `clone` -
clone(&self) -> CapabilityKind"}} />
+
clone(&self) -> CapabilityKind"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for CapabilityKind` -
Debug for CapabilityKind"}} />
+
Debug for CapabilityKind"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl<'de> Deserialize<'de> for CapabilityKind` @@ -67,7 +67,7 @@ MCP server. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for CapabilityKind` @@ -75,11 +75,11 @@ MCP server. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -87,19 +87,19 @@ MCP server. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for CapabilityKind` -
PartialEq for CapabilityKind"}} />
+
PartialEq for CapabilityKind"}} />
#### `eq` -
eq(&self, other: &CapabilityKind) -> bool"}} />
+
eq(&self, other: &CapabilityKind) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for CapabilityKind` @@ -107,16 +107,16 @@ MCP server. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl Copy for CapabilityKind` -
Copy for CapabilityKind"}} />
+
Copy for CapabilityKind"}} />
### `impl Eq for CapabilityKind` -
Eq for CapabilityKind"}} />
+
Eq for CapabilityKind"}} />
### `impl StructuralPartialEq for CapabilityKind` -
StructuralPartialEq for CapabilityKind"}} />
+
StructuralPartialEq for CapabilityKind"}} />
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..65b3acaf3 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: 69 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} @@ -45,23 +45,23 @@ Neither the adapter nor NeMo Fabric can execute the configured capability. ### `impl Clone for CapabilityTarget` -
Clone for CapabilityTarget"}} />
+
Clone for CapabilityTarget"}} />
#### `clone` -
clone(&self) -> CapabilityTarget"}} />
+
clone(&self) -> CapabilityTarget"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for CapabilityTarget` -
Debug for CapabilityTarget"}} />
+
Debug for CapabilityTarget"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl<'de> Deserialize<'de> for CapabilityTarget` @@ -69,7 +69,7 @@ Neither the adapter nor NeMo Fabric can execute the configured capability. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for CapabilityTarget` @@ -77,11 +77,11 @@ Neither the adapter nor NeMo Fabric can execute the configured capability. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -89,19 +89,19 @@ Neither the adapter nor NeMo Fabric can execute the configured capability. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for CapabilityTarget` -
PartialEq for CapabilityTarget"}} />
+
PartialEq for CapabilityTarget"}} />
#### `eq` -
eq(&self, other: &CapabilityTarget) -> bool"}} />
+
eq(&self, other: &CapabilityTarget) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for CapabilityTarget` @@ -109,16 +109,16 @@ Neither the adapter nor NeMo Fabric can execute the configured capability. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl Copy for CapabilityTarget` -
Copy for CapabilityTarget"}} />
+
Copy for CapabilityTarget"}} />
### `impl Eq for CapabilityTarget` -
Eq for CapabilityTarget"}} />
+
Eq for CapabilityTarget"}} />
### `impl StructuralPartialEq for CapabilityTarget` -
StructuralPartialEq for CapabilityTarget"}} />
+
StructuralPartialEq for CapabilityTarget"}} />
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..6ba725a43 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,14 +2,14 @@ title: "Enum Relay Atif Storage Config" sidebar-title: "RelayAtifStorageConfig" description: "Relay ATIF remote storage configuration." -position: 76 +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        headers: BTreeMap<String, String>,\n        header_env: BTreeMap<String, String>,\n        timeout_millis: u64,\n        extensions: BTreeMap<String, Value>,\n    },\n    S3 {\n        bucket: String,\n        key_prefix: Option<String>,\n        access_key_id: Option<String>,\n        secret_access_key_var: Option<String>,\n        session_token_var: Option<String>,\n        region: Option<String>,\n        endpoint_url: Option<String>,\n        allow_http: Option<bool>,\n        extensions: BTreeMap<String, Value>,\n    },\n}"}} />
+
String,\n        headers: BTreeMap<String, String>,\n        header_env: BTreeMap<String, String>,\n        timeout_millis: u64,\n        extensions: BTreeMap<String, Value>,\n    },\n    S3 {\n        bucket: String,\n        key_prefix: Option<String>,\n        access_key_id: Option<String>,\n        secret_access_key_var: Option<String>,\n        session_token_var: Option<String>,\n        region: Option<String>,\n        endpoint_url: Option<String>,\n        allow_http: Option<bool>,\n        extensions: BTreeMap<String, Value>,\n    },\n}"}} />
Relay ATIF remote storage configuration. @@ -91,23 +91,23 @@ Additive S3 storage fields. ### `impl Clone for RelayAtifStorageConfig` -
Clone for RelayAtifStorageConfig"}} />
+
Clone for RelayAtifStorageConfig"}} />
#### `clone` -
clone(&self) -> RelayAtifStorageConfig"}} />
+
clone(&self) -> RelayAtifStorageConfig"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for RelayAtifStorageConfig` -
Debug for RelayAtifStorageConfig"}} />
+
Debug for RelayAtifStorageConfig"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl<'de> Deserialize<'de> for RelayAtifStorageConfig` @@ -115,7 +115,7 @@ Additive S3 storage fields. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for RelayAtifStorageConfig` @@ -123,11 +123,11 @@ Additive S3 storage fields. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -135,19 +135,19 @@ Additive S3 storage fields. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for RelayAtifStorageConfig` -
PartialEq for RelayAtifStorageConfig"}} />
+
PartialEq for RelayAtifStorageConfig"}} />
#### `eq` -
eq(&self, other: &RelayAtifStorageConfig) -> bool"}} />
+
eq(&self, other: &RelayAtifStorageConfig) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for RelayAtifStorageConfig` @@ -155,8 +155,8 @@ Additive S3 storage fields. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl StructuralPartialEq for RelayAtifStorageConfig` -
StructuralPartialEq for RelayAtifStorageConfig"}} />
+
StructuralPartialEq for RelayAtifStorageConfig"}} />
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..808ee8d80 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: 79 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} @@ -36,31 +36,31 @@ Overwrite an existing ATOF file. ### `impl Clone for RelayAtofMode` -
Clone for RelayAtofMode"}} />
+
Clone for RelayAtofMode"}} />
#### `clone` -
clone(&self) -> RelayAtofMode"}} />
+
clone(&self) -> RelayAtofMode"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for RelayAtofMode` -
Debug for RelayAtofMode"}} />
+
Debug for RelayAtofMode"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl Default for RelayAtofMode` -
Default for RelayAtofMode"}} />
+
Default for RelayAtofMode"}} />
#### `default` -
default() -> RelayAtofMode"}} />
+
default() -> RelayAtofMode"}} />
### `impl<'de> Deserialize<'de> for RelayAtofMode` @@ -68,7 +68,7 @@ Overwrite an existing ATOF file. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for RelayAtofMode` @@ -76,11 +76,11 @@ Overwrite an existing ATOF file. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -88,19 +88,19 @@ Overwrite an existing ATOF file. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for RelayAtofMode` -
PartialEq for RelayAtofMode"}} />
+
PartialEq for RelayAtofMode"}} />
#### `eq` -
eq(&self, other: &RelayAtofMode) -> bool"}} />
+
eq(&self, other: &RelayAtofMode) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for RelayAtofMode` @@ -108,16 +108,16 @@ Overwrite an existing ATOF file. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl Copy for RelayAtofMode` -
Copy for RelayAtofMode"}} />
+
Copy for RelayAtofMode"}} />
### `impl Eq for RelayAtofMode` -
Eq for RelayAtofMode"}} />
+
Eq for RelayAtofMode"}} />
### `impl StructuralPartialEq for RelayAtofMode` -
StructuralPartialEq for RelayAtofMode"}} />
+
StructuralPartialEq for RelayAtofMode"}} />
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..878d15fdb 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,14 +2,14 @@ title: "Enum Relay Atof Sink Config" sidebar-title: "RelayAtofSinkConfig" description: "Relay ATOF sink configuration." -position: 78 +position: 80 --- {/* 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        filename: Option<String>,\n        mode: RelayAtofMode,\n        extensions: BTreeMap<String, Value>,\n    },\n    Stream {\n        url: String,\n        transport: RelayAtofStreamTransport,\n        headers: BTreeMap<String, String>,\n        header_env: BTreeMap<String, String>,\n        timeout_millis: u64,\n        field_name_policy: RelayAtofStreamFieldNamePolicy,\n        name: Option<String>,\n        extensions: BTreeMap<String, Value>,\n    },\n}"}} />
+
Option<PathBuf>,\n        filename: Option<String>,\n        mode: RelayAtofMode,\n        extensions: BTreeMap<String, Value>,\n    },\n    Stream {\n        url: String,\n        transport: RelayAtofStreamTransport,\n        headers: BTreeMap<String, String>,\n        header_env: BTreeMap<String, String>,\n        timeout_millis: u64,\n        field_name_policy: RelayAtofStreamFieldNamePolicy,\n        name: Option<String>,\n        extensions: BTreeMap<String, Value>,\n    },\n}"}} />
Relay ATOF sink configuration. @@ -83,23 +83,23 @@ Additive stream sink fields. ### `impl Clone for RelayAtofSinkConfig` -
Clone for RelayAtofSinkConfig"}} />
+
Clone for RelayAtofSinkConfig"}} />
#### `clone` -
clone(&self) -> RelayAtofSinkConfig"}} />
+
clone(&self) -> RelayAtofSinkConfig"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for RelayAtofSinkConfig` -
Debug for RelayAtofSinkConfig"}} />
+
Debug for RelayAtofSinkConfig"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl<'de> Deserialize<'de> for RelayAtofSinkConfig` @@ -107,7 +107,7 @@ Additive stream sink fields. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for RelayAtofSinkConfig` @@ -115,11 +115,11 @@ Additive stream sink fields. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -127,19 +127,19 @@ Additive stream sink fields. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for RelayAtofSinkConfig` -
PartialEq for RelayAtofSinkConfig"}} />
+
PartialEq for RelayAtofSinkConfig"}} />
#### `eq` -
eq(&self, other: &RelayAtofSinkConfig) -> bool"}} />
+
eq(&self, other: &RelayAtofSinkConfig) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for RelayAtofSinkConfig` @@ -147,8 +147,8 @@ Additive stream sink fields. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl StructuralPartialEq for RelayAtofSinkConfig` -
StructuralPartialEq for RelayAtofSinkConfig"}} />
+
StructuralPartialEq for RelayAtofSinkConfig"}} />
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..2ada675c1 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: 81 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} @@ -36,31 +36,31 @@ Replace dots in field names. ### `impl Clone for RelayAtofStreamFieldNamePolicy` -
Clone for RelayAtofStreamFieldNamePolicy"}} />
+
Clone for RelayAtofStreamFieldNamePolicy"}} />
#### `clone` -
clone(&self) -> RelayAtofStreamFieldNamePolicy"}} />
+
clone(&self) -> RelayAtofStreamFieldNamePolicy"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for RelayAtofStreamFieldNamePolicy` -
Debug for RelayAtofStreamFieldNamePolicy"}} />
+
Debug for RelayAtofStreamFieldNamePolicy"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl Default for RelayAtofStreamFieldNamePolicy` -
Default for RelayAtofStreamFieldNamePolicy"}} />
+
Default for RelayAtofStreamFieldNamePolicy"}} />
#### `default` -
default() -> RelayAtofStreamFieldNamePolicy"}} />
+
default() -> RelayAtofStreamFieldNamePolicy"}} />
### `impl<'de> Deserialize<'de> for RelayAtofStreamFieldNamePolicy` @@ -68,7 +68,7 @@ Replace dots in field names. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for RelayAtofStreamFieldNamePolicy` @@ -76,11 +76,11 @@ Replace dots in field names. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -88,19 +88,19 @@ Replace dots in field names. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for RelayAtofStreamFieldNamePolicy` -
PartialEq for RelayAtofStreamFieldNamePolicy"}} />
+
PartialEq for RelayAtofStreamFieldNamePolicy"}} />
#### `eq` -
eq(&self, other: &RelayAtofStreamFieldNamePolicy) -> bool"}} />
+
eq(&self, other: &RelayAtofStreamFieldNamePolicy) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for RelayAtofStreamFieldNamePolicy` @@ -108,16 +108,16 @@ Replace dots in field names. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl Copy for RelayAtofStreamFieldNamePolicy` -
Copy for RelayAtofStreamFieldNamePolicy"}} />
+
Copy for RelayAtofStreamFieldNamePolicy"}} />
### `impl Eq for RelayAtofStreamFieldNamePolicy` -
Eq for RelayAtofStreamFieldNamePolicy"}} />
+
Eq for RelayAtofStreamFieldNamePolicy"}} />
### `impl StructuralPartialEq for RelayAtofStreamFieldNamePolicy` -
StructuralPartialEq for RelayAtofStreamFieldNamePolicy"}} />
+
StructuralPartialEq for RelayAtofStreamFieldNamePolicy"}} />
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..56ede7f7d 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: 82 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} @@ -43,31 +43,31 @@ NDJSON transport. ### `impl Clone for RelayAtofStreamTransport` -
Clone for RelayAtofStreamTransport"}} />
+
Clone for RelayAtofStreamTransport"}} />
#### `clone` -
clone(&self) -> RelayAtofStreamTransport"}} />
+
clone(&self) -> RelayAtofStreamTransport"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for RelayAtofStreamTransport` -
Debug for RelayAtofStreamTransport"}} />
+
Debug for RelayAtofStreamTransport"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl Default for RelayAtofStreamTransport` -
Default for RelayAtofStreamTransport"}} />
+
Default for RelayAtofStreamTransport"}} />
#### `default` -
default() -> RelayAtofStreamTransport"}} />
+
default() -> RelayAtofStreamTransport"}} />
### `impl<'de> Deserialize<'de> for RelayAtofStreamTransport` @@ -75,7 +75,7 @@ NDJSON transport. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for RelayAtofStreamTransport` @@ -83,11 +83,11 @@ NDJSON transport. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -95,19 +95,19 @@ NDJSON transport. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for RelayAtofStreamTransport` -
PartialEq for RelayAtofStreamTransport"}} />
+
PartialEq for RelayAtofStreamTransport"}} />
#### `eq` -
eq(&self, other: &RelayAtofStreamTransport) -> bool"}} />
+
eq(&self, other: &RelayAtofStreamTransport) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for RelayAtofStreamTransport` @@ -115,16 +115,16 @@ NDJSON transport. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl Copy for RelayAtofStreamTransport` -
Copy for RelayAtofStreamTransport"}} />
+
Copy for RelayAtofStreamTransport"}} />
### `impl Eq for RelayAtofStreamTransport` -
Eq for RelayAtofStreamTransport"}} />
+
Eq for RelayAtofStreamTransport"}} />
### `impl StructuralPartialEq for RelayAtofStreamTransport` -
StructuralPartialEq for RelayAtofStreamTransport"}} />
+
StructuralPartialEq for RelayAtofStreamTransport"}} />
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..11b6039ce 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: 83 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} @@ -43,23 +43,23 @@ Emit OpenInference semantic-convention spans. ### `impl Clone for RelayOpenTelemetryEndpointType` -
Clone for RelayOpenTelemetryEndpointType"}} />
+
Clone for RelayOpenTelemetryEndpointType"}} />
#### `clone` -
clone(&self) -> RelayOpenTelemetryEndpointType"}} />
+
clone(&self) -> RelayOpenTelemetryEndpointType"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for RelayOpenTelemetryEndpointType` -
Debug for RelayOpenTelemetryEndpointType"}} />
+
Debug for RelayOpenTelemetryEndpointType"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl<'de> Deserialize<'de> for RelayOpenTelemetryEndpointType` @@ -67,7 +67,7 @@ Emit OpenInference semantic-convention spans. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for RelayOpenTelemetryEndpointType` @@ -75,11 +75,11 @@ Emit OpenInference semantic-convention spans. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -87,19 +87,19 @@ Emit OpenInference semantic-convention spans. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for RelayOpenTelemetryEndpointType` -
PartialEq for RelayOpenTelemetryEndpointType"}} />
+
PartialEq for RelayOpenTelemetryEndpointType"}} />
#### `eq` -
eq(&self, other: &RelayOpenTelemetryEndpointType) -> bool"}} />
+
eq(&self, other: &RelayOpenTelemetryEndpointType) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for RelayOpenTelemetryEndpointType` @@ -107,16 +107,16 @@ Emit OpenInference semantic-convention spans. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl Copy for RelayOpenTelemetryEndpointType` -
Copy for RelayOpenTelemetryEndpointType"}} />
+
Copy for RelayOpenTelemetryEndpointType"}} />
### `impl Eq for RelayOpenTelemetryEndpointType` -
Eq for RelayOpenTelemetryEndpointType"}} />
+
Eq for RelayOpenTelemetryEndpointType"}} />
### `impl StructuralPartialEq for RelayOpenTelemetryEndpointType` -
StructuralPartialEq for RelayOpenTelemetryEndpointType"}} />
+
StructuralPartialEq for RelayOpenTelemetryEndpointType"}} />
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..16e3ec7b9 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: 84 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} @@ -43,31 +43,31 @@ Project marks as tool spans. ### `impl Clone for RelayOpenTelemetryMarkProjection` -
Clone for RelayOpenTelemetryMarkProjection"}} />
+
Clone for RelayOpenTelemetryMarkProjection"}} />
#### `clone` -
clone(&self) -> RelayOpenTelemetryMarkProjection"}} />
+
clone(&self) -> RelayOpenTelemetryMarkProjection"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for RelayOpenTelemetryMarkProjection` -
Debug for RelayOpenTelemetryMarkProjection"}} />
+
Debug for RelayOpenTelemetryMarkProjection"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl Default for RelayOpenTelemetryMarkProjection` -
Default for RelayOpenTelemetryMarkProjection"}} />
+
Default for RelayOpenTelemetryMarkProjection"}} />
#### `default` -
default() -> RelayOpenTelemetryMarkProjection"}} />
+
default() -> RelayOpenTelemetryMarkProjection"}} />
### `impl<'de> Deserialize<'de> for RelayOpenTelemetryMarkProjection` @@ -75,7 +75,7 @@ Project marks as tool spans. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for RelayOpenTelemetryMarkProjection` @@ -83,11 +83,11 @@ Project marks as tool spans. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -95,19 +95,19 @@ Project marks as tool spans. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for RelayOpenTelemetryMarkProjection` -
PartialEq for RelayOpenTelemetryMarkProjection"}} />
+
PartialEq for RelayOpenTelemetryMarkProjection"}} />
#### `eq` -
eq(&self, other: &RelayOpenTelemetryMarkProjection) -> bool"}} />
+
eq(&self, other: &RelayOpenTelemetryMarkProjection) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for RelayOpenTelemetryMarkProjection` @@ -115,16 +115,16 @@ Project marks as tool spans. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl Copy for RelayOpenTelemetryMarkProjection` -
Copy for RelayOpenTelemetryMarkProjection"}} />
+
Copy for RelayOpenTelemetryMarkProjection"}} />
### `impl Eq for RelayOpenTelemetryMarkProjection` -
Eq for RelayOpenTelemetryMarkProjection"}} />
+
Eq for RelayOpenTelemetryMarkProjection"}} />
### `impl StructuralPartialEq for RelayOpenTelemetryMarkProjection` -
StructuralPartialEq for RelayOpenTelemetryMarkProjection"}} />
+
StructuralPartialEq for RelayOpenTelemetryMarkProjection"}} />
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..6af7544fd 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: 85 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} @@ -36,31 +36,31 @@ OTLP gRPC transport. ### `impl Clone for RelayOtlpTransport` -
Clone for RelayOtlpTransport"}} />
+
Clone for RelayOtlpTransport"}} />
#### `clone` -
clone(&self) -> RelayOtlpTransport"}} />
+
clone(&self) -> RelayOtlpTransport"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for RelayOtlpTransport` -
Debug for RelayOtlpTransport"}} />
+
Debug for RelayOtlpTransport"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl Default for RelayOtlpTransport` -
Default for RelayOtlpTransport"}} />
+
Default for RelayOtlpTransport"}} />
#### `default` -
default() -> RelayOtlpTransport"}} />
+
default() -> RelayOtlpTransport"}} />
### `impl<'de> Deserialize<'de> for RelayOtlpTransport` @@ -68,7 +68,7 @@ OTLP gRPC transport. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for RelayOtlpTransport` @@ -76,11 +76,11 @@ OTLP gRPC transport. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -88,19 +88,19 @@ OTLP gRPC transport. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for RelayOtlpTransport` -
PartialEq for RelayOtlpTransport"}} />
+
PartialEq for RelayOtlpTransport"}} />
#### `eq` -
eq(&self, other: &RelayOtlpTransport) -> bool"}} />
+
eq(&self, other: &RelayOtlpTransport) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for RelayOtlpTransport` @@ -108,16 +108,16 @@ OTLP gRPC transport. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl Copy for RelayOtlpTransport` -
Copy for RelayOtlpTransport"}} />
+
Copy for RelayOtlpTransport"}} />
### `impl Eq for RelayOtlpTransport` -
Eq for RelayOtlpTransport"}} />
+
Eq for RelayOtlpTransport"}} />
### `impl StructuralPartialEq for RelayOtlpTransport` -
StructuralPartialEq for RelayOtlpTransport"}} />
+
StructuralPartialEq for RelayOtlpTransport"}} />
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..cd028f459 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: 86 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} @@ -43,31 +43,31 @@ Error on the unsupported or unknown value. ### `impl Clone for RelayUnsupportedBehavior` -
Clone for RelayUnsupportedBehavior"}} />
+
Clone for RelayUnsupportedBehavior"}} />
#### `clone` -
clone(&self) -> RelayUnsupportedBehavior"}} />
+
clone(&self) -> RelayUnsupportedBehavior"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for RelayUnsupportedBehavior` -
Debug for RelayUnsupportedBehavior"}} />
+
Debug for RelayUnsupportedBehavior"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl Default for RelayUnsupportedBehavior` -
Default for RelayUnsupportedBehavior"}} />
+
Default for RelayUnsupportedBehavior"}} />
#### `default` -
default() -> RelayUnsupportedBehavior"}} />
+
default() -> RelayUnsupportedBehavior"}} />
### `impl<'de> Deserialize<'de> for RelayUnsupportedBehavior` @@ -75,7 +75,7 @@ Error on the unsupported or unknown value. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for RelayUnsupportedBehavior` @@ -83,11 +83,11 @@ Error on the unsupported or unknown value. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -95,19 +95,19 @@ Error on the unsupported or unknown value. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for RelayUnsupportedBehavior` -
PartialEq for RelayUnsupportedBehavior"}} />
+
PartialEq for RelayUnsupportedBehavior"}} />
#### `eq` -
eq(&self, other: &RelayUnsupportedBehavior) -> bool"}} />
+
eq(&self, other: &RelayUnsupportedBehavior) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for RelayUnsupportedBehavior` @@ -115,16 +115,16 @@ Error on the unsupported or unknown value. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl Copy for RelayUnsupportedBehavior` -
Copy for RelayUnsupportedBehavior"}} />
+
Copy for RelayUnsupportedBehavior"}} />
### `impl Eq for RelayUnsupportedBehavior` -
Eq for RelayUnsupportedBehavior"}} />
+
Eq for RelayUnsupportedBehavior"}} />
### `impl StructuralPartialEq for RelayUnsupportedBehavior` -
StructuralPartialEq for RelayUnsupportedBehavior"}} />
+
StructuralPartialEq for RelayUnsupportedBehavior"}} />
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 f130d1764..fcd08a472 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: 54 +position: 56 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} @@ -71,23 +71,23 @@ Adapter is installed through a harness-native plugin manager. ### `impl Clone for ResolutionStrategy` -
Clone for ResolutionStrategy"}} />
+
Clone for ResolutionStrategy"}} />
#### `clone` -
clone(&self) -> ResolutionStrategy"}} />
+
clone(&self) -> ResolutionStrategy"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for ResolutionStrategy` -
Debug for ResolutionStrategy"}} />
+
Debug for ResolutionStrategy"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl<'de> Deserialize<'de> for ResolutionStrategy` @@ -95,7 +95,7 @@ Adapter is installed through a harness-native plugin manager. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for ResolutionStrategy` @@ -103,11 +103,11 @@ Adapter is installed through a harness-native plugin manager. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -115,19 +115,19 @@ Adapter is installed through a harness-native plugin manager. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for ResolutionStrategy` -
PartialEq for ResolutionStrategy"}} />
+
PartialEq for ResolutionStrategy"}} />
#### `eq` -
eq(&self, other: &ResolutionStrategy) -> bool"}} />
+
eq(&self, other: &ResolutionStrategy) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for ResolutionStrategy` @@ -135,16 +135,16 @@ Adapter is installed through a harness-native plugin manager. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl Copy for ResolutionStrategy` -
Copy for ResolutionStrategy"}} />
+
Copy for ResolutionStrategy"}} />
### `impl Eq for ResolutionStrategy` -
Eq for ResolutionStrategy"}} />
+
Eq for ResolutionStrategy"}} />
### `impl StructuralPartialEq for ResolutionStrategy` -
StructuralPartialEq for ResolutionStrategy"}} />
+
StructuralPartialEq for ResolutionStrategy"}} />
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 8ce5c9908..5ebbf53cb 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: 64 +position: 66 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} @@ -40,7 +40,7 @@ Let the selected adapter handle telemetry natively. #### `as_str` -
str"}} />
+
str"}} />
Return the stable configuration value for this provider. @@ -48,31 +48,31 @@ Return the stable configuration value for this provider. ### `impl Clone for TelemetryProvider` -
Clone for TelemetryProvider"}} />
+
Clone for TelemetryProvider"}} />
#### `clone` -
clone(&self) -> TelemetryProvider"}} />
+
clone(&self) -> TelemetryProvider"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for TelemetryProvider` -
Debug for TelemetryProvider"}} />
+
Debug for TelemetryProvider"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl Default for TelemetryProvider` -
Default for TelemetryProvider"}} />
+
Default for TelemetryProvider"}} />
#### `default` -
default() -> TelemetryProvider"}} />
+
default() -> TelemetryProvider"}} />
### `impl<'de> Deserialize<'de> for TelemetryProvider` @@ -80,7 +80,7 @@ Return the stable configuration value for this provider. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for TelemetryProvider` @@ -88,11 +88,11 @@ Return the stable configuration value for this provider. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -100,63 +100,63 @@ Return the stable configuration value for this provider. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl Ord for TelemetryProvider` -
Ord for TelemetryProvider"}} />
+
Ord for TelemetryProvider"}} />
#### `cmp` -
cmp(&self, other: &TelemetryProvider) -> Ordering"}} />
+
cmp(&self, other: &TelemetryProvider) -> Ordering"}} />
#### `max` -
max(self, other: Self) -> Selfwhere\n    Self: Sized,"}} />
+
max(self, other: Self) -> Selfwhere\n    Self: Sized,"}} />
#### `min` -
min(self, other: Self) -> Selfwhere\n    Self: Sized,"}} />
+
min(self, other: Self) -> Selfwhere\n    Self: Sized,"}} />
#### `clamp` -
clamp(self, min: Self, max: Self) -> Selfwhere\n    Self: Sized,"}} />
+
clamp(self, min: Self, max: Self) -> Selfwhere\n    Self: Sized,"}} />
### `impl PartialEq for TelemetryProvider` -
PartialEq for TelemetryProvider"}} />
+
PartialEq for TelemetryProvider"}} />
#### `eq` -
eq(&self, other: &TelemetryProvider) -> bool"}} />
+
eq(&self, other: &TelemetryProvider) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl PartialOrd for TelemetryProvider` -
PartialOrd for TelemetryProvider"}} />
+
PartialOrd for TelemetryProvider"}} />
#### `partial_cmp` -
partial_cmp(&self, other: &TelemetryProvider) -> Option<Ordering>"}} />
+
partial_cmp(&self, other: &TelemetryProvider) -> Option<Ordering>"}} />
#### `lt` -
lt(&self, other: &Rhs) -> bool"}} />
+
lt(&self, other: &Rhs) -> bool"}} />
#### `le` -
le(&self, other: &Rhs) -> bool"}} />
+
le(&self, other: &Rhs) -> bool"}} />
#### `gt` -
gt(&self, other: &Rhs) -> bool"}} />
+
gt(&self, other: &Rhs) -> bool"}} />
#### `ge` -
ge(&self, other: &Rhs) -> bool"}} />
+
ge(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for TelemetryProvider` @@ -164,16 +164,16 @@ Return the stable configuration value for this provider. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl Copy for TelemetryProvider` -
Copy for TelemetryProvider"}} />
+
Copy for TelemetryProvider"}} />
### `impl Eq for TelemetryProvider` -
Eq for TelemetryProvider"}} />
+
Eq for TelemetryProvider"}} />
### `impl StructuralPartialEq for TelemetryProvider` -
StructuralPartialEq for TelemetryProvider"}} />
+
StructuralPartialEq for TelemetryProvider"}} />
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 fc493bae7..bd63fd7bd 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,13 +2,13 @@ title: "Function load_adapter_descriptor" sidebar-title: "load_adapter_descriptor" description: "Load an adapter descriptor from JSON package metadata." -position: 71 +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`. -
AsRef<Path>,\n) -> Result<AdapterDescriptor>"}} />
+
AsRef<Path>,\n) -> Result<AdapterDescriptor>"}} />
Load an adapter descriptor from JSON package metadata. 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 142fdba30..c2b645883 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,13 +2,13 @@ title: "Function load_adapter_target_descriptor" sidebar-title: "load_adapter_target_descriptor" description: "Load an Adapter Target Descriptor from declarative JSON metadata." -position: 72 +position: 74 --- {/* 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`. -
AsRef<Path>,\n) -> Result<AdapterTargetDescriptor>"}} />
+
AsRef<Path>,\n) -> Result<AdapterTargetDescriptor>"}} />
Load an Adapter Target Descriptor from declarative JSON metadata. 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 6bc8a973b..76811da0f 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: 73 +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/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/index.mdx index f031abd58..d9d2fc049 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: 145 +position: 147 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} @@ -53,6 +53,8 @@ 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. +- [RecordConfig](struct-recordconfig.mdx): Per-invocation run record settings. - [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 bfc4a1fcd..47ec3308d 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. @@ -85,23 +89,23 @@ Additive fields not yet recognized by this core version. ### `impl Clone for FabricConfig` -
Clone for FabricConfig"}} />
+
Clone for FabricConfig"}} />
#### `clone` -
clone(&self) -> FabricConfig"}} />
+
clone(&self) -> FabricConfig"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for FabricConfig` -
Debug for FabricConfig"}} />
+
Debug for FabricConfig"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl<'de> Deserialize<'de> for FabricConfig` @@ -109,7 +113,7 @@ Additive fields not yet recognized by this core version. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for FabricConfig` @@ -117,11 +121,11 @@ Additive fields not yet recognized by this core version. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -129,19 +133,19 @@ Additive fields not yet recognized by this core version. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for FabricConfig` -
PartialEq for FabricConfig"}} />
+
PartialEq for FabricConfig"}} />
#### `eq` -
eq(&self, other: &FabricConfig) -> bool"}} />
+
eq(&self, other: &FabricConfig) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for FabricConfig` @@ -149,8 +153,8 @@ Additive fields not yet recognized by this core version. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl StructuralPartialEq for FabricConfig` -
StructuralPartialEq for FabricConfig"}} />
+
StructuralPartialEq for FabricConfig"}} />
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..db976091d --- /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: 54 +--- +{/* 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-recordconfig.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-recordconfig.mdx new file mode 100644 index 000000000..c49f1c72e --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/config/struct-recordconfig.mdx @@ -0,0 +1,108 @@ +--- +title: "Struct Record Config" +sidebar-title: "RecordConfig" +description: "Per-invocation run record settings." +position: 55 +--- +{/* 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}"}} />
+ +Per-invocation run record settings. + +When enabled, core appends a start line and an end line for each local invocation to `/.fabric///run-record.jsonl`. + +## Fields + +### `enabled: bool` + +Whether to write a run record for each invocation. Requires an artifact root, the `local` environment provider, and a `process` or `python` adapter. + +## Trait Implementations + +### `impl Clone for RecordConfig` + +
Clone for RecordConfig"}} />
+ +#### `clone` + +
clone(&self) -> RecordConfig"}} />
+ +#### `clone_from` + +
clone_from(&mut self, source: &Self)"}} />
+ +### `impl Debug for RecordConfig` + +
Debug for RecordConfig"}} />
+ +#### `fmt` + +
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+ +### `impl Default for RecordConfig` + +
Default for RecordConfig"}} />
+ +#### `default` + +
default() -> RecordConfig"}} />
+ +### `impl<'de> Deserialize<'de> for RecordConfig` + +
Deserialize<'de> for RecordConfig"}} />
+ +#### `deserialize` + +
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `impl JsonSchema for RecordConfig` + +
RecordConfig"}} />
+ +#### `schema_name` + +
Cow<'static, str>"}} />
+ +#### `schema_id` + +
Cow<'static, str>"}} />
+ +#### `json_schema` + +
+ +#### `inline_schema` + +
bool"}} />
+ +### `impl PartialEq for RecordConfig` + +
PartialEq for RecordConfig"}} />
+ +#### `eq` + +
eq(&self, other: &RecordConfig) -> bool"}} />
+ +#### `ne` + +
ne(&self, other: &Rhs) -> bool"}} />
+ +### `impl Serialize for RecordConfig` + +
Serialize for RecordConfig"}} />
+ +#### `serialize` + +
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+ +### `impl Eq for RecordConfig` + +
Eq for RecordConfig"}} />
+ +### `impl StructuralPartialEq for RecordConfig` + +
StructuralPartialEq for RecordConfig"}} />
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..766904dd7 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,14 +2,14 @@ title: "Struct Relay Atif Config" sidebar-title: "RelayAtifConfig" description: "Relay ATIF export configuration." -position: 38 +position: 40 --- {/* 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 agent_name: String,\n    pub agent_version: Option<String>,\n    pub model_name: Option<String>,\n    pub tool_definitions: Option<Vec<Value>>,\n    pub extra: Option<Value>,\n    pub output_directory: Option<PathBuf>,\n    pub filename_template: String,\n    pub storage: Option<Vec<RelayAtifStorageConfig>>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+
bool,\n    pub agent_name: String,\n    pub agent_version: Option<String>,\n    pub model_name: Option<String>,\n    pub tool_definitions: Option<Vec<Value>>,\n    pub extra: Option<Value>,\n    pub output_directory: Option<PathBuf>,\n    pub filename_template: String,\n    pub storage: Option<Vec<RelayAtifStorageConfig>>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
Relay ATIF export configuration. @@ -59,31 +59,31 @@ Additive ATIF fields. ### `impl Clone for RelayAtifConfig` -
Clone for RelayAtifConfig"}} />
+
Clone for RelayAtifConfig"}} />
#### `clone` -
clone(&self) -> RelayAtifConfig"}} />
+
clone(&self) -> RelayAtifConfig"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for RelayAtifConfig` -
Debug for RelayAtifConfig"}} />
+
Debug for RelayAtifConfig"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl Default for RelayAtifConfig` -
Default for RelayAtifConfig"}} />
+
Default for RelayAtifConfig"}} />
#### `default` -
default() -> Self"}} />
+
default() -> Self"}} />
### `impl<'de> Deserialize<'de> for RelayAtifConfig` @@ -91,7 +91,7 @@ Additive ATIF fields. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for RelayAtifConfig` @@ -99,11 +99,11 @@ Additive ATIF fields. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -111,19 +111,19 @@ Additive ATIF fields. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for RelayAtifConfig` -
PartialEq for RelayAtifConfig"}} />
+
PartialEq for RelayAtifConfig"}} />
#### `eq` -
eq(&self, other: &RelayAtifConfig) -> bool"}} />
+
eq(&self, other: &RelayAtifConfig) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for RelayAtifConfig` @@ -131,8 +131,8 @@ Additive ATIF fields. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl StructuralPartialEq for RelayAtifConfig` -
StructuralPartialEq for RelayAtifConfig"}} />
+
StructuralPartialEq for RelayAtifConfig"}} />
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..4f5915c52 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,14 +2,14 @@ title: "Struct Relay Atof Config" sidebar-title: "RelayAtofConfig" description: "Relay ATOF export configuration." -position: 39 +position: 41 --- {/* 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 sinks: Vec<RelayAtofSinkConfig>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+
bool,\n    pub sinks: Vec<RelayAtofSinkConfig>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
Relay ATOF export configuration. @@ -31,31 +31,31 @@ Additive ATOF fields. ### `impl Clone for RelayAtofConfig` -
Clone for RelayAtofConfig"}} />
+
Clone for RelayAtofConfig"}} />
#### `clone` -
clone(&self) -> RelayAtofConfig"}} />
+
clone(&self) -> RelayAtofConfig"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for RelayAtofConfig` -
Debug for RelayAtofConfig"}} />
+
Debug for RelayAtofConfig"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl Default for RelayAtofConfig` -
Default for RelayAtofConfig"}} />
+
Default for RelayAtofConfig"}} />
#### `default` -
default() -> RelayAtofConfig"}} />
+
default() -> RelayAtofConfig"}} />
### `impl<'de> Deserialize<'de> for RelayAtofConfig` @@ -63,7 +63,7 @@ Additive ATOF fields. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for RelayAtofConfig` @@ -71,11 +71,11 @@ Additive ATOF fields. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -83,19 +83,19 @@ Additive ATOF fields. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for RelayAtofConfig` -
PartialEq for RelayAtofConfig"}} />
+
PartialEq for RelayAtofConfig"}} />
#### `eq` -
eq(&self, other: &RelayAtofConfig) -> bool"}} />
+
eq(&self, other: &RelayAtofConfig) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for RelayAtofConfig` @@ -103,8 +103,8 @@ Additive ATOF fields. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl StructuralPartialEq for RelayAtofConfig` -
StructuralPartialEq for RelayAtofConfig"}} />
+
StructuralPartialEq for RelayAtofConfig"}} />
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..9a7c06888 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,14 +2,14 @@ title: "Struct Relay Component Config" sidebar-title: "RelayComponentConfig" description: "Generic NeMo Relay plugin component configuration." -position: 40 +position: 42 --- {/* 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 enabled: bool,\n    pub config: BTreeMap<String, Value>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+
String,\n    pub enabled: bool,\n    pub config: BTreeMap<String, Value>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
Generic NeMo Relay plugin component configuration. @@ -35,23 +35,23 @@ Additive component fields. ### `impl Clone for RelayComponentConfig` -
Clone for RelayComponentConfig"}} />
+
Clone for RelayComponentConfig"}} />
#### `clone` -
clone(&self) -> RelayComponentConfig"}} />
+
clone(&self) -> RelayComponentConfig"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for RelayComponentConfig` -
Debug for RelayComponentConfig"}} />
+
Debug for RelayComponentConfig"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl<'de> Deserialize<'de> for RelayComponentConfig` @@ -59,7 +59,7 @@ Additive component fields. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for RelayComponentConfig` @@ -67,11 +67,11 @@ Additive component fields. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -79,19 +79,19 @@ Additive component fields. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for RelayComponentConfig` -
PartialEq for RelayComponentConfig"}} />
+
PartialEq for RelayComponentConfig"}} />
#### `eq` -
eq(&self, other: &RelayComponentConfig) -> bool"}} />
+
eq(&self, other: &RelayComponentConfig) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for RelayComponentConfig` @@ -99,8 +99,8 @@ Additive component fields. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl StructuralPartialEq for RelayComponentConfig` -
StructuralPartialEq for RelayComponentConfig"}} />
+
StructuralPartialEq for RelayComponentConfig"}} />
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..9fbe4ef09 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,14 +2,14 @@ title: "Struct Relay Config" sidebar-title: "RelayConfig" description: "NeMo Relay integration configuration." -position: 41 +position: 43 --- {/* 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 output_dir: Option<PathBuf>,\n    pub observability: Option<RelayObservabilityConfig>,\n    pub components: Vec<RelayComponentConfig>,\n    pub policy: Option<RelayConfigPolicy>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+
Option<String>,\n    pub output_dir: Option<PathBuf>,\n    pub observability: Option<RelayObservabilityConfig>,\n    pub components: Vec<RelayComponentConfig>,\n    pub policy: Option<RelayConfigPolicy>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
NeMo Relay integration configuration. @@ -43,31 +43,31 @@ Additive Relay fields. ### `impl Clone for RelayConfig` -
Clone for RelayConfig"}} />
+
Clone for RelayConfig"}} />
#### `clone` -
clone(&self) -> RelayConfig"}} />
+
clone(&self) -> RelayConfig"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for RelayConfig` -
Debug for RelayConfig"}} />
+
Debug for RelayConfig"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl Default for RelayConfig` -
Default for RelayConfig"}} />
+
Default for RelayConfig"}} />
#### `default` -
default() -> RelayConfig"}} />
+
default() -> RelayConfig"}} />
### `impl<'de> Deserialize<'de> for RelayConfig` @@ -75,7 +75,7 @@ Additive Relay fields. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for RelayConfig` @@ -83,11 +83,11 @@ Additive Relay fields. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -95,19 +95,19 @@ Additive Relay fields. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for RelayConfig` -
PartialEq for RelayConfig"}} />
+
PartialEq for RelayConfig"}} />
#### `eq` -
eq(&self, other: &RelayConfig) -> bool"}} />
+
eq(&self, other: &RelayConfig) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for RelayConfig` @@ -115,8 +115,8 @@ Additive Relay fields. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl StructuralPartialEq for RelayConfig` -
StructuralPartialEq for RelayConfig"}} />
+
StructuralPartialEq for RelayConfig"}} />
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..d8c555ac2 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: 44 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} @@ -31,31 +31,31 @@ Policy for unsupported values. ### `impl Clone for RelayConfigPolicy` -
Clone for RelayConfigPolicy"}} />
+
Clone for RelayConfigPolicy"}} />
#### `clone` -
clone(&self) -> RelayConfigPolicy"}} />
+
clone(&self) -> RelayConfigPolicy"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for RelayConfigPolicy` -
Debug for RelayConfigPolicy"}} />
+
Debug for RelayConfigPolicy"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl Default for RelayConfigPolicy` -
Default for RelayConfigPolicy"}} />
+
Default for RelayConfigPolicy"}} />
#### `default` -
default() -> Self"}} />
+
default() -> Self"}} />
### `impl<'de> Deserialize<'de> for RelayConfigPolicy` @@ -63,7 +63,7 @@ Policy for unsupported values. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for RelayConfigPolicy` @@ -71,11 +71,11 @@ Policy for unsupported values. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -83,19 +83,19 @@ Policy for unsupported values. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for RelayConfigPolicy` -
PartialEq for RelayConfigPolicy"}} />
+
PartialEq for RelayConfigPolicy"}} />
#### `eq` -
eq(&self, other: &RelayConfigPolicy) -> bool"}} />
+
eq(&self, other: &RelayConfigPolicy) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for RelayConfigPolicy` @@ -103,8 +103,8 @@ Policy for unsupported values. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl StructuralPartialEq for RelayConfigPolicy` -
StructuralPartialEq for RelayConfigPolicy"}} />
+
StructuralPartialEq for RelayConfigPolicy"}} />
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..c07317676 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,14 +2,14 @@ title: "Struct Relay Observability Config" sidebar-title: "RelayObservabilityConfig" description: "NeMo Relay observability component configuration." -position: 43 +position: 45 --- {/* 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 atof: Option<RelayAtofConfig>,\n    pub atif: Option<RelayAtifConfig>,\n    pub opentelemetry: Option<RelayOpenTelemetryConfig>,\n    pub policy: Option<RelayConfigPolicy>,\n    pub enable_full_payloads: bool,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+
u32,\n    pub atof: Option<RelayAtofConfig>,\n    pub atif: Option<RelayAtifConfig>,\n    pub opentelemetry: Option<RelayOpenTelemetryConfig>,\n    pub policy: Option<RelayConfigPolicy>,\n    pub enable_full_payloads: bool,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
NeMo Relay observability component configuration. @@ -47,31 +47,31 @@ Additive observability fields. ### `impl Clone for RelayObservabilityConfig` -
Clone for RelayObservabilityConfig"}} />
+
Clone for RelayObservabilityConfig"}} />
#### `clone` -
clone(&self) -> RelayObservabilityConfig"}} />
+
clone(&self) -> RelayObservabilityConfig"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for RelayObservabilityConfig` -
Debug for RelayObservabilityConfig"}} />
+
Debug for RelayObservabilityConfig"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl Default for RelayObservabilityConfig` -
Default for RelayObservabilityConfig"}} />
+
Default for RelayObservabilityConfig"}} />
#### `default` -
default() -> Self"}} />
+
default() -> Self"}} />
### `impl<'de> Deserialize<'de> for RelayObservabilityConfig` @@ -79,7 +79,7 @@ Additive observability fields. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for RelayObservabilityConfig` @@ -87,11 +87,11 @@ Additive observability fields. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -99,19 +99,19 @@ Additive observability fields. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for RelayObservabilityConfig` -
PartialEq for RelayObservabilityConfig"}} />
+
PartialEq for RelayObservabilityConfig"}} />
#### `eq` -
eq(&self, other: &RelayObservabilityConfig) -> bool"}} />
+
eq(&self, other: &RelayObservabilityConfig) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for RelayObservabilityConfig` @@ -119,8 +119,8 @@ Additive observability fields. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl StructuralPartialEq for RelayObservabilityConfig` -
StructuralPartialEq for RelayObservabilityConfig"}} />
+
StructuralPartialEq for RelayObservabilityConfig"}} />
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..fcabab53e 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,14 +2,14 @@ title: "Struct Relay Open Telemetry Config" sidebar-title: "RelayOpenTelemetryConfig" description: "Relay OpenTelemetry export section." -position: 44 +position: 46 --- {/* 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 endpoints: Vec<RelayOpenTelemetryEndpointConfig>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+
bool,\n    pub endpoints: Vec<RelayOpenTelemetryEndpointConfig>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
Relay OpenTelemetry export section. @@ -31,31 +31,31 @@ Additive OpenTelemetry section fields. ### `impl Clone for RelayOpenTelemetryConfig` -
Clone for RelayOpenTelemetryConfig"}} />
+
Clone for RelayOpenTelemetryConfig"}} />
#### `clone` -
clone(&self) -> RelayOpenTelemetryConfig"}} />
+
clone(&self) -> RelayOpenTelemetryConfig"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for RelayOpenTelemetryConfig` -
Debug for RelayOpenTelemetryConfig"}} />
+
Debug for RelayOpenTelemetryConfig"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl Default for RelayOpenTelemetryConfig` -
Default for RelayOpenTelemetryConfig"}} />
+
Default for RelayOpenTelemetryConfig"}} />
#### `default` -
default() -> RelayOpenTelemetryConfig"}} />
+
default() -> RelayOpenTelemetryConfig"}} />
### `impl<'de> Deserialize<'de> for RelayOpenTelemetryConfig` @@ -63,7 +63,7 @@ Additive OpenTelemetry section fields. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for RelayOpenTelemetryConfig` @@ -71,11 +71,11 @@ Additive OpenTelemetry section fields. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -83,19 +83,19 @@ Additive OpenTelemetry section fields. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for RelayOpenTelemetryConfig` -
PartialEq for RelayOpenTelemetryConfig"}} />
+
PartialEq for RelayOpenTelemetryConfig"}} />
#### `eq` -
eq(&self, other: &RelayOpenTelemetryConfig) -> bool"}} />
+
eq(&self, other: &RelayOpenTelemetryConfig) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for RelayOpenTelemetryConfig` @@ -103,8 +103,8 @@ Additive OpenTelemetry section fields. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl StructuralPartialEq for RelayOpenTelemetryConfig` -
StructuralPartialEq for RelayOpenTelemetryConfig"}} />
+
StructuralPartialEq for RelayOpenTelemetryConfig"}} />
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..1ce0ebe28 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,14 +2,14 @@ title: "Struct Relay Open Telemetry Endpoint Config" sidebar-title: "RelayOpenTelemetryEndpointConfig" description: "One typed Relay OpenTelemetry OTLP destination." -position: 45 +position: 47 --- {/* 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`. -
RelayOpenTelemetryEndpointType,\n    pub endpoint: String,\n    pub mark_projection: RelayOpenTelemetryMarkProjection,\n    pub mark_exclude_names: Vec<String>,\n    pub attribute_mappings: Vec<BTreeMap<String, String>>,\n    pub transport: RelayOtlpTransport,\n    pub service_name: String,\n    pub service_namespace: Option<String>,\n    pub service_version: Option<String>,\n    pub instrumentation_scope: String,\n    pub timeout_millis: u64,\n    pub headers: BTreeMap<String, String>,\n    pub header_env: BTreeMap<String, String>,\n    pub resource_attributes: BTreeMap<String, String>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+
RelayOpenTelemetryEndpointType,\n    pub endpoint: String,\n    pub mark_projection: RelayOpenTelemetryMarkProjection,\n    pub mark_exclude_names: Vec<String>,\n    pub attribute_mappings: Vec<BTreeMap<String, String>>,\n    pub transport: RelayOtlpTransport,\n    pub service_name: String,\n    pub service_namespace: Option<String>,\n    pub service_version: Option<String>,\n    pub instrumentation_scope: String,\n    pub timeout_millis: u64,\n    pub headers: BTreeMap<String, String>,\n    pub header_env: BTreeMap<String, String>,\n    pub resource_attributes: BTreeMap<String, String>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
One typed Relay OpenTelemetry OTLP destination. @@ -79,23 +79,23 @@ Additive endpoint fields. ### `impl Clone for RelayOpenTelemetryEndpointConfig` -
Clone for RelayOpenTelemetryEndpointConfig"}} />
+
Clone for RelayOpenTelemetryEndpointConfig"}} />
#### `clone` -
clone(&self) -> RelayOpenTelemetryEndpointConfig"}} />
+
clone(&self) -> RelayOpenTelemetryEndpointConfig"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for RelayOpenTelemetryEndpointConfig` -
Debug for RelayOpenTelemetryEndpointConfig"}} />
+
Debug for RelayOpenTelemetryEndpointConfig"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl<'de> Deserialize<'de> for RelayOpenTelemetryEndpointConfig` @@ -103,7 +103,7 @@ Additive endpoint fields. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for RelayOpenTelemetryEndpointConfig` @@ -111,11 +111,11 @@ Additive endpoint fields. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -123,19 +123,19 @@ Additive endpoint fields. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for RelayOpenTelemetryEndpointConfig` -
PartialEq for RelayOpenTelemetryEndpointConfig"}} />
+
PartialEq for RelayOpenTelemetryEndpointConfig"}} />
#### `eq` -
eq(&self, other: &RelayOpenTelemetryEndpointConfig) -> bool"}} />
+
eq(&self, other: &RelayOpenTelemetryEndpointConfig) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for RelayOpenTelemetryEndpointConfig` @@ -143,8 +143,8 @@ Additive endpoint fields. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl StructuralPartialEq for RelayOpenTelemetryEndpointConfig` -
StructuralPartialEq for RelayOpenTelemetryEndpointConfig"}} />
+
StructuralPartialEq for RelayOpenTelemetryEndpointConfig"}} />
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 8e70ade88..502fbba8e 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,14 +2,14 @@ title: "Struct Resolve Context" sidebar-title: "ResolveContext" description: "Source context used when resolving an in-memory NeMo Fabric config." -position: 55 +position: 57 --- {/* 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}"}} />
+
PathBuf,\n}"}} />
Source context used when resolving an in-memory NeMo Fabric config. @@ -27,7 +27,7 @@ Base directory used to resolve relative NeMo Fabric paths. #### `new` -
Into<PathBuf>) -> Self"}} />
+
Into<PathBuf>) -> Self"}} />
Build a context with an explicit base directory. @@ -35,36 +35,36 @@ Build a context with an explicit base directory. ### `impl Clone for ResolveContext` -
Clone for ResolveContext"}} />
+
Clone for ResolveContext"}} />
#### `clone` -
clone(&self) -> ResolveContext"}} />
+
clone(&self) -> ResolveContext"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for ResolveContext` -
Debug for ResolveContext"}} />
+
Debug for ResolveContext"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl PartialEq for ResolveContext` -
PartialEq for ResolveContext"}} />
+
PartialEq for ResolveContext"}} />
#### `eq` -
eq(&self, other: &ResolveContext) -> bool"}} />
+
eq(&self, other: &ResolveContext) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl StructuralPartialEq for ResolveContext` -
StructuralPartialEq for ResolveContext"}} />
+
StructuralPartialEq for ResolveContext"}} />
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 7c1e65663..d06b5690d 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,14 +2,14 @@ title: "Struct Resolved Adapter Descriptor" sidebar-title: "ResolvedAdapterDescriptor" description: "Adapter descriptor selected for a run plan." -position: 56 +position: 58 --- {/* 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<DescriptorProvenance>,\n    pub descriptor: AdapterDescriptor,\n}"}} />
+
Vec<DescriptorProvenance>,\n    pub descriptor: AdapterDescriptor,\n}"}} />
Adapter descriptor selected for a run plan. @@ -27,23 +27,23 @@ Adapter-owned compatibility and capability metadata. ### `impl Clone for ResolvedAdapterDescriptor` -
Clone for ResolvedAdapterDescriptor"}} />
+
Clone for ResolvedAdapterDescriptor"}} />
#### `clone` -
clone(&self) -> ResolvedAdapterDescriptor"}} />
+
clone(&self) -> ResolvedAdapterDescriptor"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for ResolvedAdapterDescriptor` -
Debug for ResolvedAdapterDescriptor"}} />
+
Debug for ResolvedAdapterDescriptor"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl<'de> Deserialize<'de> for ResolvedAdapterDescriptor` @@ -51,7 +51,7 @@ Adapter-owned compatibility and capability metadata. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for ResolvedAdapterDescriptor` @@ -59,11 +59,11 @@ Adapter-owned compatibility and capability metadata. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -71,19 +71,19 @@ Adapter-owned compatibility and capability metadata. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for ResolvedAdapterDescriptor` -
PartialEq for ResolvedAdapterDescriptor"}} />
+
PartialEq for ResolvedAdapterDescriptor"}} />
#### `eq` -
eq(&self, other: &ResolvedAdapterDescriptor) -> bool"}} />
+
eq(&self, other: &ResolvedAdapterDescriptor) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for ResolvedAdapterDescriptor` @@ -91,8 +91,8 @@ Adapter-owned compatibility and capability metadata. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl StructuralPartialEq for ResolvedAdapterDescriptor` -
StructuralPartialEq for ResolvedAdapterDescriptor"}} />
+
StructuralPartialEq for ResolvedAdapterDescriptor"}} />
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 8adac3efc..631e6b519 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,14 +2,14 @@ title: "Struct Resolved Adapter Target Descriptor" sidebar-title: "ResolvedAdapterTargetDescriptor" description: "Adapter target descriptor selected for a run plan." -position: 57 +position: 59 --- {/* 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<DescriptorProvenance>,\n    pub descriptor: AdapterTargetDescriptor,\n}"}} />
+
Vec<DescriptorProvenance>,\n    pub descriptor: AdapterTargetDescriptor,\n}"}} />
Adapter target descriptor selected for a run plan. @@ -27,23 +27,23 @@ Target-specific resolution and validation metadata. ### `impl Clone for ResolvedAdapterTargetDescriptor` -
Clone for ResolvedAdapterTargetDescriptor"}} />
+
Clone for ResolvedAdapterTargetDescriptor"}} />
#### `clone` -
clone(&self) -> ResolvedAdapterTargetDescriptor"}} />
+
clone(&self) -> ResolvedAdapterTargetDescriptor"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for ResolvedAdapterTargetDescriptor` -
Debug for ResolvedAdapterTargetDescriptor"}} />
+
Debug for ResolvedAdapterTargetDescriptor"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl<'de> Deserialize<'de> for ResolvedAdapterTargetDescriptor` @@ -51,7 +51,7 @@ Target-specific resolution and validation metadata. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for ResolvedAdapterTargetDescriptor` @@ -59,11 +59,11 @@ Target-specific resolution and validation metadata. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -71,19 +71,19 @@ Target-specific resolution and validation metadata. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for ResolvedAdapterTargetDescriptor` -
PartialEq for ResolvedAdapterTargetDescriptor"}} />
+
PartialEq for ResolvedAdapterTargetDescriptor"}} />
#### `eq` -
eq(&self, other: &ResolvedAdapterTargetDescriptor) -> bool"}} />
+
eq(&self, other: &ResolvedAdapterTargetDescriptor) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for ResolvedAdapterTargetDescriptor` @@ -91,8 +91,8 @@ Target-specific resolution and validation metadata. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl StructuralPartialEq for ResolvedAdapterTargetDescriptor` -
StructuralPartialEq for ResolvedAdapterTargetDescriptor"}} />
+
StructuralPartialEq for ResolvedAdapterTargetDescriptor"}} />
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 e3c43e4e1..93977598d 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: 58 +position: 60 --- {/* 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. @@ -63,23 +67,23 @@ Resolved telemetry pass-through plan. ### `impl Clone for RunPlan` -
Clone for RunPlan"}} />
+
Clone for RunPlan"}} />
#### `clone` -
clone(&self) -> RunPlan"}} />
+
clone(&self) -> RunPlan"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for RunPlan` -
Debug for RunPlan"}} />
+
Debug for RunPlan"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl<'de> Deserialize<'de> for RunPlan` @@ -87,7 +91,7 @@ Resolved telemetry pass-through plan. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for RunPlan` @@ -95,11 +99,11 @@ Resolved telemetry pass-through plan. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -107,19 +111,19 @@ Resolved telemetry pass-through plan. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for RunPlan` -
PartialEq for RunPlan"}} />
+
PartialEq for RunPlan"}} />
#### `eq` -
eq(&self, other: &RunPlan) -> bool"}} />
+
eq(&self, other: &RunPlan) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for RunPlan` @@ -127,8 +131,8 @@ Resolved telemetry pass-through plan. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl StructuralPartialEq for RunPlan` -
StructuralPartialEq for RunPlan"}} />
+
StructuralPartialEq for RunPlan"}} />
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 0d972cb87..0657c893e 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,14 +2,14 @@ title: "Struct Runtime Capabilities" sidebar-title: "RuntimeCapabilities" description: "Lifecycle behavior implemented by a resolved runtime path." -position: 59 +position: 61 --- {/* 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 streaming: bool,\n    pub updates: bool,\n    pub cancellation: bool,\n    pub metadata: BTreeMap<String, Value>,\n}"}} />
+
bool,\n    pub streaming: bool,\n    pub updates: bool,\n    pub cancellation: bool,\n    pub metadata: BTreeMap<String, Value>,\n}"}} />
Lifecycle behavior implemented by a resolved runtime path. @@ -39,31 +39,31 @@ Additional adapter-specific capability metadata. ### `impl Clone for RuntimeCapabilities` -
Clone for RuntimeCapabilities"}} />
+
Clone for RuntimeCapabilities"}} />
#### `clone` -
clone(&self) -> RuntimeCapabilities"}} />
+
clone(&self) -> RuntimeCapabilities"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for RuntimeCapabilities` -
Debug for RuntimeCapabilities"}} />
+
Debug for RuntimeCapabilities"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl Default for RuntimeCapabilities` -
Default for RuntimeCapabilities"}} />
+
Default for RuntimeCapabilities"}} />
#### `default` -
default() -> RuntimeCapabilities"}} />
+
default() -> RuntimeCapabilities"}} />
### `impl<'de> Deserialize<'de> for RuntimeCapabilities` @@ -71,7 +71,7 @@ Additional adapter-specific capability metadata. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for RuntimeCapabilities` @@ -79,11 +79,11 @@ Additional adapter-specific capability metadata. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -91,19 +91,19 @@ Additional adapter-specific capability metadata. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for RuntimeCapabilities` -
PartialEq for RuntimeCapabilities"}} />
+
PartialEq for RuntimeCapabilities"}} />
#### `eq` -
eq(&self, other: &RuntimeCapabilities) -> bool"}} />
+
eq(&self, other: &RuntimeCapabilities) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for RuntimeCapabilities` @@ -111,8 +111,8 @@ Additional adapter-specific capability metadata. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl StructuralPartialEq for RuntimeCapabilities` -
StructuralPartialEq for RuntimeCapabilities"}} />
+
StructuralPartialEq for RuntimeCapabilities"}} />
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 2c89a11b2..89f4efaf6 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,14 +2,14 @@ title: "Struct Runtime Config" sidebar-title: "RuntimeConfig" description: "Invocation runtime contract." -position: 60 +position: 62 --- {/* 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 output_schema: String,\n    pub artifacts: Option<PathBuf>,\n    pub timeout_seconds: Option<f64>,\n    pub max_turns: Option<u32>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+
String,\n    pub output_schema: String,\n    pub artifacts: Option<PathBuf>,\n    pub timeout_seconds: Option<f64>,\n    pub max_turns: Option<u32>,\n    pub record: Option<RecordConfig>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
Invocation runtime contract. @@ -35,6 +35,10 @@ Maximum duration of one invocation in seconds. Maximum number of harness turns within one invocation. +### `record: Option` + +Opt-in per-invocation run record. Core-only; never sent to adapters. + ### `extensions: BTreeMap` Additive normalized runtime fields. @@ -43,23 +47,23 @@ Additive normalized runtime fields. ### `impl Clone for RuntimeConfig` -
Clone for RuntimeConfig"}} />
+
Clone for RuntimeConfig"}} />
#### `clone` -
clone(&self) -> RuntimeConfig"}} />
+
clone(&self) -> RuntimeConfig"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for RuntimeConfig` -
Debug for RuntimeConfig"}} />
+
Debug for RuntimeConfig"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl<'de> Deserialize<'de> for RuntimeConfig` @@ -67,7 +71,7 @@ Additive normalized runtime fields. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for RuntimeConfig` @@ -75,11 +79,11 @@ Additive normalized runtime fields. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -87,19 +91,19 @@ Additive normalized runtime fields. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for RuntimeConfig` -
PartialEq for RuntimeConfig"}} />
+
PartialEq for RuntimeConfig"}} />
#### `eq` -
eq(&self, other: &RuntimeConfig) -> bool"}} />
+
eq(&self, other: &RuntimeConfig) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for RuntimeConfig` @@ -107,8 +111,8 @@ Additive normalized runtime fields. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl StructuralPartialEq for RuntimeConfig` -
StructuralPartialEq for RuntimeConfig"}} />
+
StructuralPartialEq for RuntimeConfig"}} />
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 47644f228..7654044b2 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,14 +2,14 @@ title: "Struct Skill Config" sidebar-title: "SkillConfig" description: "Skill capability configuration." -position: 61 +position: 63 --- {/* 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<PathBuf>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+
Vec<PathBuf>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
Skill capability configuration. @@ -27,31 +27,31 @@ Additive skill fields. ### `impl Clone for SkillConfig` -
Clone for SkillConfig"}} />
+
Clone for SkillConfig"}} />
#### `clone` -
clone(&self) -> SkillConfig"}} />
+
clone(&self) -> SkillConfig"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for SkillConfig` -
Debug for SkillConfig"}} />
+
Debug for SkillConfig"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl Default for SkillConfig` -
Default for SkillConfig"}} />
+
Default for SkillConfig"}} />
#### `default` -
default() -> SkillConfig"}} />
+
default() -> SkillConfig"}} />
### `impl<'de> Deserialize<'de> for SkillConfig` @@ -59,7 +59,7 @@ Additive skill fields. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for SkillConfig` @@ -67,11 +67,11 @@ Additive skill fields. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -79,19 +79,19 @@ Additive skill fields. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for SkillConfig` -
PartialEq for SkillConfig"}} />
+
PartialEq for SkillConfig"}} />
#### `eq` -
eq(&self, other: &SkillConfig) -> bool"}} />
+
eq(&self, other: &SkillConfig) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for SkillConfig` @@ -99,8 +99,8 @@ Additive skill fields. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl StructuralPartialEq for SkillConfig` -
StructuralPartialEq for SkillConfig"}} />
+
StructuralPartialEq for SkillConfig"}} />
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 2e1b4af6b..af6ab206c 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,14 +2,14 @@ title: "Struct Telemetry Config" sidebar-title: "TelemetryConfig" description: "Telemetry configuration." -position: 62 +position: 64 --- {/* 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<TelemetryProvider, TelemetryProviderConfig>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+
BTreeMap<TelemetryProvider, TelemetryProviderConfig>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
Telemetry configuration. @@ -27,23 +27,23 @@ Additive telemetry fields. ### `impl Clone for TelemetryConfig` -
Clone for TelemetryConfig"}} />
+
Clone for TelemetryConfig"}} />
#### `clone` -
clone(&self) -> TelemetryConfig"}} />
+
clone(&self) -> TelemetryConfig"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for TelemetryConfig` -
Debug for TelemetryConfig"}} />
+
Debug for TelemetryConfig"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl<'de> Deserialize<'de> for TelemetryConfig` @@ -51,7 +51,7 @@ Additive telemetry fields. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for TelemetryConfig` @@ -59,11 +59,11 @@ Additive telemetry fields. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -71,19 +71,19 @@ Additive telemetry fields. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for TelemetryConfig` -
PartialEq for TelemetryConfig"}} />
+
PartialEq for TelemetryConfig"}} />
#### `eq` -
eq(&self, other: &TelemetryConfig) -> bool"}} />
+
eq(&self, other: &TelemetryConfig) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for TelemetryConfig` @@ -91,8 +91,8 @@ Additive telemetry fields. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl StructuralPartialEq for TelemetryConfig` -
StructuralPartialEq for TelemetryConfig"}} />
+
StructuralPartialEq for TelemetryConfig"}} />
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 f12176331..2382903ee 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,14 +2,14 @@ title: "Struct Telemetry Plan" sidebar-title: "TelemetryPlan" description: "Resolved telemetry plan." -position: 63 +position: 65 --- {/* 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<TelemetryProvider>,\n    pub relay_enabled: bool,\n    pub relay_project: Option<String>,\n    pub relay_output_dir: Option<PathBuf>,\n    pub relay_config: Option<Value>,\n    pub native_config: Option<Value>,\n    pub adapter_outputs: Vec<String>,\n}"}} />
+
Vec<TelemetryProvider>,\n    pub relay_enabled: bool,\n    pub relay_project: Option<String>,\n    pub relay_output_dir: Option<PathBuf>,\n    pub relay_config: Option<Value>,\n    pub native_config: Option<Value>,\n    pub adapter_outputs: Vec<String>,\n}"}} />
Resolved telemetry plan. @@ -47,23 +47,23 @@ Telemetry outputs declared by the selected adapter descriptor. ### `impl Clone for TelemetryPlan` -
Clone for TelemetryPlan"}} />
+
Clone for TelemetryPlan"}} />
#### `clone` -
clone(&self) -> TelemetryPlan"}} />
+
clone(&self) -> TelemetryPlan"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for TelemetryPlan` -
Debug for TelemetryPlan"}} />
+
Debug for TelemetryPlan"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl<'de> Deserialize<'de> for TelemetryPlan` @@ -71,7 +71,7 @@ Telemetry outputs declared by the selected adapter descriptor. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for TelemetryPlan` @@ -79,11 +79,11 @@ Telemetry outputs declared by the selected adapter descriptor. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -91,19 +91,19 @@ Telemetry outputs declared by the selected adapter descriptor. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for TelemetryPlan` -
PartialEq for TelemetryPlan"}} />
+
PartialEq for TelemetryPlan"}} />
#### `eq` -
eq(&self, other: &TelemetryPlan) -> bool"}} />
+
eq(&self, other: &TelemetryPlan) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for TelemetryPlan` @@ -111,8 +111,8 @@ Telemetry outputs declared by the selected adapter descriptor. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl StructuralPartialEq for TelemetryPlan` -
StructuralPartialEq for TelemetryPlan"}} />
+
StructuralPartialEq for TelemetryPlan"}} />
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 f683b0886..4c1ccd5f5 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,14 +2,14 @@ title: "Struct Telemetry Provider Config" sidebar-title: "TelemetryProviderConfig" description: "Provider-specific telemetry configuration." -position: 65 +position: 67 --- {/* 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<Value>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+
Option<Value>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
Provider-specific telemetry configuration. @@ -27,31 +27,31 @@ Additive provider fields. ### `impl Clone for TelemetryProviderConfig` -
Clone for TelemetryProviderConfig"}} />
+
Clone for TelemetryProviderConfig"}} />
#### `clone` -
clone(&self) -> TelemetryProviderConfig"}} />
+
clone(&self) -> TelemetryProviderConfig"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for TelemetryProviderConfig` -
Debug for TelemetryProviderConfig"}} />
+
Debug for TelemetryProviderConfig"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl Default for TelemetryProviderConfig` -
Default for TelemetryProviderConfig"}} />
+
Default for TelemetryProviderConfig"}} />
#### `default` -
default() -> TelemetryProviderConfig"}} />
+
default() -> TelemetryProviderConfig"}} />
### `impl<'de> Deserialize<'de> for TelemetryProviderConfig` @@ -59,7 +59,7 @@ Additive provider fields. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for TelemetryProviderConfig` @@ -67,11 +67,11 @@ Additive provider fields. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -79,19 +79,19 @@ Additive provider fields. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for TelemetryProviderConfig` -
PartialEq for TelemetryProviderConfig"}} />
+
PartialEq for TelemetryProviderConfig"}} />
#### `eq` -
eq(&self, other: &TelemetryProviderConfig) -> bool"}} />
+
eq(&self, other: &TelemetryProviderConfig) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for TelemetryProviderConfig` @@ -99,8 +99,8 @@ Additive provider fields. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl StructuralPartialEq for TelemetryProviderConfig` -
StructuralPartialEq for TelemetryProviderConfig"}} />
+
StructuralPartialEq for TelemetryProviderConfig"}} />
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 b5e0b460f..393eb5c81 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,14 +2,14 @@ title: "Struct Tool Definition Config" sidebar-title: "ToolDefinitionConfig" description: "One named normalized tool or tool-group definition." -position: 66 +position: 68 --- {/* 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 ref: String,\n    pub settings: Map<String, Value>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+
String,\n    pub ref: String,\n    pub settings: Map<String, Value>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
One named normalized tool or tool-group definition. @@ -35,23 +35,23 @@ Additive definition fields. ### `impl Clone for ToolDefinitionConfig` -
Clone for ToolDefinitionConfig"}} />
+
Clone for ToolDefinitionConfig"}} />
#### `clone` -
clone(&self) -> ToolDefinitionConfig"}} />
+
clone(&self) -> ToolDefinitionConfig"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for ToolDefinitionConfig` -
Debug for ToolDefinitionConfig"}} />
+
Debug for ToolDefinitionConfig"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl<'de> Deserialize<'de> for ToolDefinitionConfig` @@ -59,7 +59,7 @@ Additive definition fields. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for ToolDefinitionConfig` @@ -67,11 +67,11 @@ Additive definition fields. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -79,19 +79,19 @@ Additive definition fields. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for ToolDefinitionConfig` -
PartialEq for ToolDefinitionConfig"}} />
+
PartialEq for ToolDefinitionConfig"}} />
#### `eq` -
eq(&self, other: &ToolDefinitionConfig) -> bool"}} />
+
eq(&self, other: &ToolDefinitionConfig) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for ToolDefinitionConfig` @@ -99,8 +99,8 @@ Additive definition fields. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl StructuralPartialEq for ToolDefinitionConfig` -
StructuralPartialEq for ToolDefinitionConfig"}} />
+
StructuralPartialEq for ToolDefinitionConfig"}} />
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 64b2d6eec..d8e4cdc45 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,14 +2,14 @@ title: "Struct Tools Config" sidebar-title: "ToolsConfig" description: "Harness-neutral tool capability configuration." -position: 67 +position: 69 --- {/* 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, ToolDefinitionConfig>,\n    pub enabled: Option<Vec<String>>,\n    pub blocked: Vec<String>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+
BTreeMap<String, ToolDefinitionConfig>,\n    pub enabled: Option<Vec<String>>,\n    pub blocked: Vec<String>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
Harness-neutral tool capability configuration. @@ -35,31 +35,31 @@ Additive tool configuration fields. ### `impl Clone for ToolsConfig` -
Clone for ToolsConfig"}} />
+
Clone for ToolsConfig"}} />
#### `clone` -
clone(&self) -> ToolsConfig"}} />
+
clone(&self) -> ToolsConfig"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for ToolsConfig` -
Debug for ToolsConfig"}} />
+
Debug for ToolsConfig"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl Default for ToolsConfig` -
Default for ToolsConfig"}} />
+
Default for ToolsConfig"}} />
#### `default` -
default() -> ToolsConfig"}} />
+
default() -> ToolsConfig"}} />
### `impl<'de> Deserialize<'de> for ToolsConfig` @@ -67,7 +67,7 @@ Additive tool configuration fields. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for ToolsConfig` @@ -75,11 +75,11 @@ Additive tool configuration fields. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -87,19 +87,19 @@ Additive tool configuration fields. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for ToolsConfig` -
PartialEq for ToolsConfig"}} />
+
PartialEq for ToolsConfig"}} />
#### `eq` -
eq(&self, other: &ToolsConfig) -> bool"}} />
+
eq(&self, other: &ToolsConfig) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for ToolsConfig` @@ -107,8 +107,8 @@ Additive tool configuration fields. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl StructuralPartialEq for ToolsConfig` -
StructuralPartialEq for ToolsConfig"}} />
+
StructuralPartialEq for ToolsConfig"}} />
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..409972b1a 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,14 +2,14 @@ title: "Struct Tools Plan" sidebar-title: "ToolsPlan" description: "Normalized tool policy for a run." -position: 58 +position: 60 --- {/* 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, ToolDefinitionConfig>,\n    pub enabled: Option<Vec<String>>,\n    pub blocked: Vec<String>,\n}"}} />
+
BTreeMap<String, ToolDefinitionConfig>,\n    pub enabled: Option<Vec<String>>,\n    pub blocked: Vec<String>,\n}"}} />
Normalized tool policy for a run. @@ -31,31 +31,31 @@ Adapter-native tool names to block. ### `impl Clone for ToolsPlan` -
Clone for ToolsPlan"}} />
+
Clone for ToolsPlan"}} />
#### `clone` -
clone(&self) -> ToolsPlan"}} />
+
clone(&self) -> ToolsPlan"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for ToolsPlan` -
Debug for ToolsPlan"}} />
+
Debug for ToolsPlan"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl Default for ToolsPlan` -
Default for ToolsPlan"}} />
+
Default for ToolsPlan"}} />
#### `default` -
default() -> ToolsPlan"}} />
+
default() -> ToolsPlan"}} />
### `impl<'de> Deserialize<'de> for ToolsPlan` @@ -63,7 +63,7 @@ Adapter-native tool names to block. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for ToolsPlan` @@ -71,11 +71,11 @@ Adapter-native tool names to block. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -83,19 +83,19 @@ Adapter-native tool names to block. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for ToolsPlan` -
PartialEq for ToolsPlan"}} />
+
PartialEq for ToolsPlan"}} />
#### `eq` -
eq(&self, other: &ToolsPlan) -> bool"}} />
+
eq(&self, other: &ToolsPlan) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for ToolsPlan` @@ -103,8 +103,8 @@ Adapter-native tool names to block. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl StructuralPartialEq for ToolsPlan` -
StructuralPartialEq for ToolsPlan"}} />
+
StructuralPartialEq for ToolsPlan"}} />
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 21beec0df..622d95e3a 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,14 +2,14 @@ title: "Struct Workflow Config" sidebar-title: "WorkflowConfig" description: "Registered workflow target and immutable construction settings." -position: 68 +position: 70 --- {/* 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 settings: Map<String, Value>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+
String,\n    pub settings: Map<String, Value>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
Registered workflow target and immutable construction settings. @@ -31,23 +31,23 @@ Additive workflow fields. ### `impl Clone for WorkflowConfig` -
Clone for WorkflowConfig"}} />
+
Clone for WorkflowConfig"}} />
#### `clone` -
clone(&self) -> WorkflowConfig"}} />
+
clone(&self) -> WorkflowConfig"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for WorkflowConfig` -
Debug for WorkflowConfig"}} />
+
Debug for WorkflowConfig"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl<'de> Deserialize<'de> for WorkflowConfig` @@ -55,7 +55,7 @@ Additive workflow fields. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for WorkflowConfig` @@ -63,11 +63,11 @@ Additive workflow fields. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -75,19 +75,19 @@ Additive workflow fields. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for WorkflowConfig` -
PartialEq for WorkflowConfig"}} />
+
PartialEq for WorkflowConfig"}} />
#### `eq` -
eq(&self, other: &WorkflowConfig) -> bool"}} />
+
eq(&self, other: &WorkflowConfig) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for WorkflowConfig` @@ -95,8 +95,8 @@ Additive workflow fields. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl StructuralPartialEq for WorkflowConfig` -
StructuralPartialEq for WorkflowConfig"}} />
+
StructuralPartialEq for WorkflowConfig"}} />
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 a002d8fc3..42cc9773a 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,14 +2,14 @@ title: "Struct Workflow Entrypoint Config" sidebar-title: "WorkflowEntrypointConfig" description: "Adapter-owned workflow entry point supplied by an Adapter Target Descriptor." -position: 69 +position: 71 --- {/* 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 ref: String,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+
String,\n    pub ref: String,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
Adapter-owned workflow entry point supplied by an Adapter Target Descriptor. @@ -31,23 +31,23 @@ Additive workflow entry-point fields. ### `impl Clone for WorkflowEntrypointConfig` -
Clone for WorkflowEntrypointConfig"}} />
+
Clone for WorkflowEntrypointConfig"}} />
#### `clone` -
clone(&self) -> WorkflowEntrypointConfig"}} />
+
clone(&self) -> WorkflowEntrypointConfig"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for WorkflowEntrypointConfig` -
Debug for WorkflowEntrypointConfig"}} />
+
Debug for WorkflowEntrypointConfig"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl<'de> Deserialize<'de> for WorkflowEntrypointConfig` @@ -55,7 +55,7 @@ Additive workflow entry-point fields. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for WorkflowEntrypointConfig` @@ -63,11 +63,11 @@ Additive workflow entry-point fields. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -75,19 +75,19 @@ Additive workflow entry-point fields. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for WorkflowEntrypointConfig` -
PartialEq for WorkflowEntrypointConfig"}} />
+
PartialEq for WorkflowEntrypointConfig"}} />
#### `eq` -
eq(&self, other: &WorkflowEntrypointConfig) -> bool"}} />
+
eq(&self, other: &WorkflowEntrypointConfig) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for WorkflowEntrypointConfig` @@ -95,8 +95,8 @@ Additive workflow entry-point fields. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl StructuralPartialEq for WorkflowEntrypointConfig` -
StructuralPartialEq for WorkflowEntrypointConfig"}} />
+
StructuralPartialEq for WorkflowEntrypointConfig"}} />
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 e66041abb..f79a1d9bc 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,14 +2,14 @@ title: "Struct Workflow Target Spec" sidebar-title: "WorkflowTargetSpec" description: "Workflow-specific Adapter Target Descriptor fields." -position: 70 +position: 72 --- {/* 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`. -
WorkflowEntrypointConfig,\n    pub settings_schema: Option<Map<String, Value>>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
+
WorkflowEntrypointConfig,\n    pub settings_schema: Option<Map<String, Value>>,\n    pub extensions: BTreeMap<String, Value>,\n}"}} />
Workflow-specific Adapter Target Descriptor fields. @@ -31,23 +31,23 @@ Additive workflow target fields. ### `impl Clone for WorkflowTargetSpec` -
Clone for WorkflowTargetSpec"}} />
+
Clone for WorkflowTargetSpec"}} />
#### `clone` -
clone(&self) -> WorkflowTargetSpec"}} />
+
clone(&self) -> WorkflowTargetSpec"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for WorkflowTargetSpec` -
Debug for WorkflowTargetSpec"}} />
+
Debug for WorkflowTargetSpec"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl<'de> Deserialize<'de> for WorkflowTargetSpec` @@ -55,7 +55,7 @@ Additive workflow target fields. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for WorkflowTargetSpec` @@ -63,11 +63,11 @@ Additive workflow target fields. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -75,19 +75,19 @@ Additive workflow target fields. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for WorkflowTargetSpec` -
PartialEq for WorkflowTargetSpec"}} />
+
PartialEq for WorkflowTargetSpec"}} />
#### `eq` -
eq(&self, other: &WorkflowTargetSpec) -> bool"}} />
+
eq(&self, other: &WorkflowTargetSpec) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for WorkflowTargetSpec` @@ -95,8 +95,8 @@ Additive workflow target fields. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl StructuralPartialEq for WorkflowTargetSpec` -
StructuralPartialEq for WorkflowTargetSpec"}} />
+
StructuralPartialEq for WorkflowTargetSpec"}} />
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 d8c0693d4..4cb86db90 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: 76 +position: 78 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} @@ -43,23 +43,23 @@ Check failed. ### `impl Clone for DoctorStatus` -
Clone for DoctorStatus"}} />
+
Clone for DoctorStatus"}} />
#### `clone` -
clone(&self) -> DoctorStatus"}} />
+
clone(&self) -> DoctorStatus"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for DoctorStatus` -
Debug for DoctorStatus"}} />
+
Debug for DoctorStatus"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl<'de> Deserialize<'de> for DoctorStatus` @@ -67,7 +67,7 @@ Check failed. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for DoctorStatus` @@ -75,11 +75,11 @@ Check failed. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -87,19 +87,19 @@ Check failed. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for DoctorStatus` -
PartialEq for DoctorStatus"}} />
+
PartialEq for DoctorStatus"}} />
#### `eq` -
eq(&self, other: &DoctorStatus) -> bool"}} />
+
eq(&self, other: &DoctorStatus) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for DoctorStatus` @@ -107,16 +107,16 @@ Check failed. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl Copy for DoctorStatus` -
Copy for DoctorStatus"}} />
+
Copy for DoctorStatus"}} />
### `impl Eq for DoctorStatus` -
Eq for DoctorStatus"}} />
+
Eq for DoctorStatus"}} />
### `impl StructuralPartialEq for DoctorStatus` -
StructuralPartialEq for DoctorStatus"}} />
+
StructuralPartialEq for DoctorStatus"}} />
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 bb8aa23e8..879117cf5 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: 77 +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/doctor/index.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/doctor/index.mdx index 6a1929704..851c0960f 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: 146 +position: 148 --- {/* 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 c64d5792e..79c2e3264 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,14 +2,14 @@ title: "Struct Doctor Check" sidebar-title: "DoctorCheck" description: "Diagnostic check result." -position: 74 +position: 76 --- {/* 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 status: DoctorStatus,\n    pub message: String,\n    pub metadata: BTreeMap<String, Value>,\n}"}} />
+
String,\n    pub status: DoctorStatus,\n    pub message: String,\n    pub metadata: BTreeMap<String, Value>,\n}"}} />
Diagnostic check result. @@ -35,23 +35,23 @@ Optional structured metadata. ### `impl Clone for DoctorCheck` -
Clone for DoctorCheck"}} />
+
Clone for DoctorCheck"}} />
#### `clone` -
clone(&self) -> DoctorCheck"}} />
+
clone(&self) -> DoctorCheck"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for DoctorCheck` -
Debug for DoctorCheck"}} />
+
Debug for DoctorCheck"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl<'de> Deserialize<'de> for DoctorCheck` @@ -59,7 +59,7 @@ Optional structured metadata. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for DoctorCheck` @@ -67,11 +67,11 @@ Optional structured metadata. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -79,19 +79,19 @@ Optional structured metadata. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for DoctorCheck` -
PartialEq for DoctorCheck"}} />
+
PartialEq for DoctorCheck"}} />
#### `eq` -
eq(&self, other: &DoctorCheck) -> bool"}} />
+
eq(&self, other: &DoctorCheck) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for DoctorCheck` @@ -99,8 +99,8 @@ Optional structured metadata. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl StructuralPartialEq for DoctorCheck` -
StructuralPartialEq for DoctorCheck"}} />
+
StructuralPartialEq for DoctorCheck"}} />
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 c7d65e76f..3cee0d12b 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,14 +2,14 @@ title: "Struct Doctor Report" sidebar-title: "DoctorReport" description: "Diagnostic report for a resolved run plan." -position: 75 +position: 77 --- {/* 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 status: DoctorStatus,\n    pub checks: Vec<DoctorCheck>,\n}"}} />
+
String,\n    pub status: DoctorStatus,\n    pub checks: Vec<DoctorCheck>,\n}"}} />
Diagnostic report for a resolved run plan. @@ -31,23 +31,23 @@ Checks. ### `impl Clone for DoctorReport` -
Clone for DoctorReport"}} />
+
Clone for DoctorReport"}} />
#### `clone` -
clone(&self) -> DoctorReport"}} />
+
clone(&self) -> DoctorReport"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for DoctorReport` -
Debug for DoctorReport"}} />
+
Debug for DoctorReport"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl<'de> Deserialize<'de> for DoctorReport` @@ -55,7 +55,7 @@ Checks. #### `deserialize` -
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+
deserialize<__D>(__deserializer: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
### `impl JsonSchema for DoctorReport` @@ -63,11 +63,11 @@ Checks. #### `schema_name` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `schema_id` -
Cow<'static, str>"}} />
+
Cow<'static, str>"}} />
#### `json_schema` @@ -75,19 +75,19 @@ Checks. #### `inline_schema` -
bool"}} />
+
bool"}} />
### `impl PartialEq for DoctorReport` -
PartialEq for DoctorReport"}} />
+
PartialEq for DoctorReport"}} />
#### `eq` -
eq(&self, other: &DoctorReport) -> bool"}} />
+
eq(&self, other: &DoctorReport) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Serialize for DoctorReport` @@ -95,8 +95,8 @@ Checks. #### `serialize` -
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
+
serialize<__S>(&self, __serializer: __S) -> Result<__S::Ok, __S::Error>where\n    __S: Serializer,"}} />
### `impl StructuralPartialEq for DoctorReport` -
StructuralPartialEq for DoctorReport"}} />
+
StructuralPartialEq for DoctorReport"}} />
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 fe01749e7..b5aac2ed3 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,14 +2,14 @@ title: "Enum Fabric Error" sidebar-title: "FabricError" description: "Errors raised by NeMo Fabric config loading and validation." -position: 78 +position: 80 --- {/* 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        source: Error,\n    },\n    PathNotFound(PathBuf),\n    UnknownAdapter {\n        adapter_id: String,\n        available: Vec<String>,\n    },\n    UnknownAdapterTarget {\n        target_id: String,\n        available: Vec<String>,\n    },\n    AmbiguousDescriptor {\n        descriptor_kind: &'static str,\n        id: String,\n        paths: Vec<PathBuf>,\n    },\n    AdapterDescriptorMismatch {\n        path: PathBuf,\n        field: &'static str,\n        expected: String,\n        actual: String,\n    },\n    AdapterDescriptorUnsupported {\n        adapter_id: String,\n        field: &'static str,\n        value: String,\n    },\n    InvalidAdapterDescriptor {\n        path: PathBuf,\n        message: String,\n    },\n    InvalidAdapterTargetDescriptor {\n        path: PathBuf,\n        message: String,\n    },\n    InvalidHarnessSettings {\n        adapter_id: String,\n        descriptor_source: DescriptorSource,\n        descriptor_path: PathBuf,\n        settings_path: String,\n        reason: String,\n    },\n    InvalidWorkflow {\n        adapter_id: String,\n        descriptor_source: DescriptorSource,\n        descriptor_path: PathBuf,\n        workflow_path: String,\n        reason: String,\n    },\n    InvalidToolDefinition {\n        adapter_id: String,\n        descriptor_source: DescriptorSource,\n        descriptor_path: PathBuf,\n        definition_path: String,\n        reason: String,\n    },\n    InvalidAdapterExtension {\n        adapter_id: String,\n        descriptor_source: DescriptorSource,\n        descriptor_path: PathBuf,\n        extension_path: String,\n        reason: String,\n    },\n    InvalidConfig {\n        field: String,\n        reason: String,\n    },\n    AdapterCompatibility {\n        adapter_id: String,\n        field: String,\n        reason: String,\n    },\n    UnknownSchema {\n        schema: String,\n        available: Vec<String>,\n    },\n    UnsupportedRuntimeAdapter {\n        harness: String,\n        adapter_kind: AdapterKind,\n    },\n    UnsupportedRuntimeCapability {\n        adapter_id: String,\n        capability: &'static str,\n    },\n    InvalidOpenAiStreamTransport {\n        field: &'static str,\n        reason: &'static str,\n    },\n    AdapterLifecycleOperation {\n        operation: &'static str,\n        runtime_id: String,\n        code: String,\n        message: String,\n        diagnostics: String,\n    },\n    RuntimeHandleMismatch {\n        field: &'static str,\n        expected: String,\n        actual: String,\n        runtime_id: String,\n    },\n    ServiceHandleMismatch {\n        field: &'static str,\n        expected: String,\n        actual: String,\n        service_id: String,\n    },\n    ServiceInUse {\n        service_id: String,\n        runtime_ids: Vec<String>,\n    },\n    ServiceNotActive {\n        service_id: String,\n    },\n    UnsupportedEnvironmentProvider {\n        provider: String,\n        adapter_kind: AdapterKind,\n    },\n    UnsupportedEnvironmentRelease {\n        provider: String,\n    },\n    EnvironmentProviderOperation {\n        provider: String,\n        operation: String,\n        code: String,\n        message: String,\n    },\n    EnvironmentHandleRequired {\n        provider: String,\n    },\n    EnvironmentHandleMismatch {\n        field: &'static str,\n        expected: String,\n        actual: String,\n        environment_id: String,\n    },\n    ResourceObservationUnavailable {\n        runtime_id: String,\n        reason: String,\n    },\n    ResourceObservationUnsupported {\n        runtime_id: String,\n        reason: String,\n    },\n    InvalidProcessSettings {\n        path: PathBuf,\n        source: Error,\n    },\n    InvalidPythonSettings {\n        path: PathBuf,\n        source: Error,\n    },\n    PythonInterpreterUnavailable {\n        path: PathBuf,\n        origin: String,\n        reason: String,\n    },\n    ProcessRunner {\n        command: String,\n        source: Error,\n    },\n    SerializeJson(Error),\n    Read {\n        path: PathBuf,\n        source: Error,\n    },\n    Write {\n        path: PathBuf,\n        source: Error,\n    },\n    ParseJson {\n        path: PathBuf,\n        source: Error,\n    },\n}"}} />
+
PathBuf,\n        source: Error,\n    },\n    PathNotFound(PathBuf),\n    UnknownAdapter {\n        adapter_id: String,\n        available: Vec<String>,\n    },\n    UnknownAdapterTarget {\n        target_id: String,\n        available: Vec<String>,\n    },\n    AmbiguousDescriptor {\n        descriptor_kind: &'static str,\n        id: String,\n        paths: Vec<PathBuf>,\n    },\n    AdapterDescriptorMismatch {\n        path: PathBuf,\n        field: &'static str,\n        expected: String,\n        actual: String,\n    },\n    AdapterDescriptorUnsupported {\n        adapter_id: String,\n        field: &'static str,\n        value: String,\n    },\n    InvalidAdapterDescriptor {\n        path: PathBuf,\n        message: String,\n    },\n    InvalidAdapterTargetDescriptor {\n        path: PathBuf,\n        message: String,\n    },\n    InvalidHarnessSettings {\n        adapter_id: String,\n        descriptor_source: DescriptorSource,\n        descriptor_path: PathBuf,\n        settings_path: String,\n        reason: String,\n    },\n    InvalidWorkflow {\n        adapter_id: String,\n        descriptor_source: DescriptorSource,\n        descriptor_path: PathBuf,\n        workflow_path: String,\n        reason: String,\n    },\n    InvalidToolDefinition {\n        adapter_id: String,\n        descriptor_source: DescriptorSource,\n        descriptor_path: PathBuf,\n        definition_path: String,\n        reason: String,\n    },\n    InvalidAdapterExtension {\n        adapter_id: String,\n        descriptor_source: DescriptorSource,\n        descriptor_path: PathBuf,\n        extension_path: String,\n        reason: String,\n    },\n    InvalidConfig {\n        field: String,\n        reason: String,\n    },\n    AdapterCompatibility {\n        adapter_id: String,\n        field: String,\n        reason: String,\n    },\n    UnknownSchema {\n        schema: String,\n        available: Vec<String>,\n    },\n    UnsupportedRuntimeAdapter {\n        harness: String,\n        adapter_kind: AdapterKind,\n    },\n    UnsupportedRuntimeCapability {\n        adapter_id: String,\n        capability: &'static str,\n    },\n    InvalidOpenAiStreamTransport {\n        field: &'static str,\n        reason: &'static str,\n    },\n    AdapterLifecycleOperation {\n        operation: &'static str,\n        runtime_id: String,\n        code: String,\n        message: String,\n        diagnostics: String,\n    },\n    RuntimeHandleMismatch {\n        field: &'static str,\n        expected: String,\n        actual: String,\n        runtime_id: String,\n    },\n    ServiceHandleMismatch {\n        field: &'static str,\n        expected: String,\n        actual: String,\n        service_id: String,\n    },\n    ServiceInUse {\n        service_id: String,\n        runtime_ids: Vec<String>,\n    },\n    ServiceNotActive {\n        service_id: String,\n    },\n    UnsupportedEnvironmentProvider {\n        provider: String,\n        adapter_kind: AdapterKind,\n    },\n    UnsupportedEnvironmentRelease {\n        provider: String,\n    },\n    EnvironmentProviderOperation {\n        provider: String,\n        operation: String,\n        code: String,\n        message: String,\n    },\n    EnvironmentInUse {\n        environment_id: String,\n        runtime_id: String,\n    },\n    EnvironmentReleaseInProgress {\n        environment_id: String,\n    },\n    EnvironmentHandleRequired {\n        provider: String,\n    },\n    EnvironmentHandleMismatch {\n        field: &'static str,\n        expected: String,\n        actual: String,\n        environment_id: String,\n    },\n    ResourceObservationUnavailable {\n        runtime_id: String,\n        reason: String,\n    },\n    ResourceObservationUnsupported {\n        runtime_id: String,\n        reason: String,\n    },\n    InvalidProcessSettings {\n        path: PathBuf,\n        source: Error,\n    },\n    InvalidPythonSettings {\n        path: PathBuf,\n        source: Error,\n    },\n    PythonInterpreterUnavailable {\n        path: PathBuf,\n        origin: String,\n        reason: String,\n    },\n    ProcessRunner {\n        command: String,\n        source: Error,\n    },\n    SerializeJson(Error),\n    Read {\n        path: PathBuf,\n        source: Error,\n    },\n    Write {\n        path: PathBuf,\n        source: Error,\n    },\n    ParseJson {\n        path: PathBuf,\n        source: Error,\n    },\n}"}} />
Errors raised by NeMo Fabric config loading and validation. @@ -33,7 +33,7 @@ Underlying path-resolution error. ### `PathNotFound(PathBuf)` -
PathBuf)"}} />
+
PathBuf)"}} />
The requested path does not exist. @@ -533,6 +533,34 @@ Stable provider or host error code. Human-readable message that excludes credentials and raw diagnostics. +### `EnvironmentInUse` + +
+ +A runtime lifecycle operation is still active or has an uncertain outcome. + +#### Fields + +##### `environment_id: String` + +Environment whose runtime operation is unresolved. + +##### `runtime_id: String` + +Runtime identity, or `unconfirmed` if the start response was lost. + +### `EnvironmentReleaseInProgress` + +
+ +An environment release is already in progress. + +#### Fields + +##### `environment_id: String` + +Environment whose lifecycle is being released or detached. + ### `EnvironmentHandleRequired`
@@ -727,36 +755,36 @@ Underlying JSON error. ### `impl Debug for FabricError` -
Debug for FabricError"}} />
+
Debug for FabricError"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl Display for FabricError` -
Display for FabricError"}} />
+
Display for FabricError"}} />
#### `fmt` -
fmt(&self, __formatter: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, __formatter: &mut Formatter<'_>) -> Result"}} />
### `impl Error for FabricError` -
Error for FabricError"}} />
+
Error for FabricError"}} />
#### `source` -
source(&self) -> Option<&(dyn Error + 'static)>"}} />
+
source(&self) -> Option<&(dyn Error + 'static)>"}} />
#### `description` -
description(&self) -> &str"}} />
+
description(&self) -> &str"}} />
#### `cause` -
cause(&self) -> Option<&dyn Error>"}} />
+
cause(&self) -> Option<&dyn Error>"}} />
#### `provide` -
provide<'a>(&'a self, request: &mut Request<'a>)"}} />
+
provide<'a>(&'a self, request: &mut Request<'a>)"}} />
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 0e2ed4c29..714e40957 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: 147 +position: 149 --- {/* 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 0a91b0129..6541dce12 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,14 +2,14 @@ title: "Type Alias Result" sidebar-title: "Result" description: "Core NeMo Fabric result type." -position: 79 +position: 81 --- {/* 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`. -
Result<T, FabricError>;"}} />
+
Result<T, FabricError>;"}} />
Core NeMo Fabric result type. diff --git a/docs/reference/api/rust-library-reference/nemo-fabric-core/fn-run-record-folder.mdx b/docs/reference/api/rust-library-reference/nemo-fabric-core/fn-run-record-folder.mdx new file mode 100644 index 000000000..82a53ea71 --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/fn-run-record-folder.mdx @@ -0,0 +1,14 @@ +--- +title: "Function run_record_folder" +sidebar-title: "run_record_folder" +description: "The recording root. This is known before startup and requires no inner IDs." +position: 158 +--- +{/* 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`. + +
RunPlan) -> Option<PathBuf>"}} />
+ +The recording root. This is known before startup and requires no inner IDs. 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 7d59cae93..fc7a89739 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,13 +2,13 @@ title: "Function version" sidebar-title: "version" description: "Returns the crate version compiled into this build." -position: 152 +position: 159 --- {/* 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"}} />
+
str"}} />
Returns the crate version compiled into this build. 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 161f1e4be..9d4de77bc 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 @@ -66,6 +66,8 @@ 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::RecordConfig;` - `pub use config::ResolutionStrategy;` - `pub use config::ResolveContext;` - `pub use config::ResolvedAdapterDescriptor;` @@ -163,11 +165,16 @@ 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. ## Functions +- [run_record_folder](fn-run-record-folder.mdx): The recording root. This is known before startup and requires no inner IDs. - [version](fn-version.mdx): Returns the crate version compiled into this build. 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..f6e285726 --- /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..5ec5c76b3 --- /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: 150 +--- +{/* 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..ded366199 --- /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..69d57502d --- /dev/null +++ b/docs/reference/api/rust-library-reference/nemo-fabric-core/price-catalog/enum-pricingcatalogerror.mdx @@ -0,0 +1,129 @@ +--- +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    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. + +### `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..d4ecc5b7d --- /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..ce7fa57bc --- /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..3fa7398d9 --- /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..98e809e24 --- /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: 151 +--- +{/* 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. For each candidate key, the first matching catalog entry wins. + +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..4b9be5cf7 --- /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..4b180e69b --- /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. For each lookup key, the first matching entry wins. + +## 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..423b73286 --- /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..8c6ea1f0b --- /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..4ff3cf607 --- /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..63d5f8211 --- /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` + +`sha256:` followed by the lowercase hexadecimal SHA-256 of the file. + +### `catalog: PricingCatalog` + +The validated catalog. + +## Implementations + +### `impl UserPriceCatalog` + +
UserPriceCatalog"}} />
+ +#### `from_bytes` + +
PathBuf,\n    bytes: &[u8],\n) -> Result<Self, PricingCatalogError>"}} />
+ +Validate bytes and retain their exact catalog fingerprint. + +## 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: __D) -> Result<Self, __D::Error>where\n    __D: Deserializer<'de>,"}} />
+ +### `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..87183911d --- /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: 152 +--- +{/* 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 4ceabefc2..39313abcf 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: 148 +position: 153 --- {/* 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 d58dd0a40..640845cdc 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: 149 +position: 154 --- {/* 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..e6f4a2882 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}"}} />
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. 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 38c553dc6..c853990df 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, Agent, AgentConfig, AgentRunRequest, @@ -34,6 +35,8 @@ pub enum SchemaName { ErrorInfo, FabricEvent, RuntimeResourceSnapshot, + RunRecordLine, + RequestRecord, } ``` @@ -41,6 +44,12 @@ Public schema snapshots generated by NeMo Fabric. ## Variants +### `UsageAccounting` + +
+ +Declared usage accounting evidence. + ### `Agent`
@@ -179,6 +188,18 @@ NeMo Fabric lifecycle event schema. Runtime resource snapshot schema. +### `RunRecordLine` + +
+ +One bounded start/end invocation record line. + +### `RequestRecord` + +
+ +Outer request evidence, including failures before an invocation exists. + ## Implementations ### `impl SchemaName` @@ -187,25 +208,25 @@ Runtime resource snapshot schema. #### `ALL` -
23]"}} />
+
26]"}} />
All public schemas in stable output order. #### `as_str` -
str"}} />
+
str"}} />
Stable file stem for this schema. #### `filename` -
String"}} />
+
String"}} />
Snapshot filename for this schema. #### `parse` -
str) -> Result<Self>"}} />
+
str) -> Result<Self>"}} />
Parse a schema name from CLI/user input. @@ -213,44 +234,44 @@ Parse a schema name from CLI/user input. ### `impl Clone for SchemaName` -
Clone for SchemaName"}} />
+
Clone for SchemaName"}} />
#### `clone` -
clone(&self) -> SchemaName"}} />
+
clone(&self) -> SchemaName"}} />
#### `clone_from` -
clone_from(&mut self, source: &Self)"}} />
+
clone_from(&mut self, source: &Self)"}} />
### `impl Debug for SchemaName` -
Debug for SchemaName"}} />
+
Debug for SchemaName"}} />
#### `fmt` -
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
+
fmt(&self, f: &mut Formatter<'_>) -> Result"}} />
### `impl PartialEq for SchemaName` -
PartialEq for SchemaName"}} />
+
PartialEq for SchemaName"}} />
#### `eq` -
eq(&self, other: &SchemaName) -> bool"}} />
+
eq(&self, other: &SchemaName) -> bool"}} />
#### `ne` -
ne(&self, other: &Rhs) -> bool"}} />
+
ne(&self, other: &Rhs) -> bool"}} />
### `impl Copy for SchemaName` -
Copy for SchemaName"}} />
+
Copy for SchemaName"}} />
### `impl Eq for SchemaName` -
Eq for SchemaName"}} />
+
Eq for SchemaName"}} />
### `impl StructuralPartialEq for SchemaName` -
StructuralPartialEq for SchemaName"}} />
+
StructuralPartialEq for SchemaName"}} />
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 fa6d54a7f..dafc045aa 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: 150 +position: 155 --- {/* 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 ac493aa86..03cddcddd 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: 151 +position: 156 --- {/* 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..d2053550b --- /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: 157 +--- +{/* 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/sdk/python.mdx b/docs/sdk/python.mdx index 9bbf05283..425297915 100644 --- a/docs/sdk/python.mdx +++ b/docs/sdk/python.mdx @@ -1178,6 +1178,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. | @@ -1214,6 +1215,61 @@ 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 the declared +`nemo_fabric.usage_accounting.v1` evidence. 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 its bytes. An explicitly established provider must match the catalog entry's provider, including aliases and shorter model paths. A different provider's entry cannot price that source solely because the model name matches. When no provider identity is available, an explicit user catalog can match the model name alone. Bundled provider prices apply only +when the configured route establishes the native provider. Unknown 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. 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. + + ## Events Each `RunResult` includes the normalized events collected for that invocation. @@ -1432,3 +1488,98 @@ backpressure. The consumer decides whether to wait, retry, scale out, or fail. ## Next Steps - Review the examples in the GitHub repo [`examples/`](https://github.com/NVIDIA/NeMo-Fabric/tree/main/examples). + + +## Opt-in run recording + +Set `runtime.record.enabled` and a host-side artifact root to save local runs: + +```python +from nemo_fabric import RecordConfig + +config.runtime.record = RecordConfig(enabled=True) +config.runtime.artifacts = "./runs" +``` + +The CLI accepts the same settings: + +```bash +nemo-fabric run --preset scripted --input "Inspect the example" --record --artifacts ./runs +``` + +Recording supports the local environment provider with Python or process adapters. +It is disabled when the record setting is omitted. Enabling it without an artifact +root or with an unsupported execution location fails validation. + +Each invocation writes bounded start/end lines to +`.fabric///run-record.jsonl`. The start line retains +identities and configured model names. It omits prompts, output, credentials, +exception messages and arbitrary adapter metadata. Allowlisted context identity +keys retain string or integer values; other context values are omitted. + +The sibling `run-result.json` contains the complete returned result and can +contain sensitive output. One-shot results are finalized after runtime shutdown; +persistent-runtime results describe the invocation before a later shutdown. +An invocation end record never establishes the outcome of a later shutdown. +Saved results are created exclusively and never replace an existing file or +symlink. Recording failures add diagnostics without retrying paid work or +replacing the execution outcome. No result file is promised when execution +raises before returning a result. + +The start/end record's elapsed time covers its invocation window, including start +record I/O and core preparation. It excludes end-record writes, full-result +persistence and one-shot runtime startup/shutdown. It is not pure model time or +the caller's task schedule interval. Record I/O is synchronous and best effort. +Each record line is limited to 64 KiB; oversized lines stop that record without +replacing the available execution result. + +### Inspect a saved run + +```bash +python -m nemo_fabric.reporting runs/.fabric///run-result.json +python -m nemo_fabric.reporting runs/.fabric///run-result.json --details +python -m nemo_fabric.reporting runs/.fabric///run-result.json --json +``` + +Replace the placeholder IDs with the returned `run_result` artifact path. The +default overview separates execution, scoped elapsed time, complete token totals, +observed subtotals, cache breakdown and each cost amount. It names accounting +gaps and exposes diagnostic paths. Estimates retain their pricing basis and are +not invoices. An execution failure can retain complete usage; successful +execution does not establish task correctness or complete accounting. + +Use `--details` for the final resource snapshot and pricing evidence. Resource +snapshots describe the runtime process tree, not inference GPU usage. The CPU +counter is cumulative since host start; endpoint RSS is not a peak. Rounded +currency and human-readable units affect text only. JSON retains exact values, +and positive amounts never display as zero. + +The inspector accepts an explicit `run-record.jsonl` to show the invocation +boundary when a full result is unavailable. It retains complete lines before a +truncated tail and rejects duplicate or conflicting lines. The bounded record preserves validated normalized accounting, including complete +and observed counts, when present in the invocation result. Legacy numeric usage +remains unverified diagnostics. Missing evidence never becomes zero. + +A full result remains authoritative over its matching sibling record. The reader +checks identities, skips sibling symlinks, and never opens referenced logs or +telemetry payloads or uses them to fill missing counts. Each inspected file is +limited to 16 MiB; larger saved results remain unchanged and can be inspected +separately. Recording requires no benchmark framework, task labels or Relay. + + +### Requests without a returned result + +When recording is enabled, one-shot runs also save bounded request-level evidence +under `.fabric/requests//`. Each request record is limited to +64 KiB; an oversized record adds a recording diagnostic without replacing the +execution outcome or preventing full-result publication. The request start is +written before runtime startup. The end distinguishes a returned result reference from no +result at the startup or invocation boundary. It retains the caller's request ID +without inventing runtime/invocation IDs, tokens or spend. + +Inspect an explicit `request-start.json` or `request-end.json` path with +`python -m nemo_fabric.reporting`. The inspector never follows a returned result +reference automatically or treats it as proof that publication succeeded. An +outer timeout does not establish that inner or remote work stopped. Existing +invocation status and error stage/code remain the stop evidence for returned +results; this adds no competing stop-reason field. diff --git a/examples/openshell/run-demo.sh b/examples/openshell/run-demo.sh index 56d2b534f..4fe36156e 100644 --- a/examples/openshell/run-demo.sh +++ b/examples/openshell/run-demo.sh @@ -272,9 +272,11 @@ run_consumer() { if [[ "${EXAMPLE_MODE}" == caller_owned || "${EXAMPLE_MODE}" == both ]]; then CALLER_SANDBOX_ATTEMPTED=1 + # Guard empty arrays for Bash 3.2's set -u behavior without adding empty args. "${OPENSHELL_CLI}" --gateway-endpoint "http://127.0.0.1:${GATEWAY_PORT}" \ --workspace default sandbox create --name "${SANDBOX_NAME}" --from "${RUNTIME_IMAGE}" \ - --policy "${POLICY_FILE}" "${MODEL_ENV_ARGS[@]}" "${SERVER_ENV_ARGS[@]}" \ + --policy "${POLICY_FILE}" ${MODEL_ENV_ARGS[@]+"${MODEL_ENV_ARGS[@]}"} \ + ${SERVER_ENV_ARGS[@]+"${SERVER_ENV_ARGS[@]}"} \ --detach --no-tty -- fabric-agent-ctl serve SANDBOX_JSON="$("${OPENSHELL_CLI}" --gateway-endpoint "http://127.0.0.1:${GATEWAY_PORT}" \ --workspace default sandbox get "${SANDBOX_NAME}" --output json)" 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 3535ea32f..25ba0afd0 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 @@ -38,15 +40,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 @@ -124,6 +129,8 @@ descriptor schema. ### Results, Artifacts, and Diagnostics +- `sdk/request-record`: outer request evidence when startup or invocation returns no result. +- `sdk/run-record-line`: bounded opt-in invocation start/end evidence, including validated normalized accounting when available. - `sdk/run-result`: normalized consumer-facing invocation result. - `sdk/artifact-manifest`: normalized artifact references. - `sdk/error-info`: structured runtime or adapter error metadata. @@ -131,6 +138,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 and provider-reported 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 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..8c43c2fb4 100644 --- a/schemas/sdk/agent.schema.json +++ b/schemas/sdk/agent.schema.json @@ -558,6 +558,34 @@ } ] }, + "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" + }, + "RecordConfig": { + "additionalProperties": false, + "description": "Per-invocation run record settings.\n\nWhen enabled, core appends a start line and an end line for each local\ninvocation to `/.fabric///run-record.jsonl`.", + "properties": { + "enabled": { + "description": "Whether to write a run record for each invocation. Requires an\nartifact root, the `local` environment provider, and a `process` or\n`python` adapter.", + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, "RelayAtifConfig": { "additionalProperties": true, "description": "Relay ATIF export configuration.", @@ -1329,6 +1357,17 @@ "description": "Output schema label.", "type": "string" }, + "record": { + "anyOf": [ + { + "$ref": "#/$defs/RecordConfig" + }, + { + "type": "null" + } + ], + "description": "Opt-in per-invocation run record. Core-only; never sent to adapters." + }, "timeout_seconds": { "description": "Maximum duration of one invocation in seconds.", "exclusiveMinimum": 0.0, @@ -1611,6 +1650,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/request-record.schema.json b/schemas/sdk/request-record.schema.json new file mode 100644 index 000000000..b5f712877 --- /dev/null +++ b/schemas/sdk/request-record.schema.json @@ -0,0 +1,120 @@ +{ + "$defs": { + "RecordVersion": { + "description": "The record's line format version.", + "oneOf": [ + { + "const": "v1alpha1", + "description": "`v1alpha1`.", + "type": "string" + } + ] + }, + "RequestSchema": { + "enum": [ + "nemo.fabric.request_record" + ], + "type": "string" + }, + "RequestStage": { + "enum": [ + "start", + "invoke" + ], + "type": "string" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "A request record does not imply a runtime or invocation was created.", + "oneOf": [ + { + "properties": { + "kind": { + "const": "start", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "oneOf": [ + { + "properties": { + "invocation_id": { + "type": "string" + }, + "result": { + "type": "string" + }, + "runtime_id": { + "type": "string" + }, + "state": { + "const": "returned", + "type": "string" + } + }, + "required": [ + "state", + "runtime_id", + "invocation_id", + "result" + ], + "type": "object" + }, + { + "properties": { + "stage": { + "$ref": "#/$defs/RequestStage" + }, + "state": { + "const": "no_result", + "type": "string" + } + }, + "required": [ + "state", + "stage" + ], + "type": "object" + } + ], + "properties": { + "kind": { + "const": "end", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + } + ], + "properties": { + "request_id": { + "type": "string" + }, + "schema": { + "$ref": "#/$defs/RequestSchema" + }, + "time": { + "format": "date-time", + "type": "string" + }, + "version": { + "$ref": "#/$defs/RecordVersion" + } + }, + "required": [ + "schema", + "version", + "request_id", + "time" + ], + "title": "RequestRecord", + "type": "object" +} \ No newline at end of file diff --git a/schemas/sdk/run-plan.schema.json b/schemas/sdk/run-plan.schema.json index e183bca75..7bc566c42 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,107 @@ } ] }, + "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. For each lookup key, the first matching entry wins.", + "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" + } + ] + }, + "RecordConfig": { + "additionalProperties": false, + "description": "Per-invocation run record settings.\n\nWhen enabled, core appends a start line and an end line for each local\ninvocation to `/.fabric///run-record.jsonl`.", + "properties": { + "enabled": { + "description": "Whether to write a run record for each invocation. Requires an\nartifact root, the `local` environment provider, and a `process` or\n`python` adapter.", + "type": "boolean" + } + }, + "required": [ + "enabled" + ], + "type": "object" + }, "RelayAtifConfig": { "additionalProperties": true, "description": "Relay ATIF export configuration.", @@ -2791,6 +2992,17 @@ "description": "Output schema label.", "type": "string" }, + "record": { + "anyOf": [ + { + "$ref": "#/$defs/RecordConfig" + }, + { + "type": "null" + } + ], + "description": "Opt-in per-invocation run record. Core-only; never sent to adapters." + }, "timeout_seconds": { "description": "Maximum duration of one invocation in seconds.", "exclusiveMinimum": 0.0, @@ -2985,6 +3197,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 +3387,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": "`sha256:` followed by the lowercase hexadecimal SHA-256 of the file.", + "type": "string" + } + }, + "required": [ + "path", + "sha256", + "catalog" + ], + "type": "object" + }, "WorkflowConfig": { "additionalProperties": true, "description": "Registered workflow target and immutable construction settings.", @@ -3235,6 +3571,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-record-line.schema.json b/schemas/sdk/run-record-line.schema.json new file mode 100644 index 000000000..c35c9a6c9 --- /dev/null +++ b/schemas/sdk/run-record-line.schema.json @@ -0,0 +1,963 @@ +{ + "$defs": { + "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" + }, + "ContextValue": { + "anyOf": [ + { + "description": "An allowlisted key's string value, cut to 256 characters.", + "type": "string" + }, + { + "description": "An allowlisted key's integer value.", + "format": "int64", + "type": "integer" + }, + { + "$ref": "#/$defs/NotRecorded", + "description": "Any other key or value: the key name only." + } + ], + "description": "A request context value as the start line keeps it." + }, + "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" + }, + "EndArtifact": { + "description": "One result artifact on the end line.", + "properties": { + "kind": { + "description": "Artifact kind.", + "type": "string" + }, + "media_type": { + "description": "Media type.", + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "Logical artifact name.", + "type": "string" + }, + "path": { + "description": "Path relative to the artifact root; absent for a path outside it.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name", + "kind" + ], + "type": "object" + }, + "EndError": { + "description": "The end line's error.", + "properties": { + "code": { + "description": "Error code matching `^[a-z0-9_.:-]{1,128}$`, else `unrecognized`.", + "pattern": "^[a-z0-9_.:-]{1,128}$", + "type": "string" + }, + "retryable": { + "description": "Whether a consumer-level retry is considered safe.", + "type": "boolean" + }, + "stage": { + "$ref": "#/$defs/ErrorStage", + "description": "Lifecycle stage where the failure surfaced." + } + }, + "required": [ + "code", + "stage", + "retryable" + ], + "type": "object" + }, + "EndLine": { + "description": "Fields of the end line.", + "properties": { + "artifacts": { + "description": "Result artifacts, with paths relative to the artifact root.", + "items": { + "$ref": "#/$defs/EndArtifact" + }, + "type": "array" + }, + "elapsed_ms": { + "description": "Monotonic time from the start line to the end line, in milliseconds.", + "format": "uint64", + "minimum": 0, + "type": "integer" + }, + "error": { + "anyOf": [ + { + "$ref": "#/$defs/EndError" + }, + { + "type": "null" + } + ], + "description": "Error code, stage and retryable flag, without the message." + }, + "model_usage": { + "anyOf": [ + { + "$ref": "#/$defs/ModelUsage" + }, + { + "type": "null" + } + ], + "description": "Validated normalized accounting frozen at the invocation boundary." + }, + "outcome_scope": { + "const": "invocation", + "description": "This line observes the invocation, before one-shot runtime shutdown.", + "type": "string" + }, + "result": { + "description": "Expected full-result file next to this record; absence means not saved.", + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/$defs/RunStatus", + "description": "Final status. A Fabric lifecycle error, such as a host timeout, reads `failed`." + }, + "usage": { + "anyOf": [ + { + "$ref": "#/$defs/EndUsage" + }, + { + "type": "null" + } + ], + "description": "Numeric usage counters, without adapter metadata." + } + }, + "required": [ + "outcome_scope", + "status", + "elapsed_ms" + ], + "type": "object" + }, + "EndUsage": { + "description": "The end line's usage: the numeric `RunUsage` fields.", + "properties": { + "cached_input_tokens": { + "description": "Cached input tokens.", + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "cost_usd": { + "description": "Reported cost in US dollars.", + "format": "double", + "minimum": 0.0, + "type": [ + "number", + "null" + ] + }, + "input_tokens": { + "description": "Input tokens.", + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "input_tokens_include_cache": { + "description": "Whether `input_tokens` includes `cached_input_tokens`; absent means unknown.", + "type": [ + "boolean", + "null" + ] + }, + "output_tokens": { + "description": "Output tokens.", + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "total_tokens": { + "description": "Total tokens reported by the provider.", + "format": "uint64", + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "ErrorStage": { + "description": "NeMo Fabric lifecycle stage associated with an error.", + "oneOf": [ + { + "const": "config", + "description": "Configuration resolution failed.", + "type": "string" + }, + { + "const": "plan", + "description": "Effective config planning failed.", + "type": "string" + }, + { + "const": "prepare", + "description": "Environment preparation failed.", + "type": "string" + }, + { + "const": "start", + "description": "Runtime start/connect failed.", + "type": "string" + }, + { + "const": "invoke", + "description": "Runtime invocation failed.", + "type": "string" + }, + { + "const": "stop", + "description": "Runtime stop/detach failed.", + "type": "string" + }, + { + "const": "release", + "description": "Environment release failed.", + "type": "string" + }, + { + "const": "artifact", + "description": "Artifact collection or writing failed.", + "type": "string" + } + ] + }, + "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" + } + ] + }, + "ModelName": { + "description": "One configured model.", + "properties": { + "model": { + "description": "Provider model identifier.", + "type": "string" + }, + "provider": { + "description": "Model provider name.", + "type": "string" + } + }, + "required": [ + "provider", + "model" + ], + "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" + }, + "NotRecorded": { + "description": "Marks a key whose value the record leaves out.", + "properties": { + "recorded": { + "const": false, + "description": "Always `false`.", + "type": "boolean" + } + }, + "required": [ + "recorded" + ], + "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" + }, + "RecordSchema": { + "description": "The record's schema name.", + "oneOf": [ + { + "const": "nemo.fabric.run_record", + "description": "`nemo.fabric.run_record`.", + "type": "string" + } + ] + }, + "RecordVersion": { + "description": "The record's line format version.", + "oneOf": [ + { + "const": "v1alpha1", + "description": "`v1alpha1`.", + "type": "string" + } + ] + }, + "RunStatus": { + "description": "Runtime completion status.", + "oneOf": [ + { + "const": "succeeded", + "description": "The harness completed successfully.", + "type": "string" + }, + { + "const": "failed", + "description": "The harness completed with a non-zero status.", + "type": "string" + }, + { + "const": "cancelled", + "description": "The invocation or runtime was cancelled.", + "type": "string" + } + ] + }, + "StartLine": { + "description": "Fields of the start line.", + "properties": { + "adapter_id": { + "description": "Adapter implementation id.", + "type": [ + "string", + "null" + ] + }, + "agent_name": { + "description": "Config name (`metadata.name`).", + "type": "string" + }, + "context": { + "additionalProperties": { + "$ref": "#/$defs/ContextValue" + }, + "description": "`RunRequest.context`. Allowlisted identity and Harbor link keys keep a\nstring or integer value; every other key is listed by name only.", + "type": "object" + }, + "fabric_version": { + "description": "NeMo Fabric version that wrote the record.", + "type": "string" + }, + "harness": { + "description": "Harness identifier.", + "type": "string" + }, + "models": { + "additionalProperties": { + "$ref": "#/$defs/ModelName" + }, + "description": "Configured models by role: provider and model name only.", + "type": "object" + }, + "override_keys": { + "description": "Top-level key names of `RunRequest.overrides`, sorted. Values are never\nrecorded.", + "items": { + "type": "string" + }, + "type": "array" + }, + "timeout_ms": { + "description": "Host invocation timeout that applied, in milliseconds.", + "format": "uint64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "agent_name", + "harness", + "fabric_version", + "timeout_ms" + ], + "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" + }, + "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" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema", + "description": "One line of a run record.", + "oneOf": [ + { + "$ref": "#/$defs/StartLine", + "description": "Written once, before core sends the request to the adapter host.", + "properties": { + "kind": { + "const": "start", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "$ref": "#/$defs/EndLine", + "description": "Written once, after the invocation returned a result or failed.", + "properties": { + "kind": { + "const": "end", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + } + ], + "properties": { + "invocation_id": { + "description": "Invocation id.", + "type": "string" + }, + "request_id": { + "description": "Request id.", + "type": "string" + }, + "runtime_id": { + "description": "Runtime id.", + "type": "string" + }, + "schema": { + "$ref": "#/$defs/RecordSchema", + "description": "Always `nemo.fabric.run_record`." + }, + "time": { + "description": "UTC wall-clock time the line was written, RFC 3339 with milliseconds.", + "format": "date-time", + "type": "string" + }, + "version": { + "$ref": "#/$defs/RecordVersion", + "description": "Line format version. `v1alpha1` may change until a record reader ships." + } + }, + "required": [ + "schema", + "version", + "runtime_id", + "invocation_id", + "request_id", + "time" + ], + "title": "RunRecordLine", + "type": "object" +} \ No newline at end of file diff --git a/schemas/sdk/run-result.schema.json b/schemas/sdk/run-result.schema.json index 63a384352..ee4a0befa 100644 --- a/schemas/sdk/run-result.schema.json +++ b/schemas/sdk/run-result.schema.json @@ -25,6 +25,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 +303,32 @@ ], "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" + }, "ErrorInfo": { "description": "Normalized error metadata.", "properties": { @@ -377,6 +442,26 @@ ], "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" + } + ] + }, "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": { @@ -686,6 +771,268 @@ } ] }, + "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": [ @@ -1065,6 +1412,169 @@ } }, "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" + }, + "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" } }, "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -1121,6 +1631,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." diff --git a/scripts/contracts/generate_usage_accounting.py b/scripts/contracts/generate_usage_accounting.py new file mode 100644 index 000000000..3b5cbd19c --- /dev/null +++ b/scripts/contracts/generate_usage_accounting.py @@ -0,0 +1,51 @@ +# 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 + usage.setdefault("$defs", {}).update(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 d03630b48..8af0ae17a 100644 --- a/scripts/docs/enhance_python_api_reference.py +++ b/scripts/docs/enhance_python_api_reference.py @@ -11,11 +11,13 @@ 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.reporting", "nemo_fabric.client", "nemo_fabric.capabilities", "nemo_fabric.runtime", @@ -27,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) `([^`]+)`$") @@ -127,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 @@ -140,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", @@ -207,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 "" @@ -266,7 +278,9 @@ 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__ == "nemo_fabric.types": + elif exported_class.__module__ == "nemo_fabric.types" 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 d9b92c222..bef86d5b1 100755 --- a/scripts/generate_api_docs.sh +++ b/scripts/generate_api_docs.sh @@ -22,6 +22,8 @@ mkdir -p "$out" PYTHONPATH="sdk/python/nemo-fabric-runtime/src" lazydocs \ --output-path "$out" \ --overview-file "index.md" \ + "nemo_fabric.accounting" \ + "nemo_fabric.reporting" \ "nemo_fabric.client" \ "nemo_fabric.capabilities" \ "nemo_fabric.runtime" \ @@ -78,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" \ @@ -118,6 +125,11 @@ add_frontmatter \ "Types" \ "Typed config, request, plan, result, artifact, telemetry, and runtime contracts." \ "/reference/api/python-library-reference/types" +add_frontmatter \ + "$out/nemo_fabric.reporting.md" \ + "Run Evidence Reporting" \ + "Inspect saved invocation results, accounting coverage, and diagnostic references." \ + "/reference/api/python-library-reference/reporting" add_frontmatter \ "$out/nemo_fabric.errors.md" \ "Errors" \ 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 a39e2a3fe..5db0c08d7 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 1580d38b2..bdf84af6d 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.capabilities import AdapterCapabilityProfile from nemo_fabric.capabilities import inspect_adapter @@ -26,6 +36,7 @@ from nemo_fabric.models import McpServerConfig from nemo_fabric.models import MetadataConfig from nemo_fabric.models import ModelConfig +from nemo_fabric.models import RecordConfig from nemo_fabric.models import RelayAtifConfig from nemo_fabric.models import RelayAtofConfig from nemo_fabric.models import RelayAtofFileSinkConfig @@ -83,6 +94,16 @@ from nemo_fabric.types import TelemetryRef __all__ = [ + "summarize_usage", + "PricingConfig", + "ModelUsage", + "PerModelUsage", + "PricingBasis", + "AppliedPricing", + "TokenCounts", + "TokenCost", + "CostAmount", + "UsageGap", "AdapterCapabilityProfile", "AdapterInfo", "ArtifactManifest", @@ -139,6 +160,7 @@ "SampleSummary", "InvocationSystemStats", "ResourceObservation", + "RecordConfig", "RunResult", "RunUsage", "RuntimeCapabilities", diff --git a/sdk/python/nemo-fabric-runtime/src/nemo_fabric/_native.pyi b/sdk/python/nemo-fabric-runtime/src/nemo_fabric/_native.pyi index bd402c74a..10c06f8a3 100644 --- a/sdk/python/nemo-fabric-runtime/src/nemo_fabric/_native.pyi +++ b/sdk/python/nemo-fabric-runtime/src/nemo_fabric/_native.pyi @@ -19,6 +19,7 @@ def run_config( request_json: str | None = None, request_file: str | None = None, ) -> str: ... +def run_plan(plan_json: str, request_json: str) -> str: ... def start_runtime(plan_json: str) -> str: ... def prepare_environment(plan_json: str) -> str: ... def attach_environment(plan_json: str, reference_json: str) -> str: ... diff --git a/sdk/python/nemo-fabric-runtime/src/nemo_fabric/_report_format.py b/sdk/python/nemo-fabric-runtime/src/nemo_fabric/_report_format.py new file mode 100644 index 000000000..d9784a7de --- /dev/null +++ b/sdk/python/nemo-fabric-runtime/src/nemo_fabric/_report_format.py @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Readable units for evidence summaries; stored quantities remain unchanged.""" + +from __future__ import annotations + + +def format_money(value: int | None) -> str: + """Format nanoUSD with cents or three significant sub-dollar digits. + + Integer rounding preserves very small positive amounts without presenting + them as free execution. JSON retains the original nanoUSD quantity. + """ + if value is None: + return "unavailable" + decimals = 2 if value == 0 else min(9, max(2, 12 - len(str(value)))) + quantum = 10 ** (9 - decimals) + rounded = (value + quantum // 2) // quantum + scale = 10**decimals + return f"${rounded // scale}.{rounded % scale:0{decimals}d}" + + +def format_duration_ms(value: int | float) -> str: + """Format a measured duration without changing its measurement scope.""" + if value < 1000: + return f"{value:g} ms" + seconds = value / 1000 + if seconds < 60: + return f"{seconds:.3f}".rstrip("0").rstrip(".") + " s" + minutes, seconds = divmod(seconds, 60) + if minutes < 60: + return f"{int(minutes)} min {seconds:g} s" + hours, minutes = divmod(int(minutes), 60) + return f"{hours} h {minutes} min {seconds:g} s" + + +def format_bytes(value: int) -> str: + """Format bytes using explicitly binary units.""" + amount = float(value) + for unit in ("B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"): + if amount < 1024 or unit == "EiB": + return f"{value} B" if unit == "B" else f"{amount:.1f} {unit}" + amount /= 1024 + raise AssertionError("unreachable byte unit") 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..8da24932f 100644 --- a/sdk/python/nemo-fabric-runtime/src/nemo_fabric/models.py +++ b/sdk/python/nemo-fabric-runtime/src/nemo_fabric/models.py @@ -24,6 +24,7 @@ from pydantic import BaseModel from pydantic import ConfigDict from pydantic import Field +from pydantic import StrictBool from pydantic import SerializerFunctionWrapHandler from pydantic import field_validator from pydantic import model_serializer @@ -187,6 +188,20 @@ class InstructionsConfig(FabricBaseModel): system: InstructionConfig | None = None +class RecordConfig(FabricBaseModel): + """Opt-in per-invocation run record. + + When ``enabled`` is true, NeMo Fabric writes + ``/.fabric///run-record.jsonl`` + for each invocation. It requires an artifact root, the ``local`` + environment provider, and a ``process`` or ``python`` adapter. + """ + + model_config = ConfigDict(extra="forbid") + + enabled: StrictBool + + class RuntimeConfig(FabricBaseModel): """Invocation runtime contract.""" @@ -199,6 +214,7 @@ class RuntimeConfig(FabricBaseModel): allow_inf_nan=False, ) max_turns: int | None = Field(default=None, gt=0, le=(1 << 32) - 1) + record: RecordConfig | None = None class EnvironmentConfig(FabricBaseModel): @@ -1118,6 +1134,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 +1170,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/reporting.py b/sdk/python/nemo-fabric-runtime/src/nemo_fabric/reporting.py new file mode 100644 index 000000000..5486d1915 --- /dev/null +++ b/sdk/python/nemo-fabric-runtime/src/nemo_fabric/reporting.py @@ -0,0 +1,871 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Inspect saved invocation evidence without a benchmark manifest or trace import.""" + +from __future__ import annotations + +import argparse +from collections.abc import Mapping +from datetime import datetime +import json +import math +from pathlib import Path +from typing import Any + +from nemo_fabric._report_format import format_bytes, format_duration_ms, format_money +from nemo_fabric.accounting import summarize_usage +from nemo_fabric.errors import FabricConfigError +from nemo_fabric.types import ( + FabricMapping, + InvocationSystemStats, + ResourceObservation, + ModelUsage, + RuntimeResourceSnapshot, +) + +MAX_RESULT_BYTES = 16 * 1024 * 1024 + + +def _text(value: Any, field: str) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{field} must be a nonempty string") + return value + + +def _invalid_constant(value: str) -> Any: + raise ValueError(f"non-finite JSON number: {value}") + + +def _finite_float(value: str) -> float: + number = float(value) + if not math.isfinite(number): + raise ValueError("non-finite JSON number") + return number + + +def _load(path: Path) -> dict[str, Any]: + with path.open("rb") as stream: + data = stream.read(MAX_RESULT_BYTES + 1) + if len(data) > MAX_RESULT_BYTES: + raise ValueError( + "saved evidence exceeds the summary reader's 16 MiB limit; " + "the original file is unchanged and can be inspected separately" + ) + try: + document = json.loads( + data, parse_constant=_invalid_constant, parse_float=_finite_float + ) + except RecursionError as error: + raise ValueError("saved evidence is nested too deeply") from error + if not isinstance(document, dict): + raise ValueError("saved evidence must be a JSON object") + return document + + +def summarize_result(result: Mapping[str, Any]) -> dict[str, Any]: + """Describe one returned invocation, preserving independent usable evidence. + + This is not a task verdict or proof of a complete scheduled run. Normalized + accounting uses the shared reducer. Legacy usage remains labeled diagnostics; + logs and Relay/ATIF totals never fill missing normalized dimensions. + """ + document = ( + result.to_mapping() if isinstance(result, FabricMapping) else dict(result) + ) + identity = { + field: _text(document.get(field), field) + for field in ("request_id", "runtime_id", "invocation_id") + } + status = document.get("status") + if not isinstance(status, str) or status not in { + "succeeded", + "failed", + "cancelled", + }: + raise ValueError("saved invocation has an invalid execution status") + error = document.get("error") + execution_error = ( + { + field: error[field] + for field in ("stage", "code", "retryable") + if field in error + } + if isinstance(error, Mapping) + else None + ) + events = document.get("events", []) + accounting_error = ( + "invalid_model_usage" + if isinstance(events, list) + and any( + isinstance(event, Mapping) and event.get("kind") == "model_usage_invalid" + for event in events + ) + else None + ) + normalized = None + if document.get("model_usage") is not None: + try: + normalized = ModelUsage.from_mapping(document["model_usage"]).to_mapping() + except (FabricConfigError, TypeError, ValueError): + accounting_error = "invalid_model_usage" + document.pop("model_usage", None) + usage = summarize_usage( + [document], expected=[(identity["runtime_id"], identity["invocation_id"])] + ) + resource = None + resource_error = None + if document.get("resource_snapshot") is not None: + try: + resource = RuntimeResourceSnapshot.from_mapping( + document["resource_snapshot"] + ).to_mapping() + if resource["runtime_id"] != identity["runtime_id"]: + raise ValueError("resource snapshot belongs to another runtime") + except (FabricConfigError, TypeError, ValueError): + resource = None + resource_error = "invalid_resource_snapshot" + stats = None + stats_error = None + if document.get("system_stats") is not None: + try: + stats = InvocationSystemStats.from_mapping( + document["system_stats"] + ).to_mapping() + if any( + observation.get("snapshot", {}).get( + "runtime_id", identity["runtime_id"] + ) + != identity["runtime_id"] + for observation in (stats["before"], stats["after"]) + ): + raise ValueError("system stats belong to another runtime") + except (FabricConfigError, TypeError, ValueError): + stats = None + stats_error = "invalid_system_stats" + refs = document.get("artifacts") + refs = refs.get("artifacts", []) if isinstance(refs, Mapping) else [] + artifacts = ( + [ + { + key: ref[key] + for key in ("name", "kind", "path", "media_type", "metadata") + if key in ref + } + for ref in refs + if isinstance(ref, Mapping) + ] + if isinstance(refs, list) + else [] + ) + return { + "schema": "nemo.fabric.invocation_summary", + "version": "v1alpha1", + **identity, + "status": status, + "recording_failed": any( + isinstance(event, Mapping) and event.get("kind") == "record_write_failed" + for event in document.get("events", []) + ) + if isinstance(document.get("events", []), list) + else False, + "execution_error": execution_error, + "harness": document.get("harness") + if isinstance(document.get("harness"), str) + else None, + "outcome_boundary": next( + ( + ref.get("metadata", {}).get("boundary") + for ref in artifacts + if ref.get("kind") == "run_result" + and isinstance(ref.get("metadata"), Mapping) + ), + None, + ), + "accounting_error": accounting_error, + "model_usage": normalized, + "usage": usage, + "legacy_usage": document.get("usage"), + "resource_snapshot": resource, + "resource_error": resource_error, + "system_stats": stats, + "system_stats_error": stats_error, + "resource_window": _resource_window( + {"resource_scope": RESOURCE_SCOPE}, + { + "resource_scope": RESOURCE_SCOPE, + "resources_before": stats["before"], + "resources": stats["after"], + }, + identity["runtime_id"], + ) + if stats is not None + else None, + "what_ran": None, + "provenance_error": None, + "artifacts": artifacts, + "telemetry": document["telemetry"] + if isinstance(document.get("telemetry"), list) + else [], + } + + +def read_result(path: Path) -> dict[str, Any]: + """Read one saved full result without requiring task labels or a manifest.""" + path = Path(path) + summary = summarize_result(_load(path)) + summary["result_path"] = str(path) + _attach_invocation_interval(summary, path) + return summary + + +def _attach_invocation_interval(summary: dict[str, Any], result_path: Path) -> None: + # Only the core's fixed sibling name is eligible. Never follow a path from + # artifact data, and never use the pre-shutdown status to replace the result. + record_path = result_path.with_name("run-record.jsonl") + try: + if record_path.is_symlink() or not record_path.is_file(): + return + record = read_invocation_record(record_path) + if any( + record[field] != summary[field] + for field in ("request_id", "runtime_id", "invocation_id") + ): + raise ValueError("sibling invocation record identity differs") + summary["execution_interval"] = { + "scope": "core_invocation", + "elapsed_ms": record["elapsed_ms"], + "source": str(record_path), + "truncated_tail": record["truncated_tail"], + } + if record["exchange_elapsed_ms"] is not None: + summary["adapter_exchange"] = { + "elapsed_ms": record["exchange_elapsed_ms"], + "source": str(record_path), + } + except (OSError, ValueError, UnicodeError): + summary["record_error"] = "invalid_or_unreadable_sibling_record" + + +def summarize_request(document: Mapping[str, Any]) -> dict[str, Any]: + """Describe outer dispatch evidence without fabricating an invocation result. + + A start alone establishes dispatch registration, not a known outcome. A + returned reference identifies the expected result location; this reader does + not follow paths from evidence or infer that publication succeeded. + """ + if (document.get("schema"), document.get("version")) != ( + "nemo.fabric.request_record", + "v1alpha1", + ): + raise ValueError("unsupported request record schema or version") + summary: dict[str, Any] = { + "schema": "nemo.fabric.request_summary", + "version": "v1alpha1", + "request_id": _text(document.get("request_id"), "request_id"), + "time": _text(document.get("time"), "time"), + } + if document.get("kind") == "start": + summary["state"] = "registered" + elif document.get("kind") == "end" and document.get("state") == "no_result": + if not isinstance(document.get("stage"), str) or document["stage"] not in { + "start", + "invoke", + }: + raise ValueError("invalid request failure stage") + summary.update(state="no_result", stage=document["stage"]) + elif document.get("kind") == "end" and document.get("state") == "returned": + summary.update( + state="returned", + runtime_id=_text(document.get("runtime_id"), "runtime_id"), + invocation_id=_text(document.get("invocation_id"), "invocation_id"), + result_reference=_text(document.get("result"), "result"), + ) + else: + raise ValueError("invalid request record kind or state") + return summary + + +def read_evidence(path: Path) -> dict[str, Any]: + """Read a full result, invocation record or outer request record; never follow references.""" + path = Path(path) + if path.suffix == ".jsonl": + return read_invocation_record(path) + document = _load(path) + if document.get("schema") == "nemo.fabric.request_record": + summary = summarize_request(document) + summary["evidence_path"] = str(path) + return summary + summary = summarize_result(document) + summary["result_path"] = str(path) + _attach_invocation_interval(summary, path) + return summary + + +RESOURCE_SCOPE = "runtime_adapter_host_process_tree" + + +def _resource_observation( + value: Any, runtime_id: str +) -> tuple[dict[str, Any] | None, str | None]: + if value is None: + return None, "missing_snapshot" + if ( + isinstance(value, Mapping) + and isinstance(value.get("error"), str) + and value["error"] + in { + "resource_observation_unavailable", + "resource_observation_unsupported", + } + ): + return None, value["error"] + try: + if isinstance(value, Mapping) and "status" in value: + observation = ResourceObservation.from_mapping(value) + if observation.status != "available": + return None, { + "not_collected": "not_collected", + "unavailable": "resource_observation_unavailable", + "unsupported": "resource_observation_unsupported", + }[observation.status] + value = observation.snapshot.to_mapping() + snapshot = RuntimeResourceSnapshot.from_mapping(value).to_mapping() + if snapshot["runtime_id"] != runtime_id: + return None, "runtime_mismatch" + for field in ("started_at", "observed_at"): + time = datetime.fromisoformat(snapshot[field].replace("Z", "+00:00")) + if time.tzinfo is None: + raise ValueError("snapshot timestamps require timezone") + cpu = snapshot["cpu_time_seconds"] + if type(cpu) not in (int, float) or not math.isfinite(cpu) or cpu < 0: + raise ValueError("invalid CPU counter") + return snapshot, None + except (FabricConfigError, TypeError, ValueError, KeyError, AttributeError): + return None, "invalid_snapshot" + + +def _resource_window( + start: Mapping[str, Any], end: Mapping[str, Any] | None, runtime_id: str +) -> dict[str, Any] | None: + if "resources" not in start and (end is None or "resources" not in end): + return None # Older records have no observation pair. + before_source = ( + end.get("resources_before") + if end is not None and "resources_before" in end + else start.get("resources") + ) + before, reason = _resource_observation(before_source, runtime_id) + after, end_reason = _resource_observation( + end.get("resources") if end else None, runtime_id + ) + window = { + "scope": RESOURCE_SCOPE, + "start": before, + "end": after, + "cpu_counter_change_seconds": None, + "delta_reason": reason or end_reason, + } + scope = ( + end.get("resource_scope") + if end is not None and "resources_before" in end + else start.get("resource_scope") + ) + if scope != RESOURCE_SCOPE or ( + end is not None and end.get("resource_scope") != RESOURCE_SCOPE + ): + window["delta_reason"] = "scope_unverified" + elif before is not None and after is not None: + + def parse_time(value: str) -> datetime: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + if before["started_at"] != after["started_at"]: + window["delta_reason"] = "host_changed" + elif ( + not parse_time(before["started_at"]) + <= parse_time(before["observed_at"]) + <= parse_time(after["observed_at"]) + ): + window["delta_reason"] = "time_reversed" + elif after["cpu_time_seconds"] < before["cpu_time_seconds"]: + window["delta_reason"] = "counter_decreased" + else: + window["cpu_counter_change_seconds"] = ( + after["cpu_time_seconds"] - before["cpu_time_seconds"] + ) + window["delta_reason"] = None + return window + + +def _render_resource_window(window: Mapping[str, Any]) -> list[str]: + lines = [] + if window["cpu_counter_change_seconds"] is not None: + lines.append( + f"Runtime process-tree CPU counter change: {window['cpu_counter_change_seconds']:.3f} s between snapshots (not invocation CPU)." + ) + elif window["delta_reason"]: + lines.append( + "Runtime process-tree CPU counter change unavailable: " + + window["delta_reason"] + + "." + ) + if window["start"] is not None and window["end"] is not None: + lines.append( + f"RSS observations: {format_bytes(window['start']['resident_memory_bytes'])} → {format_bytes(window['end']['resident_memory_bytes'])} (not a peak)." + ) + return lines + + +def render_request_summary(summary: Mapping[str, Any]) -> str: + """Explain missing execution evidence without assigning zero spend.""" + lines = ["Request: " + json.dumps(summary["request_id"], ensure_ascii=True)] + state = summary["state"] + if state == "returned": + lines.append("A result was returned; inspect the separate result file.") + lines.append( + "Expected result relative to .fabric: " + + json.dumps(summary["result_reference"], ensure_ascii=True) + ) + lines.append( + "This reference does not confirm that saving the result succeeded." + ) + elif state == "no_result": + stage = "runtime startup" if summary["stage"] == "start" else "invocation" + lines.append(f"No invocation result was returned ({stage} failed).") + lines.append("Usage and spend are unknown; no task verdict is inferred.") + else: + lines.append("Dispatch was registered; this start record has no outcome.") + lines.append( + "Inspect its end record if available. Missing evidence is not zero usage." + ) + if summary.get("evidence_path"): + lines.append( + "Saved evidence: " + json.dumps(summary["evidence_path"], ensure_ascii=True) + ) + return "\n".join(lines) + + +def read_invocation_record(path: Path) -> dict[str, Any]: + """Read bounded start/end evidence; a truncated tail never creates a result. + + This reports the core invocation window before one-shot shutdown. Only the + typed model_usage projection supplies normalized accounting; legacy counters + never gain a completeness guarantee. + """ + path = Path(path) + with path.open("rb") as stream: + data = stream.read(MAX_RESULT_BYTES + 1) + if len(data) > MAX_RESULT_BYTES: + raise ValueError( + "record exceeds the summary reader's 16 MiB limit; original file is unchanged" + ) + rows = [] + truncated = False + for line in data.splitlines(keepends=True): + if not line.endswith(b"\n"): + truncated = True + break + try: + row = json.loads( + line, parse_constant=_invalid_constant, parse_float=_finite_float + ) + except (ValueError, RecursionError) as error: + raise ValueError( + f"invalid complete invocation record line: {error}" + ) from error + if not isinstance(row, dict) or (row.get("schema"), row.get("version")) != ( + "nemo.fabric.run_record", + "v1alpha1", + ): + raise ValueError("unsupported invocation record schema or version") + rows.append(row) + if not rows or rows[0].get("kind") != "start": + raise ValueError("invocation record must begin with a complete start line") + if len(rows) > 2 or (len(rows) == 2 and rows[1].get("kind") != "end"): + raise ValueError("conflicting or duplicate invocation record lines") + identity = { + field: _text(rows[0].get(field), field) + for field in ("request_id", "runtime_id", "invocation_id") + } + end = rows[1] if len(rows) == 2 else None + if end is not None: + if any(end.get(field) != value for field, value in identity.items()): + raise ValueError("invocation record identity changed") + if not isinstance(end.get("status"), str) or end["status"] not in { + "succeeded", + "failed", + "cancelled", + }: + raise ValueError("invalid invocation record status") + elapsed = end.get("elapsed_ms") + if type(elapsed) is not int or not 0 <= elapsed < 1 << 64: + raise ValueError("invalid invocation elapsed time") + if end.get("outcome_scope") != "invocation": + raise ValueError("unsupported invocation outcome scope") + exchange = end.get("exchange_elapsed_ms") + if exchange is not None and ( + type(exchange) is not int or not 0 <= exchange < 1 << 64 + ): + raise ValueError("invalid adapter exchange elapsed time") + normalized = None + accounting_error = None + usage = None + if end is not None and end.get("model_usage") is not None: + try: + normalized = ModelUsage.from_mapping(end["model_usage"]).to_mapping() + usage = summarize_usage( + [{**identity, "model_usage": normalized}], + expected=[(identity["runtime_id"], identity["invocation_id"])], + ) + except (FabricConfigError, TypeError, ValueError): + normalized = None + accounting_error = "invalid_model_usage" + return { + "schema": "nemo.fabric.invocation_record_summary", + "version": "v1alpha1", + **identity, + "status": end["status"] if end else None, + "model_usage": normalized, + "usage": usage, + "accounting_error": accounting_error, + "legacy_usage": end.get("usage") if end else None, + "what_ran": None, + "elapsed_ms": end["elapsed_ms"] if end else None, + "exchange_elapsed_ms": end.get("exchange_elapsed_ms") if end else None, + "resource_window": None, + "outcome_boundary": "invocation", + "truncated_tail": truncated, + "evidence_path": str(path), + } + + +def render_invocation_record_summary( + summary: Mapping[str, Any], *, details: bool = False +) -> str: + """Describe the invocation interval without assuming whole-runtime success.""" + if summary.get("usage") is not None: + display = dict(summary) + display["execution_interval"] = {"elapsed_ms": summary["elapsed_ms"]} + if summary["exchange_elapsed_ms"] is not None: + display["adapter_exchange"] = {"elapsed_ms": summary["exchange_elapsed_ms"]} + lines = render_result_summary(display, details=details).splitlines() + lines[0] = "Invocation outcome before runtime shutdown: " + summary["status"] + lines.append( + "The separate full result is authoritative for the one-shot outcome." + ) + if summary["truncated_tail"]: + lines.append( + "A truncated final line was ignored; complete lines were retained." + ) + lines.append( + "Saved evidence: " + json.dumps(summary["evidence_path"], ensure_ascii=True) + ) + return "\n".join(lines) + lines = [ + "Invocation: " + + json.dumps( + f"{summary['runtime_id']}/{summary['invocation_id']}", ensure_ascii=True + ), + "Invocation outcome before runtime shutdown: " + + (summary["status"] or "unavailable"), + ] + if summary.get("provenance_error"): + lines.append( + "Saved execution facts were unreadable; status and accounting are retained." + ) + if summary.get("exchange_elapsed_ms") is not None: + lines.append( + f"Adapter exchange: {summary['exchange_elapsed_ms']:,} ms (excludes queue and result persistence)." + ) + elif summary["elapsed_ms"] is not None: + lines.append(f"Core invocation interval: {summary['elapsed_ms']:,} ms") + else: + lines.append( + "No complete end record; elapsed time and final usage are unknown." + ) + if summary["truncated_tail"]: + lines.append( + "A truncated final line was ignored; complete lines were retained." + ) + if summary.get("accounting_error"): + lines.append( + "Saved model accounting was invalid; execution evidence is retained." + ) + lines.append("Inspect the full result for accounting and the one-shot outcome.") + lines.append( + "Saved evidence: " + json.dumps(summary["evidence_path"], ensure_ascii=True) + ) + return "\n".join(lines) + + +def _render_tokens(dimensions: Mapping[str, Any]) -> list[str]: + totals = ("input_tokens", "output_tokens", "total_tokens") + caches = ("cached_input_tokens", "cache_write_input_tokens") + + def coverage(names: tuple[str, ...]) -> str: + if all(dimensions[name]["complete_total"] is not None for name in names): + return "complete" + if any( + dimensions[name]["observed_sum"] is not None + or dimensions[name]["complete_total"] is not None + for name in names + ): + return "incomplete" + return "unavailable" + + total_coverage, cache_coverage = coverage(totals), coverage(caches) + + def count(name: str, label: str, group_coverage: str) -> str: + field = dimensions[name] + if field["complete_total"] is not None: + suffix = "" if group_coverage == "complete" else " (complete)" + return f"{label} {field['complete_total']:,}{suffix}" + if field["observed_sum"] is not None: + return f"{label} {field['observed_sum']:,} (observed, incomplete)" + return f"{label} unavailable" + + return [ + "Tokens: " + + "; ".join( + count(name, label, total_coverage) + for name, label in zip(totals, ("input", "output", "total")) + ), + "Cache (included in input): " + + "; ".join( + count(name, label, cache_coverage) + for name, label in zip(caches, ("reads", "writes")) + ), + f"Token coverage: input/output/total {total_coverage}; cache breakdown {cache_coverage}", + ] + + +def render_result_summary(summary: Mapping[str, Any], *, details: bool = False) -> str: + """Render a concise overview; details add resource evidence. + + Token and cost completeness remain separate. JSON quantities and accounting + semantics are unchanged by this presentation. + """ + dimensions = summary["usage"]["dimensions"] + lines = [f"Execution: {summary['status']}"] + if details: + lines.append( + "Invocation: " + + json.dumps( + f"{summary['runtime_id']}/{summary['invocation_id']}", ensure_ascii=True + ) + ) + if summary.get("harness"): + lines.append("Harness: " + json.dumps(summary["harness"], ensure_ascii=True)) + if summary.get("execution_error"): + error = summary["execution_error"] + lines.append( + "Execution error: " + + json.dumps( + {field: error[field] for field in ("stage", "code") if field in error}, + ensure_ascii=True, + ) + ) + if details and summary.get("outcome_boundary"): + lines.append( + "Outcome boundary: " + + json.dumps(summary["outcome_boundary"], ensure_ascii=True) + ) + interval = summary.get("execution_interval") + if summary.get("adapter_exchange") is not None: + lines.append( + f"Elapsed (adapter exchange): {format_duration_ms(summary['adapter_exchange']['elapsed_ms'])} (excludes queue and result persistence)." + ) + elif summary.get("system_stats") is not None: + lines.append( + f"Elapsed (adapter exchange): {format_duration_ms(summary['system_stats']['elapsed_seconds'] * 1000)} (excludes queue and result persistence)." + ) + elif interval is not None and interval["elapsed_ms"] is not None: + lines.append( + f"Core invocation interval before shutdown: {format_duration_ms(interval['elapsed_ms'])}" + ) + else: + lines.append("Elapsed: unavailable") + if details and summary.get("resource_window") is not None: + lines.extend(_render_resource_window(summary["resource_window"])) + resource = summary.get("resource_snapshot") + if details and resource is not None: + lines.append( + f"Runtime process-tree snapshot: CPU {resource['cpu_time_seconds']:.3f} s since host start; " + f"RSS {format_bytes(resource['resident_memory_bytes'])}; live processes {resource['process_count']}" + ) + normalized = summary.get("model_usage") + models = normalized.get("models", []) if normalized is not None else [] + if models: + shown = models if details else models[:3] + lines.append( + "Accounted models: " + + ", ".join( + json.dumps( + f"{model.get('provider') or 'unspecified'}/{model['model']}", + ensure_ascii=True, + ) + for model in shown + ) + + ( + f"; {len(models) - len(shown)} more (see --details)" + if len(shown) < len(models) + else "" + ) + ) + if not normalized["models_exhaustive"]: + lines.append("Per-model accounting is not exhaustive.") + lines.extend(_render_tokens(dimensions)) + gaps = normalized.get("gaps", []) if normalized is not None else [] + not_reported = { + dimension + for gap in gaps + if gap["reason"] == "missing_usage" + for dimension in gap["dimensions"] + } + for dimension, label in ( + ("reported_cost", "Provider-reported cost"), + ("estimated_cost", "Catalog estimate"), + ): + field = dimensions[dimension] + if field["complete_total"] is not None: + text = format_money(field["complete_total"]) + " (complete)" + elif field["observed_sum"] is not None: + text = format_money(field["observed_sum"]) + " (observed, incomplete)" + else: + text = "unavailable" + ( + " (not reported)" if dimension in not_reported else "" + ) + lines.append(f"{label}: {text}") + pricing = summary["usage"].get("pricing") + if pricing: + if details: + lines.append( + "Pricing basis: " + + json.dumps(pricing, ensure_ascii=True, sort_keys=True) + ) + else: + catalogs = [ + f"{label} {pricing[key].removeprefix('sha256:')[:12]}" + for key, label in ( + ("bundled_catalog_sha256", "bundled"), + ("user_catalog_sha256", "user"), + ) + if pricing.get(key) + ] + lines.append( + "Pricing basis: catalog" + + ("; " + "; ".join(catalogs) if catalogs else "") + + " (full basis in --details or --json)." + ) + if summary.get("recording_failed"): + lines.append( + "Recording failed; execution and available accounting are retained. " + "Inspect record_write_failed events in the original result." + ) + explanations: set[str] = set() + missing: set[str] = set() + for gap in gaps: + if gap.get("code") == "cache_write_tariff_unverified": + explanations.add("The applicable cache-write price was not established.") + elif gap["reason"] == "unfinished_work": + explanations.add( + "Some invocation-owned model work was unfinished at return." + ) + elif gap["reason"] == "scope_unverified": + explanations.add( + "The source did not establish complete invocation coverage." + ) + else: + labels = { + "reported_cost": "provider-reported cost", + "estimated_cost": "catalog estimate", + } + missing.update( + labels.get(dimension, dimension.replace("_", " ")) + for dimension in gap["dimensions"] + if dimension not in {"reported_cost", "estimated_cost"} + ) + if summary.get("accounting_error"): + explanations.add( + "Saved model accounting was invalid; execution evidence is retained." + ) + if missing: + explanations.add("Not reported: " + ", ".join(sorted(missing)) + ".") + for explanation in sorted(explanations): + lines.append(explanation) + if summary.get("legacy_usage") is not None and normalized is None: + lines.append("Raw harness usage is retained in JSON as unverified diagnostics.") + if summary.get("record_error"): + lines.append( + "The sibling invocation record could not be used; result accounting is retained." + ) + if details and summary.get("system_stats") is not None: + stats = summary["system_stats"] + lines.append( + "System stats collected groups: " + + json.dumps(stats["collected_groups"], ensure_ascii=True) + ) + sampled = stats.get("sampled") + if sampled is not None: + lines.append( + f"Resource samples: {sampled['sample_count']:,}; dropped {sampled['dropped_samples']:,}; truncated {str(sampled['truncated']).lower()} (sampled peaks are not guaranteed true peaks)." + ) + if summary.get("system_stats_error"): + lines.append( + "Saved system stats were invalid; execution and accounting are retained." + ) + if summary.get("resource_error"): + lines.append( + "The saved resource snapshot is invalid; result accounting is retained." + ) + for artifact in summary.get("artifacts", []): + if artifact.get("kind") == "log" and isinstance(artifact.get("path"), str): + lines.append( + "Log artifact: " + json.dumps(artifact["path"], ensure_ascii=True) + ) + for reference in summary.get("telemetry", []): + if isinstance(reference, Mapping) and isinstance(reference.get("uri"), str): + lines.append( + "Telemetry reference: " + + json.dumps(reference["uri"], ensure_ascii=True) + ) + if summary.get("result_path"): + lines.append( + "Saved result: " + json.dumps(summary["result_path"], ensure_ascii=True) + ) + lines.append("Execution status is not a task verdict. Estimates are not invoices.") + return "\n".join(lines) + + +def _render(summary: Mapping[str, Any], *, details: bool = False) -> str: + if summary["schema"] == "nemo.fabric.request_summary": + return render_request_summary(summary) + if summary["schema"] == "nemo.fabric.invocation_record_summary": + return render_invocation_record_summary(summary, details=details) + return render_result_summary(summary, details=details) + + +def main() -> None: + """Inspect a saved invocation result as a concise summary or JSON.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("result", type=Path) + parser.add_argument("--json", action="store_true", dest="as_json") + parser.add_argument( + "--details", action="store_true", help="include resource and pricing details" + ) + args = parser.parse_args() + try: + summary = read_evidence(args.result) + except (OSError, ValueError, UnicodeError) as error: + parser.error(str(error)) + print( + json.dumps(summary, indent=2, allow_nan=False) + if args.as_json + else _render(summary, details=args.details) + ) + + +if __name__ == "__main__": + main() diff --git a/sdk/python/nemo-fabric-runtime/src/nemo_fabric/runtime.py b/sdk/python/nemo-fabric-runtime/src/nemo_fabric/runtime.py index f29e0bbad..b8f15423c 100644 --- a/sdk/python/nemo-fabric-runtime/src/nemo_fabric/runtime.py +++ b/sdk/python/nemo-fabric-runtime/src/nemo_fabric/runtime.py @@ -12,6 +12,7 @@ from contextlib import AsyncExitStack from copy import deepcopy from enum import Enum +from pathlib import Path from typing import Any, Literal, Protocol from uuid import uuid4 @@ -972,11 +973,62 @@ def _run_request_payload( return payload +def _record_root( + plan: Mapping[str, Any], runtime_config: Mapping[str, Any] +) -> str | None: + environment_plan = plan.get("environment_plan", {}) + artifact_root = runtime_config.get("artifacts") or ( + environment_plan.get("artifacts") + if isinstance(environment_plan, Mapping) + else None + ) + if artifact_root is None: + return None + root = Path(artifact_root) + if not root.is_absolute(): + root = Path(plan["base_dir"]) / root + return str(root / ".fabric") + + async def _run_native_lifecycle( native: Any, plan: Mapping[str, Any], request: Mapping[str, Any], ) -> dict[str, Any]: + config = plan.get("config", {}) + runtime_config = config.get("runtime", {}) if isinstance(config, Mapping) else {} + record = ( + runtime_config.get("record", {}) if isinstance(runtime_config, Mapping) else {} + ) + if isinstance(record, Mapping) and record.get("enabled") is True: + # Core owns the final snapshot after shutdown. Saving an invocation-only + # snapshot here would omit stop events and a possible stop failure. + try: + return json.loads( + await _call_blocking( + lambda: native.run_plan( + json.dumps(dict(plan)), json.dumps(dict(request)) + ) + ) + ) + except asyncio.CancelledError as error: + root = _record_root(plan, runtime_config) + if root is not None: + error.add_note(f"Run evidence location: {root}") + raise + except Exception as error: + stage = getattr(error, "fabric_stage", "run") + if stage not in {"plan", "prepare", "start", "invoke", "stop"}: + stage = "run" + failure = _runtime_error(error, stage=stage) + root = _record_root(plan, runtime_config) + if root is not None: + failure.details["record_root"] = root + failure.add_note(f"Run evidence location: {root}") + if failure is error: + raise + raise failure from error + def run() -> dict[str, Any]: plan_json = json.dumps(dict(plan)) try: 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..2dfcd259b 100644 --- a/sdk/python/nemo-fabric-runtime/src/nemo_fabric/types.py +++ b/sdk/python/nemo-fabric-runtime/src/nemo_fabric/types.py @@ -2519,6 +2519,381 @@ def _normalize(cls, data: dict[str, Any]) -> dict[str, Any]: return data +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) + 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 +2914,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. @@ -2561,6 +2938,7 @@ 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 @@ -2580,6 +2958,7 @@ class RunResult(FabricMapping): "output", "error", "usage", + "model_usage", "resource_snapshot", "system_stats", "artifacts", @@ -2608,6 +2987,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 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..8f11542b4 100644 --- a/skills/nemo-fabric-integrate/references/results-and-errors.md +++ b/skills/nemo-fabric-integrate/references/results-and-errors.md @@ -17,6 +17,7 @@ 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 and provider-reported cost. | | `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. | @@ -34,7 +35,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: 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/_utils/utils.py b/tests/_utils/utils.py index 9b7ca63be..aa5521ae8 100644 --- a/tests/_utils/utils.py +++ b/tests/_utils/utils.py @@ -22,11 +22,17 @@ def assert_atof_skill_selection( output: Mapping[str, Any], expected_skill: str | None ) -> None: records = atof_records(output) - tool_records = [record for record in records if record["category"] == "tool"] + tool_records = [ + record + for record in records + if record["kind"] == "scope" and record["category"] == "tool" + ] llm_end_records = [ record for record in records - if record["category"] == "llm" and record["scope_category"] == "end" + if record["kind"] == "scope" + and record["category"] == "llm" + and record["scope_category"] == "end" ] serialized_tool_records = json.dumps(tool_records).replace("\\\\", "/") serialized_skill_calls = json.dumps(tool_records + llm_end_records).replace( @@ -55,7 +61,9 @@ def assert_atof_model(output: Mapping[str, Any], expected_model: str) -> None: llm_starts = [ record for record in events - if record["category"] == "llm" and record["scope_category"] == "start" + if record["kind"] == "scope" + and record["category"] == "llm" + and record["scope_category"] == "start" ] assert llm_starts, events assert {record["data"]["content"]["model"] for record in llm_starts} == { @@ -81,7 +89,9 @@ def assert_semantic_relay_artifacts( llm_starts = [ event for event in events - if event.get("category") == "llm" and event.get("scope_category") == "start" + if event["kind"] == "scope" + and event["category"] == "llm" + and event["scope_category"] == "start" ] assert llm_starts, events assert all( @@ -92,7 +102,9 @@ def assert_semantic_relay_artifacts( llm_ends = [ event for event in events - if event.get("category") == "llm" and event.get("scope_category") == "end" + if event["kind"] == "scope" + and event["category"] == "llm" + and event["scope_category"] == "end" ] assert llm_ends, events assert any(_relay_event_total_tokens(event) > 0 for event in llm_ends) diff --git a/tests/adapters/test_adapaters_common_utils.py b/tests/adapters/test_adapaters_common_utils.py index d1805f12b..0be298052 100644 --- a/tests/adapters/test_adapaters_common_utils.py +++ b/tests/adapters/test_adapaters_common_utils.py @@ -1332,8 +1332,10 @@ async def test_relay_initializes_v4_atof_atif_config( } common_utils.validate_relay_observability(plugin_config) assert plugin.validate_exact(plugin_config)["config"]["diagnostics"] == [] + # Activation includes discovered host plugins, so compare the same layers. + expected_diagnostics = plugin.validate(plugin_config)["config"]["diagnostics"] async with plugin.activate(plugin_config) as activation: - assert activation.report["config"]["diagnostics"] == [] + assert activation.report["config"]["diagnostics"] == expected_diagnostics def test_relay_validates_all_v4_otlp_fields(): diff --git a/tests/adapters/test_adapters_common_relay_gateway.py b/tests/adapters/test_adapters_common_relay_gateway.py index c307254ae..86c23ede2 100644 --- a/tests/adapters/test_adapters_common_relay_gateway.py +++ b/tests/adapters/test_adapters_common_relay_gateway.py @@ -239,6 +239,39 @@ def test_wait_for_relay_gateway_reports_early_exit(): ) +def test_wait_for_relay_gateway_rejects_exit_during_successful_probe(monkeypatch): + process = MagicMock() + process.poll.side_effect = [None, 17] + response = MagicMock() + response.__enter__.return_value.status = 200 + monkeypatch.setattr( + relay_gateway.urllib.request, "urlopen", MagicMock(return_value=response) + ) + + with pytest.raises(relay_gateway.RelayGatewayError, match="status 17"): + relay_gateway.wait_for_relay_gateway(process, "http://127.0.0.1:43210/healthz") + + +def test_wait_for_relay_gateway_rejects_success_after_deadline( + monkeypatch, gateway_clock +): + process = MagicMock() + process.poll.return_value = None + response = MagicMock() + response.__enter__.return_value.status = 200 + + def probe_health(url, *, timeout): + gateway_clock.monotonic.return_value += 0.26 + return response + + monkeypatch.setattr(relay_gateway.urllib.request, "urlopen", probe_health) + + with pytest.raises(relay_gateway.RelayGatewayError, match="did not become ready"): + relay_gateway.wait_for_relay_gateway( + process, "http://127.0.0.1:43210/healthz", timeout=0.25 + ) + + def test_wait_for_relay_gateway_times_out(): process = MagicMock() process.poll.return_value = None @@ -271,3 +304,66 @@ def test_stop_relay_gateway_is_idempotent_for_exited_process(): process.terminate.assert_not_called() process.kill.assert_not_called() + + +@pytest.fixture(name="gateway_clock") +def gateway_clock_fixture(monkeypatch: pytest.MonkeyPatch) -> MagicMock: + mock_clock = MagicMock(spec=relay_gateway.time) + mock_clock.monotonic.return_value = 0.0 + + def advance(seconds: float) -> None: + mock_clock.monotonic.return_value += seconds + + mock_clock.sleep.side_effect = advance + monkeypatch.setattr(relay_gateway, "time", mock_clock) + return mock_clock + + +def test_wait_for_relay_gateway_allows_preflight_before_worker_readiness( + monkeypatch, gateway_clock +): + process = MagicMock() + process.poll.return_value = None + response = MagicMock() + response.__enter__.return_value.status = 200 + + def probe_health(url, *, timeout): + gateway_clock.monotonic.return_value += timeout + if gateway_clock.monotonic() < 14.0: + raise relay_gateway.urllib.error.URLError("still initializing") + return response + + monkeypatch.setattr(relay_gateway.urllib.request, "urlopen", probe_health) + + relay_gateway.wait_for_relay_gateway(process, "http://127.0.0.1:43210/healthz") + + assert 14.0 <= gateway_clock.monotonic() < 30.0 + + +@pytest.mark.parametrize( + ("probe_elapsed", "expected_sleep"), [(0.25, None), (0.20, 0.05)] +) +def test_wait_for_relay_gateway_limits_each_probe_to_remaining_startup_budget( + monkeypatch, gateway_clock, probe_elapsed, expected_sleep +): + process = MagicMock() + process.poll.return_value = None + + def probe_health(url, *, timeout): + gateway_clock.monotonic.return_value += probe_elapsed + raise relay_gateway.urllib.error.URLError("not ready") + + mock_probe = MagicMock(side_effect=probe_health) + monkeypatch.setattr(relay_gateway.urllib.request, "urlopen", mock_probe) + + with pytest.raises(relay_gateway.RelayGatewayError, match="did not become ready"): + relay_gateway.wait_for_relay_gateway( + process, "http://127.0.0.1:43210/healthz", timeout=0.25 + ) + + mock_probe.assert_called_once_with("http://127.0.0.1:43210/healthz", timeout=0.25) + if expected_sleep is None: + gateway_clock.sleep.assert_not_called() + else: + gateway_clock.sleep.assert_called_once_with(pytest.approx(expected_sleep)) + assert gateway_clock.monotonic() == pytest.approx(0.25) 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..e9482dcbf --- /dev/null +++ b/tests/adapters/test_deepagents_usage_descriptor.py @@ -0,0 +1,49 @@ +# 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 +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) diff --git a/tests/docs/test_python_api_docs.py b/tests/docs/test_python_api_docs.py index a92f51439..a71839aa9 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,8 @@ 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.reporting": "/reference/api/python-library-reference/reporting", "nemo_fabric.client": "/reference/api/python-library-reference/client", "nemo_fabric.capabilities": "/reference/api/python-library-reference/capabilities", "nemo_fabric.runtime": "/reference/api/python-library-reference/runtime", @@ -48,11 +51,17 @@ "nemo_fabric.types": "/reference/api/python-library-reference/types", "nemo_fabric.errors": "/reference/api/python-library-reference/errors", } +PUBLIC_SUBMODULES = ("nemo_fabric.reporting",) MEMBER_SIGNATURE = re.compile( r"^### (?:method|classmethod) `([^`]+)`\n\n" 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—|`[^`]*`) \| " @@ -74,6 +83,26 @@ 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) + for module_name in PUBLIC_SUBMODULES: + module = importlib.import_module(module_name) + public_names = getattr( + module, + "__all__", + [name for name in vars(module) if not name.startswith("_")], + ) + for name in public_names: + exported = getattr(module, name) + if isfunction(exported) and exported.__module__ == module_name: + exports[module_name].add(name) + return dict(exports) + + def _documented_classes(page: Path) -> set[str]: return set( re.findall( @@ -149,7 +178,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 @@ -157,6 +187,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: @@ -245,6 +282,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 @@ -310,6 +360,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/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_invocation_reporting.py b/tests/python/test_invocation_reporting.py new file mode 100644 index 000000000..68c5a20dc --- /dev/null +++ b/tests/python/test_invocation_reporting.py @@ -0,0 +1,633 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from copy import deepcopy +import json + +import pytest + +from nemo_fabric.reporting import ( + read_evidence, + read_result, + render_result_summary, + render_request_summary, + summarize_result, +) +from nemo_fabric.types import RunResult + + +@pytest.fixture(name="result") +def result_fixture(): + return { + "runtime_id": "runtime", + "invocation_id": "invocation", + "request_id": "request", + "status": "succeeded", + "model_usage": { + "input_tokens": 100, + "cached_input_tokens": 0, + "cache_write_input_tokens": 0, + "output_tokens": 2, + "total_tokens": 102, + "observed": {}, + "gaps": [{"dimensions": ["estimated_cost"], "reason": "missing_usage"}], + "models_exhaustive": False, + "models": [], + "cost": { + "reported": {"amount_nano_usd": 10, "observed_nano_usd": None}, + "estimated": {"amount_nano_usd": None, "observed_nano_usd": None}, + }, + }, + "artifacts": {"artifacts": []}, + } + + +def test_one_off_result_needs_no_task_labels_or_benchmark_manifest(tmp_path, result): + path = tmp_path / "run-result.json" + path.write_text(json.dumps(result)) + summary = read_result(path) + text = render_result_summary(summary) + assert summary["status"] == "succeeded" + assert "input 100" in text + assert "reads 0" in text + assert "Provider-reported cost: $0.000000010 (complete)" in text + assert "Catalog estimate: unavailable" in text + assert summary["result_path"] == str(path) + + +@pytest.mark.parametrize("status", ["failed", "cancelled"]) +def test_execution_failure_does_not_erase_settled_usage(result, status): + result["status"] = status + summary = summarize_result(result) + assert summary["status"] == status + assert summary["usage"]["dimensions"]["input_tokens"]["complete_total"] == 100 + + +def test_partial_input_preserves_observations_and_independent_output(result): + usage = result["model_usage"] + usage["input_tokens"] = usage["total_tokens"] = None + usage["observed"] = {"input_tokens": 50} + usage["gaps"].append( + {"dimensions": ["input_tokens", "total_tokens"], "reason": "unfinished_work"} + ) + summary = summarize_result(result) + dimensions = summary["usage"]["dimensions"] + assert dimensions["input_tokens"]["complete_total"] is None + assert dimensions["input_tokens"]["observed_sum"] == 50 + assert dimensions["output_tokens"]["complete_total"] == 2 + text = render_result_summary(summary) + assert "input 50 (observed, incomplete)" in text + assert "output 2 (complete)" in text + assert "Some invocation-owned model work was unfinished at return." in text + + +def test_legacy_usage_does_not_become_normalized_or_zero(result): + result.pop("model_usage") + result["usage"] = {"input_tokens": 100, "output_tokens": 0} + summary = summarize_result(result) + assert summary["legacy_usage"] == result["usage"] + assert summary["usage"]["dimensions"]["input_tokens"]["complete_total"] is None + assert "unverified diagnostics" in render_result_summary(summary) + + +def test_read_result_does_not_open_log_or_trace_payloads(tmp_path, result): + result["artifacts"] = { + "artifacts": [{"kind": "log", "path": "missing-sensitive-log.txt"}] + } + result["telemetry"] = [{"provider": "relay", "uri": "missing-trace.jsonl"}] + path = tmp_path / "result.json" + path.write_text(json.dumps(result)) + summary = read_result(path) + assert summary["artifacts"] == result["artifacts"]["artifacts"] + assert summary["usage"]["dimensions"]["input_tokens"]["complete_total"] == 100 + + +@pytest.mark.parametrize("mutation", ["status", "identity", "not_object", "non_finite"]) +def test_malformed_saved_result_is_rejected(tmp_path, result, mutation): + payload = deepcopy(result) + if mutation == "status": + payload["status"] = [] + elif mutation == "identity": + payload["request_id"] = "" + elif mutation == "not_object": + payload = [] + else: + payload["unrelated"] = float("nan") + path = tmp_path / "result.json" + path.write_text(json.dumps(payload)) + with pytest.raises(ValueError): + read_result(path) + + +def test_result_outcome_boundary_survives_the_reader(result): + result["artifacts"]["artifacts"] = [ + { + "kind": "run_result", + "path": "run-result.json", + "metadata": {"boundary": "one_shot_lifecycle"}, + } + ] + summary = summarize_result(result) + assert summary["outcome_boundary"] == "one_shot_lifecycle" + assert 'Outcome boundary: "one_shot_lifecycle"' in render_result_summary( + summary, details=True + ) + + +@pytest.fixture(name="record_lines") +def record_lines_fixture(): + common = { + "schema": "nemo.fabric.run_record", + "version": "v1alpha1", + "request_id": "request", + "runtime_id": "runtime", + "invocation_id": "invocation", + } + return [ + {**common, "kind": "start"}, + { + **common, + "kind": "end", + "status": "succeeded", + "elapsed_ms": 123, + "outcome_scope": "invocation", + }, + ] + + +def test_invocation_record_keeps_its_pre_shutdown_scope(tmp_path, record_lines): + path = tmp_path / "run-record.jsonl" + path.write_text("".join(json.dumps(row) + "\n" for row in record_lines)) + summary = read_evidence(path) + assert summary["elapsed_ms"] == 123 + assert summary["outcome_boundary"] == "invocation" + assert summary["model_usage"] is None and summary["usage"] is None + + +def test_truncated_tail_keeps_start_without_fabricating_a_result( + tmp_path, record_lines +): + path = tmp_path / "run-record.jsonl" + path.write_text(json.dumps(record_lines[0]) + '\n{"kind":') + summary = read_evidence(path) + assert summary["truncated_tail"] + assert summary["status"] is None and summary["elapsed_ms"] is None + + +@pytest.mark.parametrize( + "condition", ["duplicate", "identity", "negative_elapsed", "scope"] +) +def test_conflicting_records_are_rejected(tmp_path, record_lines, condition): + if condition == "duplicate": + record_lines.append(record_lines[-1]) + elif condition == "identity": + record_lines[1]["runtime_id"] = "other" + elif condition == "negative_elapsed": + record_lines[1]["elapsed_ms"] = -1 + else: + record_lines[1]["outcome_scope"] = "unknown" + path = tmp_path / "run-record.jsonl" + path.write_text("".join(json.dumps(row) + "\n" for row in record_lines)) + with pytest.raises(ValueError): + read_evidence(path) + + +@pytest.mark.parametrize("suffix", ["json", "jsonl"]) +def test_overflowing_json_exponent_is_rejected_before_summary( + tmp_path, result, record_lines, suffix +): + path = tmp_path / ("result." + suffix) + if suffix == "json": + text = json.dumps(result)[:-1] + ', "extra": 1e400}' + else: + text = json.dumps(record_lines[0])[:-1] + ', "extra": 1e400}\n' + path.write_text(text) + with pytest.raises(ValueError, match="non-finite"): + read_evidence(path) + + +def test_result_reads_only_matching_core_sibling_for_timing( + tmp_path, result, record_lines +): + path = tmp_path / "run-result.json" + path.write_text(json.dumps(result)) + record = tmp_path / "run-record.jsonl" + record.write_text("".join(json.dumps(row) + "\n" for row in record_lines)) + summary = read_result(path) + assert summary["execution_interval"]["elapsed_ms"] == 123 + assert summary["execution_interval"]["scope"] == "core_invocation" + assert "before shutdown: 123 ms" in render_result_summary(summary) + assert summary["status"] == result["status"] + record_lines[1]["runtime_id"] = "other" + record.write_text("".join(json.dumps(row) + "\n" for row in record_lines)) + summary = read_evidence(path) + assert summary["record_error"] == "invalid_or_unreadable_sibling_record" + assert summary["usage"]["dimensions"]["input_tokens"]["complete_total"] == 100 + + +def test_sibling_pre_shutdown_status_cannot_override_final_result( + tmp_path, result, record_lines +): + result["status"] = "failed" + path = tmp_path / "run-result.json" + path.write_text(json.dumps(result)) + (tmp_path / "run-record.jsonl").write_text( + "".join(json.dumps(row) + "\n" for row in record_lines) + ) + summary = read_evidence(path) + assert ( + summary["status"] == "failed" + and summary["execution_interval"]["elapsed_ms"] == 123 + ) + + +def test_sibling_symlink_is_not_followed(tmp_path, result): + path = tmp_path / "run-result.json" + path.write_text(json.dumps(result)) + (tmp_path / "run-record.jsonl").symlink_to(tmp_path / "sensitive-not-a-record.json") + summary = read_evidence(path) + assert "execution_interval" not in summary and "record_error" not in summary + + +def test_failed_result_shows_structured_error_without_losing_usage_or_dumping_message( + result, +): + result["status"] = "failed" + result["error"] = { + "stage": "invoke", + "code": "adapter_failure", + "retryable": False, + "message": "private model request details", + } + summary = summarize_result(result) + text = render_result_summary(summary) + assert summary["execution_error"] == { + "stage": "invoke", + "code": "adapter_failure", + "retryable": False, + } + assert "adapter_failure" in text + assert "private model request details" not in text + assert "input 100" in text + + +def test_foreign_runtime_snapshot_is_rejected_without_erasing_usage(result): + result["resource_snapshot"] = { + "runtime_id": "other-runtime", + "started_at": "2026-10-21T00:00:00Z", + "observed_at": "2026-10-21T00:00:01Z", + "resident_memory_bytes": 4096, + "process_count": 1, + "cpu_time_seconds": 0.25, + } + summary = summarize_result(result) + assert summary["resource_snapshot"] is None + assert summary["resource_error"] == "invalid_resource_snapshot" + assert summary["usage"]["dimensions"]["input_tokens"]["complete_total"] == 100 + assert "Runtime process-tree snapshot" not in render_result_summary(summary) + + +@pytest.mark.parametrize("saved", [False, True]) +def test_recording_failure_remains_visible_without_changing_execution( + tmp_path, result, saved +): + result["events"] = [ + { + "kind": "record_write_failed", + "message": "sensitive untrusted path\nINJECTED", + "metadata": {"artifact": "run-record.jsonl"}, + } + ] + if saved: + path = tmp_path / "run-result.json" + path.write_text(json.dumps(result)) + summary = read_result(path) + else: + summary = summarize_result(result) + assert summary["recording_failed"] is True + assert summary["status"] == "succeeded" + assert summary["usage"]["dimensions"]["input_tokens"]["complete_total"] == 100 + text = render_result_summary(summary) + assert "Recording failed" in text + assert "record_write_failed" in text + assert "sensitive untrusted path" not in text + assert "INJECTED" not in text + + +def test_unrelated_event_does_not_report_a_recording_failure(result): + result["events"] = [{"kind": "model_warning", "message": "record_write_failed"}] + summary = summarize_result(result) + assert summary["recording_failed"] is False + assert "Recording failed" not in render_result_summary(summary) + + +def test_legacy_record_does_not_invent_exchange_or_resource_observations( + tmp_path, record_lines +): + path = tmp_path / "run-record.jsonl" + write_record(path, record_lines) + summary = read_evidence(path) + assert summary["elapsed_ms"] == 123 + assert summary["exchange_elapsed_ms"] is None + assert summary["resource_window"] is None + + +def test_core_accounting_failure_event_is_visible_without_replacing_execution(result): + result.pop("model_usage") + result["usage"] = {"input_tokens": 100} + result["events"] = [{"kind": "model_usage_invalid"}] + summary = summarize_result(result) + assert summary["status"] == "succeeded" + assert summary["model_usage"] is None + assert summary["accounting_error"] == "invalid_model_usage" + assert summary["legacy_usage"] == {"input_tokens": 100} + assert "accounting was invalid" in render_result_summary(summary) + + +def test_overview_separates_token_and_cost_coverage_and_keeps_exact_json(result): + summary = summarize_result(result) + text = render_result_summary(summary) + assert ( + "Token coverage: input/output/total complete; cache breakdown complete" in text + ) + assert "Tokens: input 100; output 2; total 102" in text + assert "Provider-reported cost: $0.000000010 (complete)" in text + assert "Catalog estimate: unavailable" in text + assert "Invocation:" not in text + assert summary["usage"]["dimensions"]["reported_cost"]["complete_total"] == 10 + + +def test_overview_names_missing_token_dimensions_without_hiding_complete_output(result): + usage = result["model_usage"] + usage["input_tokens"] = usage["total_tokens"] = None + usage["observed"] = {"input_tokens": 50} + usage["gaps"].append( + {"dimensions": ["input_tokens", "total_tokens"], "reason": "unfinished_work"} + ) + text = render_result_summary(summarize_result(result)) + assert "input 50 (observed, incomplete)" in text + assert "total unavailable" in text + assert "output 2 (complete)" in text + assert "Token coverage: input/output/total incomplete" in text + assert "unfinished at return" in text + + +def test_accounted_models_are_not_presented_as_served_model_provenance(result): + summary = summarize_result(result) + summary["model_usage"]["models"] = [ + {"provider": "openai", "model": "main-model"}, + {"provider": "anthropic", "model": "helper\x1b[2J"}, + ] + text = render_result_summary(summary) + assert 'Accounted models: "openai/main-model", "anthropic/helper\\u001b[2J"' in text + assert '"served":' not in text + assert "\x1b" not in text + + +def test_reporting_cli_details_and_json_keep_independent_output_modes( + tmp_path, result, monkeypatch, capsys +): + from nemo_fabric.reporting import main + + path = tmp_path / "run-result.json" + path.write_text(json.dumps(result)) + monkeypatch.setattr("sys.argv", ["reporting", str(path), "--details"]) + main() + assert "Invocation:" in capsys.readouterr().out + monkeypatch.setattr("sys.argv", ["reporting", str(path), "--details", "--json"]) + main() + assert json.loads(capsys.readouterr().out)["status"] == "succeeded" + + +def test_missing_cache_breakdown_does_not_label_complete_token_totals_incomplete( + result, +): + usage = result["model_usage"] + usage["cached_input_tokens"] = usage["cache_write_input_tokens"] = None + usage["gaps"].append( + { + "dimensions": ["cached_input_tokens", "cache_write_input_tokens"], + "reason": "missing_usage", + } + ) + text = render_result_summary(summarize_result(result)) + assert "Tokens: input 100; output 2; total 102" in text + assert ( + "Token coverage: input/output/total complete; cache breakdown unavailable" + in text + ) + assert "Cache (included in input): reads unavailable; writes unavailable" in text + + +def test_catalog_identity_is_visible_without_dumping_the_basis(result): + 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, + } + result["model_usage"]["cost"]["pricing"] = pricing + result["model_usage"]["cost"]["estimated"] = { + "amount_nano_usd": 280000, + "observed_nano_usd": 280000, + } + result["model_usage"]["gaps"] = [] + summary = summarize_result(result) + overview = render_result_summary(summary) + detail = render_result_summary(summary, details=True) + assert "Catalog estimate: $0.000280 (complete)" in overview + assert "Pricing basis: catalog; bundled aaaaaaaaaaaa" in overview + assert "applicable_tariff_no_missing_counts_v1" not in overview + assert "applicable_tariff_no_missing_counts_v1" in detail + assert summary["usage"]["pricing"] == pricing + + +def write_record(path, rows): + path.write_text("".join(json.dumps(row) + "\n" for row in rows)) + + +@pytest.mark.parametrize("partial", [False, True]) +def test_record_projection_retains_normalized_accounting_without_full_result( + tmp_path, record_lines, result, partial +): + usage = result["model_usage"] + if partial: + usage["input_tokens"] = usage["total_tokens"] = None + usage["observed"] = {"input_tokens": 50} + usage["gaps"].append( + { + "dimensions": ["input_tokens", "total_tokens"], + "reason": "unfinished_work", + } + ) + record_lines[1]["model_usage"] = usage + path = tmp_path / "run-record.jsonl" + write_record(path, record_lines) + summary = read_evidence(path) + assert summary["outcome_boundary"] == "invocation" + assert summary["model_usage"]["output_tokens"] == 2 + dimension = summary["usage"]["dimensions"]["input_tokens"] + assert dimension["complete_total"] == (None if partial else 100) + assert dimension["observed_sum"] == (50 if partial else 100) + from nemo_fabric.reporting import render_invocation_record_summary + + text = render_invocation_record_summary(summary) + assert "input 50 (observed, incomplete)" in text if partial else "input 100" in text + assert "Invocation outcome before runtime shutdown: succeeded" in text + assert not (tmp_path / "run-result.json").exists() + + +def test_invalid_record_accounting_keeps_valid_timing(tmp_path, record_lines, result): + record_lines[1]["model_usage"] = result["model_usage"] + record_lines[1]["model_usage"]["input_tokens"] = -1 + path = tmp_path / "run-record.jsonl" + write_record(path, record_lines) + summary = read_evidence(path) + assert summary["accounting_error"] == "invalid_model_usage" + assert summary["elapsed_ms"] == 123 + assert summary["status"] == "succeeded" + assert summary["usage"] is None + + +def resource_snapshot( + cpu, + *, + observed="2026-10-21T00:00:01Z", + started="2026-10-21T00:00:00Z", + runtime="runtime", + rss=10, +): + return { + "runtime_id": runtime, + "started_at": started, + "observed_at": observed, + "resident_memory_bytes": rss, + "process_count": 1, + "cpu_time_seconds": cpu, + } + + +def invocation_stats(before, after): + return { + "collected_groups": ["process"], + "elapsed_seconds": 0.123, + "before": {"status": "available", "snapshot": before}, + "after": {"status": "available", "snapshot": after}, + "cpu_time_seconds_estimate": after["cpu_time_seconds"] + - before["cpu_time_seconds"], + } + + +def test_ordinary_full_result_preserves_system_stats_without_recording(result): + result["system_stats"] = invocation_stats( + resource_snapshot(1), resource_snapshot(3) + ) + summary = summarize_result(result) + assert summary["system_stats"]["before"] == result["system_stats"]["before"] + assert summary["system_stats"]["after"] == result["system_stats"]["after"] + assert summary["resource_window"]["cpu_counter_change_seconds"] == 2 + text = render_result_summary(summary, details=True) + assert "Elapsed (adapter exchange): 123 ms" in text + assert "not invocation CPU" in text + assert "System stats collected groups" in text + + +def test_process_stats_not_collected_never_implies_zero(result): + result["system_stats"] = { + "collected_groups": ["cgroup.cpu"], + "elapsed_seconds": 0.123, + "before": {"status": "not_collected"}, + "after": {"status": "not_collected"}, + } + summary = summarize_result(result) + assert summary["system_stats"] is not None + assert summary["resource_window"]["cpu_counter_change_seconds"] is None + assert summary["resource_window"]["delta_reason"] == "not_collected" + + +def test_invalid_system_stats_preserve_execution_and_accounting(result): + result["system_stats"] = {"elapsed_seconds": -1} + summary = summarize_result(result) + assert summary["system_stats"] is None + assert summary["system_stats_error"] == "invalid_system_stats" + assert summary["status"] == "succeeded" + assert summary["usage"]["dimensions"]["input_tokens"]["complete_total"] == 100 + + +def test_details_retain_resource_scope_while_overview_stays_compact(result): + result["system_stats"] = invocation_stats( + resource_snapshot(1), resource_snapshot(3) + ) + summary = summarize_result(result) + overview = render_result_summary(summary) + detail = render_result_summary(summary, details=True) + assert "Elapsed (adapter exchange): 123 ms" in overview + assert "process-tree" not in overview + assert "not invocation CPU" in detail + assert "not a peak" in detail + assert "Invocation:" in detail + assert "System stats collected groups" in detail + assert "bytes" not in detail # Human-readable memory uses binary units. + + +@pytest.mark.parametrize("stage", ["start", "invoke"]) +def test_request_failure_has_no_fabricated_invocation_or_zero_spend(tmp_path, stage): + path = tmp_path / "request-end.json" + path.write_text( + json.dumps( + { + "schema": "nemo.fabric.request_record", + "version": "v1alpha1", + "request_id": "request", + "time": "2026-10-21T00:00:00.000Z", + "kind": "end", + "state": "no_result", + "stage": stage, + } + ) + ) + summary = read_evidence(path) + assert summary["state"] == "no_result" + assert "runtime_id" not in summary and "usage" not in summary + assert "Usage and spend are unknown" in render_request_summary(summary) + + +def test_start_record_does_not_claim_the_dispatch_finished(tmp_path): + path = tmp_path / "request-start.json" + path.write_text( + json.dumps( + { + "schema": "nemo.fabric.request_record", + "version": "v1alpha1", + "request_id": "request", + "time": "2026-10-21T00:00:00.000Z", + "kind": "start", + } + ) + ) + summary = read_evidence(path) + assert summary["state"] == "registered" + assert "no outcome" in render_request_summary(summary) + + +def test_request_result_reference_is_not_followed_or_treated_as_saved(tmp_path): + path = tmp_path / "request-end.json" + path.write_text( + json.dumps( + { + "schema": "nemo.fabric.request_record", + "version": "v1alpha1", + "request_id": "request", + "time": "2026-10-21T00:00:00.000Z", + "kind": "end", + "state": "returned", + "runtime_id": "rt", + "invocation_id": "inv", + "result": "../../sensitive.json", + } + ) + ) + summary = read_evidence(path) + assert summary["result_reference"] == "../../sensitive.json" + assert "does not confirm" in render_request_summary(summary) diff --git a/tests/python/test_model_usage.py b/tests/python/test_model_usage.py new file mode 100644 index 000000000..4c731b546 --- /dev/null +++ b/tests/python/test_model_usage.py @@ -0,0 +1,537 @@ +# 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 diff --git a/tests/python/test_recording.py b/tests/python/test_recording.py new file mode 100644 index 000000000..92af558b6 --- /dev/null +++ b/tests/python/test_recording.py @@ -0,0 +1,135 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Recording activation delegates finalized one-shot evidence to shared core.""" + +import asyncio +import json +from threading import Event +from unittest.mock import MagicMock + +import pytest +from pydantic import ValidationError + +from nemo_fabric import Fabric, RecordConfig +from nemo_fabric.errors import FabricRuntimeError +from nemo_fabric.runtime import _run_native_lifecycle + + +@pytest.fixture(name="recorded_plan") +def recorded_plan_fixture(tmp_path): + return { + "base_dir": str(tmp_path), + "config": {"runtime": {"record": {"enabled": True}, "artifacts": "runs"}}, + } + + +async def test_recorded_one_shot_uses_shared_core_finalization(recorded_plan): + returned = {"request_id": "req", "status": "failed", "error": {"stage": "stop"}} + native = MagicMock( + spec=["run_plan", "start_runtime", "invoke_runtime", "stop_runtime"] + ) + native.run_plan.return_value = json.dumps(returned) + request = {"request_id": "req", "input": "custom task"} + assert await _run_native_lifecycle(native, recorded_plan, request) == returned + plan_json, request_json = native.run_plan.call_args.args + assert json.loads(plan_json) == recorded_plan + assert json.loads(request_json) == request + native.start_runtime.assert_not_called() + native.invoke_runtime.assert_not_called() + native.stop_runtime.assert_not_called() + + +async def test_recorded_start_failure_exposes_evidence_location( + recorded_plan, tmp_path +): + native = MagicMock(spec=["run_plan"]) + native.run_plan.side_effect = RuntimeError("original startup error") + with pytest.raises(FabricRuntimeError) as caught: + await _run_native_lifecycle(native, recorded_plan, {"request_id": "req"}) + assert str(caught.value) == "original startup error" + assert caught.value.details["record_root"] == str(tmp_path / "runs/.fabric") + assert caught.value.__notes__ == [ + f"Run evidence location: {tmp_path / 'runs/.fabric'}" + ] + assert caught.value.__cause__ is native.run_plan.side_effect + + +async def test_recorded_failure_preserves_existing_structured_error_identity( + recorded_plan, +): + original = FabricRuntimeError("original", stage="start", code="host_error") + native = MagicMock(spec=["run_plan"]) + native.run_plan.side_effect = original + with pytest.raises(FabricRuntimeError) as caught: + await _run_native_lifecycle(native, recorded_plan, {"request_id": "req"}) + assert caught.value is original + assert caught.value.stage == "start" + assert caught.value.code == "host_error" + assert caught.value.__cause__ is None + + +@pytest.mark.parametrize("value", [1, "true", None]) +def test_recording_enabled_requires_boolean(value): + with pytest.raises(ValidationError): + RecordConfig(enabled=value) + + +def test_recording_rejects_unrecognized_settings(): + with pytest.raises(ValidationError): + RecordConfig(enabled=True, retries=2) + + +@pytest.mark.parametrize("stage", ["plan", "prepare", "start", "invoke", "stop"]) +async def test_recorded_failure_preserves_native_lifecycle_stage(recorded_plan, stage): + original = RuntimeError("native lifecycle error") + original.fabric_stage = stage + native = MagicMock(spec=["run_plan"]) + native.run_plan.side_effect = original + with pytest.raises(FabricRuntimeError) as caught: + await _run_native_lifecycle(native, recorded_plan, {"request_id": "req"}) + assert caught.value.stage == stage + assert caught.value.__cause__ is original + + +async def test_recorded_process_launch_failure_retains_start_stage( + blocking_adapter_config, tmp_path +): + descriptor_path = tmp_path / "blocking.fabric-adapter.json" + descriptor = json.loads(descriptor_path.read_text()) + descriptor["runner"]["command"] = str(tmp_path / "missing-runner") + descriptor_path.write_text(json.dumps(descriptor)) + blocking_adapter_config.runtime.record = RecordConfig(enabled=True) + + with pytest.raises(FabricRuntimeError) as caught: + await Fabric().run(blocking_adapter_config, base_dir=tmp_path, input="hello") + assert caught.value.stage == "start" + + +async def test_recorded_cancellation_keeps_cancellation_and_evidence_location( + recorded_plan, tmp_path +): + loop = asyncio.get_running_loop() + started = asyncio.Event() + release = Event() + native = MagicMock(spec=["run_plan"]) + + def run(*args): + loop.call_soon_threadsafe(started.set) + assert release.wait(timeout=5), "test did not release the native worker" + return json.dumps({"status": "succeeded"}) + + native.run_plan.side_effect = run + task = asyncio.create_task( + _run_native_lifecycle(native, recorded_plan, {"request_id": "req"}) + ) + await started.wait() + task.cancel("caller cancellation") + release.set() + with pytest.raises(asyncio.CancelledError) as caught: + await task + assert caught.value.args == ("caller cancellation",) + assert caught.value.__notes__ == [ + f"Run evidence location: {tmp_path / 'runs/.fabric'}" + ] + native.run_plan.assert_called_once() diff --git a/tests/python/test_report_format.py b/tests/python/test_report_format.py new file mode 100644 index 000000000..c1215a8c7 --- /dev/null +++ b/tests/python/test_report_format.py @@ -0,0 +1,44 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import pytest + +from nemo_fabric._report_format import format_bytes, format_duration_ms, format_money + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (None, "unavailable"), + (0, "$0.00"), + (1, "$0.000000001"), + (280000, "$0.000280"), + (1234567890, "$1.23"), + (999999999, "$1.000"), + (18446744073709551615, "$18446744073.71"), + ], +) +def test_money_preserves_positive_spend_with_readable_precision(value, expected): + assert format_money(value) == expected + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (0, "0 ms"), + (0.25, "0.25 ms"), + (6000, "6 s"), + (61000, "1 min 1 s"), + (3661000, "1 h 1 min 1 s"), + ], +) +def test_duration_uses_explicit_readable_units(value, expected): + assert format_duration_ms(value) == expected + + +@pytest.mark.parametrize( + ("value", "expected"), + [(0, "0 B"), (1024, "1.0 KiB"), (21086208, "20.1 MiB")], +) +def test_memory_uses_binary_units(value, expected): + assert format_bytes(value) == expected diff --git a/tests/python/test_system_stats.py b/tests/python/test_system_stats.py index 3b89672e5..950df16a3 100644 --- a/tests/python/test_system_stats.py +++ b/tests/python/test_system_stats.py @@ -85,7 +85,8 @@ async def test_sdk_returns_typed_samples_and_artifacts_without_relay_or_recordin sampled.resident_memory_bytes.sampled_peak >= sampled.resident_memory_bytes.final_value ) - assert 0 < sampled.resident_memory_bytes.covered_fraction <= 1 + # Gaps above twice the sampling interval do not contribute coverage. + assert 0 <= sampled.resident_memory_bytes.covered_fraction <= 1 assert ( RunResult.from_mapping(result.to_mapping()).system_stats == result.system_stats ) diff --git a/tests/python/test_usage_reducer_parity.py b/tests/python/test_usage_reducer_parity.py new file mode 100644 index 000000000..9ac43aa9f --- /dev/null +++ b/tests/python/test_usage_reducer_parity.py @@ -0,0 +1,169 @@ +# 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 +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(): + 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 diff --git a/tests/scripts/test_openshell_launcher.py b/tests/scripts/test_openshell_launcher.py index 317b8052d..f54157496 100644 --- a/tests/scripts/test_openshell_launcher.py +++ b/tests/scripts/test_openshell_launcher.py @@ -12,6 +12,7 @@ import json import os from pathlib import Path +import shlex import shutil import socket import subprocess @@ -164,7 +165,7 @@ def _operations(root: Path) -> list[dict]: def _run(launcher, workload, env): script, _, base_env = launcher return subprocess.run( - ["bash", str(script), str(workload), "--input", "words with spaces"], + ["/bin/bash", str(script), str(workload), "--input", "words with spaces"], env={**base_env, **env}, text=True, capture_output=True, @@ -172,7 +173,22 @@ def _run(launcher, workload, env): ) -def test_generic_workload_gets_ownership_and_consumer_args(launcher): +@pytest.mark.parametrize( + ("model_env_args", "server_env_args"), + [ + pytest.param([], None, id="default-server-env"), + pytest.param([], [], id="no-env"), + pytest.param(["--env", "MODEL_HINT=words with spaces"], [], id="no-server-env"), + pytest.param( + ["--env", "MODEL_HINT=words with spaces"], + ["--env", "SERVER_HINT=words with spaces"], + id="quoted-env", + ), + ], +) +def test_generic_workload_gets_ownership_and_consumer_args( + launcher, model_env_args, server_env_args +): _, root, _ = launcher workload = root / "third-workload" workload.mkdir() @@ -185,6 +201,12 @@ def test_generic_workload_gets_ownership_and_consumer_args(launcher): stage_workload_image() { :; } run_workload_consumer() { local mode="$1"; shift; uv run --no-sync consumer "$mode" "$@"; } """ + + f"MODEL_ENV_ARGS=({shlex.join(model_env_args)})\n" + + ( + f"SERVER_ENV_ARGS=({shlex.join(server_env_args)})\n" + if server_env_args is not None + else "" + ) ) completed = _run(launcher, hook, {"OPENSHELL_EXAMPLE_MODE": "both"}) @@ -195,6 +217,26 @@ def test_generic_workload_gets_ownership_and_consumer_args(launcher): assert all(call["args"][-2:] == ["--input", "words with spaces"] for call in calls) assert all(call["provider"].endswith("/fabric-environment-openshell") for call in calls) assert not (root / "sandbox-state.json").exists() + create = next( + operation["args"] + for operation in _operations(root) + if operation["command"] == "openshell" + and "sandbox" in operation["args"] + and "create" in operation["args"] + ) + expected_server_env = ( + server_env_args + if server_env_args is not None + else [ + "--env", + "ADAPTER_PYTHON=/opt/fabric/bin/python", + "--env", + "TMPDIR=/sandbox/tmp", + ] + ) + assert create[create.index("--policy") + 2 : create.index("--detach")] == ( + model_env_args + expected_server_env + ) @pytest.mark.parametrize("mode", ["caller_owned", "fabric_owned"]) @@ -343,7 +385,7 @@ def test_invalid_platform_or_mode_stops_before_building(launcher, environment, m "stage_workload_image() { :; }\nrun_workload_consumer() { :; }\n" ) completed = subprocess.run( - ["bash", str(script), str(workload)], + ["/bin/bash", str(script), str(workload)], env={**base_env, **environment}, text=True, capture_output=True,