Repository navigation
v0.2.0rc17 - Release Candidate: Halve the latency of each question, report every token, stop opening database connections per question, and fail fast on errors that retrying cannot fix
Summary
0.2.0rc16 made the documented path work. This release is about what each question costs once it works: LLM calls, tokens, database connections, and how long an error takes to surface. The numbers below were measured with the same harness on 0.2.0rc16 and on this code: a local fake of the OpenAI API (so each request can be counted), pg_stat_database for connections, and live runs against the real OpenAI and Anthropic APIs. The last two rows of the table describe behavior covered by tests, not measurements.
Behavior changes:
- The LLM no longer reviews its own SQL by default (highlight 1).
tokens_usednow counts every LLM call, so it is higher than before whenever a question needed retries or repairs (highlight 2).- Authentication and request errors are no longer retried (highlight 5).
Why this release matters
| Measured per question | rc16 | rc17 |
|---|---|---|
LLM calls per ask() |
2 (generation + LLM review) | 1 (2 with llm_validation=True) |
ask() latency, live OpenAI |
~6 s | ~4 s |
ask() latency, live Anthropic |
~4 s | ~2 s |
tokens_used vs tokens actually spent, simple question |
698 of 1220 | 698 of 698 |
tokens_used vs tokens actually spent, multi-table question |
1380 of 2452 | 1380 of 1380 |
New database connections per ask() in the default mode, simple / multi-table |
10 / 41 | 0 / 0 |
ANALYZE of the whole database on the first connect() and every 24 h |
yes | no |
| Invalid API key | 8.1 s and 3 requests to fail | 0.01 s, 1 request |
| Model answer that is not the expected JSON | 3 attempts with 4-10 s waits | asked again once, no wait, both answers counted |
Model answer cut off at max_tokens |
3 attempts, "Invalid JSON response" | 1 attempt, says to raise max_tokens |
For the two measured questions, the schema context sent to the model is byte-identical to rc16's: the prompt did not change, only what happens around it. Executing the query (validate=True) still opens one connection, as in rc16.
Highlights
1. The LLM no longer reviews its own SQL by default (behavior change)
Every ask() made a second LLM call that sent the whole schema context back to the model to review the SQL. It doubled the latency of each question, and its tokens were not reported. That review predates every other check: column validation, dialect rules, semantic rules, the read-only check and execution against the database were all added later and run without extra LLM calls. Execution modes still validate against the database, which is a stronger signal than asking the model.
In the default generate_only mode nothing executes the query, so the generated SQL is now checked only by those deterministic rules. If you relied on the LLM review in that mode, use validate=True to run the query against the database, or turn the review back on.
Turn the review back on with any of:
nlp = await nlp2sql.connect(database_url, provider=provider, llm_validation=True)export NLP2SQL_LLM_VALIDATION=true
nlp2sql query --llm-validation ...NLP2SQL.validate(sql) still asks the LLM: there the review is the request.
2. tokens_used counts every LLM call
It used to report only the last generation call. It now adds up the generation, the column and semantic retries, the execution repair and the LLM review when enabled. result.metadata["token_usage"] has the breakdown: total, generation, validation, llm_calls.
3. No database connections per question
Each ask() called get_table_info() for every table in the schema context: 5 queries, each on a new connection, per table. The context is now built from the table list that connect() already loaded with one bulk query. There is still no connection pool, on purpose: an event-loop-bound pool broke nlp2sql inside agent tools (#35).
4. No ANALYZE on connect
PostgreSQLRepository.refresh_schema() ran ANALYZE on the whole database on the first connect() and every 24 hours. Nothing the library reads depends on it. refresh_schema() now drops the cached table list instead, so the next get_tables() reads the database again. RedshiftRepository.refresh_schema(), which did nothing, does the same.
5. Typed provider errors, retried only when retrying can help
New subclasses of ProviderException, raised by the OpenAI, Anthropic and Gemini adapters and available as the __cause__ of the QueryGenerationException raised by ask():
| Exception | When | Retried |
|---|---|---|
ProviderAuthenticationError |
HTTP 401/403 | no |
ProviderRequestError |
other 4xx (unknown model, bad request), Anthropic refusals, Gemini safety blocks | no |
ProviderRateLimitError |
HTTP 429 | yes, with backoff |
ProviderUnavailableError |
408/409/5xx, timeouts, connection errors | yes, with backoff |
ProviderResponseFormatError |
the model answered twice without the expected JSON | asked again once, no wait |
An answer cut off at max_tokens is not asked for again (the same limit would cut it again); it fails with a ProviderException that says to raise max_tokens.
Major code changes
Library
src/nlp2sql/schema/manager.py: schema context from the cached table listsrc/nlp2sql/services/query_service.py: optional LLM review, token accounting across every callsrc/nlp2sql/config/settings.py:NLP2SQL_LLM_VALIDATIONsrc/nlp2sql/__init__.py,src/nlp2sql/client.py,src/nlp2sql/cli.py:llm_validationoption and--llm-validationflagsrc/nlp2sql/adapters/postgres_repository.py,src/nlp2sql/adapters/redshift_adapter.py:refresh_schema()drops the table cache, noANALYZEsrc/nlp2sql/adapters/_provider_errors.py(new): error classification and the malformed-output retrysrc/nlp2sql/adapters/openai_adapter.py,anthropic_adapter.py,gemini_adapter.py: typed errors, transient-only retries, truncation checks, tokens of the LLM review; Anthropic and Gemini buildTokenLimitExceptioncorrectlysrc/nlp2sql/exceptions/__init__.py: the five new exceptions
Tests and CI
- New:
test_cost_per_question.py(LLM calls, tokens, database sessions andlast_analyzeper question, in the CI integration lane),test_provider_errors.py,test_schema_context_cache.py,test_refresh_schema.py - Extended:
test_query_service_contract.py,test_retrieval_pipeline.py,fakes/fake_openai.py(token usage, raw answers,finish_reason) .github/workflows/ci.yml: the integration lane runs the cost-per-question suite
Documentation changes
docs/API.md:llm_validation,tokens_usedandtoken_usage, and a corrected error-handling example (errors arrive as the__cause__ofQueryGenerationException)docs/CONFIGURATION.md:NLP2SQL_LLM_VALIDATIONand what each execution mode checksCHANGELOG.md:0.2.0rc17entry
Versioning
Version updated to 0.2.0rc17 in pyproject.toml, src/nlp2sql/__init__.py, src/nlp2sql/config/settings.py and uv.lock. MCP server aligned in mcp_server/pyproject.toml ([project] and [tool.mcp]) and mcp_server/__init__.py. tests/test_version_consistency.py checks all of them.
Verification
- Unit suite: 344 passed.
- Integration (
pytest -m integration, Docker database): 47 passed; 9 environment skips (Redshift/LocalStack not running). - CI green on pull requests #49 and #50 (
test,pgvector-integration,clean-install). - The rc16 field-test harness re-run against the wheel in a fresh environment without the lock file: the rows for LLM calls, tokens, database connections,
ANALYZEand the invalid API key. The malformed and truncated answer rows are covered by unit tests through the real OpenAI SDK and mocked Anthropic and Gemini clients. - Live gates against the real APIs: OpenAI (
gpt-5.6-luna, on the #49 wheel) withtokens_used=1568over 1 call, and Anthropic (claude-sonnet-5, on the #50 wheel) withtokens_used=1125over 1 call; both SQL valid and executed. Both models answered with valid JSON, so the malformed-output retry was not exercised live. - The new tests fail on the code they fix: the #49 tests on
0.2.0rc16, the #50 tests on #49. - Unit and integration suites re-run on the release commit (
5790899).
Known limitations and follow-ups
- The library still logs to stdout without configuration, including user questions and raw model responses;
NLP2SQL_LOG_LEVELis not used. QueryResultdoes not expose validation reasons or result rows.import nlp2sqltakes about 3 s (scikit-learn is imported at module level), and a warm start with OpenAI embeddings pays one embedding request.- CLI:
inspectprints the database password and ignores its filter flags,providers testreports success without calling the API,cache infodoes not find the FAISS index;NLP2SQL.statsfails inside a running event loop. - Gemini still uses the end-of-life
google-generativeaiSDK; the Redshift integration suite is tracked in #45.
Closes
Files changed
- 28 files changed, 1096 insertions(+), 129 deletions(-)
.github/workflows/ci.ymlCHANGELOG.mddocs/API.mddocs/CONFIGURATION.mdmcp_server/__init__.pymcp_server/pyproject.tomlpyproject.tomlsrc/nlp2sql/__init__.pysrc/nlp2sql/adapters/_provider_errors.py(new)src/nlp2sql/adapters/anthropic_adapter.pysrc/nlp2sql/adapters/gemini_adapter.pysrc/nlp2sql/adapters/openai_adapter.pysrc/nlp2sql/adapters/postgres_repository.pysrc/nlp2sql/adapters/redshift_adapter.pysrc/nlp2sql/cli.pysrc/nlp2sql/client.pysrc/nlp2sql/config/settings.pysrc/nlp2sql/exceptions/__init__.pysrc/nlp2sql/schema/manager.pysrc/nlp2sql/services/query_service.pytests/fakes/fake_openai.pytests/test_cost_per_question.py(new)tests/test_provider_errors.py(new)tests/test_query_service_contract.pytests/test_refresh_schema.py(new)tests/test_retrieval_pipeline.pytests/test_schema_context_cache.py(new)uv.lock