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.
Files changed (28) hide show
  1. text2sql_eval_toolkit/__init__.py +107 -0
  2. text2sql_eval_toolkit/analysis/__init__.py +5 -0
  3. text2sql_eval_toolkit/analysis/error_analysis.py +335 -0
  4. text2sql_eval_toolkit/analysis/report_tools.py +719 -0
  5. text2sql_eval_toolkit/config_args.py +65 -0
  6. text2sql_eval_toolkit/data/__init__.py +4 -0
  7. text2sql_eval_toolkit/data/benchmarks.json +69 -0
  8. text2sql_eval_toolkit/data/test-benchmarks.json +69 -0
  9. text2sql_eval_toolkit/env_loader.py +55 -0
  10. text2sql_eval_toolkit/evaluation/__init__.py +26 -0
  11. text2sql_eval_toolkit/evaluation/evaluation_tools.py +759 -0
  12. text2sql_eval_toolkit/evaluation/llm_as_judge.py +90 -0
  13. text2sql_eval_toolkit/execution/__init__.py +5 -0
  14. text2sql_eval_toolkit/execution/execution_tools.py +1448 -0
  15. text2sql_eval_toolkit/execution/replace_select_tool.py +114 -0
  16. text2sql_eval_toolkit/inference/__init__.py +5 -0
  17. text2sql_eval_toolkit/inference/agentic_pipeline.py +2335 -0
  18. text2sql_eval_toolkit/inference/base_pipeline.py +11 -0
  19. text2sql_eval_toolkit/inference/baseline_llm_pipeline.py +372 -0
  20. text2sql_eval_toolkit/inference/inference_tools.py +769 -0
  21. text2sql_eval_toolkit/logging.py +54 -0
  22. text2sql_eval_toolkit/profiling/profiling_tools.py +185 -0
  23. text2sql_eval_toolkit/utils.py +302 -0
  24. text2sql_eval_toolkit-1.0.0.dist-info/METADATA +382 -0
  25. text2sql_eval_toolkit-1.0.0.dist-info/RECORD +28 -0
  26. text2sql_eval_toolkit-1.0.0.dist-info/WHEEL +5 -0
  27. text2sql_eval_toolkit-1.0.0.dist-info/licenses/LICENSE +201 -0
  28. 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