Repository: devchat-ai/devchat (language: python)

Issue:
Wrapper of OpenAI chat APIs
**Is your feature request related to a problem? Please describe.**

LangChain is still defining the model for OpenAI's new chat APIs. Traditional conversations are not strong enough for a developer's chat with LLMs. We should simulate the experience and effectiveness of ChatGPT and even enhance it with tailored prompts and local data. But as the first step, we can start with a wrapper lib and CLI to support single-round conversations. 

**Describe the solution you'd like**

An Assistant class that stores the context and delegates the conversations with the LLM using the chat API.

**Additional context**

- https://platform.openai.com/docs/api-reference/chat/create
- https://python.langchain.com/en/latest/modules/models/chat.html
- https://github.com/openai/openai-python



Discussion / hints:

Fix patch:
diff --git a/.pylintrc b/.pylintrc
new file mode 100644
index 00000000..e0d101c3
--- /dev/null
+++ b/.pylintrc
@@ -0,0 +1,7 @@
+[MESSAGES CONTROL]
+disable=
+    too-few-public-methods,
+    missing-module-docstring
+
+[MASTER]
+extension-pkg-whitelist=pydantic
diff --git a/devchat/_cli.py b/devchat/_cli.py
index 78f606c9..31717499 100644
--- a/devchat/_cli.py
+++ b/devchat/_cli.py
@@ -2,8 +2,63 @@
 This module contains the main function for the devchat CLI.
 """
 
-def main():
+from typing import Optional
+import json
+import click
+from pydantic import ValidationError
+from devchat.message import Message
+from devchat.chat.openai_chat import OpenAIChatConfig, OpenAIChat
+from devchat.utils import is_valid_hash
+
+@click.command()
+@click.option('-r', '--reference', default='', help='Reference to prompt IDs')
+@click.argument('content')
+def main(content: str, reference: Optional[str]):
     """
-    The main function for the CLI. Prints "Hello, World!" when called.
+    Main function to run the chat application with the specified configuration file.
+    
+    This function reads the configuration file, initializes the chat system based on the
+    specified large language model (LLM), and performs interactions with the user by sending prompts
+    and retrieving responses. If the LLM is not recognized, it will print an error message.
+    
+    Args:
+        content (str): One or more lines of text for the Message object.
+        reference (str): Reference to prompt IDs.
     """
-    print("Hello World")
+    default_config_data = {
+        'llm': 'OpenAI',
+        'OpenAI': {
+            'model': 'gpt-3.5-turbo',
+            'temperature': 0.2
+        }
+    }
+
+    try:
+        with open('.chatconfig.json', 'r', encoding='utf-8') as file:
+            config_data = json.load(file)
+    except FileNotFoundError:
+        config_data = default_config_data
+
+    message = Message("user", content)
+    if reference:
+        # split the reference argument by comma and strip whitespace
+        hash_values = [value.strip() for value in reference.split(',') if value.strip()]
+
+        # validate the hash values and handle errors accordingly
+        for value in hash_values:
+            if not is_valid_hash(value):
+                click.echo(f"Invalid hash value: {value}")
+
+    llm = config_data.get('llm')
+
+    if llm == 'OpenAI':
+        try:
+            openai_config = OpenAIChatConfig(**config_data['OpenAI'])
+            chat = OpenAIChat(openai_config)
+            chat.prompt([message])
+            response_str = json.dumps(chat.complete_response())
+        except ValidationError as error:
+            response_str = f"Error: {error}"
+    else:
+        response_str = f"Unknown LLM: {llm}"
+    click.echo(response_str)
diff --git a/devchat/chat/__init__.py b/devchat/chat/__init__.py
new file mode 100644
index 00000000..0d329154
--- /dev/null
+++ b/devchat/chat/__init__.py
@@ -0,0 +1,1 @@
+from .chat import Chat
diff --git a/devchat/chat/chat.py b/devchat/chat/chat.py
new file mode 100644
index 00000000..8d632358
--- /dev/null
+++ b/devchat/chat/chat.py
@@ -0,0 +1,35 @@
+from abc import ABC, abstractmethod
+from typing import List, Iterator
+from devchat.message import Message
+
+class Chat(ABC):
+    """
+    Chat interface for managing chat-related interactions.
+
+    This interface defines methods for prompting a chat system with
+    a list of Message objects and retrieving responses, either as a
+    complete response or as a streamed response.
+    """
+
+    @abstractmethod
+    def prompt(self, messages: List[Message]) -> None:
+        """Send a list of Message objects as a prompt to the chat system."""
+
+    @abstractmethod
+    def complete_response(self) -> str:
+        """
+        Retrieve a complete response JSON string from the chat system.
+
+        Returns:
+            str: A JSON string representing the complete response from the chat system.
+        """
+
+    def stream_response(self) -> Iterator[str]:
+        """
+        Retrieve a streamed response as an iterator of JSON strings from the chat system.
+
+        Returns:
+            Iterator[str]: An iterator over JSON strings representing the streamed response
+                           events from the chat system.
+        """
+        raise NotImplementedError("stream_response() not supported.")
diff --git a/devchat/chat/openai_chat.py b/devchat/chat/openai_chat.py
new file mode 100644
index 00000000..96bbe8d0
--- /dev/null
+++ b/devchat/chat/openai_chat.py
@@ -0,0 +1,72 @@
+from typing import Optional, Union, List, Dict
+from pydantic import BaseModel, Field
+import openai
+from devchat.message import Message
+from devchat.chat import Chat
+
+class OpenAIChatConfig(BaseModel):
+    """
+    Configuration object for the OpenAIChat class.
+    """
+    model: str
+    temperature: Optional[float] = Field(None, ge=0, le=2)
+    top_p: Optional[float] = Field(None, ge=0, le=1)
+    num_choices: Optional[int] = Field(None, ge=1)
+    stream: Optional[bool] = Field(None)
+    stop: Optional[Union[str, List[str]]] = Field(None)
+    max_tokens: Optional[int] = Field(None, ge=1)
+    presence_penalty: Optional[float] = Field(None, ge=-2.0, le=2.0)
+    frequency_penalty: Optional[float] = Field(None, ge=-2.0, le=2.0)
+    logit_bias: Optional[Dict[int, float]] = Field(None)
+    user: Optional[str] = Field(None)
+
+class OpenAIChat(Chat):
+    """
+    OpenAIChat class that handles communication with the OpenAI Chat API.
+    """
+    def __init__(self, config: OpenAIChatConfig):
+        """
+        Initialize the OpenAIChat class with a configuration object.
+
+        Args:
+            config (OpenAIChatConfig): Configuration object with parameters for the OpenAI Chat API.
+        """
+        self.config = config
+        self._messages = []
+
+    def prompt(self, messages: List[Message]) -> None:
+        """
+        Prompt the chat system with a list of Message objects.
+
+        Args:
+            messages (List[Message]): A list of messages representing the conversation so far.
+        """
+        self._messages = [msg.to_dict() for msg in messages]
+
+    def complete_response(self) -> str:
+        """
+        Retrieve a complete response JSON string from the chat system.
+
+        Returns:
+            str: A JSON string representing the complete response.
+        """
+        # Filter the config parameters with non-None values
+        config_params = {
+            key: value
+            for key, value in self.config.dict().items()
+            if value is not None
+        }
+
+        response = openai.ChatCompletion.create(
+            messages=self._messages,
+            **config_params
+        )
+        return response
+
+    def stream_response(self) -> str:
+        """
+        Retrieve a streamed response from the chat system.
+
+        Returns:
+            str: An iterator of JSON strings representing the streamed response.
+        """
diff --git a/devchat/message.py b/devchat/message.py
new file mode 100644
index 00000000..75599713
--- /dev/null
+++ b/devchat/message.py
@@ -0,0 +1,85 @@
+from typing import Optional, Dict
+
+class Message:
+    """A class to represent a message in a conversation with chat API.
+
+    Attributes:
+        role (str): The role of the author of the message. One of 'system', 'user', or 'assistant'.
+        content (str): The contents of the message.
+        name (str, optional): The name of the author of the message. May contain a-z, A-Z, 0-9, and
+                              underscores, with a maximum length of 64 characters.
+    """
+
+    def __init__(self, role: str, content: str, name: Optional[str] = None):
+        self.role = role
+        self.content = content
+        self.name = name
+
+        if not self._validate_role():
+            raise ValueError("Invalid role. Must be one of 'system', 'user', or 'assistant'.")
+
+        if not self._validate_content():
+            raise ValueError("Content must not be empty or contain only whitespaces.")
+
+        if not self._validate_name():
+            raise ValueError("Invalid name. Must contain a-z, A-Z, 0-9, and underscores, "
+                             "with a maximum length of 64 characters.")
+
+    @classmethod
+    def from_dict(cls, message_data: Dict):
+        """Construct a Message instance from a dictionary.
+
+        Args:
+            message_data (Dict): A dictionary containing the message data with keys 'role',
+                                 'content', and an optional 'name'.
+
+        Returns:
+            Message: A new Message instance with the attributes set from the dictionary.
+        """
+        return cls(
+            role=message_data['role'],
+            content=message_data['content'],
+            name=message_data.get('name')
+        )
+
+    def _validate_role(self) -> bool:
+        """Validate the role attribute.
+
+        Returns:
+            bool: True if the role is valid, False otherwise.
+        """
+        return self.role in ["system", "user", "assistant"]
+
+    def _validate_content(self) -> bool:
+        """Validate the content attribute.
+
+        Returns:
+            bool: True if the content is valid, False otherwise.
+        """
+        return bool(self.content and self.content.strip())
+
+    def _validate_name(self) -> bool:
+        """Validate the name attribute.
+
+        Returns:
+            bool: True if the name is valid or None, False otherwise.
+        """
+        if self.name is None:
+            return True
+        if not self.name.strip():
+            return False
+        return len(self.name) <= 64 and self.name.replace("_", "").isalnum()
+
+    def to_dict(self) -> dict:
+        """Convert the Message object to a dictionary.
+
+        Returns:
+            dict: A dictionary representation of the Message object.
+        """
+        message_dict = {
+            "role": self.role,
+            "content": self.content,
+        }
+        if self.name is not None:
+            message_dict["name"] = self.name
+        return message_dict
diff --git a/devchat/prompt.py b/devchat/prompt.py
new file mode 100644
index 00000000..fcbac833
--- /dev/null
+++ b/devchat/prompt.py
@@ -0,0 +1,49 @@
+import json
+from devchat.message import Message
+
+class Prompt:
+    """
+    A class to represent a prompt and its corresponding response from the chat API.
+
+    Attributes:
+        model (str): The model used for the chat API request, e.g., "gpt-3.5-turbo".
+        response_meta (dict): A dictionary containing the 'id' and 'object' fields of the response.
+        response_time (int): The timestamp when the response was created.
+        request_tokens (int): The number of tokens used in the request.
+        response_tokens (int): The number of tokens used in the response.
+        messages (List[Message]): A list of Message instances containing the conversation messages.
+    """
+
+    def __init__(self, model: str):
+        self.model = model
+        self.response_meta = None
+        self.response_time = None
+        self.request_tokens = None
+        self.response_tokens = None
+        self.messages = None
+
+    def set_response(self, response_str: str):
+        """
+        Parse the API response string and set the Prompt object's attributes.
+
+        Args:
+            response_str (str): The JSON-formatted response string from the chat API.
+        """
+        response_data = json.loads(response_str)
+
+        if not response_data['model'].startswith(self.model):
+            raise ValueError(f"Model mismatch: expected '{self.model}', "
+                             f"got '{response_data['model']}'")
+
+        self.response_meta = {
+            'id': response_data['id'],
+            'object': response_data['object']
+        }
+        self.response_time = response_data['created']
+        self.request_tokens = response_data['usage']['prompt_tokens']
+        self.response_tokens = response_data['usage']['completion_tokens']
+
+        self.messages = [
+            Message.from_dict(choice['message'])
+            for choice in response_data['choices']
+        ]
diff --git a/devchat/utils.py b/devchat/utils.py
new file mode 100644
index 00000000..c3fba66e
--- /dev/null
+++ b/devchat/utils.py
@@ -0,0 +1,8 @@
+import re
+
+def is_valid_hash(hash_str):
+    """Check if a string is a valid hash value."""
+    # Hash values are usually alphanumeric with a fixed length
+    # depending on the algorithm used to generate them
+    pattern = re.compile(r'^[a-fA-F0-9]{40}$')  # Example pattern for SHA-1 hash
+    return bool(pattern.match(hash_str))
diff --git a/requirements.txt b/requirements.txt
index 06a638c8..7a84b097 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,1 +1,5 @@
+click==8.1.3
+openai==0.27.4
+pydantic==1.10.7
+pytest==7.3.1
 setuptools==67.6.1


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index a2f6ee8e..2846adc6 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1,17 +1,59 @@
-"""
-This module contains tests for the devchat CLI.
-"""
-
-import io
-from contextlib import redirect_stdout
+import os
+import json
+import shutil
+import tempfile
+from click.testing import CliRunner
 from devchat._cli import main
+from devchat.prompt import Prompt
+
+runner = CliRunner()
+
+def test_main_no_args():
+    result = runner.invoke(main)
+    assert result.exit_code == 2
+
+def test_main_with_content():
+    content = "What is the capital of France?"
+    result = runner.invoke(main, [content])
+    assert result.exit_code == 0
+
+    prompt = Prompt("gpt-3.5-turbo")
+    prompt.set_response(result.output.strip())
+    assert prompt.response_meta is not None
+    assert prompt.response_time is not None
+    assert prompt.request_tokens is not None
+    assert prompt.response_tokens is not None
+    assert prompt.messages is not None
+
+def test_main_with_temp_config_file():
+    config_data = {
+        'llm': 'OpenAI',
+        'OpenAI': {
+            'model': 'gpt-3.5-turbo',
+            'temperature': 0.2
+        }
+    }
+
+    temp_dir = tempfile.mkdtemp()
+    temp_config_path = os.path.join(temp_dir, ".chatconfig.json")
+
+    with open(temp_config_path, "w") as temp_config_file:
+        json.dump(config_data, temp_config_file)
+
+    original_cwd = os.getcwd()
+    os.chdir(temp_dir)
+
+    content = "What is the capital of Spain?"
+    result = runner.invoke(main, [content])
+    assert result.exit_code == 0
+
+    prompt = Prompt("gpt-3.5-turbo")
+    prompt.set_response(result.output.strip())
+    assert prompt.response_meta is not None
+    assert prompt.response_time is not None
+    assert prompt.request_tokens is not None
+    assert prompt.response_tokens is not None
+    assert prompt.messages is not None
 
-def test_main():
-    """
-    Test the main function of the CLI. Ensures it prints "Hello, World!" as expected.
-    """
-    buffer = io.StringIO()
-    with redirect_stdout(buffer):
-        main()
-    output = buffer.getvalue().strip()
-    assert output == "Hello World"
+    os.chdir(original_cwd)
+    shutil.rmtree(temp_dir)
diff --git a/tests/test_message.py b/tests/test_message.py
new file mode 100644
index 00000000..2cbccc81
--- /dev/null
+++ b/tests/test_message.py
@@ -0,0 +1,74 @@
+import pytest
+from devchat.message import Message
+
+def test_valid_message_creation():
+    message = Message(role="user", content="Hello, World!")
+    assert message.role == "user"
+    assert message.content == "Hello, World!"
+    assert message.name is None
+
+def test_valid_message():
+    message = Message("user", "Hello, World!", "John_Doe")
+    assert message.to_dict() == {"role": "user", "content": "Hello, World!", "name": "John_Doe"}
+
+def test_invalid_role():
+    with pytest.raises(ValueError):
+        Message("invalid_role", "Hello, World!")
+
+def test_none_content():
+    with pytest.raises(ValueError):
+        Message(role="user", content=None)
+
+def test_empty_content():
+    with pytest.raises(ValueError):
+        Message("user", "")
+
+def test_blank_content():
+    with pytest.raises(ValueError):
+        Message("user", "  ")
+
+def test_none_content():
+    with pytest.raises(ValueError):
+        Message("user", None)
+
+def test_invalid_name():
+    with pytest.raises(ValueError):
+        Message("user", "Hello, World!", "Invalid@Name")
+
+def test_empty_name():
+    with pytest.raises(ValueError):
+        Message("user", "Hello, World!", "")
+
+def test_blank_name():
+    with pytest.raises(ValueError):
+        Message("user", "Hello, World!", "  ")
+
+def test_tab_content():
+    with pytest.raises(ValueError):
+        Message(role="user", content="\t")
+
+def test_none_name():
+    message = Message("user", "Hello, World!", None)
+    assert message.to_dict() == {"role": "user", "content": "Hello, World!"}
+
+def test_from_dict():
+    message_data = {
+        "content": "Welcome to the chat.",
+        "role": "system"
+    }
+    message = Message.from_dict(message_data)
+    assert message.role == "system"
+    assert message.content == "Welcome to the chat."
+    assert message.name is None
+
+def test_from_dict_with_name():
+    message_data = {
+        "content": "Hello, Assistant!",
+        "role": "user",
+        "name": "JohnDoe"
+    }
+    message = Message.from_dict(message_data)
+    assert message.role == "user"
+    assert message.content == "Hello, Assistant!"
+    assert message.name == "JohnDoe"
+    
\ No newline at end of file
diff --git a/tests/test_prompt.py b/tests/test_prompt.py
new file mode 100644
index 00000000..8dabd1dd
--- /dev/null
+++ b/tests/test_prompt.py
@@ -0,0 +1,68 @@
+import pytest
+from devchat.prompt import Prompt
+
+
+def test_prompt_init_and_set_response():
+    prompt = Prompt(model="gpt-3.5-turbo")
+    assert prompt.model == "gpt-3.5-turbo"
+
+    response_str = '''
+    {
+      "id": "chatcmpl-6p9XYPYSTTRi0xEviKjjilqrWU2Ve",
+      "object": "chat.completion",
+      "created": 1677649420,
+      "model": "gpt-3.5-turbo-0301",
+      "usage": {"prompt_tokens": 56, "completion_tokens": 31, "total_tokens": 87},
+      "choices": [
+        {
+          "message": {
+            "role": "assistant",
+            "content": "The 2020 World Series was played in Arlington, Texas at the Globe Life Field, which was the new home stadium for the Texas Rangers."
+          },
+          "finish_reason": "stop",
+          "index": 0
+        }
+      ]
+    }
+    '''
+    prompt.set_response(response_str)
+
+    assert prompt.response_meta == {
+        'id': 'chatcmpl-6p9XYPYSTTRi0xEviKjjilqrWU2Ve',
+        'object': 'chat.completion'
+    }
+    assert prompt.response_time == 1677649420
+    assert prompt.request_tokens == 56
+    assert prompt.response_tokens == 31
+    assert len(prompt.messages) == 1
+    assert prompt.messages[0].role == "assistant"
+    assert prompt.messages[0].content == "The 2020 World Series was played in Arlington, Texas at the Globe Life Field, which was the new home stadium for the Texas Rangers."
+
+def test_prompt_model_mismatch():
+    prompt = Prompt(model="gpt-3.5-turbo")
+
+    response_str = '''
+    {
+      "choices": [
+        {
+          "finish_reason": "stop",
+          "index": 0,
+          "message": {
+            "content": "\\n\\n1, 2, 3, 4, 5, 6, 7, 8, 9, 10.",
+            "role": "assistant"
+          }
+        }
+      ],
+      "created": 1677825456,
+      "id": "chatcmpl-6ptKqrhgRoVchm58Bby0UvJzq2ZuQ",
+      "model": "gpt-4",
+      "object": "chat.completion",
+      "usage": {
+        "completion_tokens": 301,
+        "prompt_tokens": 36,
+        "total_tokens": 337
+      }
+    }
+    '''
+    with pytest.raises(ValueError):
+        prompt.set_response(response_str)