Query API
Purpose
Answer a question with the NL2SQL pipeline: engine.run_query,
engine.stream_query, and engine.results to read back what a run saved.
Responsibilities
- Invoke pipeline graph runtime.
- Return a structured
QueryResult, or the run as events. - Read a run's saved results by its
trace_id.
Key Modules
packages/nl2sql/src/nl2sql/public_api.py(NL2SQL.run_query,NL2SQL.stream_query)packages/nl2sql/src/nl2sql/api/query_api.py(QueryResult,result_from_state)packages/nl2sql/src/nl2sql/api/results_api.py(ResultsAPI, internal)packages/nl2sql/src/nl2sql/pipeline/runtime.pypackages/nl2sql/src/nl2sql/pipeline/graph.pypackages/nl2sql/src/nl2sql/pipeline/state.py
Public Surface
NL2SQL.run_query
Source:
packages/nl2sql/src/nl2sql/public_api.py, packages/nl2sql/src/nl2sql/api/query_api.py
Signature:
run_query(question: str, *, datasource_id: Optional[str] = None, execute: bool = True, user_context: Optional[UserContext] = None, cancellation_token: Optional[CancellationToken] = None, plan: Optional[SuppliedPlan] = None, approvals: Optional[Sequence[Approval]] = None) -> QueryResult
Every argument after the question is keyword-only. run_query passes the runtime's artifact store to result_from_state, so each
sub-query carries a capped row sample alongside its artifact reference.
Parameters:
| name | type | required | meaning |
| --- | --- | --- | --- |
| question | str | yes | The question, in plain language. |
| datasource_id | Optional[str] | no | Datasource override; otherwise resolved. |
| execute | bool | no | Whether to execute SQL against datasource. |
| user_context | Optional[UserContext] | no | RBAC context. Omitted (or None), the run starts with an empty UserContext, a caller with no roles, and RBAC treats it exactly as it treats any roles list it has no policy for. Pass one with a role to be granted anything. |
| cancellation_token | Optional[CancellationToken] | no | A token (from nl2sql import CancellationToken) the caller cancels from another thread to stop the run. No further step and no further model call starts once it is cancelled; the step in flight finishes, and the result carries one CANCELLED error. The playground cancels it when the browser drops the request. |
| plan | Optional[SuppliedPlan] | no | A plan the caller holds (from nl2sql import SuppliedPlan: datasource_id, sub_query, plan). See Running a plan you hold. A datasource_id passed with it must be the plan's own, or the call raises ValueError before anything runs. |
| approvals | Optional[Sequence[Approval]] | no | Approvals for an earlier run's approvals_needed. See Approval. |
Running a plan you hold
run_query(question, plan=SuppliedPlan(datasource_id=..., sub_query=..., plan=...))
runs one sub-query's plan without routing or planning: the curated matcher,
the datasource resolver, the decomposer and the planner's model call are
skipped. What is not skipped:
- The datasource is checked against
user_context's scope, as the resolver would; a datasource the caller may not read ends the run withSECURITY_VIOLATIONbefore anything runs. - The plan passes the
plan.afterhooks, the logical validator (so a table outside the caller's scope is refused) and the generator, like any plan. - It is never written to the plan cache, and a validator failure ends the sub-query with the validator's error: the refiner is never asked for a different plan. A database error is not retried either.
The sub-query's plan_source is "supplied". The question is still asked:
the answer is written for it. The same entry serves the approved re-run
("approved") and, in Wave B, a plan the asker chose ("chosen").
Approval
A hook at execute.before may answer NeedsApproval(reason). The run then
ends with status: "needs_approval", the error NEEDS_APPROVAL and one
ApprovalRequest per pending sub-query in approvals_needed: the question,
the asker's user_id and tenant_id, the hook, the datasource, the run's
decomposition, the sub-query and its plan (or, on the verified path, the
verified query's id and values), the SQL and its SHA-256, the reason, and the
roles that may approve (for display). Its id is a SHA-256 over a fixed list
of those fields (REQUEST_ID_FIELDS: all but the reason and the roles), each
without the values it holds at their defaults, so an edit shows, the same
request always has the same id, and a field added in a later version keeps
the ids of requests an app stored before.
An approver re-runs the question as the asker:
result = engine.run_query(question, user_context=asker)
if result.status == "needs_approval":
approvals = [Approval(request=r, by=approver) for r in result.approvals_needed]
result = engine.run_query(question, user_context=asker, approvals=approvals)
The engine, not the hook, checks each approval. An approval pins nothing when
its request was edited (its id no longer matches), its sql is not the SQL
of its sql_sha256 (the SQL an approver reads is the SQL whose hash is
checked), it is for another question or another asker, its hook is not
configured, or by holds none of that hook's approver roles. Then the run
plans as usual and the hook asks again. Otherwise the re-run takes the
request's decomposition and plan (plan_source: "approved", no resolver,
decomposer or planner call) and validates and generates the SQL again. At execute.before the approval
counts as the hook's Allow only when it is for that hook and datasource,
the hash of the SQL just generated equals the request's (and, on the verified
path, the values are the same), and by holds one of the hook's configured
approver_roles (by default policies.admin_roles), never the roles the
request lists. Then the next hook still runs. Anything else asks again: the
run ends needs_approval with a new request. Nothing is stored, and an
Approval is not signed and does not expire: it is trusted as far as the
user_context a caller passes. See Hooks.
Returns:
QueryResult, built from the pipeline graph state by result_from_state.
Raises:
No exceptions are raised by run_query; errors are returned in QueryResult.errors.
Side Effects: - Pipeline execution, LLM calls, optional database execution.
Idempotency: - Not guaranteed; execution can depend on external systems and time.
Execution Lifecycle
- Use the caller's
CancellationToken, or create one per run, and install signal / Ctrl+X handlers bound to it. - Pass the graph a callback that raises
RunCancelledat the next node start or model call once the token is cancelled. - Build LangGraph pipeline from
build_graph. - Execute graph in thread pool with timeout.
- On timeout/cancel, return
PipelineErrorwith appropriateErrorCode.
QueryResult
Source:
packages/nl2sql/src/nl2sql/api/query_api.py
Fields:
| name | type | required | meaning |
| --- | --- | --- | --- |
| sub_queries | List[SubQueryResult] | no | One entry per decomposed sub-query. |
| final_answer | Optional[Dict[str, Any]] | no | Answer synthesizer payload (summary, format_type, content, warnings, and truncated: True when a result was cut at the row limit). |
| errors | List[Dict[str, Any]] | no | Pipeline errors (node, message, error_code, severity, provider, provider_response). provider and provider_response are set for a PROVIDER_* code and null otherwise; see provider failures. |
| trace_id | str | no | Trace identifier. |
| reasoning | List[Dict[str, Any]] | no | Reasoning events/logs. |
| warnings | List[RunWarning] | no | What the answer should say without failing the run. See Warnings. |
| artifact_refs | Dict[str, ArtifactRef] | no | Result artifact references keyed by execution node id. |
| status | Optional[RunStatus] | no | "success", "plan_only", "error", "needs_approval", "stopped", "needs_clarification", or None when nothing ran and nothing failed. See below. |
| timings | Dict[str, float] | no | Wall-clock seconds per graph node, plus LangGraph for the whole run. |
| usage | QuestionUsage | no | LLM calls, tokens and model time per node and for the whole question. See below. |
| trace_path | Optional[str] | no | Where this run's trace file was written, or None when TRACE_MODE did not write one. See Debugging a Run. |
| truncated | bool | no | True when any sub-query's result reached its row limit, so rows may be missing. See sub_queries[].truncated. |
| result_manifest | List[ResultSummary] | no | The run's saved results, one per terminal DAG node (node_id, columns, row_count, truncated): the manifest the aggregator writes beside them. Never the rows; see Reading a run's saved results. Empty when nothing was saved. |
| source | AnswerSource | no | Where the answer came from: type "verified" with the verified query's id, datasource_id and the params it ran with, or type "generated" with the other fields None. See Curated answers. |
| stop | Optional[RunStop] | no | Set when status is "stopped": the limit the run reached (max_llm_calls or max_cost_usd), its configured value, and what was spent. |
| approvals_needed | List[ApprovalRequest] | no | Set when status is "needs_approval": one request per sub-query a hook asked a person to approve, with the SQL, its SHA-256, the reason and the asker. Re-run the question with an Approval for each. |
errors holds only ERROR and CRITICAL entries; WARNING-severity pipeline
errors are appended to warnings as RunWarnings.
A sub-query that succeeded contributes nothing to errors: the errors of the
attempts it recovered from are in warnings with detail.recovered true, and
its warnings with an empty detail, each with its sub_query_id. The run trace and
subgraph_outputs[<id>].errors keep every attempt's errors for debugging. A
sub-query that failed without a blocking error of its own (it ended with no SQL
on warnings alone) adds a MISSING_SQL error, so the run reports "error".
status is derived: "needs_approval" when a hook asked for approval
(approvals_needed), "stopped" when the run reached a per-question limit
(stop), otherwise "error" when any blocking error is present, otherwise
"plan_only" when no sub-query produced rows or an artifact (an
execute=False run), otherwise "success". A state with no sub-queries and no
errors leaves it None. "needs_clarification" is reserved for Wave B's
clarification result: nothing produces it yet. Hooks, approval and the caps
that produce "needs_approval" and "stopped" arrive with release 0.7 (see
Wave A).
Only a capped sample of the rows is inlined, in sub_queries[].rows. The full
result set lives in artifact storage, addressed through artifact_refs; each
combined result is listed in result_manifest and read back by the run's
trace_id with the methods below.
Warnings
QueryResult.warnings is a list of RunWarning (from nl2sql import RunWarning):
| name | type | meaning |
|---|---|---|
code |
str |
An ErrorCode value, such as VALUE_NOT_FOUND, JOIN_FAN_OUT, ALL_NULL_COLUMN, JOIN_KEY_UNCONVERTED, RESULT_TRUNCATED or HOOK_DENIED; CURATED_FALLBACK when a verified query failed and the question was generated instead. |
node |
str |
The node that found it. |
message |
str |
What the answer says. |
sub_query_id |
Optional[str] |
The sub-query it is about, when it is about one. |
detail |
Dict[str, Any] |
What a program may branch on: recovered: true for an earlier attempt's error a retry replaced; the column, counts and type of a JOIN_KEY_UNCONVERTED. Never result rows or sample values. |
The answer sanity checks (a missing filter value, a join that repeated the rows
an aggregate counted, a column with no value) add theirs, and the answer
synthesizer says them; see the
executor's sanity checks.
settings.pipeline.sanity_checks: false turns those checks off.
0.7 changed this shape: a warning was a dict with error_code and severity,
and is now a RunWarning with code, and detail.recovered where severity
said ERROR. A run's state is never kept across an upgrade (a run that ended
needs_approval is asked again, not resumed; see
Wave A, D10), so no older warning is
ever read back.
Streaming a run
NL2SQL.stream_query
Source:
packages/nl2sql/src/nl2sql/api/query_api.py, step events in
packages/nl2sql/src/nl2sql/pipeline/events.py, the allow-list in
packages/nl2sql/src/nl2sql/events/catalog.py
Signature:
stream_query(question: str, *, datasource_id: Optional[str] = None, execute: bool = True, user_context: Optional[UserContext] = None, cancellation_token: Optional[CancellationToken] = None, plan: Optional[SuppliedPlan] = None, approvals: Optional[Sequence[Approval]] = None) -> Iterator[Event]
The same run as run_query, with the same parameters, yielded as it happens:
the run's own events on the engine's bus (Events),
from the caller-safe allow-list. nl2sql run shows its live progress from them.
from nl2sql import NL2SQL, UserContext
engine = NL2SQL.from_config("nl2sql.yaml")
for event in engine.stream_query("Which genre sells the most tracks?",
user_context=UserContext(roles=["admin"])):
if event.name == "node.finished":
print(f"{event.data['node']} took {event.data['seconds']} s")
elif event.name == "answer.delta" and event.data["field"] == "summary":
print(event.data["text"], end="", flush=True)
elif event.name == "run.finished":
result = event.data["result"] # run_query's QueryResult, as JSON
An Event (nl2sql.Event, from nl2sql_sdk.events) has a name, at (UTC),
the run's trace_id, its tenant_id and a JSON-ready data dict;
event.sse() is the event as one Server-Sent Event (event: <name>, and the
whole event as JSON on the data line), which is how nl2sql-api and the
playground send it.
| name | data | when |
|---|---|---|
run.started |
question, user_id, tenant_id, roles, datasource_id, execute |
The run began. Always first. |
node.started |
node, sub_query_id |
A pipeline step began (the SQL agent's steps once per sub-query, and again on a retry). sub_query_id is None outside a sub-query. |
node.finished |
node, sub_query_id, seconds, ok |
The step ended; ok is False when it raised. |
result.manifest |
results |
The aggregator saved the results: results is the run's result_manifest and the event's trace_id its request_id, so engine.results.page can be called before the answer is written. |
answer.delta |
field, text |
The next piece of final_answer.summary or final_answer.content, as the answer model writes it. |
sanity.flagged, run.limit_reached |
see Events | Published by the sanity checks and the caps when they arrive (Wave A's S1 and E3). |
run.finished |
the run's summary (status, duration_s, usage, error_codes, source, datasources, tables, result_manifest, user_id, roles) plus result |
The run finished. result is exactly what run_query returns, as JSON; it is on the stream's copy only, never on the bus. A refusal, a provider failure, a timeout or a cancel is a run.finished with status: "error", as run_query returns it. |
run.failed |
message, error_code |
The run could not start or finish (an exception outside the pipeline, such as a missing API key when the graph is built). |
Every stream ends in exactly one run.finished or run.failed. Operator
events (policy.decision, hook.decision, llm.call, plan_cache.lookup)
are never on it: a denial would tell a denied caller which table exists, and the
rest are operator data. An operator reads them with engine.events.subscribe.
How it works:
- The run goes on a thread of its own, started on the first
next(); the caller's context goes with it, so request-scoped keys (nl2sql.llm.request_key) reach the graph as they do inrun_query. - Every run (streamed or not) has a
RunScope, andQueryAPI.open_scopeattaches aRunEventsto it: a LangChain callback the graph gets with the scope's callbacks, which publishes through the scope. A node's own run (its name is itslanggraph_node) is anode.started/node.finished; the runnables inside a node are not events. The aggregator's end publishesresult.manifest. The stream subscribes to its scope's events from the allow-list (nl2sql.events.STREAM_EVENTS), so it yields exactly what the bus carries, in publish order, and then its ownrun.finishedwith the result. run_with_graph(stream_answer=True)asks the answer synthesizer to stream: its chain isstreaming_structured(nl2sql.llm.wires) instead ofstructured, the same request withstreamon. Each piece of the answer's tool call reacheson_llm_new_token;RunEventsparses the arguments so far as partial JSON and publishes whatsummaryandcontentgained asanswer.delta. The pieces are joined into one message before the parser sees it, so the answer, and the token usage the call reports, are the unstreamed call's. On the Anthropic wire the joined message gets the same tool-call repair as an unstreamed one.- Closing the iterator before the last event (a client that went away) cancels the run's token, so it stops before its next step or model call.
Token by token, per wire: both the OpenAI wire (OpenAI, OpenRouter, Ollama, any
OpenAI-compatible endpoint) and the Anthropic wire stream the answer's forced tool
call, so the answer streams on every provider; how finely is the provider's choice,
and one that sends the whole answer at once gives one answer.delta per field. The
other model steps return a typed object the next step needs whole, and are not
streamed. FakeLLMServer streams both wires, a word at a time, so replay mode and
the keyless tests stream too.
Reading a run's saved results
The aggregator saves each terminal result of a run as
<tenant_id>/<request_id>/result_<node_id>.parquet and writes manifest.json
beside them (see Artifact Store → The result manifest).
engine.results reads them back by the run's trace_id (its run_id, the
request_id of the REST routes). They read lazily from the
parquet file: a page reads only its slice, and the CSV export reads and yields a
slice at a time (CSV_CHUNK_ROWS, 10,000 rows), so neither holds the whole
result in memory.
| method | returns | meaning |
|---|---|---|
results.manifest(run_id, tenant_id=None) |
ResultManifest |
request_id and results: List[ResultSummary], the same entries as QueryResult.result_manifest. |
results.page(run_id, result_id, offset=0, limit=100, tenant_id=None) |
ResultPage |
Rows offset to offset + limit of the result whose node_id is result_id. limit above MAX_RESULT_PAGE_SIZE (1,000) is cut to it, and the page's limit says so. A page past the end has no rows. next_offset is where the next page starts, None at the end. A negative offset or a limit below 1 raises ValueError. |
results.csv(run_id, result_id, tenant_id=None) |
Iterator[bytes] |
The whole result as UTF-8 CSV, header first, one chunk per slice. The ids are checked before the iterator is returned, so a missing result raises before any byte is sent. |
ResultPage has request_id, result_id, columns, rows (rows[i] is row
offset + i), offset, limit, total_rows and next_offset.
Scope: results are read from one tenant's folder, tenant_id or else the
engine's own TENANT_ID, the tenant every run of this engine writes under. Each
of these raises ResultNotFound (a LookupError, exported from nl2sql): an
unknown run_id, a run in another tenant's folder, a result_id the run's
manifest does not list, and an id that is not a plain token (letters, digits,
_, -; at most 128 characters). Ids are checked before any path is built, and
the parquet path is rendered from the artifact template, never read from the
manifest, so no id reaches a file outside the run's folder. Nothing else guards
a result yet: any caller who knows a trace_id can read it.
result = engine.run_query("List every genre.", user_context=UserContext(roles=["admin"]))
[saved] = result.result_manifest # node_id, columns, row_count, truncated
page = engine.results.page(result.trace_id, saved.node_id, offset=0, limit=20)
with open("genres.csv", "wb") as out:
for chunk in engine.results.csv(result.trace_id, saved.node_id):
out.write(chunk)
nl2sql-api serves these as
GET /api/v1/query/{request_id}/results/{result_id}
and .../{result_id}.csv; the playground serves the same two under /api/query/....
SubQueryResult
| name | type | required | meaning |
|---|---|---|---|
id |
str |
no | Sub-query identifier. |
intent |
str |
no | Semantic intent of the sub-query. |
sql |
str |
no | SQL generated for the sub-query (subgraph_outputs[<id>].sql_draft). |
datasource_id |
str |
no | Datasource the sub-query targets. |
schema_version |
str |
no | Schema version used for planning. |
plan |
Optional[Dict[str, Any]] |
no | The validated PlanModel, dumped. |
validation |
List[ValidationCheck] |
no | name, passed, message per validation gate. |
rows |
Optional[RowSample] |
no | Capped row sample; None when nothing executed or the artifact could not be read. |
status |
str |
no | "success" or "error" for this sub-query, from its final attempt: "success" when it ended with SQL and, if executed, a result; otherwise "error". A retry that recovers reports "success". "dropped" when a hook denied it with scope="sub_query": the run went on without it. |
retry_count |
int |
no | Plan/SQL refinement attempts made. |
plan_source |
"llm" \| "cache" \| "verified" \| "supplied" \| "approved" \| "chosen" |
no | "cache" when the plan came from the plan cache (no planner LLM call; still validated, generated and executed), "verified" when a verified query answered (no plan; sql is the verified query's own), "supplied" for a plan passed as run_query(plan=...), "approved" for an approval's pinned plan ("chosen" is reserved for Wave B), otherwise "llm". Only an "llm" plan is ever cached. See Determinism → The plan cache. |
row_cap |
Optional[int] |
no | The row limit that bounded this sub-query's SQL (the datasource's row_limit, bounded by RESULT_ROW_LIMIT), or None when the plan's own LIMIT was smaller. |
truncated |
bool |
no | True when the result has row_cap rows: it was cut at the limit and may be missing rows. A result of exactly row_cap rows may be whole; it is counted as cut. |
RowSample
| name | type | required | meaning |
|---|---|---|---|
columns |
List[str] |
no | Column names, in result order. |
rows |
List[List[Any]] |
no | At most sample_rows rows (default 50). |
total_rows |
int |
no | Row count of the whole result, not of the sample. |
State mapping
result_from_state normalises the raw graph state (LangGraph may return dicts or
model instances for nested values):
QueryResult field |
Graph state source |
|---|---|
sub_queries[].sql |
subgraph_outputs[<id>].sql_draft |
sub_queries[].id / .intent / .datasource_id / .schema_version |
subgraph_outputs[<id>].sub_query |
sub_queries[].plan / .validation / .status / .retry_count / .plan_source / .row_cap / .truncated |
subgraph_outputs[<id>] |
usage.plan_cache_hits |
count of sub_queries[] with plan_source == "cache" |
sub_queries[].rows |
artifact_store.read_result_frame(subgraph_outputs[<id>].artifact) |
final_answer |
answer_synthesizer_response.final_answer |
source |
verified_match (set by the curated matcher on a verified answer; absent means generated) |
errors, reasoning, warnings, trace_id, artifact_refs, timings, usage |
top-level state |
errors are projected onto a client-safe summary; stack_trace and details are
deliberately not exposed.
result_from_state(state, artifact_store=None, sample_rows=50) reads rows only
when an artifact store is supplied. A failed read is not fatal: rows stays
None and a warning is appended.
timings is collected by NodeTimingCallback
(packages/nl2sql/src/nl2sql/pipeline/timing.py), a LangChain callback handler
that run_with_graph always attaches. It keys on metadata["langgraph_node"]
where LangGraph provides it, so the keys are node names such as ast_planner,
logical_validator, generator and executor.
Usage: tokens, calls and model time
usage is collected by TokenUsageCallback
(packages/nl2sql/src/nl2sql/services/callbacks/token_handler.py), which
run_with_graph attaches to every run, so the Python API, the REST API, the CLI
and the demo playground all report it. It is also filled in, with whatever had
been recorded, on a timed-out or cancelled run.
QuestionUsage:
| name | type | meaning |
|---|---|---|
total |
UsageTotals |
The whole question. |
nodes |
Dict[str, UsageTotals] |
Keyed by graph node: datasource_resolver (the answerability check), decomposer, ast_planner, refiner, answer_synthesizer. |
calls |
List[LLMCallUsage] |
Every model call in order, with its node and model. |
plan_cache_hits |
int |
Sub-queries whose plan came from the plan cache. A hit makes no planner call, so it adds no ast_planner calls or tokens; a question answered entirely from the cache has no ast_planner entry in nodes. |
UsageTotals:
| name | type | meaning |
|---|---|---|
calls |
int |
LLM calls. The planner and refiner run once per retry, so a sub-query retried once shows ast_planner.calls == 2. |
input_tokens |
Optional[int] |
Prompt tokens, including cached ones. |
cached_input_tokens |
Optional[int] |
Input tokens served from the provider's prompt cache (a subset of input_tokens). |
cache_write_input_tokens |
Optional[int] |
Input tokens written to the prompt cache (Anthropic reports these; OpenAI does not, so it is null there). |
output_tokens |
Optional[int] |
Completion tokens, including reasoning ones. |
reasoning_tokens |
Optional[int] |
Reasoning ("thinking") tokens (a subset of output_tokens). |
total_tokens |
Optional[int] |
As the provider reports it; input + output if it does not. |
latency_s |
float |
Seconds spent waiting on the model, summed over calls. The node's wall-clock time, which includes its non-LLM work, is in timings. |
cost |
Optional[float] |
Dollars, when every call counted here has a cost; otherwise null. Each call is priced from llm.prices, then the dated price table: cache reads at cached_input, cache writes at cache_write (Claude's are 1.25x input), the rest of input_tokens at input and output_tokens at output; see Prices. |
A token count in UsageTotals is the sum of what the calls reported, and
null when no call reported it: an unknown count is never 0.
LLMCallUsage has the same token fields plus node, model (the model the
provider says served the call, e.g. gpt-4o-2024-08-06), latency_s, cost,
usage_reported and error.
Tokens are read by the adapter of the wire type that served the call
(read_usage in nl2sql/llm/wires/openai.py or anthropic.py), picked by the
model_provider the client stamps on the response; TokenUsageCallback only
asks it. Both read LangChain's AIMessage.usage_metadata
(input_token_details.cache_read, input_token_details.cache_creation,
output_token_details.reasoning) into the same fields, and differ where their
providers differ, as below. A detail the provider did not report is null, never 0. A call whose result carried no usage
at all is recorded with every count null and usage_reported: false; a failed call is
recorded the same way, with its latency and error.
For OpenAI through langchain-openai (verified against the pinned 1.6.x by
packages/nl2sql/tests/unit/test_fake_llm_server.py): prompt_tokens,
completion_tokens, total_tokens, prompt_tokens_details.cached_tokens and
completion_tokens_details.reasoning_tokens all arrive. OpenAI's chat
completions API reports no cache-write count, so cache_write_input_tokens is
null there.
For Claude through langchain-anthropic (provider: anthropic; verified
against the fake Anthropic endpoint by
packages/nl2sql/tests/unit/test_llm_wire_contract.py, which runs the same
usage tests against both wires), Anthropic's usage maps
as follows:
Anthropic usage |
field here |
|---|---|
cache_read_input_tokens |
cached_input_tokens |
cache_creation_input_tokens, or its per-TTL split cache_creation.ephemeral_5m_input_tokens + ephemeral_1h_input_tokens |
cache_write_input_tokens |
input_tokens + cache_read_input_tokens + cache_creation_input_tokens |
input_tokens |
output_tokens |
output_tokens |
Anthropic's own input_tokens counts only the uncached tail of the prompt;
langchain-anthropic adds the cache reads and writes back, so input_tokens
here is the whole prompt, counted once, and both cache fields stay subsets of
it. When the write is reported per TTL, langchain-anthropic sets the generic
cache_creation detail to 0 and puts the tokens under the TTL keys, which
are summed here.
Cost is (input - cached - cache_write) * input_price + cached * cached_input_price
+ cache_write * cache_write_price + output * output_price, per million tokens; a
cache count the provider did not report is not split out of input. Prices are
looked up in llm.prices by the served model name, then by the configured one,
and only then in the dated table by either, by exact match only, so a gpt-4o
price never prices gpt-4o-mini. A call with no price, or no input or output count, has a
null cost. See Prices.