Skip to content

ai: tanstack-bridge drops promptTokensDetails/cost from TokenUsage — cache and cost accounting are structurally impossible #1677

Description

@rickylabs

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:

  • promptTokensDetailscachedTokens (cache read) and cacheWriteTokens (cache write)
  • completionTokensDetailsreasoningTokens
  • 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:

  1. 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.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions