From a809ae8f15cdf4e925165984a2a9f37a6505ec10 Mon Sep 17 00:00:00 2001 From: Yaroslav Yashin Date: Thu, 1 Jun 2023 01:23:43 +0200 Subject: [PATCH 01/14] Added dirty way to stream ChatGPT answer. --- openAI.sublime-settings | 6 +++ openai_worker.py | 85 +++++++++++++++++++++++++++-------------- outputpanel.py | 14 +++++++ 3 files changed, 76 insertions(+), 29 deletions(-) diff --git a/openAI.sublime-settings b/openAI.sublime-settings index 88e0c32..8ad5835 100644 --- a/openAI.sublime-settings +++ b/openAI.sublime-settings @@ -17,6 +17,12 @@ // ____Affects only chat completion mode___ "chat_model": "gpt-3.5-turbo", + // ChatGPT model knows how to role, lol + // It can act as a different kind of person. Recently in this plugin it was acting + // like as a code assistant. With this setting you're able to set it up more precisely. + // E.g. "act like a (rust|python|js|whatewer) development assistant", act like a english tutor and so on. + "assistant_role": "You are a senior code assitant", + // Controls randomness: Lowering results in less random completions. // As the temperature approaches zero, the model will become deterministic and repetitive. "temperature": 0.7, diff --git a/openai_worker.py b/openai_worker.py index bf3d0f8..1a1c293 100644 --- a/openai_worker.py +++ b/openai_worker.py @@ -1,3 +1,4 @@ +from re import fullmatch import sublime, sublime_plugin import http.client import threading @@ -5,6 +6,7 @@ from .outputpanel import get_number_of_lines, SharedOutputPanelListener import json import logging +import re class OpenAIWorker(threading.Thread): @@ -23,6 +25,16 @@ def __init__(self, region, text, view, mode, command): self.port = settings.get('proxy')['port'] super(OpenAIWorker, self).__init__() + def update_output_panel(self, text_chunk: str, shot_panel: bool = False): + from .outputpanel import SharedOutputPanelListener + window = sublime.active_window() + listner = SharedOutputPanelListener() + listner.show_panel(window=window) + listner.update_output_panel( + text=text_chunk, + window=window + ) + def prompt_completion(self, completion): completion = completion.replace("$", "\$") if self.mode == 'insertion': @@ -34,17 +46,6 @@ def prompt_completion(self, completion): self.view.run_command("insert_snippet", {"contents": completion}) return - if self.mode == 'chat_completion': - from .outputpanel import SharedOutputPanelListener - window = sublime.active_window() - ## FIXME: This setting applies only in one way none -> markdown - listner = SharedOutputPanelListener() - listner.refresh_output_panel( - window=window, - markdown=self.settings.get('markdown'), - ) - listner.show_panel(window=window) - if self.mode == 'completion': region = self.view.sel()[0] if region.a <= region.b: @@ -64,24 +65,45 @@ def prompt_completion(self, completion): return def exec_net_request(self, connect: http.client.HTTPSConnection): - # TODO: Add status bar "loading" status, to make it obvious, that we're waiting the server response. try: res = connect.getresponse() - data = res.read() - status = res.status - data_decoded = data.decode('utf-8') - connect.close() - response = json.loads(data_decoded) - if self.mode == 'chat_completion': - Cacher().append_to_cache([response['choices'][0]['message']]) - completion = "" - print(f"token number: {response['usage']['total_tokens']}") - else: - completion = json.loads(data_decoded)['choices'][0]['text'] + if res.status != 200: + raise Exception(f"Server Error: {res.status}") + + decoder = json.JSONDecoder() + + full_response_content = {"role": "", "content": ""} + + self.update_output_panel("\n\n## Answer\n\n") + + for chunk in res: + chunk_str = chunk.decode('utf-8') + + # Check for SSE data + if chunk_str.startswith("data:") and not re.search(r"\[DONE\]$", chunk_str): + print(chunk_str) + print(re.search(r"\[DONE\]$", chunk_str)) + chunk_str = chunk_str[len("data:"):].strip() + + try: + response = decoder.decode(chunk_str) + except ValueError as ex: + sublime.error_message(f"Server Error: {str(ex)}") + logging.exception("Exception: " + str(ex)) + + if 'delta' in response['choices'][0]: + delta = response['choices'][0]['delta'] + if 'role' in delta: + full_response_content['role'] = delta['role'] + elif 'content' in delta: + full_response_content['content'] += delta['content'] + self.update_output_panel(delta['content']) + + connect.close() + Cacher().append_to_cache([full_response_content]) + # self.prompt_completion(completion) - completion = completion.strip() # Remove leading and trailing spaces - self.prompt_completion(completion) except KeyError: # TODO: Add status bar user notification for this action. if self.mode == 'chat_completion' and response['error']['code'] == 'context_length_exceeded': @@ -89,11 +111,11 @@ def exec_net_request(self, connect: http.client.HTTPSConnection): self.chat_complete() else: sublime.error_message("Exception\n" + "The OpenAI response could not be decoded. There could be a problem on their side. Please look in the console for additional error info.") - logging.exception("Exception: " + str(data_decoded)) + logging.exception("Exception: " + str(response)) return except Exception as ex: - sublime.error_message(f"Server Error: {str(status)}\n{ex}") - logging.exception("Exception: " + str(data_decoded)) + sublime.error_message(f"Server Error: {str(ex)}") + logging.exception("Exception: " + str(ex)) return def create_connection(self) -> http.client.HTTPSConnection: @@ -108,17 +130,22 @@ def chat_complete(self): cacher = Cacher() conn = self.create_connection() + role = self.settings.get('assistant_role') + + self.update_output_panel("\n\n## Question\n\n") + self.update_output_panel(cacher.read_all()[-1]["content"]) payload = { # Todo add uniq name for each output panel (e.g. each window) "messages": [ - {"role": "system", "content": "You are a code assistant."}, + {"role": "system", "content": role}, *cacher.read_all() ], "model": self.settings.get('chat_model'), "temperature": self.settings.get("temperature"), "max_tokens": self.settings.get("max_tokens"), "top_p": self.settings.get("top_p"), + "stream": True } json_payload = json.dumps(payload) diff --git a/outputpanel.py b/outputpanel.py index 2b697b1..76f07f7 100644 --- a/outputpanel.py +++ b/outputpanel.py @@ -8,6 +8,20 @@ class SharedOutputPanelListener(sublime_plugin.EventListener): def get_output_panel(self, window: sublime.Window): return window.find_output_panel(self.OUTPUT_PANEL_NAME) if window.find_output_panel(self.OUTPUT_PANEL_NAME) != None else window.create_output_panel(self.OUTPUT_PANEL_NAME) + def update_output_panel(self, text: str, window: sublime.Window): + output_panel = self.get_output_panel(window=window) + output_panel.set_read_only(False) + output_panel.run_command('append', {'characters': text}) + output_panel.set_read_only(True) + num_lines = get_number_of_lines(output_panel) + print(f'num_lines: {num_lines}') + + point = output_panel.text_point(num_lines, 0) + + # FIXME: make me scrollable while printing in addition to following bottom edge if not scrolled. + output_panel.show_at_center(point) + + def refresh_output_panel(self, window, markdown: bool): output_panel = self.get_output_panel(window=window) output_panel.set_read_only(False) From 31e6d6d372a2cae6b52afc4eac43d4760304e8ed Mon Sep 17 00:00:00 2001 From: Yaroslav Yashin Date: Thu, 1 Jun 2023 01:25:02 +0200 Subject: [PATCH 02/14] Cacher number of stripping leading lines on a too long request error decreased to 2 (question and answer). --- openai_worker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openai_worker.py b/openai_worker.py index 1a1c293..87d1e21 100644 --- a/openai_worker.py +++ b/openai_worker.py @@ -107,7 +107,7 @@ def exec_net_request(self, connect: http.client.HTTPSConnection): except KeyError: # TODO: Add status bar user notification for this action. if self.mode == 'chat_completion' and response['error']['code'] == 'context_length_exceeded': - Cacher().drop_first(4) + Cacher().drop_first(2) self.chat_complete() else: sublime.error_message("Exception\n" + "The OpenAI response could not be decoded. There could be a problem on their side. Please look in the console for additional error info.") From a91585af468fc7efcdba73292f579dbd7f5f4122 Mon Sep 17 00:00:00 2001 From: Yaroslav Date: Fri, 2 Jun 2023 20:17:17 +0200 Subject: [PATCH 03/14] Updated template with suggested bindings with `chat_completion` command. --- Default.sublime-keymap | 13 +++++++++++-- openai_worker.py | 1 - 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Default.sublime-keymap b/Default.sublime-keymap index dfa086b..9608ea4 100644 --- a/Default.sublime-keymap +++ b/Default.sublime-keymap @@ -1,5 +1,14 @@ [ - // { + // { + // "keys": [ + // "super+shift+'" + // ], + // "command": "openai", + // "args": { + // "mode": "chat_completion" + // } + // }, + // { // "keys": [ // "super+shift+;" // ], @@ -34,5 +43,5 @@ // "args": { // "mode": "insertion" // } - // }, + // } ] \ No newline at end of file diff --git a/openai_worker.py b/openai_worker.py index 87d1e21..89738db 100644 --- a/openai_worker.py +++ b/openai_worker.py @@ -102,7 +102,6 @@ def exec_net_request(self, connect: http.client.HTTPSConnection): connect.close() Cacher().append_to_cache([full_response_content]) - # self.prompt_completion(completion) except KeyError: # TODO: Add status bar user notification for this action. From c7b8b8246bef73f80b909804ae48d70d11afd227 Mon Sep 17 00:00:00 2001 From: Yaroslav Yashin Date: Tue, 13 Jun 2023 17:08:52 +0200 Subject: [PATCH 04/14] Updated output panel name. --- Default.sublime-keymap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Default.sublime-keymap b/Default.sublime-keymap index 9608ea4..4e12081 100644 --- a/Default.sublime-keymap +++ b/Default.sublime-keymap @@ -14,7 +14,7 @@ // ], // "command": "show_panel", // "args": { - // "panel": "output.OpenAI" + // "panel": "output.OpenAI Chat" // } // }, // { From 93df11fa09998e1dc4d51d6824f53eb80d353eae Mon Sep 17 00:00:00 2001 From: Yaroslav Yashin Date: Wed, 5 Jul 2023 18:18:29 +0200 Subject: [PATCH 05/14] Fixed redundant static data downloading on installation. --- .gitattributes | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.gitattributes b/.gitattributes index 830ef71..379dafb 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1 @@ -image1.png export-ignore -image2.png export-ignore -image3.png export-ignore -image4.png export-ignore \ No newline at end of file +/static/**/image*.png export-ignore \ No newline at end of file From c9af651acdef918aa3a65a91b031aa8ea69e6bcb Mon Sep 17 00:00:00 2001 From: Yaroslav Yashin Date: Wed, 5 Jul 2023 19:57:48 +0200 Subject: [PATCH 06/14] Documentation updated. --- messages/2.0.0.txt | 2 +- openAI.sublime-settings | 30 ++++++++++++++++++++---------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/messages/2.0.0.txt b/messages/2.0.0.txt index 8842c5c..eca291a 100644 --- a/messages/2.0.0.txt +++ b/messages/2.0.0.txt @@ -15,7 +15,7 @@ ChatGPT mode works the following way: 4. If you would like to fetch chat history to another window manually, you can do that by running the `OpenAI: Refresh Chat` command. 5. When you're done or want to start all over you should run the `OpenAI: Reset Chat History` command, which deletes the chat cache. -> You can bind both of the most usable commands `OpenAI: New Message` and `OpenAI: Show output panel`, to do that please follow `Settings`->`Package Control`->`OpenAI completion`->`Key Bindings`. +> You can bind both of the most usable commands `OpenAI: New Message` and `OpenAI: Show output panel`, to do that please follow `Settings` -> `Package Control` -> `OpenAI completion` -> `Key Bindings`. > As for now there's just a single history instance. I guess this limitation would disappear sometime, but highly likely it wouldn't be soon. diff --git a/openAI.sublime-settings b/openAI.sublime-settings index 8ad5835..a71c9ae 100644 --- a/openAI.sublime-settings +++ b/openAI.sublime-settings @@ -20,28 +20,38 @@ // ChatGPT model knows how to role, lol // It can act as a different kind of person. Recently in this plugin it was acting // like as a code assistant. With this setting you're able to set it up more precisely. - // E.g. "act like a (rust|python|js|whatewer) development assistant", act like a english tutor and so on. + // E.g. "You are (rust|python|js|whatewer) developer assistant", "You are an english tutor" and so on. "assistant_role": "You are a senior code assitant", - // Controls randomness: Lowering results in less random completions. - // As the temperature approaches zero, the model will become deterministic and repetitive. + // What sampling temperature to use, between 0 and 2. + // Higher values like 0.8 will make the output more random, + // while lower values like 0.2 will make it more focused and deterministic. + // + // OpenAI generally recommend altering this or top_p but not both. "temperature": 0.7, - // The maximum number of tokens to generate. - // Requests can use up to 2,048 or 4,000 tokens shared between prompt and completion. - // The exact limit varies by model. + // The maximum number of tokens to generate in the completion. + // The token count of your prompt plus `max_tokens` cannot exceed the model's context length. // (One token is roughly 4 characters for normal English text) // Does not affect editing mode. "max_tokens": 256, - // Controls diversity via nucleus sampling: - // 0.5 means half of all likelihood-weighted options are considered. + // An alternative to sampling with temperature, called nucleus sampling, + // where the model considers the results of the tokens with `top_p` probability mass. + // So 0.1 means only the tokens comprising the top 10% probability mass are considered. + // OpenAI generally recommend altering this or temperature but not both. "top_p": 1, - // Controls the minimum height of the debugger output panels in lines. + // Number between -2.0 and 2.0. + // Positive values penalize new tokens based on their existing frequency in the text so far, + // decreasing the model's likelihood to repeat the same line verbatim. + // docs: https://platform.openai.com/docs/api-reference/parameter-details "frequency_penalty": 0, - // Some new features are locked behind this flag. + // Number between -2.0 and 2.0. + /// Positive values penalize new tokens based on whether they appear in the text so far, + // increasing the model's likelihood to talk about new topics. + // docs: https://platform.openai.com/docs/api-reference/parameter-details "presence_penalty": 0, // Placeholder for insert mode. You should to put it where you want the suggestion to be inserted. From 73476421cf2359e4f74c34b4d66fa4971bae7182 Mon Sep 17 00:00:00 2001 From: Yaroslav Yashin Date: Wed, 5 Jul 2023 23:23:09 +0200 Subject: [PATCH 07/14] Huge plugin logic refactoring. Now it's at least somewhat follows OOP principles and code appears to be much more readable and maintainable. --- buffer.py | 36 ++++++ openai_network_client.py | 77 +++++++++++++ openai_worker.py | 231 +++++++++++---------------------------- 3 files changed, 179 insertions(+), 165 deletions(-) create mode 100644 buffer.py create mode 100644 openai_network_client.py diff --git a/buffer.py b/buffer.py new file mode 100644 index 0000000..e1c2810 --- /dev/null +++ b/buffer.py @@ -0,0 +1,36 @@ +import sublime +from typing import Optional + +class SublimeBuffer(): + + def __init__(self, view) -> None: + self.view = view + + def prompt_completion(self, mode: str, completion: str, placeholder: Optional[str] = None): + completion = completion.replace("$", "\$") + if mode == 'insertion': + result = self.view.find(placeholder, 0, 1) + if result: + self.view.sel().clear() + self.view.sel().add(result) + # Replace the placeholder with the specified replacement text + self.view.run_command("insert_snippet", {"contents": completion}) + return + + elif mode == 'completion': + region = self.view.sel()[0] + if region.a <= region.b: + region.a = region.b + else: + region.b = region.a + + self.view.sel().clear() + self.view.sel().add(region) + # Replace the placeholder with the specified replacement text + self.view.run_command("insert_snippet", {"contents": completion}) + return + + elif mode == 'edition': # it's just replacing all given text for now. + region = self.view.sel()[0] + self.view.run_command("insert_snippet", {"contents": completion}) + return \ No newline at end of file diff --git a/openai_network_client.py b/openai_network_client.py new file mode 100644 index 0000000..1133682 --- /dev/null +++ b/openai_network_client.py @@ -0,0 +1,77 @@ +import http.client +from typing import Optional, List +import sublime +import json +from .cacher import Cacher + +class NetworkClient(): + def __init__(self, settings: sublime.Settings) -> None: + self.settings = settings + self.headers = { + 'Content-Type': "application/json", + 'Authorization': f'Bearer {self.settings.get("token")}', + 'cache-control': "no-cache", + } + + if len(self.settings.get('proxy')['address']) > 0: + self.connection = http.client.HTTPSConnection( + host=self.settings.get('proxy')['address'], + port=self.settings.get('proxy')['port'] + ) + self.connection.set_tunnel("api.openai.com") + else: + self.connection = http.client.HTTPSConnection("api.openai.com") + + def prepare_payload(self, mode: str, text: Optional[str] = None, command: Optional[str] = None, cacher: Optional[Cacher] = None, role: Optional[str] = None, parts: Optional[List[str]] = None) -> str: + if mode == 'insertion': + return json.dumps({ + "model": self.settings.get("model"), + "prompt": parts[0], + "suffix": parts[1], + "temperature": self.settings.get("temperature"), + "max_tokens": self.settings.get("max_tokens"), + "top_p": self.settings.get("top_p"), + "frequency_penalty": self.settings.get("frequency_penalty"), + "presence_penalty": self.settings.get("presence_penalty") + }) + + elif mode == 'edition': + return json.dumps({ + "model": self.settings.get('edit_model'), + "input": text, + "instruction": command, + "temperature": self.settings.get("temperature"), + "top_p": self.settings.get("top_p"), + }) + + elif mode == 'completion': + return json.dumps({ + "prompt": text, + "model": self.settings.get("model"), + "temperature": self.settings.get("temperature"), + "max_tokens": self.settings.get("max_tokens"), + "top_p": self.settings.get("top_p"), + "frequency_penalty": self.settings.get("frequency_penalty"), + "presence_penalty": self.settings.get("presence_penalty") + }) + + elif mode == 'chat_completion': + return json.dumps({ + # Todo add uniq name for each output panel (e.g. each window) + "messages": [ + {"role": "system", "content": role}, + *cacher.read_all() + ], + "model": self.settings.get('chat_model'), + "temperature": self.settings.get("temperature"), + "max_tokens": self.settings.get("max_tokens"), + "top_p": self.settings.get("top_p"), + "stream": True + }) + else: raise Exception("Undefined mode") + + def prepare_request(self, gateway, json_payload): + self.connection.request(method="POST", url=gateway, body=json_payload, headers=self.headers) + + def execute_response(self) -> http.client.HTTPResponse: + return self.connection.getresponse() diff --git a/openai_worker.py b/openai_worker.py index 89738db..71c7f6c 100644 --- a/openai_worker.py +++ b/openai_worker.py @@ -1,17 +1,15 @@ -from re import fullmatch -import sublime, sublime_plugin -import http.client +import sublime import threading from .cacher import Cacher -from .outputpanel import get_number_of_lines, SharedOutputPanelListener +from typing import Optional, List +from .openai_network_client import NetworkClient +from .buffer import SublimeBuffer import json import logging import re class OpenAIWorker(threading.Thread): - message = {} - def __init__(self, region, text, view, mode, command): self.region = region self.text = text @@ -19,10 +17,10 @@ def __init__(self, region, text, view, mode, command): self.mode = mode self.command = command # optional self.message = {"role": "user", "content": self.command, 'name': 'OpenAI_completion'} - settings = sublime.load_settings("openAI.sublime-settings") - self.settings = settings - self.proxy = settings.get('proxy')['address'] - self.port = settings.get('proxy')['port'] + self.settings = sublime.load_settings("openAI.sublime-settings") + self.provider = NetworkClient(settings=self.settings) + + self.buffer_manager = SublimeBuffer(self.view) super(OpenAIWorker, self).__init__() def update_output_panel(self, text_chunk: str, shot_panel: bool = False): @@ -36,40 +34,15 @@ def update_output_panel(self, text_chunk: str, shot_panel: bool = False): ) def prompt_completion(self, completion): - completion = completion.replace("$", "\$") - if self.mode == 'insertion': - result = self.view.find(self.settings.get('placeholder'), 0, 1) - if result: - self.view.sel().clear() - self.view.sel().add(result) - # Replace the placeholder with the specified replacement text - self.view.run_command("insert_snippet", {"contents": completion}) - return + placeholder: str = self.settings.get('placeholder') + self.buffer_manager.prompt_completion(mode=self.mode, completion=completion, placeholder=placeholder) - if self.mode == 'completion': - region = self.view.sel()[0] - if region.a <= region.b: - region.a = region.b - else: - region.b = region.a - - self.view.sel().clear() - self.view.sel().add(region) - # Replace the placeholder with the specified replacement text - self.view.run_command("insert_snippet", {"contents": completion}) - return - - if self.mode == 'edition': # it's just replacing all given text for now. - region = self.view.sel()[0] - self.view.run_command("insert_snippet", {"contents": completion}) - return - - def exec_net_request(self, connect: http.client.HTTPSConnection): + def handle_chat_completion_response(self): try: - res = connect.getresponse() + response = self.provider.execute_response() - if res.status != 200: - raise Exception(f"Server Error: {res.status}") + if response.status != 200: + raise Exception(f"Server Error: {response.status}") decoder = json.JSONDecoder() @@ -77,7 +50,7 @@ def exec_net_request(self, connect: http.client.HTTPSConnection): self.update_output_panel("\n\n## Answer\n\n") - for chunk in res: + for chunk in response: chunk_str = chunk.decode('utf-8') # Check for SSE data @@ -100,14 +73,13 @@ def exec_net_request(self, connect: http.client.HTTPSConnection): full_response_content['content'] += delta['content'] self.update_output_panel(delta['content']) - connect.close() + self.provider.connection.close() Cacher().append_to_cache([full_response_content]) except KeyError: # TODO: Add status bar user notification for this action. if self.mode == 'chat_completion' and response['error']['code'] == 'context_length_exceeded': Cacher().drop_first(2) - self.chat_complete() else: sublime.error_message("Exception\n" + "The OpenAI response could not be decoded. There could be a problem on their side. Please look in the console for additional error info.") logging.exception("Exception: " + str(response)) @@ -117,124 +89,27 @@ def exec_net_request(self, connect: http.client.HTTPSConnection): logging.exception("Exception: " + str(ex)) return - def create_connection(self) -> http.client.HTTPSConnection: - if len(self.proxy) > 0: - connection = http.client.HTTPSConnection(host=self.proxy, port=self.port) - connection.set_tunnel("api.openai.com") - return connection - else: - return http.client.HTTPSConnection("api.openai.com") - - def chat_complete(self): - cacher = Cacher() - - conn = self.create_connection() - role = self.settings.get('assistant_role') - - self.update_output_panel("\n\n## Question\n\n") - self.update_output_panel(cacher.read_all()[-1]["content"]) - - payload = { - # Todo add uniq name for each output panel (e.g. each window) - "messages": [ - {"role": "system", "content": role}, - *cacher.read_all() - ], - "model": self.settings.get('chat_model'), - "temperature": self.settings.get("temperature"), - "max_tokens": self.settings.get("max_tokens"), - "top_p": self.settings.get("top_p"), - "stream": True - } - - json_payload = json.dumps(payload) - token = self.settings.get('token') - - headers = { - 'Content-Type': "application/json", - 'Authorization': f'Bearer {token}', - 'cache-control': "no-cache", - } - conn.request("POST", "/v1/chat/completions", json_payload, headers) - self.exec_net_request(connect=conn) - - def complete(self): - conn = self.create_connection() - - payload = { - "prompt": self.text, - "model": self.settings.get("model"), - "temperature": self.settings.get("temperature"), - "max_tokens": self.settings.get("max_tokens"), - "top_p": self.settings.get("top_p"), - "frequency_penalty": self.settings.get("frequency_penalty"), - "presence_penalty": self.settings.get("presence_penalty") - } - json_payload = json.dumps(payload) - - token = self.settings.get('token') - - headers = { - 'Content-Type': "application/json", - 'Authorization': 'Bearer {}'.format(token), - 'cache-control': "no-cache", - } - conn.request("POST", "/v1/completions", json_payload, headers) - self.exec_net_request(connect=conn) - - def insert(self): - conn = self.create_connection() - parts = self.text.split(self.settings.get('placeholder')) + def handle_ordinary_response(self): try: - if not len(parts) == 2: - raise AssertionError("There is no placeholder '" + self.settings.get('placeholder') + "' within the selected text. There should be exactly one.") + response = self.provider.execute_response() + data = response.read() + data_decoded = data.decode('utf-8') + self.provider.connection.close() + completion = json.loads(data_decoded)['choices'][0]['text'] + completion = completion.strip() # Remove leading and trailing spaces + self.prompt_completion(completion) + except KeyError: + sublime.error_message("Exception\n" + "The OpenAI response could not be decoded. There could be a problem on their side. Please look in the console for additional error info.") + logging.exception("Exception: " + str(data_decoded)) + return except Exception as ex: - sublime.error_message("Exception\n" + str(ex)) - logging.exception("Exception: " + str(ex)) + sublime.error_message(f"Server Error: {str(response.status)}\n{ex}") + logging.exception("Exception: " + str(data_decoded)) return - payload = { - "model": self.settings.get("model"), - "prompt": parts[0], - "suffix": parts[1], - "temperature": self.settings.get("temperature"), - "max_tokens": self.settings.get("max_tokens"), - "top_p": self.settings.get("top_p"), - "frequency_penalty": self.settings.get("frequency_penalty"), - "presence_penalty": self.settings.get("presence_penalty") - } - json_payload = json.dumps(payload) - - token = self.settings.get('token') - - headers = { - 'Content-Type': "application/json", - 'Authorization': 'Bearer {}'.format(token), - 'cache-control': "no-cache", - } - conn.request("POST", "/v1/completions", json_payload, headers) - self.exec_net_request(connect=conn) - - def edit_f(self): - conn = self.create_connection() - payload = { - "model": self.settings.get('edit_model'), - "input": self.text, - "instruction": self.command, - "temperature": self.settings.get("temperature"), - "top_p": self.settings.get("top_p"), - } - json_payload = json.dumps(payload) - - token = self.settings.get('token') - - headers = { - 'Content-Type': "application/json", - 'Authorization': 'Bearer {}'.format(token), - 'cache-control': "no-cache", - } - conn.request("POST", "/v1/edits", json_payload, headers) - self.exec_net_request(connect=conn) + def handle_response(self): + if self.mode == "chat_completion": self.handle_chat_completion_response() + else: self.handle_ordinary_response() def run(self): try: @@ -243,17 +118,43 @@ def run(self): # raise AssertionError("OpenAI accepts max. 4000 tokens, so the selected text and the max_tokens setting must be lower than 4000.") if not self.settings.has("token"): raise AssertionError("No token provided, you have to set the OpenAI token into the settings to make things work.") - token = self.settings.get('token') - if len(token) < 10: + if len(self.settings.get('token')) < 10: raise AssertionError("No token provided, you have to set the OpenAI token into the settings to make things work.") except Exception as ex: sublime.error_message("Exception\n" + str(ex)) logging.exception("Exception: " + str(ex)) return - if self.mode == 'insertion': self.insert() - if self.mode == 'edition': self.edit_f() - if self.mode == 'completion': self.complete() - if self.mode == 'chat_completion': + if self.mode == 'insertion': + parts: List[str] = self.text.split(self.settings.get('placeholder')) + try: + if not len(parts) == 2: + raise AssertionError("There is no placeholder '" + self.settings.get('placeholder') + "' within the selected text. There should be exactly one.") + except Exception as ex: + sublime.error_message("Exception\n" + str(ex)) + logging.exception("Exception: " + str(ex)) + return + payload = self.provider.prepare_payload(mode=self.mode, parts=parts) + self.provider.prepare_request(gateway="/v1/completions", json_payload=payload) + self.handle_response() + + elif self.mode == 'edition': + payload = self.provider.prepare_payload(mode=self.mode, text=self.text, command=self.command) + self.provider.prepare_request(gateway="/v1/edits", json_payload=payload) + self.handle_response() + elif self.mode == 'completion': + payload = self.provider.prepare_payload(mode=self.mode, text=self.text) + self.provider.prepare_request(gateway="/v1/completions", json_payload=payload) + self.handle_response() + + elif self.mode == 'chat_completion': Cacher().append_to_cache([self.message]) - self.chat_complete() + cacher = Cacher() + role: str = self.settings.get('assistant_role') + self.update_output_panel("\n\n## Question\n\n") + self.update_output_panel(cacher.read_all()[-1]["content"]) + + payload = self.provider.prepare_payload(mode=self.mode, cacher=cacher, role=role) + + self.provider.prepare_request(gateway="/v1/chat/completions", json_payload=payload) + self.handle_response() From addaa3d2812f6d2906c9365cb9742f7a3a345066 Mon Sep 17 00:00:00 2001 From: Yaroslav Yashin Date: Thu, 6 Jul 2023 00:44:32 +0200 Subject: [PATCH 08/14] - Improved codestyle and fixed majority of a linting errors in `outputpanel.py` - Improved codestyle and fixed majority of a linting errors in `openai_worker.py` - Improved codestyle and fixed majority of a linting errors in `openai_network_client.py` - Improved codestyle and fixed majority of a linting errors in `outputpanel.py` - Updated `openai.py` to follow updated API calls. --- buffer.py | 1 - openai.py | 9 +++------ openai_network_client.py | 35 ++++++++++++++++++++--------------- openai_worker.py | 37 +++++++++++++++++++++++++++---------- outputpanel.py | 31 ++++++++++++++++++------------- 5 files changed, 68 insertions(+), 45 deletions(-) diff --git a/buffer.py b/buffer.py index e1c2810..5f70168 100644 --- a/buffer.py +++ b/buffer.py @@ -1,4 +1,3 @@ -import sublime from typing import Optional class SublimeBuffer(): diff --git a/openai.py b/openai.py index c1d4cba..2d3302e 100644 --- a/openai.py +++ b/openai.py @@ -28,7 +28,7 @@ def run(self, edit, **kwargs): text = self.view.substr(region) - # Cheching that user select some text + # Checking that user select some text try: if region.__len__() < settings.get("minimum_selection_length"): if mode != 'chat_completion' and mode != 'reset_chat_history' and mode != 'refresh_output_panel': @@ -57,11 +57,8 @@ def run(self, edit, **kwargs): elif mode == 'refresh_output_panel': from .outputpanel import SharedOutputPanelListener window = sublime.active_window() - listner = SharedOutputPanelListener() - listner.refresh_output_panel( - window=window, - markdown=settings.get('markdown'), - ) + listner = SharedOutputPanelListener(markdown=settings.get('markdown')) + listner.refresh_output_panel(window=window) listner.show_panel(window=window) else: # mode 'chat_completion', always in panel sublime.active_window().show_input_panel("Question: ", "", functools.partial(self.on_input, "region", "text", self.view, mode), None, None) diff --git a/openai_network_client.py b/openai_network_client.py index 1133682..e1ac7b9 100644 --- a/openai_network_client.py +++ b/openai_network_client.py @@ -1,11 +1,11 @@ -import http.client +from http.client import HTTPSConnection, HTTPResponse from typing import Optional, List -import sublime +from sublime import Settings import json from .cacher import Cacher class NetworkClient(): - def __init__(self, settings: sublime.Settings) -> None: + def __init__(self, settings: Settings) -> None: self.settings = settings self.headers = { 'Content-Type': "application/json", @@ -13,21 +13,26 @@ def __init__(self, settings: sublime.Settings) -> None: 'cache-control': "no-cache", } - if len(self.settings.get('proxy')['address']) > 0: - self.connection = http.client.HTTPSConnection( - host=self.settings.get('proxy')['address'], - port=self.settings.get('proxy')['port'] - ) - self.connection.set_tunnel("api.openai.com") - else: - self.connection = http.client.HTTPSConnection("api.openai.com") + proxy_settings = self.settings.get('proxy') + if isinstance(proxy_settings, dict): + address = proxy_settings.get('address') + port = proxy_settings.get('port') + if address and len(address) > 0 and port: + self.connection = HTTPSConnection( + host=address, + port=port + ) + self.connection.set_tunnel("api.openai.com") + else: + self.connection = HTTPSConnection("api.openai.com") def prepare_payload(self, mode: str, text: Optional[str] = None, command: Optional[str] = None, cacher: Optional[Cacher] = None, role: Optional[str] = None, parts: Optional[List[str]] = None) -> str: if mode == 'insertion': + prompt, suffix = (parts[0], parts[1]) if parts and len(parts) >= 2 else ("Print out that input text is wrong", "Print out that input text is wrong") return json.dumps({ "model": self.settings.get("model"), - "prompt": parts[0], - "suffix": parts[1], + "prompt": prompt, + "suffix": suffix, "temperature": self.settings.get("temperature"), "max_tokens": self.settings.get("max_tokens"), "top_p": self.settings.get("top_p"), @@ -60,7 +65,7 @@ def prepare_payload(self, mode: str, text: Optional[str] = None, command: Option # Todo add uniq name for each output panel (e.g. each window) "messages": [ {"role": "system", "content": role}, - *cacher.read_all() + *(cacher.read_all() if cacher is not None else []) ], "model": self.settings.get('chat_model'), "temperature": self.settings.get("temperature"), @@ -73,5 +78,5 @@ def prepare_payload(self, mode: str, text: Optional[str] = None, command: Option def prepare_request(self, gateway, json_payload): self.connection.request(method="POST", url=gateway, body=json_payload, headers=self.headers) - def execute_response(self) -> http.client.HTTPResponse: + def execute_response(self) -> HTTPResponse: return self.connection.getresponse() diff --git a/openai_worker.py b/openai_worker.py index 71c7f6c..8af2d2c 100644 --- a/openai_worker.py +++ b/openai_worker.py @@ -1,7 +1,7 @@ import sublime import threading from .cacher import Cacher -from typing import Optional, List +from typing import List from .openai_network_client import NetworkClient from .buffer import SublimeBuffer import json @@ -23,10 +23,14 @@ def __init__(self, region, text, view, mode, command): self.buffer_manager = SublimeBuffer(self.view) super(OpenAIWorker, self).__init__() - def update_output_panel(self, text_chunk: str, shot_panel: bool = False): + def update_output_panel(self, text_chunk: str): from .outputpanel import SharedOutputPanelListener window = sublime.active_window() - listner = SharedOutputPanelListener() + markdown_setting = self.settings.get('markdown') + if not isinstance(markdown_setting, bool): + markdown_setting = True + + listner = SharedOutputPanelListener(markdown=markdown_setting) listner.show_panel(window=window) listner.update_output_panel( text=text_chunk, @@ -34,8 +38,14 @@ def update_output_panel(self, text_chunk: str, shot_panel: bool = False): ) def prompt_completion(self, completion): - placeholder: str = self.settings.get('placeholder') - self.buffer_manager.prompt_completion(mode=self.mode, completion=completion, placeholder=placeholder) + placeholder = self.settings.get('placeholder') + if not isinstance(placeholder, str): + placeholder = "[insert]" + self.buffer_manager.prompt_completion( + mode=self.mode, + completion=completion, + placeholder=placeholder + ) def handle_chat_completion_response(self): try: @@ -116,9 +126,10 @@ def run(self): # FIXME: It's better to have such check locally, but it's pretty complicated with all those different modes and models # if (self.settings.get("max_tokens") + len(self.text)) > 4000: # raise AssertionError("OpenAI accepts max. 4000 tokens, so the selected text and the max_tokens setting must be lower than 4000.") - if not self.settings.has("token"): - raise AssertionError("No token provided, you have to set the OpenAI token into the settings to make things work.") - if len(self.settings.get('token')) < 10: + token = self.settings.get('token') + if not isinstance(token, str): + raise AssertionError("The token must be a string.") + if len(token) < 10: raise AssertionError("No token provided, you have to set the OpenAI token into the settings to make things work.") except Exception as ex: sublime.error_message("Exception\n" + str(ex)) @@ -126,10 +137,13 @@ def run(self): return if self.mode == 'insertion': + placeholder = self.settings.get('placeholder') + if not isinstance(placeholder, str): + raise AssertionError("The placeholder must be a string.") parts: List[str] = self.text.split(self.settings.get('placeholder')) try: if not len(parts) == 2: - raise AssertionError("There is no placeholder '" + self.settings.get('placeholder') + "' within the selected text. There should be exactly one.") + raise AssertionError("There is no placeholder '" + placeholder + "' within the selected text. There should be exactly one.") except Exception as ex: sublime.error_message("Exception\n" + str(ex)) logging.exception("Exception: " + str(ex)) @@ -150,7 +164,10 @@ def run(self): elif self.mode == 'chat_completion': Cacher().append_to_cache([self.message]) cacher = Cacher() - role: str = self.settings.get('assistant_role') + assistant_role = self.settings.get('assistant_role') + if not isinstance(assistant_role, str): + raise ValueError("The assistant_role setting must be a string.") + role: str = assistant_role self.update_output_panel("\n\n## Question\n\n") self.update_output_panel(cacher.read_all()[-1]["content"]) diff --git a/outputpanel.py b/outputpanel.py index 76f07f7..4658898 100644 --- a/outputpanel.py +++ b/outputpanel.py @@ -1,15 +1,22 @@ -import sublime -import sublime_plugin +from sublime import Window, View +from sublime_plugin import EventListener +from typing import Optional from .cacher import Cacher -class SharedOutputPanelListener(sublime_plugin.EventListener): +class SharedOutputPanelListener(EventListener): OUTPUT_PANEL_NAME = "OpenAI Chat" - def get_output_panel(self, window: sublime.Window): - return window.find_output_panel(self.OUTPUT_PANEL_NAME) if window.find_output_panel(self.OUTPUT_PANEL_NAME) != None else window.create_output_panel(self.OUTPUT_PANEL_NAME) + def __init__(self, markdown: bool = True) -> None: + self.markdown: bool = markdown + super().__init__() - def update_output_panel(self, text: str, window: sublime.Window): - output_panel = self.get_output_panel(window=window) + def __get_output_panel__(self, window: Window) -> Optional[View]: + output_panel = window.find_output_panel(self.OUTPUT_PANEL_NAME) or window.create_output_panel(self.OUTPUT_PANEL_NAME) + if self.markdown: output_panel.set_syntax_file("Packages/Markdown/MultiMarkdown.sublime-syntax") + return output_panel + + def update_output_panel(self, text: str, window: Window): + output_panel = self.__get_output_panel__(window=window) output_panel.set_read_only(False) output_panel.run_command('append', {'characters': text}) output_panel.set_read_only(True) @@ -22,18 +29,16 @@ def update_output_panel(self, text: str, window: sublime.Window): output_panel.show_at_center(point) - def refresh_output_panel(self, window, markdown: bool): - output_panel = self.get_output_panel(window=window) + def refresh_output_panel(self, window): + output_panel = self.__get_output_panel__(window=window) output_panel.set_read_only(False) self.clear_output_panel(window) - if markdown: output_panel.set_syntax_file("Packages/Markdown/MultiMarkdown.sublime-syntax") - for line in Cacher().read_all(): if line['role'] == 'user': output_panel.run_command('append', {'characters': f'\n\n## Question\n\n'}) elif line['role'] == 'assistant': - ## This one left here as there're could be loooong questions. + ## This one placed here as there're could be loooong questions. output_panel.run_command('append', {'characters': '\n\n## Answer\n\n'}) output_panel.run_command('append', {'characters': line['content']}) @@ -49,7 +54,7 @@ def refresh_output_panel(self, window, markdown: bool): output_panel.show_at_center(point) def clear_output_panel(self, window): - output_panel = self.get_output_panel(window=window) + output_panel = self.__get_output_panel__(window=window) output_panel.run_command("select_all") output_panel.run_command("right_delete") From 2c6e77d6aaf069a8119c73e5e7f8126dc3af2ced Mon Sep 17 00:00:00 2001 From: Yaroslav Yashin Date: Thu, 6 Jul 2023 00:59:40 +0200 Subject: [PATCH 09/14] Done with refactoring. All modes seems workable. --- openai_worker.py | 1 + outputpanel.py | 29 +++++++++++++++++------------ 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/openai_worker.py b/openai_worker.py index 8af2d2c..57d5fdc 100644 --- a/openai_worker.py +++ b/openai_worker.py @@ -86,6 +86,7 @@ def handle_chat_completion_response(self): self.provider.connection.close() Cacher().append_to_cache([full_response_content]) + ## FIXME: Make this handler handle tokens overflow error. except KeyError: # TODO: Add status bar user notification for this action. if self.mode == 'chat_completion' and response['error']['code'] == 'context_length_exceeded': diff --git a/outputpanel.py b/outputpanel.py index 4658898..3b071ef 100644 --- a/outputpanel.py +++ b/outputpanel.py @@ -1,6 +1,5 @@ from sublime import Window, View from sublime_plugin import EventListener -from typing import Optional from .cacher import Cacher class SharedOutputPanelListener(EventListener): @@ -10,24 +9,29 @@ def __init__(self, markdown: bool = True) -> None: self.markdown: bool = markdown super().__init__() - def __get_output_panel__(self, window: Window) -> Optional[View]: + def __get_output_panel__(self, window: Window) -> View: output_panel = window.find_output_panel(self.OUTPUT_PANEL_NAME) or window.create_output_panel(self.OUTPUT_PANEL_NAME) if self.markdown: output_panel.set_syntax_file("Packages/Markdown/MultiMarkdown.sublime-syntax") return output_panel + def __scroll_to_text_point__(self, output_panel: View, num_lines: int): + point = output_panel.text_point(num_lines, 0) + # FIXME: make me scrollable while printing in addition to following bottom edge if not scrolled. + output_panel.show_at_center(point) + + ## FIXME: This one should allow scroll while updating, yet it should follow the text if it's not def update_output_panel(self, text: str, window: Window): output_panel = self.__get_output_panel__(window=window) output_panel.set_read_only(False) output_panel.run_command('append', {'characters': text}) output_panel.set_read_only(True) - num_lines = get_number_of_lines(output_panel) + num_lines: int = get_number_of_lines(output_panel) print(f'num_lines: {num_lines}') - point = output_panel.text_point(num_lines, 0) - - # FIXME: make me scrollable while printing in addition to following bottom edge if not scrolled. - output_panel.show_at_center(point) - + self.__scroll_to_text_point__( + output_panel=output_panel, + num_lines=num_lines + ) def refresh_output_panel(self, window): output_panel = self.__get_output_panel__(window=window) @@ -49,9 +53,10 @@ def refresh_output_panel(self, window): ## Hardcoded to -10 lines from the end, just completely randrom number. ## TODO: Here's some complex scrolling logic based on the content (## Answer) required. - point = output_panel.text_point(num_lines - 10, 0) - - output_panel.show_at_center(point) + self.__scroll_to_text_point__( + output_panel=output_panel, + num_lines=num_lines - 10 + ) def clear_output_panel(self, window): output_panel = self.__get_output_panel__(window=window) @@ -61,6 +66,6 @@ def clear_output_panel(self, window): def show_panel(self, window): window.run_command("show_panel", {"panel": f"output.{self.OUTPUT_PANEL_NAME}"}) -def get_number_of_lines(view): +def get_number_of_lines(view: View) -> int: last_line_num = view.rowcol(view.size())[0] + 1 return last_line_num \ No newline at end of file From 32c180d05e6f755afe573726287c67a48d39d7bb Mon Sep 17 00:00:00 2001 From: Yaroslav Yashin Date: Thu, 6 Jul 2023 17:19:08 +0200 Subject: [PATCH 10/14] Updated `.gitattributes` to ignore FUNDING.yml at export. --- .gitattributes | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitattributes b/.gitattributes index 379dafb..e7c5a3b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ -/static/**/image*.png export-ignore \ No newline at end of file +/static/**/image*.png export-ignore +/.github/FUNDING.yml export-ignore \ No newline at end of file From e3dbdf1339e54709d524c54985086ded8a33c56e Mon Sep 17 00:00:00 2001 From: Yaroslav Date: Thu, 6 Jul 2023 17:23:58 +0200 Subject: [PATCH 11/14] Create FUNDING.yml --- .github/FUNDING.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..976dafa --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,13 @@ +# These are supported funding model platforms + +github: [yaroslavyaroslav] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +# patreon: # Replace with a single Patreon username +# open_collective: # Replace with a single Open Collective username +# ko_fi: # Replace with a single Ko-fi username +# tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +# community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +# liberapay: # Replace with a single Liberapay username +# issuehunt: # Replace with a single IssueHunt username +# otechie: # Replace with a single Otechie username +# lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +# custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] From 0922812d73381cc3d3d3cbe02e1cd7b990b548d1 Mon Sep 17 00:00:00 2001 From: Yaroslav Yashin Date: Fri, 7 Jul 2023 19:16:23 +0200 Subject: [PATCH 12/14] - `OpenAIExceptions` type added in sake for convenient error handling - Added logic to handle context window overflow, now user can decide whether it's fin for they to delete farthest pair of dialogue or not - Added general context window overflow server error handling. --- errors/OpenAIException.py | 22 +++++++ openai_network_client.py | 34 +++++++++-- openai_worker.py | 123 ++++++++++++++++++++------------------ 3 files changed, 115 insertions(+), 64 deletions(-) create mode 100644 errors/OpenAIException.py diff --git a/errors/OpenAIException.py b/errors/OpenAIException.py new file mode 100644 index 0000000..d237f15 --- /dev/null +++ b/errors/OpenAIException.py @@ -0,0 +1,22 @@ +from sublime import error_message +from logging import exception + +class OpenAIException(Exception): + """Exception raised for errors in the input. + + Attributes: + message -- explanation of the error + """ + + def __init__(self, message: str): + self.message = message + super().__init__(self.message) + +class ContextLengthExceededException(OpenAIException): ... + +class UnknownException(OpenAIException): ... + + +def present_error(title: str, error: OpenAIException): + exception(f"{title}: {error.message}") + error_message(f"{title}\n{error.message}") \ No newline at end of file diff --git a/openai_network_client.py b/openai_network_client.py index e1ac7b9..bc2af35 100644 --- a/openai_network_client.py +++ b/openai_network_client.py @@ -1,11 +1,16 @@ from http.client import HTTPSConnection, HTTPResponse +from os import error +from urllib.error import HTTPError, URLError from typing import Optional, List -from sublime import Settings +import logging +import sublime import json +from .errors.OpenAIException import ContextLengthExceededException, UnknownException, present_error from .cacher import Cacher class NetworkClient(): - def __init__(self, settings: Settings) -> None: + mode = "" + def __init__(self, settings: sublime.Settings) -> None: self.settings = settings self.headers = { 'Content-Type': "application/json", @@ -26,7 +31,8 @@ def __init__(self, settings: Settings) -> None: else: self.connection = HTTPSConnection("api.openai.com") - def prepare_payload(self, mode: str, text: Optional[str] = None, command: Optional[str] = None, cacher: Optional[Cacher] = None, role: Optional[str] = None, parts: Optional[List[str]] = None) -> str: + def prepare_payload(self, mode: str, text: Optional[str] = None, command: Optional[str] = None, role: Optional[str] = None, parts: Optional[List[str]] = None) -> str: + self.mode = mode if mode == 'insertion': prompt, suffix = (parts[0], parts[1]) if parts and len(parts) >= 2 else ("Print out that input text is wrong", "Print out that input text is wrong") return json.dumps({ @@ -65,7 +71,7 @@ def prepare_payload(self, mode: str, text: Optional[str] = None, command: Option # Todo add uniq name for each output panel (e.g. each window) "messages": [ {"role": "system", "content": role}, - *(cacher.read_all() if cacher is not None else []) + *Cacher().read_all() ], "model": self.settings.get('chat_model'), "temperature": self.settings.get("temperature"), @@ -78,5 +84,21 @@ def prepare_payload(self, mode: str, text: Optional[str] = None, command: Option def prepare_request(self, gateway, json_payload): self.connection.request(method="POST", url=gateway, body=json_payload, headers=self.headers) - def execute_response(self) -> HTTPResponse: - return self.connection.getresponse() + def execute_response(self) -> Optional[HTTPResponse]: + return self._execute_network_request() + + def _execute_network_request(self) -> Optional[HTTPResponse]: + response = self.connection.getresponse() + # handle 400-499 client errors and 500-599 server errors + if 400 <= response.status < 600: + error_object = response.read().decode('utf-8') + error_data = json.loads(error_object) + if error_data.get('error', {}).get('code') == 'context_length_exceeded': + raise ContextLengthExceededException(error_data['error']['message']) + # raise custom exception for 'context_length_exceeded' error + # if error_data.get('error', {}).get('code') == 'context_length_exceeded': + # raise ContextLengthExceeded(error_data['error']['message']) + code = error_data.get('error', {}).get('code') or error_data.get('error', {}).get('type') + unknown_error = UnknownException(error_data.get('error', {}).get('message')) + present_error(title=code, error=unknown_error) + return response diff --git a/openai_worker.py b/openai_worker.py index 57d5fdc..7b441d1 100644 --- a/openai_worker.py +++ b/openai_worker.py @@ -4,6 +4,7 @@ from typing import List from .openai_network_client import NetworkClient from .buffer import SublimeBuffer +from .errors.OpenAIException import ContextLengthExceededException, UnknownException, present_error import json import logging import re @@ -48,80 +49,87 @@ def prompt_completion(self, completion): ) def handle_chat_completion_response(self): - try: - response = self.provider.execute_response() + response = self.provider.execute_response() - if response.status != 200: - raise Exception(f"Server Error: {response.status}") + if response is None or response.status != 200: + print("xxxx5") + return - decoder = json.JSONDecoder() + decoder = json.JSONDecoder() - full_response_content = {"role": "", "content": ""} + full_response_content = {"role": "", "content": ""} - self.update_output_panel("\n\n## Answer\n\n") + self.update_output_panel("\n\n## Answer\n\n") - for chunk in response: - chunk_str = chunk.decode('utf-8') + for chunk in response: + chunk_str = chunk.decode('utf-8') - # Check for SSE data - if chunk_str.startswith("data:") and not re.search(r"\[DONE\]$", chunk_str): - print(chunk_str) - print(re.search(r"\[DONE\]$", chunk_str)) - chunk_str = chunk_str[len("data:"):].strip() + # Check for SSE data + if chunk_str.startswith("data:") and not re.search(r"\[DONE\]$", chunk_str): + # print(chunk_str) + # print(re.search(r"\[DONE\]$", chunk_str)) + chunk_str = chunk_str[len("data:"):].strip() - try: - response = decoder.decode(chunk_str) - except ValueError as ex: - sublime.error_message(f"Server Error: {str(ex)}") - logging.exception("Exception: " + str(ex)) + try: + response = decoder.decode(chunk_str) + except ValueError as ex: + sublime.error_message(f"Server Error: {str(ex)}") + logging.exception("Exception: " + str(ex)) - if 'delta' in response['choices'][0]: - delta = response['choices'][0]['delta'] - if 'role' in delta: - full_response_content['role'] = delta['role'] - elif 'content' in delta: - full_response_content['content'] += delta['content'] - self.update_output_panel(delta['content']) + if 'delta' in response['choices'][0]: + delta = response['choices'][0]['delta'] + if 'role' in delta: + full_response_content['role'] = delta['role'] + elif 'content' in delta: + full_response_content['content'] += delta['content'] + self.update_output_panel(delta['content']) - self.provider.connection.close() - Cacher().append_to_cache([full_response_content]) + self.provider.connection.close() + Cacher().append_to_cache([full_response_content]) - ## FIXME: Make this handler handle tokens overflow error. + def handle_ordinary_response(self): + response = self.provider.execute_response() + if response is None or response.status != 200: + return + data = response.read() + data_decoded = data.decode('utf-8') + self.provider.connection.close() + completion = json.loads(data_decoded)['choices'][0]['text'] + completion = completion.strip() # Remove leading and trailing spaces + self.prompt_completion(completion) + + + def handle_response(self): + try: + if self.mode == "chat_completion": self.handle_chat_completion_response() + else: self.handle_ordinary_response() + except ContextLengthExceededException as error: + print("xxxx8") + if self.mode == 'chat_completion': + # As user to delete first dialog pair, + do_delete = sublime.ok_cancel_dialog(msg=f'Delete the two farthest pairs?\n\n{error.message}', ok_title="Delete") + if do_delete: + Cacher().drop_first(2) + assistant_role = self.settings.get('assistant_role') + if not isinstance(assistant_role, str): + raise ValueError("The assistant_role setting must be a string.") + payload = self.provider.prepare_payload(mode=self.mode, role=assistant_role) + self.provider.prepare_request(gateway="/v1/chat/completions", json_payload=payload) + self.handle_response() + else: + present_error(title="OpenAI error", error=error) except KeyError: - # TODO: Add status bar user notification for this action. if self.mode == 'chat_completion' and response['error']['code'] == 'context_length_exceeded': Cacher().drop_first(2) else: sublime.error_message("Exception\n" + "The OpenAI response could not be decoded. There could be a problem on their side. Please look in the console for additional error info.") logging.exception("Exception: " + str(response)) return - except Exception as ex: - sublime.error_message(f"Server Error: {str(ex)}") - logging.exception("Exception: " + str(ex)) - return - - def handle_ordinary_response(self): - try: - response = self.provider.execute_response() - data = response.read() - data_decoded = data.decode('utf-8') - self.provider.connection.close() - completion = json.loads(data_decoded)['choices'][0]['text'] - completion = completion.strip() # Remove leading and trailing spaces - self.prompt_completion(completion) - except KeyError: - sublime.error_message("Exception\n" + "The OpenAI response could not be decoded. There could be a problem on their side. Please look in the console for additional error info.") - logging.exception("Exception: " + str(data_decoded)) - return except Exception as ex: sublime.error_message(f"Server Error: {str(response.status)}\n{ex}") logging.exception("Exception: " + str(data_decoded)) return - def handle_response(self): - if self.mode == "chat_completion": self.handle_chat_completion_response() - else: self.handle_ordinary_response() - def run(self): try: # FIXME: It's better to have such check locally, but it's pretty complicated with all those different modes and models @@ -163,16 +171,15 @@ def run(self): self.handle_response() elif self.mode == 'chat_completion': - Cacher().append_to_cache([self.message]) cacher = Cacher() - assistant_role = self.settings.get('assistant_role') - if not isinstance(assistant_role, str): - raise ValueError("The assistant_role setting must be a string.") - role: str = assistant_role + cacher.append_to_cache([self.message]) self.update_output_panel("\n\n## Question\n\n") self.update_output_panel(cacher.read_all()[-1]["content"]) - payload = self.provider.prepare_payload(mode=self.mode, cacher=cacher, role=role) + assistant_role = self.settings.get('assistant_role') + if not isinstance(assistant_role, str): + raise ValueError("The assistant_role setting must be a string.") + payload = self.provider.prepare_payload(mode=self.mode, role=assistant_role) self.provider.prepare_request(gateway="/v1/chat/completions", json_payload=payload) self.handle_response() From 769e795f7c83960cde2692ace97ab1ee27a27ec8 Mon Sep 17 00:00:00 2001 From: Yaroslav Yashin Date: Fri, 7 Jul 2023 22:11:17 +0200 Subject: [PATCH 13/14] Release notes 2.1.0 provided. --- messages.json | 3 ++- messages/2.1.0.txt | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 messages/2.1.0.txt diff --git a/messages.json b/messages.json index 94d92a7..7ab140d 100644 --- a/messages.json +++ b/messages.json @@ -2,5 +2,6 @@ "install": "README.md", "2.0.0": "messages/2.0.0.txt", "2.0.3": "messages/2.0.3.txt", - "2.0.4": "messages/2.0.4.txt" + "2.0.4": "messages/2.0.4.txt", + "2.1.0": "messages/2.1.0.txt" } \ No newline at end of file diff --git a/messages/2.1.0.txt b/messages/2.1.0.txt new file mode 100644 index 0000000..619795b --- /dev/null +++ b/messages/2.1.0.txt @@ -0,0 +1,14 @@ +=> 2.1.0 + +## Features + +- Completion streaming support. +- Drop the 2 farthest replies from the plugin cache dialogue. + +### Completion streaming support. + +Yep, you've heard it that cool shiny way that you see in the original OpenAI Chat app now comes to Sublime. Embrace, behold and all that stuff. Jokes aside — this thing makes GPT-4 completion workable w/o any tradeoffs. + +### Drop the 2 farthest replies from the plugin cache dialogue. + +Now if you reach the context window limit, you're getting requested whether you or not wish to delete the 2 farthest messages (1 yours and 1 from the assistant) to fix that issue in the background. If yes, the plugin would drop them and resend all the other chat history to OpenAI servers once again. This thing is recursive and will spit the popup in your face until the chat history would fit within the model context window once again. On cancel it will do nothing, as expected. \ No newline at end of file From cb1bc050c6738ac5e2b7c4334b5d430476e9c2db Mon Sep 17 00:00:00 2001 From: Yaroslav Yashin Date: Fri, 7 Jul 2023 23:07:54 +0200 Subject: [PATCH 14/14] Release notes updated. --- messages/2.1.0.txt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/messages/2.1.0.txt b/messages/2.1.0.txt index 619795b..e5a14d6 100644 --- a/messages/2.1.0.txt +++ b/messages/2.1.0.txt @@ -7,8 +7,11 @@ ### Completion streaming support. -Yep, you've heard it that cool shiny way that you see in the original OpenAI Chat app now comes to Sublime. Embrace, behold and all that stuff. Jokes aside — this thing makes GPT-4 completion workable w/o any tradeoffs. +Yep, you've heard it right. That new cool shiny way that you see in the original OpenAI Chat now comes to Sublime. Embrace, behold and all that stuff. Jokes aside — this thing only makes GPT-4 completion workable, by releasing the most significant tradeoff it has — long answering time. I mean GPT-4 answering time is still the same, but now you starting to see it up to 20 seconds earlier which is matters in terms of UX. ### Drop the 2 farthest replies from the plugin cache dialogue. -Now if you reach the context window limit, you're getting requested whether you or not wish to delete the 2 farthest messages (1 yours and 1 from the assistant) to fix that issue in the background. If yes, the plugin would drop them and resend all the other chat history to OpenAI servers once again. This thing is recursive and will spit the popup in your face until the chat history would fit within the model context window once again. On cancel it will do nothing, as expected. \ No newline at end of file +Now if you reach the context window limit, you're getting asked whether you or not wish to delete the 2 farthest messages (1 yours and 1 from the assistant) to shorter the chat history. If yes, the plugin would drop them and resend all the other chat history to OpenAI servers once again. This thing is recursive and will spit the popup in your face until the chat history would fit within a given model context window again. On cancel it will do nothing, as expected. + +PS: As usual, if you have any issues feel free to open an issue [here](https://github.com/yaroslavyaroslav/OpenAI-sublime-text/issues). +PS2: If you feel happy with this plugin you can drop me some coins for paying my OpenAI bills on Ethereum here (including L2 chains): 0x60843b4026Ff630b36835a8b78561eDD559ab208. \ No newline at end of file