"""
Plugin that implements using OpenAI API compatible AI providers for Wing Pro's AI assisted
software development features.  This file contains the public API expected by Wing.  Files
named _openai_*.py contain the implementation details.

Copyright (c) 1999-2024, Archaeopteryx Software, Inc.  All rights reserved.

Written by Stephan R.A. Deibel and John P. Ehresman
"""

import re
import os
import sys
import pathlib
import traceback

old_path = list(sys.path)
try:
    sys.path.append(os.path.dirname(os.path.abspath(__file__)))
    import _openai_completion as comp_impl
    import _openai_chat as chat_impl
finally:
    sys.path = old_path

PROVIDER = "OpenAI"

_CONFIG_DEFAULTS = {
    # Configuration name
    'name': 'Untitled',
    
    # Provider / base URL
    'base_url': ('', None),
    
    # API Key
    'key': ('default', None),
    
    # Model
    'model': '',
    
    # Billing currency and costs per 1000 prompt and result tokens
    'currency': '$',
    'prompt_tok': .0,
    'result_tok': .0,
    
    # Override defaults for core attribs
    'max_context_unit_size': 50,
    'max_total_context_size': 100, 
    'max_upload_size': 250,
    'max_upload_count': 20
}

_SUPPORTED_PROVIDERS = [
    ("Claude", 'claude'), 
    ("Deepseek", 'deepseek'),
    ("Gemini", 'gemini'), 
    ("Grok", 'grok'),
    ("Mistral", 'mistral'), 
    ("OpenAI", 'openai'),
    ("Perplexity", 'perplexity'),
    None, 
    ("Azure OpenAI Endpoint:", 'azure'), 
    ("OpenAI Compatible Base URL:", 'custom'), 
]
_SUPPORTED_PROVIDER_BASE_URLS = {
    'azure': '',
    'claude': 'https://api.anthropic.com/v1/',
    'deepseek': 'https://api.deepseek.com', 
    'gemini': 'https://generativelanguage.googleapis.com/v1beta/openai/',
    'mistral': 'https://api.mistral.ai/v1/',
    'grok': 'https://api.x.ai/v1',
    'openai': 'None',
    'perplexity': 'https://api.perplexity.ai',
}
_SUPPORTED_PROVIDER_DEFAULT_MODELS = {
    'azure': 'gpt-4.1',
    'claude': 'claude-3-7-sonnet-20250219',
    'deepseek': 'deepseek-chat', 
    'gemini': 'gemini-2.0-flash',
    'mistral': 'mistral-large-latest',
    'grok': 'grok-3-beta',
    'openai': 'gpt-4o',
    'perplexity': 'sonar-pro',
    'custom': '',
}
_SUPPORTED_PROVIDER_DEFAULT_COSTS = {
    'azure': (0., 0.),
    'claude': (.003, .015),
    'deepseek': (.00027, .0011), 
    'gemini': (.0001, .0007),
    'mistral': (.002, .006),
    'grok': (.003, .015),
    'openai': (.0025, .01),
    'perplexity': (.003, .015),
    'custom': (0., 0.),    
}

CONFIG = _CONFIG_DEFAULTS.copy()

def GetConfigHelp():
    
    import gettext
    _ = gettext.translation('plugins_ai_openai_gpt', fallback=True).gettext
    
    return _("Please select the OpenAI API compatible AI provider, API key, and model to use with your AI provider "
             "configuration.  Although older models work with a free API keys, you may get better results by purchasing "
             "an API key and using recent models.  Please check the AI provider's current pricing and rate limit policies.")
    
def GetConfigFields():
    
    import _winghome
    from guiutils import formbuilder
    
    import gettext
    _ = gettext.translation('plugins_ai_openai_gpt', fallback=True).gettext
    
    _api_key_choices = [
        # This is adjusted in ConnectConfigFields() according to AI vendor used
        (_("Use Key File {}").format(os.path.join(_winghome.kUserSettings, 'openai.key')), 'default', formbuilder.CHiddenGui()),
        (_("Use Key File:"), 'file', formbuilder.CFileSelectorGui(default_ext='.key', name_type='existing-file')),
        (_("Use Key:"), 'key', formbuilder.CSmallTextGui(max_chars=256)), 
    ]

    fields = [
        (_("API Host"), 'base_url', '', formbuilder.CPopupPlusOtherGui(_SUPPORTED_PROVIDERS, formbuilder.CSmallTextGui(max_chars=255),
                                                                       enable_other_for=('custom', 'azure'), hide_if_disabled=True),
           _("Select a predefined OpenAI compatible API provider or specify a custom base URL.")),        
        (_("API Key"), 'key', _CONFIG_DEFAULTS['key'], formbuilder.CRadioChoiceWithSubGuis(False, _api_key_choices),
         _("The AI provider API key to use for this AI provider configuration.  You may place it in a file, either at the "
           "default location in Wing's settings directory or another file, or paste it in here.")), 
        (_("Model"), 'model', _CONFIG_DEFAULTS['model'], formbuilder.CSmallTextGui(default=_CONFIG_DEFAULTS['model']),
         _("The generative AI model to use for this AI configuration.  The default may not be the best model "
           "available.  Check the AI provider's website for the latest models and pricing.")),
        (_("Billing Currency Symbol"), 'currency', _CONFIG_DEFAULTS['currency'], formbuilder.CSmallTextGui(default=_CONFIG_DEFAULTS['currency']),
         _("The currency symbol to use in displays of money spent making AI requests.  This should match the currency in which "
           "you are billed by the AI provider.")), 
        (_("Cost/1M Input Tokens"), 'prompt_tok', _CONFIG_DEFAULTS['prompt_tok'] * 1000., formbuilder.CNumberGui(0.00, 10000., .001, 3, default=_CONFIG_DEFAULTS['prompt_tok'] * 1000.),
         _("The cost per 1,000,000 input tokens in AI requests.  This varies by model and should be given in the above-configured currency.")), 
        (_("Cost/1M Output Tokens"), 'result_tok', _CONFIG_DEFAULTS['result_tok'] * 1000., formbuilder.CNumberGui(0.00, 10000., .001, 3, default=_CONFIG_DEFAULTS['result_tok'] * 1000.),
         _("The cost per 1,000,000 output tokens for responses to AI requests.  This varies by model and should be given in the above-configured currency.")), 
    ]

    return fields, _CONFIG_DEFAULTS

def ConnectConfigFields(form_page):
    """Connect any field relationships in the configuration form page."""
    
    from guiutils import formutils
    from guiutils import widgets
    from guiutils import wgtk
    import _winghome
    
    import gettext
    _ = gettext.translation('plugins_ai_openai_gpt', fallback=True).gettext

    def base_url_changed(*args, blank_only=False):
        
        brand, base_url = form_page['base_url']
        if brand is None:
            return
        
        # Update default key file path
        fn = brand.lower() + '.key'
        for child in formutils.GetAllFieldWidgets(form_page.GetField('key')):
            if isinstance(child, wgtk.RadioButton):
                child.set_label(_("Use Key File {}").format(os.path.join(_winghome.kUserSettings, fn)))
                break
            
        # Update default model and costs
        for child in formutils.GetAllFieldWidgets(form_page.GetField('model')):
            if isinstance(child, widgets.CChoiceEntryPanel) and (not blank_only or not child.text()):
                child.setText(_SUPPORTED_PROVIDER_DEFAULT_MODELS.get(brand, ''))
                break
        for i, field in enumerate(('prompt_tok', 'result_tok')):
            for child in formutils.GetAllFieldWidgets(form_page.GetField(field)):
                if isinstance(child, wgtk.QLineEdit) and (not blank_only or float(child.text()) == 0.):
                    child.setText(str(_SUPPORTED_PROVIDER_DEFAULT_COSTS.get(brand, (0., 0.))[i]*1000.))
                    break
            
    @wgtk.call_at_idle
    def connect_field_relationship():
        for child in formutils.GetAllFieldWidgets(form_page.GetField('base_url')):
            if isinstance(child, wgtk.OptionMenu):
                wgtk.connect_while_alive(child, 'changed', base_url_changed, form_page)
        base_url_changed(blank_only=True)
    
    wgtk.gui_connect(form_page, 'map', wgtk.NoArgCallback(connect_field_relationship))

def GetVendor():
    """Get the AI provider vendor's name for display to the user"""
    
    import gettext
    _ = gettext.translation('plugins_ai_openai_gpt', fallback=True).gettext

    brand, base_url = _GetConfig('base_url')
    if brand == 'custom':
        return _GetConfig('name')
    elif brand == 'openai':
        return "OpenAI"
    else:
        return brand.title()
    
def GetConfigSummary(config):
    
    import gettext
    _ = gettext.translation('plugins_ai_openai_gpt', fallback=True).gettext
    
    brand, base_url = config.get('base_url', ('openai', None))

    def prune_cost(cost):
        cost = '{}'.format(cost)
        if cost.endswith('.0'):
            cost = cost[:-2]
        return cost
            
    summary = []
    summary.append("{} {}".format(brand.title(), config['model']))
    summary.append('{}{}in+{}{}out/1Mtok'.format(config['currency'], prune_cost(config['prompt_tok']*1000.),
                                                 config['currency'], prune_cost(config['result_tok']*1000.)))
    
    which, value = config['key']
    if which == 'default':
        key_loc = _("default location")
    elif which == 'file':
        if len(value) > 20:
            value = '...' + value[-17:]
        key_loc = _("in {}").format(value)
    else:
        key_loc = '...' + value[-8:]
    summary.append(_("Key:") + ' ' + key_loc)
    
    return '; '.join(summary)
    
def ValidateConfig(config):
    
    import _winghome
    import gettext
    _ = gettext.translation('plugins_ai_openai_gpt', fallback=True).gettext

    errors = {}
    
    url_regex = re.compile(r'^https?://\S+$', re.IGNORECASE)
    brand, base_url = config['base_url']
    if brand is None:
        errors['base_url'] = _("You must select an AI provider API Host")
        
    if brand == 'custom' and not url_regex.match(base_url):
        errors['base_url'] = _("The OpenAI compatible API base_url is not valid.  Please enter a valid URL starting with https://")
    if brand == 'azure' and not url_regex.match(base_url):
        errors['base_url'] = _("The Azure OpenAI engpoint is not valid.  Please enter a valid URL starting with https://")
    
    choice, keyfile = config['key']

    if brand is not None and choice == 'default':
        keyfile = os.path.join(_winghome.kUserSettings, f'{brand.lower()}.key')
    if brand is not None and choice in ('default', 'file'):
        if not os.path.exists(keyfile):
            errors['key'] = _("Key file does not exist: {}").format(keyfile)
    elif choice == 'key' and not keyfile:
        errors['key'] = _("You must enter an API key")

    if not config['model'].strip():
        errors['model'] = _("You must choose an AI model")
        
    if not config['currency'].strip():
        errors['currency'] = _("You must enter a currency symbol for cost display")
        
    try:
        x = float(config['prompt_tok'])
    except:
        errors['prompt_tok'] = _("Cost of prompt tokens must be a number")
        
    try:
        x = float(config['result_tok'])
    except:
        errors['result_tok'] = _("Cost of result tokens must be a number")
        
    return errors
    
def Suggest(insertion_position: int, filename: str, text: str, request: str, files: dict, result_cb) -> str:
    """Request a suggestion for code to insert after the given insertion point in the
    editor with given filename and content text. Optionally, also include the given extra
    files in the query as a dictionary from file name to (content, mime_type, description).
    
    This calls the given result_cb with the (error, cost, response) where error is
    a non-empty error on failure and otherwise response is the code suggested by the
    language model.

    Return value is the spawn.CProcess object running the query and a description of the
    query for diagnostic purposes."""

    preceding_code = text[:insertion_position].replace('```', '"""')
    following_code = text[insertion_position:].replace('```', '"""')
    
    files = comp_impl.GetFilesPart(files)
    if files:
        files = comp_impl.SUGGEST_FILES + files
    if request:
        request = comp_impl.SUGGEST_REQUEST.format(**locals())
        
    query = comp_impl.SUGGEST_PROMPT.format(**locals())
    
    def suggest_complete(error, cost, response, mime_type):
        result_cb(error, cost, response, mime_type)
        
    proc, description = comp_impl.CompletionQuery(query, suggest_complete)
    description = "Starting AI Suggest:" + '\n' + description
    
    return proc, description
    
def Transform(selection_position: int, selected_text: str, request: str, filename: str, text: str, files: dict, output_variant, result_cb) -> str:
    """Transform the selected text using a written request from the user, within the
    context of the named file and its full content. Optionally, also include the given
    extra files in the query as a dictionary from file name to (content, description).
    
    The output_variant is None if the result is going to replace the selected_text, or
    'new-file' when it will be pasted into a new file.
    
    If selected_text is empty and output_variant is None then this call is completed using
    Suggest().
    
    This calls the given result_cb with the (error, cost,response) where error is a
    non-empty error on failure and otherwise response is the replacement code suggested by
    the language model.
    
    Return value is the spawn.CProcess object running the query and a description of the
    query for diagnostic purposes."""

    import gettext
    _ = gettext.translation('plugins_ai_openai_gpt', fallback=True).gettext

    if not selected_text.strip() and output_variant is None:
        return Suggest(selection_position, filename, text, request, files, result_cb)
    
    files = comp_impl.GetFilesPart(files)
    if files:
        files = comp_impl.TRANSFORM_FILES + files
    
    selected_text = selected_text.replace('```', '"""')    
    request = request.replace('```', '"""')
    text = text.replace('```', '"""')

    if output_variant is None:
        query = comp_impl.TRANSFORM_PROMPT.format(**locals())
    else:
        assert output_variant == 'new-file'
        query = comp_impl.TRANSFORM_PROMPT_NEW_FILE.format(**locals())
    
    def transform_complete(error, cost, response, mime_type):
        result_cb(error, cost, response, mime_type)
        
    try:
        proc, description = comp_impl.CompletionQuery(query, transform_complete)
    except Exception as exc:
        return None, _("AI Transform failed:") + ' ' + exc.args[0]
    else:
        description = _("Starting AI Transform:") + '\n' + description
    
    return proc, description

def Info(request: str, filename: str, text: str, files: dict, result_cb) -> str:
    """Ask about the given file and its contents using a written request from the user.
    This is intended for cases where no code is being changed, abnd the result is a summary
    or other processing of the given file.  Optionally, also include the given extra files
    in the query as a dictionary from file name to (content, description).
    
    This calls the given result_cb with the (error, cost, response) where error is a
    non-empty error on failure and otherwise response is the replacement code suggested by
    the language model.
    
    Return value is the spawn.CProcess object running the query and a description of the
    query for diagnostic purposes."""

    import gettext
    _ = gettext.translation('plugins_ai_openai_gpt', fallback=True).gettext

    files = comp_impl.GetFilesPart(files)
    if files:
        files = comp_impl.TRANSFORM_FILES + files
    
    request = request.replace('```', '"""')
    text = text.replace('```', '"""')

    query = comp_impl.INFO_PROMPT.format(**locals())
    
    def transform_complete(error, cost, response, mime_type):
        result_cb(error, cost, response, mime_type)
        
    try:
        proc, description = comp_impl.CompletionQuery(query, transform_complete)
    except Exception as exc:
        return None, _("AI Info failed:") + ' ' + exc.args[0]
    else:
        description = _("Starting AI Info:") + '\n' + description
    
    return proc, description

class Assistant(chat_impl.AssistantClient):
    """An AI assistant that uses OpenAI's Assistants API to implement chat. Each assistant
    can be associated with a number of files as context for that chat. One or more chat
    threads may be created, each of which consists of a sequence of requests and
    responses. Each request may have a file associated with it, in addition to those that
    are attached to the assistant as a whole.
    
    All operations run asynchronously, with the result of the operation being passed back
    to the caller through a callable result_cb.
    
    NOTE:  This won't work with AI providers other than OpenAI because they lack the API
    compatibility.  We call other code to implement AI Chat when those are selected."""
    
    # Maximum number of files that can be given to an assistant as context.  This
    # value is determined by OpenAI.
    MAX_ASSISTANT_FILES = chat_impl.MAX_ASSISTANT_CONTEXT_FILES

    def __init__(self, log_cb: callable):
        """Constructor. The given log_cb is called with output suitable for logging when
        there is diagnostic output from the server process."""
        
        self.log_cb = log_cb

        self._clear()
        
        super(Assistant, self).__init__()
        
    def _clear(self):

        self.assistant_id = None
        self.uploaded_files = []
        self.proc = None
        self._any_messages = False
        
    def any_messages(self):
        return self._any_messages
    
    def _error(self, error):
        self.log_cb(error)

    def connect(self, result_cb: callable):
        """Create the underlying services and connect to them.
        
        Calls result_cb(error: str) where error is an error string suitable for logging if
        the connection failed, and None on success."""
        
        def server_started(exit_code, error, host, port):

            if error:
                result_cb(error)
                return
            if exit_code != -1:
                result_cb(f"Server exited unexpectedly with exit_code {exit_code}")
                return

            self.connect_to_server(host, port)

            config = CONFIG.copy()
            
            try:
                key = _APIKey()
            except Exception as exc:
                result_cb(f"Server started but there is no valid API Key: {exc.args[0]}")
                return
                
            config['key'] = ('key', key)
        
            def config_done():
                result_cb(None)
            self._send_request('set_config', config_done, config=config)
            
        self.proc, description = chat_impl.StartServer(server_started, self.log_cb)
        self.log_cb(description)
        
    def create(self, name: str, result_cb: callable):
        """Create the assistant in the OpenAI account, using the given assistant name.
        This creates an OpenAI assistant in the user's account.
        
        Calls result_cb(error: str) where error is an error string suitable for logging if
        it fails to create the resources, or None on success."""

        def wrap_result_cb(assistant_id, error):
            self.assistant_id = assistant_id
            result_cb(error)
            
        self._send_request('create', wrap_result_cb, name=name)

    def delete(self, result_cb: callable):
        """Delete this OpenAI Assistant instance and all its threads, and then remove the
        Assistant and all associated uploaded files from the user's OpenAI account.
        
        Calls result_cb(error: str) where error is an error string suitable for logging if
        there were any failures or None if all resources were successfully deleted from
        the OpenAI account.
        
        If there are errors, some resources could not be deleted and should be removed
        later with delete_assistant_from_account() and delete_uploaded_file_from_account()."""
        
        def wrap_result_cb(error):
            
            self.disconnect()
            self._clear()
            
            if self.proc is not None:
                self.proc.Kill()
            
            result_cb(error)
            
        self._send_request('delete', wrap_result_cb)
        
    def get_id(self) -> str:
        """Get the OpenAI id for this assistant.  The value is None if the assistant
        hasn't been created yet."""
        
        return self.assistant_id

    def set_context(self, files: list, result_cb: callable):
        """Set the context files to use for this assistant as a whole.  This includes
        all the files other than the current file that are relevant to conversation
        threads hosted by the assistant.  The files should be sent most important first
        since OpenAI limits how many can be associated with the assistant.
    
        The arg files is a list of (filename, content_file, description) where content_file
        is None if the primary file content should be used or non-None if it contains the
        content to use for the file, encoded as utf-8.
    
        Returns (file_ids, error) where file_ids is a list of Open AI file ids for the
        context files on success and error is and error message if one or more file
        failed to upload or associate with the assistant.
    
        When there are errors, there may still be items in file_ids for the files that were
        successfully processed."""

        def wrap_result_cb(file_ids, uploaded_files, error):
            self.uploaded_files = uploaded_files
            result_cb(file_ids, error)
            
        self._send_request('set_context', wrap_result_cb, files=files)
        
    def create_thread(self, result_cb: callable):
        """Create a new chat thread managed by this assistant. Calls the given
        result_cb(thread_id: str, error: str) when complete, where thread_id is the
        thread id on success and error is an error string on failure."""

        self._send_request('create_thread', result_cb)
        
    def delete_thread(self, thread_id, result_cb: callable):
        """Delete the given chat thread managed by this assistant. Calls the given
        result_cb(deleted: bool) when complete, where indicating whether the thread was
        successfully deleted."""

        self._send_request('delete_thread', result_cb, thread_id=thread_id)
        
    def run_request(self, thread_id: str, request: str, filename: str, content_file: str,
            selected_text: str, result_cb: callable) -> int:
        """Add a user request to the given thread and run it. Optionally, the request may
        be associated with a filename with given content and/or a range of selected text
        that serves as the focus for the message.
        
        Calls the given result_cb(status: str, response: str, mime_types: list[str],
        cost:float, error: str) when the request has finished running, where:
        
        * status is one of: queued, in_progress, requires_action, cancelling, cancelled,
          failed, completed, or expired
        * response is a list of lines in the AI's response, or None if the request failed
        * mime_types is a dictionary from line number in the response (0=first) to
          the mime type of that line. This is used to indicate some lines are Python
          code or other types of code. Those lines not in the mime_types dictionary
          are plain text.
        * cost is the amount that the request cost to run
        * error is set to a string suitable for logging if the request failed
        
        Returns a unique ID for the request that can be sent to cancel_request()
        """

        def wrap_result_cb(status, response, mime_types, cost, uploaded_files, error):
            self._any_messages = True
            self.uploaded_files = uploaded_files
            result_cb(status, response, mime_types, cost, error)
                
        request_id = self._send_request('run_request', wrap_result_cb,
                                        thread_id=thread_id, request=request, filename=filename,
                                        content_file=content_file, selected_text=selected_text)
        
        return request_id
        
    def cancel_request(self, request_id: str, thread_id: str, result_cb):
        """Cancel the currently running request in the given thread."""

        if request_id not in self._callback_map:
            return
        
        self._ignore_callbacks.add(request_id)
        
        self._send_request('cancel_request', result_cb, thread_id=thread_id)
        
    def get_uploaded_files(self):
        """Get the list of uploaded files for this assistant. Returns a list of (file_id,
        filename).
        
        This resulting file_ids list includes files in the current context, files
        associated with a request on threads that still exists, and also any others that
        were previously uploaded not yet removed from the OpenAI account. This occurs
        because unused uploaded files are cached in the account while the assistant still
        exists, in order to avoid repeated re-uploads."""
        
        return self.uploaded_files
        
    @classmethod
    def delete_uploaded_file_from_account(cls, file_id):
        """Delete the given file from the OpenAI account. Returns an error if the file was
        not deleted, and None on success.  This runs synchronously and thus may take
        time to complete."""
        
        return chat_impl.Assistant.delete_uploaded_file_from_account(file_id)

    @classmethod
    def delete_assistant_from_account(cls, assistant_id):
        """Delete the given assistant from the OpenAI account.  This runs synchronously
        and thus may take some time to complete."""
        
        return chat_impl.Assistant.delete_assistant_from_account(assistant_id)
    
    @classmethod
    def delete_matching_resources(cls, untitled_vector_stores=False):
        """Delete all assistants and files that were created by this OpenAI integration and
        all uploaded files matching the given prefix in path name from the OpenAI account.
    
        This is used for cleanup when normal resource removal fails. Use with caution, since
        this may remove assistants and files that are in use!"""
        
        return chat_impl.Assistant.delete_matching_resources(untitled_vector_stores)


class ThreadForOpenAICompletionsAPI:
    """Replacement for the Thread class in _openai_chat.py to use when trhe provider is not OpenAI
    and thus does not implement the Assistants API."""
    
    def __init__(self, assistant):

        from wingbase import textutils
        
        if 0:
            isinstance(assistant, AssistantForOpenAICompletionsAPI)
            
        self.assistant = assistant
        self.id = 'thread-' + textutils.GeneratePW()
        self.messages = []

    def add_message(self, request, filename, content_file, selected_text):
        self.messages.append(('user', request, filename, content_file, selected_text))

    def add_response(self, response):
        self.messages.append(('assistant', response, None, None, None))
        
    def _get_file_content(self, filename, content_filename):

        from wingbase import location
        from wingbase import mime

        if content_filename is None:
            content_loc = location.CreateFromName(filename)
        else:
            content_loc = location.CreateFromName(content_filename)
            
        if not content_loc.DoesResourceExist():
            return None, None
        
        mime_type = mime.GuessMimeType(content_loc)
        content = [None]

        def got_content(error, txt, encoding):
            content[0] = txt
        location.ReadFromLocation(content_loc, got_content)

        # Should always be local file and thus immediately read
        assert content[0] is not None

        return content[0], mime_type
        
    def build_messages(self, request: str, filename:str, content_file: str, selected_text: str) -> str:
        """Build the messages to send to the completions API.  Returns a list of (message_type, content_type, message)
        where message_type is one of 'system', 'user', or 'assistant', content_type is currently always 'text', and
        message is the message content."""
    
        import gettext
        _ = gettext.translation('plugins_ai_openai_gpt', fallback=True).gettext

        from wingbase import location

        request = request.replace('```', '"""')

        messages = []
        
        # Add system message for context including currently open file/selection and other context
        msg = 'This automated call is being made by a plugin to Wing IDE that allows a chat in which you can help the user with their code.  Your responses can include explanations as well as sections of code delimited by triple back ticks.'
        
        if filename is not None:
            text, primary_mime_type = self._get_file_content(filename, content_file)
            text = text.replace('```', '"""')
            if selected_text:
                msg += comp_impl.CHAT_VIA_COMPLETIONS_API_PROMPT.format(**locals())
            else:
                msg += comp_impl.CHAT_VIA_COMPLETIONS_API_PROMPT_NO_SELECTION.format(**locals())
            
        context = {}
        for file_id, filename, content_filename, description in self.assistant.context:
            loc = location.CreateFromName(filename)
            content, mime_type = self._get_file_content(filename, content_filename)
            if content is not None:
                context[loc] = (content, mime_type, description)                
        files = comp_impl.GetFilesPart(context)
        if files:
            msg += comp_impl.TRANSFORM_FILES + files
            
        messages.append(('system', 'text', msg))
            
        # Add previous request/response messages
        for mtype, req, filename, content_file, selected_text in self.messages:
            messages.append((mtype, 'text', req))
            
        # Add current request
        messages.append(('user', 'text', request))
        
        return messages

        
class AssistantForOpenAICompletionsAPI:
    """Replacement for the Assistant class in the AI provider to use when the provider is not OpenAI
    and thus does not implement the Assistants API used by that class and the code in
    _openai_chat.py."""
    
    def __init__(self, log_cb: callable):

        from wingbase import textutils

        self.log_cb = log_cb
        self.assistant_id = 'assistant-' + textutils.GeneratePW()
        self.threads = {}
        self.context = []
        self._socket = True
        self.running_requests = {}
        self._any_messages = False

    def connect(self, result_cb: callable):
        result_cb(None)
        
    def create(self, name: str, result_cb: callable):
        
        self.name = name
        result_cb(None)
        
    def get_id(self):
        
        return self.assistant_id

    def create_thread(self, result_cb: callable):
        
        thread = ThreadForOpenAICompletionsAPI(self)
        self.threads[thread.id] = thread
        
        result_cb(thread.id, None)
        
    def any_messages(self):
        return self._any_messages
    
    def delete_thread(self, thread_id, result_cb: callable):
        
        if thread_id in self.threads:
            del self.threads[thread_id]
            
        result_cb(True)
        
    def set_context(self, files: list, result_cb: callable):

        from wingbase import textutils

        self.context = []
        retval = []
        for filename, content_filename, description in files:
            file_id = 'file-' + textutils.GeneratePW()
            self.context.append((file_id, filename, content_filename, description))
            retval.append((file_id, filename))

        result_cb(retval, None)
    
    def get_uploaded_files(self):
        
        retval = []
        for file_id, filename, content_file, description in self.context:
            retval.append((filename, file_id))
            
        return retval
    
    def run_request(self, thread_id: str, request: str, filename: str, content_file: str,
            selected_text: str, result_cb: callable) -> int:
        """Add a user request to the given thread and run it. Optionally, the request may
        be associated with a filename with given content and/or a range of selected text
        that serves as the focus for the message.
        
        Calls the given result_cb(status: str, response: str, mime_types: list[str],
        cost:float, error: str) when the request has finished running, where:
        
        * status is one of: queued, in_progress, requires_action, cancelling, cancelled,
          failed, completed, or expired
        * response is a list of lines in the AI's response, or None if the request failed
        * mime_types is a dictionary from line number in the response (0=first) to
          the mime type of that line. This is used to indicate some lines are Python
          code or other types of code. Those lines not in the mime_types dictionary
          are plain text.
        * cost is the amount that the request cost to run
        * error is set to a string suitable for logging if the request failed
        
        Returns a unique ID for the request that can be sent to cancel_request()
        """
        
        import gettext
        _ = gettext.translation('plugins_ai_openai_gpt', fallback=True).gettext

        assert thread_id not in self.running_requests
        
        thread = self.threads[thread_id]        
        messages = thread.build_messages(request, filename, content_file, selected_text)
        thread.add_message(request, filename, content_file, selected_text)
        
        def request_complete(exit_code, error, cost, response):
            
            if thread_id not in self.running_requests:
                return
            
            del self.running_requests[thread_id]
            
            if exit_code != 0:
                error = 'Chat via completions API failed with exit_code={}; output:'.format(exit_code) + '\n' + error
                
            if error and response:
                print("AI provider error output with possibly valid response:")
                print(error)
                print(response)

            self._any_messages = True
            
            response, mime_types = chat_impl.ParseMessage(response)
            thread.add_response(response)
            
            result_cb('completed', response, mime_types, cost[1], error)
            
        try:
            proc, description = comp_impl._RunQueryProcess(messages, request_complete)
        except Exception as exc:
            error = _("AI chat via completions API failed:") + ' ' + exc.args[0]
            result_cb('failed', None, [], 0., error)
            return None, error
        else:
            description = _("Starting AI chat via completions API:") + '\n' + description
        
        self.running_requests[thread_id] = (proc, result_cb)
        
        return id(result_cb)
        
    def cancel_request(self, request_id: str, thread_id: str, result_cb):

        if thread_id not in self.running_requests:
            result_cb(None)
            return
        
        proc, ignore = self.running_requests[thread_id]
        del self.running_requests[thread_id]
        
        try:
            proc.Kill()
            error = None
        except:
            error = _("Failed to terminate assistant via completions API process:") + '\n' + traceback.format_exc()
            
        result_cb(error)
        
    
##########################################################################
# Shared local utilities that need access to data in this module but
# are used in the implementation modules and not here

def _OverrideConfig(config):
    global CONFIG
    CONFIG = config
        
def _APIKey():
    
    # Punt on translation when running in the chat server where we can't import 'ai'
    try:
        import gettext
        _ = gettext.translation('plugins_ai_openai_gpt', fallback=True).gettext
    except ImportError:
        def _(x):
            return x

    brand, base_url = CONFIG['base_url']
    if not brand:
        brand = 'openai'
    selector, value = CONFIG['key']
    
    if selector == 'key':
        return value.strip()
    
    elif selector == 'file':
        if not os.path.isfile(value):
            raise RuntimeError(_("Key file {} does not exist").format(value))
        
        with open(value) as f:
            return f.read().strip()
    
    if selector == 'default':

        import gettext
        _ = gettext.translation('plugins_ai_openai_gpt', fallback=True).gettext
        
        import _winghome
        
        KEYPATH = [
            _winghome.kUserSettings, 
            _winghome.kWingHome, 
        ]
    
        for path in KEYPATH:
            try:
                key = (pathlib.Path(path) / f'{brand.lower()}.key').read_text()
                return key.strip()
            except:
                continue
            
        raise RuntimeError(_("Could not find %s.key in settings or installation directory") % brand.lower())
    
    else:
        raise RuntimeError(_("Invalid AI provider API key configuration"))

def _GetConfig(key):
    return CONFIG.get(key, _CONFIG_DEFAULTS[key])


# Set these rather than importing openai_gpt in the impls to avoid getting a different copy
# of the module since the plugin system doesn't register modules in sys.modules

chat_impl._OverrideConfig = _OverrideConfig
chat_impl._APIKey = _APIKey
chat_impl._GetConfig = _GetConfig

comp_impl._OverrideConfig = _OverrideConfig
comp_impl._APIKey = _APIKey
comp_impl._GetConfig = _GetConfig
comp_impl._SUPPORTED_PROVIDER_BASE_URLS = _SUPPORTED_PROVIDER_BASE_URLS
