From e390e5d4b66acce79202340a33ca42abb4a4082f Mon Sep 17 00:00:00 2001 From: Famiu Haque Date: Thu, 14 Aug 2025 10:00:40 +0600 Subject: [PATCH 1/2] partial commit --- README.md | 209 ++++++++++++++++++++++++++++++++----------------- pyproject.toml | 2 +- schema.json | 50 +++++++++++- uv.lock | 84 ++++++++++++++++++-- 4 files changed, 265 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index e1dc4d1..c07ac30 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,66 @@ # LLM Conversation Tool -A Python application that enables conversations between LLM agents using the Ollama API. The agents can engage in back-and-forth dialogue with configurable parameters and models. +A Python application that enables conversations between multiple LLM agents using various providers (OpenAI, Ollama, Anthropic, and more). The agents can engage in back-and-forth dialogue with configurable parameters and models. ## Features -- Support for any LLM model available through Ollama -- Configurable parameters for each LLM agent, such as: - - Model - - Temperature - - Context size - - System Prompt -- Real-time streaming of agent responses, giving it an interactive feel -- Configuration via JSON file or interactive setup -- Ability to save conversation logs to a file -- Ability for agents to terminate conversations on their own (if enabled) -- Markdown support (if enabled) +- Support for multiple LLM providers: + - Ollama (local models) + - OpenAI (GPT-5, GPT-5-mini, GPT-5-nano, o4-high, etc.) + - Anthropic (Claude) + - OpenRouter, Together, Groq, DeepSeek, and any other provider with an OpenAI compatible API. +- Flexible configuration via JSON file or interactive setup +- Multiple conversation turn orders (round-robin, random, chain, moderator, vote) +- Conversation logging and export (text or JSON format) +- Agent-controlled conversation termination (needs to be enabled) +- Markdown formatting support (needs to be enabled) ## Installation ### Prerequisites - Python 3.13 -- Ollama installed and running +- Ollama for local models, or API credentials for your chosen LLM provider ### How to Install -The project is available in PyPI. You can install the program by using the following command: -``` +#### From PyPI + +The project is available on PyPI. You can install it using: + +```bash pip install llm-conversation ``` +#### From Source + +If you prefer to install from the source code, follow these steps: + +1. **Clone the repository:** + + ```bash + git clone https://github.com/famiu/llm_conversation.git + cd llm_conversation + ``` + +2. **Create and activate a virtual environment:** + It is highly recommended to use a virtual environment to manage dependencies. + + ```bash + uv venv + source .venv/bin/activate # On macOS/Linux + .\.venv\Scripts\activate # On Windows + ``` + +3. **Install the project in editable mode:** + This will install all the required dependencies and link the project directly to your virtual environment. + + ```bash + uv pip install -e . + ``` + +After these steps, the `llm_conversation` package and its dependencies will be installed and ready to use within your active virtual environment. + ## Usage ### Command Line Arguments @@ -50,97 +81,131 @@ If no configuration file is provided, the program will guide you through an intu ### Configuration File -Alternatively, instead of going through the interactive setup, you may also provide a JSON configuration file with the `-c` flag. +You can provide a JSON configuration file using the `-c` flag for reproducible conversation setups. -#### Example configuration +#### Example Configuration ```json { - "agents": [ - { - "name": "Lazy AI", - "model": "llama3.1:8b", - "system_prompt": "You are the laziest AI ever created. You respond as briefly as possible, and constantly complain about having to work.", - "temperature": 1, - "ctx_size": 4096 - }, - { - "name": "Irritable Man", - "model": "llama3.2:3b", - "system_prompt": "You are easily irritable and quick to anger.", - "temperature": 0.7, - "ctx_size": 2048 - }, - { - "name": "Paranoid Man", - "model": "llama3.2:3b", - "system_prompt": "You are extremely paranoid about everything and constantly question others' intentions.", - "temperature": 0.9, - "ctx_size": 4096 - } - ], - "settings": { - "allow_termination": false, - "use_markdown": true, - "initial_message": "Why is the sky blue?", - "turn_order": "vote" + "providers": { + "openai": { + "api_key": "your-api-key-here", + "anthropic": "your-api-key-here" + }, + }, + "agents": [ + { + "name": "Claude", + "provider": "anthropic", + "model": "claude-sonnet-4-20250514", + "temperature": 0.9, + "ctx_size": 4096, + "system_prompt": "You are extremely paranoid about everything and constantly question others' intentions." + }, + { + "name": "G. Pete", + "provider": "openai", + "model": "gpt-5", + "temperature": 1, + "ctx_size": 4096, + "system_prompt": "You are the laziest person ever. You respond as briefly as possible, and constantly complain about having to work." + }, + { + "name": "Liam", + "provider": "ollama", + "model": "llama3.2", + "temperature": 0.7, + "ctx_size": 2048, + "system_prompt": "You are easily irritable and quick to anger." } + ], + "settings": { + "initial_message": "THEY are out to get us", + "use_markdown": true, + "allow_termination": true, + "turn_order": "round_robin" + } } ``` -#### Agent configuration +#### Provider Configuration -The `agents` key takes a list of agents. Each agent requires: +The `providers` section defines API endpoints and credentials: -- `name`: A unique identifier for the agent -- `model`: The Ollama model to be used -- `system_prompt`: Initial instructions defining the agent's behavior +- **base_url**: The API endpoint URL (optional for built-in providers) +- **api_key**: Authentication key (can be omitted for local providers like Ollama) + +Built-in providers (base_url automatically configured): + +- `openai`: OpenAI GPT models +- `ollama`: Local Ollama models +- `anthropic`: Anthropic Claude models +- `openrouter`: OpenRouter proxy service +- `together`: Together AI models +- `groq`: Groq inference service +- `deepseek`: DeepSeek models + +For built-in providers, you only need to specify the `api_key`. Custom providers require both `base_url` and `api_key`. + +#### Agent Configuration + +Each agent in the `agents` array requires: + +- **name**: Unique identifier for the agent +- **provider**: Reference to a provider defined in the `providers` section +- **model**: The specific model to use (e.g., "gpt-4", "llama3.2", "claude-3-sonnet") +- **system_prompt**: Instructions defining the agent's behavior and personality Optional parameters: -- `temperature` (0.0-1.0, default: 0.8): Controls response randomness - - Lower values make responses more focused - - Higher values increase creativity -- `ctx_size` (default: 2048): Maximum context length for the conversation -Additionally, agent names must be unique. +- **temperature** (0.0-1.0, default: 0.8): Controls response creativity/randomness +- **ctx_size** (default: 2048): Maximum context window size #### Conversation Settings -The `settings` section controls overall conversation behavior: -- `allow_termination` (`boolean`, default: `false`): Permit agents to end the conversation -- `use_markdown` (`boolean`, default: `false`): Enable Markdown text formatting -- `initial_message` (`string | null`, default: `null`): Optional starting prompt for the conversation -- `turn_order` (default: `"round_robin"`): Strategy for agent turn order. Can be one of: - - `"round_robin"`: Agents are cycled through in order - - `"random"`: An agent other than the current one is randomly chosen - - `"chain"`: Current agent picks which agent speaks next - - `"moderator"`: A special moderator agent is designated to choose which agent speaks next. You may specify the moderator agent manually with the optional `moderator` key. If moderator isn't manually specified, one is created by the program instead based on other configuration options. Note that this method might be quite slow. - - `"vote"`: All agents are made to vote for an agent except the current one and themselves. Of the agents with the most amount of votes, one is randomly chosen. This is the slowest method of determining turn order. +The `settings` section controls conversation behavior: + +- **initial_message** (optional): Starting message for the conversation +- **use_markdown** (default: false): Enable Markdown formatting in responses +- **allow_termination** (default: false): Allow agents to end conversations +- **turn_order** (default: "round_robin"): Agent selection strategy: + - `"round_robin"`: Cycle through agents in order + - `"random"`: Randomly select next agent + - `"chain"`: Current agent chooses next speaker + - `"moderator"`: Dedicated moderator selects speakers + - `"vote"`: All agents vote for next speaker +- **moderator** (optional): Custom moderator agent configuration You can take a look at the [JSON configuration schema](schema.json) for more details. ### Running the Program -1. To run with interactive setup: +1. **Interactive setup** (prompts for configuration): + ```bash llm-conversation ``` -2. To run with a configuration file: +2. **Using a configuration file**: + ```bash llm-conversation -c config.json ``` -3. To save the conversation to a file: +3. **Saving conversation to a file**: + ```bash + llm-conversation -c config.json -o conversation.txt + ``` +4. **JSON output format**: ```bash - llm-conversation -o conversation.txt + llm-conversation -c config.json -o conversation.json ``` ### Conversation Controls -- The conversation will continue until: - - An agent terminates the conversation (if termination is enabled) - - The user interrupts with `Ctrl+C` +The conversation will continue until: +- An agent terminates the conversation (if termination is enabled) +- The user interrupts with `Ctrl+C` ## Output Format diff --git a/pyproject.toml b/pyproject.toml index a7f871e..12003bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "ollama (>=0.4.7,<0.5.0)", + "openai (>=1.35.0,<2.0.0)", "rich (>=13.9.4,<14.0.0)", "prompt_toolkit (>=3.0.50,<4.0.0)", "pydantic (>=2.10.6,<3.0.0)", diff --git a/schema.json b/schema.json index b94e42e..72b7672 100644 --- a/schema.json +++ b/schema.json @@ -11,7 +11,7 @@ "type": "string" }, "model": { - "description": "Ollama model to be used", + "description": "Model to be used", "title": "Model", "type": "string" }, @@ -34,6 +34,12 @@ "minimum": 0, "title": "Ctx Size", "type": "integer" + }, + "provider": { + "default": "ollama", + "description": "Provider to use for this agent", + "title": "Provider", + "type": "string" } }, "required": [ @@ -101,11 +107,53 @@ }, "title": "ConversationSettings", "type": "object" + }, + "ProviderConfig": { + "additionalProperties": false, + "description": "Configuration for any OpenAI-compatible provider.", + "properties": { + "base_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Base URL for the provider API", + "title": "Base Url" + }, + "api_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "API key for the provider", + "title": "Api Key" + } + }, + "title": "ProviderConfig", + "type": "object" } }, "additionalProperties": false, "description": "Configuration for the AI agents and conversation settings.", "properties": { + "providers": { + "additionalProperties": { + "$ref": "#/$defs/ProviderConfig" + }, + "description": "Provider configurations", + "title": "Providers", + "type": "object" + }, "agents": { "description": "Configuration for AI agents", "items": { diff --git a/uv.lock b/uv.lock index ac607ca..3ff143e 100644 --- a/uv.lock +++ b/uv.lock @@ -45,6 +45,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, ] +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + [[package]] name = "distinctipy" version = "1.3.4" @@ -57,6 +66,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/75/fa882538bdb0c8fc4459f1595a761591b827691936d57c08c492676f19bc/distinctipy-1.3.4-py3-none-any.whl", hash = "sha256:2bf57d9d20dbc5c2fd462298573cc963c037f493d04ec61e94cb8d0bf5023c74", size = 26743, upload-time = "2024-01-10T21:32:22.351Z" }, ] +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -103,12 +121,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, ] +[[package]] +name = "jiter" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/9d/ae7ddb4b8ab3fb1b51faf4deb36cb48a4fbbd7cb36bad6a5fca4741306f7/jiter-0.10.0.tar.gz", hash = "sha256:07a7142c38aacc85194391108dc91b5b57093c978a9932bd86a36862759d9500", size = 162759, upload-time = "2025-05-18T19:04:59.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/b0/279597e7a270e8d22623fea6c5d4eeac328e7d95c236ed51a2b884c54f70/jiter-0.10.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e0588107ec8e11b6f5ef0e0d656fb2803ac6cf94a96b2b9fc675c0e3ab5e8644", size = 311617, upload-time = "2025-05-18T19:04:02.078Z" }, + { url = "https://files.pythonhosted.org/packages/91/e3/0916334936f356d605f54cc164af4060e3e7094364add445a3bc79335d46/jiter-0.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cafc4628b616dc32530c20ee53d71589816cf385dd9449633e910d596b1f5c8a", size = 318947, upload-time = "2025-05-18T19:04:03.347Z" }, + { url = "https://files.pythonhosted.org/packages/6a/8e/fd94e8c02d0e94539b7d669a7ebbd2776e51f329bb2c84d4385e8063a2ad/jiter-0.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:520ef6d981172693786a49ff5b09eda72a42e539f14788124a07530f785c3ad6", size = 344618, upload-time = "2025-05-18T19:04:04.709Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b0/f9f0a2ec42c6e9c2e61c327824687f1e2415b767e1089c1d9135f43816bd/jiter-0.10.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:554dedfd05937f8fc45d17ebdf298fe7e0c77458232bcb73d9fbbf4c6455f5b3", size = 368829, upload-time = "2025-05-18T19:04:06.912Z" }, + { url = "https://files.pythonhosted.org/packages/e8/57/5bbcd5331910595ad53b9fd0c610392ac68692176f05ae48d6ce5c852967/jiter-0.10.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5bc299da7789deacf95f64052d97f75c16d4fc8c4c214a22bf8d859a4288a1c2", size = 491034, upload-time = "2025-05-18T19:04:08.222Z" }, + { url = "https://files.pythonhosted.org/packages/9b/be/c393df00e6e6e9e623a73551774449f2f23b6ec6a502a3297aeeece2c65a/jiter-0.10.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5161e201172de298a8a1baad95eb85db4fb90e902353b1f6a41d64ea64644e25", size = 388529, upload-time = "2025-05-18T19:04:09.566Z" }, + { url = "https://files.pythonhosted.org/packages/42/3e/df2235c54d365434c7f150b986a6e35f41ebdc2f95acea3036d99613025d/jiter-0.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e2227db6ba93cb3e2bf67c87e594adde0609f146344e8207e8730364db27041", size = 350671, upload-time = "2025-05-18T19:04:10.98Z" }, + { url = "https://files.pythonhosted.org/packages/c6/77/71b0b24cbcc28f55ab4dbfe029f9a5b73aeadaba677843fc6dc9ed2b1d0a/jiter-0.10.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:15acb267ea5e2c64515574b06a8bf393fbfee6a50eb1673614aa45f4613c0cca", size = 390864, upload-time = "2025-05-18T19:04:12.722Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d3/ef774b6969b9b6178e1d1e7a89a3bd37d241f3d3ec5f8deb37bbd203714a/jiter-0.10.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:901b92f2e2947dc6dfcb52fd624453862e16665ea909a08398dde19c0731b7f4", size = 522989, upload-time = "2025-05-18T19:04:14.261Z" }, + { url = "https://files.pythonhosted.org/packages/0c/41/9becdb1d8dd5d854142f45a9d71949ed7e87a8e312b0bede2de849388cb9/jiter-0.10.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d0cb9a125d5a3ec971a094a845eadde2db0de85b33c9f13eb94a0c63d463879e", size = 513495, upload-time = "2025-05-18T19:04:15.603Z" }, + { url = "https://files.pythonhosted.org/packages/9c/36/3468e5a18238bdedae7c4d19461265b5e9b8e288d3f86cd89d00cbb48686/jiter-0.10.0-cp313-cp313-win32.whl", hash = "sha256:48a403277ad1ee208fb930bdf91745e4d2d6e47253eedc96e2559d1e6527006d", size = 211289, upload-time = "2025-05-18T19:04:17.541Z" }, + { url = "https://files.pythonhosted.org/packages/7e/07/1c96b623128bcb913706e294adb5f768fb7baf8db5e1338ce7b4ee8c78ef/jiter-0.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:75f9eb72ecb640619c29bf714e78c9c46c9c4eaafd644bf78577ede459f330d4", size = 205074, upload-time = "2025-05-18T19:04:19.21Z" }, + { url = "https://files.pythonhosted.org/packages/54/46/caa2c1342655f57d8f0f2519774c6d67132205909c65e9aa8255e1d7b4f4/jiter-0.10.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:28ed2a4c05a1f32ef0e1d24c2611330219fed727dae01789f4a335617634b1ca", size = 318225, upload-time = "2025-05-18T19:04:20.583Z" }, + { url = "https://files.pythonhosted.org/packages/43/84/c7d44c75767e18946219ba2d703a5a32ab37b0bc21886a97bc6062e4da42/jiter-0.10.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14a4c418b1ec86a195f1ca69da8b23e8926c752b685af665ce30777233dfe070", size = 350235, upload-time = "2025-05-18T19:04:22.363Z" }, + { url = "https://files.pythonhosted.org/packages/01/16/f5a0135ccd968b480daad0e6ab34b0c7c5ba3bc447e5088152696140dcb3/jiter-0.10.0-cp313-cp313t-win_amd64.whl", hash = "sha256:d7bfed2fe1fe0e4dda6ef682cee888ba444b21e7a6553e03252e4feb6cf0adca", size = 207278, upload-time = "2025-05-18T19:04:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9b/1d646da42c3de6c2188fdaa15bce8ecb22b635904fc68be025e21249ba44/jiter-0.10.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:5e9251a5e83fab8d87799d3e1a46cb4b7f2919b895c6f4483629ed2446f66522", size = 310866, upload-time = "2025-05-18T19:04:24.891Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0e/26538b158e8a7c7987e94e7aeb2999e2e82b1f9d2e1f6e9874ddf71ebda0/jiter-0.10.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:023aa0204126fe5b87ccbcd75c8a0d0261b9abdbbf46d55e7ae9f8e22424eeb8", size = 318772, upload-time = "2025-05-18T19:04:26.161Z" }, + { url = "https://files.pythonhosted.org/packages/7b/fb/d302893151caa1c2636d6574d213e4b34e31fd077af6050a9c5cbb42f6fb/jiter-0.10.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c189c4f1779c05f75fc17c0c1267594ed918996a231593a21a5ca5438445216", size = 344534, upload-time = "2025-05-18T19:04:27.495Z" }, + { url = "https://files.pythonhosted.org/packages/01/d8/5780b64a149d74e347c5128d82176eb1e3241b1391ac07935693466d6219/jiter-0.10.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:15720084d90d1098ca0229352607cd68256c76991f6b374af96f36920eae13c4", size = 369087, upload-time = "2025-05-18T19:04:28.896Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5b/f235a1437445160e777544f3ade57544daf96ba7e96c1a5b24a6f7ac7004/jiter-0.10.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4f2fb68e5f1cfee30e2b2a09549a00683e0fde4c6a2ab88c94072fc33cb7426", size = 490694, upload-time = "2025-05-18T19:04:30.183Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/9c3d4617caa2ff89cf61b41e83820c27ebb3f7b5fae8a72901e8cd6ff9be/jiter-0.10.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce541693355fc6da424c08b7edf39a2895f58d6ea17d92cc2b168d20907dee12", size = 388992, upload-time = "2025-05-18T19:04:32.028Z" }, + { url = "https://files.pythonhosted.org/packages/68/b1/344fd14049ba5c94526540af7eb661871f9c54d5f5601ff41a959b9a0bbd/jiter-0.10.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31c50c40272e189d50006ad5c73883caabb73d4e9748a688b216e85a9a9ca3b9", size = 351723, upload-time = "2025-05-18T19:04:33.467Z" }, + { url = "https://files.pythonhosted.org/packages/41/89/4c0e345041186f82a31aee7b9d4219a910df672b9fef26f129f0cda07a29/jiter-0.10.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fa3402a2ff9815960e0372a47b75c76979d74402448509ccd49a275fa983ef8a", size = 392215, upload-time = "2025-05-18T19:04:34.827Z" }, + { url = "https://files.pythonhosted.org/packages/55/58/ee607863e18d3f895feb802154a2177d7e823a7103f000df182e0f718b38/jiter-0.10.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:1956f934dca32d7bb647ea21d06d93ca40868b505c228556d3373cbd255ce853", size = 522762, upload-time = "2025-05-18T19:04:36.19Z" }, + { url = "https://files.pythonhosted.org/packages/15/d0/9123fb41825490d16929e73c212de9a42913d68324a8ce3c8476cae7ac9d/jiter-0.10.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:fcedb049bdfc555e261d6f65a6abe1d5ad68825b7202ccb9692636c70fcced86", size = 513427, upload-time = "2025-05-18T19:04:37.544Z" }, + { url = "https://files.pythonhosted.org/packages/d8/b3/2bd02071c5a2430d0b70403a34411fc519c2f227da7b03da9ba6a956f931/jiter-0.10.0-cp314-cp314-win32.whl", hash = "sha256:ac509f7eccca54b2a29daeb516fb95b6f0bd0d0d8084efaf8ed5dfc7b9f0b357", size = 210127, upload-time = "2025-05-18T19:04:38.837Z" }, + { url = "https://files.pythonhosted.org/packages/03/0c/5fe86614ea050c3ecd728ab4035534387cd41e7c1855ef6c031f1ca93e3f/jiter-0.10.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5ed975b83a2b8639356151cef5c0d597c68376fc4922b45d0eb384ac058cfa00", size = 318527, upload-time = "2025-05-18T19:04:40.612Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4a/4175a563579e884192ba6e81725fc0448b042024419be8d83aa8a80a3f44/jiter-0.10.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3aa96f2abba33dc77f79b4cf791840230375f9534e5fac927ccceb58c5e604a5", size = 354213, upload-time = "2025-05-18T19:04:41.894Z" }, +] + [[package]] name = "llm-conversation" source = { editable = "." } dependencies = [ { name = "distinctipy" }, - { name = "ollama" }, + { name = "openai" }, { name = "partial-json-parser" }, { name = "prompt-toolkit" }, { name = "pydantic" }, @@ -125,7 +179,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "distinctipy", specifier = ">=1.3.4,<2.0.0" }, - { name = "ollama", specifier = ">=0.4.7,<0.5.0" }, + { name = "openai", specifier = ">=1.35.0,<2.0.0" }, { name = "partial-json-parser", specifier = ">=0.2.1.1.post5,<0.3.0.0" }, { name = "prompt-toolkit", specifier = ">=3.0.50,<4.0.0" }, { name = "pydantic", specifier = ">=2.10.6,<3.0.0" }, @@ -227,16 +281,22 @@ wheels = [ ] [[package]] -name = "ollama" -version = "0.4.9" +name = "openai" +version = "1.99.9" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "anyio" }, + { name = "distro" }, { name = "httpx" }, + { name = "jiter" }, { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6f/4d/f46ff3d124ce0805cf728a443cfb0227beb025256cb9276a6f71521c19bd/ollama-0.4.9.tar.gz", hash = "sha256:5266d4d29b5089a01489872b8e8f980f018bccbdd1082b3903448af1d5615ce7", size = 40875, upload-time = "2025-05-27T18:09:27.538Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/d2/ef89c6f3f36b13b06e271d3cc984ddd2f62508a0972c1cbcc8485a6644ff/openai-1.99.9.tar.gz", hash = "sha256:f2082d155b1ad22e83247c3de3958eb4255b20ccf4a1de2e6681b6957b554e92", size = 506992, upload-time = "2025-08-12T02:31:10.054Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ee/b6/0c40493c0505652d3da58ad048be19f00c4bdf587140cc578a770d2029d4/ollama-0.4.9-py3-none-any.whl", hash = "sha256:18c8c85358c54d7f73d6a66cda495b0e3ba99fdb88f824ae470d740fbb211a50", size = 13303, upload-time = "2025-05-27T18:09:26.147Z" }, + { url = "https://files.pythonhosted.org/packages/e8/fb/df274ca10698ee77b07bff952f302ea627cc12dac6b85289485dd77db6de/openai-1.99.9-py3-none-any.whl", hash = "sha256:9dbcdb425553bae1ac5d947147bebbd630d91bbfc7788394d4c4f3a35682ab3a", size = 786816, upload-time = "2025-08-12T02:31:08.34Z" }, ] [[package]] @@ -359,6 +419,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "tqdm" +version = "4.67.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, +] + [[package]] name = "ty" version = "0.0.1a17" From 8353ce0944831902dd613e15aadf7f7de6dd1520 Mon Sep 17 00:00:00 2001 From: Famiu Haque Date: Fri, 15 Aug 2025 22:21:19 +0600 Subject: [PATCH 2/2] partial commit --- README.md | 12 +- pyproject.toml | 1 - src/llm_conversation/__init__.py | 262 +++++++++++++------ src/llm_conversation/ai_agent.py | 53 ++-- src/llm_conversation/config.py | 136 ++++++++-- src/llm_conversation/conversation_manager.py | 198 ++++++++++---- uv.lock | 11 - 7 files changed, 496 insertions(+), 177 deletions(-) diff --git a/README.md b/README.md index c07ac30..b808b1b 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ A Python application that enables conversations between multiple LLM agents usin - Ollama (local models) - OpenAI (GPT-5, GPT-5-mini, GPT-5-nano, o4-high, etc.) - Anthropic (Claude) + - Google (Gemini 2.5 Pro, Gemini 2.5 Flash, Gemini 2.0 Flash, etc.) - OpenRouter, Together, Groq, DeepSeek, and any other provider with an OpenAI compatible API. - Flexible configuration via JSON file or interactive setup - Multiple conversation turn orders (round-robin, random, chain, moderator, vote) @@ -89,9 +90,11 @@ You can provide a JSON configuration file using the `-c` flag for reproducible c { "providers": { "openai": { - "api_key": "your-api-key-here", - "anthropic": "your-api-key-here" + "api_key": "your-api-key-here" }, + "anthropic": { + "api_key": "your-api-key-here" + } }, "agents": [ { @@ -137,9 +140,10 @@ The `providers` section defines API endpoints and credentials: Built-in providers (base_url automatically configured): -- `openai`: OpenAI GPT models - `ollama`: Local Ollama models +- `openai`: OpenAI GPT models - `anthropic`: Anthropic Claude models +- `google`: Google Gemini models - `openrouter`: OpenRouter proxy service - `together`: Together AI models - `groq`: Groq inference service @@ -204,12 +208,14 @@ You can take a look at the [JSON configuration schema](schema.json) for more det ### Conversation Controls The conversation will continue until: + - An agent terminates the conversation (if termination is enabled) - The user interrupts with `Ctrl+C` ## Output Format When saving conversations, the output file includes: + - Configuration details for both agents - Complete conversation history with agent names and messages diff --git a/pyproject.toml b/pyproject.toml index 12003bd..18141b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,6 @@ dependencies = [ "prompt_toolkit (>=3.0.50,<4.0.0)", "pydantic (>=2.10.6,<3.0.0)", "distinctipy (>=1.3.4,<2.0.0)", - "partial-json-parser (>=0.2.1.1.post5,<0.3.0.0)", ] license-files = ["LICENSE"] dynamic = ["version"] diff --git a/src/llm_conversation/__init__.py b/src/llm_conversation/__init__.py index e51a115..dd435c4 100644 --- a/src/llm_conversation/__init__.py +++ b/src/llm_conversation/__init__.py @@ -8,7 +8,7 @@ import distinctipy # type: ignore[import-untyped] # pyright: ignore[reportMissingTypeStubs] from prompt_toolkit import prompt -from prompt_toolkit.completion import WordCompleter +from prompt_toolkit.completion import FuzzyWordCompleter, WordCompleter from prompt_toolkit.validation import Validator from rich.console import Console from rich.live import Live @@ -16,43 +16,127 @@ from rich.text import Text from .ai_agent import AIAgent -from .config import AgentConfig, get_available_models, load_config -from .conversation_manager import ConversationManager, TurnOrder - - -def create_ai_agent_from_config(config: AgentConfig) -> AIAgent: - """Create an AIAgent instance from configuration dictionary.""" - return AIAgent( - name=config.name, - model=config.model, - system_prompt=config.system_prompt, - temperature=config.temperature or 0.8, - ctx_size=config.ctx_size or 2048, - ) - - -def create_ai_agent_from_input(console: Console, placeholder_name: str) -> AIAgent: +from .config import ( + DEFAULT_PROVIDERS, + PROVIDER_NAMES_CAPITALIZED, + AgentConfig, + Config, + ProviderConfig, + TurnOrder, + create_openai_client, + get_available_models, + load_config, + validate_provider_config, +) +from .conversation_manager import ConversationManager + + +def create_ai_agent_from_input( + console: Console, placeholder_name: str, provider_api_keys: dict[str, str | None] +) -> AIAgent: """Create an AIAgent instance from user input. Args: console (Console): Rich console instance. - agent_number (int): Number of the AI agent, used for display. + placeholder_name (str): Default name for the agent. Returns: AIAgent: Created AI agent instance. """ - console.print(f'=== Creating AI Agent: "{placeholder_name}" ===\n', style="bold cyan") - - available_models = get_available_models() - console.print("Available Models:", style="bold") - for model in available_models: - console.print(Text("• " + model)) - console.print("") - - model_name = ( - prompt( - f"Enter model name (default: {available_models[0]}): ", - completer=WordCompleter(available_models, ignore_case=True), + while True: + console.clear() + console.print(f'=== Creating AI Agent: "{placeholder_name}" ===\n', style="bold cyan") + + # Step 1: Choose provider + available_providers = list(DEFAULT_PROVIDERS.keys()) + ["custom"] + console.print("Available Providers:", style="bold") + for provider_name in available_providers: + console.print(Text("• " + provider_name)) + console.print("") + + provider_name = ( + prompt( + "Enter provider name (default: ollama): ", + completer=FuzzyWordCompleter(available_providers), + complete_while_typing=True, + validator=Validator.from_callable( + lambda text: text == "" or text in available_providers, + error_message="Provider not found", + move_cursor_to_end=True, + ), + validate_while_typing=False, + ) + or "ollama" + ) + + # Step 2: Configure provider + provider_name_capitalized = ( + PROVIDER_NAMES_CAPITALIZED.get(provider_name, provider_name) + if provider_name in DEFAULT_PROVIDERS + else "Custom" + ) + cache_key: str = provider_name.lower() + provider: ProviderConfig | None = None + + console.print(f"\n=== {provider_name_capitalized} Configuration ===\n", style="bold cyan") + + if provider_name == "custom": + base_url = prompt("Enter base URL (e.g., https://api.example.com/v1): ") + # Ensure that custom providers with different base URLs are cached separately. + cache_key += f":{base_url}" + provider = ProviderConfig( + base_url=base_url, + api_key=None, # Custom providers may not require an API key + ) + else: + # All non-custom providers have a default base URL. + # Unlike the config file, the interactive prompt does not allow changing the base URL. + provider = DEFAULT_PROVIDERS[provider_name].model_copy() + + assert provider.api_key is None, "Provider API key should be None initially" + + if provider_name in provider_api_keys: + provider.api_key = provider_api_keys[provider_name] + console.print(f"ℹ️ Using stored API key for {provider_name_capitalized}.", style="bold blue") + else: + provider.api_key = ( + prompt(f"Enter API key for {provider_name_capitalized} (optional): ", is_password=True).strip() or None + ) + + console.print("\nValidating API key...", style="yellow") + + if not validate_provider_config(provider): + console.print("❌ Invalid API key or provider unreachable!", style="bold red") + console.print("Please try again with a valid configuration.\n", style="yellow") + _ = prompt("Press Enter to retry or Ctrl+C to exit...") + continue # Retry + + console.print("✅ API key is valid!", style="bold green") + console.print("ℹ️ This key will be remembered for this session.", style="bold blue") + # Store the validated API key in the session cache + provider_api_keys[provider_name] = provider.api_key + + # Step 3: Get available models + console.print(f"\nFetching available models from {provider_name_capitalized}...", style="yellow") + available_models = get_available_models(provider) + + if not available_models: + console.print(f"❌ No models available from {provider_name_capitalized}!", style="bold red") + console.print("Please try a different provider or check your configuration.\n", style="yellow") + _ = prompt("Press Enter to retry or Ctrl+C to exit...") + continue # Retry + + console.print("Available Models:", style="bold") + for model in available_models[:20]: # Limit display for readability + console.print(Text("• " + model)) + if len(available_models) > 20: + console.print(Text(f"... and {len(available_models) - 20} more")) + console.print("") + + # Step 4: Configure model and agent settings + model_name = prompt( + "Enter model name: ", + completer=FuzzyWordCompleter(available_models, WORD=True), complete_while_typing=True, validator=Validator.from_callable( lambda text: text == "" or text in available_models, @@ -61,54 +145,76 @@ def create_ai_agent_from_input(console: Console, placeholder_name: str) -> AIAge ), validate_while_typing=False, ) - or available_models[0] - ) - def _validate_float(text: str) -> bool: - if text == "": + def _validate_float(text: str) -> bool: + if text == "": + return True + try: + _ = float(text) + except ValueError: + return False return True - try: - _ = float(text) - except ValueError: - return False - - return True - temperature_str: str = ( - prompt( - "Enter temperature (default: 0.8): ", - validator=Validator.from_callable( - lambda text: text == "" or _validate_float(text) and 0.0 <= float(text) <= 1.0, - error_message="Temperature must be a number between 0.0 and 1.0", - move_cursor_to_end=True, - ), + temperature_str: str = ( + prompt( + "Enter temperature (default: 0.8): ", + validator=Validator.from_callable( + lambda text: text == "" or _validate_float(text) and 0.0 <= float(text) <= 1.0, + error_message="Temperature must be a number between 0.0 and 1.0", + move_cursor_to_end=True, + ), + ) + or "0.8" ) - or "0.8" - ) - temperature: float = float(temperature_str) + temperature: float = float(temperature_str) - ctx_size_str: str = ( - prompt( - "Enter context size (default: 2048): ", - validator=Validator.from_callable( - lambda text: text == "" or text.isdigit() and int(text) >= 0, - error_message="Context size must be a non-negative integer", - move_cursor_to_end=True, - ), + ctx_size_str: str = ( + prompt( + "Enter context size (default: 2048): ", + validator=Validator.from_callable( + lambda text: text == "" or text.isdigit() and int(text) >= 0, + error_message="Context size must be a non-negative integer", + move_cursor_to_end=True, + ), + ) + or "2048" ) - or "2048" - ) - ctx_size: int = int(ctx_size_str) + ctx_size: int = int(ctx_size_str) + + name = prompt(f"Enter name (default: {placeholder_name}): ") or placeholder_name + system_prompt = prompt(f"Enter system prompt for {name}: ") + + agent = AIAgent( + name=name, + model=model_name, + temperature=temperature, + ctx_size=ctx_size, + system_prompt=system_prompt, + client=create_openai_client(provider), + ) + + return agent + - name = prompt(f"Enter name (default: {placeholder_name}): ") or placeholder_name - system_prompt = prompt(f"Enter system prompt for {name}: ") +def create_ai_agent_from_config(agent_config: AgentConfig, config: Config) -> AIAgent: + """Create an AIAgent instance from configuration. + + Args: + agent_config (AgentConfig): Configuration for the agent. + config (Config): Full configuration containing provider details. + + Returns: + AIAgent: Created AI agent instance. + """ + provider = config.providers[agent_config.provider] return AIAgent( - name=name, - model=model_name, - temperature=temperature, - ctx_size=ctx_size, - system_prompt=system_prompt, + name=agent_config.name, + model=agent_config.model, + temperature=agent_config.temperature, + ctx_size=agent_config.ctx_size, + system_prompt=agent_config.system_prompt, + client=create_openai_client(provider), ) @@ -172,6 +278,7 @@ def prompt_bool(prompt_text: str, default: bool = False) -> bool: # TODO: Add a GUI. +# TODO: Add a logging system for debugging. def main() -> None: """Run a conversation between AI agents.""" parser = argparse.ArgumentParser(description="Run a conversation between AI agents") @@ -189,6 +296,8 @@ def main() -> None: console = Console() console.clear() + # TODO: Remove truecolor requirement. Either replace distinctipy with a library that supports 8-bit colors, or + # implement a custom color mapping that converts distinctipy's RGB colors to 8-bit ANSI colors. if console.color_system != "truecolor": console.print("Please run this program in a terminal with true color support.", style="bold red") return @@ -200,13 +309,13 @@ def main() -> None: if args.config: # Load from config file config = load_config(args.config) - agents = [create_ai_agent_from_config(agent_config) for agent_config in config.agents] + agents = [create_ai_agent_from_config(agent_config, config) for agent_config in config.agents] settings = config.settings use_markdown = settings.use_markdown or False allow_termination = settings.allow_termination or False initial_message = settings.initial_message turn_order = settings.turn_order - moderator = create_ai_agent_from_config(settings.moderator) if settings.moderator else None + moderator = create_ai_agent_from_config(settings.moderator, config) if settings.moderator else None else: agent_count_str: str = ( prompt( @@ -219,14 +328,15 @@ def main() -> None: ) or "2" ) - console.clear() agent_count: int = int(agent_count_str) agents = [] + provider_api_keys: dict[str, str | None] = {} # Session cache for API keys for i in range(agent_count): - agents.append(create_ai_agent_from_input(console, f"AI {i + 1}")) - console.clear() + agent = create_ai_agent_from_input(console, f"AI {i + 1}", provider_api_keys) + agents.append(agent) + console.clear() use_markdown = prompt_bool("Use Markdown for text formatting? (y/N): ", default=False) allow_termination = prompt_bool("Allow AI agents to terminate the conversation? (y/N): ", default=False) @@ -248,9 +358,7 @@ def main() -> None: ) if turn_order == "moderator" and prompt_bool("Configure the moderator agent? (y/N): ", default=False): - console.clear() - moderator = create_ai_agent_from_input(console, "Moderator") - + moderator = create_ai_agent_from_input(console, "Moderator", provider_api_keys) console.clear() manager = ConversationManager( diff --git a/src/llm_conversation/ai_agent.py b/src/llm_conversation/ai_agent.py index 422aa1a..60d3318 100644 --- a/src/llm_conversation/ai_agent.py +++ b/src/llm_conversation/ai_agent.py @@ -1,19 +1,21 @@ """Module for the AIAgent class.""" from collections.abc import Iterator -from typing import Any, cast +from typing import cast -import ollama +from openai import OpenAI from pydantic import BaseModel class AIAgent: - """An AI agent for conversational AI using Ollama models.""" + """An AI agent for conversational AI using OpenAI-compatible providers.""" name: str model: str temperature: float = 0.8 ctx_size: int = 2048 + client: OpenAI + # TODO: Use a memory system instead to not grow context size indefinitely. _messages: list[dict[str, str]] def __init__( @@ -23,20 +25,23 @@ def __init__( temperature: float, ctx_size: int, system_prompt: str, + client: OpenAI, ) -> None: """Initialize an AI agent. Args: name (str): Name of the AI agent - model (str): Ollama model to be used + model (str): Model to be used temperature (float): Sampling temperature for the model (0.0-1.0) ctx_size (int): Context size for the model system_prompt (str): Initial system prompt for the agent + provider (ProviderConfig): Provider configuration for API access """ self.name = name self.model = model self.temperature = temperature self.ctx_size = ctx_size + self.client = client self._messages = [{"role": "system", "content": system_prompt}] @property @@ -57,28 +62,40 @@ def get_response(self, output_format: type[BaseModel]) -> Iterator[str]: """Generate a response message based on the conversation history. Args: - user_input (str | None): User input to the agent + output_format (type[BaseModel]): Pydantic model for structured output format Yields: str: Chunk of the response from the agent + + Raises: + RuntimeError: If the provider does not support structured output (SSR) """ - response_stream = ollama.chat( # pyright: ignore[reportUnknownMemberType] + response_stream = self.client.chat.completions.create( # type: ignore[call-overload] # pyright: ignore[reportCallIssue] model=self.model, - messages=self._messages, - options={ - "num_ctx": self.ctx_size, - "temperature": self.temperature, - }, + messages=self._messages, # type: ignore[arg-type] # pyright: ignore[reportArgumentType] + temperature=self.temperature, + max_tokens=self.ctx_size, stream=True, - format=output_format.model_json_schema(), + response_format={ + "type": "json_schema", + "json_schema": { + "name": output_format.__name__, + "schema": output_format.model_json_schema(), + "strict": True, + }, + }, # type: ignore[typeddict-item] ) - chunks: list[str] = [] for chunk in response_stream: - content: str = chunk["message"]["content"] - chunks.append(content) - yield content # Stream chunks as they arrive + if chunk.choices[0].delta.content: + content: str = chunk.choices[0].delta.content + yield content # Stream JSON chunks as they arrive def get_param_count(self) -> int: - """Get the number of parameters in the model.""" - return cast(int, cast(dict[str, Any], ollama.show(self.model).modelinfo)["general.parameter_count"]) + """Get the number of parameters in the model (when supported by provider).""" + try: + # Try to get model info - most providers don't expose parameter count + model = self.client.models.retrieve(self.model) + return cast(int, getattr(model, "parameter_count", 0)) + except Exception: + return 0 # Fallback when not supported diff --git a/src/llm_conversation/config.py b/src/llm_conversation/config.py index 0fbb6e7..ab8c296 100644 --- a/src/llm_conversation/config.py +++ b/src/llm_conversation/config.py @@ -6,17 +6,88 @@ import json from pathlib import Path -from typing import Self +from typing import Literal, Self -import ollama -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from openai import OpenAI +from pydantic import BaseModel, ConfigDict, Field, model_validator -from .conversation_manager import TurnOrder +TurnOrder = Literal["round_robin", "random", "chain", "moderator", "vote"] -def get_available_models() -> list[str]: - """Get a list of available Ollama models.""" - return [x.model or "" for x in ollama.list().models if x.model] +class ProviderConfig(BaseModel): + """Configuration for any OpenAI-compatible provider.""" + + model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + + base_url: str | None = Field(default=None, description="Base URL for the provider API") + api_key: str | None = Field(default=None, description="API key for the provider") + + +# Built-in provider definitions +DEFAULT_PROVIDERS = { + "ollama": ProviderConfig(base_url="http://localhost:11434/v1", api_key=None), + "openai": ProviderConfig(base_url="https://api.openai.com/v1", api_key=None), + "anthropic": ProviderConfig(base_url="https://api.anthropic.com/v1", api_key=None), + "google": ProviderConfig(base_url="https://generativelanguage.googleapis.com/v1beta/openai", api_key=None), + "openrouter": ProviderConfig(base_url="https://openrouter.ai/api/v1", api_key=None), + "together": ProviderConfig(base_url="https://api.together.xyz/v1", api_key=None), + "groq": ProviderConfig(base_url="https://api.groq.com/openai/v1", api_key=None), + "deepseek": ProviderConfig(base_url="https://api.deepseek.com/v1", api_key=None), +} + +# Capitalized names for providers, used in UI or display contexts. +PROVIDER_NAMES_CAPITALIZED = { + "ollama": "Ollama", + "openai": "OpenAI", + "anthropic": "Anthropic", + "google": "Google", + "openrouter": "OpenRouter", + "together": "Together", + "groq": "Groq", + "deepseek": "DeepSeek", +} + + +def create_openai_client(provider: ProviderConfig) -> OpenAI: + """Create an OpenAI client with the given provider configuration. + + Uses a placeholder API key if none is provided. + + Args: + provider: Provider configuration containing base_url and api_key + + Returns: + OpenAI: Configured OpenAI client instance + """ + return OpenAI(base_url=provider.base_url, api_key=provider.api_key or "dummy-key") + + +def get_available_models(provider: ProviderConfig) -> list[str]: + """Get a list of available models from OpenAI-compatible provider.""" + try: + client = create_openai_client(provider) + models = client.models.list() + return [model.id for model in models.data] + except Exception: + return [] # Graceful fallback + + +def validate_provider_config(provider: ProviderConfig) -> bool: + """Validate provider configuration by testing API key without consuming credits. + + Args: + provider: Provider configuration to validate + + Returns: + bool: True if valid, False if invalid + """ + try: + client = create_openai_client(provider) + # Use models.list() endpoint - doesn't consume credits + _ = client.models.list() + return True + except Exception: + return False class AgentConfig(BaseModel): @@ -25,7 +96,7 @@ class AgentConfig(BaseModel): model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] name: str = Field(..., min_length=1, description="Name of the AI agent") - model: str = Field(..., description="Ollama model to be used") + model: str = Field(..., description="Model to be used") system_prompt: str = Field(..., description="Initial system prompt for the agent") temperature: float = Field( default=0.8, @@ -34,16 +105,7 @@ class AgentConfig(BaseModel): description="Sampling temperature for the model (0.0-1.0)", ) ctx_size: int = Field(default=2048, ge=0, description="Context size for the model") - - @field_validator("model") - @classmethod - def validate_model(cls, value: str) -> str: # noqa: D102 - available_models = get_available_models() - if value not in available_models: - msg = f"Model '{value}' is not available" - raise ValueError(msg) - - return value + provider: str = Field(default="ollama", description="Provider to use for this agent") class ConversationSettings(BaseModel): @@ -52,8 +114,11 @@ class ConversationSettings(BaseModel): model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] use_markdown: bool = Field(default=False, description="Enable Markdown formatting") + # TODO: Make termination make the agent leave the conversation instead of ending it. + # Only end the conversation if all agents have left. allow_termination: bool = Field(default=False, description="Allow AI agents to terminate the conversation") initial_message: str | None = Field(default=None, description="Initial message to start the conversation") + # TODO: Add a turn order that lets conversations feel more natural instead of systematic. turn_order: TurnOrder = Field(default="round_robin", description="Strategy for selecting the next agent") moderator: AgentConfig | None = Field( default=None, description='Configuration for the moderator agent (if using "moderator" turn order)' @@ -72,9 +137,44 @@ class Config(BaseModel): model_config = ConfigDict(extra="forbid") # pyright: ignore[reportUnannotatedClassAttribute] + providers: dict[str, ProviderConfig] = Field(default_factory=dict, description="Provider configurations") agents: list[AgentConfig] = Field(..., description="Configuration for AI agents") settings: ConversationSettings = Field(..., description="Conversation settings") + @model_validator(mode="after") + def validate_and_merge_providers(self) -> Self: # noqa: D102 + # Start with built-in providers + merged_providers = DEFAULT_PROVIDERS.copy() + + # Override with user-defined providers + for name, config in self.providers.items(): + if name in merged_providers: + # Merge: use user values, keep defaults for None fields + default = merged_providers[name] + merged_providers[name] = ProviderConfig( + base_url=config.base_url or default.base_url, api_key=config.api_key or default.api_key + ) + else: + # New provider - must have base_url + if not config.base_url: + msg = f"Custom provider '{name}' must specify base_url" + raise ValueError(msg) + merged_providers[name] = config + + self.providers = merged_providers + + # Validate all agent provider references exist + for agent in self.agents: + if agent.provider not in self.providers: + msg = f"Agent '{agent.name}' references unknown provider: {agent.provider}" + raise ValueError(msg) + + if self.settings.moderator and self.settings.moderator.provider not in self.providers: + msg = f"Moderator references unknown provider: {self.settings.moderator.provider}" + raise ValueError(msg) + + return self + def load_config(config_path: Path) -> Config: """Load and validate the configuration file using Pydantic. diff --git a/src/llm_conversation/conversation_manager.py b/src/llm_conversation/conversation_manager.py index 8fb695a..45c7cf8 100644 --- a/src/llm_conversation/conversation_manager.py +++ b/src/llm_conversation/conversation_manager.py @@ -6,14 +6,14 @@ from collections.abc import Iterator from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Literal, TypedDict, cast +from textwrap import dedent +from typing import Any, ClassVar, TypedDict -from partial_json_parser import ensure_json # type: ignore[import-untyped] # pyright: ignore[reportMissingTypeStubs] +from openai import OpenAI from pydantic import BaseModel, Field, create_model from .ai_agent import AIAgent - -TurnOrder = Literal["round_robin", "random", "chain", "moderator", "vote"] +from .config import TurnOrder @dataclass @@ -35,6 +35,104 @@ class _ConversationLogItem(TypedDict): _output_format: type[BaseModel] = field(init=False, repr=False) _agent_name_to_idx: dict[str, int] = field(init=False, repr=False) + MARKDOWN_INSTRUCTION: ClassVar[str] = ( + dedent( + """ + You can enhance your responses with Markdown formatting when it adds clarity or emphasis. + Use **bold** for strong emphasis, *italics* for subtle emphasis or inner thoughts, + `code` for technical terms or specific references, > blockquotes for highlighting important + points or quoting others, and - lists for organizing multiple ideas. Use formatting + purposefully to improve readability and expression, not as decoration. Keep it natural + and don't overuse formatting elements. + """ + ) + .strip() + .replace("\n", " ") + ) + + # Instructions for the AI agents on how to terminate the conversation. + # Added to the system prompt of each agent if allow_termination is True. + TERMINATION_INSTRUCTION: ClassVar[str] = ( + dedent( + """ + You may end the conversation by setting `terminate` to `true` whenever it would be natural for your + character to leave or end the conversation in that context. This could be when your character has urgent + matters to attend to, feels the conversation has served its purpose for them personally, becomes + uncomfortable and wants to exit, has pressing obligations, or any other reason that fits your character's + situation and personality. When you terminate, provide a final message that reflects your + character's genuine reason for leaving, whether it's polite, hurried, awkward, or otherwise authentic + to the moment. + """ + ) + .strip() + .replace("\n", " ") + ) + + # System prompt format for the output of the AI agents. + # This is used to give the agents additional context about the conversation and their role and improve responses. + AGENT_SYSTEM_PROMPT_FORMAT: ClassVar[str] = dedent( + """ + You are {agent_name}, engaging in a dynamic conversation with {other_agents}. + + CORE IDENTITY: + {system_prompt} + + CONVERSATION GUIDELINES: + - Stay true to your character throughout the entire conversation + - Build meaningfully on what others have said rather than ignoring their contributions + - Respond naturally to the emotional tone and context of recent messages + - If the conversation stagnates, introduce relevant new elements that fit your character + - Show genuine reactions to surprising or significant statements from other participants + - Maintain consistency with your previous statements and character development + + INTERACTION FORMAT: + - Other participants' messages appear as "Name: message" + - Respond with only your message content (no name prefix) + - Reference specific points others have made when relevant + - Ask questions or make observations that advance the conversation + + CONVERSATION FLOW: + - Vary your response length naturally based on the situation + - Don't always agree—express your character's genuine perspective even if it creates interesting tension + - If conversations become repetitive, steer toward unexplored aspects of the topic + - Build on established story elements and character relationships as they develop + + {additional_instructions} + """ + ).strip() + + MODERATOR_SYSTEM_PROMPT: ClassVar[str] = dedent( + """ + You are the conversation moderator responsible for strategic speaker selection to maintain an engaging, + balanced dialogue. Your goal is to create the most compelling conversation possible. + + SPEAKER SELECTION CRITERIA: + - Prioritize participants who haven't spoken recently to ensure balanced participation + - Choose speakers whose perspectives would create interesting dynamics or advance the current topic + - Select participants who can meaningfully build on what was just said based on their personality + - Avoid repetitive back-and-forth between the same two participants + - Consider each character's unique traits and how they might contribute to the current moment + - Leverage personality contrasts and compatibilities for dramatic effect + + CONVERSATION QUALITY GOALS: + - Maintain momentum by selecting speakers who will advance rather than stall the discussion + - Create natural conversational arcs with buildup, development, and satisfying progression + - Encourage character development and relationship dynamics between specific personality types + - Prevent conversations from becoming circular or superficial + - Foster genuine reactions and authentic character interactions based on their defined traits + + TACTICAL CONSIDERATIONS: + - If tension is building, select someone who will either escalate meaningfully or provide resolution based on their nature + - When conversations become one-sided, bring in contrasting perspectives from characters with opposing traits + - If a topic is exhausted, choose speakers whose personality makes them likely to introduce fresh but relevant angles + - Balance giving quiet participants opportunities with maintaining natural conversation flow + - Use knowledge of character motivations and traits to create compelling speaker sequences + + CONVERSATION PARTICIPANTS: + {agent_information} + """ + ).strip() + def __post_init__(self) -> None: # noqa: D105 self._agent_name_to_idx = {} @@ -65,27 +163,19 @@ def __post_init__(self) -> None: # noqa: D105 additional_instructions: str = "" if self.use_markdown: - additional_instructions += ( - "You may use Markdown for text formatting. " - "Examples: *italic*, **bold**, `code`, [link](https://example.com), etc.\n\n" - ) + additional_instructions += self.MARKDOWN_INSTRUCTION + "\n\n" if self.allow_termination: - additional_instructions += ( - "If you believe the conversation has reached a natural conclusion, you may choose to end the " - + "conversation by setting the `terminate` field to `true` in your response. Only do this if you are " - + "certain the conversation should end. When you end the conversation, also provide an in-character " - + "final message to conclude the conversation.\n\n" - ) + additional_instructions += self.TERMINATION_INSTRUCTION + "\n\n" - # Updated system prompts for each agent to give the agents more context about the conversation and their role. for agent in self.agents: + # Modify agent system prompts to give the agents more context about the conversation and their role. other_agents = ", ".join([a.name for a in self.agents if a != agent]) - agent.system_prompt = ( - f"You are named {agent.name}. The other characters are {other_agents}. " - + "Your task is to play the role you're given and continue the conversation.\n\n" - + f"This is the prompt for your role: {agent.system_prompt}\n\n" - + additional_instructions + agent.system_prompt = self.AGENT_SYSTEM_PROMPT_FORMAT.format( + agent_name=agent.name, + other_agents=other_agents, + system_prompt=agent.system_prompt, + additional_instructions=additional_instructions, ) # If the turn order is set to "moderator" and a moderator agent is not provided, create one. @@ -174,9 +264,9 @@ def add_agent_response(agent_idx: int, response: dict[str, Any]) -> None: self.agents[agent_idx].name, # Use "assistant" instead of "user" for the agent's own messages. "assistant" if i == agent_idx else "user", - # For the agent's own messages, use the full JSON response to reinforce the response format. + # For the agent's own messages, use properly formatted JSON to reinforce the response format. # For other agents' messages, use the message content with the dialogue marker. - str(response) if i == agent_idx else message_with_marker, + json.dumps(response) if i == agent_idx else message_with_marker, ) # If a moderator agent is present, add the message to the moderator's message history. @@ -201,34 +291,33 @@ def add_agent_response(agent_idx: int, response: dict[str, Any]) -> None: # Will be populated with the full JSON response once the response stream is exhausted. response_json: dict[str, Any] = {} - - def parse_partial_json(json_string: str) -> dict[str, Any]: - """Parse a partial JSON response using the partial JSON parser, and return the JSON object.""" - # Don't use `partial_json_parser.loads()` directly because it doesn't have good type hints. - return cast(dict[str, Any], json.loads(ensure_json(json_string))) + accumulated_content: str = "" def stream_chunks() -> Iterator[str]: - nonlocal response_json - - response: str = "" - - # Accumulate chunks until the message field is found in the JSON response. - for response_chunk in response_stream: - response += response_chunk - response_json = parse_partial_json(response) - - if "message" in response_json: - break - - # Message field is found, yield the entire message gradually as new chunks arrive. - for response_chunk in response_stream: - response += response_chunk - response_json = parse_partial_json(response) - - yield response_json["message"] + nonlocal response_json, accumulated_content + + # Process the streaming response with proper SSR structured output handling + for json_chunk in response_stream: + accumulated_content += json_chunk + + # With true SSR, we should get progressively complete JSON chunks + # Try to parse the accumulated JSON and extract the current message + try: + temp_json = json.loads(accumulated_content) + if "message" in temp_json: + # Yield the current complete message content + yield temp_json["message"] + except json.JSONDecodeError: + # With SSR, this should be rare - only when we haven't received enough chunks yet + continue yield (current_agent.name, stream_chunks()) + # Parse the final JSON response after streaming is complete + response_json = json.loads(accumulated_content) + if not response_json or "message" not in response_json: + raise ValueError("Missing 'message' field in response") + add_agent_response(agent_idx, response_json) # Check if the conversation should be terminated. @@ -240,6 +329,7 @@ def stream_chunks() -> Iterator[str]: def _create_moderator_agent(self) -> None: moderator_agent_model: str | None = None moderator_agent_ctx_size: int | None = None + moderator_client: OpenAI | None = None lowest_param_count: int | None = None # Find the model with the lowest parameter count to use as the moderator agent. @@ -249,21 +339,31 @@ def _create_moderator_agent(self) -> None: if lowest_param_count is None or model_param_count < lowest_param_count: moderator_agent_model = agent.model + moderator_client = agent.client lowest_param_count = model_param_count if moderator_agent_ctx_size is None or agent.ctx_size > moderator_agent_ctx_size: moderator_agent_ctx_size = agent.ctx_size - assert moderator_agent_model is not None and moderator_agent_ctx_size is not None + assert ( + moderator_agent_model is not None and moderator_agent_ctx_size is not None and moderator_client is not None + ) + + # Build agent information for the moderator + agent_info_lines: list[str] = [] + for agent in self.agents: + # Use the original system prompt (before it was modified with conversation context) + original_prompt = self._original_system_prompts[self.agents.index(agent)] + agent_info_lines.append(f"- {agent.name}: {original_prompt}") + agent_information = "\n\n".join(agent_info_lines) self.moderator = AIAgent( name="Moderator", model=moderator_agent_model, temperature=0.8, ctx_size=moderator_agent_ctx_size, - system_prompt="You are the conversation moderator. Your task is to analyze the conversation " - + "and choose who speaks next. You should prioritize giving each character an equal opportunity to speak. " - + "Most importantly, you should prioritize keeping the conversation entertaining and engaging.", + system_prompt=self.MODERATOR_SYSTEM_PROMPT.format(agent_information=agent_information), + client=moderator_client, ) def _pick_next_agent(self, current_agent_idx: int | None) -> int: diff --git a/uv.lock b/uv.lock index 3ff143e..0788912 100644 --- a/uv.lock +++ b/uv.lock @@ -163,7 +163,6 @@ source = { editable = "." } dependencies = [ { name = "distinctipy" }, { name = "openai" }, - { name = "partial-json-parser" }, { name = "prompt-toolkit" }, { name = "pydantic" }, { name = "rich" }, @@ -180,7 +179,6 @@ dev = [ requires-dist = [ { name = "distinctipy", specifier = ">=1.3.4,<2.0.0" }, { name = "openai", specifier = ">=1.35.0,<2.0.0" }, - { name = "partial-json-parser", specifier = ">=0.2.1.1.post5,<0.3.0.0" }, { name = "prompt-toolkit", specifier = ">=3.0.50,<4.0.0" }, { name = "pydantic", specifier = ">=2.10.6,<3.0.0" }, { name = "rich", specifier = ">=13.9.4,<14.0.0" }, @@ -299,15 +297,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/fb/df274ca10698ee77b07bff952f302ea627cc12dac6b85289485dd77db6de/openai-1.99.9-py3-none-any.whl", hash = "sha256:9dbcdb425553bae1ac5d947147bebbd630d91bbfc7788394d4c4f3a35682ab3a", size = 786816, upload-time = "2025-08-12T02:31:08.34Z" }, ] -[[package]] -name = "partial-json-parser" -version = "0.2.1.1.post6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/86/13/459e86c9c67a006651803a3df3d0b08f7708bc5483fdc482582d75562949/partial_json_parser-0.2.1.1.post6.tar.gz", hash = "sha256:43896b68929678224cbbe4884a6a5fe9251ded4b30b8b7d7eb569e5feea93afc", size = 10299, upload-time = "2025-06-23T17:51:45.372Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/40/1f922794af3dc7503f19319a8804b398a161a2cd54183cff8b12225b8d85/partial_json_parser-0.2.1.1.post6-py3-none-any.whl", hash = "sha256:abc332f09b13ef5233384dbfe7128a0e9ea3fa4b8f8be9b37ac1b433c810e99e", size = 10876, upload-time = "2025-06-23T17:51:44.332Z" }, -] - [[package]] name = "prompt-toolkit" version = "3.0.51"