The gap
@netscript/ai's TanStack bridge narrows TokenUsage to three flat fields on the way through, silently discarding every nested detail object — for every provider.
src/providers/tanstack-bridge.ts (0.0.6), the module its own doc calls "the single anti-corruption boundary between the framework's owned chat vocabulary and TanStack AI":
// line 243
case EventType.RUN_FINISHED: {
return { type: 'finish', usage: toOwnedUsage(chunk.usage), finishReason: ... };
}
// line 352
/** Map TanStack real token usage onto the owned `Usage` core fields. */
function toOwnedUsage(
usage: { promptTokens: number; completionTokens: number; totalTokens: number } | undefined,
): Usage | undefined {
if (!usage) return undefined;
return {
promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens,
totalTokens: usage.totalTokens,
};
}
The parameter type is narrowed to three fields, so these are dropped with no type error:
promptTokensDetails — cachedTokens (cache read) and cacheWriteTokens (cache write)
completionTokensDetails — reasoningTokens
cost, costDetails — the provider's own reported cost
providerUsageDetails
Why this matters: the contract already promises these
This is not a missing feature — NetScript's OWN usage contract (src/contracts/usage.ts) declares every one of them:
readonly cachedTokens?: number;
readonly cacheWriteTokens?: number;
readonly reasoningTokens?: number;
readonly cost?: number;
readonly costDetails?: UsageCostBreakdown;
So consumers type against fields that are structurally guaranteed to be undefined. The type says the data may be there; the mapper guarantees it never is. That combination is worse than not declaring them — it is silently unimplemented, and it type-checks.
Measured impact
Building a cost-analytics surface on eis-chat (rickylabs/eis-chat#237), we capture per-turn usage on the settled RUN_FINISHED chunk.
MEASURED, 2026-08-19, across all channel databases: cache_read_tokens, cache_write_tokens and cost_usd are non-NULL in 0 of 210 assistant turns.
The smoking gun is reasoning_tokens: it is NULL on a gpt-5.6-sol turn that emitted 2429 output tokens. A reasoning model does not produce 2429 output tokens with zero reasoning tokens. The flat scalars (promptTokens, completionTokens) landed correctly on the same turns — 5926 and 7895 prompt tokens, recorded fine. Only the nested objects are missing.
That is a shape-level strip, not provider silence.
The adapters underneath do populate these:
| adapter |
cachedTokens |
cacheWriteTokens |
source |
@tanstack/openai-base@0.9.6 |
yes |
n/a |
usage.ts:100, from input_tokens_details.cached_tokens |
@tanstack/ai-anthropic@0.15.13 |
yes |
yes |
usage.ts:52-57, from cache_read_input_tokens / cache_creation_input_tokens |
So the data exists at the adapter boundary and is destroyed one layer above it.
Consequence for consumers
Prompt-cache accounting is the single highest-value number on a cost surface — cache reads are billed at a small fraction of fresh input, and cache writes at a premium, so the hit rate is the difference between a cheap channel and an expensive one. With this strip in place, no consumer of @netscript/ai can:
- report a cache hit rate at all;
- distinguish a cache read from a cache write (they are priced ~10x apart, so one combined number is uncostable);
- use the provider's own
cost, which already accounts for cache discounts and gateway markup that a local price table cannot know;
- report reasoning-token spend.
Every consumer that needs any of this has to fork the bridge or bypass the framework's chat vocabulary entirely.
Suggested fix
Widen the parameter to the real TokenUsage and pass the detail objects through. The owned Usage contract already has the shape, so this is a mapper change, not a contract change:
function toOwnedUsage(usage: TokenUsage | undefined): Usage | undefined {
if (!usage) return undefined;
return {
promptTokens: usage.promptTokens,
completionTokens: usage.completionTokens,
totalTokens: usage.totalTokens,
...(usage.promptTokensDetails ? { promptTokensDetails: usage.promptTokensDetails } : {}),
...(usage.completionTokensDetails
? { completionTokensDetails: usage.completionTokensDetails }
: {}),
...(usage.cost == null ? {} : { cost: usage.cost }),
...(usage.costDetails ? { costDetails: usage.costDetails } : {}),
};
}
Two details worth preserving while you are in there:
- Any multi-step accumulator must merge the details, not drop them. We had the identical bug in our own accumulator (
addUsage), and fixing only one side changes nothing. Ours is fixed in rickylabs/eis-chat#241 if a reference is useful.
- Do not use truthiness guards on the numbers.
details?.cachedTokens ? {...} : {} discards a genuine measured 0, which makes "the cache was checked and missed" indistinguishable from "we never measured". For a cost surface those mean opposite things. Guard on != null. (Both openai-base@0.9.6:38 and ai-anthropic@0.15.13:56-57 currently use truthiness guards, so this is worth raising with them too.)
Related, but separate
@tanstack/ai-openrouter (0.15.6 and 0.15.11) sends only streamOptions.includeUsage, never OpenRouter's own usage: { include: true } accounting flag — so cost and the cached-token breakdown never arrive for that lane regardless of this issue. Filing separately upstream; noting it here so a fix to the bridge is not expected to light up OpenRouter on its own.
The gap
@netscript/ai's TanStack bridge narrowsTokenUsageto three flat fields on the way through, silently discarding every nested detail object — for every provider.src/providers/tanstack-bridge.ts(0.0.6), the module its own doc calls "the single anti-corruption boundary between the framework's owned chat vocabulary and TanStack AI":The parameter type is narrowed to three fields, so these are dropped with no type error:
promptTokensDetails—cachedTokens(cache read) andcacheWriteTokens(cache write)completionTokensDetails—reasoningTokenscost,costDetails— the provider's own reported costproviderUsageDetailsWhy this matters: the contract already promises these
This is not a missing feature — NetScript's OWN usage contract (
src/contracts/usage.ts) declares every one of them:So consumers type against fields that are structurally guaranteed to be
undefined. The type says the data may be there; the mapper guarantees it never is. That combination is worse than not declaring them — it is silently unimplemented, and it type-checks.Measured impact
Building a cost-analytics surface on
eis-chat(rickylabs/eis-chat#237), we capture per-turn usage on the settledRUN_FINISHEDchunk.MEASURED, 2026-08-19, across all channel databases:cache_read_tokens,cache_write_tokensandcost_usdare non-NULL in 0 of 210 assistant turns.The smoking gun is
reasoning_tokens: it isNULLon agpt-5.6-solturn that emitted 2429 output tokens. A reasoning model does not produce 2429 output tokens with zero reasoning tokens. The flat scalars (promptTokens,completionTokens) landed correctly on the same turns — 5926 and 7895 prompt tokens, recorded fine. Only the nested objects are missing.That is a shape-level strip, not provider silence.
The adapters underneath do populate these:
cachedTokenscacheWriteTokens@tanstack/openai-base@0.9.6usage.ts:100, frominput_tokens_details.cached_tokens@tanstack/ai-anthropic@0.15.13usage.ts:52-57, fromcache_read_input_tokens/cache_creation_input_tokensSo the data exists at the adapter boundary and is destroyed one layer above it.
Consequence for consumers
Prompt-cache accounting is the single highest-value number on a cost surface — cache reads are billed at a small fraction of fresh input, and cache writes at a premium, so the hit rate is the difference between a cheap channel and an expensive one. With this strip in place, no consumer of
@netscript/aican:cost, which already accounts for cache discounts and gateway markup that a local price table cannot know;Every consumer that needs any of this has to fork the bridge or bypass the framework's chat vocabulary entirely.
Suggested fix
Widen the parameter to the real
TokenUsageand pass the detail objects through. The ownedUsagecontract already has the shape, so this is a mapper change, not a contract change:Two details worth preserving while you are in there:
addUsage), and fixing only one side changes nothing. Ours is fixed in rickylabs/eis-chat#241 if a reference is useful.details?.cachedTokens ? {...} : {}discards a genuine measured0, which makes "the cache was checked and missed" indistinguishable from "we never measured". For a cost surface those mean opposite things. Guard on!= null. (Bothopenai-base@0.9.6:38andai-anthropic@0.15.13:56-57currently use truthiness guards, so this is worth raising with them too.)Related, but separate
@tanstack/ai-openrouter(0.15.6 and 0.15.11) sends onlystreamOptions.includeUsage, never OpenRouter's ownusage: { include: true }accounting flag — socostand the cached-token breakdown never arrive for that lane regardless of this issue. Filing separately upstream; noting it here so a fix to the bridge is not expected to light up OpenRouter on its own.