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.
Reset — prompt() 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) : Real — prompt_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.
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
- Stop reason for a token/cost cap: reuse
"max_iterations" vs new "budget_exceeded"
(recommended: distinct).
- Pricing source of truth: bundled JSON only, instance override only, or both (recommended: both,
override wins).
- Whether
totalUsage/totalCost survive reset() (recommended: reset clears them; add a
separate getter if lifetime-across-resets is ever needed).
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 seewhat 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) returnsbody.usage→{prompt_tokens, completion_tokens, total_tokens, prompt_tokens_details, completion_tokens_details}.usage(OpenAIChatCompletionsStreamResult.4dm)returns
data.usage, but only whenstream_options: {include_usage: True}is set.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
usageinto running totals, before theloop decides whether to continue.
Changes —
OpenAIChatHelperAccumulators (properties)
property usage : Object— totals for the current turn; reset inprompt().property totalUsage : Object— totals across the helper's lifetime; reset inreset().Shape mirrors the API:
{prompt_tokens; completion_tokens; total_tokens}(+ optional*_tokens_detailsmerged). Initialise to zeros so they're never null._accumulateUsage($result)— guard$result.usage = Null(provider didn't report / mid-streamwithout
include_usage); else add each numeric field into bothusageandtotalUsage, anddeep-add the
*_detailssub-objects. Call it in_manageResponseright after a successfulcompletion is pushed (both branches), before the continue/terminal decision.
Reset —
prompt()zeroesusage;reset()zeroes bothusageandtotalUsage.Cost (optional layer on top of usage)
Usage (tokens) needs no pricing. Cost does, so introduce a small, overridable price source:
(
_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) : Real—prompt_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) andproperty totalCost : Real(lifetime), accumulated alongsideusage using the turn's resolved model.
Token / cost budget (ties back to item 1)
maxTokens : Integerand/ormaxCost : Real(0 = unlimited, matchingmaxIterations)._reachedBudget() : Boolean— true whenmaxTokens>0 && usage.total_tokens>=maxTokens, ormaxCost>0 && cost>=maxCost._reachedMaxIterations()in_manageResponse(cancel >budget > step, once item 3 lands). On trip, stop the loop.
"max_iterations"(it already means "the turn hit itsbudget") or introduce a distinct
"budget_exceeded". Recommendation: distinct value so callerscan 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:
cs.OpenAIChatCompletionsResult.new()withrequest:={response:{status:200; body:{usage:{prompt_tokens:10; completion_tokens:5; total_tokens:15}}}};_accumulateUsageonce →usage.total_tokens=15; twice →30;totalUsagetracks both.usage=Null→ accumulators unchanged (no crash).prompt()-style reset zeroesusagebut nottotalUsage;reset()zeroes both._estimateCostwith a stub price map → expected dollars; unknown model → 0 and recorded._reachedBudget()boundaries formaxTokensandmaxCost(under / at / over; 0 = unlimited).Notes
stream_options: {include_usage: True}(OpenAIChatCompletionsParameters);
document that usage is otherwise empty for streams.
Open decisions
"max_iterations"vs new"budget_exceeded"(recommended: distinct).
override wins).
totalUsage/totalCostsurvivereset()(recommended: reset clears them; add aseparate getter if lifetime-across-resets is ever needed).