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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/contribute-adapter/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ 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; use the shared `cached_input_tokens` and `input_tokens_include_cache` fields instead of consumer-specific parsing of native output.
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 `instructions.system`, keep `config.system_instruction_modes`, planning
behavior, direct adapter validation, and target-native composition synchronized.
Expand Down
6 changes: 3 additions & 3 deletions adapters/typescript/scripts/check-install.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -327,9 +327,9 @@ try {
"--no-audit",
"--no-fund",
"--package-lock=false",
"@earendil-works/pi-ai@1.0.3",
"@earendil-works/pi-coding-agent@1.0.3",
"@earendil-works/pi-mcp@1.0.3",
"@earendil-works/pi-ai@1.2.0",
"@earendil-works/pi-coding-agent@1.2.0",
"@earendil-works/pi-mcp@1.2.0",
],
consumerRoot,
);
Expand Down
31 changes: 31 additions & 0 deletions crates/fabric-core/src/agent_execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -436,4 +436,35 @@ mod tests {
);
}
}

#[test]
fn v1alpha2_usage_keeps_detailed_fields_in_extensions() {
let error = serde_json::from_value::<AgentUsage>(serde_json::json!({
"cache_write_input_tokens": 3
}))
.expect_err("the closed v1alpha2 usage root rejects new fields");
assert!(
error
.to_string()
.contains("unknown field `cache_write_input_tokens`")
);

let usage = serde_json::from_value::<AgentUsage>(serde_json::json!({
"extensions": {
"nemo_fabric.detailed_token_usage.v1": {
"cache_write_input_tokens": 3,
"reasoning_tokens": 4
}
}
}))
.expect("v1alpha2 usage extensions accept detailed accounting");

assert_eq!(
usage.extensions["nemo_fabric.detailed_token_usage.v1"],
serde_json::json!({
"cache_write_input_tokens": 3,
"reasoning_tokens": 4
})
);
}
}
111 changes: 110 additions & 1 deletion crates/fabric-core/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3673,7 +3673,38 @@ mod tests {
},
"usage": {
"type": "object",
"properties": {"provider": {"type": "string"}},
"properties": {
"provider": {"type": "string"},
"nemo_fabric.detailed_token_usage.v1": {
"type": "object",
"properties": {
"input_tokens": {"type": "integer", "minimum": 0},
"cached_input_tokens": {"type": "integer", "minimum": 0},
"cache_write_input_tokens": {"type": "integer", "minimum": 0},
"input_tokens_include_cache": {"type": "boolean"},
"output_tokens": {"type": "integer", "minimum": 0},
"reasoning_tokens": {"type": "integer", "minimum": 0},
"output_tokens_include_reasoning": {"type": "boolean"},
"total_tokens": {"type": "integer", "minimum": 0},
"peak_request_input_tokens": {"type": "integer", "minimum": 0},
"models": {
"type": "array",
"items": {
"type": "object",
"properties": {
"model": {"type": "string", "minLength": 1},
"provider": {"type": "string", "minLength": 1},
"input_tokens": {"type": "integer", "minimum": 0},
"reasoning_tokens": {"type": "integer", "minimum": 0}
},
"required": ["model"],
"additionalProperties": false
}
}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"artifact": {
Expand Down Expand Up @@ -3885,6 +3916,39 @@ for line in sys.stdin:
}],
"extensions": {"trace_id": "trace-1"},
}
elif MODE == "detailed_usage_result":
result = {
"status": "succeeded",
"output": output,
"usage": {
"input_tokens": 3,
"cached_input_tokens": 1,
"input_tokens_include_cache": True,
"output_tokens": 5,
"total_tokens": 8,
"cost_usd": 0.25,
"extensions": {
"provider": "fake",
"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,
"total_tokens": 37,
"peak_request_input_tokens": 18,
"models": [{
"model": "test-model",
"provider": "fake",
"input_tokens": 20,
"reasoning_tokens": 4,
}],
},
},
},
}
else:
result = {"status": "succeeded", "output": output}
response(operation, output=result)
Expand Down Expand Up @@ -4985,6 +5049,51 @@ for line in sys.stdin:
let _ = fs::remove_dir_all(root);
}

#[test]
fn local_host_preserves_detailed_usage_in_metadata() {
let (root, plan) = local_host_plan("detailed_usage_result");

let result = run_plan(&plan, RunRequest::text("usage")).expect("normalized result");

assert_eq!(result.status, RunStatus::Succeeded);
let usage = result.usage.expect("run usage");
assert_eq!(
usage,
RunUsage {
input_tokens: Some(3),
cached_input_tokens: Some(1),
input_tokens_include_cache: Some(true),
output_tokens: Some(5),
total_tokens: Some(8),
cost_usd: Some(0.25),
metadata: BTreeMap::from([
("provider".to_string(), serde_json::json!("fake")),
(
"nemo_fabric.detailed_token_usage.v1".to_string(),
serde_json::json!({
"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,
"total_tokens": 37,
"peak_request_input_tokens": 18,
"models": [{
"model": "test-model",
"provider": "fake",
"input_tokens": 20,
"reasoning_tokens": 4
}]
}),
),
]),
}
);
let _ = fs::remove_dir_all(root);
}

#[test]
fn local_host_rejects_typed_artifacts_without_runtime_root() {
let (root, mut plan) = local_host_plan("typed_result");
Expand Down
6 changes: 5 additions & 1 deletion docs/adapter-contract/tutorials/results.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ return AgentRunResult(
| `status` | Required | Reports `succeeded`, `failed`, or `cancelled`. |
| `output` | Required | Carries the primary JSON-compatible output and can be `null`. |
| `error` | Required for `failed` | Carries a stable code, safe message, retry guidance, and declared extensions. |
| `usage` | Optional | Carries invocation-local input, cached-input, output, and total token counts plus reported cost when known. |
| `usage` | Optional | Carries invocation-local input, cached-input, output, and total token counts plus reported cost when known. Descriptor-declared usage extensions can carry additional detail. |
| `artifacts` | Optional | Carries target-produced artifact references relative to the runtime artifact root. |
| `extensions` | Optional | Carries adapter-owned result data validated by the descriptor. |

Expand All @@ -77,6 +77,10 @@ arbitrary output fields.

Report only observed invocation usage. For cumulative target counters, compute the difference between successive observations; do not sum cumulative snapshots or substitute the last model response for an entire invocation. Missing and reset counters remain unknown. `cached_input_tokens` records cached prompt tokens; `input_tokens_include_cache` declares whether they are included in `input_tokens` (`True`), excluded (`False`), or unknown (absent). Keep estimates separate from `cost_usd`, and retain available usage on unsuccessful terminal results.

`fabric.adapter/v1alpha2` usage is a closed object. Do not add cache-write, reasoning, peak request, or per-model fields at its root. Put that detail in `usage.extensions["nemo_fabric.detailed_token_usage.v1"]` and declare the extension with `extension_schemas.usage` in the adapter descriptor. The detailed object repeats the normalized input, cached-input, output, and total token fields and can add `cache_write_input_tokens`, `reasoning_tokens`, `output_tokens_include_reasoning`, `peak_request_input_tokens`, and `models`. Within this object, `input_tokens_include_cache` applies to both cached reads and cache writes. Normalize `input_tokens` first if the provider includes only one component. Each model entry uses the same counters and requires a non-blank `model`; include `provider` when known.

NeMo Fabric preserves the extension at `RunResult.usage.metadata["nemo_fabric.detailed_token_usage.v1"]`. Consumers treat the detailed object as authoritative independently for each token dimension. Any of `input_tokens`, `cached_input_tokens`, `cache_write_input_tokens`, or `input_tokens_include_cache` selects the detailed input counters. Any of `output_tokens`, `reasoning_tokens`, or `output_tokens_include_reasoning` selects the detailed output counters. Consumers do not fall back to the top-level counters for a selected dimension, so the detailed and top-level views can diverge. If the selected counters do not contain enough information to determine an inclusive total, that total remains unknown even when the top-level view contains a value.

**Success Check**: `invoke` returns one typed `AgentRunResult` whose explicit
status matches the presence or absence of an error.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1170,7 +1170,7 @@ Return a detached, JSON-compatible mapping for serialization.

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.
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``.



Expand Down
2 changes: 1 addition & 1 deletion docs/sdk/python.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -893,7 +893,7 @@ if result.usage is not None:
print(result.usage.get("cost_usd"))
```

Each counter is optional: missing values are unknown, not zero. `cached_input_tokens` reports cached prompt tokens. `input_tokens_include_cache` is `True` when those tokens are already included in `input_tokens`, `False` when they are excluded, and absent when the adapter cannot establish the semantics. Consumers must not add cached tokens to an inclusive input count. `cost_usd` contains reported cost only; keep estimates separately in provider metadata rather than presenting them as measured charges.
Each counter is optional: missing values are unknown, not zero. `cached_input_tokens` reports cached prompt tokens. `input_tokens_include_cache` is `True` when those tokens are already included in `input_tokens`, `False` when they are excluded, and absent when the adapter cannot establish the semantics. Consumers must not add cached tokens to an inclusive input count. Adapters can report cache-write, reasoning, peak request, and per-model detail through the descriptor-declared `usage.extensions["nemo_fabric.detailed_token_usage.v1"]` object. NeMo Fabric preserves that object at `RunUsage.metadata["nemo_fabric.detailed_token_usage.v1"]`. Consumers treat detailed input and output independently: any detailed accounting field for a dimension makes that dimension authoritative, with no fallback to its top-level counters. The views can diverge, and an inclusive total remains unknown when the selected detailed counters do not provide enough information to calculate it. `cost_usd` contains reported cost only; keep estimates separately in provider metadata rather than presenting them as measured charges.

If NeMo Fabric cannot resolve config, start a runtime, or obtain a normalized result,
the SDK raises a `FabricError` subclass instead of returning a partial
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import shlex
import uuid
import warnings
from collections.abc import Mapping
from pathlib import Path
from pathlib import PurePosixPath
from typing import Any
Expand Down Expand Up @@ -63,6 +64,7 @@
}
HARBOR_ARTIFACT_ROOT = "/logs/agent/fabric-artifacts"
HARBOR_DEFAULT_WORKSPACE = "/testbed"
_DETAILED_TOKEN_USAGE_V1_KEY = "nemo_fabric.detailed_token_usage.v1"

try:
from harbor.agents.base import BaseAgent
Expand Down Expand Up @@ -836,6 +838,44 @@ def raise_run_failure(status: str, error: dict[str, Any] | None) -> NoReturn:
raise RuntimeError(message)


def _usage_counter(usage: Mapping[str, Any], field: str) -> int | None:
value = usage.get(field)
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
return None
return value


def _inclusive_token_count(
usage: Mapping[str, Any],
*,
primary_field: str,
included_field: str,
component_fields: tuple[str, ...],
) -> int | None:
primary = _usage_counter(usage, primary_field)
if primary is None:
return None
included = usage.get(included_field)
if included is True:
return primary
components = [_usage_counter(usage, field) for field in component_fields]
if included is False:
if any(value is None for value in components):
return None
return primary + sum(cast(int, value) for value in components)
if included is None and all(value == 0 for value in components):
return primary
return None


def _detailed_token_usage(usage: Mapping[str, Any]) -> Mapping[str, Any] | None:
metadata = usage.get("metadata")
if not isinstance(metadata, Mapping):
return None
detailed = metadata.get(_DETAILED_TOKEN_USAGE_V1_KEY)
return detailed if isinstance(detailed, Mapping) else None


def populate_context_from_result(
context: AgentContext, path: Path
) -> RunResult | FabricRunnerFailure:
Expand Down Expand Up @@ -866,24 +906,65 @@ def populate_context_from_result(
}
if result.usage is not None:
usage = result.usage
input_tokens = usage.get("input_tokens")
cached_input_tokens = usage.get("cached_input_tokens")
includes_cache = usage.get("input_tokens_include_cache")
if includes_cache is False:
input_tokens = (
input_tokens + cached_input_tokens
if input_tokens is not None and cached_input_tokens is not None
else None
detailed_usage = _detailed_token_usage(usage)
# Do not mix top-level and detailed counters within one dimension. Any
# dimension-specific detailed field selects that view, even if incomplete.
has_detailed_input = detailed_usage is not None and any(
field in detailed_usage
for field in (
"input_tokens",
"cached_input_tokens",
"cache_write_input_tokens",
"input_tokens_include_cache",
)
)
if has_detailed_input:
input_tokens = _inclusive_token_count(
cast(Mapping[str, Any], detailed_usage),
primary_field="input_tokens",
included_field="input_tokens_include_cache",
component_fields=("cached_input_tokens", "cache_write_input_tokens"),
)
elif includes_cache is None and cached_input_tokens != 0:
# Neither missing cache nor an unspecified convention proves an inclusive total.
input_tokens = None
cached_input_tokens = _usage_counter(
cast(Mapping[str, Any], detailed_usage), "cached_input_tokens"
)
else:
input_tokens = usage.get("input_tokens")
cached_input_tokens = usage.get("cached_input_tokens")
includes_cache = usage.get("input_tokens_include_cache")
if includes_cache is False:
input_tokens = (
input_tokens + cached_input_tokens
if input_tokens is not None and cached_input_tokens is not None
else None
)
elif includes_cache is None and cached_input_tokens != 0:
# Neither missing cache nor an unspecified convention proves an inclusive total.
input_tokens = None

has_detailed_output = detailed_usage is not None and any(
field in detailed_usage
for field in (
"output_tokens",
"reasoning_tokens",
"output_tokens_include_reasoning",
)
)
if has_detailed_output:
output_tokens = _inclusive_token_count(
cast(Mapping[str, Any], detailed_usage),
primary_field="output_tokens",
included_field="output_tokens_include_reasoning",
component_fields=("reasoning_tokens",),
)
else:
output_tokens = usage.get("output_tokens")
if input_tokens is not None:
context.n_input_tokens = input_tokens
if cached_input_tokens is not None:
context.n_cache_tokens = cached_input_tokens
if usage.get("output_tokens") is not None:
context.n_output_tokens = usage["output_tokens"]
if output_tokens is not None:
context.n_output_tokens = output_tokens
Comment thread
mnajafian-nv marked this conversation as resolved.
if usage.get("cost_usd") is not None:
context.cost_usd = usage["cost_usd"]
return result
Expand Down
1 change: 1 addition & 0 deletions sdk/python/nemo-fabric-runtime/src/nemo_fabric/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -1774,6 +1774,7 @@ class RunUsage(FabricMapping):
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``.
"""

input_tokens: int | None
Expand Down
Loading
Loading