Skip to content

[ChatHelper] Cumulative usage / cost tracking #29

Description

@e-marchand

Context

A single agent turn makes several completions (one per tool-call round). Each result already
exposes token usage, but the helper throws it away between iterations, so the caller can't see
what a turn actually cost. We want to accumulate usage across the loop, optionally translate
it to money, and let item 1's budget become token/cost-based instead of only step-based.

What exists today:

  • OpenAIResult.usage (OpenAIResult.4dm) returns
    body.usage{prompt_tokens, completion_tokens, total_tokens, prompt_tokens_details, completion_tokens_details}.
  • The streamed result's usage (OpenAIChatCompletionsStreamResult.4dm)
    returns data.usage, but only when stream_options: {include_usage: True} is set.
  • No pricing data anywhere in the repo (so cost needs a new, small price source).

Where it plugs in

_manageResponse (OpenAIChatHelper.4dm)
processes every successful completion — the no-stream success branch and the stream-terminal
branch. That is the one place to fold each completion's usage into running totals, before the
loop decides whether to continue.

Changes — OpenAIChatHelper

Accumulators (properties)

  • property usage : Object — totals for the current turn; reset in prompt().
  • property totalUsage : Object — totals across the helper's lifetime; reset in reset().

Shape mirrors the API: {prompt_tokens; completion_tokens; total_tokens} (+ optional
*_tokens_details merged). Initialise to zeros so they're never null.

_accumulateUsage($result) — guard $result.usage = Null (provider didn't report / mid-stream
without include_usage); else add each numeric field into both usage and totalUsage, and
deep-add the *_details sub-objects. Call it in _manageResponse right after a successful
completion is pushed (both branches), before the continue/terminal decision.

Resetprompt() zeroes usage; reset() zeroes both usage and totalUsage.

Cost (optional layer on top of usage)

Usage (tokens) needs no pricing. Cost does, so introduce a small, overridable price source:

  • A bundled model→price map, loaded the same way providers are
    (_AIProvidersObject() in OpenAIProviders.4dm
    → add a sibling _AIModelPricing() returning { "<model>": {input: <usd per 1M>, output: <usd per 1M>} }),
    plus an instance override (client.pricing / helper.pricing).
  • _estimateCost($usage; $model) : Realprompt_tokens/1e6*input + completion_tokens/1e6*output;
    returns 0 when the model is unknown (and records it, so missing prices are visible rather than
    silently wrong).
  • property cost : Real (turn) and property totalCost : Real (lifetime), accumulated alongside
    usage using the turn's resolved model.

Token / cost budget (ties back to item 1)

  • Add maxTokens : Integer and/or maxCost : Real (0 = unlimited, matching maxIterations).
  • _reachedBudget() : Boolean — true when maxTokens>0 && usage.total_tokens>=maxTokens, or
    maxCost>0 && cost>=maxCost.
  • Check it at the same boundary as _reachedMaxIterations() in _manageResponse (cancel >
    budget > step, once item 3 lands). On trip, stop the loop.
  • Open decision: the stop reason. Reuse "max_iterations" (it already means "the turn hit its
    budget") or introduce a distinct "budget_exceeded". Recommendation: distinct value so callers
    can tell step-cap from token/cost-cap apart; document it in the stopReason table.

Tests — offline, in test_openai_chat_helper_agent.4dm (per-check ASSERT)

No network needed — feed fake results and call the accumulators directly:

  • Build cs.OpenAIChatCompletionsResult.new() with
    request:={response:{status:200; body:{usage:{prompt_tokens:10; completion_tokens:5; total_tokens:15}}}};
    _accumulateUsage once → usage.total_tokens=15; twice → 30; totalUsage tracks both.
  • Result with usage=Null → accumulators unchanged (no crash).
  • prompt()-style reset zeroes usage but not totalUsage; reset() zeroes both.
  • _estimateCost with a stub price map → expected dollars; unknown model → 0 and recorded.
  • _reachedBudget() boundaries for maxTokens and maxCost (under / at / over; 0 = unlimited).

Notes

  • Streaming totals require stream_options: {include_usage: True}
    (OpenAIChatCompletionsParameters);
    document that usage is otherwise empty for streams.
  • Some providers omit usage entirely — accumulators must treat that as zero, not error.
  • ACP has no usage field today; this is internal/budget metadata that an adapter can surface.

Open decisions

  1. Stop reason for a token/cost cap: reuse "max_iterations" vs new "budget_exceeded"
    (recommended: distinct).
  2. Pricing source of truth: bundled JSON only, instance override only, or both (recommended: both,
    override wins).
  3. Whether totalUsage/totalCost survive reset() (recommended: reset clears them; add a
    separate getter if lifetime-across-resets is ever needed).

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions