LLM Configuration
The llm section of nl2sql.yaml names the model every agent
uses, and optional per-agent overrides. nl2sql init writes it to llm.yaml
with the provider's default model and no key.
# llm.yaml
provider: openai
model: gpt-5.4
temperature: 0.0
# api_key is optional: without it the provider's standard variable
# (OPENAI_API_KEY) is read. Write it to read another one:
# api_key: "${env:TEAM_OPENAI_KEY}"
agents:
ast_planner: { provider: anthropic, model: claude-opus-5, temperature: null }
indexing_enrichment: { model: gpt-5.4-mini }
prices:
gpt-4o: { input: 2.5, cached_input: 1.25, output: 10 }
Fields
The section is flat: its keys are the top-level agent's.
| Field | Default | Meaning |
|---|---|---|
provider |
required | openai, anthropic, openai_compatible, its presets openrouter and ollama, or a provider a plugin registers; see Providers. A misspelt or uninstalled one is a ConfigError with "did you mean". |
model |
required | The model identifier. In Python it may be a LangChain chat model object instead (LLM(model=ChatOpenAI(...))), with no provider. |
temperature |
0.0 |
A float, or null to send no temperature at all; see Temperature. |
api_key |
none | Without it, the provider's standard variable (OPENAI_API_KEY, ANTHROPIC_API_KEY, OPENROUTER_API_KEY) is read. With it, a ${env:VAR} or ${provider:key} reference (or a literal, which is not recommended). The reference is resolved when the client is built, never at load. |
base_url |
the provider's | An endpoint override. Required for openai_compatible; see Any other OpenAI-style server. |
structured_output |
the provider's | tool_calling, json_schema or json_mode: how structured answers are asked for. See Structured output. |
parse_retries |
1 |
How many times a structured answer that does not parse is asked for again, with what was wrong. 0 asks once. See Parse-and-retry. |
max_tokens |
the provider's | The most tokens one answer may use. Unset, Anthropic gets 16,000 and the other providers send none. |
timeout_s |
the client library's | Seconds one request may take. |
max_retries |
the client library's | Network and rate-limit retries, done by the client library. |
headers |
{} |
HTTP headers sent with every request, such as a gateway's team or routing header. A value may be a ${env:VAR} or ${provider:key} reference, resolved when the client is built. Values never print: not in a repr, a log, engine.llm.list() or the cache key. |
agents |
{} |
Per-node overrides, keyed by node name; see Per-node models. |
prices |
{} |
Per-model prices per million tokens; see Prices. |
engine.check() reports every agent whose key does not resolve, without
calling a model.
Providers and wire types
The engine is model-agnostic. A provider is a preset: an endpoint, the
environment variable its key comes from, and the wire type it speaks, which
is the HTTP protocol on the wire. Everything that differs between wire types
lives in one adapter per wire type, in nl2sql/llm/wires/:
| wire type | adapter | client | providers |
|---|---|---|---|
openai |
wires/openai.py |
ChatOpenAI |
openai, openai_compatible, openrouter, ollama |
anthropic |
wires/anthropic.py |
ChatAnthropic (the anthropic extra) |
anthropic |
Each adapter owns four things, so neither the registry nor the nodes know which wire they are on:
openai wire |
anthropic wire |
|
|---|---|---|
| Client | ConfiguredChatOpenAI, seed=42 |
CachingChatAnthropic, max_tokens=16000 unless the agent sets one |
| Structured output | the agent's structured_output |
the agent's structured_output |
| Prompt caching | nothing to mark: OpenAI caches long prefixes by itself | cache_control on the last system block |
| Usage | prompt_tokens_details.cached_tokens → cached; no cache writes reported |
cache reads and writes, see Query API → usage |
Both adapters also turn a model's HTTP 400 on temperature into the same
"set temperature: null" error (see Temperature).
The built-in providers are deliberately few: openai, anthropic and
openai_compatible. openrouter and ollama are named presets of
openai_compatible: a fixed endpoint and key variable, and no code of their
own.
| provider | wire type | key variable | base_url |
default structured_output |
|---|---|---|---|---|
openai |
openai |
OPENAI_API_KEY |
optional | tool_calling |
anthropic |
anthropic |
ANTHROPIC_API_KEY |
optional | tool_calling |
openai_compatible |
openai |
none: write api_key (usually ${env:...}) if the server needs one |
required | json_schema |
openrouter |
preset of openai_compatible |
OPENROUTER_API_KEY |
fixed: https://openrouter.ai/api/v1 |
tool_calling |
ollama |
preset of openai_compatible |
none | fixed: http://localhost:11434/v1 (a base_url may override it) |
tool_calling |
There is no Azure provider: Azure OpenAI's v1 endpoint is openai_compatible
(see the base_url table).
Any other name raises ValueError: Unsupported LLM provider, naming the valid
providers. Every agent picks its own provider, so one question can use several;
see Per-node models.
Adding a provider
- Another OpenAI-style server needs no new provider: use
openai_compatiblewith itsbase_url(see Any other OpenAI-style server). - Anything else (Azure with Entra ID sign-in, Bedrock, Gemini) is a plugin:
a class implementing
LLMProvider(nl2sql/llm/providers.py, interface version 2:interface_version = 2,label,api_key_env,requires,install_hint,structured_outputandcreate(agent)), registered withregistry.add_llmor under thenl2sql.llmsentry point group. A provider built for another interface version is rejected when it is loaded. - A new wire type inside the engine (Gemini, Bedrock) is one new adapter module in
nl2sql/llm/wires/implementing theWireprotocol inwires/base.py(build_client,structured_output_method,mark_cache,read_usage), one entry inWIRESinwires/__init__.py, and presets that name it. Add a case toCASESintests/unit/test_llm_wire_contract.py. The contract suite then runs every structured-output call, usage, cache and key test against it.
OpenAI
nl2sql init and nl2sql demo write gpt-5.4 as the OpenAI default. On the
owner's account (probed 2026-09-20) it accepted temperature: 0 with a limit of
500,000 tokens per minute, against 30,000 for the previous default gpt-4o -
less than one question with a single retry needs (about 33,500 tokens).
Every node asks for structured output as a tool call, OpenAI's default
structured_output: it is what OpenRouter's and Ollama's models support most
widely too. See Structured output.
Anthropic (Claude)
Claude runs on Anthropic's own API through ChatAnthropic, not through an
OpenAI-compatibility shim: prompt caching and reliable structured output need
Anthropic's native API. langchain-anthropic is an optional extra:
Selecting provider: anthropic without it fails on the first LLM call with
ValueError: ... Install the anthropic extra: pip install "nl2sql-engine[anthropic]",
and nl2sql doctor reports the missing extra.
provider: anthropic
model: claude-opus-5
temperature: null # Claude Opus 5 and Sonnet 5 reject any temperature
nl2sql init --provider anthropic and nl2sql demo write claude-opus-5 as the Claude default,
with temperature: null. Current models:
| model | temperature |
minimum cacheable prefix |
|---|---|---|
claude-opus-5 |
rejected (HTTP 400): use null |
512 tokens |
claude-sonnet-5 |
rejected (HTTP 400): use null |
1,024 tokens |
claude-haiku-4-5 |
accepted, e.g. 0.0 |
4,096 tokens |
These come from Anthropic's documented model rules, not from a probe on a real
account. A rejected temperature fails with the same "set temperature: null"
error as OpenAI (see Temperature).
How the engine uses it:
- Structured output is a forced tool call (
with_structured_output'sfunction_callingmethod,structured_output: tool_calling), for every node. Anthropic's native JSON outputs (output_config.format) do not accept recursive schemas, and the planner'sPlanModelis recursive; neither does strict tool use (strict: true). Claude now and then nests a tool call's arguments one level down under a key of its own choosing ({"input": {...}},{"query": {...}},{"dtype": {...}}). Recording runs onclaude-opus-5failed the decomposer this way on up to 9 of 20 questions, and no other node. The root cause was the decomposer's tool: it went out with an empty description and no field descriptions, while its prompt asked for "JSON only" rather than a tool call.DecomposerResponsenow describes itself and each top-level field ("call this tool with the decomposition itself as the arguments ... do not wrap them"), and the prompt asks for the tool call. As a safety net the wire also unwraps that shape before any node parses it: a dict with exactly one key the schema does not declare, holding an object with every required field and at least one declared one. Any other malformed answer still fails validation as before. A new node's response model should carry a docstring and field descriptions for the same reason. - Prompt caching. The resolver, decomposer, planner and refiner send a
stable system message and a variable human message. On the
anthropicwire only, the last system block carriescache_control: {"type": "ephemeral"}(a 5-minute cache). Anthropic renders tools, then system, then messages, so the breakpoint caches the tool schema and the system prompt; the question follows it. The answer synthesizer sends no system message, so nothing is marked. Other providers send nocache_control. - Whether a prefix clears the minimum. On Chinook the planner's cached
prefix is its system prompt (about 3.5k tokens, most of it the retrieved
schema) plus the
PlanModeltool schema (about 2k): above every model's minimum. The decomposer's (about 0.7k of system prompt plus a 1.4k tool schema) and the refiner's (about 2.7k, no tool) clear Opus 5's 512 and Sonnet 5's 1,024 but not Haiku 4.5's 4,096, so on Haiku they are silently not cached. The resolver's answerability check (datasourceresolver) is marked the same way, but its prefix (about 260 tokens of system prompt plus a small tool schema) is below every Claude model's minimum, so it is never cached. The decomposer's prefix is the same for every question; the planner's and refiner's include the retrieved tables, so they hit when a sub-query retrieves the same tables again (a retry, or a similar question). max_tokensis 16,000 per call unless the agent setsmax_tokens: every node call but the streamed answer is non-streaming, and the SDK refuses a non-streaming call whosemax_tokenscould outlast its timeout.- Usage. Cache reads and writes are reported as
cached_input_tokensandcache_write_input_tokens, with each input token counted once (see Query API → usage). A cache write costs 1.25x the model'sinputrate (Anthropic's price for a 5-minute write, the only kind the engine asks for): the price table's Claude rows say so, and anllm.pricesrow without its owncache_writegets it from the wire.
nl2sql demo --record records Claude too: with an Anthropic key the recording
proxy speaks Anthropic's Messages API to https://api.anthropic.com, and stores
each answer under the same name and question an OpenAI one would get, so replay
(which answers on the OpenAI wire) serves it unchanged. The hosted demo's shipped
recordings are made this way; see
Hosted demo.
OpenRouter
A preset of openai_compatible. OpenRouter is an OpenAI-compatible gateway: a single
API key reaches Anthropic, Google, Meta and hundreds of other models. Models are
named <vendor>/<model>, for example anthropic/claude-sonnet-4.5 or
google/gemini-2.5-pro.
Ollama
A preset of openai_compatible. Ollama exposes an
OpenAI-compatible API on http://localhost:11434/v1, so it needs no api_key
and no base_url: start
the daemon, pull a model, and point llm.yaml at it.
Set base_url if the daemon is not on localhost:11434 (a remote box, or a
different port).
Ollama is supported at the transport level, not guaranteed end to end
The pipeline drives every model through with_structured_output, and the
AST planner's target is PlanModel — a recursive Pydantic schema whose
Expr references itself. That is a demanding structured-output target, and
small local models often handle it poorly: expect malformed plans, empty
fields or repeated refiner loops depending on the model you choose. Whether
a given local model works is a property of that model, not of the transport.
Prefer the largest instruct model with reliable JSON-schema/tool-calling
support that your hardware allows, and validate against your own questions
before relying on it.
Ollama also does not make the demo run fully offline: embeddings are a separate path (see below), and the demo's other steps are unchanged.
Temperature
Each agent sends exactly the temperature its config names, and seed=42:
- Omitted, it is
0.0, sent with every call. This reduces variance; it does not make output reproducible (see Determinism). temperature: nullsends no temperature parameter at all, so the model uses its own default.
Use null for models that accept only their default. Probed on 2026-09-20 with
the engine's parameters of the time (temperature=0, seed=42, strict
json_schema; the planner now sends a tool call instead):
| model | temperature: 0 |
|---|---|
gpt-5.4, gpt-5.4-mini, gpt-4.1, gpt-4.1-mini, gpt-4o |
accepted |
gpt-5.5, gpt-5-mini |
rejected: HTTP 400 "Unsupported value: 'temperature' does not support 0.0 with this model. Only the default (1) value is supported." |
The rejected models accepted the same call without temperature (still with
seed=42), so only the temperature needs to go. When a model rejects it, the
first LLM call fails with an error that names the model and the agent and says
what to set, instead of the provider's raw response:
ValueError: Model 'gpt-5.5' (LLM agent 'default') rejected the temperature
parameter (HTTP 400: Unsupported value: 'temperature' does not support 0.0 with
this model. Only the default (1) value is supported.) Set 'temperature: null'
for agent 'default' in the LLM config file so no temperature is sent to this
model.
Nothing is retried without the parameter: what the config says is what is sent.
langchain-openai would drop it silently
langchain-openai removes any temperature other than 1 from a model whose
name starts with gpt-5 before the request is built, so gpt-5.4 would
never receive the 0.0 it accepts. The registry sets the configured value
after the client is constructed, so the config - not the model name -
decides what is sent.
Per-node models
The six pipeline nodes that call a model, indexing enrichment and eval
seeding each have an agent. An agent not under agents runs on the top level:
agents key |
node |
|---|---|
curated_matcher |
picks the verified query that answers the question, if any (one short call; only when curated answers are configured and a candidate clears the floor) |
datasource_resolver |
checks that the connected data can answer the question at all (one short call; refuses with QUESTION_NOT_ANSWERABLE) |
decomposer |
splits the question into sub-queries |
ast_planner |
writes the query plan (the largest prompt: it carries the schema) |
refiner |
explains a failed plan so the planner can retry |
answer_synthesizer |
writes the final answer from the rows |
indexing_enrichment |
writes missing schema descriptions during nl2sql index --enrich (optional, off by default) |
eval_seeder |
drafts eval questions with typed plans during nl2sql eval seed (Benchmark your own database) |
Any other key is an error with "did you mean". An agent takes every field it
sets (provider, model, temperature, api_key, base_url,
structured_output, parse_retries, max_tokens, timeout_s, max_retries, headers) and
inherits the rest from the top level. Inheritance follows which fields were written, so
temperature: null on an agent still means "send none". Each node can be on a
different provider, and so a different wire type. For example, plan on Claude
and run everything else on OpenAI, with one cheaper node that only accepts its
default temperature:
provider: openai
model: gpt-5.4
temperature: 0.0
agents:
ast_planner:
provider: anthropic
model: claude-opus-5
temperature: null # Claude Opus 5 rejects a temperature
answer_synthesizer:
model: gpt-5-mini # on OpenAI, inherited
temperature: null # gpt-5-mini rejects temperature=0
Here curated_matcher, datasource_resolver, decomposer and refiner run on the top level. With no api_key, the
planner reads ANTHROPIC_API_KEY and the others OPENAI_API_KEY. Each node
keeps its own key, endpoint and cache behaviour: the planner's system prompt is
cache-marked for Anthropic, the others are sent unmarked to OpenAI. The model
each call actually used is recorded per node in QueryResult.usage.calls and
in a run trace's llm.by_node (see Debugging).
In the demo, the playground's Settings page writes exactly these entries
into the demo folder's llm.yaml. Choosing a provider and a model for a step adds
an agents: entry with provider, model and the temperature that model
takes: 0.0, or null for gpt-5.5, gpt-5-mini, claude-opus-5 and
claude-sonnet-5. A step on the default's provider takes the default's
base_url and api_key; a step on another provider names no key (its
standard variable is read) and no base_url. A key saved on the page goes to
the demo's .env. Choosing "Default" removes the entry. The running engine
reloads its llm section from nl2sql.yaml, so the change applies to the
next question. The page offers the providers and models in
VERIFIED_MODELS (nl2sql/llm/providers.py), OpenAI and Anthropic for
now, and a provider only once its key is saved. See
the Settings page.
The playground's Pipeline page reads the same configuration back: it lists
every step of a run and, for each of the six a model decides, the provider and
model it is set to use, then what it actually spent once a question has been
asked. It is served by GET /api/pipeline from engine.about(), whose steps
(nl2sql.pipeline.steps) take their six model steps from LLM_AGENTS rather
than repeating the table above. See the Pipeline page.
On the hosted demo (nl2sql demo --hosted) the same choice is offered and
nothing is written at all. The visitor keeps one key per provider and a model
per step in their own browser tab, and both travel with each question: a key
per provider in a header of its own, and the choices in one compact
X-NL2SQL-Models header, {"astplanner": "anthropic:claude-opus-5"} (the header names a step by
its registry agent name, LLM_AGENTS in nl2sql/llm/providers.py). The
server validates every name against LLM_AGENTS and VERIFIED_MODELS, builds
each step's client for that one request from the key for the provider it names,
caches none of them, and refuses with 400 -- naming the step and the
provider -- when a step is on a provider the request brought no key for. See
Hosted demo.
When nl2sql demo picks replay or live mode at start-up it points the top
level of llm.yaml at the chosen provider. In replay and record mode every
agents: entry follows, with the local replay server or recording proxy as
base_url and the placeholder api_key (replay, proxy) it accepts,
because one fake or proxy serves every call; no key goes into the
environment. In live mode a step keeps the provider it was given; only an
entry with no provider, or one left on the replay endpoint, follows the top
level, and no api_key is written. Moving an entry between OpenAI and
Anthropic replaces a model the new provider does not serve (a gpt- model on
Anthropic, a claude- one on OpenAI) with that provider's default and its
temperature. Every provider key saved in the demo's .env is loaded at
start-up, so each step finds its own.
Any other OpenAI-style server (openai_compatible)
openai_compatible is any server with an OpenAI-style chat API. Its
base_url is required, and it reads no key variable: write api_key (usually
${env:...}) if the server needs one. Without one it sends a placeholder,
because the OpenAI client refuses to start without a key; it never sends
OPENAI_API_KEY, which belongs to OpenAI.
# llm.yaml
provider: openai_compatible
base_url: http://vllm:8000/v1
model: qwen3-32b
api_key: "${env:VLLM_KEY}" # optional for this provider
structured_output: json_schema # the default for openai_compatible
max_tokens: 8000
timeout_s: 60
max_retries: 2
headers: { X-Team: analytics }
agents:
answer_synthesizer:
provider: openai_compatible # Azure OpenAI's v1 endpoint
base_url: https://my-resource.openai.azure.com/openai/v1/
api_key: "${env:AZURE_OPENAI_API_KEY}"
model: gpt-5-4-prod # the Azure deployment name
structured_output: tool_calling
| Server | provider |
base_url |
model |
Notes |
|---|---|---|---|---|
| Azure OpenAI (v1 endpoint) | openai_compatible |
https://<resource>.openai.azure.com/openai/v1/ |
the deployment name | api_key: "${env:AZURE_OPENAI_API_KEY}"; set structured_output: tool_calling |
| vLLM | openai_compatible |
http://<host>:8000/v1 |
the served model, e.g. qwen3-32b |
json_schema works with its guided decoding |
| Ollama | ollama |
preset: http://localhost:11434/v1; set it for another host |
a pulled model, e.g. llama3.1 |
no key |
| LiteLLM proxy | openai_compatible |
http://<host>:4000/v1 |
a model name the proxy routes | api_key is the proxy's key, if it has one |
| OpenRouter | openrouter |
preset: https://openrouter.ai/api/v1 |
<vendor>/<model> |
OPENROUTER_API_KEY |
LM Studio, llama.cpp's server and a company gateway are openai_compatible
too.
Structured output
Every node asks for a typed answer (the plan, the decomposition, the answer).
structured_output says how:
structured_output |
What is sent | Use it for |
|---|---|---|
tool_calling |
a forced tool call whose arguments are the answer | OpenAI, Anthropic, Azure, OpenRouter, Ollama |
json_schema |
a response format carrying the JSON schema, which the server decodes against | local servers with schema-guided decoding (vLLM, LM Studio) |
json_mode |
JSON mode, with the schema added to the prompt, since JSON mode alone does not carry it | a server with neither of the above |
Unset, an agent uses its provider's default (the table under Providers). The method is never switched automatically: the agent's setting is what runs.
Parse-and-retry
An answer that does not parse (the parser fails, it does not validate against
the schema, or the model made no tool call) is asked for again, up to the
agent's parse_retries times (default 1). Each retry adds the model's answer
and what was wrong with it to the messages:
Your answer did not match the schema: `steps[0].op`: Field required. Answer again with only PlanModel.
If the last answer still does not parse, the node reports
MODEL_OUTPUT_INVALID, whose message names the agent and suggests another
structured_output or a stronger model; see
Error handling.
- Each retry is a model call of its own, so it is in the run's trace (one more
entry in the node's
llm_calls) and in its token usage and cost. - A streamed answer (the answer synthesizer's, when the run streams) that does not parse is retried without streaming.
parse_retriesis separate frommax_retries, which is the client library's retry of network errors and rate limits.
Timeouts, retries and headers
timeout_s, max_retries and headers go to the client library
(ChatOpenAI or ChatAnthropic) as its timeout, max_retries and
default_headers; max_tokens as its max_tokens. Left unset, the client
library's own default holds (for Anthropic's max_tokens, 16,000). These
retries are the client library's, for network errors and rate limits.
Clients are built when the LLM is set
The engine builds every agent's client when it is created, and again when the LLM is replaced, without calling a model. Each engine keeps its clients in its own cache (an LRU of 16), keyed on the agent's resolved settings: every field of the agent, and the API key. The key and each header value enter the cache key only as a SHA-256 digest, so a cache key can be logged and never holds a secret. Agents with equal settings share one client: seven agents on one model build one client.
A missing key does not stop the engine from starting:
- Building the engine (which
nl2sql index, the API and the demo all do) does not require a chat provider key. A machine with no key can still run key-free work such as indexing withembeddings: {provider: local}. That holds for indexing because theindexing_enrichmentpass is genuinely optional: it is skipped, not merely deferred to a later failure. engine.check()reports every agent whose key does not resolve, without calling a model. Using that agent raises the same error, naming the agent, the provider and the environment variable to set:
ValueError: LLM agent 'default' uses provider 'openai', which requires an API
key, but none could be resolved. Set OPENAI_API_KEY ... or in the environment,
or give the agent an 'api_key' in the LLM config file.
The key is read when the clients are built. A key set in the environment after the engine started is used only once the LLM is set again.
Genuine misconfiguration fails immediately: an unknown provider, or an agent with no model.
Replacing the LLM at runtime
engine.llm.replace swaps the whole llm section at once, while the engine
runs:
from nl2sql import LLM, AgentLLM
change = engine.llm.replace(
LLM(provider="anthropic", model="claude-sonnet-5-5",
agents={"ast_planner": AgentLLM(model="claude-opus-5-5")}),
by=user, # optional: the UserContext making the change
)
print(change.changes) # {"default": {"old": "openai/gpt-5.4", "new": "anthropic/claude-sonnet-5-5"}, ...}
- Validated like the file. The new section is checked as the
llmsection ofnl2sql.yamlis: every provider must be registered and installed, and every agent's API key must resolve. If anything is wrong it raisesConfigErrorand nothing changes. No model is called. - Atomic. The new clients are built first, then the engine's LLM and
engine.config.llmare swapped together. A query reads the engine's LLM once, when it starts: queries already running finish on the old LLM, and every query that starts afterwards uses the new one. No query mixes the two. - Logged and audited. One INFO line on the
nl2sql.llmlogger and onellm.changedaudit record name who made the change (by's user and roles when given, otherwise the entry point,pythonby default) and, for each agent whose provider or model changed, the old and the new one. They never hold a key, a key reference or a header.
INFO nl2sql.llm: LLM replaced by user=u42 roles=[admin] via api: default: openai/gpt-5.4 -> anthropic/claude-sonnet-5-5; ast_planner: openai/gpt-5.4 -> anthropic/claude-opus-5-5
- Not saved. Nothing is written to disk, so a restart goes back to the config file.
- Cached clients. Swapping back to an earlier LLM reuses the clients built
for it. A client that falls out of the cache is closed (its
close(), when it has one) once no running query uses it. - Plans follow the model. The plan cache key includes the planner's provider and model, so after a swap a plan written by another model is never served. Swapping back hits the old plans again. See Determinism → The plan cache.
replace also takes the section as written in nl2sql.yaml, as a mapping,
checked by the same rules as the file. A ${...} reference is kept in a secret
field (api_key, a headers value) and is an error anywhere else, so data that did not come
from your files never reads the environment. nl2sql-api serves it as the
admin-only PUT /api/v1/settings/llm: the body is the
section as JSON, and a bad one is HTTP 422 with the ConfigError text. The
playground's model switch calls replace too. engine.llm.list() shows the
model each agent runs on.
Embeddings are a separate path
None of Anthropic, OpenRouter or Ollama covers embeddings — they are not routed through
LLMRegistry at all. Each engine builds its own embedder from its
embeddings section: {provider: local} embeds with the
key-free ONNX all-MiniLM-L6-v2 model bundled with chromadb, and
{provider: openai} reads OPENAI_API_KEY (or embeddings.api_key).
Switching embedding providers requires a re-index.
Prices
Every model call is priced, so QueryResult.usage reports a cost, from a
dated price table the engine ships (nl2sql/llm/prices.py, USD per million
tokens, each row with its source), with prices in the config adding a model
or replacing a row (a negotiated rate, a fine-tune):
| Model | Input | Cached input | Cache write | Output |
|---|---|---|---|---|
| gpt-5.4 | 2.50 | 0.25 | 2.50 | 15.00 |
| gpt-5.4-mini | 0.75 | 0.075 | 0.75 | 4.50 |
| claude-opus-5 | 5.00 | 0.50 | 6.25 | 25.00 |
| claude-sonnet-5 | 2.00 | 0.20 | 2.50 | 10.00 |
| claude-haiku-4-5 | 1.00 | 0.10 | 1.25 | 5.00 |
The Claude rows match Anthropic's first-party prices, with the 5-minute
cache write (1.25x input; the engine never asks for the 1-hour cache, which
costs 2x). The OpenAI rows still need verifying against OpenAI's published
prices (owner to confirm); put a row in llm.prices if yours differ.
The table was checked on 2026-09-21; engine.llm.prices() returns it as the
engine uses it, llm.prices applied: checked_on, and per model the four
rates, verified, source (table or config) and citation.
- Exact names, overrides first. A call is priced from
llm.pricesby the model name the provider reports, then by the name configured, and only then from the table, by either name. So a negotiated rate for a deployment alias (acme-gpt) holds even when the provider reports a table model (gpt-5.4), and a dated snapshot the provider reports (gpt-5.4-2026-03-05) is priced by its configured name. Matches are exact:gpt-5.4never pricesgpt-5.4-mini. A rate is never negative. - Unknown is null, never zero. A model neither lists has a
nullcost, and so does any total that includes one of its calls. A token count the provider did not report (a cache read, reasoning tokens) isnulltoo. - Each part at its own rate. Cache reads at
cached_input, cache writes atcache_write, the rest of the prompt atinput, and the output, reasoning tokens included, atoutput. In apricesrow,cached_inputdefaults toinput, andcache_writetoinputtimes the wire's write premium: 1.25 for Claude (provider: anthropic, whose 5-minute cache write Anthropic bills at 1.25x input) and 1 for the OpenAI wire. - A cost cap needs prices. With
settings.pipeline.max_cost_usdset, every model thellmsection names must have a price, or the engine does not build (aConfigErrornaming each model, with the fixadd llm.prices.<model>);engine.llm.replacerefuses such a model the same way.
Notes
agentsoverrides give individual nodes, or indexing enrichment, their own model and temperature; see Per-node models.indexing_enrichmentis optional and runs only when asked for (nl2sql index --enrich, orenrich=Truefrom Python). Omit it, or leave its key unset, and indexing still works - the schema is indexed without LLM-written descriptions. It never replaces a comment read from the database.nl2sql init --providertakesopenai,anthropicoropenrouter, the providers with a default model, and writes no key. Foropenai_compatibleorollama, editllm.yamlas shown above.nl2sql demo --api-keypicks the provider from the key's shape (sk-ant-is Anthropic,sk-or-is OpenRouter, anything else is OpenAI).