├── src └── gptcmd │ ├── __init__.py │ ├── config_sample.toml │ ├── llm │ ├── __init__.py │ ├── openai.py │ └── README.md │ ├── macros.py │ ├── config.py │ └── message.py ├── pyproject.toml ├── tests ├── test_llm.py └── test_message.py ├── README.md └── COPYING.txt /src/gptcmd/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Gptcmd package root 3 | Copyright 2025 Bill Dengler 4 | This Source Code Form is subject to the terms of the Mozilla Public 5 | License, v. 2.0. If a copy of the MPL was not distributed with this 6 | file, You can obtain one at https://mozilla.org/MPL/2.0/. 7 | """ 8 | 9 | __all__ = ["__version__", "Gptcmd"] 10 | 11 | __version__ = "2.3.3" 12 | 13 | from .cli import Gptcmd # noqa: E402 14 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "gptcmd" 7 | authors = [ 8 | { name="Bill Dengler", email="codeofdusk@gmail.com" }, 9 | ] 10 | description = "Command line GPT conversation and experimentation environment" 11 | readme = "README.md" 12 | requires-python = ">=3.8.6" 13 | license = "MPL-2.0" 14 | classifiers = [ 15 | "Programming Language :: Python :: 3", 16 | "Operating System :: OS Independent", 17 | ] 18 | dependencies = [ 19 | "openai>=1.54.0, < 3.0.0", 20 | "tomli>=1.1.0, < 2.0.0 ; python_version < '3.11'", 21 | "backports.strenum>=1.3.1, < 2.0.0 ; python_version < '3.11'", 22 | "importlib_metadata>=3.6 ; python_version < '3.10'", 23 | "packaging>=23.0", 24 | ] 25 | dynamic = ["version"] 26 | 27 | [project.urls] 28 | "Homepage" = "https://github.com/codeofdusk/gptcmd" 29 | "Bug Tracker" = "https://github.com/codeofdusk/gptcmd/issues" 30 | 31 | [project.scripts] 32 | gptcmd = "gptcmd.cli:main" 33 | 34 | [tool.setuptools.package-data] 35 | "gptcmd" = ["config_sample.toml"] 36 | 37 | [tool.setuptools.dynamic] 38 | version = {attr = "gptcmd.__version__"} 39 | 40 | [tool.black] 41 | line-length = 79 42 | target-version = ['py38'] 43 | preview=true 44 | -------------------------------------------------------------------------------- /tests/test_llm.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from gptcmd.llm import InvalidAPIParameterError, LLMProvider, LLMResponse 4 | from gptcmd.message import Message 5 | from typing import Dict, Sequence 6 | 7 | 8 | class CactusProvider(LLMProvider): 9 | @classmethod 10 | def from_config(cls, conf: Dict): 11 | return cls(**conf) 12 | 13 | def complete(self, messages: Sequence[Message]) -> LLMResponse: 14 | return LLMResponse(Message(content="Cactus cactus!", role="assistant")) 15 | 16 | def validate_api_params(self, params): 17 | if "invalid_param" in params: 18 | raise InvalidAPIParameterError("Invalid parameter") 19 | return params 20 | 21 | @property 22 | def valid_models(self): 23 | return ["saguaro-1", "saguaro-2"] 24 | 25 | def get_best_model(self): 26 | return "saguaro-2" 27 | 28 | 29 | class TestLLMProvider(unittest.TestCase): 30 | def setUp(self): 31 | self.llm = CactusProvider() 32 | 33 | def test_init(self): 34 | self.assertEqual(self.llm.model, "saguaro-2") 35 | self.assertEqual(self.llm._api_params, {}) 36 | self.assertFalse(self.llm.stream) 37 | 38 | def test_set_api_param_valid(self): 39 | self.llm.set_api_param("temperature", 0.8) 40 | self.assertEqual(self.llm._api_params["temperature"], 0.8) 41 | 42 | def test_set_api_param_invalid(self): 43 | with self.assertRaises(InvalidAPIParameterError): 44 | self.llm.set_api_param("invalid_param", "value") 45 | 46 | def test_update_api_params_valid(self): 47 | self.llm.update_api_params({"temperature": 0.8, "max_tokens": 100}) 48 | self.assertEqual(self.llm._api_params["temperature"], 0.8) 49 | self.assertEqual(self.llm._api_params["max_tokens"], 100) 50 | 51 | def test_update_api_params_invalid(self): 52 | with self.assertRaises(InvalidAPIParameterError): 53 | self.llm.update_api_params( 54 | {"temperature": 0.8, "invalid_param": "value"} 55 | ) 56 | 57 | def test_complete(self): 58 | messages = [Message(content="Hello", role="user")] 59 | response = self.llm.complete(messages) 60 | self.assertIsInstance(response, LLMResponse) 61 | self.assertEqual(response.message.content, "Cactus cactus!") 62 | self.assertEqual(response.message.role, "assistant") 63 | 64 | def test_default_text_iter(self): 65 | messages = [Message(content="Testing testing", role="user")] 66 | response = self.llm.complete(messages) 67 | self.assertIsInstance(response, LLMResponse) 68 | buf = "" 69 | for chunk in response: 70 | buf += chunk 71 | self.assertEqual(buf, "Cactus cactus!") 72 | 73 | def test_valid_models(self): 74 | self.assertEqual(self.llm.valid_models, ["saguaro-1", "saguaro-2"]) 75 | 76 | def test_get_best_model(self): 77 | self.assertEqual(self.llm.get_best_model(), "saguaro-2") 78 | 79 | 80 | if __name__ == "__main__": 81 | unittest.main() 82 | -------------------------------------------------------------------------------- /src/gptcmd/config_sample.toml: -------------------------------------------------------------------------------- 1 | # Gptcmd configuration 2 | 3 | # This option is used by the application for version tracking. 4 | schema_version = "1.3.0" 5 | 6 | # This option controls the formatting of the prompt. 7 | # The following keywords (when placed in braces) are replaced by: 8 | # model: the name of the active model 9 | # thread: the name of the active thread (if not the detached thread) 10 | # account: the name of the active account 11 | # Python escape sequences are supported. 12 | # Any other characters placed in this string are printed literally. 13 | prompt = "{thread}({model}) " 14 | 15 | # This option controls whether estimated session cost is displayed, when 16 | # available, after each successful request. 17 | show_cost = true 18 | 19 | # Sometimes, such as when switching to a model that doesn't have cost 20 | # information available, cost estimation is unsupported. 21 | # Since these requests aren't counted in the session cost estimate, when 22 | # switching back to a scenario that does support cost estimation, the reported 23 | # estimated cost will be incomplete. 24 | # This option controls whether these incomplete estimates are displayed. 25 | show_incomplete_cost = false 26 | 27 | # This option controls whether the number of prompt (input) and sampled 28 | # (generated) tokens used for each request is displayed when available. 29 | show_token_usage = true 30 | 31 | # This option controls whether Gptcmd checks for updates on startup. 32 | check_for_updates = true 33 | 34 | # This option specifies the external editor Gptcmd uses for commands that require one. 35 | # If this option is not set, Gptcmd uses Notepad on Windows. 36 | # On Unix-like systems, Gptcmd uses the default configured editor, typically 37 | # determined by the EDITOR environment variable. 38 | # To specify a custom editor, uncomment the line setting the editor option 39 | # below and set it to an editor of your choice. 40 | # For example, to use Notepad++ on Windows: 41 | # editor = "C:\\Program Files (x86)\\Notepad++\\notepad++.exe -multiInst -notabbar -nosession -noPlugin" 42 | 43 | # This option controls how Gptcmd handles situations when the user invokes an 44 | # external editor to add a message but then closes the editor without entering 45 | # any content. 46 | # By default, this option is set to "never", meaning Gptcmd will cancel the 47 | # operation if no content is entered. 48 | # When this option is set to "ask", Gptcmd will prompt the user to confirm 49 | # whether to add an empty message or cancel. 50 | # Setting this option to "always" will add an empty message without prompting, 51 | # replicating Gptcmd's behaviour before version 2.0.0. 52 | # Unless you know that you have a specific need to create empty messages, 53 | # "never" is recommended. 54 | allow_add_empty_messages = "never" 55 | 56 | # This option controls what Gptcmd does when the user runs `retry` from 57 | # a named thread. 58 | # When this option is set to "always", a new thread will be created on retry, 59 | # replicating Gptcmd's behaviour before version 2.1.0. 60 | # When this option is set to "ask", Gptcmd will prompt the user whether to 61 | # create a new thread for this retried query or to overwrite the 62 | # existing contents, similar to Gptcmd's behaviour in the detached thread. 63 | # When this option is set to "never", Gptcmd always overwrites previous 64 | # assistant contents with the retried query in both detached and named threads. 65 | create_new_thread_on_retry = "ask" 66 | 67 | # Macro Configuration 68 | # The [macros] section allows you to define custom commands, called macros, 69 | # which can be executed from the Gptcmd prompt. A macro consists of one or more 70 | # standard Gptcmd commands. 71 | # To define a macro, add an entry to the [macros] section where the key is the 72 | # name of your macro and the value is a string containing the commands to run. 73 | # For multi-line macros, use TOML's triple-quote string syntax. 74 | # Macro definitions can contain placeholders for arguments provided at runtime: 75 | # {1}, {2}, etc.: Positional arguments passed to the macro. 76 | # {*}: All arguments joined into a single string. 77 | # {arg?default}: Use a default value if the argument `arg` is not provided. 78 | # For example, {1?hello} will substitute "hello" if the first argument is missing. 79 | # To include a literal { or }, double it: {{ or }} 80 | # Macros also have access to some built-in variables: 81 | # {thread}: The name of the current thread. 82 | # {model}: The name of the active model. 83 | # {account}: The name of the active account. 84 | # To enable macros, uncomment the [macros] header below. The describe macro is provided as an example. 85 | # [macros] 86 | # Example: A macro to describe an image at a given path or URL. 87 | # Usage: describe /path/to/image.jpg 88 | # describe = """ 89 | # user What's in this image? 90 | # image {*} 91 | # send 92 | # """ 93 | 94 | # Account Configuration 95 | # The following sections configure Gptcmd's connections to large language model provider accounts. 96 | # By default, Gptcmd uses the [accounts.default] section on startup. 97 | # If this section doesn't exist, Gptcmd uses the first account section it finds. 98 | # You can add multiple accounts by creating additional sections: 99 | # [accounts.first] 100 | # [accounts.second] 101 | # [accounts.custom_name] 102 | # Each account section should contain connection details similar to [accounts.default]. 103 | 104 | # Within each account section (placed between its header and the next account's header), you can specify the following options: 105 | 106 | # provider: Specifies the large language model provider; must be "openai", 107 | # "azure", or the name of an external provider. 108 | # Example: 109 | # provider = "openai" 110 | 111 | # model: The OpenAI model or Azure deployment Gptcmd should use when this account is activated. 112 | # Example: 113 | # model = "gpt-4o-mini" 114 | 115 | # endpoint: For Azure accounts, the Azure endpoint URL. 116 | # Example: 117 | # endpoint = "https://contoso.openai.azure.com/" 118 | 119 | # api_key: The API key to use. If omitted, Gptcmd reads it from the OPENAI_API_KEY (for OpenAI accounts) or AZURE_OPENAI_API_KEY (for Azure accounts) environment variable. 120 | # Example: 121 | # api_key = "sk-xxxxxx" 122 | 123 | # base_url: For OpenAI accounts, the endpoint URL to which Gptcmd should connect. 124 | # With the "model" option, this option can be used to connect Gptcmd to third-party OpenAI-compatible APIs. 125 | # Example: 126 | # base_url = "https://openrouter.ai/api/v1" 127 | 128 | # Any additional options are passed directly to the Python OpenAI client's constructor for this account. 129 | 130 | [accounts.default] 131 | provider="openai" 132 | -------------------------------------------------------------------------------- /src/gptcmd/llm/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module contains the LLMProvider class and supporting infrastructure. 3 | Copyright 2024 Bill Dengler 4 | This Source Code Form is subject to the terms of the Mozilla Public 5 | License, v. 2.0. If a copy of the MPL was not distributed with this 6 | file, You can obtain one at https://mozilla.org/MPL/2.0/. 7 | """ 8 | 9 | import dataclasses 10 | from abc import ABC, abstractmethod 11 | from decimal import Decimal 12 | from enum import Flag, auto 13 | from typing import ( 14 | Any, 15 | Callable, 16 | Dict, 17 | Iterable, 18 | Optional, 19 | Sequence, 20 | Type, 21 | Union, 22 | ) 23 | 24 | from ..message import Message, MessageAttachment, UnknownAttachment 25 | 26 | 27 | @dataclasses.dataclass 28 | class LLMResponse: 29 | message: Message 30 | prompt_tokens: Optional[int] = None 31 | sampled_tokens: Optional[int] = None 32 | cost_in_cents: Optional[Union[int, Decimal]] = None 33 | 34 | def __iter__(self): 35 | """The default iterator for non-streaming LLMResponse objects.""" 36 | yield self.message.content 37 | 38 | 39 | class InvalidAPIParameterError(Exception): 40 | pass 41 | 42 | 43 | class CompletionError(Exception): 44 | pass 45 | 46 | 47 | class LLMProviderFeature(Flag): 48 | """ 49 | An enum representing optional features that an LLMProvider might 50 | implement. 51 | """ 52 | 53 | # Whether this LLM implements support for the name attribute 54 | # on Message objects. If this flag is not set, message names are likely 55 | # to be ignored. 56 | MESSAGE_NAME_FIELD = auto() 57 | 58 | # Whether this LLM implements support for streamed responses 59 | RESPONSE_STREAMING = auto() 60 | 61 | 62 | class LLMProvider(ABC): 63 | """ 64 | An object which generates the most likely next Message 65 | given a sequence of Messages. 66 | """ 67 | 68 | SUPPORTED_FEATURES: LLMProviderFeature = LLMProviderFeature(0) 69 | 70 | def __init__(self, model: Optional[str] = None): 71 | self.model: Optional[str] = model or self.get_best_model() 72 | self._api_params: Dict[str, Any] = {} 73 | self._stream: bool = False 74 | 75 | def __init_subclass__(cls): 76 | cls._attachment_formatters: Dict[ 77 | Type[MessageAttachment], 78 | Callable[[MessageAttachment], Dict[str, Any]], 79 | ] = {} 80 | 81 | @abstractmethod 82 | def complete(self, messages: Sequence[Message]) -> LLMResponse: 83 | pass 84 | 85 | @abstractmethod 86 | def validate_api_params(self, params: Dict[str, Any]) -> Dict[str, Any]: 87 | """ 88 | Given a dict of API parameters, this method: 89 | Raises InvalidAPIParameterError if this model doesn't support a 90 | parameter defined in the dictionary. 91 | If the user-provided value is out of range or in the incorrect format, 92 | this method adjusts the value accordingly. 93 | """ 94 | pass 95 | 96 | @property 97 | @abstractmethod 98 | def valid_models(self) -> Optional[Iterable[str]]: 99 | """ 100 | A collection of model names that can be set on this LLM provider 101 | """ 102 | pass 103 | 104 | @classmethod 105 | @abstractmethod 106 | def from_config(cls, conf: Dict): 107 | "Instantiate this object from a dict of configuration file parameters." 108 | pass 109 | 110 | @property 111 | def stream(self) -> bool: 112 | return ( 113 | self._stream 114 | and LLMProviderFeature.RESPONSE_STREAMING 115 | in self.SUPPORTED_FEATURES 116 | ) 117 | 118 | @stream.setter 119 | def stream(self, val: bool): 120 | if ( 121 | LLMProviderFeature.RESPONSE_STREAMING 122 | not in self.SUPPORTED_FEATURES 123 | ): 124 | raise NotImplementedError( 125 | "Response streaming is not supported by this LLM" 126 | ) 127 | self._stream = val 128 | 129 | @property 130 | def api_params(self) -> Dict[str, Any]: 131 | return self._api_params.copy() 132 | 133 | def set_api_param(self, key: str, value: Any) -> Any: 134 | """Set an API parameter after validating it.""" 135 | new_params = self._api_params.copy() 136 | new_params[key] = value 137 | validated_params = self.validate_api_params(new_params) 138 | self._api_params = validated_params 139 | return validated_params.get(key) 140 | 141 | def unset_api_param(self, key: Optional[str] = None) -> None: 142 | if key is None: 143 | self._api_params = {} 144 | else: 145 | try: 146 | del self._api_params[key] 147 | except KeyError: 148 | raise InvalidAPIParameterError(f"{key} not set") 149 | 150 | def update_api_params(self, params: Dict[str, Any]) -> None: 151 | """Update multiple API parameters at once after validating them.""" 152 | new_params = self._api_params.copy() 153 | new_params.update(params) 154 | validated_params = self.validate_api_params(new_params) 155 | self._api_params = validated_params 156 | 157 | @abstractmethod 158 | def get_best_model(self) -> str: 159 | """ 160 | This method returns the name of the most capable model offered by 161 | this provider. 162 | """ 163 | pass 164 | 165 | @classmethod 166 | def register_attachment_formatter( 167 | cls, attachment_type: Type[MessageAttachment] 168 | ): 169 | def decorator(func: Callable[[MessageAttachment], Dict[str, Any]]): 170 | cls._attachment_formatters[attachment_type] = func 171 | return func 172 | 173 | return decorator 174 | 175 | def format_attachment( 176 | self, attachment: MessageAttachment 177 | ) -> Dict[str, Any]: 178 | if isinstance(attachment, UnknownAttachment): 179 | raise ValueError( 180 | f"{attachment.type} attachments are not supported. Perhaps you" 181 | " need to update Gptcmd or install a package?" 182 | ) 183 | for cls in self.__class__.__mro__: 184 | formatter = getattr(cls, "_attachment_formatters", {}).get( 185 | type(attachment) 186 | ) 187 | if formatter: 188 | return formatter(attachment) 189 | raise ValueError( 190 | f"{type(attachment).__name__} attachments aren't supported by" 191 | " this LLM" 192 | ) 193 | -------------------------------------------------------------------------------- /src/gptcmd/macros.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module contains components of Gptcmd's macro support. 3 | Copyright 2025 Bill Dengler 4 | This Source Code Form is subject to the terms of the Mozilla Public 5 | License, v. 2.0. If a copy of the MPL was not distributed with this 6 | file, You can obtain one at https://mozilla.org/MPL/2.0/. 7 | """ 8 | 9 | import collections 10 | from contextlib import contextmanager 11 | import shlex 12 | from string import Formatter 13 | from typing import ( 14 | Any, 15 | Callable, 16 | Dict, 17 | List, 18 | Mapping, 19 | Optional, 20 | Sequence, 21 | TYPE_CHECKING, 22 | Union, 23 | Set, 24 | ) 25 | import textwrap 26 | 27 | if TYPE_CHECKING: 28 | from .cli import Gptcmd 29 | 30 | 31 | class MacroError(Exception): 32 | def __init__(self, msg, line_num: Optional[int] = None): 33 | super().__init__(msg) 34 | self.line_num = line_num 35 | 36 | 37 | class _MacroFormatter(Formatter): 38 | def get_value( 39 | self, 40 | key: Union[int, str], 41 | args: Sequence[Any], 42 | kwargs: Mapping[str, Any], 43 | ) -> Any: 44 | if key == "*": 45 | return shlex.join(args) 46 | elif isinstance(key, str) and key.isdecimal(): 47 | key = int(key) 48 | if isinstance(key, int): 49 | if key <= 0: 50 | raise MacroError("Invalid argument index") 51 | idx = key - 1 52 | if idx >= len(args): 53 | raise MacroError(f"Missing positional argument {key}") 54 | return args[idx] 55 | else: 56 | try: 57 | return kwargs[key] 58 | except KeyError as e: 59 | raise MacroError(f"Undefined name {key!r}") from e 60 | 61 | def get_field( 62 | self, field_name: str, args: Sequence[Any], kwargs: Mapping[str, Any] 63 | ): 64 | if "." in field_name or "[" in field_name: 65 | raise MacroError( 66 | "Attribute and item access have not been implemented" 67 | ) 68 | 69 | default = None 70 | if "?" in field_name: 71 | field_name, default = field_name.split("?", maxsplit=1) 72 | 73 | try: 74 | # This will call our overridden get_value, which might raise 75 | # a MacroError for missing positional arguments. 76 | obj, used_key = super().get_field(field_name, args, kwargs) 77 | except (KeyError, IndexError, MacroError): 78 | if default is None: 79 | raise 80 | obj, used_key = default, field_name 81 | 82 | return obj, used_key 83 | 84 | def check_unused_args(self, used_args, args, kwargs): 85 | pass 86 | 87 | 88 | class _MacroEnvironment(collections.ChainMap): 89 | def __init__(self, shell: "Gptcmd"): 90 | super().__init__({}, _MacroBuiltins(shell)) 91 | 92 | 93 | class _MacroBuiltins(collections.abc.Mapping): 94 | _BUILTINS = { 95 | "thread": lambda sh: ( 96 | sh._current_thread.name 97 | if sh._current_thread != sh._detached 98 | else "" 99 | ), 100 | "model": lambda sh: sh._account.provider.model, 101 | "account": lambda sh: sh._account.name, 102 | } 103 | 104 | def __init__(self, shell: "Gptcmd"): 105 | self._shell = shell 106 | 107 | def __getitem__(self, key: str): 108 | if key in self._BUILTINS: 109 | return str(self._BUILTINS[key](self._shell)) 110 | raise KeyError(key) 111 | 112 | def __iter__(self): 113 | return iter(self._BUILTINS) 114 | 115 | def __len__(self): 116 | return len(self._BUILTINS) 117 | 118 | 119 | class _MacroDirectiveHandler: 120 | _registry: Dict[str, Callable] = {} 121 | _PREFIX = "@" 122 | 123 | @classmethod 124 | def register(cls, name: str): 125 | def decorator(func): 126 | lower = name.lower() 127 | if lower in cls._registry: 128 | raise KeyError(f"Directive {name!r} already registered") 129 | cls._registry[lower] = func 130 | return func 131 | 132 | return decorator 133 | 134 | @classmethod 135 | def run(cls, spec: str, shell, env, line_num, macro_args): 136 | try: 137 | tokens = shlex.split(spec, posix=True) 138 | except ValueError as e: 139 | raise MacroError(f"Error parsing directive: {e}") from e 140 | 141 | if not tokens: 142 | raise MacroError("Empty directive specification") 143 | else: 144 | keyword, *args = tokens 145 | 146 | directive = cls._registry.get(keyword.lower()) 147 | if directive is None: 148 | raise MacroError(f"Unknown directive {keyword!r}") 149 | 150 | directive( 151 | args, 152 | shell=shell, 153 | env=env, 154 | line_num=line_num, 155 | macro_args=macro_args, 156 | ) 157 | 158 | 159 | class MacroRunner: 160 | _STACK_DEPTH_LIMIT = 10 161 | 162 | def __init__(self, shell: "Gptcmd"): 163 | self._depth = 0 164 | self._formatter = _MacroFormatter() 165 | self._shell = shell 166 | self._active_macros: Set[str] = set() 167 | 168 | @contextmanager 169 | def _stack_frame(self): 170 | if self._depth >= self.__class__._STACK_DEPTH_LIMIT: 171 | raise MacroError("Stack overflow") 172 | self._depth += 1 173 | try: 174 | yield 175 | finally: 176 | self._depth -= 1 177 | 178 | def run( 179 | self, 180 | name: str, 181 | definition: str, 182 | args: Sequence[str], 183 | ) -> bool: 184 | # The active_macros set tracks macros currently on the execution stack 185 | # to prevent recursion. A macro is added when it starts and removed 186 | # when it finishes, so calling the same macro sequentially is fine. 187 | if name in self._active_macros: 188 | raise MacroError(f"Recursive invocation of macro {name!r}") 189 | self._active_macros.add(name) 190 | try: 191 | with self._stack_frame(): 192 | env = _MacroEnvironment(self._shell) 193 | for line_num, tpl in enumerate( 194 | self.__class__._split(definition), start=1 195 | ): 196 | try: 197 | if tpl.startswith(_MacroDirectiveHandler._PREFIX): 198 | _MacroDirectiveHandler.run( 199 | tpl[1:].lstrip(), 200 | shell=self._shell, 201 | env=env, 202 | line_num=line_num, 203 | macro_args=args, 204 | ) 205 | else: 206 | try: 207 | rendered = self._formatter.vformat( 208 | tpl, args, env 209 | ) 210 | except ValueError as e: 211 | raise MacroError( 212 | f"Invalid format string: {e}" 213 | ) from e 214 | if self._shell.onecmd(rendered): 215 | return True 216 | except MacroError as e: 217 | if e.line_num is None: 218 | e.line_num = line_num 219 | raise 220 | finally: 221 | self._active_macros.discard(name) 222 | return False 223 | 224 | @staticmethod 225 | def _split(definition: str) -> List[str]: 226 | dedented = textwrap.dedent(definition) 227 | return [ 228 | raw.strip() 229 | for raw in dedented.splitlines() 230 | if raw.strip() and not raw.lstrip().startswith("#") 231 | ] 232 | -------------------------------------------------------------------------------- /tests/test_message.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from gptcmd.message import ( 3 | Image, 4 | Message, 5 | MessageRole, 6 | MessageThread, 7 | PopStickyMessageError, 8 | UnknownAttachment, 9 | ) 10 | 11 | """ 12 | This module contains unit tests for MessageThread and related objects. 13 | Copyright 2023 Bill Dengler 14 | This Source Code Form is subject to the terms of the Mozilla Public 15 | License, v. 2.0. If a copy of the MPL was not distributed with this 16 | file, You can obtain one at https://mozilla.org/MPL/2.0/. 17 | """ 18 | 19 | 20 | class TestMessageThreadInit(unittest.TestCase): 21 | def test_init_empty(self): 22 | thread = MessageThread(name="test") 23 | self.assertEqual(thread.name, "test") 24 | self.assertEqual(len(thread), 0) 25 | self.assertEqual(thread.dirty, False) 26 | 27 | def test_init_with_messages(self): 28 | messages = [ 29 | Message(content="Hello", role=MessageRole.USER), 30 | Message(content="Hi", role=MessageRole.ASSISTANT), 31 | ] 32 | thread = MessageThread(name="test", messages=messages) 33 | self.assertEqual(len(thread), 2) 34 | self.assertEqual(thread[0].content, "Hello") 35 | self.assertEqual(thread[1].content, "Hi") 36 | 37 | def test_init_with_names(self): 38 | names = {MessageRole.USER: "Alice", MessageRole.ASSISTANT: "Mila"} 39 | thread = MessageThread(name="test", names=names) 40 | self.assertEqual(thread.names, names) 41 | 42 | 43 | class TestMessageThread(unittest.TestCase): 44 | def setUp(self): 45 | self.thread = MessageThread(name="test") 46 | 47 | def test_append(self): 48 | self.thread.append(Message(content="Hello", role=MessageRole.USER)) 49 | self.assertEqual(len(self.thread), 1) 50 | self.assertEqual(self.thread[0].content, "Hello") 51 | self.assertEqual(self.thread[0].role, MessageRole.USER) 52 | self.assertTrue(self.thread.dirty) 53 | 54 | def test_render(self): 55 | self.thread.append( 56 | Message(content="What is a cactus?", role=MessageRole.USER) 57 | ) 58 | self.thread.append( 59 | Message( 60 | content=( 61 | "A desert plant with thick, fleshy stems, sharp spines," 62 | " and beautiful, short-lived flowers." 63 | ), 64 | role=MessageRole.ASSISTANT, 65 | ) 66 | ) 67 | self.assertEqual( 68 | self.thread.render(), 69 | "user: What is a cactus?\nassistant: A desert plant with thick," 70 | " fleshy stems, sharp spines, and beautiful, short-lived flowers.", 71 | ) 72 | 73 | def test_render_custom_names(self): 74 | self.thread.names = { 75 | MessageRole.USER: "Bill", 76 | MessageRole.ASSISTANT: "Kevin", 77 | } 78 | self.thread.append( 79 | Message(content="What is a cactus?", role=MessageRole.USER) 80 | ) 81 | self.thread.append( 82 | Message( 83 | content=( 84 | "A desert plant with thick, fleshy stems, sharp spines," 85 | " and beautiful, short-lived flowers." 86 | ), 87 | role=MessageRole.ASSISTANT, 88 | ) 89 | ) 90 | self.assertEqual( 91 | self.thread.render(), 92 | "Bill: What is a cactus?\nKevin: A desert plant with thick, fleshy" 93 | " stems, sharp spines, and beautiful, short-lived flowers.", 94 | ) 95 | 96 | def test_pop(self): 97 | self.thread.append(Message(content="Hello", role=MessageRole.USER)) 98 | self.thread.append(Message(content="Hi", role=MessageRole.ASSISTANT)) 99 | popped = self.thread.pop() 100 | self.assertEqual(len(self.thread), 1) 101 | self.assertEqual(popped.content, "Hi") 102 | self.assertEqual(popped.role, MessageRole.ASSISTANT) 103 | self.thread.pop() 104 | with self.assertRaises(IndexError): 105 | self.thread.pop() 106 | 107 | def test_pop_sticky(self): 108 | self.thread.append( 109 | Message(content="Hello", role=MessageRole.USER, sticky=True) 110 | ) 111 | with self.assertRaises(PopStickyMessageError): 112 | self.thread.pop() 113 | 114 | def test_clear(self): 115 | self.thread.append(Message(content="Hello", role=MessageRole.USER)) 116 | self.thread.append(Message(content="Hi", role=MessageRole.ASSISTANT)) 117 | self.thread.clear() 118 | self.assertEqual(len(self.thread), 0) 119 | 120 | def test_clear_sticky(self): 121 | self.thread.append( 122 | Message(content="Hello", role=MessageRole.USER, sticky=True) 123 | ) 124 | self.thread.append(Message(content="Hi", role=MessageRole.ASSISTANT)) 125 | self.thread.clear() 126 | self.assertEqual(len(self.thread), 1) 127 | 128 | def test_flip(self): 129 | self.thread.append(Message(content="Hello", role=MessageRole.USER)) 130 | self.thread.append(Message(content="Hi", role=MessageRole.ASSISTANT)) 131 | flipped = self.thread.move(-1, 0) 132 | self.assertEqual(flipped.content, "Hi") 133 | self.assertEqual(self.thread[0].content, "Hi") 134 | self.assertEqual(self.thread[0].role, MessageRole.ASSISTANT) 135 | self.assertEqual(self.thread[1].content, "Hello") 136 | self.assertEqual(self.thread[1].role, MessageRole.USER) 137 | 138 | def test_rename(self): 139 | self.thread.append(Message(content="Hello", role=MessageRole.USER)) 140 | self.thread.append(Message(content="Hi", role=MessageRole.ASSISTANT)) 141 | self.thread.rename(role=MessageRole.ASSISTANT, name="GPT") 142 | self.assertEqual(self.thread[1].name, "GPT") 143 | 144 | def test_rename_limited_range(self): 145 | self.thread.append(Message(content="abc", role=MessageRole.USER)) 146 | self.thread.append(Message(content="def", role=MessageRole.ASSISTANT)) 147 | self.thread.append(Message(content="ghi", role=MessageRole.USER)) 148 | self.thread.append(Message(content="jkl", role=MessageRole.USER)) 149 | self.thread.rename( 150 | role=MessageRole.USER, name="Kevin", start_index=0, end_index=2 151 | ) 152 | self.assertEqual(self.thread[0].name, "Kevin") 153 | self.assertIsNone(self.thread[1].name) 154 | self.assertIsNone(self.thread[2].name) 155 | self.assertIsNone(self.thread[3].name) 156 | 157 | def test_sticky(self): 158 | self.thread.append(Message(content="Hello", role=MessageRole.USER)) 159 | self.thread.append(Message(content="Hi", role=MessageRole.ASSISTANT)) 160 | self.thread.sticky(0, 1, True) 161 | self.assertTrue(self.thread[0].sticky) 162 | self.assertFalse(self.thread[1].sticky) 163 | 164 | def test_messages_property(self): 165 | self.thread.append(Message(content="Hello", role=MessageRole.USER)) 166 | self.thread.append(Message(content="Hi", role=MessageRole.ASSISTANT)) 167 | messages = self.thread.messages 168 | self.assertIsInstance(messages, tuple) 169 | self.assertEqual(len(messages), 2) 170 | self.assertEqual(messages[0].content, "Hello") 171 | self.assertEqual(messages[1].content, "Hi") 172 | 173 | def test_to_dict(self): 174 | self.thread.append(Message(content="Hello", role=MessageRole.USER)) 175 | self.thread.append(Message(content="Hi", role=MessageRole.ASSISTANT)) 176 | thread_dict = self.thread.to_dict() 177 | self.assertIn("messages", thread_dict) 178 | self.assertIn("names", thread_dict) 179 | self.assertEqual(len(thread_dict["messages"]), 2) 180 | 181 | def test_from_dict(self): 182 | thread_dict = { 183 | "messages": [ 184 | {"content": "Hello", "role": "user"}, 185 | {"content": "Hi", "role": "assistant"}, 186 | ], 187 | "names": {"user": "Alice", "assistant": "Mila"}, 188 | } 189 | thread = MessageThread.from_dict(thread_dict, name="test") 190 | self.assertEqual(thread.name, "test") 191 | self.assertEqual(len(thread), 2) 192 | self.assertEqual(thread[0].content, "Hello") 193 | self.assertEqual(thread[0].role, MessageRole.USER) 194 | self.assertEqual(thread[1].content, "Hi") 195 | self.assertEqual(thread[1].role, MessageRole.ASSISTANT) 196 | self.assertEqual( 197 | thread.names, 198 | {MessageRole.USER: "Alice", MessageRole.ASSISTANT: "Mila"}, 199 | ) 200 | 201 | 202 | class TestMessage(unittest.TestCase): 203 | def test_message_creation(self): 204 | message = Message(content="Hello", role=MessageRole.USER) 205 | self.assertEqual(message.content, "Hello") 206 | self.assertEqual(message.role, MessageRole.USER) 207 | self.assertIsNone(message.name) 208 | self.assertFalse(message.sticky) 209 | self.assertEqual(message.attachments, []) 210 | 211 | def test_message_with_attachment(self): 212 | image = Image(url="http://example.com/image.jpg") 213 | message = Message( 214 | content="What's in this image?", 215 | role=MessageRole.USER, 216 | attachments=[image], 217 | ) 218 | self.assertEqual(len(message.attachments), 1) 219 | self.assertIsInstance(message.attachments[0], Image) 220 | 221 | def test_message_to_dict(self): 222 | message = Message(content="Hello", role=MessageRole.USER, name="Bill") 223 | message_dict = message.to_dict() 224 | self.assertEqual(message_dict["content"], "Hello") 225 | self.assertEqual(message_dict["role"], MessageRole.USER) 226 | self.assertEqual(message_dict["name"], "Bill") 227 | 228 | def test_message_from_dict(self): 229 | message_dict = { 230 | "content": "Hello", 231 | "role": "user", 232 | "name": "Bill", 233 | "sticky": True, 234 | "attachments": [ 235 | { 236 | "type": "image_url", 237 | "data": {"url": "http://example.com/image.jpg"}, 238 | } 239 | ], 240 | } 241 | message = Message.from_dict(message_dict) 242 | self.assertEqual(message.content, "Hello") 243 | self.assertEqual(message.role, MessageRole.USER) 244 | self.assertEqual(message.name, "Bill") 245 | self.assertTrue(message.sticky) 246 | self.assertEqual(len(message.attachments), 1) 247 | self.assertIsInstance(message.attachments[0], Image) 248 | 249 | def test_message_unknown_attachment(self): 250 | message_dict = { 251 | "content": "", 252 | "role": "user", 253 | "attachments": [ 254 | { 255 | "type": "nonexistent_attachment", 256 | "data": {"username": "kwebb"}, 257 | } 258 | ], 259 | } 260 | message = Message.from_dict(message_dict) 261 | self.assertEqual(len(message.attachments), 1) 262 | self.assertIsInstance(message.attachments[0], UnknownAttachment) 263 | serialized_message = message.to_dict() 264 | self.assertEqual( 265 | message_dict["attachments"][0], 266 | serialized_message["attachments"][0], 267 | ) 268 | 269 | 270 | class TestImage(unittest.TestCase): 271 | def test_image_creation(self): 272 | image = Image(url="http://example.com/image.jpg", detail="high") 273 | self.assertEqual(image.url, "http://example.com/image.jpg") 274 | self.assertEqual(image.detail, "high") 275 | 276 | def test_image_to_dict(self): 277 | image = Image(url="http://example.com/image.jpg", detail="high") 278 | image_dict = image.to_dict() 279 | self.assertEqual(image_dict["type"], "image_url") 280 | self.assertEqual( 281 | image_dict["data"]["url"], "http://example.com/image.jpg" 282 | ) 283 | self.assertEqual(image_dict["data"]["detail"], "high") 284 | 285 | def test_image_from_dict(self): 286 | image_dict = { 287 | "type": "image_url", 288 | "data": {"url": "http://example.com/image.jpg", "detail": "high"}, 289 | } 290 | image = Image.from_dict(image_dict) 291 | self.assertEqual(image.url, "http://example.com/image.jpg") 292 | self.assertEqual(image.detail, "high") 293 | 294 | 295 | if __name__ == "__main__": 296 | unittest.main() 297 | -------------------------------------------------------------------------------- /src/gptcmd/config.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module contains the ConfigManager class, which controls Gptcmd's 3 | config system. 4 | Copyright 2024 Bill Dengler 5 | This Source Code Form is subject to the terms of the Mozilla Public 6 | License, v. 2.0. If a copy of the MPL was not distributed with this 7 | file, You can obtain one at https://mozilla.org/MPL/2.0/. 8 | """ 9 | 10 | import dataclasses 11 | import json 12 | import os 13 | import platform 14 | import shlex 15 | import shutil 16 | import sys 17 | import urllib.request 18 | from functools import cached_property 19 | from importlib import resources 20 | from packaging.version import parse as parse_version 21 | from typing import Any, Dict, List, Optional, Set, Tuple, Type, Union 22 | 23 | if sys.version_info < (3, 10): 24 | from importlib_metadata import entry_points 25 | else: 26 | from importlib.metadata import entry_points 27 | if sys.version_info >= (3, 11): 28 | import tomllib 29 | else: 30 | import tomli as tomllib 31 | 32 | from . import __version__ 33 | from .llm import LLMProvider 34 | from .llm.openai import AzureAI, OpenAI 35 | 36 | DEFAULT_PROVIDERS: Dict[str, Type[LLMProvider]] = { 37 | "openai": OpenAI, 38 | "azure": AzureAI, 39 | } 40 | 41 | 42 | class ConfigError(Exception): 43 | pass 44 | 45 | 46 | @dataclasses.dataclass(frozen=True) 47 | class Account: 48 | name: str 49 | provider: dataclasses.InitVar[Union[LLMProvider, Type[LLMProvider]]] 50 | _conf: Dict[str, Any] = dataclasses.field( 51 | default_factory=dict, repr=False, compare=False, hash=False 52 | ) 53 | _provider_cls: Type[LLMProvider] = dataclasses.field( 54 | init=False, repr=False, compare=False, hash=False 55 | ) 56 | 57 | def __post_init__(self, provider): 58 | if isinstance(provider, LLMProvider): 59 | object.__setattr__(self, "_provider_cls", type(provider)) 60 | # bypass cached_property 61 | object.__setattr__(self, "provider", provider) 62 | elif isinstance(provider, type) and issubclass(provider, LLMProvider): 63 | object.__setattr__(self, "_provider_cls", provider) 64 | else: 65 | raise TypeError( 66 | "provider must be an LLMProvider instance or subclass" 67 | ) 68 | 69 | @cached_property 70 | def provider(self) -> LLMProvider: 71 | return self._provider_cls.from_config(self._conf) 72 | 73 | 74 | class ConfigManager: 75 | "Handles Gptcmd's configuration system." 76 | 77 | SYSTEM_MACRO_PREFIX = "__" 78 | SYSTEM_MACRO_SUFFIX = "__" 79 | # Basenames for macros reserved for internal use 80 | _SYSTEM_MACRO_BASENAMES: Set[str] = set() 81 | 82 | def __init__( 83 | self, 84 | config: Dict, 85 | providers: Optional[Dict[str, Type[LLMProvider]]] = None, 86 | ): 87 | """ 88 | Initialize the ConfigManager with a configuration dictionary. 89 | """ 90 | # Validate the provided config 91 | if "schema_version" not in config: 92 | raise ConfigError("Missing 'schema_version'") 93 | 94 | conf = self._load_sample_config() 95 | 96 | my_major = int(conf.pop("schema_version").split(".")[0]) 97 | their_major = int(config["schema_version"].split(".")[0]) 98 | if their_major > my_major: 99 | raise ConfigError( 100 | "This configuration is too new for the current version " 101 | "of Gptcmd!" 102 | ) 103 | 104 | conf.update(config) 105 | self.conf = conf 106 | if providers is None: 107 | providers = self.__class__._discover_external_providers( 108 | initial_providers=DEFAULT_PROVIDERS 109 | ) 110 | self.accounts = self._configure_accounts( 111 | self.conf["accounts"], providers 112 | ) 113 | # Validate the default account immediately; others stay lazy-loaded 114 | _ = self.default_account.provider 115 | 116 | @classmethod 117 | def _discover_external_providers( 118 | cls, 119 | initial_providers: Optional[Dict[str, Type[LLMProvider]]] = None, 120 | ) -> Dict[str, Type[LLMProvider]]: 121 | """ 122 | Discover external providers registered via entry points. 123 | """ 124 | res: Dict[str, Type[LLMProvider]] = {} 125 | if initial_providers: 126 | res.update(initial_providers) 127 | for ep in cls._iter_provider_entry_points(): 128 | provider_cls = ep.load() 129 | if ep.name in res: 130 | 131 | def fully_qualified_name(cls): 132 | return cls.__module__ + "." + cls.__qualname__ 133 | 134 | raise ConfigError( 135 | f"Duplicate registration for {ep.name}:" 136 | f" {fully_qualified_name(res[ep.name])} and" 137 | f" {fully_qualified_name(provider_cls)}" 138 | ) 139 | else: 140 | res[ep.name] = provider_cls 141 | return res 142 | 143 | @classmethod 144 | def from_toml(cls, path: Optional[str] = None): 145 | """ 146 | Create a ConfigManager instance from a TOML file. 147 | """ 148 | if path is None: 149 | config_root = cls._get_config_root() 150 | config_path = os.path.join(config_root, "config.toml") 151 | if not os.path.exists(config_path): 152 | os.makedirs(config_root, exist_ok=True) 153 | with resources.path( 154 | "gptcmd", "config_sample.toml" 155 | ) as sample_path: 156 | shutil.copy(sample_path, config_path) 157 | else: 158 | config_path = path 159 | 160 | try: 161 | with open(config_path, "rb") as fin: 162 | return cls(tomllib.load(fin)) 163 | except (OSError, tomllib.TOMLDecodeError) as e: 164 | raise ConfigError(str(e)) from e 165 | 166 | def _configure_accounts( 167 | self, account_config: Dict, providers: Dict[str, Type[LLMProvider]] 168 | ) -> Dict[str, Account]: 169 | res = {} 170 | for name, conf in account_config.items(): 171 | if "provider" not in conf: 172 | raise ConfigError(f"Account {name} has no provider specified") 173 | provider_cls = providers.get(conf["provider"]) 174 | if not provider_cls: 175 | raise ConfigError( 176 | f"Provider {conf['provider']} is not available. Perhaps" 177 | " you need to install it?" 178 | ) 179 | res[name] = Account( 180 | name=name, 181 | provider=provider_cls, 182 | _conf=conf.copy(), 183 | ) 184 | return res 185 | 186 | @property 187 | def default_account(self) -> Account: 188 | try: 189 | return self.accounts.get( 190 | "default", 191 | next( 192 | iter(self.accounts.values()) 193 | ), # The first configured account 194 | ) 195 | except StopIteration: 196 | raise ConfigError("No default account configured") 197 | 198 | @property 199 | def editor(self) -> List[str]: 200 | posix = platform.system().lower() != "windows" 201 | editor = ( 202 | self.conf.get("editor") or self.__class__._get_default_editor() 203 | ) 204 | return shlex.split(editor, posix=posix) 205 | 206 | @cached_property 207 | def macros(self) -> Dict[str, str]: 208 | section = self.conf.get("macros", {}) 209 | if not isinstance(section, dict): 210 | raise ConfigError("Macros section must be a table") 211 | for k, v in section.items(): 212 | if not isinstance(k, str): 213 | raise ConfigError("Macro names must be strings") 214 | if any(c.isspace() for c in k): 215 | raise ConfigError( 216 | f"Macro name {k!r} cannot contain whitespace" 217 | ) 218 | if not isinstance(v, str): 219 | raise ConfigError(f"Macro {k!r} must be a string") 220 | if ( 221 | k.startswith(self.SYSTEM_MACRO_PREFIX) 222 | and k.endswith(self.SYSTEM_MACRO_SUFFIX) 223 | and k not in self.valid_system_macro_names 224 | ): 225 | raise ConfigError(f"Unknown system macro {k!r}") 226 | return section.copy() 227 | 228 | @property 229 | def valid_system_macro_names(self): 230 | """Complete system macro names (for validation)""" 231 | return { 232 | ( 233 | self.__class__.SYSTEM_MACRO_PREFIX 234 | + name 235 | + self.__class__.SYSTEM_MACRO_SUFFIX 236 | ) 237 | for name in self.__class__._SYSTEM_MACRO_BASENAMES 238 | } 239 | 240 | @staticmethod 241 | def _get_config_root(): 242 | """Get the root directory for the configuration file.""" 243 | system = platform.system().lower() 244 | if system == "windows": 245 | base_path = os.environ.get("APPDATA") or os.path.expanduser("~") 246 | elif system == "darwin": 247 | base_path = os.path.expanduser("~/Library/Application Support") 248 | else: 249 | base_path = os.environ.get( 250 | "XDG_CONFIG_HOME" 251 | ) or os.path.expanduser("~/.config") 252 | return os.path.join(base_path, "gptcmd") 253 | 254 | @staticmethod 255 | def _load_sample_config(): 256 | "Load the sample configuration file as a dict" 257 | with resources.open_binary("gptcmd", "config_sample.toml") as fin: 258 | return tomllib.load(fin) 259 | 260 | @staticmethod 261 | def _get_default_editor(): 262 | system = platform.system().lower() 263 | if system == "windows": 264 | # On Windows, default to notepad 265 | return "notepad" 266 | else: 267 | # On Unix-like systems, use the EDITOR environment variable if set 268 | editor = os.environ.get("EDITOR") 269 | if editor: 270 | return editor 271 | else: 272 | # Try common editors in order of preference 273 | for cmd in ("nano", "emacs", "vim", "ed", "vi"): 274 | if shutil.which(cmd): 275 | return cmd 276 | raise ConfigError("No editor available") 277 | 278 | @staticmethod 279 | def _iter_provider_entry_points(): 280 | """ 281 | Yield provider entry points registered in the entry point group. 282 | Silent on any error; returns an empty tuple if discovery fails. 283 | """ 284 | GROUP = "gptcmd.providers" 285 | try: 286 | eps = entry_points() 287 | return eps.select(group=GROUP) 288 | except Exception: 289 | return () 290 | 291 | @staticmethod 292 | def _is_updatable_distribution(dist) -> bool: 293 | """ 294 | Return True if the distribution appears to be a standard PyPI install, 295 | False otherwise or on error. 296 | """ 297 | try: 298 | files = dist.files or () 299 | for f in files: 300 | if str(f).endswith("direct_url.json"): 301 | p = dist.locate_file(f) 302 | with open(p, "r", encoding="utf-8") as fin: 303 | data = json.load(fin) 304 | if data.get("dir_info", {}).get("editable"): 305 | return False 306 | url = ( 307 | data.get("archive_info", {}).get("url") 308 | or data.get("url") 309 | or "" 310 | ) 311 | vcs_prefixes = ("git+", "hg+", "svn+", "bzr+") 312 | if any(url.startswith(pref) for pref in vcs_prefixes): 313 | return False 314 | if url.startswith("file://"): 315 | return False 316 | if url and not ( 317 | "files.pythonhosted.org" in url or "pypi.org" in url 318 | ): 319 | return False 320 | return True 321 | return True 322 | except Exception: 323 | return False 324 | 325 | @staticmethod 326 | def _fetch_pypi_version(dist_name: str) -> Optional[str]: 327 | """ 328 | Fetch the latest version string for a distribution from PyPI. 329 | Returns None on failure. 330 | """ 331 | try: 332 | req = urllib.request.Request( 333 | f"https://pypi.org/pypi/{dist_name}/json", 334 | headers={ 335 | "User-Agent": f"Gptcmd/{__version__}", 336 | "Accept": "application/json", 337 | }, 338 | ) 339 | with urllib.request.urlopen(req, timeout=3.0) as resp: 340 | if getattr(resp, "status", 200) not in (200, 203): 341 | return None 342 | data = json.loads(resp.read().decode("utf-8")) 343 | ver = data.get("info", {}).get("version") 344 | return str(ver) if ver else None 345 | except Exception: 346 | return None 347 | 348 | def get_updatable_provider_packages(self) -> List[Tuple[str, str, str]]: 349 | """ 350 | Return a list of (name, current_version, latest_version) tuples for 351 | provider packages with newer versions available on PyPI. 352 | """ 353 | res: List[Tuple[str, str, str]] = [] 354 | seen: Set[str] = set() 355 | for ep in self._iter_provider_entry_points(): 356 | dist = getattr(ep, "dist", None) 357 | if dist is None: 358 | continue 359 | name = dist.metadata.get("Name", dist.name) 360 | if not name or name == "gptcmd" or name in seen: 361 | continue 362 | seen.add(name) 363 | if not self._is_updatable_distribution(dist): 364 | continue 365 | mine = dist.version 366 | theirs = self._fetch_pypi_version(name) 367 | if theirs and parse_version(mine) < parse_version(theirs): 368 | res.append((name, mine, theirs)) 369 | return res 370 | -------------------------------------------------------------------------------- /src/gptcmd/llm/openai.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module contains implementations of LLMProvider for OpenAI and Azure. 3 | Copyright 2024 Bill Dengler 4 | This Source Code Form is subject to the terms of the Mozilla Public 5 | License, v. 2.0. If a copy of the MPL was not distributed with this 6 | file, You can obtain one at https://mozilla.org/MPL/2.0/. 7 | """ 8 | 9 | import inspect 10 | import mimetypes 11 | import re 12 | 13 | from collections import namedtuple 14 | from decimal import Decimal 15 | from typing import Any, Dict, Iterable, Optional, Sequence 16 | 17 | from . import ( 18 | CompletionError, 19 | InvalidAPIParameterError, 20 | LLMProviderFeature, 21 | LLMProvider, 22 | LLMResponse, 23 | ) 24 | from ..message import Audio, Image, Message, MessageRole 25 | 26 | import openai 27 | 28 | ModelCostInfo = namedtuple( 29 | "ModelCostInfo", ("prompt_scale", "sampled_scale", "cache_discount_factor") 30 | ) 31 | 32 | OPENAI_COSTS: Dict[str, ModelCostInfo] = { 33 | "gpt-5.2-2025-12-11": ModelCostInfo( 34 | Decimal("1.75") / Decimal("1000000"), 35 | Decimal("14") / Decimal("1000000"), 36 | Decimal("0.1"), 37 | ), 38 | "gpt-5.1-2025-11-13": ModelCostInfo( 39 | Decimal("1.25") / Decimal("1000000"), 40 | Decimal("10") / Decimal("1000000"), 41 | Decimal("0.1"), 42 | ), 43 | "gpt-5-2025-08-07": ModelCostInfo( 44 | Decimal("1.25") / Decimal("1000000"), 45 | Decimal("10") / Decimal("1000000"), 46 | Decimal("0.1"), 47 | ), 48 | "gpt-5.2-chat-latest": ModelCostInfo( 49 | Decimal("1.75") / Decimal("1000000"), 50 | Decimal("14") / Decimal("1000000"), 51 | Decimal("0.1"), 52 | ), 53 | "gpt-5.1-chat-latest": ModelCostInfo( 54 | Decimal("1.25") / Decimal("1000000"), 55 | Decimal("10") / Decimal("1000000"), 56 | Decimal("0.1"), 57 | ), 58 | "gpt-5-chat-latest": ModelCostInfo( 59 | Decimal("1.25") / Decimal("1000000"), 60 | Decimal("10") / Decimal("1000000"), 61 | Decimal("0.1"), 62 | ), 63 | "gpt-5-search-api-2025-10-14": ModelCostInfo( 64 | Decimal("1.25") / Decimal("1000000"), 65 | Decimal("10") / Decimal("1000000"), 66 | Decimal("0.1"), 67 | ), 68 | "o3-2025-04-16": ModelCostInfo( 69 | Decimal("2") / Decimal("1000000"), 70 | Decimal("8") / Decimal("1000000"), 71 | Decimal("0.25"), 72 | ), 73 | "o1-2024-12-17": ModelCostInfo( 74 | Decimal("15") / Decimal("1000000"), 75 | Decimal("60") / Decimal("1000000"), 76 | Decimal("0.5"), 77 | ), 78 | "o1-preview-2024-09-12": ModelCostInfo( 79 | Decimal("15") / Decimal("1000000"), 80 | Decimal("60") / Decimal("1000000"), 81 | Decimal("0.5"), 82 | ), 83 | "gpt-5-mini-2025-08-07": ModelCostInfo( 84 | Decimal("0.25") / Decimal("1000000"), 85 | Decimal("2") / Decimal("1000000"), 86 | Decimal("0.1"), 87 | ), 88 | "gpt-5-nano-2025-08-07": ModelCostInfo( 89 | Decimal("0.05") / Decimal("1000000"), 90 | Decimal("0.4") / Decimal("1000000"), 91 | Decimal("0.1"), 92 | ), 93 | "o4-mini-2025-04-16": ModelCostInfo( 94 | Decimal("1.1") / Decimal("1000000"), 95 | Decimal("4.4") / Decimal("1000000"), 96 | Decimal("0.25"), 97 | ), 98 | "o3-mini-2025-01-31": ModelCostInfo( 99 | Decimal("1.1") / Decimal("1000000"), 100 | Decimal("4.4") / Decimal("1000000"), 101 | Decimal("0.5"), 102 | ), 103 | "o1-mini-2024-09-12": ModelCostInfo( 104 | Decimal("3") / Decimal("1000000"), 105 | Decimal("12") / Decimal("1000000"), 106 | Decimal("0.5"), 107 | ), 108 | "gpt-4.1-2025-04-14": ModelCostInfo( 109 | Decimal("2") / Decimal("1000000"), 110 | Decimal("8") / Decimal("1000000"), 111 | Decimal("0.25"), 112 | ), 113 | "gpt-4.5-preview-2025-02-27": ModelCostInfo( 114 | Decimal("75") / Decimal("1000000"), 115 | Decimal("150") / Decimal("1000000"), 116 | Decimal("0.5"), 117 | ), 118 | "gpt-4o-audio-preview-2025-06-03": ModelCostInfo( 119 | Decimal("2.5") / Decimal("1000000"), 120 | Decimal("10") / Decimal("1000000"), 121 | Decimal("0.5"), 122 | ), 123 | "gpt-4o-audio-preview-2024-12-17": ModelCostInfo( 124 | Decimal("2.5") / Decimal("1000000"), 125 | Decimal("10") / Decimal("1000000"), 126 | Decimal("0.5"), 127 | ), 128 | "gpt-4o-2024-11-20": ModelCostInfo( 129 | Decimal("2.5") / Decimal("1000000"), 130 | Decimal("10") / Decimal("1000000"), 131 | Decimal("0.5"), 132 | ), 133 | "gpt-4o-2024-08-06": ModelCostInfo( 134 | Decimal("2.5") / Decimal("1000000"), 135 | Decimal("10") / Decimal("1000000"), 136 | Decimal("0.5"), 137 | ), 138 | "gpt-4o-2024-05-13": ModelCostInfo( 139 | Decimal("5") / Decimal("1000000"), 140 | Decimal("15") / Decimal("1000000"), 141 | Decimal("0.5"), 142 | ), 143 | "gpt-4.1-mini-2025-04-14": ModelCostInfo( 144 | Decimal("0.4") / Decimal("1000000"), 145 | Decimal("1.6") / Decimal("1000000"), 146 | Decimal("0.25"), 147 | ), 148 | "gpt-4.1-nano-2025-04-14": ModelCostInfo( 149 | Decimal("0.1") / Decimal("1000000"), 150 | Decimal("0.4") / Decimal("1000000"), 151 | Decimal("0.25"), 152 | ), 153 | "gpt-4o-mini-2024-07-18": ModelCostInfo( 154 | Decimal("0.15") / Decimal("1000000"), 155 | Decimal("0.6") / Decimal("1000000"), 156 | Decimal("0.5"), 157 | ), 158 | "gpt-4-turbo-2024-04-09": ModelCostInfo( 159 | Decimal("10") / Decimal("1000000"), 160 | Decimal("30") / Decimal("1000000"), 161 | Decimal("0.5"), 162 | ), 163 | "gpt-4-0125-preview": ModelCostInfo( 164 | Decimal("10") / Decimal("1000000"), 165 | Decimal("30") / Decimal("1000000"), 166 | Decimal("0.5"), 167 | ), 168 | "gpt-4-1106-preview": ModelCostInfo( 169 | Decimal("10") / Decimal("1000000"), 170 | Decimal("30") / Decimal("1000000"), 171 | Decimal("0.5"), 172 | ), 173 | "gpt-4-1106-vision-preview": ModelCostInfo( 174 | Decimal("10") / Decimal("1000000"), 175 | Decimal("30") / Decimal("1000000"), 176 | Decimal("0.5"), 177 | ), 178 | "gpt-4-0613": ModelCostInfo( 179 | Decimal("30") / Decimal("1000000"), 180 | Decimal("60") / Decimal("1000000"), 181 | Decimal("0.5"), 182 | ), 183 | "gpt-3.5-turbo-0125": ModelCostInfo( 184 | Decimal("0.5") / Decimal("1000000"), 185 | Decimal("1.5") / Decimal("1000000"), 186 | Decimal("0.5"), 187 | ), 188 | "gpt-3.5-turbo-1106": ModelCostInfo( 189 | Decimal("1") / Decimal("1000000"), 190 | Decimal("2") / Decimal("1000000"), 191 | Decimal("0.5"), 192 | ), 193 | "gpt-3.5-turbo-0613": ModelCostInfo( 194 | Decimal("1.5") / Decimal("1000000"), 195 | Decimal("2") / Decimal("1000000"), 196 | Decimal("0.5"), 197 | ), 198 | "gpt-3.5-turbo-16k-0613": ModelCostInfo( 199 | Decimal("3") / Decimal("1000000"), 200 | Decimal("4") / Decimal("1000000"), 201 | Decimal("0.5"), 202 | ), 203 | "gpt-3.5-turbo-0301": ModelCostInfo( 204 | Decimal("1.5") / Decimal("1000000"), 205 | Decimal("2") / Decimal("1000000"), 206 | Decimal("0.5"), 207 | ), 208 | } 209 | 210 | 211 | class OpenAI(LLMProvider): 212 | SUPPORTED_FEATURES = ( 213 | LLMProviderFeature.MESSAGE_NAME_FIELD 214 | | LLMProviderFeature.RESPONSE_STREAMING 215 | ) 216 | 217 | _reasoning_model_expr = re.compile(r"^o\d.*$") 218 | 219 | def __init__(self, client, *args, **kwargs): 220 | self._client = client 221 | try: 222 | self._models = {m.id for m in self._client.models.list().data} 223 | except openai.NotFoundError: 224 | # Some OpenAI-like APIs implement a chat completions endpoint but 225 | # don't offer a list of models. 226 | # For these APIs, disable model validation. 227 | self._models = None 228 | super().__init__(*args, **kwargs) 229 | self._stream = True 230 | 231 | @classmethod 232 | def from_config(cls, conf: Dict): 233 | SPECIAL_OPTS = ( 234 | "model", 235 | "provider", 236 | ) 237 | model = conf.get("model") 238 | client_opts = {k: v for k, v in conf.items() if k not in SPECIAL_OPTS} 239 | try: 240 | client = openai.OpenAI(**client_opts) 241 | except openai.OpenAIError as e: 242 | # Import late to avoid circular import 243 | from ..config import ConfigError 244 | 245 | raise ConfigError(str(e)) from e 246 | return cls(client, model=model) 247 | 248 | def _message_to_openai(self, msg: Message) -> Dict[str, Any]: 249 | res = { 250 | "role": ( 251 | "developer" 252 | if self.__class__._reasoning_model_expr.match(self.model) 253 | and msg.role == MessageRole.SYSTEM 254 | else msg.role 255 | ) 256 | } 257 | if msg.name: 258 | res["name"] = msg.name 259 | if msg.attachments: 260 | res["content"] = [ 261 | {"type": "text", "text": msg.content}, 262 | *[self.format_attachment(a) for a in msg.attachments], 263 | ] 264 | else: 265 | res["content"] = msg.content 266 | return res 267 | 268 | @staticmethod 269 | def _estimate_cost_in_cents( 270 | model: str, 271 | prompt_tokens: int, 272 | cached_prompt_tokens: int, 273 | sampled_tokens: int, 274 | ) -> Optional[Decimal]: 275 | info = OPENAI_COSTS.get(model) 276 | if not info: 277 | return None 278 | cached_prompt_scale = info.prompt_scale * info.cache_discount_factor 279 | prompt_tokens = max(0, prompt_tokens) 280 | cached_prompt_tokens = min(max(cached_prompt_tokens, 0), prompt_tokens) 281 | uncached_prompt_tokens = prompt_tokens - cached_prompt_tokens 282 | sampled_tokens = max(0, sampled_tokens) 283 | return ( 284 | Decimal(uncached_prompt_tokens) * info.prompt_scale 285 | + Decimal(cached_prompt_tokens) * cached_prompt_scale 286 | + Decimal(sampled_tokens) * info.sampled_scale 287 | ) * Decimal("100") 288 | 289 | def complete(self, messages: Sequence[Message]) -> LLMResponse: 290 | kwargs = { 291 | "model": self.model, 292 | "messages": [self._message_to_openai(m) for m in messages], 293 | "stream": self.stream, 294 | **self.validate_api_params(self.api_params), 295 | } 296 | if self.stream: 297 | # Enable usage statistics 298 | kwargs["stream_options"] = {"include_usage": True} 299 | if kwargs["model"] == "gpt-4-vision-preview": 300 | # For some unknown reason, OpenAI sets a very low 301 | # default max_tokens. For consistency with other models, 302 | # set it to the maximum if not overridden by the user. 303 | kwargs.setdefault("max_tokens", 4096) 304 | try: 305 | resp = self._client.chat.completions.create(**kwargs) 306 | except openai.OpenAIError as e: 307 | raise CompletionError(str(e)) from e 308 | if isinstance(resp, openai.Stream): 309 | return StreamedOpenAIResponse(resp, self) 310 | if resp.choices is None: 311 | raise CompletionError("Empty response (no choices specified)") 312 | elif len(resp.choices) != 1: 313 | raise CompletionError( 314 | f"Unexpected number of choices ({len(resp.choices)}) from" 315 | " OpenAI response" 316 | ) 317 | choice = resp.choices[0] 318 | prompt_tokens = resp.usage.prompt_tokens 319 | prompt_tokens_details = getattr( 320 | resp.usage, "prompt_tokens_details", None 321 | ) 322 | if prompt_tokens_details is None: 323 | cached_prompt_tokens = 0 324 | else: 325 | cached_prompt_tokens = prompt_tokens_details.cached_tokens 326 | sampled_tokens = resp.usage.completion_tokens 327 | 328 | return LLMResponse( 329 | message=Message( 330 | content=choice.message.content, 331 | role=MessageRole(choice.message.role), 332 | ), 333 | prompt_tokens=prompt_tokens, 334 | sampled_tokens=sampled_tokens, 335 | cost_in_cents=self.__class__._estimate_cost_in_cents( 336 | model=resp.model, 337 | prompt_tokens=prompt_tokens, 338 | cached_prompt_tokens=cached_prompt_tokens, 339 | sampled_tokens=sampled_tokens, 340 | ), 341 | ) 342 | 343 | def validate_api_params(self, params): 344 | SPECIAL_OPTS = frozenset( 345 | ("model", "messages", "stream", "n", "stream_options") 346 | ) 347 | valid_opts = ( 348 | frozenset( 349 | inspect.signature( 350 | self._client.chat.completions.create 351 | ).parameters.keys() 352 | ) 353 | - SPECIAL_OPTS 354 | ) 355 | for opt in params: 356 | if opt not in valid_opts: 357 | raise InvalidAPIParameterError(f"Unknown parameter {opt}") 358 | return params 359 | 360 | @property 361 | def stream(self) -> bool: 362 | return self._stream 363 | 364 | @stream.setter 365 | def stream(self, val: bool): 366 | self._stream = val 367 | 368 | @property 369 | def valid_models(self) -> Iterable[str]: 370 | return self._models 371 | 372 | def get_best_model(self): 373 | BEST_MODELS = ( 374 | "gpt-4.1", 375 | "gpt-4o", 376 | "gpt-4-turbo", 377 | "gpt-4.1-mini", 378 | "gpt-4o-mini", 379 | "gpt-4", 380 | "gpt-3.5-turbo", 381 | ) 382 | res = next( 383 | (model for model in BEST_MODELS if model in self.valid_models), 384 | None, 385 | ) 386 | if res is None: 387 | raise RuntimeError( 388 | "No known GPT model available! If this is an OpenAI-like API, " 389 | "set the model explicitly" 390 | ) 391 | else: 392 | return res 393 | 394 | 395 | class AzureAI(OpenAI): 396 | AZURE_API_VERSION = "2024-06-01" 397 | 398 | @classmethod 399 | def from_config(cls, conf): 400 | SPECIAL_OPTS = ( 401 | "model", 402 | "provider", 403 | "api_version", 404 | ) 405 | model = conf.get("model") 406 | client_opts = {k: v for k, v in conf.items() if k not in SPECIAL_OPTS} 407 | client_opts["api_version"] = cls.AZURE_API_VERSION 408 | endpoint = client_opts.pop("endpoint", None) 409 | if endpoint: 410 | client_opts["azure_endpoint"] = endpoint 411 | client = openai.AzureOpenAI(**client_opts) 412 | return cls(client, model=model) 413 | 414 | 415 | @OpenAI.register_attachment_formatter(Image) 416 | def format_image_for_openai(img: Image) -> Dict[str, Any]: 417 | res = {"type": "image_url", "image_url": {"url": img.url}} 418 | if img.detail is not None: 419 | res["image_url"]["detail"] = img.detail 420 | return res 421 | 422 | 423 | @OpenAI.register_attachment_formatter(Audio) 424 | def format_audio_for_openai(a: Audio) -> Dict[str, Any]: 425 | res: Dict[str, Any] = { 426 | "type": "input_audio", 427 | "input_audio": {"data": a.b64}, 428 | } 429 | ext = mimetypes.guess_extension(a.mimetype) 430 | if ext: 431 | res["input_audio"]["format"] = ext.lstrip(".") 432 | return res 433 | 434 | 435 | class StreamedOpenAIResponse(LLMResponse): 436 | def __init__(self, backing_stream: openai.Stream, provider: OpenAI): 437 | self._stream = backing_stream 438 | self._provider = provider 439 | 440 | m = Message(content="", role="") 441 | super().__init__(m) 442 | 443 | def __iter__(self): 444 | return self 445 | 446 | def __next__(self): 447 | try: 448 | chunk = next(self._stream) 449 | except openai.OpenAIError as e: 450 | raise CompletionError(str(e)) from e 451 | if chunk is None: 452 | return "" 453 | if chunk.usage: 454 | prompt_tokens = chunk.usage.prompt_tokens 455 | prompt_tokens_details = getattr( 456 | chunk.usage, "prompt_tokens_details", None 457 | ) 458 | if prompt_tokens_details is None: 459 | cached_prompt_tokens = 0 460 | else: 461 | cached_prompt_tokens = prompt_tokens_details.cached_tokens 462 | sampled_tokens = chunk.usage.completion_tokens 463 | self.prompt_tokens = prompt_tokens 464 | self.sampled_tokens = sampled_tokens 465 | self.cost_in_cents = ( 466 | self._provider.__class__._estimate_cost_in_cents( 467 | model=chunk.model, 468 | prompt_tokens=prompt_tokens, 469 | cached_prompt_tokens=cached_prompt_tokens, 470 | sampled_tokens=sampled_tokens, 471 | ) 472 | ) 473 | if chunk.choices is None or len(chunk.choices) != 1: 474 | return "" 475 | delta = chunk.choices[0].delta 476 | if delta is None: 477 | return "" 478 | if delta.role and delta.role != self.message.role: 479 | self.message.role += delta.role 480 | if delta.content: 481 | self.message.content += delta.content 482 | return delta.content 483 | else: 484 | return "" 485 | -------------------------------------------------------------------------------- /src/gptcmd/llm/README.md: -------------------------------------------------------------------------------- 1 | # Large language model providers 2 | 3 | Gptcmd uses instances of the `LLMProvider` abstract class to interact with large language models (LLMs). This document describes the `LLMProvider` abstract class and supporting infrastructure and demonstrates how to implement a simple custom provider. 4 | 5 | ## Overview 6 | 7 | `gptcmd.llm.LLMProvider` is an [abstract base class](https://docs.python.org/3/glossary.html#term-abstract-base-class) located in `src/gptcmd/llm/__init__.py`. It defines the interface that all LLM providers must implement to work with Gptcmd. Below is an overview of the main components. 8 | 9 | ### Key methods 10 | 11 | #### `from_config(cls, conf: Dict) -> LLMProvider` 12 | 13 | * **Purpose**: A class method that instantiates the LLMProvider from a user configuration dictionary. 14 | * **Usage**: This class method is used by the configuration system to instantiate `LLMProvider` classes. 15 | 16 | #### `complete(self, messages: Sequence[Message]) -> LLMResponse` 17 | 18 | * **Purpose**: Generate a response from the LLM given a collection of `Message` objects. 19 | * **Usage**: This method should contain the logic that calls your LLM API and converts its response into an `LLMResponse` object. 20 | 21 | #### `validate_api_params(self, params: Dict[str, Any]) -> Dict[str, Any]` 22 | 23 | * **Purpose**: Validate and sanitize API parameters provided by the user. 24 | * **Usage**: Ensure that only valid parameters are accepted and that they are within acceptable ranges or formats. This method should raise `InvalidAPIParameterError` for unknown parameter values or values that cannot be sanitized programmatically. 25 | 26 | #### `get_best_model(self) -> str` 27 | 28 | * **Purpose**: Return the name of the most capable model offered by this provider. 29 | * **Usage**: This method helps in selecting a default model if none is otherwise configured. 30 | 31 | #### `valid_models(self) -> Optional[Iterable[str]]` 32 | 33 | * **Purpose**: Provide a collection of valid model names that can be used with this provider. If a list of valid models cannot be determined in this session, return `None`. 34 | * **Usage**: Used during validation when switching the active model. 35 | 36 | ### Supporting classes and exceptions 37 | 38 | #### `gptcmd.message.Message` 39 | 40 | * **Purpose**: A [`dataclass`](https://docs.python.org/3/library/dataclasses.html) representing a message written by the user or LLM. 41 | * **Usage**: Used throughout the application. 42 | 43 | ##### Key fields 44 | 45 | Field | Type | Description 46 | --- | --- | --- 47 | `content` | `str` | The text of the message. 48 | `role` | `gptcmd.message.MessageRole` | The conversational role of this message, such as `gptcmd.message.MessageRole.USER`. 49 | `name` | `Optional[str]` | The user-provided name for this message. 50 | `attachments` | `List[gptcmd.message.MessageAttachment]` | A list of rich attachments, such as images, associated with this message. 51 | `metadata` | `Dict[str, Any]` | A dictionary of arbitrary metadata associated with this message, which can be used to get or set data particular to a specific provider (such as reasoning text, a digital signature, user requests for special handling, etc.). Since `metadata` is a field on `Message`, it can be accessed by any provider: it may be wise to, say, prefix metadata keys with the `LLMProvider`'s entry point name and an underscore for namespacing. Metadata values must be JSON serializable. 52 | 53 | #### `gptcmd.llm.LLMResponse` 54 | 55 | * **Purpose**: A [`dataclass`](https://docs.python.org/3/library/dataclasses.html) containing a `Message` generated by the `LLMProvider` in response to a user request, as well as optional metadata like token counts and cost estimates. 56 | * **Usage**: Return this from your `complete` method. 57 | 58 | ##### Key fields 59 | 60 | Field | Type | Description 61 | --- | --- | --- 62 | `message` | `gptcmd.message.Message` | The `Message` object containing the LLM's response to a user query. 63 | `prompt_tokens` | `Optional[int]` | The number of tokens, as determined by the LLM's tokenizer, which the request (context) that generated this response contains. 64 | `sampled_tokens` | `Optional[int]` | The number of tokens, as determined by the LLM's tokenizer, which this response contains. 65 | `cost_in_cents` | `Optional[Union[int, Decimal]]` | An estimate of the cost, in US cents, of the request that generated this response. 66 | 67 | #### Exceptions 68 | 69 | * **`gptcmd.config.ConfigError`**: Raised by the `from_config` method when the provider cannot be configured. 70 | * **`gptcmd.llm.CompletionError`**: Raised by the `complete` method when the LLM cannot generate a response. 71 | * **`gptcmd.llm.InvalidAPIParameterError`**: Raised by the `validate_api_params` method when invalid API parameters are provided. 72 | 73 | ## Building an `LLMProvider` 74 | 75 | To show how the process works, we'll build a simple `LLMProvider` implementation that mostly just responds with a copy of the user's request. To start, create a directory called `gptcmd-echo-provider`. 76 | 77 | ### Packaging metadata 78 | 79 | In your `gptcmd-echo-provider` directory, create a file called `pyproject.toml` with the following content: 80 | 81 | ``` toml 82 | [build-system] 83 | requires = ["setuptools>=61.0"] 84 | build-backend = "setuptools.build_meta" 85 | 86 | [project] 87 | name = "gptcmd-echo-provider" 88 | version = "0.1.0" 89 | dependencies = ["gptcmd >= 2.0.0"] 90 | ``` 91 | 92 | More information about the `pyproject.toml` format can be found in the [relevant section of the Python Packaging Tutorial](https://packaging.python.org/en/latest/guides/writing-pyproject-toml/). 93 | 94 | Gptcmd uses a [packaging entry point](https://packaging.python.org/en/latest/specifications/entry-points/) to find external providers. The name of the entry point corresponds to the value of the `provider` option used to select it in a user account configuration. Add this to the end of `pyproject.toml`, which will make our new provider selectable with `provider="echo"` in an account configuration table: 95 | 96 | ``` toml 97 | [project.entry-points."gptcmd.providers"] 98 | echo = "gptcmd_echo_provider.echo:EchoProvider" 99 | ``` 100 | 101 | Create an `src` directory inside `gptcmd-echo-provider`. Inside that directory, create a subdirectory called `gptcmd_echo_provider`. Create an empty file at `gptcmd-echo-provider/src/gptcmd_echo_provider/__init__.py` so that Python considers this directory a package. 102 | 103 | ### Provider implementation 104 | 105 | Create a new file, `gptcmd-echo-provider/src/gptcmd_echo_provider/echo.py`, with the following content: 106 | 107 | ``` python 108 | from typing import Any, Dict, Iterable, Sequence 109 | 110 | from gptcmd.llm import ( 111 | CompletionError, 112 | InvalidAPIParameterError, 113 | LLMProvider, 114 | LLMResponse, 115 | ) 116 | from gptcmd.message import Message, MessageRole 117 | 118 | 119 | class EchoProvider(LLMProvider): 120 | @classmethod 121 | def from_config(cls, conf: Dict[str, Any]) -> "EchoProvider": 122 | # No config options supported 123 | return cls() 124 | 125 | def validate_api_params(self, params: Dict[str, Any]) -> Dict[str, Any]: 126 | raise InvalidAPIParameterError("API parameters are unsupported") 127 | ``` 128 | 129 | #### Implementing `complete` 130 | 131 | For this provider, the `complete` method just returns a copy of whatever the user said last. Add this to `echo.py` (inside the `EchoProvider` class): 132 | 133 | ``` python 134 | def complete(self, messages: Sequence[Message]) -> LLMResponse: 135 | for msg in reversed(messages): 136 | if msg.role == MessageRole.USER: 137 | return LLMResponse( 138 | Message(content=msg.content, role=MessageRole.ASSISTANT) 139 | ) 140 | # We never reached a user message, so just throw an error 141 | raise CompletionError("Nothing to echo!") 142 | ``` 143 | 144 | #### Implementing model selection 145 | 146 | Since this provider is just an example, we'll only support one model called `echo-1`. If this provider made multiple models available, we would provide the full list of options in the `valid_models` method. The currently selected model is available on the `model` attribute of an `LLMProvider` instance. Add this to `echo.py` (inside the class): 147 | 148 | ``` python 149 | def get_best_model(self) -> str: 150 | return "echo-1" 151 | 152 | @property 153 | def valid_models(self) -> Iterable[str]: 154 | return ("echo-1",) 155 | ``` 156 | 157 | ### Testing the provider 158 | 159 | Let's install, configure, and try out the new provider. From the `gptcmd-echo-provider` directory, run `pip install .` to install the provider package. During provider development, you might want to do an [editable install](https://pip.pypa.io/en/latest/topics/local-project-installs/) (`pip install -e .`) so that you don't need to reinstall the package after each change. 160 | 161 | After the provider is installed, add a new account to your configuration file: 162 | 163 | ``` toml 164 | [accounts.echotest] 165 | provider="echo" 166 | ``` 167 | 168 | Start Gptcmd and test the provider: 169 | 170 | ``` 171 | (gpt-4o) account echotest 172 | Switched to account 'echotest' 173 | (echo-1) say Hello, world! 174 | ... 175 | Hello, world! 176 | ``` 177 | 178 | ### Optional features 179 | 180 | #### User configuration 181 | 182 | Configuration values can be extracted and passed to the created `LLMProvider` instance from its `from_config` constructor. For instance, we can add a configuration option to echo messages in reverse. First, add a constructor to the `EchoProvider` class: 183 | 184 | ``` python 185 | def __init__(self, backwards: bool = False, *args, **kwargs): 186 | self.backwards = backwards 187 | super().__init__(*args, **kwargs) 188 | ``` 189 | 190 | Then, replace the `from_config` method with: 191 | 192 | ``` python 193 | @classmethod 194 | def from_config(cls, conf: Dict[str, Any]) -> "EchoProvider": 195 | return cls(backwards=conf.get("backwards")) 196 | ``` 197 | 198 | In this example, `from_config` always succeeds. If `from_config` might throw an error (for instance, due to invalid user input, failed network requests, etc.), the method should raise `ConfigError` (in the `gptcmd.config` module). 199 | 200 | Now, modify `complete`: 201 | 202 | ``` python 203 | def complete(self, messages: Sequence[Message]) -> LLMResponse: 204 | for msg in reversed(messages): 205 | if msg.role == MessageRole.USER: 206 | content = msg.content[::-1] if self.backwards else msg.content 207 | return LLMResponse( 208 | Message(content=content, role=MessageRole.ASSISTANT) 209 | ) 210 | # We never reached a user message, so just throw an error 211 | raise CompletionError("Nothing to echo!") 212 | ``` 213 | 214 | By default, the provider outputs messages as-is (in forward order): 215 | 216 | ``` 217 | (echo-1) say Hello, world! 218 | ... 219 | Hello, world! 220 | ``` 221 | 222 | However, when we add `backwards=true` to the account configuration: 223 | 224 | ``` toml 225 | [accounts.echotest] 226 | provider="echo" 227 | backwards=true 228 | ``` 229 | 230 | We get: 231 | 232 | ``` 233 | (echo-1) say Hello, world! 234 | ... 235 | !dlrow ,olleH 236 | (echo-1) say Was it Eliot's toilet I saw? 237 | ... 238 | ?was I teliot s'toilE ti saW 239 | ``` 240 | 241 | #### API parameters 242 | 243 | To support API parameters, implement `validate_api_params`. We'll add a parameter to control how many times the user's message is echoed. Replace the `validate_api_params` method with: 244 | 245 | ``` python 246 | def validate_api_params(self, params: Dict[str, Any]) -> Dict[str, Any]: 247 | # Examine the provided parameters 248 | for param, value in params.items(): 249 | if param == "repeat": 250 | if not isinstance(value, int): 251 | raise InvalidAPIParameterError("Repeat must be an integer") 252 | # We must echo at least one time 253 | # If the user provides zero or a negative number, set it to 1 254 | params["repeat"] = max(1, value) 255 | else: # An unsupported parameter 256 | raise InvalidAPIParameterError( 257 | f"Parameter {param!r} not supported" 258 | ) 259 | return params 260 | ``` 261 | 262 | Implement support for the new parameter in `complete`: 263 | 264 | ``` python 265 | def complete(self, messages: Sequence[Message]) -> LLMResponse: 266 | for msg in reversed(messages): 267 | if msg.role == MessageRole.USER: 268 | content = msg.content 269 | if "repeat" in self.api_params: 270 | content *= self.api_params["repeat"] 271 | return LLMResponse( 272 | Message(content=content, role=MessageRole.ASSISTANT) 273 | ) 274 | # We never reached a user message, so just throw an error 275 | raise CompletionError("Nothing to echo!") 276 | ``` 277 | 278 | Test it: 279 | 280 | ``` 281 | (echo-1) say hello 282 | ... 283 | hello 284 | (echo-1) set repeat 3 285 | repeat set to 3 286 | (echo-1) retry 287 | ... 288 | hellohellohello 289 | (echo-1) set repeat -1 290 | repeat set to 1 291 | (echo-1) retry 292 | ... 293 | hello 294 | (echo-1) unset repeat 295 | repeat unset 296 | (echo-1) retry 297 | ... 298 | hello 299 | ``` 300 | 301 | ##### Default parameters 302 | 303 | To define default values for API parameters, update the constructor to set them, and override `unset_api_param` to restore the default value when a default parameter is unset. We'll set a default value of 1 for the `repeat` parameter. Add a class variable to the `EchoProvider` class: 304 | 305 | ``` python 306 | class EchoProvider(LLMProvider): 307 | DEFAULT_API_PARAMS: Dict[str, Any] = {"repeat": 1} 308 | # ... 309 | ``` 310 | 311 | Next, add a constructor: 312 | 313 | ``` python 314 | def __init__(self, *args, **kwargs): 315 | super().__init__(*args, **kwargs) 316 | self.update_api_params(self.__class__.DEFAULT_API_PARAMS) 317 | ``` 318 | 319 | And override `unset_api_param`: 320 | 321 | ``` python 322 | def unset_api_param(self, key: Optional[str] = None) -> None: 323 | super().unset_api_param(key) 324 | if key in self.__class__.DEFAULT_API_PARAMS: 325 | self.set_api_param(key, self.__class__.DEFAULT_API_PARAMS[param]) 326 | elif key is None: # Unset all parameters 327 | self.update_api_params(self.__class__.DEFAULT_API_PARAMS) 328 | ``` 329 | 330 | Then, we can simplify `complete`: 331 | 332 | ``` python 333 | def complete(self, messages: Sequence[Message]) -> LLMResponse: 334 | for msg in reversed(messages): 335 | if msg.role == MessageRole.USER: 336 | return LLMResponse( 337 | Message( 338 | content=msg.content * self.api_params["repeat"], 339 | role=MessageRole.ASSISTANT, 340 | ) 341 | ) 342 | # We never reached a user message, so just throw an error 343 | raise CompletionError("Nothing to echo!") 344 | ``` 345 | 346 | #### Message name field 347 | 348 | If your `LLMProvider` implementation processes the `name` field set on `Message` objects, you'll need to advertise this support. Add a class variable called `SUPPORTED_FEATURES` containing the appropriate member of the `gptcmd.llm.LLMProviderFeature` [flag enumeration](https://docs.python.org/3/library/enum.html#enum.Flag). Import `LLMProviderFeature` from `gptcmd.llm` in your provider's module, then add this inside the class: 349 | 350 | ``` python 351 | SUPPORTED_FEATURES = LLMProviderFeature.MESSAGE_NAME_FIELD 352 | ``` 353 | 354 | If your class implements support for multiple `LLMProviderFeature`s, separate them with a pipe (`|`) character. 355 | 356 | #### Message attachments 357 | 358 | To implement support for message attachments, write a formatter function for each attachment type you support, decorated with the `register_attachment_formatter` decorator on your provider class. For `EchoProvider`, we'll convert images to a simple string representation. First, import `Image` from `gptcmd.message`, then add this function to `echo.py` (outside the class): 359 | 360 | ``` python 361 | @EchoProvider.register_attachment_formatter(Image) 362 | def format_image(img: Image) -> str: 363 | return f"img={img.url}" 364 | ``` 365 | 366 | Now, modify `complete` to process attachments in the correct place. For `EchoProvider`, we'll just add them to the response content. For a more functional provider, you might add them to an API request body: 367 | 368 | ``` python 369 | def complete(self, messages: Sequence[Message]) -> LLMResponse: 370 | for msg in reversed(messages): 371 | if msg.role == MessageRole.USER: 372 | content = msg.content 373 | for a in msg.attachments: 374 | content += "\n" + self.format_attachment(a) 375 | return LLMResponse( 376 | Message(content=content, role=MessageRole.ASSISTANT) 377 | ) 378 | # We never reached a user message, so just throw an error 379 | raise CompletionError("Nothing to echo!") 380 | ``` 381 | 382 | Now, when we attach images, their URLs are echoed: 383 | 384 | ``` 385 | (echo-1) user hello! 386 | 'hello!' added as user 387 | (echo-1) image http://example.com/image.jpg 388 | Image added to 'hello!' 389 | (echo-1) send 390 | ... 391 | hello! 392 | img=http://example.com/image.jpg 393 | ``` 394 | 395 | `Image`, `Audio`, and other similar "file-like" attachment types expose `b64` and `mimetype` as strings, which may be helpful for some APIs. 396 | 397 | #### Streamed responses 398 | 399 | If your `LLMProvider` implementation can stream parts of a response as they are generated, you'll need to advertise this support. Add a class variable called `SUPPORTED_FEATURES` containing the appropriate member of the `gptcmd.llm.LLMProviderFeature` [flag enumeration](https://docs.python.org/3/library/enum.html#enum.Flag). Import `LLMProviderFeature` from `gptcmd.llm` in your provider's module, then add this inside the class: 400 | 401 | ``` python 402 | SUPPORTED_FEATURES = LLMProviderFeature.RESPONSE_STREAMING 403 | ``` 404 | 405 | If your class implements support for multiple `LLMProviderFeature`s, separate them with a pipe (`|`) character. 406 | 407 | The `stream` property on the `LLMProvider` instance will be set to `True` when a response should be streamed. If your `LLMProvider` only supports streaming under certain conditions (such as when certain models are used but not others), override the `stream` property getter to return `False` and the setter to raise `NotImplementedError` with an appropriate message in unsupported scenarios (you can use the already defined `LLMProvider._stream` attribute as a backing field). To enable streaming by default, set the property in your provider's constructor. Then, subclass `LLMResponse` to handle streams. In your `LLMResponse` implementation, you'll want to create a backing `Message` (that you update as the response streams in, so that user disconnections and runtime errors are handled gracefully), and implement an iterator to update this message and yield the text of the next chunk of the stream as a string. In `complete`, return your custom `LLMResponse` when `self.stream == True`. 408 | -------------------------------------------------------------------------------- /src/gptcmd/message.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module contains classes and types for interacting with messages and 3 | message threads. 4 | Copyright 2024 Bill Dengler 5 | This Source Code Form is subject to the terms of the Mozilla Public 6 | License, v. 2.0. If a copy of the MPL was not distributed with this 7 | file, You can obtain one at https://mozilla.org/MPL/2.0/. 8 | """ 9 | 10 | import base64 11 | import binascii 12 | import dataclasses 13 | import mimetypes 14 | import re 15 | import sys 16 | import urllib.parse 17 | import urllib.request 18 | from urllib.error import URLError, HTTPError 19 | from abc import ABC, abstractmethod 20 | from collections.abc import Sequence 21 | from enum import auto 22 | from typing import ( 23 | Any, 24 | Callable, 25 | Dict, 26 | Generic, 27 | Iterable, 28 | List, 29 | Optional, 30 | Tuple, 31 | Type, 32 | TypeVar, 33 | ) 34 | 35 | if sys.version_info >= (3, 11): 36 | from enum import StrEnum 37 | else: 38 | from backports.strenum import StrEnum 39 | 40 | 41 | T = TypeVar("T") 42 | 43 | 44 | class TwoWayRegistrar(Generic[T]): 45 | """ 46 | A registrar that maintains both forward and reverse mappings between keys 47 | and classes. 48 | Ensures a one-to-one relationship and provides reverse lookup. 49 | """ 50 | 51 | def __init__(self): 52 | self._registry: Dict[str, Type[T]] = {} 53 | self._reverse_registry: Dict[Type[T], str] = {} 54 | 55 | def __contains__(self, key: str) -> bool: 56 | return key in self._registry 57 | 58 | def register(self, key: str) -> Callable[[Type[T]], Type[T]]: 59 | """ 60 | Decorator to register a class with the passed-in key. 61 | """ 62 | 63 | def decorator(cls: Type[T]) -> Type[T]: 64 | if key in self._registry: 65 | raise ValueError(f"{key} is already registered") 66 | elif cls in self._reverse_registry: 67 | raise ValueError(f"{cls} is already registered") 68 | self._registry[key] = cls 69 | self._reverse_registry[cls] = key 70 | return cls 71 | 72 | return decorator 73 | 74 | def get(self, key: str) -> Type[T]: 75 | """ 76 | Retrieve a class from the registry by key. 77 | """ 78 | if key not in self._registry: 79 | raise KeyError(f"{key} is not registered") 80 | return self._registry[key] 81 | 82 | def reverse_get(self, cls: Type[T]) -> str: 83 | """ 84 | Retrieve the key associated with a class from the reverse registry. 85 | """ 86 | if cls not in self._reverse_registry: 87 | raise KeyError(f"Class '{cls.__name__}' is not registered") 88 | return self._reverse_registry[cls] 89 | 90 | 91 | attachment_type_registrar: TwoWayRegistrar["MessageAttachment"] = ( 92 | TwoWayRegistrar() 93 | ) 94 | 95 | 96 | class MessageAttachment(ABC): 97 | """ 98 | A non-text component that can be associated with a Message, such as an 99 | image for vision models. 100 | """ 101 | 102 | @classmethod 103 | def from_dict(cls, d: Dict[str, Any]) -> "MessageAttachment": 104 | """ 105 | Instantiate a MessageAttachment from a dict in the format returned by 106 | MessageAttachment.to_dict() 107 | """ 108 | attachment_type_key = d.get("type") 109 | attachment_data = d.get("data", {}) 110 | try: 111 | attachment_type = attachment_type_registrar.get( 112 | attachment_type_key 113 | ) 114 | except KeyError: 115 | return UnknownAttachment( 116 | _type=attachment_type_key, _data=attachment_data 117 | ) 118 | return attachment_type._deserialize(attachment_data) 119 | 120 | @classmethod 121 | @abstractmethod 122 | def _deserialize(cls, d: Dict[str, Any]) -> "MessageAttachment": 123 | "Deserialize a dict into a MessageAttachment subclass instance" 124 | pass 125 | 126 | def to_dict(self) -> Dict[str, Any]: 127 | "Exports this attachment as a serializable dict" 128 | return { 129 | "type": attachment_type_registrar.reverse_get(self.__class__), 130 | "data": self._serialize(), 131 | } 132 | 133 | @abstractmethod 134 | def _serialize(self) -> Dict[str, Any]: 135 | "Serialize this attachment into a dict" 136 | pass 137 | 138 | def __eq__(self, other): 139 | return self.to_dict() == other.to_dict() 140 | 141 | 142 | class FileAttachment(MessageAttachment): 143 | "Base class for file-like attachments" 144 | 145 | def __init__( 146 | self, 147 | url: Optional[str] = None, 148 | b64: Optional[str] = None, 149 | mimetype: Optional[str] = None, 150 | ): 151 | self.url: Optional[str] = None 152 | self._b64: Optional[str] = None 153 | self._mimetype: Optional[str] = None 154 | 155 | if url: 156 | if url.startswith("data:"): 157 | # data:[][;][;base64], 158 | match = re.fullmatch(r"data:([^,]*?),(.*)", url, re.I) 159 | if not match: 160 | raise ValueError(f"Invalid data URL format: {url}") 161 | header, raw_data = match.groups() 162 | parts = header.split(";") 163 | self._mimetype = parts[0] or "application/octet-stream" 164 | is_b64 = any(p.lower() == "base64" for p in parts[1:]) 165 | if is_b64: 166 | self._b64 = raw_data 167 | else: 168 | # URL-encoded data, decode first 169 | decoded_bytes = urllib.parse.unquote_to_bytes(raw_data) 170 | self._b64 = base64.b64encode(decoded_bytes).decode() 171 | self.url = url 172 | elif re.match(r"^[a-z][a-z0-9+\-.]*://", url, re.I): # any scheme 173 | self.url = url 174 | self._mimetype = mimetype 175 | else: 176 | raise ValueError( 177 | "URL must be a data: URL or start with a scheme like" 178 | f" http://), got: {url}" 179 | ) 180 | elif b64 and mimetype: 181 | self._b64 = b64 182 | self._mimetype = mimetype 183 | self.url = f"data:{mimetype};base64,{b64}" 184 | else: 185 | raise ValueError("Provide either url or both b64 and mimetype") 186 | 187 | # Validate base64 data if provided 188 | if self._b64: 189 | try: 190 | base64.b64decode(self._b64, validate=True) 191 | except binascii.Error as exc: 192 | raise ValueError("Invalid base64 data") from exc 193 | 194 | @classmethod 195 | def from_path(cls, path: str, **kwargs): 196 | with open(path, "rb") as fin: 197 | data = base64.b64encode(fin.read()).decode("utf-8") 198 | mime = mimetypes.guess_type(path)[0] or "application/octet-stream" 199 | return cls(b64=data, mimetype=mime, **kwargs) 200 | 201 | @classmethod 202 | def _deserialize(cls, d: Dict[str, Any]): 203 | return cls(url=d["url"]) 204 | 205 | @property 206 | def b64(self) -> str: 207 | if self._b64 is None: 208 | try: 209 | with urllib.request.urlopen(self.url, timeout=20) as resp: 210 | raw = resp.read() 211 | except (URLError, HTTPError) as exc: 212 | raise ValueError( 213 | f"Unable to fetch data from {self.url}" 214 | ) from exc 215 | self._b64 = base64.b64encode(raw).decode("utf-8") 216 | if self._mimetype is None: 217 | ctype = resp.headers.get("Content-Type") 218 | if ctype: 219 | self._mimetype = ctype.split(";")[0].strip() 220 | return self._b64 221 | 222 | @property 223 | def mimetype(self) -> str: 224 | if self._mimetype is None: 225 | # First try to guess from the URL path 226 | guess = mimetypes.guess_type(urllib.parse.urlparse(self.url).path)[ 227 | 0 228 | ] 229 | if guess: 230 | self._mimetype = guess 231 | else: 232 | # Last resort: trigger a fetch which may set _mimetype from 233 | # the Content-Type header. 234 | _ = self.b64 235 | self._mimetype = self._mimetype or "application/octet-stream" 236 | return self._mimetype 237 | 238 | def _serialize(self) -> Dict[str, Any]: 239 | return {"url": self.url} 240 | 241 | def __eq__(self, other): 242 | """ 243 | Equality is based on the actual file content (and mimetype) rather than 244 | the original URL so that semantically identical attachments 245 | compare equal even when their source URLs differ. 246 | """ 247 | if not isinstance(other, FileAttachment): 248 | return NotImplemented 249 | return self.mimetype == other.mimetype and self.b64 == other.b64 250 | 251 | def __hash__(self): 252 | return hash((self.mimetype, self.b64)) 253 | 254 | 255 | @attachment_type_registrar.register("image_url") 256 | class Image(FileAttachment): 257 | "An image URL that can be fetched by the LLM API." 258 | 259 | def __init__(self, *args, detail: Optional[str] = None, **kwargs): 260 | super().__init__(*args, **kwargs) 261 | self.detail = detail 262 | 263 | @classmethod 264 | def from_path(cls, path: str, detail: Optional[str] = None): 265 | return super().from_path(path, detail=detail) 266 | 267 | def _serialize(self) -> Dict[str, Any]: 268 | res = super()._serialize() 269 | if self.detail is not None: 270 | res["detail"] = self.detail 271 | return res 272 | 273 | @classmethod 274 | def _deserialize(cls, d: Dict[str, Any]) -> "Image": 275 | return cls(url=d["url"], detail=d.get("detail")) 276 | 277 | 278 | @attachment_type_registrar.register("audio_url") 279 | class Audio(FileAttachment): 280 | "An audio file." 281 | 282 | pass 283 | 284 | 285 | class UnknownAttachment(MessageAttachment): 286 | """ 287 | A MessageAttachment created when a dict in the form returned by 288 | MessageAttachment.to_dict contains an unknown or ambiguous type. 289 | This class should not be instantiated directly. 290 | """ 291 | 292 | def __init__(self, _type: str, _data: Dict): 293 | self.data = _data 294 | self.type = _type 295 | 296 | @classmethod 297 | def _deserialize(cls, data): 298 | return cls(_type=None, _data=data) 299 | 300 | def _serialize(self): 301 | return self.data.copy() 302 | 303 | def to_dict(self): 304 | # Since these attachments are explicitly not registered, use our 305 | # internal type field instead of the registrar. 306 | return {"type": self.type, "data": self._serialize()} 307 | 308 | 309 | class MessageRole(StrEnum): 310 | """ 311 | An enumeration defining valid values for the role attribute on 312 | Message objects 313 | """ 314 | 315 | USER = auto() 316 | ASSISTANT = auto() 317 | SYSTEM = auto() 318 | 319 | 320 | @dataclasses.dataclass 321 | class Message: 322 | """A message sent to or received from an LLM.""" 323 | 324 | #: The text content of the message 325 | content: str 326 | #: a member of MessageRole that defines the conversational role of the 327 | #: author of this message 328 | role: MessageRole 329 | #: The name of the author of this message 330 | name: Optional[str] = None 331 | #: Whether this message is "sticky" (not affected by thread-level deletion 332 | #: operations) 333 | sticky: bool = False 334 | #: A collection of attached objects, such as images 335 | attachments: List[MessageAttachment] = dataclasses.field( 336 | default_factory=list 337 | ) 338 | #: Arbitrary metadata for this message 339 | metadata: Dict[str, Any] = dataclasses.field(default_factory=dict) 340 | 341 | @classmethod 342 | def from_dict(cls, d: Dict[str, Any]): 343 | """ 344 | Instantiate a Message from a dict in the format returned by 345 | Message.to_dict() 346 | """ 347 | valid_keys = [f.name for f in dataclasses.fields(cls)] 348 | kwargs = {} 349 | for k, v in d.items(): 350 | if k == "attachments": 351 | kwargs[k] = [MessageAttachment.from_dict(i) for i in v] 352 | elif k == "role": 353 | kwargs[k] = MessageRole(v) 354 | elif k == "_sticky": # v1 sticky field 355 | kwargs["sticky"] = v 356 | elif k in valid_keys: 357 | kwargs[k] = v 358 | return cls(**kwargs) 359 | 360 | def to_dict(self) -> Dict[str, Any]: 361 | "Exports this message as a serializable dict" 362 | res = {f.name: getattr(self, f.name) for f in dataclasses.fields(self)} 363 | res["attachments"] = [a.to_dict() for a in self.attachments] 364 | return res 365 | 366 | @property 367 | def display_indicators(self): 368 | """ 369 | Returns indicators for various states (sticky, has attachments, etc.) 370 | for use in thread rendering and similar display scenarios. 371 | """ 372 | return "*" * self.sticky + "@" * len(self.attachments) 373 | 374 | 375 | class PopStickyMessageError(Exception): 376 | "Thrown when attempting to pop a Message marked sticky" 377 | 378 | pass 379 | 380 | 381 | class MessageThread(Sequence): 382 | def __init__( 383 | self, 384 | name: str, 385 | messages: Optional[Iterable[Message]] = None, 386 | names: Optional[Dict[str, str]] = None, 387 | ): 388 | """A conversation thread 389 | 390 | args: 391 | name: The display name of this thread 392 | messages: An iterable of Message objects from which to populate 393 | this thread 394 | names: Mapping of roles to names that should be set on 395 | future messages added to this thread 396 | """ 397 | self.name: str = name 398 | self._messages: List[Message] = ( 399 | [dataclasses.replace(m) for m in messages] 400 | if messages is not None 401 | else [] 402 | ) 403 | self.names: Dict[MessageRole, str] = names if names is not None else {} 404 | self.dirty: bool = False 405 | 406 | @classmethod 407 | def from_dict(cls, d: Dict[str, Any], name: str): 408 | """ 409 | Instantiate a MessageThread from a dict in the format returned by 410 | MessageThread.to_dict() 411 | """ 412 | messages = [Message.from_dict(m) for m in d.get("messages", [])] 413 | names = d.get("names") 414 | if names: 415 | names = {MessageRole(k): v for k, v in names.items()} 416 | res = cls(name=name, messages=messages, names=names) 417 | return res 418 | 419 | def __repr__(self) -> str: 420 | return f"<{self.name} MessageThread {self._messages!r}>" 421 | 422 | def __getitem__(self, n): 423 | return self._messages[n] 424 | 425 | def __len__(self) -> int: 426 | return len(self._messages) 427 | 428 | @property 429 | def messages(self) -> Tuple[Message, ...]: 430 | return tuple(self._messages) 431 | 432 | @messages.setter 433 | def messages(self, val: Iterable[Message]): 434 | self._messages = list(val) 435 | self.dirty = True 436 | 437 | @property 438 | def stickys(self) -> List[Message]: 439 | return [m for m in self._messages if m.sticky] 440 | 441 | def to_dict(self) -> Dict[str, Any]: 442 | "Exports this thread to a serializable dict." 443 | return { 444 | "messages": [m.to_dict() for m in self._messages], 445 | "names": self.names.copy(), 446 | } 447 | 448 | def append(self, message: Message) -> None: 449 | "Adds a new message to the end of this thread" 450 | if not isinstance(message, Message): 451 | raise TypeError("append requires a Message object") 452 | message.name = self.names.get(message.role) 453 | self._messages.append(message) 454 | self.dirty = True 455 | 456 | def render( 457 | self, 458 | start_index: Optional[int] = None, 459 | end_index: Optional[int] = None, 460 | display_indicators: bool = True, 461 | ) -> str: 462 | """Renders this thread as a human-readable transcript 463 | 464 | args: 465 | start_index: the beginning of the range of messages to render 466 | end_index: the end of the range of messages to render 467 | display_indicators: Output symbols to indicate particular message 468 | states (such as an asterisk for sticky messages) 469 | """ 470 | lines = ( 471 | (msg.display_indicators if display_indicators else "") 472 | + (msg.name if msg.name is not None else msg.role) 473 | + ": " 474 | + msg.content 475 | for msg in self._messages[start_index:end_index] 476 | ) 477 | return "\n".join(lines) 478 | 479 | def pop(self, n: Optional[int] = None) -> Message: 480 | "Remove the nth message from this thread and return it" 481 | if n is None: 482 | n = -1 483 | if self._messages[n].sticky: 484 | raise PopStickyMessageError 485 | res = self._messages.pop(n) 486 | self.dirty = True 487 | return res 488 | 489 | def clear(self) -> None: 490 | "Remove *all* messages (except those marked sticky) from this thread" 491 | if self._messages: 492 | self.dirty = True 493 | self._messages = self.stickys 494 | 495 | def move(self, i: Optional[int], j: Optional[int]) -> Message: 496 | """Pop the message at index i and re-insert it at index j""" 497 | msg = self.pop(i) 498 | if j is None: 499 | j = len(self) 500 | self._messages.insert(j, msg) 501 | return msg 502 | 503 | def rename( 504 | self, 505 | role: MessageRole, 506 | name: str, 507 | start_index: Optional[int] = None, 508 | end_index: Optional[int] = None, 509 | ) -> List[Message]: 510 | """ 511 | Changes the name set on all non-sticky messages of the specified role 512 | in this thread. If start_index or end_index is specified, only 513 | messages in the specified range are affected 514 | """ 515 | res = [] 516 | for msg in self._messages[start_index:end_index]: 517 | if msg.role == role and not msg.sticky: 518 | msg.name = name 519 | res.append(msg) 520 | if res: 521 | self.dirty = True 522 | return res 523 | 524 | def sticky( 525 | self, start_index: Optional[int], end_index: Optional[int], state: bool 526 | ) -> List[Message]: 527 | """ 528 | Stickys or unstickys (depending on the state parameter) all messages 529 | in this thread. If start_index or end_index is specified, only 530 | messages in the specified range are affected. Returns a list of 531 | messages affected by this operation. 532 | """ 533 | res = [] 534 | for m in self._messages[start_index:end_index]: 535 | if m.sticky != state: 536 | m.sticky = state 537 | res.append(m) 538 | if res: 539 | self.dirty = True 540 | return res 541 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gptcmd 2 | Gptcmd allows you to interact with large language models, such as OpenAI's GPT, efficiently in your terminal. Gptcmd can manage multiple concurrent "threads" of conversation, allowing for free and easy prompt experimentation and iteration. Individual messages can be manipulated, loaded from, and saved to files (both plain text and JSON), and API parameters are fully customizable. In short, Gptcmd is simple yet flexible, useful for both basic conversation and more involved prototyping. 3 | 4 | ## Getting started 5 | Gptcmd requires [Python](https://python.org) 3.8.6 or later. It is available on PyPI, and can, for instance, be installed with `pip install gptcmd` at a command line shell. Running `gptcmd` at a shell starts the application. If Python's `bin` or `scripts` directory isn't on your path, you may need to launch the application with a command like `~/.local/bin/gptcmd` (depending on your system configuration). In most cases though, `gptcmd` should "just work". 6 | 7 | If you'd like to use OpenAI models and you don't have an OpenAI account, you'll need to create one and [add some credit](https://platform.openai.com/account/billing/overview). $5 or so goes very far, [especially on `gpt-4.1-mini` or `gpt-4.1-nano`](#model-selection). 8 | 9 | Gptcmd searches for provider credentials in its configuration file, falling back to the `OPENAI_API_KEY` environment variable if no key is provided in its configuration. If you'd like to use OpenAI models and you don't have an API key, you'll need to [generate a key](https://platform.openai.com/account/api-keys). 10 | 11 | Once Gptcmd starts, it presents a prompt containing the name of the currently active model and waits for user input. Running the `quit` command (typing `quit` at the prompt and pressing Return) exits the program. 12 | 13 | Gptcmd has a help facility that provides a list of available commands and brief usage hints for each. The `help` command with no arguments provides a list of available commands. Passing a command as an argument to `help` returns information on the selected command. 14 | 15 | ### Configuring Gptcmd 16 | When Gptcmd starts for the first time, it generates a configuration file whose location depends on your operating system: 17 | 18 | Platform | Config location 19 | --- | --- 20 | Windows | `%appdata%\gptcmd\config.toml` 21 | MacOS | `~/Library/Application Support/gptcmd/config.toml` 22 | Other | `$XDG_CONFIG_HOME/gptcmd/config.toml` or `~/.config/gptcmd/config.toml` 23 | 24 | You may open Gptcmd's configuration file in a text editor to change application settings. The file contains comments that describe the available options. Configuration changes will be applied the next time Gptcmd is restarted. 25 | 26 | ### Simple conversation 27 | The `say` command sends a message to the model: 28 | 29 | ``` 30 | (gpt-4o) say Hello, world! 31 | ... 32 | Hello! How can I assist you today? 33 | ``` 34 | 35 | Gptcmd sends the entire conversation every time, never deleting history unless told to do so. 36 | 37 | ``` 38 | (gpt-4o) say I'm good! How are you? 39 | ... 40 | I'm just a program, so I don't have feelings, but I'm here and ready to help you with anything you need! 41 | (gpt-4o) say That's alright. Count from 1 to 5. 42 | ... 43 | Sure! Here you go: 1, 2, 3, 4, 5. 44 | (gpt-4o) say What are the next two numbers after that? 45 | ... 46 | The next two numbers are 6 and 7. 47 | ``` 48 | 49 | The conversation can be cleared with the `clear` command, at which point any previous context will no longer be made available to the model: 50 | 51 | ``` 52 | (gpt-4o) clear 53 | Delete 8 messages? (y/n)y 54 | Cleared 55 | (gpt-4o) say What are the next two numbers after that? 56 | ... 57 | I apologize, but your request is unclear. Could you please provide more information or context? For example, if you're referring to a numerical pattern or sequence, sharing the sequence would help me assist you better. 58 | ``` 59 | 60 | ### Viewing messages 61 | The `first` and `last` commands view the first and last messages in the conversation respectively: 62 | 63 | ``` 64 | (gpt-4o) say Write a limerick about generative AI. 65 | ... 66 | In the land of the silicon chip, 67 | Generative AI took a trip. 68 | With words it would play, 69 | In a curious way, 70 | Creating tales at the click of a lip. 71 | (gpt-4o) first 72 | user: What are the next two numbers after that? 73 | (gpt-4o) last 74 | assistant: In the land of the silicon chip, 75 | Generative AI took a trip. 76 | With words it would play, 77 | In a curious way, 78 | Creating tales at the click of a lip. 79 | ``` 80 | 81 | Providing an integer k as an argument shows the first/last k messages: 82 | 83 | ``` 84 | (gpt-4o) first 2 85 | user: What are the next two numbers after that? 86 | assistant: I apologize, but your request is unclear. Could you please provide more information or context? For example, if you're referring to a numerical pattern or sequence, sharing the sequence would help me assist you better. 87 | (gpt-4o) last 3 88 | assistant: I apologize, but your request is unclear. Could you please provide more information or context? For example, if you're referring to a numerical pattern or sequence, sharing the sequence would help me assist you better. 89 | user: Write a limerick about generative AI. 90 | assistant: In the land of the silicon chip, 91 | Generative AI took a trip. 92 | With words it would play, 93 | In a curious way, 94 | Creating tales at the click of a lip. 95 | ``` 96 | 97 | The `view` command shows the entire conversation: 98 | 99 | ``` 100 | (gpt-4o) view 101 | user: What are the next two numbers after that? 102 | assistant: I apologize, but your request is unclear. Could you please provide more information or context? For example, if you're referring to a numerical pattern or sequence, sharing the sequence would help me assist you better. 103 | user: Write a limerick about generative AI. 104 | assistant: In the land of the silicon chip, 105 | Generative AI took a trip. 106 | With words it would play, 107 | In a curious way, 108 | Creating tales at the click of a lip. 109 | ``` 110 | 111 | ### Message ranges 112 | Various Gptcmd commands work over ranges of messages in a conversation. Ranges are specified either as the index (position) of a single message, or a space-separated pair of the inclusive indices of the beginning and end of an interval of messages. Unlike in many programming languages, messages are one-indexed (i.e. `1` refers to the first message, `2` to the second, etc.). A dot (`.`) refers either to the entire conversation or, in place of a numeric index, to either the beginning or end of the conversation. Negative indexing is supported (`-1` refers to the last message, `-2` to the penultimate, and so on). 113 | 114 | The `view` command accepts a range of messages as an argument. When provided, it only shows messages in the indicated range. Some example message range specifications follow: 115 | 116 | ``` 117 | (gpt-4o) view 1 118 | user: What are the next two numbers after that? 119 | (gpt-4o) view 2 120 | assistant: I apologize, but your request is unclear. Could you please provide more information or context? For example, if you're referring to a numerical pattern or sequence, sharing the sequence would help me assist you better. 121 | (gpt-4o) view 2 3 122 | assistant: I apologize, but your request is unclear. Could you please provide more information or context? For example, if you're referring to a numerical pattern or sequence, sharing the sequence would help me assist you better. 123 | user: Write a limerick about generative AI. 124 | (gpt-4o) view -1 125 | assistant: In the land of the silicon chip, 126 | Generative AI took a trip. 127 | With words it would play, 128 | In a curious way, 129 | Creating tales at the click of a lip. 130 | (gpt-4o) view 3 . 131 | user: Write a limerick about generative AI. 132 | assistant: In the land of the silicon chip, 133 | Generative AI took a trip. 134 | With words it would play, 135 | In a curious way, 136 | Creating tales at the click of a lip. 137 | (gpt-4o) view . 138 | user: What are the next two numbers after that? 139 | assistant: I apologize, but your request is unclear. Could you please provide more information or context? For example, if you're referring to a numerical pattern or sequence, sharing the sequence would help me assist you better. 140 | user: Write a limerick about generative AI. 141 | assistant: In the land of the silicon chip, 142 | Generative AI took a trip. 143 | With words it would play, 144 | In a curious way, 145 | Creating tales at the click of a lip. 146 | ``` 147 | 148 | ### Conversation roles 149 | Gptcmd messages can have one of three "roles": user, representing the end-user; assistant, representing the large language model; and system, used to inform the model's behaviour. System messages can be appended to the conversation with the `system` command: 150 | 151 | ``` 152 | (gpt-4o) system As a philosophical proof assistant, you prefer to use the technique of proof by contradiction at any opportunity. Therefore, whenever you are asked to prove something, you first assume that the proposition is false, then given this assumption, you attempt to find a contradiction. 153 | 'As a philosophical proof assistant, you prefer to use the...' added as system 154 | (gpt-4o) say Prove that all cacti are succulents. 155 | ... 156 | To prove that all cacti are succulents, let's employ proof by contradiction. We begin by assuming the opposite of what we want to prove—that not all cacti are succulents. This assumption asserts that there is at least one cactus that is not a succulent. 157 | 158 | Now, let's examine the definitions involved: 159 | 160 | 1. **Cactus**: A cactus is a type of plant belonging to the family Cactaceae, which is well-adapted to arid environments. Cacti typically have thick, fleshy parts that store water, and they often have needles instead of leaves. 161 | 162 | 2. **Succulent**: A succulent is a plant that has thick, fleshy parts developed to retain water in arid climates or soil conditions. This is a broader category that includes many different plant families, including Cactaceae. 163 | 164 | Under our assumption, there exists a cactus that does not fit the definition of a succulent. In other words, this hypothetical cactus would not be adapted for water storage, lacking the thick, fleshy parts that characterize succulents. However, such characteristics are fundamental to the biological adaptation seen in all cacti, given their native environments, which require water conservation. 165 | 166 | Thus, the assumption leads to a contradiction with the basic biological characteristics and adaptations of cacti. As all known cacti possess the necessary attributes to be classified as succulents, our assumption is false. 167 | 168 | Therefore, we conclude that all cacti are indeed succulents. 169 | ``` 170 | 171 | Similarly, user and assistant messages can be added with the `user` and `assistant` commands respectively. Since models like GPT are agnostic of the source of messages (i.e. they don't track their own responses and expect downstream applications to manage context), Gptcmd allows you to inject your own arbitrary conversation history to which the model can respond: 172 | 173 | ``` 174 | (gpt-4o) user What are the first five Fibonacci numbers? 175 | 'What are the first five Fibonacci numbers?' added as user 176 | (gpt-4o) assistant 1, 1, 2, 3, 5. 177 | '1, 1, 2, 3, 5.' added as assistant 178 | (gpt-4o) say And the next five? 179 | ... 180 | The next five Fibonacci numbers after 1, 1, 2, 3, 5 are 8, 13, 21, 34, and 55. 181 | ``` 182 | 183 | The `send` command sends the conversation in its current state to the model and requests a response: 184 | 185 | ``` 186 | (gpt-4o) user What are the first ten digits of pi? 187 | 'What are the first ten digits of pi?' added as user 188 | (gpt-4o) send 189 | ... 190 | The first ten digits of pi are 3.141592653. 191 | ``` 192 | 193 | In fact, the `say` command just adds a user message and sends the conversation: 194 | 195 | ``` 196 | (gpt-4o) say What are the first ten digits of pi? 197 | ... 198 | The first ten digits of pi are 3.141592653. 199 | ``` 200 | 201 | With no arguments, the `user`, `assistant`, `system`, and `say` commands open an external text editor (based on your system or Gptcmd configuration) for message composition. 202 | 203 | ### Working with attachments 204 | OpenAI's latest models, such as `gppt-4o`, support images alongside text content. Images can be attached to messages with the `image` command, which accepts two arguments: the location of the image, either a URL or path to a local file; and the index of the message to which the image should be attached (if unspecified, it defaults to the last). We'll ask GPT to describe an image by creating a user message and attaching an image from Wikimedia Commons: 205 | 206 | ``` 207 | (gpt-4o) user What's in this image? 208 | "What's in this image?" added as user 209 | (gpt-4o) image https://upload.wikimedia.org/wikipedia/commons/c/ce/Long_cane.jpg 210 | Image added to "What's in this image?" 211 | ``` 212 | 213 | When viewing the conversation, an at sign before a message indicates an attachment (multiple at signs indicate multiple attachments): 214 | 215 | ``` 216 | (gpt-4o) view 217 | @user: What's in this image? 218 | ``` 219 | 220 | Now, we can `send` our message to get a description: 221 | 222 | ``` 223 | (gpt-4o) send 224 | ... 225 | This is a white cane, often used by individuals who are blind or visually impaired to aid in mobility and navigation. It has a handle, a long shaft, and a rounded tip. 226 | ``` 227 | 228 | Similarly, the `audio` command can be used to attach audio files to messages. Its syntax and operation is identical to the `image` command. 229 | 230 | ### Managing messages 231 | The `pop` command with no argument deletes the last message of a conversation: 232 | 233 | ``` 234 | (gpt-4o) say Responding with only one word, tell me a female given name. 235 | ... 236 | Alice. 237 | (gpt-4o) pop 238 | 'Alice.' deleted 239 | (gpt-4o) send 240 | ... 241 | Sophia 242 | (gpt-4o) pop 243 | 'Sophia' deleted 244 | (gpt-4o) send 245 | ... 246 | Emily 247 | (gpt-4o) view 248 | user: Responding with only one word, tell me a female given name. 249 | assistant: Emily 250 | ``` 251 | 252 | Deleting the last message and resending the conversation is a very common action while experimenting with large language models, so Gptcmd includes a shortcut: the `retry` command: 253 | 254 | ``` 255 | (gpt-4o) say Responding with only one word, tell me a male given name. 256 | ... 257 | David 258 | (gpt-4o) retry 259 | ... 260 | John 261 | (gpt-4o) retry 262 | ... 263 | William 264 | ``` 265 | 266 | Providing the index of a single message as an argument to `pop` deletes the specified message: 267 | 268 | ``` 269 | (gpt-4o) user abc 270 | 'abc' added as user 271 | (gpt-4o) user def 272 | 'def' added as user 273 | (gpt-4o) user ghi 274 | 'ghi' added as user 275 | (gpt-4o) pop 2 276 | 'def' deleted 277 | (gpt-4o) view 278 | user: abc 279 | user: ghi 280 | ``` 281 | 282 | The `move` command moves the message at the start of a range to the end of that range. This is often useful for adding a system message to conversations that previously didn't have one: 283 | 284 | ``` 285 | (gpt-4o) say Prove that pi is not exactly equal to 3.14628 without the use of any mathematical symbols. 286 | ... 287 | To demonstrate that pi is not exactly equal to three point one four six two eight, we can refer to the nature of pi and how it has been studied and understood throughout history. 288 | 289 | Pi is defined as the ratio of the circumference of a circle to its diameter. It is known to be an irrational number, meaning it cannot be exactly expressed as a finite decimal or a fraction with whole numbers. This characteristic implies that it has an infinite and non-repeating decimal expansion. Historical effort has been put into calculating the digits of pi, revealing many digits beyond the decimal point. 290 | 291 | While three point one four six two eight might appear to be close to pi, it is only an approximation. The actual digits of pi, after the well-known initial digits three point one four, continue as one five nine two six five and so forth. Since pi is irrational, its decimal representation is infinite and non-repeating, and cannot completely coincide with any finite number, no matter how many digits it contains. 292 | 293 | Thus, because three point one four six two eight ends after five decimal places and pi does not, we conclude that they cannot be exactly equal. 294 | (gpt-4o) first 295 | user: Prove that pi is not exactly equal to 3.14628 without the use of any mathematical symbols. 296 | (gpt-4o) system As a philosophical proof assistant, you prefer to use the technique of proof by contradiction at any opportunity. Therefore, whenever you are asked to prove something, you first assume that the proposition is false, then given this assumption, you attempt to find a contradiction. 297 | 'As a philosophical proof assistant, you prefer to use the...' added as system 298 | (gpt-4o) move -1 1 299 | 'As a philosophical proof assistant, you prefer to use the technique...' moved to start 300 | (gpt-4o) view 1 2 301 | system: As a philosophical proof assistant, you prefer to use the technique of proof by contradiction at any opportunity. Therefore, whenever you are asked to prove something, you first assume that the proposition is false, then given this assumption, you attempt to find a contradiction. 302 | user: Prove that pi is not exactly equal to 3.14628 without the use of any mathematical symbols. 303 | (gpt-4o) pop 3 304 | 'To demonstrate that pi is not exactly equal to three point one four...' deleted 305 | (gpt-4o) send 306 | ... 307 | To prove that pi is not exactly equal to three point one four six two eight, we begin with the assumption that pi is indeed exactly three point one four six two eight. Given this assumption, we need to find a contradiction. 308 | 309 | Let's recall the nature of pi. By definition, pi is the ratio of the circumference of a circle to its diameter in Euclidean space, and it is known to be an irrational number. An irrational number is one that cannot be expressed as the exact quotient of two integers, meaning it has a non-repeating, non-terminating decimal expansion. 310 | 311 | Now, consider the number three point one four six two eight. This number is clearly a rational number because it can be expressed as the fraction thirty one thousand four hundred sixty-two and eight ten-thousandths. Since three point one four six two eight is a terminating decimal, it can be expressed precisely as the division of two integers. 312 | 313 | If pi were equal to three point one four six two eight, then pi would be rational. However, this contradicts the well-established fact that pi is irrational. This contradiction implies that our initial assumption must be false. 314 | 315 | Therefore, we conclude that pi is not exactly equal to three point one four six two eight. 316 | ``` 317 | 318 | The `grep` command takes a [regular expression](https://en.wikipedia.org/wiki/Regular_expression) as an argument and searches the conversation for occurrences within its content, displaying the index and a small preview of each message that matches: 319 | 320 | ``` 321 | (gpt-4o) grep contra 322 | 1 (system): '...prefer to use the technique of proof by [contra]diction at any...' 323 | 3 (assistant): "...iven this assumption, we need to find a [contra]diction. Let's recall..." 324 | (gpt-4o) grep \d+ 325 | 2 (user): 'Prove that pi is not exactly equal to [3].[14628] without the use of any...' 326 | ``` 327 | 328 | The `edit` command with no arguments opens the contents of the last message in an external text editor for modification. Providing the index of a message to `edit` as an argument edits that message. 329 | 330 | ### Message streaming 331 | The `stream` command toggles message streaming. By default, streaming is enabled, so long responses from the language model are output in real time as they are generated. While a message is being streamed, pressing Control+c causes Gptcmd to stop waiting for the message to generate fully, allowing other commands to be used. When streaming is disabled, Gptcmd retrieves an entire response for each query and displays it when it arrives. 332 | 333 | ### Model selection 334 | The `model` command switches the active model. For instance, we can switch to [`gpt-4.1-nano`](https://openai.com/index/gpt-4-1/), a smaller, cheaper model offered by OpenAI: 335 | 336 | ``` 337 | (gpt-4o) model gpt-4.1-nano 338 | Switched to model 'gpt-4.1-nano' 339 | (gpt-4.1-nano) say Hello! 340 | ... 341 | Hello! How can I assist you today? 342 | (gpt-4.1-nano) model gpt-4o 343 | Switched to model 'gpt-4o' 344 | ``` 345 | 346 | Similarly, if you've configured multiple accounts (such as to use non-OpenAI providers), the `account` command can be used to switch among them by providing the name of the account to use as an argument. 347 | 348 | ### API parameters 349 | Gptcmd supports customization of [chat completion API parameters](https://platform.openai.com/docs/api-reference/chat/create), such as `max_tokens` and `temperature`. The `set` command sets an OpenAI API parameter. When setting a parameter, the first argument to `set` is the name of the parameter and the second argument is its value (valid Python literals are supported). A value of `None` is equivalent to sending `null` via the API. 350 | 351 | The `max_tokens` parameter limits the number of [sampled tokens](https://platform.openai.com/tokenizer) returned by GPT. This can be useful to, for instance, limit costs or prevent the generation of very long output. Note that if `max_tokens` is reached, output may be cut off abruptly: 352 | 353 | ``` 354 | (gpt-4o) set max_tokens 50 355 | max_tokens set to 50 356 | (gpt-4o) say Describe generative AI in three paragraphs 357 | ... 358 | Generative AI refers to a subset of artificial intelligence techniques that focus on creating new content or data rather than analyzing existing datasets. Unlike traditional AI models, which are primarily designed to classify, predict, or perform specific tasks, generative AI systems are equipped 359 | ``` 360 | 361 | The `temperature` parameter controls GPT's sampling temperature. A temperature of 0 causes GPT to be very deterministic: 362 | 363 | ``` 364 | (gpt-4o) set temperature 0 365 | temperature set to 0 366 | (gpt-4o) say Tell me a fun fact about generative AI. 367 | ... 368 | A fun fact about generative AI is that it has been used to create entirely new pieces of art and music, sometimes even fooling experts into thinking they were crafted by humans. For instance, AI-generated paintings have been sold at prestigious art auctions for 369 | (gpt-4o) retry 370 | ... 371 | A fun fact about generative AI is that it has been used to create entirely new pieces of art and music, sometimes even fooling experts into thinking they were crafted by humans. For instance, AI-generated paintings have been sold at prestigious art auctions for 372 | (gpt-4o) retry 373 | ... 374 | A fun fact about generative AI is that it has been used to create entirely new pieces of art and music, sometimes even fooling experts into thinking they were crafted by humans. For instance, AI-generated paintings have been sold at prestigious art auctions for 375 | ``` 376 | 377 | The `unset` command, with an argument, reverts the specified API parameter to its default value. With no argument, it restores all API parameters to default. Here, we'll unset `max_tokens`, so that full length responses can again be generated: 378 | 379 | ``` 380 | (gpt-4o) unset max_tokens 381 | max_tokens unset 382 | ``` 383 | 384 | Higher temperatures result in more apparent randomness, which can translate in some applications to increased creativity or decreased factual accuracy: 385 | 386 | ``` 387 | (gpt-4o) set temperature 0.75 388 | temperature set to 0.75 389 | (gpt-4o) retry 390 | ... 391 | A fun fact about generative AI is that it has been used to create entirely new pieces of art and music, sometimes even fooling experts into thinking these creations were made by humans. For instance, in 2018, an AI-generated painting called "Portrait of Edmond de Belamy" was auctioned at Christie’s for $432,500, far exceeding its estimated price. This demonstrated not only the creative capabilities of generative AI but also its potential impact on the art world, challenging traditional notions of creativity and authorship. 392 | (gpt-4o) retry 393 | ... 394 | A fun fact about generative AI is that it can create entirely new and unique pieces of art, music, and even poetry. For instance, AI models like OpenAI's DALL-E can generate imaginative and surreal images from simple text prompts, blending concepts that might not typically go together—such as a "two-headed flamingo in a bustling cityscape." This ability to merge creativity with computational power showcases how generative AI can expand the boundaries of artistic expression, offering novel tools for artists and creators to explore new dimensions of their work. 395 | (gpt-4o) retry 396 | ... 397 | A fun fact about generative AI is that it has been used to create entirely new pieces of music in the style of famous composers. For instance, AI models have been trained on the works of classical composers like Bach or Mozart to generate new compositions that mimic their distinct styles. This has opened up exciting possibilities not just for music enthusiasts but also for the entertainment industry, where AI-generated music can be used in films, video games, and other media to enhance creativity and reduce production costs. 398 | ``` 399 | 400 | Too high, though, and GPT will just emit nonsense. To prevent the generation of an extremely large volume of output, we'll again set `max_tokens`: 401 | 402 | ``` 403 | (gpt-4o) set max_tokens 30 404 | max_tokens set to 30 405 | (gpt-4o) set temperature 2 406 | temperature set to 2 407 | (gpt-4o) retry 408 | ... 409 | A fun fact about generative AI is that it's unique nature sometimes find unexpected parallels in non-modern multipart generators like Soukawi Internet authored phoenix drôle mime 410 | ``` 411 | 412 | Another useful parameter is `timeout` which controls how long (in seconds) Gptcmd waits for a response from GPT: 413 | 414 | ``` 415 | (gpt-4o) set timeout 0.25 416 | timeout set to 0.25 417 | (gpt-4o) say Hello! 418 | ... 419 | Request timed out. 420 | ``` 421 | 422 | The `set` command with no arguments shows all set API parameters: 423 | 424 | ``` 425 | (gpt-4o) set 426 | max_tokens: 30 427 | temperature: 2 428 | timeout: 0.25 429 | (gpt-4o) unset 430 | Unset all parameters 431 | ``` 432 | 433 | ### Names 434 | GPT allows mesages to be annotated with the name of their author. The `name` command sets the name to be sent with all future messages of the specified role. Its first argument is the role to which this new name should be applied, and its second is the name to use: 435 | 436 | ``` 437 | (gpt-4o) name user Michael 438 | user set to 'Michael' 439 | (gpt-4o) say Hello! What's my name? 440 | ... 441 | Hello! You mentioned your name is Michael. How can I assist you today? 442 | ``` 443 | 444 | With no arguments, `name` shows currently set names: 445 | 446 | ``` 447 | (gpt-4o) name 448 | user: Michael 449 | ``` 450 | 451 | The `unname` command removes a name definition to be sent with future messages. With a role passed as an argument, it unsets the name definition for that role. With no arguments, it unsets all definitions. Any previously annotated messages are unaffected: 452 | 453 | ``` 454 | (gpt-4o) view 455 | Michael: Hello! 456 | assistant: Hello! You mentioned your name is Michael. How can I assist you today? 457 | ``` 458 | 459 | Name annotations are useful for providing one- or multi-shot prompts to GPT, in which example user and assistant messages help inform future responses: 460 | 461 | ``` 462 | (gpt-4o) system You are a helpful assistant who understands many languages very well, but can only speak Spanish and therefore you always respond in that language. 463 | 'You are a helpful assistant who understands many languages...' added as system 464 | (gpt-4o) name system example_user 465 | system set to 'example_user' 466 | (gpt-4o) system Hello! 467 | 'Hello!' added as 'example_user' (system) 468 | (gpt-4o) name system example_assistant 469 | system set to 'example_assistant' 470 | (gpt-4o) system ¡Hola! ¿Cómo estás? 471 | '¡Hola! ¿Cómo estás?' added as 'example_assistant' (system) 472 | (gpt-4o) view 473 | system: You are a helpful assistant who understands many languages very well, but can only speak Spanish and therefore you always respond in that language. 474 | example_user: Hello! 475 | example_assistant: ¡Hola! ¿Cómo estás? 476 | (gpt-4o) say Qu'est-ce que amazon.com? 477 | ... 478 | Amazon.com es una empresa de comercio electrónico y tecnología que ofrece una amplia gama de productos y servicios en línea. Originalmente fundada en 1994 por Jeff Bezos como una librería en línea, Amazon se ha expandido para vender prácticamente de todo, desde electrónica hasta ropa, alimentos y servicios de computación en la nube, como AWS (Amazon Web Services). La empresa también produce dispositivos electrónicos, como el Kindle y dispositivos de la línea Echo con Alexa. Además, Amazon ofrece servicios de transmisión de video y música mediante Amazon Prime Video y Amazon Music, respectivamente. 479 | ``` 480 | 481 | The `rename` command changes the name set on existing messages in the conversation. The command has two required arguments and one optional argument: the role to affect, the range of messages to affect, and (optionally) the name to set (if omitted, the name is cleared). For instance, `rename assistant .` clears the name on all assistant messages in the conversation where a name is set, `rename user 1 Paul` sets the name of the first message to "Paul" if it is a user message, and `rename system 2 5 Mitchell` sets the name of all system messages in the second through fifth to "Mitchell". 482 | 483 | ### Sticky messages 484 | Messages can be marked "sticky", so deletion, renaming, and similar modifications do not affect them. This is often useful for system messages and example context that you don't wish to delete accidentally. The sticky command takes the range of messages to sticky as an argument: 485 | 486 | ``` 487 | (gpt-4o) system You are a Python programmer. Therefore, when responding, you write in Python source code exclusively. 488 | 'You are a Python programmer. Therefore, when responding, you...' added as system 489 | (gpt-4o) sticky . 490 | 1 message stickied 491 | ``` 492 | 493 | Now that the message is sticky, `clear` does not affect it, and its sticky status is indicated by an asterisk: 494 | 495 | ``` 496 | (gpt-4o) say Find the nth Fibonacci number. 497 | ... 498 | def fibonacci(n): 499 | if n <= 0: 500 | raise ValueError("n must be a positive integer.") 501 | elif n == 1: 502 | return 0 503 | elif n == 2: 504 | return 1 505 | 506 | a, b = 0, 1 507 | for _ in range(2, n): 508 | a, b = b, a + b 509 | return b 510 | 511 | # Example usage: 512 | # nth_fibonacci = fibonacci(10) 513 | # print(nth_fibonacci) # Output: 34 514 | (gpt-4o) clear 515 | Delete 2 messages? (y/n)y 516 | Cleared 517 | (gpt-4o) view 518 | *system: You are a Python programmer. Therefore, when responding, you write in Python source code exclusively. 519 | ``` 520 | 521 | Similarly, `pop` is blocked: 522 | 523 | ``` 524 | (gpt-4o) pop 525 | That message is sticky; unsticky it first 526 | ``` 527 | 528 | The `unsticky` command makes all sticky messages in the specified range no longer sticky: 529 | 530 | ``` 531 | (gpt-4o) unsticky . 532 | 1 message unstickied 533 | (gpt-4o) pop 534 | 'You are a Python programmer. Therefore, when responding, you write...' deleted 535 | ``` 536 | 537 | ### Message metadata 538 | Gptcmd allows arbitrary [key–value metadata](https://en.wikipedia.org/wiki/Name%E2%80%93value_pair) to be stored with each message. This might be useful, for instance, to store personal notes with messages, or as an interface to enable special features in external large language model providers (consult external package documentation for details). 539 | 540 | Providing a key `k` and value `v` to the `meta` command stores `v` at `k` on the last message: 541 | 542 | ``` 543 | (gpt-4o) user This is a test. 544 | 'This is a test.' added as user 545 | (gpt-4o) meta notes "This is a test of message metadata." 546 | notes set to 'This is a test of message metadata.' on 'This is a test. 547 | ``` 548 | 549 | Valid JSON literals are supported in metadata values: 550 | 551 | ``` 552 | (gpt-4o) meta list [1,2,3] 553 | list set to [1, 2, 3] on 'This is a test.' 554 | (gpt-4o) meta obj {"key1": "value1", "key2": true} 555 | obj set to {'key1': 'value1', 'key2': True} on 'This is a test.' 556 | ``` 557 | 558 | Providing just a key shows the associated value: 559 | 560 | ``` 561 | (gpt-4o) meta list 562 | [1, 2, 3] 563 | (gpt-4o) meta list2 564 | 'list2 not set' 565 | ``` 566 | 567 | With no arguments, `meta` shows all keys set on the last message: 568 | 569 | ``` 570 | (gpt-4o) meta 571 | notes: 'This is a test of message metadata.' 572 | list: [1, 2, 3] 573 | obj: {'key1': 'value1', 'key2': True} 574 | ``` 575 | 576 | Providing an index as the first argument to `meta` operates on the selected message: 577 | 578 | ``` 579 | (gpt-4o) user Second message 580 | 'Second message' added as user 581 | (gpt-4o) meta 1 list 582 | [1, 2, 3] 583 | (gpt-4o) meta 1 list2 [4,5,6] 584 | list2 set to [4, 5, 6] on 'This is a test.' 585 | ``` 586 | 587 | The `unmeta` command deletes a key–value pair. Similarly to `meta`, it accepts an index as its first argument, operating on the last message if no index is provided: 588 | 589 | ``` 590 | (gpt-4o) unmeta 1 list2 591 | list2 unset on 'This is a test.' 592 | (gpt-4o) meta 1 list2 593 | 'list2 not set' 594 | ``` 595 | 596 | With no key specified, `unmeta` deletes all keys: 597 | 598 | ``` 599 | (gpt-4o) unmeta 1 600 | delete 3 items on 'This is a test.'? (y/n)y 601 | Unset all metadata on 'This is a test.' 602 | (gpt-4o) meta 1 603 | No metadata set on 'This is a test.' 604 | (gpt-4o) clear 605 | Delete 2 messages? (y/n)y 606 | Cleared 607 | ``` 608 | 609 | ### Message threads 610 | Until this point, we have been engaging in a single conversation (or series of conversations) with the model. However, Gptcmd supports the creation and maintenance of several concurrent conversation "threads". 611 | 612 | Gptcmd starts in the "detached thread", a scratch area intended for quick conversation. A new, named conversation thread can be created from the current thread with the `thread` command, which takes a name for the new thread as an argument: 613 | 614 | ``` 615 | (gpt-4o) say Responding only using ASCII/Unicode symbols and without narrative explanation, what is the closed-form formula to calculate the nth Fibonacci number? 616 | ... 617 | F(n) = (φ^n - ψ^n) / √5 618 | 619 | where: 620 | φ = (1 + √5) / 2 621 | ψ = (1 - √5) / 2 622 | (gpt-4o) thread induction 623 | Switched to new thread 'induction' 624 | ``` 625 | 626 | By default, the prompt changes to indicate the current thread. All messages have been copied: 627 | 628 | ``` 629 | induction(gpt-4o) view 630 | user: Responding only using ASCII/Unicode symbols and without narrative explanation, what is the closed-form formula to calculate the nth Fibonacci number? 631 | assistant: F(n) = (φ^n - ψ^n) / √5 632 | 633 | where: 634 | φ = (1 + √5) / 2 635 | ψ = (1 - √5) / 2 636 | ``` 637 | 638 | The `thread` command with no argument switches back to the detached thread: 639 | 640 | ``` 641 | induction(gpt-4o) thread 642 | detached thread 643 | (gpt-4o) say Tell me a fun fact about Braille. 644 | ... 645 | Braille was invented by Louis Braille, who was inspired by a tactile military code called "night writing" developed by Charles Barbier, intended for soldiers to communicate silently and without light. 646 | ``` 647 | 648 | Passing the name of an existing thread as an argument to `thread` switches to that thread. Once created, threads are completely independent: 649 | 650 | ``` 651 | (gpt-4o) last 3 652 | assistant: F(n) = (φ^n - ψ^n) / √5 653 | 654 | where: 655 | φ = (1 + √5) / 2 656 | ψ = (1 - √5) / 2 657 | user: Tell me a fun fact about Braille. 658 | assistant: Braille was invented by Louis Braille, who was inspired by a tactile military code called "night writing" developed by Charles Barbier, intended for soldiers to communicate silently and without light. 659 | (gpt-4o) thread induction 660 | Switched to thread 'induction' 661 | induction(gpt-4o) last 3 662 | user: Responding only using ASCII/Unicode symbols and without narrative explanation, what is the closed-form formula to calculate the nth Fibonacci number? 663 | assistant: F(n) = (φ^n - ψ^n) / √5 664 | 665 | where: 666 | φ = (1 + √5) / 2 667 | ψ = (1 - √5) / 2 668 | ``` 669 | 670 | When running `retry` in a named thread, Gptcmd asks whether you'd like to create a new thread, preserving both responses (this can be changed in Gptcmd configuration): 671 | 672 | ``` 673 | induction(gpt-4o) say Responding in a short paragraph and without showing your algebra, prove by induction that this formula is equivalent to the recursive definition. 674 | ... 675 | To prove the closed-form formula, known as Binet's formula, is equivalent to the recursive definition of the Fibonacci sequence, use mathematical induction. First, verify the base cases: for \( n = 0 \) and \( n = 1 \), evaluate the formula and confirm it gives the correct Fibonacci numbers \( F(0) = 0 \) and \( F(1) = 1 \). Next, assume the formula is true for \( n = k \) and \( n = k + 1 \), meaning \( F(k) \) and \( F(k+1) \) are correct. Prove it for \( n = k + 2 \) by showing that substituting these into the recursive relation \( F(k+2) = F(k+1) + F(k) \) satisfies the formula. The linear recurrence relations of φ and ψ ensure that φ and ψ raised to any integer power adhere to the Fibonacci sequence, maintaining the truth of the expression for all \( n \). This confirms the formula's validity by induction. 676 | induction(gpt-4o) retry 677 | Create thread 'induction2'? (y/n)y 678 | Switched to new thread 'induction2' 679 | ... 680 | To prove by induction that the closed-form formula for the nth Fibonacci number is equivalent to the recursive definition, we start with the base cases. For n=0 and n=1, the formula gives F(0) = 0 and F(1) = 1, which match the recursive definition. For the inductive step, assume that F(k) and F(k+1) are true as per the formula for some k. Then, F(k+2) can be written as F(k+2) = φ^(k+2)/√5 - ψ^(k+2)/√5, which simplifies to φ^k+1 + φ^k/√5 after using the induction hypothesis and algebraic manipulation with φ and ψ properties, thus matching the recursive definition F(k+2) = F(k+1) + F(k). This closes the induction, showing the equivalence of the closed-form and recursive definitions for all n. 681 | ``` 682 | 683 | We can continue our conversation from here: 684 | 685 | ``` 686 | induction2(gpt-4o) say Explain this proof to a five-year-old. 687 | ... 688 | Okay, imagine you have a cool way to get into a big number treasure chest called the Fibonacci chest! There are two ways to find the "number treasure": one is building steps one by one (like adding the last two steps to get the next step), which is like adding blocks — 0, 1, 1, 2, 3, 5... And there's a magic code (the special formula) that lets you jump right to any step you want, as long as you know which number step it is. When you try both ways, they both give you the same number treasure every time you check a step, starting with the first hood on up. Isn't that neat? So whether you build step by step or use your special code, the treasure you find is always the same! 🧡 689 | ``` 690 | 691 | Or return to the original response before we ran `retry`: 692 | 693 | ``` 694 | induction2(gpt-4o) thread induction 695 | Switched to thread 'induction' 696 | ``` 697 | 698 | The `copy` command copies all messages in the specified range and appends the copies to the thread provided, creating it if it doesn't exist. If no thread is provided, `copy` copies messages to the detached thread: 699 | 700 | ``` 701 | induction(gpt-4o) thread 702 | detached thread 703 | (gpt-4o) clear 704 | Delete 4 messages? (y/n)y 705 | Cleared 706 | (gpt-4o) thread induction2 707 | Switched to thread 'induction2' 708 | induction2(gpt-4o) copy 1 2 709 | Selecting 2 messages 710 | First message selected: 'Responding only using ASCII/Unicode symbols and...' 711 | Last message selected: 'F(n) = (φ^n - ψ^n) / √5 where: φ = (1 + √5) / 2 ψ =...' 712 | Copy to detached thread? (y/n)y 713 | Copied 714 | induction2(gpt-4o) thread 715 | detached thread 716 | (gpt-4o) say Write a C function that implements this closed-form formula without any narrative explanation. 717 | ... 718 | #include 719 | 720 | int fibonacci(int n) { 721 | double phi = (1 + sqrt(5)) / 2; 722 | double psi = (1 - sqrt(5)) / 2; 723 | return round((pow(phi, n) - pow(psi, n)) / sqrt(5)); 724 | } 725 | ``` 726 | 727 | The `threads` command lists the named threads present in this session: 728 | 729 | ``` 730 | (gpt-4o) threads 731 | induction2 (6 messages) 732 | induction (4 messages) 733 | (4 detached messages) 734 | ``` 735 | 736 | The `delete` command, with the name of a thread passed as an argument, deletes the specified thread: 737 | 738 | ``` 739 | (gpt-4o) delete induction 740 | Deleted thread induction 741 | (gpt-4o) threads 742 | induction2 (6 messages) 743 | (4 detached messages) 744 | ``` 745 | 746 | With no argument, `delete` deletes **all** named threads in this session: 747 | 748 | ``` 749 | (gpt-4o) delete 750 | Delete 1 thread? (y/n)y 751 | Deleted 752 | (gpt-4o) threads 753 | No threads 754 | (4 detached messages) 755 | ``` 756 | 757 | ### Working with files 758 | The `transcribe` command writes a plain-text transcript of the current thread to a text file, overwriting any existing file contents. It takes the path to the file to write as an argument. 759 | 760 | The `save` command writes all named threads to a JSON file, overwriting any existing file contents. It takes the path to the file to write as an argument. With no argument, `save` writes to the most recently loaded or saved JSON file in the current session. 761 | 762 | The `load` command loads all saved named threads from a JSON file in the format written by `save`, merging them into the current session. If there is a naming conflict between a thread in the current session and a thread in the file to load, the thread in the file wins. The `load` command takes the path of the file to load as an argument. 763 | 764 | The `write` command writes the contents of the last message of the current thread to a text file, overwriting any existing file contents. This command is particularly useful when working with source code. It takes the path to the file to write as an argument. 765 | 766 | The `read` command appends a new message to the current thread containing the text content of the specified file. It takes two arguments: the path of the file to read and the role of the new message. For instance, `read prompt.txt system` reads the content of `prompt.txt` appending it as a new system message. 767 | 768 | ## Command line parameters 769 | Gptcmd supports a few command line parameters: 770 | 771 | ``` 772 | $ gptcmd -h 773 | usage: gptcmd [-h] [-c CONFIG] [-t THREAD] [-m MODEL] [-a ACCOUNT] [--version] [path] 774 | 775 | positional arguments: 776 | path The path to a JSON file of named threads to load on launch 777 | 778 | options: 779 | -h, --help show this help message and exit 780 | -c CONFIG, --config CONFIG 781 | The path to a Gptcmd configuration file to use for this session 782 | -t THREAD, --thread THREAD 783 | The name of the thread to switch to on launch 784 | -m MODEL, --model MODEL 785 | The name of the model to switch to on launch 786 | -a ACCOUNT, --account ACCOUNT 787 | The name of the account to switch to on launch 788 | --version Show version and exit 789 | ``` 790 | -------------------------------------------------------------------------------- /COPYING.txt: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at https://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | 375 | Python 376 | A. HISTORY OF THE SOFTWARE 377 | ========================== 378 | 379 | Python was created in the early 1990s by Guido van Rossum at Stichting 380 | Mathematisch Centrum (CWI, see http://www.cwi.nl) in the Netherlands 381 | as a successor of a language called ABC. Guido remains Python's 382 | principal author, although it includes many contributions from others. 383 | 384 | In 1995, Guido continued his work on Python at the Corporation for 385 | National Research Initiatives (CNRI, see http://www.cnri.reston.va.us) 386 | in Reston, Virginia where he released several versions of the 387 | software. 388 | 389 | In May 2000, Guido and the Python core development team moved to 390 | BeOpen.com to form the BeOpen PythonLabs team. In October of the same 391 | year, the PythonLabs team moved to Digital Creations, which became 392 | Zope Corporation. In 2001, the Python Software Foundation (PSF, see 393 | https://www.python.org/psf/) was formed, a non-profit organization 394 | created specifically to own Python-related Intellectual Property. 395 | Zope Corporation was a sponsoring member of the PSF. 396 | 397 | All Python releases are Open Source (see http://www.opensource.org for 398 | the Open Source Definition). Historically, most, but not all, Python 399 | releases have also been GPL-compatible; the table below summarizes 400 | the various releases. 401 | 402 | Release Derived Year Owner GPL- 403 | from compatible? (1) 404 | 405 | 0.9.0 thru 1.2 1991-1995 CWI yes 406 | 1.3 thru 1.5.2 1.2 1995-1999 CNRI yes 407 | 1.6 1.5.2 2000 CNRI no 408 | 2.0 1.6 2000 BeOpen.com no 409 | 1.6.1 1.6 2001 CNRI yes (2) 410 | 2.1 2.0+1.6.1 2001 PSF no 411 | 2.0.1 2.0+1.6.1 2001 PSF yes 412 | 2.1.1 2.1+2.0.1 2001 PSF yes 413 | 2.1.2 2.1.1 2002 PSF yes 414 | 2.1.3 2.1.2 2002 PSF yes 415 | 2.2 and above 2.1.1 2001-now PSF yes 416 | 417 | Footnotes: 418 | 419 | (1) GPL-compatible doesn't mean that we're distributing Python under 420 | the GPL. All Python licenses, unlike the GPL, let you distribute 421 | a modified version without making your changes open source. The 422 | GPL-compatible licenses make it possible to combine Python with 423 | other software that is released under the GPL; the others don't. 424 | 425 | (2) According to Richard Stallman, 1.6.1 is not GPL-compatible, 426 | because its license has a choice of law clause. According to 427 | CNRI, however, Stallman's lawyer has told CNRI's lawyer that 1.6.1 428 | is "not incompatible" with the GPL. 429 | 430 | Thanks to the many outside volunteers who have worked under Guido's 431 | direction to make these releases possible. 432 | 433 | 434 | B. TERMS AND CONDITIONS FOR ACCESSING OR OTHERWISE USING PYTHON 435 | =============================================================== 436 | 437 | Python software and documentation are licensed under the 438 | Python Software Foundation License Version 2. 439 | 440 | Starting with Python 3.8.6, examples, recipes, and other code in 441 | the documentation are dual licensed under the PSF License Version 2 442 | and the Zero-Clause BSD license. 443 | 444 | Some software incorporated into Python is under different licenses. 445 | The licenses are listed with code falling under that license. 446 | 447 | 448 | PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 449 | -------------------------------------------- 450 | 451 | 1. This LICENSE AGREEMENT is between the Python Software Foundation 452 | ("PSF"), and the Individual or Organization ("Licensee") accessing and 453 | otherwise using this software ("Python") in source or binary form and 454 | its associated documentation. 455 | 456 | 2. Subject to the terms and conditions of this License Agreement, PSF hereby 457 | grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, 458 | analyze, test, perform and/or display publicly, prepare derivative works, 459 | distribute, and otherwise use Python alone or in any derivative version, 460 | provided, however, that PSF's License Agreement and PSF's notice of copyright, 461 | i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, 462 | 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021 Python Software Foundation; 463 | All Rights Reserved" are retained in Python alone or in any derivative version 464 | prepared by Licensee. 465 | 466 | 3. In the event Licensee prepares a derivative work that is based on 467 | or incorporates Python or any part thereof, and wants to make 468 | the derivative work available to others as provided herein, then 469 | Licensee hereby agrees to include in any such work a brief summary of 470 | the changes made to Python. 471 | 472 | 4. PSF is making Python available to Licensee on an "AS IS" 473 | basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR 474 | IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND 475 | DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS 476 | FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT 477 | INFRINGE ANY THIRD PARTY RIGHTS. 478 | 479 | 5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON 480 | FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS 481 | A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, 482 | OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. 483 | 484 | 6. This License Agreement will automatically terminate upon a material 485 | breach of its terms and conditions. 486 | 487 | 7. Nothing in this License Agreement shall be deemed to create any 488 | relationship of agency, partnership, or joint venture between PSF and 489 | Licensee. This License Agreement does not grant permission to use PSF 490 | trademarks or trade name in a trademark sense to endorse or promote 491 | products or services of Licensee, or any third party. 492 | 493 | 8. By copying, installing or otherwise using Python, Licensee 494 | agrees to be bound by the terms and conditions of this License 495 | Agreement. 496 | 497 | 498 | BEOPEN.COM LICENSE AGREEMENT FOR PYTHON 2.0 499 | ------------------------------------------- 500 | 501 | BEOPEN PYTHON OPEN SOURCE LICENSE AGREEMENT VERSION 1 502 | 503 | 1. This LICENSE AGREEMENT is between BeOpen.com ("BeOpen"), having an 504 | office at 160 Saratoga Avenue, Santa Clara, CA 95051, and the 505 | Individual or Organization ("Licensee") accessing and otherwise using 506 | this software in source or binary form and its associated 507 | documentation ("the Software"). 508 | 509 | 2. Subject to the terms and conditions of this BeOpen Python License 510 | Agreement, BeOpen hereby grants Licensee a non-exclusive, 511 | royalty-free, world-wide license to reproduce, analyze, test, perform 512 | and/or display publicly, prepare derivative works, distribute, and 513 | otherwise use the Software alone or in any derivative version, 514 | provided, however, that the BeOpen Python License is retained in the 515 | Software, alone or in any derivative version prepared by Licensee. 516 | 517 | 3. BeOpen is making the Software available to Licensee on an "AS IS" 518 | basis. BEOPEN MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR 519 | IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, BEOPEN MAKES NO AND 520 | DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS 521 | FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE WILL NOT 522 | INFRINGE ANY THIRD PARTY RIGHTS. 523 | 524 | 4. BEOPEN SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF THE 525 | SOFTWARE FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS 526 | AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THE SOFTWARE, OR ANY 527 | DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. 528 | 529 | 5. This License Agreement will automatically terminate upon a material 530 | breach of its terms and conditions. 531 | 532 | 6. This License Agreement shall be governed by and interpreted in all 533 | respects by the law of the State of California, excluding conflict of 534 | law provisions. Nothing in this License Agreement shall be deemed to 535 | create any relationship of agency, partnership, or joint venture 536 | between BeOpen and Licensee. This License Agreement does not grant 537 | permission to use BeOpen trademarks or trade names in a trademark 538 | sense to endorse or promote products or services of Licensee, or any 539 | third party. As an exception, the "BeOpen Python" logos available at 540 | http://www.pythonlabs.com/logos.html may be used according to the 541 | permissions granted on that web page. 542 | 543 | 7. By copying, installing or otherwise using the software, Licensee 544 | agrees to be bound by the terms and conditions of this License 545 | Agreement. 546 | 547 | 548 | CNRI LICENSE AGREEMENT FOR PYTHON 1.6.1 549 | --------------------------------------- 550 | 551 | 1. This LICENSE AGREEMENT is between the Corporation for National 552 | Research Initiatives, having an office at 1895 Preston White Drive, 553 | Reston, VA 20191 ("CNRI"), and the Individual or Organization 554 | ("Licensee") accessing and otherwise using Python 1.6.1 software in 555 | source or binary form and its associated documentation. 556 | 557 | 2. Subject to the terms and conditions of this License Agreement, CNRI 558 | hereby grants Licensee a nonexclusive, royalty-free, world-wide 559 | license to reproduce, analyze, test, perform and/or display publicly, 560 | prepare derivative works, distribute, and otherwise use Python 1.6.1 561 | alone or in any derivative version, provided, however, that CNRI's 562 | License Agreement and CNRI's notice of copyright, i.e., "Copyright (c) 563 | 1995-2001 Corporation for National Research Initiatives; All Rights 564 | Reserved" are retained in Python 1.6.1 alone or in any derivative 565 | version prepared by Licensee. Alternately, in lieu of CNRI's License 566 | Agreement, Licensee may substitute the following text (omitting the 567 | quotes): "Python 1.6.1 is made available subject to the terms and 568 | conditions in CNRI's License Agreement. This Agreement together with 569 | Python 1.6.1 may be located on the Internet using the following 570 | unique, persistent identifier (known as a handle): 1895.22/1013. This 571 | Agreement may also be obtained from a proxy server on the Internet 572 | using the following URL: http://hdl.handle.net/1895.22/1013". 573 | 574 | 3. In the event Licensee prepares a derivative work that is based on 575 | or incorporates Python 1.6.1 or any part thereof, and wants to make 576 | the derivative work available to others as provided herein, then 577 | Licensee hereby agrees to include in any such work a brief summary of 578 | the changes made to Python 1.6.1. 579 | 580 | 4. CNRI is making Python 1.6.1 available to Licensee on an "AS IS" 581 | basis. CNRI MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR 582 | IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, CNRI MAKES NO AND 583 | DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS 584 | FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 1.6.1 WILL NOT 585 | INFRINGE ANY THIRD PARTY RIGHTS. 586 | 587 | 5. CNRI SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON 588 | 1.6.1 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS 589 | A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 1.6.1, 590 | OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. 591 | 592 | 6. This License Agreement will automatically terminate upon a material 593 | breach of its terms and conditions. 594 | 595 | 7. This License Agreement shall be governed by the federal 596 | intellectual property law of the United States, including without 597 | limitation the federal copyright law, and, to the extent such 598 | U.S. federal law does not apply, by the law of the Commonwealth of 599 | Virginia, excluding Virginia's conflict of law provisions. 600 | Notwithstanding the foregoing, with regard to derivative works based 601 | on Python 1.6.1 that incorporate non-separable material that was 602 | previously distributed under the GNU General Public License (GPL), the 603 | law of the Commonwealth of Virginia shall govern this License 604 | Agreement only as to issues arising under or with respect to 605 | Paragraphs 4, 5, and 7 of this License Agreement. Nothing in this 606 | License Agreement shall be deemed to create any relationship of 607 | agency, partnership, or joint venture between CNRI and Licensee. This 608 | License Agreement does not grant permission to use CNRI trademarks or 609 | trade name in a trademark sense to endorse or promote products or 610 | services of Licensee, or any third party. 611 | 612 | 8. By clicking on the "ACCEPT" button where indicated, or by copying, 613 | installing or otherwise using Python 1.6.1, Licensee agrees to be 614 | bound by the terms and conditions of this License Agreement. 615 | 616 | ACCEPT 617 | 618 | 619 | CWI LICENSE AGREEMENT FOR PYTHON 0.9.0 THROUGH 1.2 620 | -------------------------------------------------- 621 | 622 | Copyright (c) 1991 - 1995, Stichting Mathematisch Centrum Amsterdam, 623 | The Netherlands. All rights reserved. 624 | 625 | Permission to use, copy, modify, and distribute this software and its 626 | documentation for any purpose and without fee is hereby granted, 627 | provided that the above copyright notice appear in all copies and that 628 | both that copyright notice and this permission notice appear in 629 | supporting documentation, and that the name of Stichting Mathematisch 630 | Centrum or CWI not be used in advertising or publicity pertaining to 631 | distribution of the software without specific, written prior 632 | permission. 633 | 634 | STICHTING MATHEMATISCH CENTRUM DISCLAIMS ALL WARRANTIES WITH REGARD TO 635 | THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND 636 | FITNESS, IN NO EVENT SHALL STICHTING MATHEMATISCH CENTRUM BE LIABLE 637 | FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 638 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 639 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT 640 | OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 641 | 642 | ZERO-CLAUSE BSD LICENSE FOR CODE IN THE PYTHON DOCUMENTATION 643 | ---------------------------------------------------------------------- 644 | 645 | Permission to use, copy, modify, and/or distribute this software for any 646 | purpose with or without fee is hereby granted. 647 | 648 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 649 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 650 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 651 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 652 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 653 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 654 | PERFORMANCE OF THIS SOFTWARE. 655 | 656 | 657 | 658 | Additional Conditions for this Windows binary build 659 | --------------------------------------------------- 660 | 661 | This program is linked with and uses Microsoft Distributable Code, 662 | copyrighted by Microsoft Corporation. The Microsoft Distributable Code 663 | is embedded in each .exe, .dll and .pyd file as a result of running 664 | the code through a linker. 665 | 666 | If you further distribute programs that include the Microsoft 667 | Distributable Code, you must comply with the restrictions on 668 | distribution specified by Microsoft. In particular, you must require 669 | distributors and external end users to agree to terms that protect the 670 | Microsoft Distributable Code at least as much as Microsoft's own 671 | requirements for the Distributable Code. See Microsoft's documentation 672 | (included in its developer tools and on its website at microsoft.com) 673 | for specific details. 674 | 675 | Redistribution of the Windows binary build of the Python interpreter 676 | complies with this agreement, provided that you do not: 677 | 678 | - alter any copyright, trademark or patent notice in Microsoft's 679 | Distributable Code; 680 | 681 | - use Microsoft's trademarks in your programs' names or in a way that 682 | suggests your programs come from or are endorsed by Microsoft; 683 | 684 | - distribute Microsoft's Distributable Code to run on a platform other 685 | than Microsoft operating systems, run-time technologies or application 686 | platforms; or 687 | 688 | - include Microsoft Distributable Code in malicious, deceptive or 689 | unlawful programs. 690 | 691 | These restrictions apply only to the Microsoft Distributable Code as 692 | defined above, not to Python itself or any programs running on the 693 | Python interpreter. The redistribution of the Python interpreter and 694 | libraries is governed by the Python Software License included with this 695 | file, or by other licenses as marked. 696 | 697 | 698 | 699 | -------------------------------------------------------------------------- 700 | 701 | This program, "bzip2", the associated library "libbzip2", and all 702 | documentation, are copyright (C) 1996-2010 Julian R Seward. All 703 | rights reserved. 704 | 705 | Redistribution and use in source and binary forms, with or without 706 | modification, are permitted provided that the following conditions 707 | are met: 708 | 709 | 1. Redistributions of source code must retain the above copyright 710 | notice, this list of conditions and the following disclaimer. 711 | 712 | 2. The origin of this software must not be misrepresented; you must 713 | not claim that you wrote the original software. If you use this 714 | software in a product, an acknowledgment in the product 715 | documentation would be appreciated but is not required. 716 | 717 | 3. Altered source versions must be plainly marked as such, and must 718 | not be misrepresented as being the original software. 719 | 720 | 4. The name of the author may not be used to endorse or promote 721 | products derived from this software without specific prior written 722 | permission. 723 | 724 | THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS 725 | OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 726 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 727 | ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY 728 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 729 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE 730 | GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 731 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 732 | WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 733 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 734 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 735 | 736 | Julian Seward, jseward@bzip.org 737 | bzip2/libbzip2 version 1.0.6 of 6 September 2010 738 | 739 | -------------------------------------------------------------------------- 740 | 741 | 742 | LICENSE ISSUES 743 | ============== 744 | 745 | The OpenSSL toolkit stays under a double license, i.e. both the conditions of 746 | the OpenSSL License and the original SSLeay license apply to the toolkit. 747 | See below for the actual license texts. 748 | 749 | OpenSSL License 750 | --------------- 751 | 752 | /* ==================================================================== 753 | * Copyright (c) 1998-2019 The OpenSSL Project. All rights reserved. 754 | * 755 | * Redistribution and use in source and binary forms, with or without 756 | * modification, are permitted provided that the following conditions 757 | * are met: 758 | * 759 | * 1. Redistributions of source code must retain the above copyright 760 | * notice, this list of conditions and the following disclaimer. 761 | * 762 | * 2. Redistributions in binary form must reproduce the above copyright 763 | * notice, this list of conditions and the following disclaimer in 764 | * the documentation and/or other materials provided with the 765 | * distribution. 766 | * 767 | * 3. All advertising materials mentioning features or use of this 768 | * software must display the following acknowledgment: 769 | * "This product includes software developed by the OpenSSL Project 770 | * for use in the OpenSSL Toolkit. (http://www.openssl.org/)" 771 | * 772 | * 4. The names "OpenSSL Toolkit" and "OpenSSL Project" must not be used to 773 | * endorse or promote products derived from this software without 774 | * prior written permission. For written permission, please contact 775 | * openssl-core@openssl.org. 776 | * 777 | * 5. Products derived from this software may not be called "OpenSSL" 778 | * nor may "OpenSSL" appear in their names without prior written 779 | * permission of the OpenSSL Project. 780 | * 781 | * 6. Redistributions of any form whatsoever must retain the following 782 | * acknowledgment: 783 | * "This product includes software developed by the OpenSSL Project 784 | * for use in the OpenSSL Toolkit (http://www.openssl.org/)" 785 | * 786 | * THIS SOFTWARE IS PROVIDED BY THE OpenSSL PROJECT ``AS IS'' AND ANY 787 | * EXPRESSED OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 788 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 789 | * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE OpenSSL PROJECT OR 790 | * ITS CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 791 | * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT 792 | * NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 793 | * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 794 | * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, 795 | * STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 796 | * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED 797 | * OF THE POSSIBILITY OF SUCH DAMAGE. 798 | * ==================================================================== 799 | * 800 | * This product includes cryptographic software written by Eric Young 801 | * (eay@cryptsoft.com). This product includes software written by Tim 802 | * Hudson (tjh@cryptsoft.com). 803 | * 804 | */ 805 | 806 | Original SSLeay License 807 | ----------------------- 808 | 809 | /* Copyright (C) 1995-1998 Eric Young (eay@cryptsoft.com) 810 | * All rights reserved. 811 | * 812 | * This package is an SSL implementation written 813 | * by Eric Young (eay@cryptsoft.com). 814 | * The implementation was written so as to conform with Netscapes SSL. 815 | * 816 | * This library is free for commercial and non-commercial use as long as 817 | * the following conditions are aheared to. The following conditions 818 | * apply to all code found in this distribution, be it the RC4, RSA, 819 | * lhash, DES, etc., code; not just the SSL code. The SSL documentation 820 | * included with this distribution is covered by the same copyright terms 821 | * except that the holder is Tim Hudson (tjh@cryptsoft.com). 822 | * 823 | * Copyright remains Eric Young's, and as such any Copyright notices in 824 | * the code are not to be removed. 825 | * If this package is used in a product, Eric Young should be given attribution 826 | * as the author of the parts of the library used. 827 | * This can be in the form of a textual message at program startup or 828 | * in documentation (online or textual) provided with the package. 829 | * 830 | * Redistribution and use in source and binary forms, with or without 831 | * modification, are permitted provided that the following conditions 832 | * are met: 833 | * 1. Redistributions of source code must retain the copyright 834 | * notice, this list of conditions and the following disclaimer. 835 | * 2. Redistributions in binary form must reproduce the above copyright 836 | * notice, this list of conditions and the following disclaimer in the 837 | * documentation and/or other materials provided with the distribution. 838 | * 3. All advertising materials mentioning features or use of this software 839 | * must display the following acknowledgement: 840 | * "This product includes cryptographic software written by 841 | * Eric Young (eay@cryptsoft.com)" 842 | * The word 'cryptographic' can be left out if the rouines from the library 843 | * being used are not cryptographic related :-). 844 | * 4. If you include any Windows specific code (or a derivative thereof) from 845 | * the apps directory (application code) you must include an acknowledgement: 846 | * "This product includes software written by Tim Hudson (tjh@cryptsoft.com)" 847 | * 848 | * THIS SOFTWARE IS PROVIDED BY ERIC YOUNG ``AS IS'' AND 849 | * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 850 | * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 851 | * ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE 852 | * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 853 | * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS 854 | * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) 855 | * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 856 | * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY 857 | * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF 858 | * SUCH DAMAGE. 859 | * 860 | * The licence and distribution terms for any publically available version or 861 | * derivative of this code cannot be changed. i.e. this code cannot simply be 862 | * copied and put under another distribution licence 863 | * [including the GNU Public Licence.] 864 | */ 865 | 866 | 867 | libffi - Copyright (c) 1996-2014 Anthony Green, Red Hat, Inc and others. 868 | See source files for details. 869 | 870 | Permission is hereby granted, free of charge, to any person obtaining 871 | a copy of this software and associated documentation files (the 872 | ``Software''), to deal in the Software without restriction, including 873 | without limitation the rights to use, copy, modify, merge, publish, 874 | distribute, sublicense, and/or sell copies of the Software, and to 875 | permit persons to whom the Software is furnished to do so, subject to 876 | the following conditions: 877 | 878 | The above copyright notice and this permission notice shall be 879 | included in all copies or substantial portions of the Software. 880 | 881 | THE SOFTWARE IS PROVIDED ``AS IS'', WITHOUT WARRANTY OF ANY KIND, 882 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 883 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 884 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 885 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 886 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 887 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 888 | 889 | This software is copyrighted by the Regents of the University of 890 | California, Sun Microsystems, Inc., Scriptics Corporation, ActiveState 891 | Corporation and other parties. The following terms apply to all files 892 | associated with the software unless explicitly disclaimed in 893 | individual files. 894 | 895 | The authors hereby grant permission to use, copy, modify, distribute, 896 | and license this software and its documentation for any purpose, provided 897 | that existing copyright notices are retained in all copies and that this 898 | notice is included verbatim in any distributions. No written agreement, 899 | license, or royalty fee is required for any of the authorized uses. 900 | Modifications to this software may be copyrighted by their authors 901 | and need not follow the licensing terms described here, provided that 902 | the new terms are clearly indicated on the first page of each file where 903 | they apply. 904 | 905 | IN NO EVENT SHALL THE AUTHORS OR DISTRIBUTORS BE LIABLE TO ANY PARTY 906 | FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES 907 | ARISING OUT OF THE USE OF THIS SOFTWARE, ITS DOCUMENTATION, OR ANY 908 | DERIVATIVES THEREOF, EVEN IF THE AUTHORS HAVE BEEN ADVISED OF THE 909 | POSSIBILITY OF SUCH DAMAGE. 910 | 911 | THE AUTHORS AND DISTRIBUTORS SPECIFICALLY DISCLAIM ANY WARRANTIES, 912 | INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY, 913 | FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. THIS SOFTWARE 914 | IS PROVIDED ON AN "AS IS" BASIS, AND THE AUTHORS AND DISTRIBUTORS HAVE 915 | NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR 916 | MODIFICATIONS. 917 | 918 | GOVERNMENT USE: If you are acquiring this software on behalf of the 919 | U.S. government, the Government shall have only "Restricted Rights" 920 | in the software and related documentation as defined in the Federal 921 | Acquisition Regulations (FARs) in Clause 52.227.19 (c) (2). If you 922 | are acquiring the software on behalf of the Department of Defense, the 923 | software shall be classified as "Commercial Computer Software" and the 924 | Government shall have only "Restricted Rights" as defined in Clause 925 | 252.227-7014 (b) (3) of DFARs. Notwithstanding the foregoing, the 926 | authors grant the U.S. Government and others acting in its behalf 927 | permission to use and distribute the software in accordance with the 928 | terms specified in this license. 929 | 930 | This software is copyrighted by the Regents of the University of 931 | California, Sun Microsystems, Inc., Scriptics Corporation, ActiveState 932 | Corporation, Apple Inc. and other parties. The following terms apply to 933 | all files associated with the software unless explicitly disclaimed in 934 | individual files. 935 | 936 | The authors hereby grant permission to use, copy, modify, distribute, 937 | and license this software and its documentation for any purpose, provided 938 | that existing copyright notices are retained in all copies and that this 939 | notice is included verbatim in any distributions. No written agreement, 940 | license, or royalty fee is required for any of the authorized uses. 941 | Modifications to this software may be copyrighted by their authors 942 | and need not follow the licensing terms described here, provided that 943 | the new terms are clearly indicated on the first page of each file where 944 | they apply. 945 | 946 | IN NO EVENT SHALL THE AUTHORS OR DISTRIBUTORS BE LIABLE TO ANY PARTY 947 | FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES 948 | ARISING OUT OF THE USE OF THIS SOFTWARE, ITS DOCUMENTATION, OR ANY 949 | DERIVATIVES THEREOF, EVEN IF THE AUTHORS HAVE BEEN ADVISED OF THE 950 | POSSIBILITY OF SUCH DAMAGE. 951 | 952 | THE AUTHORS AND DISTRIBUTORS SPECIFICALLY DISCLAIM ANY WARRANTIES, 953 | INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY, 954 | FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. THIS SOFTWARE 955 | IS PROVIDED ON AN "AS IS" BASIS, AND THE AUTHORS AND DISTRIBUTORS HAVE 956 | NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR 957 | MODIFICATIONS. 958 | 959 | GOVERNMENT USE: If you are acquiring this software on behalf of the 960 | U.S. government, the Government shall have only "Restricted Rights" 961 | in the software and related documentation as defined in the Federal 962 | Acquisition Regulations (FARs) in Clause 52.227.19 (c) (2). If you 963 | are acquiring the software on behalf of the Department of Defense, the 964 | software shall be classified as "Commercial Computer Software" and the 965 | Government shall have only "Restricted Rights" as defined in Clause 966 | 252.227-7013 (b) (3) of DFARs. Notwithstanding the foregoing, the 967 | authors grant the U.S. Government and others acting in its behalf 968 | permission to use and distribute the software in accordance with the 969 | terms specified in this license. 970 | 971 | Copyright (c) 1993-1999 Ioi Kim Lam. 972 | Copyright (c) 2000-2001 Tix Project Group. 973 | Copyright (c) 2004 ActiveState 974 | 975 | This software is copyrighted by the above entities 976 | and other parties. The following terms apply to all files associated 977 | with the software unless explicitly disclaimed in individual files. 978 | 979 | The authors hereby grant permission to use, copy, modify, distribute, 980 | and license this software and its documentation for any purpose, provided 981 | that existing copyright notices are retained in all copies and that this 982 | notice is included verbatim in any distributions. No written agreement, 983 | license, or royalty fee is required for any of the authorized uses. 984 | Modifications to this software may be copyrighted by their authors 985 | and need not follow the licensing terms described here, provided that 986 | the new terms are clearly indicated on the first page of each file where 987 | they apply. 988 | 989 | IN NO EVENT SHALL THE AUTHORS OR DISTRIBUTORS BE LIABLE TO ANY PARTY 990 | FOR DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES 991 | ARISING OUT OF THE USE OF THIS SOFTWARE, ITS DOCUMENTATION, OR ANY 992 | DERIVATIVES THEREOF, EVEN IF THE AUTHORS HAVE BEEN ADVISED OF THE 993 | POSSIBILITY OF SUCH DAMAGE. 994 | 995 | THE AUTHORS AND DISTRIBUTORS SPECIFICALLY DISCLAIM ANY WARRANTIES, 996 | INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY, 997 | FITNESS FOR A PARTICULAR PURPOSE, AND NON-INFRINGEMENT. THIS SOFTWARE 998 | IS PROVIDED ON AN "AS IS" BASIS, AND THE AUTHORS AND DISTRIBUTORS HAVE 999 | NO OBLIGATION TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR 1000 | MODIFICATIONS. 1001 | 1002 | GOVERNMENT USE: If you are acquiring this software on behalf of the 1003 | U.S. government, the Government shall have only "Restricted Rights" 1004 | in the software and related documentation as defined in the Federal 1005 | Acquisition Regulations (FARs) in Clause 52.227.19 (c) (2). If you 1006 | are acquiring the software on behalf of the Department of Defense, the 1007 | software shall be classified as "Commercial Computer Software" and the 1008 | Government shall have only "Restricted Rights" as defined in Clause 1009 | 252.227-7013 (c) (1) of DFARs. Notwithstanding the foregoing, the 1010 | authors grant the U.S. Government and others acting in its behalf 1011 | permission to use and distribute the software in accordance with the 1012 | terms specified in this license. 1013 | 1014 | ---------------------------------------------------------------------- 1015 | 1016 | Parts of this software are based on the Tcl/Tk software copyrighted by 1017 | the Regents of the University of California, Sun Microsystems, Inc., 1018 | and other parties. The original license terms of the Tcl/Tk software 1019 | distribution is included in the file docs/license.tcltk. 1020 | 1021 | Parts of this software are based on the HTML Library software 1022 | copyrighted by Sun Microsystems, Inc. The original license terms of 1023 | the HTML Library software distribution is included in the file 1024 | docs/license.html_lib. 1025 | 1026 | 1027 | Graphviz 1028 | THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS COMMON PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT. 1029 | 1. DEFINITIONS 1030 | "Contribution" means: 1031 | “ 1032 | a) in the case of the initial Contributor, the initial code and documentation distributed under this Agreement, and 1033 | b) in the case of each subsequent Contributor: 1034 | i) changes to the Program, and 1035 | ii) additions to the Program; 1036 | where such changes and/or additions to the Program originate from and are distributed by that particular Contributor. A Contribution 'originates' from a Contributor if it was added to the Program by such Contributor itself or anyone acting on such Contributor's behalf. Contributions do not include additions to the Program which: (i) are separate modules of software distributed in conjunction with the Program under their own license agreement, and (ii) are not derivative works of the Program. 1037 | "Contributor" means any person or entity that distributes the Program. 1038 | "Licensed Patents " mean patent claims licensable by a Contributor which are necessarily infringed by the use or sale of its Contribution alone or when combined with the Program. 1039 | "Program" means the Contributions distributed in accordance with this Agreement. 1040 | "Recipient" means anyone who receives the Program under this Agreement, including all Contributors. 1041 | 2. GRANT OF RIGHTS 1042 | “ 1043 | a) Subject to the terms of this Agreement, each Contributor hereby grants Recipient a non-exclusive, worldwide, royalty-free copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, distribute and sublicense the Contribution of such Contributor, if any, and such derivative works, in source code and object code form. 1044 | b) Subject to the terms of this Agreement, each Contributor hereby grants Recipient a non-exclusive, worldwide, royalty-free patent license under Licensed Patents to make, use, sell, offer to sell, import and otherwise transfer the Contribution of such Contributor, if any, in source code and object code form. This patent license shall apply to the combination of the Contribution and the Program if, at the time the Contribution is added by the Contributor, such addition of the Contribution causes such combination to be covered by the Licensed Patents. The patent license shall not apply to any other combinations which include the Contribution. No hardware per se is licensed hereunder. 1045 | c) Recipient understands that although each Contributor grants the licenses to its Contributions set forth herein, no assurances are provided by any Contributor that the Program does not infringe the patent or other intellectual property rights of any other entity. Each Contributor disclaims any liability to Recipient for claims brought by any other entity based on infringement of intellectual property rights or otherwise. As a condition to exercising the rights and licenses granted hereunder, each Recipient hereby assumes sole responsibility to secure any other intellectual property rights needed, if any. For example, if a third party patent license is required to allow Recipient to distribute the Program, it is Recipient's responsibility to acquire that license before distributing the Program. 1046 | d) Each Contributor represents that to its knowledge it has sufficient copyright rights in its Contribution, if any, to grant the copyright license set forth in this Agreement. 1047 | 3. REQUIREMENTS 1048 | A Contributor may choose to distribute the Program in object code form under its own license agreement, provided that: 1049 | “ 1050 | a) it complies with the terms and conditions of this Agreement; and 1051 | b) its license agreement: 1052 | i) effectively disclaims on behalf of all Contributors all warranties and conditions, express and implied, including warranties or conditions of title and non-infringement, and implied warranties or conditions of merchantability and fitness for a particular purpose; 1053 | ii) effectively excludes on behalf of all Contributors all liability for damages, including direct, indirect, special, incidental and consequential damages, such as lost profits; 1054 | iii) states that any provisions which differ from this Agreement are offered by that Contributor alone and not by any other party; and 1055 | iv) states that source code for the Program is available from such Contributor, and informs licensees how to obtain it in a reasonable manner on or through a medium customarily used for software exchange. 1056 | When the Program is made available in source code form: 1057 | “ 1058 | a) it must be made available under this Agreement; and 1059 | b) a copy of this Agreement must be included with each copy of the Program. 1060 | Contributors may not remove or alter any copyright notices contained within the Program. 1061 | Each Contributor must identify itself as the originator of its Contribution, if any, in a manner that reasonably allows subsequent Recipients to identify the originator of the Contribution. 1062 | 4. COMMERCIAL DISTRIBUTION 1063 | Commercial distributors of software may accept certain responsibilities with respect to end users, business partners and the like. While this license is intended to facilitate the commercial use of the Program, the Contributor who includes the Program in a commercial product offering should do so in a manner which does not create potential liability for other Contributors. Therefore, if a Contributor includes the Program in a commercial product offering, such Contributor ("Commercial Contributor") hereby agrees to defend and indemnify every other Contributor ("Indemnified Contributor") against any losses, damages and costs (collectively "Losses") arising from claims, lawsuits and other legal actions brought by a third party against the Indemnified Contributor to the extent caused by the acts or omissions of such Commercial Contributor in connection with its distribution of the Program in a commercial product offering. The obligations in this section do not apply to any claims or Losses relating to any actual or alleged intellectual property infringement. In order to qualify, an Indemnified Contributor must: a) promptly notify the Commercial Contributor in writing of such claim, and b) allow the Commercial Contributor to control, and cooperate with the Commercial Contributor in, the defense and any related settlement negotiations. The Indemnified Contributor may participate in any such claim at its own expense. 1064 | For example, a Contributor might include the Program in a commercial product offering, Product X. That Contributor is then a Commercial Contributor. If that Commercial Contributor then makes performance claims, or offers warranties related to Product X, those performance claims and warranties are such Commercial Contributor's responsibility alone. Under this section, the Commercial Contributor would have to defend claims against the other Contributors related to those performance claims and warranties, and if a court requires any other Contributor to pay any damages as a result, the Commercial Contributor must pay those damages. 1065 | 5. NO WARRANTY 1066 | EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, THE PROGRAM IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Each Recipient is solely responsible for determining the appropriateness of using and distributing the Program and assumes all risks associated with its exercise of rights under this Agreement, including but not limited to the risks and costs of program errors, compliance with applicable laws, damage to or loss of data, programs or equipment, and unavailability or interruption of operations. 1067 | 6. DISCLAIMER OF LIABILITY 1068 | EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, NEITHER RECIPIENT NOR ANY CONTRIBUTORS SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 1069 | 7. GENERAL 1070 | If any provision of this Agreement is invalid or unenforceable under applicable law, it shall not affect the validity or enforceability of the remainder of the terms of this Agreement, and without further action by the parties hereto, such provision shall be reformed to the minimum extent necessary to make such provision valid and enforceable. 1071 | If Recipient institutes patent litigation against a Contributor with respect to a patent applicable to software (including a cross-claim or counterclaim in a lawsuit), then any patent licenses granted by that Contributor to such Recipient under this Agreement shall terminate as of the date such litigation is filed. In addition, if Recipient institutes patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Program itself (excluding combinations of the Program with other software or hardware) infringes such Recipient's patent(s), then such Recipient's rights granted under Section 2(b) shall terminate as of the date such litigation is filed. 1072 | All Recipient's rights under this Agreement shall terminate if it fails to comply with any of the material terms or conditions of this Agreement and does not cure such failure in a reasonable period of time after becoming aware of such noncompliance. If all Recipient's rights under this Agreement terminate, Recipient agrees to cease use and distribution of the Program as soon as reasonably practicable. However, Recipient's obligations under this Agreement and any licenses granted by Recipient relating to the Program shall continue and survive. 1073 | Everyone is permitted to copy and distribute copies of this Agreement, but in order to avoid inconsistency the Agreement is copyrighted and may only be modified in the following manner. The Agreement Steward reserves the right to publish new versions (including revisions) of this Agreement from time to time. No one other than the Agreement Steward has the right to modify this Agreement. IBM is the initial Agreement Steward. IBM may assign the responsibility to serve as the Agreement Steward to a suitable separate entity. Each new version of the Agreement will be given a distinguishing version number. The Program (including Contributions) may always be distributed subject to the version of the Agreement under which it was received. In addition, after a new version of the Agreement is published, Contributor may elect to distribute the Program (including its Contributions) under the new version. Except as expressly stated in Sections 2(a) and 2(b) above, Recipient receives no rights or licenses to the intellectual property of any Contributor under this Agreement, whether expressly, by implication, estoppel or otherwise. All rights in the Program not expressly granted under this Agreement are reserved. 1074 | This Agreement is governed by the laws of the State of New York and the intellectual property laws of the United States of America. No party to this Agreement will bring a legal action under this Agreement more than one year after the cause of action arose. Each party waives its rights to a jury trial in any resulting litigation. 1075 | 1076 | Python Graphviz package 1077 | The MIT License (MIT) 1078 | 1079 | Copyright (c) 2013-2021 Sebastian Bank 1080 | 1081 | Permission is hereby granted, free of charge, to any person obtaining a copy 1082 | of this software and associated documentation files (the "Software"), to deal 1083 | in the Software without restriction, including without limitation the rights 1084 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 1085 | copies of the Software, and to permit persons to whom the Software is 1086 | furnished to do so, subject to the following conditions: 1087 | 1088 | The above copyright notice and this permission notice shall be included in 1089 | all copies or substantial portions of the Software. 1090 | 1091 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 1092 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 1093 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 1094 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 1095 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 1096 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 1097 | THE SOFTWARE. 1098 | 1099 | wxWindows Library Licence, Version 3.1 1100 | ====================================== 1101 | 1102 | Copyright (c) 1998-2005 Julian Smart, Robert Roebling et al 1103 | 1104 | Everyone is permitted to copy and distribute verbatim copies 1105 | of this licence document, but changing it is not allowed. 1106 | 1107 | WXWINDOWS LIBRARY LICENCE 1108 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 1109 | 1110 | This library is free software; you can redistribute it and/or modify it 1111 | under the terms of the GNU Library General Public Licence as published by 1112 | the Free Software Foundation; either version 2 of the Licence, or (at 1113 | your option) any later version. 1114 | 1115 | This library is distributed in the hope that it will be useful, but 1116 | WITHOUT ANY WARRANTY; without even the implied warranty of 1117 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Library 1118 | General Public Licence for more details. 1119 | 1120 | You should have received a copy of the GNU Library General Public Licence 1121 | along with this software, usually in a file named COPYING.LIB. If not, 1122 | write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, 1123 | Boston, MA 02110-1301 USA. 1124 | 1125 | EXCEPTION NOTICE 1126 | 1127 | 1. As a special exception, the copyright holders of this library give 1128 | permission for additional uses of the text contained in this release of 1129 | the library as licenced under the wxWindows Library Licence, applying 1130 | either version 3.1 of the Licence, or (at your option) any later version of 1131 | the Licence as published by the copyright holders of version 1132 | 3.1 of the Licence document. 1133 | 1134 | 2. The exception is that you may use, copy, link, modify and distribute 1135 | under your own terms, binary object code versions of works based 1136 | on the Library. 1137 | 1138 | 3. If you copy code from files distributed under the terms of the GNU 1139 | General Public Licence or the GNU Library General Public Licence into a 1140 | copy of this library, as this licence permits, the exception does not 1141 | apply to the code that you add in this way. To avoid misleading anyone as 1142 | to the status of such modified files, you must delete this exception 1143 | notice from such code and/or adjust the licensing conditions notice 1144 | accordingly. 1145 | 1146 | 4. If you write modifications of your own for this library, it is your 1147 | choice whether to permit this exception to apply to your modifications. 1148 | If you do not wish that, you must delete the exception notice from such 1149 | code and/or adjust the licensing conditions notice accordingly. 1150 | --------------------------------------------------------------------------------