nlp2sql 0.2.0rc16__tar.gz → 0.2.0rc17__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/.github/workflows/ci.yml +4 -3
  2. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/CHANGELOG.md +53 -0
  3. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/PKG-INFO +1 -1
  4. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/docs/API.md +31 -11
  5. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/docs/CONFIGURATION.md +7 -0
  6. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/mcp_server/__init__.py +1 -1
  7. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/mcp_server/pyproject.toml +2 -2
  8. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/pyproject.toml +1 -1
  9. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/__init__.py +14 -1
  10. nlp2sql-0.2.0rc17/src/nlp2sql/adapters/_provider_errors.py +99 -0
  11. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/adapters/anthropic_adapter.py +58 -19
  12. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/adapters/gemini_adapter.py +66 -32
  13. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/adapters/openai_adapter.py +43 -23
  14. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/adapters/postgres_repository.py +11 -21
  15. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/adapters/redshift_adapter.py +11 -4
  16. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/cli.py +8 -0
  17. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/client.py +6 -0
  18. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/config/settings.py +3 -1
  19. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/exceptions/__init__.py +33 -0
  20. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/schema/manager.py +6 -1
  21. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/services/query_service.py +61 -4
  22. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/tests/fakes/fake_openai.py +24 -4
  23. nlp2sql-0.2.0rc17/tests/test_cost_per_question.py +117 -0
  24. nlp2sql-0.2.0rc17/tests/test_provider_errors.py +267 -0
  25. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/tests/test_query_service_contract.py +63 -0
  26. nlp2sql-0.2.0rc17/tests/test_refresh_schema.py +46 -0
  27. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/tests/test_retrieval_pipeline.py +2 -0
  28. nlp2sql-0.2.0rc17/tests/test_schema_context_cache.py +58 -0
  29. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/uv.lock +1 -1
  30. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/.claude/commands/db.md +0 -0
  31. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/.claude/commands/quality.md +0 -0
  32. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/.claude/commands/test.md +0 -0
  33. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/.claude/rules/adapters.md +0 -0
  34. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/.claude/rules/architecture.md +0 -0
  35. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/.claude/rules/python-style.md +0 -0
  36. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/.claude/rules/testing.md +0 -0
  37. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/.claude/settings.json +0 -0
  38. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  39. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  40. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/.github/scripts/verify_release_tag.py +0 -0
  41. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/.github/workflows/publish-pypi.yml +0 -0
  42. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/.github/workflows/publish-testpypi.yml +0 -0
  43. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/.gitignore +0 -0
  44. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/.mcp.json.example +0 -0
  45. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/.python-version +0 -0
  46. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/CLAUDE.md +0 -0
  47. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/CONTRIBUTING.md +0 -0
  48. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/LICENSE +0 -0
  49. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/README.md +0 -0
  50. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/SECURITY.md +0 -0
  51. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/assets/nlp2sql-logo.png +0 -0
  52. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/benchmarks/enterprise_benchmark_results.json +0 -0
  53. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/docker/.env.example +0 -0
  54. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/docker/README.md +0 -0
  55. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/docker/docker-compose.yml +0 -0
  56. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/docker/get-db-urls.sh +0 -0
  57. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/docker/init-schema.sql +0 -0
  58. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/docker/large-schema.sql +0 -0
  59. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/docker/localstack-init.sh +0 -0
  60. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/docker/test-redshift.sh +0 -0
  61. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/docs/ARCHITECTURE.md +0 -0
  62. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/docs/ENTERPRISE.md +0 -0
  63. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/docs/Redshift.md +0 -0
  64. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/examples/README.md +0 -0
  65. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/examples/advanced/test_multiple_providers.py +0 -0
  66. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/examples/advanced/test_simple_api.py +0 -0
  67. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/examples/database_specific/test_odoo_integration.py +0 -0
  68. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/examples/default_examples.json +0 -0
  69. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/examples/documentation/real_world_example.py +0 -0
  70. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/examples/getting_started/basic_usage.py +0 -0
  71. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/examples/getting_started/simple_demo.py +0 -0
  72. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/examples/getting_started/test_api_setup.py +0 -0
  73. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/examples/schema_management/test_auto_schema.py +0 -0
  74. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/examples/schema_management/test_schema_filters_comprehensive.py +0 -0
  75. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/examples/strands-fastapi-agent/.dockerignore +0 -0
  76. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/examples/strands-fastapi-agent/.env.example +0 -0
  77. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/examples/strands-fastapi-agent/DOCKER_README.md +0 -0
  78. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/examples/strands-fastapi-agent/DOCKER_SETUP.md +0 -0
  79. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/examples/strands-fastapi-agent/DOCKER_TESTING_SUMMARY.md +0 -0
  80. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/examples/strands-fastapi-agent/Dockerfile +0 -0
  81. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/examples/strands-fastapi-agent/INDEX.md +0 -0
  82. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/examples/strands-fastapi-agent/README.md +0 -0
  83. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/examples/strands-fastapi-agent/docker/init-ecommerce-schema.sql +0 -0
  84. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/examples/strands-fastapi-agent/docker-compose.yml +0 -0
  85. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/examples/strands-fastapi-agent/main.py +0 -0
  86. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/examples/strands-fastapi-agent/pyproject.toml +0 -0
  87. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/examples/strands-fastapi-agent/test-docker-endpoints.sh +0 -0
  88. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/install-dev.sh +0 -0
  89. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/mcp_server/README.md +0 -0
  90. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/mcp_server/example_config.json +0 -0
  91. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/mcp_server/server.py +0 -0
  92. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/adapters/__init__.py +0 -0
  93. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/adapters/default_error_classifier.py +0 -0
  94. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/adapters/default_repair_policy.py +0 -0
  95. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/adapters/dict_semantic_resolver.py +0 -0
  96. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/adapters/file_semantic_resolver.py +0 -0
  97. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/adapters/local_embedding_adapter.py +0 -0
  98. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/adapters/noop_semantic_resolver.py +0 -0
  99. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/adapters/noop_semantic_validator.py +0 -0
  100. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/adapters/openai_embedding_adapter.py +0 -0
  101. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/adapters/pgvector_example_repository.py +0 -0
  102. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/adapters/regex_query_validator.py +0 -0
  103. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/adapters/schema_repository_execution_adapter.py +0 -0
  104. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/config/__init__.py +0 -0
  105. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/core/__init__.py +0 -0
  106. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/core/database_prompts.py +0 -0
  107. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/core/entities.py +0 -0
  108. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/core/provider_config.py +0 -0
  109. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/core/result.py +0 -0
  110. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/core/runtime.py +0 -0
  111. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/core/sql_keywords.py +0 -0
  112. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/core/sql_safety.py +0 -0
  113. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/factories/__init__.py +0 -0
  114. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/factories/repository_factory.py +0 -0
  115. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/ports/__init__.py +0 -0
  116. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/ports/ai_provider.py +0 -0
  117. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/ports/cache.py +0 -0
  118. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/ports/embedding_provider.py +0 -0
  119. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/ports/error_classifier.py +0 -0
  120. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/ports/example_repository.py +0 -0
  121. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/ports/query_execution.py +0 -0
  122. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/ports/query_optimizer.py +0 -0
  123. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/ports/query_safety.py +0 -0
  124. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/ports/query_validator.py +0 -0
  125. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/ports/repair_policy.py +0 -0
  126. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/ports/schema_repository.py +0 -0
  127. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/ports/schema_strategy.py +0 -0
  128. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/ports/semantic_resolver.py +0 -0
  129. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/ports/semantic_validator.py +0 -0
  130. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/schema/__init__.py +0 -0
  131. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/schema/analyzer.py +0 -0
  132. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/schema/embedding_manager.py +0 -0
  133. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/schema/example_loader.py +0 -0
  134. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/schema/example_store.py +0 -0
  135. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/services/__init__.py +0 -0
  136. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/services/example_selection_service.py +0 -0
  137. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/services/prompt_assembly_service.py +0 -0
  138. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/services/query_analysis_service.py +0 -0
  139. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/services/query_repair_service.py +0 -0
  140. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/services/semantic_resolution_service.py +0 -0
  141. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/services/semantic_validation_service.py +0 -0
  142. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/services/sql_intent_planning_service.py +0 -0
  143. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/utils/__init__.py +0 -0
  144. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/utils/artifact_loader.py +0 -0
  145. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/utils/helpers.py +0 -0
  146. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/utils/semantic_prompt.py +0 -0
  147. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/src/nlp2sql/utils/storage.py +0 -0
  148. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/test_tmp/.gitignore +0 -0
  149. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/tests/conftest.py +0 -0
  150. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/tests/data/gold_questions.json +0 -0
  151. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/tests/fakes/__init__.py +0 -0
  152. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/tests/mcp/__init__.py +0 -0
  153. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/tests/mcp/test_sql_security.py +0 -0
  154. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/tests/test_artifact_loader.py +0 -0
  155. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/tests/test_basic.py +0 -0
  156. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/tests/test_batch_scoring.py +0 -0
  157. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/tests/test_cli_semantic_support.py +0 -0
  158. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/tests/test_cli_setup.py +0 -0
  159. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/tests/test_client_hooks.py +0 -0
  160. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/tests/test_column_validation.py +0 -0
  161. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/tests/test_connect_embeddings.py +0 -0
  162. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/tests/test_database_prompts.py +0 -0
  163. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/tests/test_dsl_integration.py +0 -0
  164. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/tests/test_embedding_manager.py +0 -0
  165. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/tests/test_false_invalid_regression.py +0 -0
  166. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/tests/test_first_run.py +0 -0
  167. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/tests/test_integration.py +0 -0
  168. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/tests/test_model_defaults.py +0 -0
  169. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/tests/test_pgvector_example_repository.py +0 -0
  170. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/tests/test_postgres_integration.py +0 -0
  171. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/tests/test_provider_compat_llm.py +0 -0
  172. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/tests/test_redshift_integration.py +0 -0
  173. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/tests/test_repository_cache.py +0 -0
  174. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/tests/test_repository_factory.py +0 -0
  175. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/tests/test_sql_safety.py +0 -0
  176. {nlp2sql-0.2.0rc16 → nlp2sql-0.2.0rc17}/tests/test_version_consistency.py +0 -0
@@ -90,8 +90,9 @@ jobs:
90
90
  NLP2SQL_TEST_POSTGRES_DB: testdb
91
91
  run: pytest tests/test_pgvector_example_repository.py -m integration -q
92
92
 
93
- # README Quick Start per provider and the false-invalid gold set, with the
94
- # OpenAI SDK pointed at tests/fakes/fake_openai.py (no keys, no cost).
93
+ # README Quick Start per provider, the false-invalid gold set and the cost of
94
+ # one question (LLM calls, tokens, database connections), with the OpenAI SDK
95
+ # pointed at tests/fakes/fake_openai.py (no keys, no cost).
95
96
  - name: First-run regression tests
96
97
  env:
97
98
  PYTHONPATH: src
@@ -101,7 +102,7 @@ jobs:
101
102
  NLP2SQL_TEST_POSTGRES_USER: testuser
102
103
  NLP2SQL_TEST_POSTGRES_PASS: testpass
103
104
  NLP2SQL_TEST_POSTGRES_DB: testdb
104
- run: pytest tests/test_first_run.py tests/test_false_invalid_regression.py -m integration -q
105
+ run: pytest tests/test_first_run.py tests/test_false_invalid_regression.py tests/test_cost_per_question.py -m integration -q
105
106
 
106
107
  # Installs the built wheel the way a user does: fresh environment, no lock
107
108
  # file, latest dependency versions. Catches undeclared dependencies (rc13-rc15
@@ -22,6 +22,59 @@ pre-1.0.
22
22
 
23
23
  ## [Unreleased]
24
24
 
25
+ ## [0.2.0rc17] - 2026-10-20
26
+
27
+ ### Changed
28
+ - **User-visible: the LLM no longer reviews its own SQL by default.** Every `ask()`
29
+ made a second LLM call that sent the whole schema context back to the model; it
30
+ doubled the latency of each question and its tokens were not reported. Column,
31
+ dialect, semantic and read-only checks still run, and execution modes still
32
+ validate against the database. Turn the review back on with
33
+ `connect(..., llm_validation=True)`, `NLP2SQL_LLM_VALIDATION=true` or
34
+ `nlp2sql query --llm-validation`. `NLP2SQL.validate(sql)` still asks the LLM.
35
+ - `QueryResult.tokens_used` now counts every LLM call made for the question
36
+ (retries, repairs and LLM validation); it used to report only the last
37
+ generation call. `metadata["token_usage"]` has the breakdown.
38
+ - `refresh_schema()` (PostgreSQL and Redshift) now drops the cached table list so
39
+ the next `get_tables()` reads the database again. PostgreSQL no longer runs
40
+ `ANALYZE` on the whole database on the first `connect()` and every 24 hours.
41
+ - Provider calls are retried only on rate limits, timeouts, connection errors and
42
+ provider-side errors. Authentication and request errors fail on the first
43
+ attempt, instead of after about 8 seconds and 3 requests.
44
+ - An answer from the model that is not the expected JSON (not JSON, not an object,
45
+ or without `sql`) is asked for again once, without waiting; it used to be retried
46
+ up to 3 times with 4-10 second waits. Both answers count in `tokens_used` and in
47
+ `metadata["token_usage"]["llm_calls"]`.
48
+ - An answer cut off at `max_tokens` (OpenAI `finish_reason="length"`, Anthropic
49
+ `stop_reason="max_tokens"`, Gemini `MAX_TOKENS`) fails on the first attempt with a
50
+ `ProviderException` that says to raise `max_tokens`, instead of "Invalid JSON
51
+ response" after 3 attempts. Anthropic refusals and Gemini safety blocks raise
52
+ `ProviderRequestError`.
53
+
54
+ ### Added
55
+ - `llm_validation` option on `connect()`, `create_query_service()` and
56
+ `create_and_initialize_service()`, `NLP2SQL_LLM_VALIDATION`, and
57
+ `nlp2sql query --llm-validation`.
58
+ - `ProviderAuthenticationError`, `ProviderRequestError`, `ProviderRateLimitError`
59
+ and `ProviderUnavailableError`, subclasses of `ProviderException`, raised by the
60
+ OpenAI, Anthropic and Gemini adapters and available as the `__cause__` of the
61
+ `QueryGenerationException` raised by `ask()`.
62
+ - `ProviderResponseFormatError` (subclass of `ProviderException`): the model answered
63
+ twice without the expected JSON. `details["tokens_used"]` has the tokens of both
64
+ answers.
65
+
66
+ ### Fixed
67
+ - Each `ask()` opened 5 new database connections per table in the schema context
68
+ (41 for a multi-table question). The context is now built from the table list
69
+ loaded at `connect()`, with no extra connections.
70
+ - `RedshiftRepository.refresh_schema()` was a no-op.
71
+ - The Anthropic and Gemini adapters raised `TypeError` instead of
72
+ `TokenLimitException` when the context was too large, and dropped the SDK error
73
+ as the cause of generation failures.
74
+ - The Anthropic adapter reported its own errors as "Failed to parse response: ...".
75
+ - `docs/API.md` showed catching `TokenLimitException` and `ProviderException`
76
+ directly from `ask()`; they arrive as the cause of `QueryGenerationException`.
77
+
25
78
  ## [0.2.0rc16] - 2026-10-19
26
79
 
27
80
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: nlp2sql
3
- Version: 0.2.0rc16
3
+ Version: 0.2.0rc17
4
4
  Summary: Enterprise-ready Natural Language to SQL converter with multi-provider support. Built for production scale (1000+ tables) with Clean Architecture.
5
5
  Project-URL: Homepage, https://github.com/luiscarbonel1991/nlp2sql
6
6
  Project-URL: Bug Tracker, https://github.com/luiscarbonel1991/nlp2sql/issues
@@ -57,6 +57,7 @@ nlp = await nlp2sql.connect(
57
57
  | `hooks` | `ExecutionHooks | None` | Execution, classification, and repair hooks |
58
58
  | `semantic_hooks` | `SemanticHooks | None` | Semantic resolver, validator, and optional default semantic context |
59
59
  | `semantic_context` | `SemanticContext | dict | None` | Default semantic context applied to future `ask()` calls |
60
+ | `llm_validation` | `bool | None` | Also ask the LLM to review each query (a second call). Default: `NLP2SQL_LLM_VALIDATION`, off |
60
61
 
61
62
 
62
63
  ### `hooks`
@@ -157,7 +158,7 @@ If you do not pass `execution_mode`, the library infers it from `validate` and `
157
158
  | `explanation` | `str | None` | Optional natural-language explanation |
158
159
  | `provider` | `str` | Provider that generated the SQL |
159
160
  | `database_type` | `str` | Postgres or Redshift |
160
- | `tokens_used` | `int` | Tokens consumed |
161
+ | `tokens_used` | `int` | Tokens of every LLM call made for the question |
161
162
  | `generation_time_ms` | `float` | End-to-end generation time |
162
163
  | `examples_used` | `int` | Count of selected examples |
163
164
  | `metadata` | `dict[str, object]` | Runtime metadata for debugging and integration |
@@ -175,6 +176,7 @@ The metadata payload is intentionally rich. Common fields include:
175
176
  | `selected_examples` | Few-shot examples selected for prompting |
176
177
  | `repair_attempts` | Repair loop details when repair is enabled |
177
178
  | `execution_validation` | Execution-time validation outcome |
179
+ | `token_usage` | `total`, `generation`, `validation` and `llm_calls` for the question |
178
180
  | `analysis` | Query analysis hints |
179
181
 
180
182
 
@@ -272,29 +274,47 @@ print(result["validation"]["is_valid"])
272
274
 
273
275
  ## Error Handling
274
276
 
277
+ `ask()` raises `QueryGenerationException` when SQL cannot be generated. The underlying error is
278
+ its `__cause__`, typed so callers can react to each case:
279
+
275
280
  ```python
276
281
  from nlp2sql.exceptions import (
277
- ProviderException,
282
+ ProviderAuthenticationError,
283
+ ProviderRateLimitError,
284
+ ProviderRequestError,
285
+ ProviderResponseFormatError,
286
+ ProviderUnavailableError,
278
287
  QueryGenerationException,
279
288
  SchemaException,
280
- SecurityException,
281
289
  TokenLimitException,
282
290
  )
283
291
 
284
292
  try:
285
293
  result = await nlp.ask("Show revenue by month")
286
- except TokenLimitException:
287
- print("The schema context is too large. Add schema filters.")
294
+ except QueryGenerationException as error:
295
+ cause = error.__cause__
296
+ if isinstance(cause, ProviderAuthenticationError):
297
+ print("Invalid or unauthorized API key (HTTP 401/403).")
298
+ elif isinstance(cause, TokenLimitException):
299
+ print("The schema context is too large. Add schema filters.")
300
+ elif isinstance(cause, (ProviderRateLimitError, ProviderUnavailableError)):
301
+ print("Transient provider error; already retried three times.")
302
+ elif isinstance(cause, ProviderRequestError):
303
+ print("The provider rejected the request, e.g. an unknown model.")
304
+ elif isinstance(cause, ProviderResponseFormatError):
305
+ print("The model answered twice without the expected JSON.")
306
+ else:
307
+ print("The provider could not generate SQL.")
288
308
  except SchemaException:
289
309
  print("Database connection or schema discovery failed.")
290
- except QueryGenerationException:
291
- print("The provider could not generate SQL.")
292
- except ProviderException:
293
- print("Provider auth, network, or rate-limit error.")
294
- except SecurityException:
295
- print("Generated SQL failed safety checks.")
296
310
  ```
297
311
 
312
+ Only rate-limit and unavailable errors are retried with backoff; authentication and request
313
+ errors fail on the first attempt. An answer that is not the expected JSON is asked for again
314
+ once, without waiting, and `tokens_used` counts both answers. An answer cut off at `max_tokens`
315
+ is not asked for again: it fails with a `ProviderException` that says to raise `max_tokens`.
316
+ Queries that are not read-only come back with `is_valid=False` and are never executed.
317
+
298
318
  ## CLI Reference
299
319
 
300
320
  ### `nlp2sql query`
@@ -73,6 +73,7 @@ export NLP2SQL_EMBEDDING_PROVIDER=local
73
73
 
74
74
  | Variable | Default | Description |
75
75
  |----------|---------|-------------|
76
+ | `NLP2SQL_LLM_VALIDATION` | `false` | Also ask the LLM to review each generated query (a second call per question) |
76
77
  | `NLP2SQL_LOG_LEVEL` | `INFO` | Logging verbosity |
77
78
  | `NLP2SQL_CACHE_ENABLED` | `true` | Enable or disable query-result caching |
78
79
  | `NLP2SQL_ENV` | unset | Optional environment label |
@@ -184,6 +185,12 @@ The runtime has three practical execution modes.
184
185
  | `generate_and_validate` | safe validation of generated SQL against a readonly execution target |
185
186
  | `generate_validate_repair` | production-like usage where repair is worth the extra latency |
186
187
 
188
+ Every mode checks column names, dialect rules, semantic rules and that the query is read-only,
189
+ without extra LLM calls. An additional LLM review of the generated SQL is off by default: it
190
+ doubles the latency of each question, and executing the query against the database gives a
191
+ stronger signal. Turn it on with `connect(..., llm_validation=True)`,
192
+ `NLP2SQL_LLM_VALIDATION=true` or `nlp2sql query --llm-validation`.
193
+
187
194
  ### Python
188
195
 
189
196
  ```python
@@ -1,3 +1,3 @@
1
1
  """nlp2sql MCP Server - Expose nlp2sql as MCP tools."""
2
2
 
3
- __version__ = "0.2.0rc16"
3
+ __version__ = "0.2.0rc17"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nlp2sql-mcp"
3
- version = "0.2.0rc16"
3
+ version = "0.2.0rc17"
4
4
  description = "MCP server for nlp2sql - Natural Language to SQL conversion"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
@@ -21,4 +21,4 @@ build-backend = "hatchling.build"
21
21
  [tool.mcp]
22
22
  name = "nlp2sql"
23
23
  description = "Convert natural language queries to SQL using multiple AI providers"
24
- version = "0.2.0rc16"
24
+ version = "0.2.0rc17"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nlp2sql"
3
- version = "0.2.0rc16"
3
+ version = "0.2.0rc17"
4
4
  description = "Enterprise-ready Natural Language to SQL converter with multi-provider support. Built for production scale (1000+ tables) with Clean Architecture."
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -46,7 +46,7 @@ from .ports.semantic_validator import SemanticValidatorPort
46
46
  from .schema.example_store import ExampleStore
47
47
  from .services.query_service import QueryGenerationService
48
48
 
49
- __version__ = "0.2.0rc16"
49
+ __version__ = "0.2.0rc17"
50
50
  __author__ = "Luis Carbonel"
51
51
  __email__ = "devhighlevel@gmail.com"
52
52
 
@@ -112,6 +112,11 @@ __all__ = [
112
112
  "NLP2SQLException",
113
113
  "SchemaException",
114
114
  "ProviderException",
115
+ "ProviderAuthenticationError",
116
+ "ProviderRequestError",
117
+ "ProviderRateLimitError",
118
+ "ProviderUnavailableError",
119
+ "ProviderResponseFormatError",
115
120
  "TokenLimitException",
116
121
  "QueryGenerationException",
117
122
  "OptimizationException",
@@ -219,6 +224,7 @@ def create_query_service(
219
224
  semantic_validator: SemanticValidatorPort | None = None,
220
225
  *,
221
226
  provider_config: Optional[ProviderConfig] = None,
227
+ llm_validation: Optional[bool] = None,
222
228
  ) -> QueryGenerationService:
223
229
  """
224
230
  Create a configured query service instance.
@@ -239,6 +245,8 @@ def create_query_service(
239
245
  provider_config: Optional ProviderConfig for full control over provider settings
240
246
  (model, temperature, max_tokens, extra_kwargs). Individual kwargs like api_key
241
247
  and model take precedence over values in provider_config.
248
+ llm_validation: Also ask the LLM to review each generated query (a second call per
249
+ question). If None, uses NLP2SQL_LLM_VALIDATION (default: off).
242
250
 
243
251
  Returns:
244
252
  Configured QueryGenerationService instance
@@ -352,6 +360,7 @@ def create_query_service(
352
360
  repair_policy=repair_policy,
353
361
  semantic_resolver=semantic_resolver,
354
362
  semantic_validator=semantic_validator,
363
+ llm_validation=llm_validation,
355
364
  )
356
365
 
357
366
 
@@ -373,6 +382,7 @@ async def create_and_initialize_service(
373
382
  semantic_validator: SemanticValidatorPort | None = None,
374
383
  *,
375
384
  provider_config: Optional[ProviderConfig] = None,
385
+ llm_validation: Optional[bool] = None,
376
386
  ) -> QueryGenerationService:
377
387
  """
378
388
  Create and initialize a query service with automatic schema loading.
@@ -393,6 +403,8 @@ async def create_and_initialize_service(
393
403
  schema_name: Database schema name (default: 'public')
394
404
  example_store: Optional pre-loaded few-shot examples for better SQL generation
395
405
  provider_config: Optional ProviderConfig for full control over provider settings
406
+ llm_validation: Also ask the LLM to review each generated query. If None, uses
407
+ NLP2SQL_LLM_VALIDATION (default: off).
396
408
 
397
409
  Returns:
398
410
  Initialized QueryGenerationService ready for queries
@@ -423,6 +435,7 @@ async def create_and_initialize_service(
423
435
  semantic_resolver,
424
436
  semantic_validator,
425
437
  provider_config=provider_config,
438
+ llm_validation=llm_validation,
426
439
  )
427
440
  await service.initialize(database_type)
428
441
  return service
@@ -0,0 +1,99 @@
1
+ """Translate provider SDK errors into nlp2sql exceptions, and decide what is retried.
2
+
3
+ The AI adapters retry only transient failures. Before, every exception was retried
4
+ three times with backoff, so an invalid API key took about 8 seconds to surface.
5
+ Works without importing any SDK: OpenAI and Anthropic errors carry ``status_code``,
6
+ google.api_core errors carry an integer ``code``.
7
+
8
+ Output that is not the expected JSON is a different failure: the request worked, the
9
+ model answered badly. ``call_with_format_retry`` asks again once, without waiting,
10
+ and counts the tokens of both answers.
11
+ """
12
+
13
+ from typing import Any, Awaitable, Callable, Dict, NamedTuple, Optional, TypeVar
14
+
15
+ import structlog
16
+
17
+ from ..exceptions import (
18
+ ProviderAuthenticationError,
19
+ ProviderException,
20
+ ProviderRateLimitError,
21
+ ProviderRequestError,
22
+ ProviderResponseFormatError,
23
+ ProviderUnavailableError,
24
+ )
25
+
26
+ logger = structlog.get_logger()
27
+
28
+ TRANSIENT_PROVIDER_ERRORS = (ProviderRateLimitError, ProviderUnavailableError)
29
+ FORMAT_RETRY_ATTEMPTS = 2
30
+
31
+ ResponseT = TypeVar("ResponseT")
32
+
33
+
34
+ def _http_status(exc: BaseException) -> Optional[int]:
35
+ for attribute in ("status_code", "code"):
36
+ value = getattr(exc, attribute, None)
37
+ if isinstance(value, int) and 100 <= value < 600:
38
+ return int(value)
39
+ return None
40
+
41
+
42
+ def _is_transport_error(exc: BaseException) -> bool:
43
+ if isinstance(exc, (TimeoutError, ConnectionError)):
44
+ return True
45
+ name = type(exc).__name__
46
+ return "Timeout" in name or "Connection" in name
47
+
48
+
49
+ def classify_provider_error(exc: BaseException, message: str) -> ProviderException:
50
+ """Return the nlp2sql exception for ``exc``; nlp2sql exceptions pass through unchanged."""
51
+ if isinstance(exc, ProviderException):
52
+ return exc
53
+
54
+ status = _http_status(exc)
55
+ if status in (401, 403):
56
+ return ProviderAuthenticationError(message, {"status_code": status})
57
+ if status == 429:
58
+ return ProviderRateLimitError(message, {"status_code": status})
59
+ if status is not None and (status >= 500 or status in (408, 409)):
60
+ return ProviderUnavailableError(message, {"status_code": status})
61
+ if status is not None and 400 <= status < 500:
62
+ return ProviderRequestError(message, {"status_code": status})
63
+ if _is_transport_error(exc):
64
+ return ProviderUnavailableError(message)
65
+ return ProviderException(message)
66
+
67
+
68
+ class ParsedGeneration(NamedTuple):
69
+ response: Any
70
+ result: Dict[str, Any]
71
+ tokens_used: int
72
+ format_retries: int
73
+
74
+
75
+ async def call_with_format_retry(
76
+ send: Callable[[], Awaitable[ResponseT]],
77
+ parse: Callable[[ResponseT], Dict[str, Any]],
78
+ tokens_of: Callable[[ResponseT], int],
79
+ *,
80
+ provider: str,
81
+ attempts: int = FORMAT_RETRY_ATTEMPTS,
82
+ ) -> ParsedGeneration:
83
+ """Send a generation request and parse it, asking again when the output is malformed.
84
+
85
+ Tokens are read before parsing, so a discarded answer still counts. Every other
86
+ error propagates on the first attempt; transient ones are retried by the caller.
87
+ """
88
+ tokens_used = 0
89
+ for attempt in range(1, attempts + 1):
90
+ response = await send()
91
+ tokens_used += tokens_of(response)
92
+ try:
93
+ return ParsedGeneration(response, parse(response), tokens_used, attempt - 1)
94
+ except ProviderResponseFormatError as error:
95
+ error.details["tokens_used"] = tokens_used
96
+ if attempt == attempts:
97
+ raise
98
+ logger.warning("Malformed model output, asking again", provider=provider, error=str(error))
99
+ raise ValueError("attempts must be at least 1")
@@ -5,14 +5,15 @@ from typing import Any, Dict, Optional
5
5
 
6
6
  import anthropic
7
7
  import structlog
8
- from tenacity import retry, stop_after_attempt, wait_exponential
8
+ from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
9
9
 
10
10
  from ..config.settings import settings
11
11
  from ..core.provider_config import ProviderConfig
12
- from ..exceptions import ProviderException, TokenLimitException
12
+ from ..exceptions import ProviderException, ProviderRequestError, ProviderResponseFormatError, TokenLimitException
13
13
  from ..ports.ai_provider import AIProviderPort, AIProviderType, QueryContext, QueryResponse
14
14
  from ..utils.helpers import first_not_none
15
15
  from ..utils.semantic_prompt import format_semantic_context_lines, format_sql_intent_plan_lines
16
+ from ._provider_errors import TRANSIENT_PROVIDER_ERRORS, call_with_format_retry, classify_provider_error
16
17
 
17
18
  logger = structlog.get_logger()
18
19
 
@@ -119,7 +120,12 @@ class AnthropicAdapter(AIProviderPort):
119
120
  """Get maximum input context size for the model."""
120
121
  return self._max_context_size
121
122
 
122
- @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=10), reraise=True)
123
+ @retry(
124
+ stop=stop_after_attempt(3),
125
+ wait=wait_exponential(multiplier=1, min=4, max=10),
126
+ retry=retry_if_exception_type(TRANSIENT_PROVIDER_ERRORS),
127
+ reraise=True,
128
+ )
123
129
  async def generate_query(self, context: QueryContext) -> QueryResponse:
124
130
  """Generate SQL query using Claude."""
125
131
  try:
@@ -133,22 +139,27 @@ class AnthropicAdapter(AIProviderPort):
133
139
  # Create messages
134
140
  messages = [{"role": "user", "content": prompt}]
135
141
 
136
- # Call Claude API (no sampling parameters: see class docstring)
137
- response = await self.client.messages.create(
138
- model=self.model,
139
- max_tokens=context.max_tokens,
140
- system=system_prompt,
141
- messages=messages,
142
+ # Call Claude API (no sampling parameters: see class docstring); malformed
143
+ # output is asked for again once
144
+ generation = await call_with_format_retry(
145
+ lambda: self.client.messages.create(
146
+ model=self.model,
147
+ max_tokens=context.max_tokens,
148
+ system=system_prompt,
149
+ messages=messages,
150
+ ),
151
+ self._parse_response,
152
+ self._total_tokens,
153
+ provider=self.provider_type.value,
142
154
  )
143
-
144
- # Parse response
145
- result = self._parse_response(response)
155
+ response, result = generation.response, generation.result
146
156
 
147
157
  # Create QueryResponse
148
158
  metadata = {
149
159
  "model": self.model,
150
160
  "input_tokens": response.usage.input_tokens,
151
161
  "output_tokens": response.usage.output_tokens,
162
+ "format_retries": generation.format_retries,
152
163
  }
153
164
 
154
165
  # Include raw response if available
@@ -159,14 +170,17 @@ class AnthropicAdapter(AIProviderPort):
159
170
  sql=result["sql"],
160
171
  explanation=result.get("explanation", ""),
161
172
  confidence=result.get("confidence", 0.8),
162
- tokens_used=response.usage.input_tokens + response.usage.output_tokens,
173
+ tokens_used=generation.tokens_used,
163
174
  provider=self.provider_type.value,
164
175
  metadata=metadata,
165
176
  )
166
177
 
167
178
  except Exception as e:
168
- logger.error("Anthropic query generation failed", error=str(e))
169
- raise ProviderException(f"Anthropic query generation failed: {e!s}")
179
+ logger.error("Anthropic query generation failed", error=str(e), error_type=type(e).__name__)
180
+ error = classify_provider_error(e, f"Anthropic query generation failed: {e!s}")
181
+ if error is e:
182
+ raise
183
+ raise error from e
170
184
 
171
185
  def _build_prompt(self, context: QueryContext) -> str:
172
186
  """Build prompt for query generation."""
@@ -256,6 +270,15 @@ Ensure the JSON is properly formatted with no syntax errors. Escape any quotes i
256
270
  def _parse_response(self, response) -> Dict[str, Any]:
257
271
  """Parse Anthropic response."""
258
272
  try:
273
+ stop_reason = getattr(response, "stop_reason", None)
274
+ if stop_reason == "max_tokens":
275
+ # An answer cut at max_tokens would be cut again, so it is not retried.
276
+ raise ProviderException("Response truncated at max_tokens (stop_reason=max_tokens); raise max_tokens")
277
+ if stop_reason == "refusal":
278
+ raise ProviderRequestError("Claude declined to answer (stop_reason=refusal)")
279
+ if not response.content:
280
+ raise ProviderResponseFormatError(f"Empty response (stop_reason={stop_reason})")
281
+
259
282
  raw_content = response.content[0].text.strip()
260
283
  content = raw_content
261
284
 
@@ -279,8 +302,8 @@ Ensure the JSON is properly formatted with no syntax errors. Escape any quotes i
279
302
  result = json.loads(content)
280
303
 
281
304
  # Validate required fields
282
- if "sql" not in result:
283
- raise ProviderException("Response missing required 'sql' field")
305
+ if not isinstance(result, dict) or "sql" not in result:
306
+ raise ProviderResponseFormatError("Response missing required 'sql' field")
284
307
 
285
308
  # Set defaults
286
309
  result.setdefault("explanation", "")
@@ -291,14 +314,21 @@ Ensure the JSON is properly formatted with no syntax errors. Escape any quotes i
291
314
 
292
315
  return result
293
316
 
317
+ except ProviderException:
318
+ raise
294
319
  except json.JSONDecodeError as e:
295
320
  # Log the problematic content
296
321
  logger.error("JSON parsing failed", content=content, error=str(e))
297
- raise ProviderException(f"Invalid JSON response: {e!s}")
322
+ raise ProviderResponseFormatError(f"Invalid JSON response: {e!s}") from e
298
323
  except Exception as e:
299
324
  logger.error("Response parsing failed", error=str(e))
300
325
  raise ProviderException(f"Failed to parse response: {e!s}")
301
326
 
327
+ @staticmethod
328
+ def _total_tokens(response) -> int:
329
+ usage = response.usage
330
+ return (getattr(usage, "input_tokens", 0) or 0) + (getattr(usage, "output_tokens", 0) or 0)
331
+
302
332
  async def _validate_token_count(self, context: QueryContext) -> None:
303
333
  """Validate that context doesn't exceed token limits."""
304
334
  # Build prompt to count tokens
@@ -315,7 +345,11 @@ Ensure the JSON is properly formatted with no syntax errors. Escape any quotes i
315
345
  available_tokens = max_context - context.max_tokens # Reserve space for response
316
346
 
317
347
  if total_tokens > available_tokens:
318
- raise TokenLimitException(f"Context too large: {total_tokens} tokens exceeds limit of {available_tokens}")
348
+ raise TokenLimitException(
349
+ f"Context too large: {total_tokens} tokens exceeds limit of {available_tokens}",
350
+ tokens_used=total_tokens,
351
+ max_tokens=available_tokens,
352
+ )
319
353
 
320
354
  logger.debug(
321
355
  "Token validation passed",
@@ -379,6 +413,11 @@ Check for:
379
413
  result.setdefault("issues", [])
380
414
  result.setdefault("suggestions", [])
381
415
  result.setdefault("complexity", "moderate")
416
+ # Reported so the service can account for every LLM call of a question
417
+ usage = getattr(message, "usage", None)
418
+ input_tokens = getattr(usage, "input_tokens", 0) or 0
419
+ output_tokens = getattr(usage, "output_tokens", 0) or 0
420
+ result["_tokens_used"] = input_tokens + output_tokens
382
421
 
383
422
  return result
384
423