"""
Functions for performing API calls
"""

import os
from importlib import resources
from typing import List, Optional

import anthropic
import numpy as np
import openai
import pandas as pd
import requests
from dotenv import load_dotenv
from google import genai
from google.genai.types import GenerateContentConfig
from openai import OpenAI
from groq import Groq

from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Optional, Type, List
from pydantic import BaseModel
import time

import requests
import json

import bilbystats as bs

# Use importlib to get a temporary path to the installed .env file
#env_path = resources.files("bilbystats.defaults").joinpath("apikeys.env")
# Load environment variables from the .env file
#load_dotenv(dotenv_path=env_path)


def read_api_key(
    api_key_name: str = "openai",
) -> str:
    """
    Read an API key from a specified file

    This function reads an API key from a text file located in the provided
    directory. The file is expected to be named using the format
    '{api_key_name}_api_key.txt'. The contents of the file are stripped of
    leading and trailing whitespace before being returned.

    ---------------------------------------------------------------------------
    ARGUMENTS:
    api_key_name : str, optional (default="openai")
        The base name of the API key file (without the '_api_key.txt' suffix).

    ---------------------------------------------------------------------------
    OUTPUT:
    api_key : str
        The API key read from the specified file.

    ---------------------------------------------------------------------------
    AUTHORS: Samuel Davenport
    ---------------------------------------------------------------------------
    """
    api_key = os.getenv(api_key_name.upper() + "_API_KEY")

    if api_key is None:
        raise ValueError(
            f"The {api_key_name} API key is not stored and must be added to bilbystats/defaults/apikeys.env"
        )

    return api_key

def llm_api(
    content: str,
    instructions: str = "",
    model_name: str = "gpt-4o",
    temperature: float = 1.0,
    max_tokens: int = 1000,
    do_stream: bool = False,
    top_logprobs = None,
    mymessages = None,
    outputall = False,
    response_format = None,
    web_search = False,
    reasoning_effort = 'default'
) -> str:
    """
    Unified interface to call different LLM APIs based on the specified model name.

    This function routes the request to the appropriate API handler (e.g., OpenAI or Claude)
    depending on the selected model name. It supports models like GPT-4o, DeepSeek, Claude, etc.

    ---------------------------------------------------------------------------
    ARGUMENTS:
    content : str
        User-provided prompt or content for which a response is requested.
    instructions : str
        System-level instructions to guide the model's behavior and response generation.
    model_name : str, optional
        The identifier for the target model to be used. Supports values such as
        "deepseek", "gpt-4o", "gpt-4-mini", "claude", and "claude-3-7-sonnet-20250219".
        Defaults to "gpt-4o".

    ---------------------------------------------------------------------------
    OUTPUT:
    output : str
        The text response generated by the selected LLM API.

    ---------------------------------------------------------------------------
    AUTHORS: Samuel Davenport
    ---------------------------------------------------------------------------
    """    
    available_models = bs.read_data("api_model_dict.json")
    #available_open_router_models = bs.read_data('openrouter_models.json')
    # if model_name in ("deepseek", "gpt-4o", "gpt-4o-mini", 'gpt-4.1'):
    if (
        (model_name in available_models["openai"])
        or model_name in ("deepseek")
        or model_name in available_models["deepseek"]
    ):
        output, logprobs = bs.openai_api(
            content,
            instructions,
            model_name,
            temperature=temperature,
            max_tokens=max_tokens,
            do_stream=do_stream,
            top_logprobs=top_logprobs,
            mymessages=mymessages,
            response_format = response_format,
            web_search = web_search,
            reasoning_effort = reasoning_effort
        )
    elif model_name in available_models["claude"] or model_name in ("claude"):
        output = bs.claude_api(
            content,
            instructions,
            model_name,
            temperature=temperature,
            max_tokens=max_tokens,
            do_stream=do_stream,
        )

    elif model_name in available_models["gemini"] or model_name in ("gemini"):
        output = bs.gemini_api(
            instructions + content,
            model_name,
            temperature=temperature,
            max_tokens=max_tokens,
            do_stream=do_stream,
        )
    elif model_name[:7] == 'ollama/':
        model_name = model_name[7:]
        output = bs.ollama(content, instructions, model_name, do_stream)
    else:
        output, logprobs = bs.openai_api(content, instructions, model_name, 
                               temperature = temperature, max_tokens = max_tokens, 
                                   do_stream = do_stream, top_logprobs = top_logprobs, mymessages = mymessages, outputall = outputall, response_format = response_format, web_search = web_search)
        
    if not top_logprobs:
        return output
    else :
        return output, logprobs

def openai_api(
    content: str,
    instructions: str = "",    
    model_name: str = "gpt-4o",
    temperature: float = 1.0,
    max_tokens: int = None,
    do_stream: bool = False,
    response_format = None,
    top_logprobs = None,
    mymessages = None,
    outputall = False,
    web_search = False,
    reasoning_effort = 'default'
) -> str:
    """
    Call the OpenAI API with custom instructions and content.
    This function sends a request to the OpenAI API using the specified model,
    along with provided instructions and user content. It retrieves and returns
    the response content generated by the model.
    ---------------------------------------------------------------------------
    ARGUMENTS:
    instructions : str
        The system or developer-level instructions that guide the model's behavior.
    content : str
        The main user-provided content or prompt to process.
    model_name : str, optional (default="gpt-4o")
        The name of the OpenAI model to use for generating the completion.
        Typically "gpt-4o" or "gpt-4o-mini".
    provider : str, optional (default=None)
        The provider to use for OpenRouter requests (e.g., "OpenAI", "Anthropic").
    ---------------------------------------------------------------------------
    OUTPUT:
    output : str
        The generated response content from the OpenAI API.
    ---------------------------------------------------------------------------
    AUTHORS: Samuel Davenport
    ---------------------------------------------------------------------------
    """
    provider = None
    if model_name[len(model_name) - 5:] == ':groq':
        use_groq = True
        model_name = model_name[:len(model_name) - 5]
    else:
        use_groq = False
    
    if top_logprobs:
        DOLP = True
    else:
        DOLP = False
    
    # Convert do_stream to bool if it's not
    do_stream = bool(do_stream)
    if model_name == "deepseek":
        model_name = "deepseek-chat"
    available_models = bs.read_data("api_model_dict.json")
    
    extra_body = {}
    if model_name in available_models["openai"]:
        openai.api_key = read_api_key("openai")
        # Now you can use the OpenAI client
        client = openai
    elif model_name in available_models["deepseek"]:
        client = OpenAI(
            api_key=read_api_key("deepseek"),
            base_url="https://api.deepseek.com",
        )
        
    elif use_groq:
        print('Using groq')
        client = Groq(api_key=bs.read_api_key('groq'))
    else:
        if provider:
            extra_body = {"provider": {"order": [provider]}}
        client = OpenAI(
          base_url="https://openrouter.ai/api/v1",
          api_key=bs.read_api_key('openrouter'),
        )
        
    messages = [] 
    if len(instructions) > 0:
        messages.append({"role": "system", "content": instructions})
        
    if mymessages:
        messages = messages + mymessages
        
    messages.append({"role": "user","content": content})
    
    completion_kwargs = {
        "model": model_name,
        "messages": messages,
        "temperature": temperature,
        "max_tokens": max_tokens,
        "stream": do_stream,
        "response_format": response_format,
        "logprobs": DOLP,
        "top_logprobs": top_logprobs
    }
    
    if extra_body:
        completion_kwargs["extra_body"] = extra_body
        
    if web_search:
       completion_kwargs["tools"] = [{"type": "web_search"}]
       
    if ('gpt-5' in model_name) and reasoning_effort != 'default':
        completion_kwargs["reasoning_effort"] = reasoning_effort
    
    completion = client.chat.completions.create(**completion_kwargs)
    
    logprobs = None
    if do_stream:
        output = ""
        for chunk in completion:
            if chunk.choices[0].delta.content:
                content = chunk.choices[0].delta.content
                print(content, end="", flush=True)
                output += content  # Collect the content
    else:
        if outputall:
            output = completion
        else:
            response = completion.choices[0].message
            output = response.content
            
        if DOLP:
            logprobs = completion.choices[0].logprobs.content[0].top_logprobs
    return output, logprobs

def claude_api(
    content: str,
    instructions: str = "",
    model_name: str = "claude-3-7-sonnet-20250219",
    temperature: float = 1.0,
    max_tokens: int = 1000,
    do_stream: bool = False,
) -> str:
    """
    Call the Claude API to generate a response based on user input and system instructions.

    This function initializes the Claude API client, constructs a message payload with the given
    instructions and content, and retrieves the model's response text. Supports streaming output.

    ---------------------------------------------------------------------------
    ARGUMENTS:
    content : str
        User-provided content or prompt for which a response is requested.
    instructions : str
        System-level instructions guiding the model's behavior and response style.
    model_name : str, optional
        The name of the Claude model to be used for generating the response.
        Defaults to "claude-3-7-sonnet-20250219".
    temperature : float, optional
        Temperature parameter for controlling randomness in generation.
        Defaults to 1.0.
    do_stream : bool, optional
        Whether to stream the response (print in real-time) or return all at once.
        Defaults to False.

    ---------------------------------------------------------------------------
    OUTPUT:
    output : str
        The text content of the model's response generated by Claude.

    ---------------------------------------------------------------------------
    AUTHORS: Samuel Davenport
    ---------------------------------------------------------------------------
    """
    if model_name in ("claude"):
        model_name = "claude-3-7-sonnet-20250219"

    key = bs.read_api_key("claude")
    client = anthropic.Anthropic(api_key=key)

    if do_stream:
        output = ""

        with client.messages.stream(
            model=model_name,
            max_tokens=max_tokens,
            temperature=temperature,
            system=instructions,
            messages=[{"role": "user", "content": [{"type": "text", "text": content}]}],
        ) as stream:
            for text in stream.text_stream:
                print(text, end="", flush=True)
                output += text

    else:
        message = client.messages.create(
            model=model_name,
            max_tokens=max_tokens,
            temperature=temperature,
            system=instructions,
            messages=[
                {
                    "role": "user",
                    "content": [
                        {
                            "type": "text",
                            "text": content,
                        }
                    ],
                }
            ],
        )
        output = message.content[0].text

    return output


def gemini_api(
    content: str,
    model_name: str = "gemini-2.0-flash",
    temperature: float = 1.0,
    max_tokens: int = 1024,
    do_stream: bool = False,
) -> str:
    """
    Generate content using the Gemini API.

    This function sends a request to the Gemini API to generate content based on
    the provided input. If the default model name "gemini" is used, it is replaced
    with "gemini-2.0-flash" to ensure compatibility. The function returns the
    generated text content. Supports streaming output.

    ---------------------------------------------------------------------------
    ARGUMENTS:
    content : str
        The input prompt or content to be sent to the Gemini API for generation.
    model_name : str, optional (default="gemini-2.0-flash")
        The name of the Gemini model to use for content generation. If "gemini"
        is specified, it defaults to "gemini-2.0-flash". Other options are e.g.
        gemini-2.5-flash-preview-04-17 and gemini-2.0-flash-lite and
        gemini-2.5-pro-preview-05-06. See
        https://ai.google.dev/gemini-api/docs/models for a list of the different
        possible models.
    do_stream : bool, optional
        Whether to stream the response (print in real-time) or return all at once.
        Defaults to False.

    ---------------------------------------------------------------------------
    OUTPUT:
    output : str
        The generated text returned by the Gemini API.

    ---------------------------------------------------------------------------
    AUTHORS: Samuel Davenport
    ---------------------------------------------------------------------------
    """
    if model_name == "gemini":
        model_name = "gemini-2.0-flash"

    client = genai.Client(
        api_key=bs.read_api_key("gemini"),
    )

    if do_stream:
        output = ""

        response = client.models.generate_content_stream(
            model=model_name,
            contents=content,
            config=GenerateContentConfig(
                temperature=temperature,
                max_output_tokens=max_tokens,
            ),
        )

        for chunk in response:
            if hasattr(chunk, "text") and chunk.text:
                print(
                    chunk.text,
                    end="",
                    flush=True,
                )
                output += chunk.text
    else:
        response = client.models.generate_content(
            model=model_name,
            contents=content,
        )
        output = response.text

    return output


def api2data(
    colname: str,
    promptloc: str,
    dataloc: str,
    saveloc: str,
    task: str = "Classify the following text: ",
    model_name: str = "gpt-4o",
    labels: List[str] = ["Label: ", "Explanation: "],
    names: List[str] = ["label", "explanation"],
    lowercase: List[bool] = [True, False],
) -> None:
    """
    Apply an LLM API to a dataset column and save results.

    This function reads a dataset from a Parquet file, applies a large language model (LLM) API
    to each entry in a specified column, and extracts a label and explanation from the model's
    response. The results are saved back into the dataset under dynamically generated column
    names based on the model name, unless explicitly specified.

    ---------------------------------------------------------------------------
    ARGUMENTS:
    colname : str
        Name of the column in the dataset whose entries will be passed as input to the LLM.
    promptloc : str
        Path to the file containing instructions or prompts for the LLM.
    dataloc : str
        Path to the input Parquet file containing the dataset.
    saveloc : str
        Path where the resulting Parquet file with model outputs will be saved.
    task : str, optional (default='Classify the following text: ')
        Task prefix to prepend to each input text before sending to the LLM.
    model_name : str, optional (default="gpt-4o")
        Name of the LLM model to be used for inference.
    labels : List[str], optional
        List of label prefixes to search for in the model's response.
    names : List[str], optional
        List of column names where the extracted fields will be stored.
    lowercase : List[bool], optional
        List indicating whether to convert each extracted field to lowercase.

    ---------------------------------------------------------------------------
    OUTPUT:
    None
        The function saves the updated dataset with new label and explanation columns
        to the specified output Parquet file but does not return any object.
    ---------------------------------------------------------------------------
    AUTHORS: Samuel Davenport
    ---------------------------------------------------------------------------
    """
    final_names = []
    for i in np.arange(len(names)):
        name = names[i]
        final_names.append(f"{model_name}_{name}")

    df = pd.read_parquet(dataloc)

    # Initialize new columns with NaN
    for name in final_names:
        df[name] = np.nan

    with open(promptloc, "r") as file:
        instructions = file.read()

    for i in np.arange(len(df)):
        bs.loader(i, len(df))

        try:
            # Try applying the llm and getting an answer.
            output = bs.llm_api(
                task + df[colname][i],
                instructions,
                model_name,
            )

            lines = output.strip().splitlines()

            for j in np.arange(len(names)):
                label = lines[j].replace(labels[j], "").strip()
                df.loc[i, final_names[j]] = label.lower()

        except:
            # If the answer was not in the correct format, print the output.
            # print(df[colname][i])
            print(output)

    for i in np.arange(len(names)):
        if lowercase[i]:
            df[final_names[j]] = df[final_names[j]].str.lower()

    df.to_parquet(saveloc)


def api2data_dep(
    colname: str,
    promptloc: str,
    dataloc: str,
    saveloc: str,
    task: str = "Classify the following text: ",
    model_name: str = "gpt-4o",
    label_name: Optional[str] = None,
    explanation_name: Optional[str] = None,
) -> None:
    """
    Apply an LLM API to a dataset column and save results.

    This function reads a dataset from a Parquet file, applies a large language model (LLM) API
    to each entry in a specified column, and extracts a label and explanation from the model's
    response. The results are saved back into the dataset under dynamically generated column
    names based on the model name, unless explicitly specified.

    ---------------------------------------------------------------------------
    ARGUMENTS:
    colname : str
        Name of the column in the dataset whose entries will be passed as input to the LLM.
    promptloc : str
        Path to the file containing instructions or prompts for the LLM.
    dataloc : str
        Path to the input Parquet file containing the dataset.
    saveloc : str
        Path where the resulting Parquet file with model outputs will be saved.
    task : str, optional (default='Classify the following text: ')
        Task prefix to prepend to each input text before sending to the LLM.
    model_name : str, optional (default="gpt-4o")
        Name of the LLM model to be used for inference.
    label_name : str, optional (default=None)
        Column name where the extracted label will be stored in the dataset.
        If None, defaults to "<model_name>_label".
    explanation_name : str, optional (default=None)
        Column name where the extracted explanation will be stored in the dataset.
        If None, defaults to "<model_name>_explanation".

    ---------------------------------------------------------------------------
    OUTPUT:
    None
        The function saves the updated dataset with new label and explanation columns
        to the specified output Parquet file but does not return any object.
    ---------------------------------------------------------------------------
    AUTHORS: Samuel Davenport
    ---------------------------------------------------------------------------
    """
    if not label_name:
        label_name = model_name + "_label"

    if not explanation_name:
        explanation_name = model_name + "_explanation"

    df = pd.read_parquet(dataloc)

    # df[label_name] = pd.Series([pd.NA] * len(df), dtype='string')
    # df[explanation_name] = pd.Series([pd.NA] * len(df), dtype='string')
    df[label_name] = np.nan
    df[explanation_name] = np.nan

    with open(promptloc, "r") as file:
        instructions = file.read()

    for i in np.arange(len(df)):
        bs.loader(i, len(df))

        try:
            # Try applying the llm and getting an answer.
            output = bs.llm_api(task + df[colname][i], instructions, model_name)
            lines = output.strip().splitlines()
            label = lines[0].replace("Label:", "").strip()
            explanation = lines[1].replace("Explanation:", "").strip()
            df.loc[i, label_name] = label.lower()
            df.loc[i, explanation_name] = explanation

        except:
            # If the answer was not in the correct format, print the output.
            # print(df[colname][i])
            print(output)

    df.to_parquet(saveloc)


def detect_sentiment(
    text: str,
    model_name: str = "gpt-4o",
) -> str:
    """
    Detect the sentiment of a given text using a specified language model.

    This function uses the OpenAI API to analyze the sentiment of the provided
    text. It classifies the sentiment as 'positive', 'neutral', or 'negative',
    and returns only the sentiment label.

    ---------------------------------------------------------------------------
    ARGUMENTS:
    text : str
        The text for which sentiment analysis is to be performed.
    model_name : str, optional (default="gpt-4o")
        The name of the OpenAI model used for sentiment analysis.

    ---------------------------------------------------------------------------
    OUTPUT:
    sentiment : str
        The sentiment classification of the input text ('positive',
        'neutral', or 'negative').

    ---------------------------------------------------------------------------
    AUTHORS: Samuel Davenport
    ---------------------------------------------------------------------------
    """
    # instructions = "You are an expert in sentiment analysis."
    # content = "For the following text return positive/neutral/negative depending on the sentiment of the text. Just give the sentiment as an answer. Text: "
    # content = content + text
    instructions = 'You are an expert in sentiment analysis. Given any input text, determine the overall sentiment and classify it as one of the following categories: positive, neutral, or negative. Respond with only one of these words — "positive", "neutral", or "negative" — and nothing else.'
    sentiment = llm_api(text, instructions, model_name=model_name)
    return sentiment


def batch_llm_api(
    texts: List[str],
    instructions: str = "",
    model_name: str = "gpt-4o",
) -> List[str]:
    """
    Process multiple texts through LLM API calls using synchronous requests.

    This function processes a list of texts by calling the llm_api function for each text
    sequentially. It provides a simple batch processing interface without async complexity.

    ---------------------------------------------------------------------------
    ARGUMENTS:
    texts : List[str]
        List of texts to process.
    instructions : str, optional
        System instructions for the LLM. Defaults to empty string.
    model_name : str, optional (default="gpt-4o")
        Name of the model to use for API calls.

    ---------------------------------------------------------------------------
    OUTPUT:
    responses : List[str]
        List of responses from the LLM, in the same order as input texts.

    ---------------------------------------------------------------------------
    AUTHORS: Samuel Davenport
    ---------------------------------------------------------------------------
    """
    responses = []
    total_texts = len(texts)

    for i, text in enumerate(texts):
        # Show progress using bilbystats loader function
        bs.loader(i, total_texts)

        try:
            # Call the existing llm_api function
            response = bs.llm_api(
                text,
                instructions,
                model_name,
            )
            responses.append(response)

        except Exception as e:
            print(f"\nError processing text {i}: {e}")
            responses.append("")  # Add empty string for failed requests

    # Final progress update
    bs.loader(total_texts, total_texts)
    #print(f"\nBatch processing complete! Processed {total_texts} texts.")

    return responses

def batch_llm_api_mr(texts: List[str], instructions: str = "", model_name: str = "gpt-4o", max_rounds: int = 5, columns=None) -> List[str]:
    """
    Run a batched LLM API call with multiple rounds of correction for failed responses.

    This function sends a batch of texts to an LLM API for processing according to the
    provided instructions and model name. If any of the returned responses contain
    missing or empty labels, it retries processing those specific entries up to a
    specified number of rounds. The function returns a DataFrame containing the
    final parsed responses.

    ---------------------------------------------------------------------------
    ARGUMENTS:
    texts : List[str]
        A list of text inputs to be processed by the LLM.
    instructions : str, optional
        Instructions or prompts to guide the LLM’s processing (default is an empty string).
    model_name : str, optional
        The name of the model to use for inference (default is "gpt-4o").
    max_rounds : int, optional
        The maximum number of retry rounds for correcting failed or incomplete responses (default is 5).
    columns : list or None, optional
        Column names to use when parsing the JSON responses into a DataFrame (default is None).

    ---------------------------------------------------------------------------
    OUTPUT:
    df : pd.DataFrame
        A DataFrame containing parsed responses from the LLM, with retries applied to any initially failed entries.
    ---------------------------------------------------------------------------
    AUTHORS: Samuel Davenport
    ---------------------------------------------------------------------------
    """    
    if 'json' not in instructions.lower():
        raise Exception("The output needs to be requested to be in JSON format")
    
    responses = bs.batch_llm_api_single(texts, instructions, model_name)
    df = bs.json2df(responses, columns = columns)

    for i in range(max_rounds - 1):
        empty_labels = df[df['label'].isna() | (df['label'].str.strip() == '')]
        empty_label_index = empty_labels.index
        
        if len(empty_label_index) > 0:
            print('Re-running the ' + str(len(empty_label_index)) + ' texts that failed: Round ' + str(i + 1) + ' of corrections.\n')

            texts2update = [texts[i] for i in empty_label_index]
            
            new_responses = bs.batch_llm_api(texts2update, instructions, model_name)
            new_df = bs.json2df(new_responses, columns = columns)
            new_df.index = empty_label_index
            df.update(new_df)
        else:
            return df

    return df
  
  
def bilby_api(
    start_date: str,
    end_date: str,
    dataset: str = "basic_documents",
    limit: int = 10,
    offset: int = 0,
):
    """
    Query the Bilby API for document data within a date range.

    This function sends a POST request to the Bilby API to retrieve document
    data based on the specified dataset, date range, and pagination parameters.
    The response is returned as a pandas DataFrame.

    ---------------------------------------------------------------------------
    ARGUMENTS:
    dataset : str
        The name of the dataset to query from the Bilby API.
    start_date : str
        The start date for the data request in 'YYYY-MM-DD' format.
    end_date : str
        The end date for the data request in 'YYYY-MM-DD' format.
    offset : int
        The pagination offset, indicating the starting point of the data batch.
    limit : int
        The number of records to retrieve in the request.

    ---------------------------------------------------------------------------
    OUTPUT:
    df : pd.DataFrame
        A DataFrame containing the document data returned by the Bilby API.
    ---------------------------------------------------------------------------
    AUTHORS: Samuel Davenport
    ---------------------------------------------------------------------------
    """
    API_KEY = bs.read_api_key("BILBY")

    response = requests.post(
        "https://api.quant.bilby.ai/v1/documents",
        headers={
            "accept": "application/json",
            "x-api-key": API_KEY,
        },
        json={
            "dataset": dataset,
            "start_date": start_date,
            "end_date": "2024-09-27",
            "offset": 0,
            "limit": 10,
        },
    )

    res = response.json()

    # If you then want to use DataFrames, you can do the following:
    import pandas as pd

    df = pd.DataFrame(res["data"])

    return df


def or_key_status():
    response = requests.get(
        url="https://openrouter.ai/api/v1/key",
        headers={
            "Authorization": "Bearer " + bs.read_api_key('openrouter')
    }
    )
    print(json.dumps(response.json(), indent=2))
    
    

def batch_thread_llm_api(
    texts: List[str],
    instructions: str = "",
    model_name: str = "gpt-4o",
    prefix: str = "",
    num_threads: int = 32,
    batch_size: int = 100,
    temperature: float = 1.0,
    max_tokens: int = None,
    mymessages = None,
    response_format: Optional[Type[BaseModel]] = None
) -> List[str]:
    """
    Process multiple texts through LLM API calls with batching and concurrency control.
    
    This uses threading (like DSPy) instead of async for better compatibility with
    synchronous LLM SDKs and their built-in retry logic.
    
    ---------------------------------------------------------------------------
    ARGUMENTS:
    texts : list[str]
        List of texts to process.
    instructions : str
        System instructions for the LLM.
    model_name : str, optional
        Name of the model to use.
    num_threads : int, optional
        Number of threads in the pool (replaces max_concurrent).
    batch_size : int, optional
        Number of requests per batch.
    response_format : Optional[Type[BaseModel]], optional
        Pydantic model class to enforce structured JSON output.
        If None, returns regular string response.
    temperature : float, optional
        Temperature for LLM sampling.
    max_tokens : int, optional
        Maximum tokens in response.
    mymessages : optional
        Additional messages parameter.
    ---------------------------------------------------------------------------
    OUTPUT:
    responses : list[str]
        List of responses from the LLM, in the same order as input texts.
    ---------------------------------------------------------------------------
    AUTHOR: Samuel Davenport (Modified to use threading)
    ---------------------------------------------------------------------------
    """
    
    def process_single_text(text: str, text_index: int) -> tuple[int, str]:
        """
        Process a single text and return (index, result) to maintain order.
        
        Note: We removed retry logic here because llm_api (synchronous version)
        already has built-in retries. No need for double-retry!
        """
        try:
            result = llm_api(  # Use synchronous version, NOT llm_api_async
                content=prefix + text,
                instructions=instructions,
                model_name=model_name,
                temperature=temperature,
                max_tokens=max_tokens,
                mymessages=mymessages,
                response_format = response_format
            )
            return (text_index, result)
            
        except Exception as e:
            # Let the error propagate or return empty string
            print(f"Error processing text {text_index}: {e}")
            return (text_index, "")
    
    # Initialize progress
    bs.loader(1, 1, "Progress")
    
    # Store results with their original indices
    results_dict = {}
    total_batches = (len(texts) - 1) // batch_size + 1
    
    for batch_idx in range(total_batches):
        start_idx = batch_idx * batch_size
        end_idx = min(start_idx + batch_size, len(texts))
        batch_texts = texts[start_idx:end_idx]
        
        bs.loader(batch_idx, total_batches, "Progress")
        
        # Process batch with thread pool
        with ThreadPoolExecutor(max_workers=num_threads) as executor:
            # Submit all tasks for this batch
            futures = {
                executor.submit(process_single_text, text, start_idx + i): start_idx + i
                for i, text in enumerate(batch_texts)
            }
            
            # Collect results as they complete
            for future in as_completed(futures):
                try:
                    text_index, result = future.result()
                    results_dict[text_index] = result
                except Exception as e:
                    # Handle any unexpected errors
                    original_index = futures[future]
                    print(f"Unexpected error for text {original_index}: {e}")
                    results_dict[original_index] = ""
        
        # Small delay between batches to be nice to the API
        if batch_idx < total_batches - 1:
            time.sleep(0.1)
    
    bs.loader(total_batches, total_batches, "Progress")
    
    # Reconstruct responses in original order
    responses = [results_dict[i] for i in range(len(texts))]
    
    # Report summary
    failed_count = sum(1 for r in responses if r == "")
    if failed_count > 0:
        print(f"\nWarning: {failed_count}/{len(texts)} requests failed")
    
    return responses

def thread_llm_api(
    texts: List[str],
    instructions: str = "",
    model_name: str = "gpt-4o",
    prefix: str = "",
    num_threads: int = 32,
    temperature: float = 1.0,
    max_tokens: int = None,
    mymessages = None,
    show_progress: bool = True
) -> List[str]:
    """
    Process multiple texts through LLM API calls with threading.
    
    This uses threading (like DSPy) for better compatibility with synchronous
    LLM SDKs and their built-in retry logic.
    
    ---------------------------------------------------------------------------
    ARGUMENTS:
    texts : list[str]
        List of texts to process.
    instructions : str
        System instructions for the LLM.
    model_name : str, optional
        Name of the model to use.
    num_threads : int, optional
        Number of threads in the pool.
    prefix : str, optional
        Prefix to add to each text.
    response_format : Optional[Type[BaseModel]], optional
        Pydantic model class to enforce structured JSON output.
        If None, returns regular string response.
    temperature : float, optional
        Temperature for LLM sampling.
    max_tokens : int, optional
        Maximum tokens in response.
    mymessages : optional
        Additional messages parameter.
    show_progress : bool, optional
        Whether to show progress updates.
    ---------------------------------------------------------------------------
    OUTPUT:
    responses : list[str]
        List of responses from the LLM, in the same order as input texts.
    ---------------------------------------------------------------------------
    AUTHOR: Samuel Davenport (Modified to use threading)
    ---------------------------------------------------------------------------
    """
    
    def process_single_text(text: str, text_index: int) -> tuple[int, str]:
        """
        Process a single text and return (index, result) to maintain order.
        
        Note: llm_api (synchronous version) already has built-in retries,
        so we don't need retry logic here.
        """
        try:
            result = llm_api(  # Use synchronous version, NOT llm_api_async
                content=prefix + text,
                instructions=instructions,
                model_name=model_name,
                temperature=temperature,
                max_tokens=max_tokens,
                mymessages=mymessages
            )
            return (text_index, result)
            
        except Exception as e:
            print(f"Error processing text {text_index}: {e}")
            return (text_index, "")
    
    # Initialize
    total_items = len(texts)
    results_dict = {}
    completed = 0
    
    if show_progress:
        bs.loader(0, total_items, "Progress")
    
    # Process all texts with thread pool
    with ThreadPoolExecutor(max_workers=num_threads) as executor:
        # Submit all tasks at once - thread pool handles queuing
        futures = {
            executor.submit(process_single_text, text, i): i
            for i, text in enumerate(texts)
        }
        
        # Collect results as they complete
        for future in as_completed(futures):
            try:
                text_index, result = future.result()
                results_dict[text_index] = result
                
                # Update progress
                completed += 1
                if show_progress and completed % 10 == 0:  # Update every 10 items
                    bs.loader(completed, total_items, "Progress")
                    
            except Exception as e:
                # Handle any unexpected errors
                original_index = futures[future]
                print(f"Unexpected error for text {original_index}: {e}")
                results_dict[original_index] = ""
                completed += 1
    
    if show_progress:
        bs.loader(total_items, total_items, "Progress")
    
    # Reconstruct responses in original order
    responses = [results_dict[i] for i in range(len(texts))]
    
    # Report summary
    failed_count = sum(1 for r in responses if r == "")
    if failed_count > 0:
        print(f"\nWarning: {failed_count}/{len(texts)} requests failed")
    
    return responses

def make_response_format(schema: dict | list, name: str = "name", strict: bool = True) -> dict:
    """
    Build a response_format dict for Claude's API.
    
    Schema can be:
    - A dict: returns an object with those properties
    - A list with one dict element: returns an array of objects with that schema
    
    Examples:
        # Object output
        make_response_format({"name": str, "age": int})
        
        # Array output
        make_response_format([{"Sector": ["Energy", "Materials"], "Likely_Impact": str}])
    """
    
    def build_properties(schema_dict: dict) -> tuple[dict, list]:
        """Convert a schema dict to JSON Schema properties and required list."""
        properties = {}
        required = []
        
        for key, value in schema_dict.items():
            required.append(key)
            
            if value == str:
                properties[key] = {"type": "string"}
            elif isinstance(value, list) and all(isinstance(v, str) for v in value):
                # Enum case: list of allowed strings
                properties[key] = {
                    "type": "string",
                    "enum": sorted(value)
                }
            elif value == int:
                properties[key] = {"type": "integer"}
            elif value == float:
                properties[key] = {"type": "number"}
            elif value == bool:
                properties[key] = {"type": "boolean"}
            # Support list[str] or tuple ('list', str)
            elif value == list[str] or value == (list, str):
                properties[key] = {
                    "type": "array",
                    "items": {"type": "string"}
                }
            elif value == list[int] or value == (list, int):
                properties[key] = {
                    "type": "array",
                    "items": {"type": "integer"}
                }
            elif value == list[float] or value == (list, float):
                properties[key] = {
                    "type": "array",
                    "items": {"type": "number"}
                }
            elif value == list[bool] or value == (list, bool):
                properties[key] = {
                    "type": "array",
                    "items": {"type": "boolean"}
                }
            elif value == dict[str, str] or value == (dict, str):
                properties[key] = {
                    "type": "object",
                    "additionalProperties": {"type": "string"}
                }
            elif value == dict[str, int] or value == (dict, int):
                properties[key] = {
                    "type": "object",
                    "additionalProperties": {"type": "integer"}
                }
            elif value == dict[str, float] or value == (dict, float):
                properties[key] = {
                    "type": "object",
                    "additionalProperties": {"type": "number"}
                }
            elif value == dict[str, bool] or value == (dict, bool):
                properties[key] = {
                    "type": "object",
                    "additionalProperties": {"type": "boolean"}
                }
            elif isinstance(value, dict):
                # Nested schema — recursive handling
                nested_props, nested_req = build_properties(value)
                properties[key] = {
                    "type": "object",
                    "properties": nested_props,
                    "required": nested_req,
                    "additionalProperties": False
                }
            else:
                raise ValueError(f"Unsupported schema type for field '{key}': {value}")
        
        return properties, required

    # Determine if root is array or object
    if isinstance(schema, list):
        if len(schema) != 1 or not isinstance(schema[0], dict):
            raise ValueError("Array schema must be a list with exactly one dict element")
        
        item_schema = schema[0]
        item_props, item_req = build_properties(item_schema)
        
        root_schema = {
            "type": "array",
            "items": {
                "type": "object",
                "properties": item_props,
                "required": item_req,
                "additionalProperties": False
            }
        }
    else:
        properties, required = build_properties(schema)
        root_schema = {
            "type": "object",
            "properties": properties,
            "required": required,
            "additionalProperties": False
        }
    
    return {
        "type": "json_schema",
        "json_schema": {
            "name": name,
            "strict": strict,
            "schema": root_schema
        }
    }

def conversation(content: str,
                 instructions: str = "",
                 model_name: str = "gpt-4o",
                 temperature: float = 1.0,
                 max_tokens: int = 1000,
                 do_stream: bool = False,
                 top_logprobs = None,
                 mymessages = None):
    inconvo = True
    
    while inconvo:
        out = bs.llm_api(content, instructions = '', model_name = 'anthropic/claude-sonnet-4.5', mymessages=mymessages, do_stream = True)
