Token and Cost Capture¶
Traigent charges each trial for the LLM calls its examples make. It reads the
provider's reported usage (prompt and completion tokens), prices it, and
records input_tokens, output_tokens, total_tokens, input_cost,
output_cost and total_cost on every example and trial.
This page lists which calls are captured, how to price a model Traigent does not know, and what a trial reports when nothing was captured.
What is captured¶
| Client | Captured when | Notes |
|---|---|---|
LangChain ChatOpenAI, ChatAnthropic, Bedrock chat models: invoke, ainvoke, abatch, stream, astream |
always (patched by the evaluator) | ainvoke/abatch are captured (#2424, #2445). stream/astream capture the last chunk, which carries usage only with stream_usage=True; without it the call is reported as unmeasured, not $0 |
litellm.completion / litellm.acompletion |
always (patched by the evaluator) | streaming calls are not captured |
Traigent's BedrockChatClient |
always | |
Raw OpenAI SDK, sync and async: chat.completions.create, chat.completions.parse (also beta.chat.completions.parse), completions.create, responses.create, responses.parse, and the with_raw_response form of each |
the OpenAI override is active (see below) | non-streaming calls that return usage. Responses API input_tokens/output_tokens are counted as prompt/completion tokens. Your code still receives the normal return value; a with_raw_response result is parsed once and the parsed object is cached, so your own .parse() returns it |
Direct google.genai calls (Gemini API or Vertex AI client) |
never | Traigent does not patch this client, so these calls are outside cost tracking. A trial can show a cost of 0.0, and a run with a cost cap can stop with an unmeasured-cost error. Calling Gemini through litellm.completion gets its usage captured. |
Raw OpenAI SDK calls (including OpenAI-compatible gateways)¶
An agent that calls the OpenAI SDK directly is captured when the OpenAI framework override is active. Either turn it on for the whole process:
from traigent.integrations.llms.openai import enable_openai_optimization
enable_openai_optimization() # patches openai.OpenAI and openai.AsyncOpenAI
or name the classes on the decorator:
client = openai.OpenAI(base_url=os.environ["LLM_BASE_URL"]) # build it once
@traigent.optimize(
configuration_space={"model": ["gpt-4o-mini", "gpt-4o"]},
objectives=["accuracy", "cost"],
eval_dataset="data.jsonl",
auto_override_frameworks=True,
framework_targets=["openai.OpenAI", "openai.AsyncOpenAI"],
)
def answer(question: str) -> str:
response = client.chat.completions.create(
model="gpt-4o-mini", # replaced by the trial's model
messages=[{"role": "user", "content": question}],
)
return response.choices[0].message.content
Every call made while an example runs is charged, so an agent that calls the model several times per example is billed for all of them.
Coverage limits:
- The override must be active. Without
enable_openai_optimization()orframework_targets, raw OpenAI calls are not captured (issue #2441 tracks capture that needs no opt-in). - Streaming calls (
stream=True) are not captured. A stream's usage arrives only after your code reads it, and only withstream_options={"include_usage": True}. The example is reported as unmeasured, not as$0. - Responses without
usage(some gateways omit it) are not captured. They are reported as unmeasured. - Not captured: streaming forms (
stream=True,chat.completions.stream,responses.stream),completions.createon a gateway that omitsusage, and any other method (embeddings, images, audio,responses.retrieve). A client whosewith_raw_responseaccessor was first used before the override was activated keeps the unpatched method: build the client after enabling the override, or accesswith_raw_responseonly afterwards. - The new parse and Responses paths are capture-only: Traigent records their
usage but does not inject trial parameters (
model,temperature, ...) into them. Onlychat.completions.createandcompletions.createreceive injected parameters. - When LangChain's
ChatOpenAI.invokeorlitellm.completionmakes the OpenAI call for you, that wrapper records the usage and the underlying OpenAI call is not counted a second time.
Pricing a model Traigent does not know¶
Traigent prices tokens from LiteLLM's model table. A gateway alias such as
acme-gateway/house-model is not in that table, so its calls are
unpriced: tokens are recorded, cost is not known. What happens next
depends on TRAIGENT_STRICT_COST_ACCOUNTING:
true: an unpriced model raises.- unset: a run whose objectives include
costis strict at runtime, so it stops at the first call that has neither a table price nor a provider-reported cost. A run without acostobjective records the call at$0and the result carries anUNPRICED_MODEL_RUNTIMEwarning. false: explicit opt-out. The call is recorded at$0with the same warning. The trial is flaggedcost_unpriced, and a flagged trial is left out of cost ranking (best config, Pareto front, weighted scores), so a placeholder$0never wins as "cheapest".
Give the alias a price with TRAIGENT_CUSTOM_MODEL_PRICING_JSON (inline) or
TRAIGENT_CUSTOM_MODEL_PRICING_FILE (path to the same JSON). Prices are USD
per token:
export TRAIGENT_CUSTOM_MODEL_PRICING_JSON='{
"acme-gateway/house-model": {
"input_cost_per_token": 0.000002,
"output_cost_per_token": 0.000005
}
}'
The key must be the model name Traigent prices. In the custom-evaluator lane
that is the trial's model setting when the configuration has one, otherwise
the model the response reports.
Mock provenance in result metadata¶
A result records whether its LLM calls were simulated in result.metadata:
| Key | Meaning |
|---|---|
mock_llm_run |
True if any mock response was served during the run, False if the run's calls were real. |
mock_llm_mixed |
Present and True only when both mock and real calls were observed. |
Both keys are saved and restored by PersistenceManager. A result saved before
these keys existed, or built by hand, has no mock_llm_run key: that means
unknown, not False. Read it with result.metadata.get("mock_llm_run") and
treat None as "not recorded". The results table labels a recorded mock run,
and hedges an unrecorded result only while mock mode is on in the process.
When nothing was captured¶
A missing measurement is not a free call. When a custom-evaluator example has
no captured usage, its result has metadata["llm_usage_measured"] = False
and no token or cost keys. When no example in a trial was measured, the
trial's token and cost totals are left out of its metrics (or set to None
when TRAIGENT_STRICT_METRICS_NULLS=true) instead of being reported as 0.
When only some examples were measured, the totals cover those examples and a
warning names the coverage.
The same holds for a cost objective: a trial with no measured example has
no cost (or None under strict nulls), never 0.0; cost_unmeasured is
1.0 on a trial where any example had no measured cost, and token counts
estimated from text length (input from the dataset or prompt template, output from the returned string, about 4 characters per token; a system message written inside your function is not counted, so the estimate can be well below the provider's count) are labelled with tokens_estimated and are never priced. The results
table shows the cost as unmeasured. Weighted best_config selection counts a
missing cost as the worst cost, the Pareto front leaves out a trial that is
missing a configured objective, and the batch composite score gives such a
trial the failed-trial score instead of scoring it on quality alone. A
constraint written as metrics.get("cost", 0) <= limit still accepts unmeasured
trials (#2446, item 3);
write metrics.get("cost") is not None and metrics["cost"] <= limit instead.
Workflow spans for an unmeasured trial, and for a failed or pruned trial that
recorded no cost, send the known spend as cost_usd (0.0 when none was
reported) together with the metadata flag traigent.cost_unmeasured: true; the
flag, not the number, says the cost is not a measurement. Run summaries
(get_summary(), experiment_stats) withhold total_cost and report cost_unmeasured_trials
and cost_lower_bound when any trial's cost is unmeasured.
A run that stops before the grid is exhausted records why in
result.metadata["stop_detail"] and prints it under the results table: the SDK
default max_trials=10 when none was passed, an explicit max_trials, or the
unmeasured-cost safety limit.
A metric_limit on a cost metric leaves unmeasured trials out of its running
total rather than failing the run. Two run-level warnings say what happened:
COST_OBJECTIVE_NO_USAGE_CAPTURED: no trial captured usage, so the cost column is unmeasured. Under strict cost accounting the run fails instead.COST_OBJECTIVE_PARTIAL_USAGE_CAPTURED: some successful trials captured usage and others did not; the cost comparison covers only the measured ones.
When a run stops because cost was not measured¶
The cost limit (cost_limit= on @traigent.optimize or .optimize(), or
TRAIGENT_RUN_COST_LIMIT, default $2.00) can only bound spend it can see.
When any trial reports no cost, what happens depends on whether you sized the
run yourself.
You did not set max_trials or max_total_examples. Traigent is
conservative: the run stops at a default safety limit of 10 trials
(TRAIGENT_FALLBACK_TRIAL_LIMIT changes it) with
stop_reason == "cost_limit". One unmeasured trial is enough, even if every
other trial was measured. The result carries the
COST_UNMEASURED_TRIAL_LIMIT_REACHED warning code and a message, which is also
logged, that says how many trials were unmeasured and how many were measured.
You set max_trials or max_total_examples explicitly, on the decorator,
on OptimizedFunction(...), or on .optimize()/.optimize_sync(). That is
taken as consent: the safety limit does not apply, and the run goes up to the
size you set. Set max_trials to run more trials. max_total_examples caps the
total number of examples across trials and also counts as your explicit
consent, but it does not raise max_trials, which defaults to 10, so on its
own it never yields more than 10 trials. The measured part of the spend is still
bounded by cost_limit. The result carries COST_UNMEASURED_TRIALS_RAN, and a
cost-objective run also carries COST_OBJECTIVE_NO_USAGE_CAPTURED or
COST_OBJECTIVE_PARTIAL_USAGE_CAPTURED. "Explicitly" means you passed the
argument; passing the same value as the default counts.
To continue after the safety stop, either:
- Get cost measured for every configuration. Call the model through a
captured client (the table above).
In a mixed run, find why the unmeasured configurations report no usage: a
gateway that omits
usage, a streaming call, or an uncaptured client. A fully measured run is bounded by its cost budget instead of the trial limit, so raisecost_limitif the budget is what stops you. - Size the run yourself. Set
max_trialsexplicitly to run more trials, for examplefunc.optimize_sync(max_trials=50).max_total_examplescaps total examples and also counts as your explicit consent, but does not raise the defaultmax_trialsof 10. Traigent cannot tell you what the unmeasured trials cost.
A max_total_examples passed to .optimize() or .optimize_sync() is kept on
the function object, so later calls on the same object keep that sample budget
(and keep counting as explicitly sized) until you change it.
Build the OpenAI client once, outside the optimized function, as in the
examples above. The override injects the trial's model and sampling
parameters at chat.completions.create, not into the client constructor.