text2sql-eval-toolkit 1.0.0__py3-none-any.whl
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.
- text2sql_eval_toolkit/__init__.py +107 -0
- text2sql_eval_toolkit/analysis/__init__.py +5 -0
- text2sql_eval_toolkit/analysis/error_analysis.py +335 -0
- text2sql_eval_toolkit/analysis/report_tools.py +719 -0
- text2sql_eval_toolkit/config_args.py +65 -0
- text2sql_eval_toolkit/data/__init__.py +4 -0
- text2sql_eval_toolkit/data/benchmarks.json +69 -0
- text2sql_eval_toolkit/data/test-benchmarks.json +69 -0
- text2sql_eval_toolkit/env_loader.py +55 -0
- text2sql_eval_toolkit/evaluation/__init__.py +26 -0
- text2sql_eval_toolkit/evaluation/evaluation_tools.py +759 -0
- text2sql_eval_toolkit/evaluation/llm_as_judge.py +90 -0
- text2sql_eval_toolkit/execution/__init__.py +5 -0
- text2sql_eval_toolkit/execution/execution_tools.py +1448 -0
- text2sql_eval_toolkit/execution/replace_select_tool.py +114 -0
- text2sql_eval_toolkit/inference/__init__.py +5 -0
- text2sql_eval_toolkit/inference/agentic_pipeline.py +2335 -0
- text2sql_eval_toolkit/inference/base_pipeline.py +11 -0
- text2sql_eval_toolkit/inference/baseline_llm_pipeline.py +372 -0
- text2sql_eval_toolkit/inference/inference_tools.py +769 -0
- text2sql_eval_toolkit/logging.py +54 -0
- text2sql_eval_toolkit/profiling/profiling_tools.py +185 -0
- text2sql_eval_toolkit/utils.py +302 -0
- text2sql_eval_toolkit-1.0.0.dist-info/METADATA +382 -0
- text2sql_eval_toolkit-1.0.0.dist-info/RECORD +28 -0
- text2sql_eval_toolkit-1.0.0.dist-info/WHEEL +5 -0
- text2sql_eval_toolkit-1.0.0.dist-info/licenses/LICENSE +201 -0
- text2sql_eval_toolkit-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
#
|
|
2
|
+
# Copyright IBM Corp. 2025 - 2026
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
#
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import requests
|
|
9
|
+
from typing import Any
|
|
10
|
+
from ibm_watsonx_ai import Credentials
|
|
11
|
+
from ibm_watsonx_ai.foundation_models import ModelInference
|
|
12
|
+
from text2sql_eval_toolkit.logging import get_logger
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from openai import OpenAI
|
|
16
|
+
except ImportError:
|
|
17
|
+
OpenAI = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
logger = get_logger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Text2SQLPrompt:
|
|
24
|
+
"""
|
|
25
|
+
Constructs a prompt for SQL generation from a natural language question and a database schema.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self, utterance: str, schema: dict[str, Any], db_type: str, evidence: str = None
|
|
30
|
+
):
|
|
31
|
+
self.utterance = utterance
|
|
32
|
+
self.schema = schema
|
|
33
|
+
self.prompt = (
|
|
34
|
+
# f"You are a SQL expert. Your task is to convert natural language questions into accurate SQL queries using the given {db_type} database schema.\n\n"
|
|
35
|
+
f"Your task is to convert a natural language question into an accurate SQL query using the given {db_type} database schema.\n\n"
|
|
36
|
+
f"**Question:**:\n{self.utterance}\n\n"
|
|
37
|
+
f"**Database Engine / Dialect:**:\n{db_type}\n\n"
|
|
38
|
+
f"**Schema:**\n{self.verbalize_schema(schema)}\n\n"
|
|
39
|
+
"**Instructions:**\n"
|
|
40
|
+
"- Only use columns listed in the schema.\n"
|
|
41
|
+
"- Do not use any other columns or tables not mentioned in the schema.\n"
|
|
42
|
+
"- Ensure the SQL query is valid and executable.\n"
|
|
43
|
+
"- Use proper SQL syntax and conventions.\n"
|
|
44
|
+
"- Generate a complete SQL query that answers the question.\n"
|
|
45
|
+
f"- Use the correct SQL dialect for the database, i.e., {db_type}.\n"
|
|
46
|
+
"- Do not include any explanations or comments in the SQL output.\n"
|
|
47
|
+
"- Your output must start with ```sql and end with ```.\n\n"
|
|
48
|
+
)
|
|
49
|
+
if evidence:
|
|
50
|
+
self.prompt += (
|
|
51
|
+
"***Hints***\n"
|
|
52
|
+
+ "\n".join(f"- {hint}" for hint in evidence.split("; "))
|
|
53
|
+
+ "\n\n"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
self.prompt += f"Question: {self.utterance}" # \nSQL:\n```sql\n"
|
|
57
|
+
|
|
58
|
+
def verbalize_schema(self, schema: dict[str, Any]) -> str:
|
|
59
|
+
"""
|
|
60
|
+
Verbalizes a database schema dictionary into a readable string for LLM prompts,
|
|
61
|
+
including sample values if present.
|
|
62
|
+
"""
|
|
63
|
+
lines = []
|
|
64
|
+
db_desc = schema.get("description", "")
|
|
65
|
+
if db_desc:
|
|
66
|
+
lines.append(f"Database description: {db_desc}\n")
|
|
67
|
+
tables = []
|
|
68
|
+
if not isinstance(schema.get("tables"), list) and isinstance(
|
|
69
|
+
schema.get("tables"), dict
|
|
70
|
+
):
|
|
71
|
+
for table_name, table_obj in schema.get("tables").items():
|
|
72
|
+
tables.append(table_obj)
|
|
73
|
+
else:
|
|
74
|
+
tables = schema.get("tables")
|
|
75
|
+
for table in tables:
|
|
76
|
+
table_name = table.get("name")
|
|
77
|
+
table_desc = table.get("description", "")
|
|
78
|
+
lines.append(f"Table: {table_name}")
|
|
79
|
+
if table_desc:
|
|
80
|
+
lines.append(f" Description: {table_desc}")
|
|
81
|
+
lines.append(" Columns:")
|
|
82
|
+
for col in table.get("columns"):
|
|
83
|
+
col_name = col.get("name")
|
|
84
|
+
col_type = col.get("type")
|
|
85
|
+
col_desc = col.get("description", "")
|
|
86
|
+
pk = " (Primary Key)" if col.get("primary_key", False) else ""
|
|
87
|
+
# Prepare sample values if present
|
|
88
|
+
samples = col.get("samples")
|
|
89
|
+
if samples is None:
|
|
90
|
+
samples = col.get("value_samples")
|
|
91
|
+
sample_str = ""
|
|
92
|
+
if samples and isinstance(samples, list):
|
|
93
|
+
# Show up to 5 sample values
|
|
94
|
+
shown = samples[:5]
|
|
95
|
+
shown_str = ", ".join(str(s) for s in shown)
|
|
96
|
+
sample_str = f" # Example values: {shown_str}"
|
|
97
|
+
elif samples and isinstance(samples, (str, int, float)):
|
|
98
|
+
sample_str = f" # Example value: {samples}"
|
|
99
|
+
if col_desc:
|
|
100
|
+
lines.append(
|
|
101
|
+
f" - {col_name} ({col_type}){pk}: {col_desc}{sample_str}"
|
|
102
|
+
)
|
|
103
|
+
else:
|
|
104
|
+
lines.append(f" - {col_name} ({col_type}){pk}{sample_str}")
|
|
105
|
+
lines.append("") # Blank line between tables
|
|
106
|
+
return "\n".join(lines)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def postprocess_sql(text: str) -> str:
|
|
110
|
+
"""
|
|
111
|
+
Post-processes the generated SQL text to extract and clean the SQL from markdown-style fenced blocks.
|
|
112
|
+
Handles both properly fenced blocks and malformed ones.
|
|
113
|
+
"""
|
|
114
|
+
stripped = text.strip()
|
|
115
|
+
|
|
116
|
+
# Case-insensitive match for ```sql fenced block
|
|
117
|
+
fenced_block = re.search(r"(?is)```sql\s*\n?(.*?)(?:\n)?```", stripped)
|
|
118
|
+
if fenced_block:
|
|
119
|
+
text = fenced_block.group(1)
|
|
120
|
+
else:
|
|
121
|
+
# Fallback: generic fenced block without language label
|
|
122
|
+
generic_fenced = re.search(r"(?s)```\s*\n?(.*?)(?:\n)?```", stripped)
|
|
123
|
+
if generic_fenced:
|
|
124
|
+
text = generic_fenced.group(1)
|
|
125
|
+
elif stripped.lower().startswith("```sql"):
|
|
126
|
+
# Malformed SQL code block (unterminated)
|
|
127
|
+
text = re.sub(r"(?is)^```sql\s*", "", stripped).strip("`").strip()
|
|
128
|
+
elif stripped.startswith("```"):
|
|
129
|
+
# Malformed unlabeled fenced block (unterminated)
|
|
130
|
+
text = stripped.lstrip("`").strip()
|
|
131
|
+
else:
|
|
132
|
+
# Plain SQL
|
|
133
|
+
text = stripped
|
|
134
|
+
|
|
135
|
+
# Remove leading 'sql' or 'sql\n' (case-insensitive)
|
|
136
|
+
text = re.sub(r"(?i)^\s*sql\s*\n?", "", text)
|
|
137
|
+
|
|
138
|
+
# Remove trailing semicolons and whitespace
|
|
139
|
+
return text.rstrip("; \n")
|
|
140
|
+
|
|
141
|
+
def extract_sql_from_reasoning(reasoning_text: str) -> str:
|
|
142
|
+
"""
|
|
143
|
+
Extract SQL from reasoning_content using multiple fallback strategies.
|
|
144
|
+
|
|
145
|
+
This handles cases where the model outputs reasoning with embedded SQL
|
|
146
|
+
but doesn't provide a separate 'content' field.
|
|
147
|
+
|
|
148
|
+
Strategies (in order of preference):
|
|
149
|
+
1. Look for ```sql fenced blocks (partial or complete)
|
|
150
|
+
2. Look for SELECT statements after "SQL:" marker
|
|
151
|
+
3. Find the longest complete SELECT statement
|
|
152
|
+
4. Extract any SELECT statement with cleanup
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
reasoning_text: The reasoning content from the model response
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
Extracted SQL query string, or empty string if no SQL found
|
|
159
|
+
"""
|
|
160
|
+
if not reasoning_text:
|
|
161
|
+
return ""
|
|
162
|
+
|
|
163
|
+
# Strategy 1: Try to find ```sql blocks (even if incomplete/cut off)
|
|
164
|
+
sql_block = re.search(r'```sql\s*\n?(.*?)(?:```|$)', reasoning_text, re.DOTALL | re.IGNORECASE)
|
|
165
|
+
if sql_block:
|
|
166
|
+
sql = sql_block.group(1).strip()
|
|
167
|
+
if sql and sql.upper().startswith('SELECT'):
|
|
168
|
+
return sql.rstrip(';').strip()
|
|
169
|
+
|
|
170
|
+
# Strategy 2: Look for "SQL:" marker followed by SELECT
|
|
171
|
+
sql_marker = re.search(r'SQL:\s*\n+(SELECT.*?)(?:\n\n|;|\Z)', reasoning_text, re.DOTALL | re.IGNORECASE)
|
|
172
|
+
if sql_marker:
|
|
173
|
+
sql = sql_marker.group(1).strip()
|
|
174
|
+
if sql:
|
|
175
|
+
return sql.rstrip(';').strip()
|
|
176
|
+
|
|
177
|
+
# Strategy 3: Find the last complete SELECT statement before cutoff
|
|
178
|
+
# Look for SELECT...FROM...WHERE/GROUP/ORDER/LIMIT patterns
|
|
179
|
+
select_statements = re.findall(
|
|
180
|
+
r'(SELECT\s+.*?(?:FROM|JOIN).*?)(?=\n\n|;|\Z)',
|
|
181
|
+
reasoning_text,
|
|
182
|
+
re.DOTALL | re.IGNORECASE
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
if select_statements:
|
|
186
|
+
# Return the longest one (likely most complete)
|
|
187
|
+
longest_sql = max(select_statements, key=len).strip()
|
|
188
|
+
return longest_sql.rstrip(';').strip()
|
|
189
|
+
|
|
190
|
+
# Strategy 4: Last resort - find any SELECT statement
|
|
191
|
+
select_match = re.search(r'(SELECT\s+.+)', reasoning_text, re.DOTALL | re.IGNORECASE)
|
|
192
|
+
if select_match:
|
|
193
|
+
sql = select_match.group(1).strip()
|
|
194
|
+
# Clean up common trailing text
|
|
195
|
+
sql = re.sub(r'\n\n.*$', '', sql) # Remove text after double newline
|
|
196
|
+
sql = re.sub(r'\n(That\'s|This|We need|The).*$', '', sql, flags=re.IGNORECASE)
|
|
197
|
+
return sql.rstrip(';').strip()
|
|
198
|
+
|
|
199
|
+
return ""
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class WXAIClient:
|
|
203
|
+
"""
|
|
204
|
+
LLM API client using IBM watsonx.ai.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
def __init__(self, model_name: str, model_parameters: dict):
|
|
208
|
+
env_vars = {
|
|
209
|
+
"api_key": "WATSONX_APIKEY",
|
|
210
|
+
"url": "WATSONX_API_BASE",
|
|
211
|
+
"project_id": "WATSONX_PROJECTID",
|
|
212
|
+
}
|
|
213
|
+
values = {k: os.environ.get(v) for k, v in env_vars.items()}
|
|
214
|
+
missing = [env_vars[k] for k, val in values.items() if not val]
|
|
215
|
+
api_key = values["api_key"]
|
|
216
|
+
url = values["url"]
|
|
217
|
+
project_id = values["project_id"]
|
|
218
|
+
if missing:
|
|
219
|
+
raise ValueError(
|
|
220
|
+
f"Missing WATSONX.AI credentials in environment variables: {', '.join(missing)}"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
creds = Credentials(api_key=api_key, url=url)
|
|
224
|
+
self.model = ModelInference(
|
|
225
|
+
model_id=model_name,
|
|
226
|
+
credentials=creds,
|
|
227
|
+
project_id=project_id,
|
|
228
|
+
params=model_parameters,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
def generate_sql(self, prompt: Text2SQLPrompt) -> str:
|
|
232
|
+
logger.debug(f"Inference with prompt: {prompt.prompt}\n\n")
|
|
233
|
+
# response = run_with_timeout(self.model.generate, prompt=prompt.prompt)
|
|
234
|
+
response = self.model.generate(prompt.prompt)
|
|
235
|
+
logger.debug(f"Response: {response}\n\n")
|
|
236
|
+
sql = response.get("results", [{}])[0].get("generated_text", "").strip()
|
|
237
|
+
if not sql:
|
|
238
|
+
raise ValueError("No text generated by the model.")
|
|
239
|
+
sql = prompt.postprocess_sql(sql)
|
|
240
|
+
logger.debug(f"Generated SQL: {sql}\n\n")
|
|
241
|
+
return sql
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class WXAIClientChatAPI:
|
|
245
|
+
"""
|
|
246
|
+
LLM API client using IBM watsonx.ai Chat API.
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
def __init__(self, model_name: str, model_parameters: dict):
|
|
250
|
+
env_vars = {
|
|
251
|
+
"api_key": "WATSONX_APIKEY",
|
|
252
|
+
"url": "WATSONX_API_BASE",
|
|
253
|
+
"project_id": "WATSONX_PROJECTID",
|
|
254
|
+
}
|
|
255
|
+
values = {k: os.environ.get(v) for k, v in env_vars.items()}
|
|
256
|
+
missing = [env_vars[k] for k, val in values.items() if not val]
|
|
257
|
+
if missing:
|
|
258
|
+
raise ValueError(
|
|
259
|
+
f"Missing WATSONX.AI credentials in environment variables: {', '.join(missing)}"
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
creds = Credentials(api_key=values["api_key"], url=values["url"])
|
|
263
|
+
# model_parameters can be a plain dict **or**
|
|
264
|
+
# a TextChatParameters instance – both are accepted.
|
|
265
|
+
|
|
266
|
+
# Filter and convert parameters for WatsonX Chat API compatibility
|
|
267
|
+
# WatsonX Chat API uses different parameter names than the legacy API:
|
|
268
|
+
# - max_tokens (not max_new_tokens)
|
|
269
|
+
# - Does not support: decoding_method, stop_sequences (legacy API only)
|
|
270
|
+
filtered_params = dict(model_parameters)
|
|
271
|
+
|
|
272
|
+
# Convert max_new_tokens -> max_tokens
|
|
273
|
+
if "max_new_tokens" in filtered_params:
|
|
274
|
+
filtered_params["max_tokens"] = filtered_params.pop("max_new_tokens")
|
|
275
|
+
|
|
276
|
+
# Remove unsupported parameters (supported by legacy WatsonX API but not Chat API)
|
|
277
|
+
for unsupported_param in ["decoding_method", "stop_sequences"]:
|
|
278
|
+
filtered_params.pop(unsupported_param, None)
|
|
279
|
+
|
|
280
|
+
self.model = ModelInference(
|
|
281
|
+
model_id=model_name,
|
|
282
|
+
credentials=creds,
|
|
283
|
+
project_id=values["project_id"],
|
|
284
|
+
params=filtered_params,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
def _build_messages(self, prompt_text: str) -> list[dict]:
|
|
288
|
+
"""
|
|
289
|
+
Convert the flat prompt text into the Chat API message format.
|
|
290
|
+
You can customize the system message here if desired.
|
|
291
|
+
"""
|
|
292
|
+
return [
|
|
293
|
+
{
|
|
294
|
+
"role": "system",
|
|
295
|
+
"content": (
|
|
296
|
+
"You are a SQL expert. Your task is to convert natural language questions into accurate SQL queries using the given database schema and instructions."
|
|
297
|
+
),
|
|
298
|
+
},
|
|
299
|
+
{"role": "user", "content": prompt_text},
|
|
300
|
+
]
|
|
301
|
+
|
|
302
|
+
def generate_sql(self, prompt: Any) -> tuple[str, dict]:
|
|
303
|
+
if isinstance(prompt, Text2SQLPrompt):
|
|
304
|
+
messages = self._build_messages(prompt.prompt)
|
|
305
|
+
logger.debug(f"Inference with constructed chat prompt: {messages}\n")
|
|
306
|
+
response = self.model.chat(messages=messages)
|
|
307
|
+
elif isinstance(prompt, list):
|
|
308
|
+
logger.debug(f"Inference with provided chat prompt: {prompt}\n")
|
|
309
|
+
response = self.model.chat(messages=prompt)
|
|
310
|
+
else:
|
|
311
|
+
raise ValueError(
|
|
312
|
+
f"Incorrect prompt type. Prompt must of Text2SQLPrompt or a list for chat prompt: {prompt}"
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
logger.debug(f"Raw response: {response}\n")
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
message = response["choices"][0]["message"]
|
|
319
|
+
|
|
320
|
+
# Try content first (normal case)
|
|
321
|
+
sql = message.get("content", "").strip()
|
|
322
|
+
|
|
323
|
+
# Fall back to reasoning_content if content is empty
|
|
324
|
+
if not sql:
|
|
325
|
+
reasoning = message.get("reasoning_content", "").strip()
|
|
326
|
+
if reasoning:
|
|
327
|
+
logger.debug("Attempting to extract SQL from reasoning_content")
|
|
328
|
+
sql = extract_sql_from_reasoning(reasoning)
|
|
329
|
+
if sql:
|
|
330
|
+
logger.info("Successfully extracted SQL from reasoning_content")
|
|
331
|
+
else:
|
|
332
|
+
logger.warning("Could not extract valid SQL from reasoning_content")
|
|
333
|
+
|
|
334
|
+
if not sql:
|
|
335
|
+
error = ValueError("No SQL content found in response")
|
|
336
|
+
error.response = str(response) # Attach raw response to exception
|
|
337
|
+
raise error
|
|
338
|
+
|
|
339
|
+
except (KeyError, IndexError) as e:
|
|
340
|
+
logger.error(f"SQL generation error: {repr(e)}. Raw response: {response}\n")
|
|
341
|
+
error = ValueError("No SQL returned by the model.")
|
|
342
|
+
error.response = str(response) # Attach raw response to exception
|
|
343
|
+
raise error
|
|
344
|
+
|
|
345
|
+
# Extract token usage from WatsonX response
|
|
346
|
+
token_usage = None
|
|
347
|
+
try:
|
|
348
|
+
# WatsonX returns usage in the response
|
|
349
|
+
usage = response.get("usage", {})
|
|
350
|
+
if usage:
|
|
351
|
+
token_usage = {
|
|
352
|
+
"prompt_tokens": usage.get("prompt_tokens", 0),
|
|
353
|
+
"completion_tokens": usage.get("completion_tokens", 0),
|
|
354
|
+
"total_tokens": usage.get("total_tokens", 0),
|
|
355
|
+
}
|
|
356
|
+
logger.debug(f"Token usage: {token_usage}\n")
|
|
357
|
+
except Exception as e:
|
|
358
|
+
logger.warning(f"Could not extract token usage: {e}")
|
|
359
|
+
token_usage = None
|
|
360
|
+
|
|
361
|
+
sql = postprocess_sql(sql)
|
|
362
|
+
logger.debug(f"Generated SQL: {sql}\n")
|
|
363
|
+
return sql, token_usage
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
class VLLMClientChatAPI:
|
|
367
|
+
"""
|
|
368
|
+
LLM API client using vLLM OpenAI-compatible Chat API.
|
|
369
|
+
"""
|
|
370
|
+
|
|
371
|
+
def __init__(self, model_name: str, model_parameters: dict):
|
|
372
|
+
# Environment variables for vLLM API
|
|
373
|
+
env_vars = {
|
|
374
|
+
"base_url": "VLLM_API_BASE", # e.g., "http://localhost:8000/v1"
|
|
375
|
+
"api_key": "VLLM_API_KEY", # Optional, some vLLM deployments don't require this
|
|
376
|
+
"rits_api_key": "RITS_API_KEY", # Optional, for RITS
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
values = {k: os.environ.get(v) for k, v in env_vars.items()}
|
|
380
|
+
|
|
381
|
+
# base_url is required, api_key is optional
|
|
382
|
+
if not values["base_url"]:
|
|
383
|
+
raise ValueError("Missing VLLM_API_BASE environment variable")
|
|
384
|
+
|
|
385
|
+
self.base_url = values["base_url"].rstrip("/")
|
|
386
|
+
self.api_key = values["api_key"] # Can be None
|
|
387
|
+
self.rits_api_key = values["rits_api_key"] # Can be None
|
|
388
|
+
self.model_name = model_name
|
|
389
|
+
self.model_parameters = model_parameters
|
|
390
|
+
|
|
391
|
+
# Set up headers
|
|
392
|
+
self.headers = {
|
|
393
|
+
"Content-Type": "application/json",
|
|
394
|
+
"accept": "application/json",
|
|
395
|
+
}
|
|
396
|
+
if self.rits_api_key:
|
|
397
|
+
self.headers["RITS_API_KEY"] = f"{self.rits_api_key}"
|
|
398
|
+
elif self.api_key:
|
|
399
|
+
self.headers["Authorization"] = f"Bearer {self.api_key}"
|
|
400
|
+
|
|
401
|
+
def _build_messages(self, prompt_text: str) -> list[dict[str, str]]:
|
|
402
|
+
"""
|
|
403
|
+
Convert the flat prompt text into the Chat API message format.
|
|
404
|
+
You can customize the system message here if desired.
|
|
405
|
+
"""
|
|
406
|
+
return [
|
|
407
|
+
{
|
|
408
|
+
"role": "system",
|
|
409
|
+
"content": (
|
|
410
|
+
"You are a SQL expert. Your task is to convert natural language questions into accurate SQL queries using the given database schema and instructions."
|
|
411
|
+
),
|
|
412
|
+
},
|
|
413
|
+
{"role": "user", "content": prompt_text},
|
|
414
|
+
]
|
|
415
|
+
|
|
416
|
+
def _make_chat_request(self, messages: list[dict[str, str]]) -> dict:
|
|
417
|
+
"""
|
|
418
|
+
Make a request to the vLLM chat completions endpoint.
|
|
419
|
+
"""
|
|
420
|
+
url = f"{self.base_url}/chat/completions"
|
|
421
|
+
|
|
422
|
+
# Prepare the request payload
|
|
423
|
+
payload = {
|
|
424
|
+
"model": self.model_name,
|
|
425
|
+
"messages": messages,
|
|
426
|
+
**self.model_parameters, # Include temperature, max_tokens, etc.
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
try:
|
|
430
|
+
response = requests.post(
|
|
431
|
+
url,
|
|
432
|
+
headers=self.headers,
|
|
433
|
+
json=payload,
|
|
434
|
+
timeout=120, # 2 minute timeout
|
|
435
|
+
)
|
|
436
|
+
response.raise_for_status()
|
|
437
|
+
return response.json()
|
|
438
|
+
except requests.exceptions.RequestException as e:
|
|
439
|
+
logger.error(f"vLLM API request failed: {e}")
|
|
440
|
+
raise ValueError(f"Failed to get response from vLLM API: {e}")
|
|
441
|
+
|
|
442
|
+
def generate_sql(self, prompt: Any) -> tuple[str, dict]:
|
|
443
|
+
if hasattr(prompt, "prompt"): # Text2SQLPrompt-like object
|
|
444
|
+
messages = self._build_messages(prompt.prompt)
|
|
445
|
+
logger.debug(f"Inference with constructed chat prompt: {messages}\n")
|
|
446
|
+
elif isinstance(prompt, list):
|
|
447
|
+
messages = prompt
|
|
448
|
+
logger.debug(f"Inference with provided chat prompt: {prompt}\n")
|
|
449
|
+
else:
|
|
450
|
+
raise ValueError(
|
|
451
|
+
f"Incorrect prompt type. Prompt must have a 'prompt' attribute or be a list for chat prompt: {prompt}"
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
# Make the API request
|
|
455
|
+
response = self._make_chat_request(messages)
|
|
456
|
+
logger.debug(f"Raw response: {response}\n")
|
|
457
|
+
|
|
458
|
+
try:
|
|
459
|
+
sql = response["choices"][0]["message"]["content"].strip()
|
|
460
|
+
except (KeyError, IndexError) as e:
|
|
461
|
+
logger.error(f"SQL generation error: {repr(e)}. Raw response: {response}\n")
|
|
462
|
+
raise ValueError("No SQL returned by the model.")
|
|
463
|
+
|
|
464
|
+
# Extract token usage from vLLM response (OpenAI-compatible format)
|
|
465
|
+
token_usage = None
|
|
466
|
+
try:
|
|
467
|
+
usage = response.get("usage", {})
|
|
468
|
+
if usage:
|
|
469
|
+
token_usage = {
|
|
470
|
+
"prompt_tokens": usage.get("prompt_tokens", 0),
|
|
471
|
+
"completion_tokens": usage.get("completion_tokens", 0),
|
|
472
|
+
"total_tokens": usage.get("total_tokens", 0),
|
|
473
|
+
}
|
|
474
|
+
logger.debug(f"Token usage: {token_usage}\n")
|
|
475
|
+
except Exception as e:
|
|
476
|
+
logger.warning(f"Could not extract token usage: {e}")
|
|
477
|
+
token_usage = None
|
|
478
|
+
|
|
479
|
+
# Apply post-processing
|
|
480
|
+
sql = postprocess_sql(sql)
|
|
481
|
+
logger.debug(f"Generated SQL: {sql}\n")
|
|
482
|
+
return sql, token_usage
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
class ClaudeClientChatAPI:
|
|
486
|
+
"""
|
|
487
|
+
LLM API client using Anthropic's Claude API.
|
|
488
|
+
"""
|
|
489
|
+
|
|
490
|
+
def __init__(self, model_name: str, model_parameters: dict):
|
|
491
|
+
# Environment variables for Claude API
|
|
492
|
+
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
|
493
|
+
if not api_key:
|
|
494
|
+
raise ValueError("Missing ANTHROPIC_API_KEY environment variable")
|
|
495
|
+
|
|
496
|
+
self.api_key = api_key
|
|
497
|
+
self.base_url = "https://api.anthropic.com"
|
|
498
|
+
self.model_name = model_name
|
|
499
|
+
|
|
500
|
+
# Filter and convert parameters for Claude API compatibility
|
|
501
|
+
# Claude uses: max_tokens, temperature, stop_sequences (as array)
|
|
502
|
+
# Does not support: decoding_method (WatsonX-specific)
|
|
503
|
+
self.model_parameters = dict(model_parameters)
|
|
504
|
+
|
|
505
|
+
# Convert max_new_tokens -> max_tokens
|
|
506
|
+
if "max_new_tokens" in self.model_parameters:
|
|
507
|
+
self.model_parameters["max_tokens"] = self.model_parameters.pop(
|
|
508
|
+
"max_new_tokens"
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
# Remove unsupported parameters
|
|
512
|
+
self.model_parameters.pop("decoding_method", None)
|
|
513
|
+
|
|
514
|
+
# Set up headers for Claude API
|
|
515
|
+
self.headers = {
|
|
516
|
+
"Content-Type": "application/json",
|
|
517
|
+
"x-api-key": self.api_key,
|
|
518
|
+
"anthropic-version": "2023-06-01", # API version
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
def _build_messages(self, prompt_text: str) -> list[dict[str, str]]:
|
|
522
|
+
"""
|
|
523
|
+
Convert the flat prompt text into Claude's message format.
|
|
524
|
+
Claude uses a slightly different format than OpenAI.
|
|
525
|
+
"""
|
|
526
|
+
return [{"role": "user", "content": prompt_text}]
|
|
527
|
+
|
|
528
|
+
def _build_system_message(self) -> str:
|
|
529
|
+
"""
|
|
530
|
+
Claude handles system messages separately from the messages array.
|
|
531
|
+
"""
|
|
532
|
+
return (
|
|
533
|
+
"You are a SQL expert. Your task is to convert natural language questions "
|
|
534
|
+
"into accurate SQL queries using the given database schema and instructions."
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
def _make_chat_request(self, messages: list[dict[str, str]]) -> dict:
|
|
538
|
+
"""
|
|
539
|
+
Make a request to Claude's messages endpoint.
|
|
540
|
+
"""
|
|
541
|
+
url = f"{self.base_url}/v1/messages"
|
|
542
|
+
|
|
543
|
+
# Prepare the request payload for Claude
|
|
544
|
+
payload = {
|
|
545
|
+
"model": self.model_name,
|
|
546
|
+
"messages": messages,
|
|
547
|
+
"system": self._build_system_message(),
|
|
548
|
+
**self.model_parameters,
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
print(f"\n\n\n ******** \n payload:{payload} \n\n\n\n")
|
|
552
|
+
|
|
553
|
+
try:
|
|
554
|
+
response = requests.post(
|
|
555
|
+
url, headers=self.headers, json=payload, timeout=120
|
|
556
|
+
)
|
|
557
|
+
response.raise_for_status()
|
|
558
|
+
return response.json()
|
|
559
|
+
except requests.exceptions.HTTPError as e:
|
|
560
|
+
# Try to extract error details from response
|
|
561
|
+
error_detail = ""
|
|
562
|
+
try:
|
|
563
|
+
error_json = e.response.json()
|
|
564
|
+
if "error" in error_json:
|
|
565
|
+
error_type = error_json["error"].get("type", "unknown")
|
|
566
|
+
error_msg = error_json["error"].get("message", "")
|
|
567
|
+
error_detail = f" - {error_type}: {error_msg}"
|
|
568
|
+
except:
|
|
569
|
+
pass
|
|
570
|
+
|
|
571
|
+
# Provide specific guidance for common errors
|
|
572
|
+
if e.response.status_code == 401:
|
|
573
|
+
logger.error(f"Claude API authentication failed{error_detail}")
|
|
574
|
+
raise ValueError(
|
|
575
|
+
f"Claude API authentication failed{error_detail}\n"
|
|
576
|
+
"Please check that your ANTHROPIC_API_KEY is valid.\n"
|
|
577
|
+
"Get a valid key at: https://console.anthropic.com/settings/keys"
|
|
578
|
+
)
|
|
579
|
+
elif e.response.status_code == 429:
|
|
580
|
+
logger.error(f"Claude API rate limit exceeded{error_detail}")
|
|
581
|
+
raise ValueError(f"Claude API rate limit exceeded{error_detail}")
|
|
582
|
+
else:
|
|
583
|
+
logger.error(f"Claude API request failed: {e}{error_detail}")
|
|
584
|
+
raise ValueError(f"Failed to get response from Claude API: {e}{error_detail}")
|
|
585
|
+
except requests.exceptions.RequestException as e:
|
|
586
|
+
logger.error(f"Claude API request failed: {e}")
|
|
587
|
+
raise ValueError(f"Failed to get response from Claude API: {e}")
|
|
588
|
+
|
|
589
|
+
def generate_sql(self, prompt: Any) -> tuple[str, dict]:
|
|
590
|
+
if hasattr(prompt, "prompt"): # Text2SQLPrompt-like object
|
|
591
|
+
messages = self._build_messages(prompt.prompt)
|
|
592
|
+
logger.debug(f"Inference with constructed chat prompt: {messages}\n")
|
|
593
|
+
elif isinstance(prompt, list):
|
|
594
|
+
# If already formatted messages, use as-is but ensure no system messages in array
|
|
595
|
+
messages = [msg for msg in prompt if msg.get("role") != "system"]
|
|
596
|
+
logger.debug(f"Inference with provided chat prompt: {messages}\n")
|
|
597
|
+
else:
|
|
598
|
+
raise ValueError(
|
|
599
|
+
f"Incorrect prompt type. Prompt must have a 'prompt' attribute or be a list for chat prompt: {prompt}"
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
# Make the API request
|
|
603
|
+
response = self._make_chat_request(messages)
|
|
604
|
+
logger.debug(f"Raw response: {response}\n")
|
|
605
|
+
|
|
606
|
+
try:
|
|
607
|
+
sql = response["content"][0]["text"].strip()
|
|
608
|
+
except (KeyError, IndexError) as e:
|
|
609
|
+
logger.error(f"SQL generation error: {repr(e)}. Raw response: {response}\n")
|
|
610
|
+
raise ValueError("No SQL returned by the model.")
|
|
611
|
+
|
|
612
|
+
# Extract token usage from Claude response
|
|
613
|
+
token_usage = None
|
|
614
|
+
try:
|
|
615
|
+
usage = response.get("usage", {})
|
|
616
|
+
if usage:
|
|
617
|
+
# Claude returns input_tokens and output_tokens
|
|
618
|
+
prompt_tokens = usage.get("input_tokens", 0)
|
|
619
|
+
completion_tokens = usage.get("output_tokens", 0)
|
|
620
|
+
token_usage = {
|
|
621
|
+
"prompt_tokens": prompt_tokens,
|
|
622
|
+
"completion_tokens": completion_tokens,
|
|
623
|
+
"total_tokens": prompt_tokens + completion_tokens,
|
|
624
|
+
}
|
|
625
|
+
logger.debug(f"Token usage: {token_usage}\n")
|
|
626
|
+
except Exception as e:
|
|
627
|
+
logger.warning(f"Could not extract token usage: {e}")
|
|
628
|
+
token_usage = None
|
|
629
|
+
|
|
630
|
+
# Apply post-processing
|
|
631
|
+
sql = postprocess_sql(sql)
|
|
632
|
+
logger.debug(f"Generated SQL: {sql}\n")
|
|
633
|
+
return sql, token_usage
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
class OpenAIClientChatAPI:
|
|
637
|
+
"""
|
|
638
|
+
LLM API client using OpenAI-compatible API (e.g., LiteLLM proxy).
|
|
639
|
+
"""
|
|
640
|
+
|
|
641
|
+
def __init__(self, model_name: str, model_parameters: dict):
|
|
642
|
+
if OpenAI is None:
|
|
643
|
+
raise ImportError(
|
|
644
|
+
"openai package is required for OpenAI client. Install it with: pip install openai"
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
# Check if this is an Ollama model (will be passed without prefix after stripping in baseline_llm_pipeline)
|
|
648
|
+
# For Ollama, try OLLAMA_* env vars first, fall back to OPENAI_* for compatibility
|
|
649
|
+
ollama_base_url = os.environ.get("OLLAMA_BASE_URL")
|
|
650
|
+
ollama_api_key = os.environ.get("OLLAMA_API_KEY", "ollama") # Ollama doesn't require real API key
|
|
651
|
+
|
|
652
|
+
if ollama_base_url:
|
|
653
|
+
# Using Ollama
|
|
654
|
+
self.base_url = ollama_base_url.rstrip("/")
|
|
655
|
+
self.api_key = ollama_api_key
|
|
656
|
+
else:
|
|
657
|
+
# Using OpenAI or OpenAI-compatible API
|
|
658
|
+
env_vars = {
|
|
659
|
+
"base_url": "OPENAI_BASE_URL",
|
|
660
|
+
"api_key": "OPENAI_API_KEY",
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
values = {k: os.environ.get(v) for k, v in env_vars.items()}
|
|
664
|
+
|
|
665
|
+
# base_url and api_key are required
|
|
666
|
+
if not values["base_url"]:
|
|
667
|
+
raise ValueError("Missing OPENAI_BASE_URL environment variable")
|
|
668
|
+
if not values["api_key"]:
|
|
669
|
+
raise ValueError("Missing OPENAI_API_KEY environment variable")
|
|
670
|
+
|
|
671
|
+
self.base_url = values["base_url"].rstrip("/")
|
|
672
|
+
self.api_key = values["api_key"]
|
|
673
|
+
|
|
674
|
+
self.model_name = model_name
|
|
675
|
+
|
|
676
|
+
# Filter and convert parameters for OpenAI API compatibility
|
|
677
|
+
# OpenAI uses: max_tokens, temperature, stop (as array or string)
|
|
678
|
+
# Does not support: decoding_method (WatsonX-specific)
|
|
679
|
+
self.model_parameters = dict(model_parameters)
|
|
680
|
+
|
|
681
|
+
# Convert max_new_tokens -> max_tokens
|
|
682
|
+
if "max_new_tokens" in self.model_parameters:
|
|
683
|
+
self.model_parameters["max_tokens"] = self.model_parameters.pop(
|
|
684
|
+
"max_new_tokens"
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
# Convert stop_sequences -> stop (OpenAI expects stop as a list or string)
|
|
688
|
+
if "stop_sequences" in self.model_parameters:
|
|
689
|
+
stop_seqs = self.model_parameters.pop("stop_sequences")
|
|
690
|
+
if stop_seqs:
|
|
691
|
+
# OpenAI accepts stop as a list or a single string
|
|
692
|
+
if isinstance(stop_seqs, list):
|
|
693
|
+
self.model_parameters["stop"] = stop_seqs
|
|
694
|
+
else:
|
|
695
|
+
self.model_parameters["stop"] = [stop_seqs]
|
|
696
|
+
|
|
697
|
+
# Remove unsupported parameters
|
|
698
|
+
self.model_parameters.pop("decoding_method", None)
|
|
699
|
+
|
|
700
|
+
# Initialize OpenAI client
|
|
701
|
+
self.client = OpenAI(
|
|
702
|
+
api_key=self.api_key,
|
|
703
|
+
base_url=self.base_url,
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
def _build_messages(self, prompt_text: str) -> list[dict[str, str]]:
|
|
707
|
+
"""
|
|
708
|
+
Convert the flat prompt text into OpenAI Chat API message format.
|
|
709
|
+
"""
|
|
710
|
+
return [
|
|
711
|
+
{
|
|
712
|
+
"role": "system",
|
|
713
|
+
"content": (
|
|
714
|
+
"You are a SQL expert. Your task is to convert natural language questions "
|
|
715
|
+
"into accurate SQL queries using the given database schema and instructions."
|
|
716
|
+
),
|
|
717
|
+
},
|
|
718
|
+
{"role": "user", "content": prompt_text},
|
|
719
|
+
]
|
|
720
|
+
|
|
721
|
+
def generate_sql(self, prompt: Any) -> tuple[str, dict]:
|
|
722
|
+
if hasattr(prompt, "prompt"): # Text2SQLPrompt-like object
|
|
723
|
+
messages = self._build_messages(prompt.prompt)
|
|
724
|
+
logger.debug(f"Inference with constructed chat prompt: {messages}\n")
|
|
725
|
+
elif isinstance(prompt, list):
|
|
726
|
+
messages = prompt
|
|
727
|
+
logger.debug(f"Inference with provided chat prompt: {prompt}\n")
|
|
728
|
+
else:
|
|
729
|
+
raise ValueError(
|
|
730
|
+
f"Incorrect prompt type. Prompt must have a 'prompt' attribute or be a list for chat prompt: {prompt}"
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
# Make the API request using OpenAI client
|
|
734
|
+
try:
|
|
735
|
+
response = self.client.chat.completions.create(
|
|
736
|
+
model=self.model_name,
|
|
737
|
+
messages=messages,
|
|
738
|
+
**self.model_parameters,
|
|
739
|
+
)
|
|
740
|
+
logger.debug(f"Raw response: {response}\n")
|
|
741
|
+
except Exception as e:
|
|
742
|
+
logger.error(f"OpenAI API request failed: {e}")
|
|
743
|
+
raise ValueError(f"Failed to get response from OpenAI API: {e}")
|
|
744
|
+
|
|
745
|
+
try:
|
|
746
|
+
sql = response.choices[0].message.content.strip()
|
|
747
|
+
except (AttributeError, IndexError, KeyError) as e:
|
|
748
|
+
logger.error(f"SQL generation error: {repr(e)}. Raw response: {response}\n")
|
|
749
|
+
raise ValueError("No SQL returned by the model.")
|
|
750
|
+
|
|
751
|
+
# Extract token usage from OpenAI response
|
|
752
|
+
token_usage = None
|
|
753
|
+
try:
|
|
754
|
+
if hasattr(response, "usage") and response.usage:
|
|
755
|
+
usage = response.usage
|
|
756
|
+
token_usage = {
|
|
757
|
+
"prompt_tokens": usage.prompt_tokens if hasattr(usage, "prompt_tokens") else 0,
|
|
758
|
+
"completion_tokens": usage.completion_tokens if hasattr(usage, "completion_tokens") else 0,
|
|
759
|
+
"total_tokens": usage.total_tokens if hasattr(usage, "total_tokens") else 0,
|
|
760
|
+
}
|
|
761
|
+
logger.debug(f"Token usage: {token_usage}\n")
|
|
762
|
+
except Exception as e:
|
|
763
|
+
logger.warning(f"Could not extract token usage: {e}")
|
|
764
|
+
token_usage = None
|
|
765
|
+
|
|
766
|
+
# Apply post-processing
|
|
767
|
+
sql = postprocess_sql(sql)
|
|
768
|
+
logger.debug(f"Generated SQL: {sql}\n")
|
|
769
|
+
return sql, token_usage
|