From 75f404f68f642121624eff049c7008604e10d4c5 Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Sat, 27 Jun 2026 15:38:27 +0530 Subject: [PATCH 01/35] Feat: Chatbot template --- src/spawn/templates/chatbot/content.py | 265 +++++++++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 src/spawn/templates/chatbot/content.py diff --git a/src/spawn/templates/chatbot/content.py b/src/spawn/templates/chatbot/content.py new file mode 100644 index 0000000..045a284 --- /dev/null +++ b/src/spawn/templates/chatbot/content.py @@ -0,0 +1,265 @@ +INIT_CONTENT = "" + +PYDANTIC_AI_MAIN_CONTENT = """\ +from src.chatbot.chat import get_response +from src.utils.env import load_env + + +def main() -> None: + load_env() + print("Chatbot ready. Type 'quit' to exit.\n") + while True: + user_input = input("You: ").strip() + if not user_input: + continue + if user_input.lower() in ("quit", "exit"): + break + response = get_response(user_input) + print(f"Bot: {{response}}\n") + + +if __name__ == "__main__": + main() +""" + +OPENAI_MAIN_CONTENT = PYDANTIC_AI_MAIN_CONTENT + +PYDANTIC_AI_LLM_CONTENT = """\ +import os + +from pydantic_ai import Agent + + +def get_llm_response(messages: list[dict], system_prompt: str) -> str: + api_key = os.getenv("API_KEY", "") + model = os.getenv("MODEL", "openai:gpt-4o-mini") + agent = Agent(model, system_prompt=system_prompt, api_key=api_key) + history = [m["content"] for m in messages if m["role"] == "user"] + prompt = history[-1] if history else "" + result = agent.run_sync(prompt) + return result.data +""" + +OPENAI_LLM_CONTENT = """\ +import os + +from openai import OpenAI + + +def get_llm_response(messages: list[dict], system_prompt: str) -> str: + client = OpenAI( + api_key=os.getenv("API_KEY", ""), + base_url=os.getenv("BASE_URL", "https://api.openai.com/v1"), + ) + model = os.getenv("MODEL", "gpt-4o-mini") + full_messages = [{{"role": "system", "content": system_prompt}}] + messages + response = client.chat.completions.create( + model=model, + messages=full_messages, + ) + return response.choices[0].message.content or "" +""" + +CHAT_CONTENT = """\ +from src.providers.llm import get_llm_response +from src.prompts.system_prompt import SYSTEM_PROMPT + +_history: list[dict] = [] + + +def get_response(user_input: str) -> str: + _history.append({{"role": "user", "content": user_input}}) + response = get_llm_response(_history, SYSTEM_PROMPT) + _history.append({{"role": "assistant", "content": response}}) + return response +""" + +SYSTEM_PROMPT_CONTENT = """\ +SYSTEM_PROMPT = ( + "You are a helpful assistant. " + "Answer questions clearly and concisely." +) +""" + +ENV_UTIL_CONTENT = """\ +from dotenv import load_dotenv + + +def load_env() -> None: + load_dotenv() +""" + +ENV_EXAMPLE_CONTENT = """\ +APP_NAME={project_name} +API_KEY= +MODEL=gpt-4o-mini +BASE_URL=https://api.openai.com/v1 +""" + +PYDANTIC_AI_TEST_CONTENT = """\ +from unittest.mock import patch + +from src.chatbot.chat import get_response + + +def test_get_response_returns_string(): + with patch("src.chatbot.chat.get_llm_response", return_value="Hello!"): + result = get_response("Hi") + assert isinstance(result, str) + assert result == "Hello!" + + +def test_get_response_non_empty(): + with patch("src.chatbot.chat.get_llm_response", return_value="Sure!"): + result = get_response("Tell me something") + assert result != "" +""" + +OPENAI_TEST_CONTENT = PYDANTIC_AI_TEST_CONTENT + +PYDANTIC_AI_README_CONTENT = """\ +# {project_name} + +An AI chatbot generated with Spawn, using PydanticAI. + +## Getting Started + +1. Add your API key to `.env`: + +```env +API_KEY=your-api-key-here +MODEL=openai:gpt-4o-mini +``` + +2. Run the chatbot: + +```bash +uv run python src/main.py +``` + +## Project Structure + +``` +{project_name}/ +├── src/ +│ ├── chatbot/ # Conversation logic +│ ├── providers/ # LLM provider (llm.py) +│ ├── prompts/ # System prompt definitions +│ ├── utils/ # Shared helpers +│ └── main.py # Entry point +├── tests/ +├── .env.example +└── README.md +``` + +## Running Tests + +```bash +uv run pytest +``` + +## Switching Providers + +Edit `src/providers/llm.py` to change model or provider. + +Update `MODEL` and `API_KEY` in `.env`. + +## Future Expansions + +```bash +spawn add memory +spawn add tools +spawn add rag +spawn add discord +``` +""" + +OPENAI_README_CONTENT = """\ +# {project_name} + +An AI chatbot generated with Spawn, using the OpenAI SDK. + +## Getting Started + +1. Add your API key to `.env`: + +```env +API_KEY=your-api-key-here +MODEL=gpt-4o-mini +BASE_URL=https://api.openai.com/v1 +``` + +2. Run the chatbot: + +```bash +uv run python src/main.py +``` + +## Project Structure + +``` +{project_name}/ +├── src/ +│ ├── chatbot/ # Conversation logic +│ ├── providers/ # LLM provider (llm.py) +│ ├── prompts/ # System prompt definitions +│ ├── utils/ # Shared helpers +│ └── main.py # Entry point +├── tests/ +├── .env.example +└── README.md +``` + +## Running Tests + +```bash +uv run pytest +``` + +## Switching Providers + +Edit `src/providers/llm.py` and update `BASE_URL`, `MODEL`, and `API_KEY` in `.env`. + +Compatible with any OpenAI-compatible API (Groq, OpenRouter, Ollama). + +## Future Expansions + +```bash +spawn add memory +spawn add tools +spawn add rag +spawn add discord +``` +""" + +GITHUB_ACTIONS_CI_BASE = """\ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + ci: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v5 + + - name: Install dependencies + run: uv sync +""" + +GITHUB_ACTIONS_CI_RUFF_STEP = """\ + - name: Lint + run: uv run ruff check . +""" + +GITHUB_ACTIONS_CI_PYTEST_STEP = """\ + - name: Test + run: uv run pytest +""" From ad9d647766ba0b57cf99789b8c5ee1eb4e1bcd85 Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Sat, 27 Jun 2026 15:47:29 +0530 Subject: [PATCH 02/35] update: registry and tests --- src/spawn/core/registry.py | 9 ++ src/spawn/templates/chatbot/__init__.py | 123 ++++++++++++++++++++++++ tests/test_chatbot_template.py | 102 ++++++++++++++++++++ tests/test_generator.py | 122 +++++++++++++++++++++++ tests/test_registry.py | 29 +++++- 5 files changed, 384 insertions(+), 1 deletion(-) create mode 100644 src/spawn/templates/chatbot/__init__.py create mode 100644 tests/test_chatbot_template.py diff --git a/src/spawn/core/registry.py b/src/spawn/core/registry.py index 96f9cf7..c55a5a8 100644 --- a/src/spawn/core/registry.py +++ b/src/spawn/core/registry.py @@ -9,6 +9,7 @@ from spawn.templates.backend_api import BackendAPITemplate from spawn.templates.cli_application import CLITemplate from spawn.templates.automation import AutomationTemplate +from spawn.templates.chatbot import ChatbotTemplate from spawn.templates.base import BaseTemplate @@ -53,6 +54,14 @@ class TemplateMetadata: template_class=AutomationTemplate, available_extras=["ruff", "pytest", "github-actions"], ), + "chatbot": TemplateMetadata( + slug="chatbot", + display_name="AI Chatbot", + description="Conversational AI with PydanticAI or OpenAI SDK", + template_class=ChatbotTemplate, + available_frameworks=["pydantic-ai", "openai-sdk"], + available_extras=["ruff", "pytest", "github-actions"], + ), } diff --git a/src/spawn/templates/chatbot/__init__.py b/src/spawn/templates/chatbot/__init__.py new file mode 100644 index 0000000..c91b16d --- /dev/null +++ b/src/spawn/templates/chatbot/__init__.py @@ -0,0 +1,123 @@ +from pathlib import Path + +from spawn.templates.base import BaseTemplate +from spawn.templates.chatbot.content import ( + INIT_CONTENT, + PYDANTIC_AI_MAIN_CONTENT, + OPENAI_MAIN_CONTENT, + PYDANTIC_AI_LLM_CONTENT, + OPENAI_LLM_CONTENT, + CHAT_CONTENT, + SYSTEM_PROMPT_CONTENT, + ENV_UTIL_CONTENT, + ENV_EXAMPLE_CONTENT, + PYDANTIC_AI_TEST_CONTENT, + OPENAI_TEST_CONTENT, + PYDANTIC_AI_README_CONTENT, + OPENAI_README_CONTENT, + GITHUB_ACTIONS_CI_BASE, + GITHUB_ACTIONS_CI_RUFF_STEP, + GITHUB_ACTIONS_CI_PYTEST_STEP, +) + +CHATBOT_FOLDERS = [ + "src/chatbot", + "src/providers", + "src/prompts", + "src/utils", + "tests", +] + + +def _build_files(main_content: str, llm_content: str, test_content: str) -> list: + return [ + ("src/__init__.py", INIT_CONTENT), + ("src/chatbot/__init__.py", INIT_CONTENT), + ("src/chatbot/chat.py", CHAT_CONTENT), + ("src/providers/__init__.py", INIT_CONTENT), + ("src/providers/llm.py", llm_content), + ("src/prompts/__init__.py", INIT_CONTENT), + ("src/prompts/system_prompt.py", SYSTEM_PROMPT_CONTENT), + ("src/utils/__init__.py", INIT_CONTENT), + ("src/utils/env.py", ENV_UTIL_CONTENT), + ("src/main.py", main_content), + ("tests/__init__.py", INIT_CONTENT), + ("tests/test_chatbot.py", test_content), + (".env.example", ENV_EXAMPLE_CONTENT), + ] + + +class ChatbotTemplate(BaseTemplate): + def __init__( + self, + framework: str | None = None, + extras: list[str] | None = None, + ) -> None: + self.framework = framework or "pydantic-ai" + self.extras = extras or [] + + if self.framework == "openai-sdk": + main_content = OPENAI_MAIN_CONTENT + llm_content = OPENAI_LLM_CONTENT + test_content = OPENAI_TEST_CONTENT + else: + # Default: pydantic-ai + main_content = PYDANTIC_AI_MAIN_CONTENT + llm_content = PYDANTIC_AI_LLM_CONTENT + test_content = PYDANTIC_AI_TEST_CONTENT + + super().__init__( + name="AI Chatbot", + folders=list(CHATBOT_FOLDERS), + starter_files=_build_files(main_content, llm_content, test_content), + next_steps=[ + "cd {project_name}", + "Add your API_KEY to .env", + "uv run python src/main.py", + ], + ) + + def get_readme_content(self, context: dict) -> str | None: + if self.framework == "openai-sdk": + return OPENAI_README_CONTENT.format_map(context) + return PYDANTIC_AI_README_CONTENT.format_map(context) + + def get_dependencies(self) -> list[str]: + if self.framework == "openai-sdk": + base = ["openai", "python-dotenv"] + else: + base = ["pydantic-ai", "python-dotenv"] + if "pytest" in self.extras: + base += ["pytest"] + if "ruff" in self.extras: + base += ["ruff"] + return base + + def post_install(self, project_path: Path) -> None: + pyproject = project_path / "pyproject.toml" + current = pyproject.read_text(encoding="utf-8") + additions = "" + + if "pytest" in self.extras: + additions += ( + "\n[tool.pytest.ini_options]\n" + 'testpaths = ["tests"]\n' + ) + + if "ruff" in self.extras: + additions += "\n[tool.ruff]\nline-length = 88\n" + + if additions: + pyproject.write_text(current + additions, encoding="utf-8") + + if "github-actions" in self.extras: + workflows_path = project_path / ".github" / "workflows" + workflows_path.mkdir(parents=True, exist_ok=True) + ci_content = GITHUB_ACTIONS_CI_BASE + if "ruff" in self.extras: + ci_content += GITHUB_ACTIONS_CI_RUFF_STEP + if "pytest" in self.extras: + ci_content += GITHUB_ACTIONS_CI_PYTEST_STEP + (workflows_path / "ci.yml").write_text( + ci_content, encoding="utf-8" + ) diff --git a/tests/test_chatbot_template.py b/tests/test_chatbot_template.py new file mode 100644 index 0000000..4abf0b5 --- /dev/null +++ b/tests/test_chatbot_template.py @@ -0,0 +1,102 @@ +from spawn.templates.chatbot import ChatbotTemplate + + +def test_chatbot_template_default(): + t = ChatbotTemplate() + assert t.name == "AI Chatbot" + assert t.framework == "pydantic-ai" + assert t.extras == [] + + +def test_chatbot_template_openai_sdk(): + t = ChatbotTemplate(framework="openai-sdk") + assert t.framework == "openai-sdk" + + +def test_chatbot_template_folders(): + t = ChatbotTemplate() + assert "src/chatbot" in t.folders + assert "src/providers" in t.folders + assert "src/prompts" in t.folders + assert "src/utils" in t.folders + assert "tests" in t.folders + + +def test_chatbot_template_starter_files(): + t = ChatbotTemplate() + paths = [path for path, _ in t.starter_files] + assert "src/main.py" in paths + assert "src/chatbot/chat.py" in paths + assert "src/providers/llm.py" in paths + assert "src/prompts/system_prompt.py" in paths + assert "src/utils/env.py" in paths + assert "tests/test_chatbot.py" in paths + assert ".env.example" in paths + + +def test_chatbot_pydantic_ai_dependencies(): + t = ChatbotTemplate(framework="pydantic-ai") + deps = t.get_dependencies() + assert "pydantic-ai" in deps + assert "python-dotenv" in deps + assert "openai" not in deps + + +def test_chatbot_openai_sdk_dependencies(): + t = ChatbotTemplate(framework="openai-sdk") + deps = t.get_dependencies() + assert "openai" in deps + assert "python-dotenv" in deps + assert "pydantic-ai" not in deps + + +def test_chatbot_extras_add_deps(): + t = ChatbotTemplate(extras=["pytest", "ruff"]) + deps = t.get_dependencies() + assert "pytest" in deps + assert "ruff" in deps + + +def test_chatbot_no_extras_excludes_optional_deps(): + t = ChatbotTemplate() + deps = t.get_dependencies() + assert "pytest" not in deps + assert "ruff" not in deps + + +def test_chatbot_pydantic_ai_readme_contains_project_name(): + t = ChatbotTemplate(framework="pydantic-ai") + readme = t.get_readme_content({"project_name": "my-bot"}) + assert "my-bot" in readme + assert "PydanticAI" in readme + + +def test_chatbot_openai_sdk_readme_contains_project_name(): + t = ChatbotTemplate(framework="openai-sdk") + readme = t.get_readme_content({"project_name": "my-bot"}) + assert "my-bot" in readme + assert "OpenAI" in readme + + +def test_chatbot_pydantic_ai_llm_file_differs_from_openai(): + t1 = ChatbotTemplate(framework="pydantic-ai") + t2 = ChatbotTemplate(framework="openai-sdk") + files1 = dict(t1.starter_files) + files2 = dict(t2.starter_files) + assert files1["src/providers/llm.py"] != files2["src/providers/llm.py"] + + +def test_chatbot_starter_file_paths_are_strings(): + t = ChatbotTemplate() + for path, _ in t.starter_files: + assert isinstance(path, str) + + +def test_chatbot_next_steps_contain_run_command(): + t = ChatbotTemplate() + assert any("src/main.py" in step for step in t.next_steps) + + +def test_chatbot_next_steps_contain_env_instruction(): + t = ChatbotTemplate() + assert any("API_KEY" in step for step in t.next_steps) diff --git a/tests/test_generator.py b/tests/test_generator.py index 6ce0e80..b538b8d 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -33,6 +33,17 @@ def _patch_automation_post_install(): return patch.object(AutomationTemplate, "post_install") +def _chatbot_config(name="demo", **kwargs): + """Return a minimal ProjectConfig using the chatbot template.""" + return ProjectConfig(name=name, template="chatbot", use_git=False, **kwargs) + + +def _patch_chatbot_post_install(): + """Context manager that silences ChatbotTemplate.post_install.""" + from spawn.templates.chatbot import ChatbotTemplate + return patch.object(ChatbotTemplate, "post_install") + + # --------------------------------------------------------------------------- # Basic project structure # --------------------------------------------------------------------------- @@ -494,3 +505,114 @@ def test_automation_extras_in_dependencies(): assert "pytest" in deps assert "requests" in deps assert "python-dotenv" in deps + + +# --------------------------------------------------------------------------- +# AI Chatbot generator tests +# --------------------------------------------------------------------------- + + +@patch("spawn.generators.project_generator.install_packages") +@patch("spawn.generators.project_generator.initialize_uv") +def test_chatbot_generator_creates_project(mock_uv, mock_install, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _patch_chatbot_post_install(): + ProjectGenerator().generate(_chatbot_config()) + assert (tmp_path / "demo").exists() + + +@patch("spawn.generators.project_generator.install_packages") +@patch("spawn.generators.project_generator.initialize_uv") +def test_chatbot_generator_creates_folders(mock_uv, mock_install, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _patch_chatbot_post_install(): + ProjectGenerator().generate(_chatbot_config()) + assert (tmp_path / "demo" / "src" / "chatbot").exists() + assert (tmp_path / "demo" / "src" / "providers").exists() + assert (tmp_path / "demo" / "src" / "prompts").exists() + assert (tmp_path / "demo" / "src" / "utils").exists() + assert (tmp_path / "demo" / "tests").exists() + + +@patch("spawn.generators.project_generator.install_packages") +@patch("spawn.generators.project_generator.initialize_uv") +def test_chatbot_generator_creates_main(mock_uv, mock_install, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _patch_chatbot_post_install(): + ProjectGenerator().generate(_chatbot_config()) + assert (tmp_path / "demo" / "src" / "main.py").exists() + + +@patch("spawn.generators.project_generator.install_packages") +@patch("spawn.generators.project_generator.initialize_uv") +def test_chatbot_generator_creates_env_example(mock_uv, mock_install, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _patch_chatbot_post_install(): + ProjectGenerator().generate(_chatbot_config()) + assert (tmp_path / "demo" / ".env.example").exists() + + +@patch("spawn.generators.project_generator.install_packages") +@patch("spawn.generators.project_generator.initialize_uv") +def test_chatbot_generator_creates_meta_json(mock_uv, mock_install, tmp_path, monkeypatch): + import json + + monkeypatch.chdir(tmp_path) + with _patch_chatbot_post_install(): + ProjectGenerator().generate(_chatbot_config()) + meta = json.loads((tmp_path / "demo" / ".spawn" / "meta.json").read_text()) + assert meta["intent"] == "chatbot" + assert meta["framework"] is None + + +@patch("spawn.generators.project_generator.install_packages") +@patch("spawn.generators.project_generator.initialize_uv") +def test_chatbot_generator_with_openai_sdk_framework(mock_uv, mock_install, tmp_path, monkeypatch): + import json + + monkeypatch.chdir(tmp_path) + config = ProjectConfig( + name="demo", template="chatbot", use_git=False, framework="openai-sdk" + ) + with _patch_chatbot_post_install(): + ProjectGenerator().generate(config) + meta = json.loads((tmp_path / "demo" / ".spawn" / "meta.json").read_text()) + assert meta["framework"] == "openai-sdk" + + +@patch("spawn.generators.project_generator.install_packages") +@patch("spawn.generators.project_generator.initialize_uv") +def test_chatbot_env_example_contains_project_name(mock_uv, mock_install, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _patch_chatbot_post_install(): + ProjectGenerator().generate(_chatbot_config(name="my-bot")) + content = (tmp_path / "my-bot" / ".env.example").read_text(encoding="utf-8") + assert "my-bot" in content + + +def test_chatbot_pydantic_ai_extras_in_dependencies(): + from spawn.core.registry import instantiate_template + + config = ProjectConfig( + name="demo", template="chatbot", use_git=False, + framework="pydantic-ai", extras=["ruff", "pytest"], + ) + deps = instantiate_template(config).get_dependencies() + assert "pydantic-ai" in deps + assert "python-dotenv" in deps + assert "ruff" in deps + assert "pytest" in deps + + +def test_chatbot_openai_sdk_extras_in_dependencies(): + from spawn.core.registry import instantiate_template + + config = ProjectConfig( + name="demo", template="chatbot", use_git=False, + framework="openai-sdk", extras=["pytest"], + ) + deps = instantiate_template(config).get_dependencies() + assert "openai" in deps + assert "python-dotenv" in deps + assert "pytest" in deps + assert "pydantic-ai" not in deps diff --git a/tests/test_registry.py b/tests/test_registry.py index cf68bad..d450df4 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -24,7 +24,8 @@ def test_list_templates_returns_all(): assert "backend-api" in slugs assert "cli" in slugs assert "automation" in slugs - assert len(slugs) == 3 + assert "chatbot" in slugs + assert len(slugs) == 4 def test_get_metadata_returns_none_for_unknown(): @@ -120,3 +121,29 @@ def test_automation_metadata(): def test_automation_in_list_templates(): slugs = [m.slug for m in list_templates()] assert "automation" in slugs + + +def test_chatbot_template_is_registered(): + from spawn.templates.chatbot import ChatbotTemplate + + template = get_template("chatbot") + assert template is not None + assert isinstance(template, ChatbotTemplate) + + +def test_chatbot_metadata(): + meta = get_metadata("chatbot") + assert meta is not None + assert meta.slug == "chatbot" + assert meta.display_name == "AI Chatbot" + assert "pydantic-ai" in meta.available_frameworks + assert "openai-sdk" in meta.available_frameworks + assert "ruff" in meta.available_extras + assert "pytest" in meta.available_extras + assert "github-actions" in meta.available_extras + assert meta.available_cli_types == [] + + +def test_chatbot_in_list_templates(): + slugs = [m.slug for m in list_templates()] + assert "chatbot" in slugs From eead5f36c86b26c09288ec6a9331c4823431111a Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Sat, 27 Jun 2026 15:48:39 +0530 Subject: [PATCH 03/35] update: test_prompts --- tests/test_prompts.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_prompts.py b/tests/test_prompts.py index 1e44450..292acf7 100644 --- a/tests/test_prompts.py +++ b/tests/test_prompts.py @@ -132,6 +132,7 @@ def test_non_numeric_template_choice_retried(mock_prompt, mock_confirm): ("1", "backend-api"), ("2", "cli"), ("3", "automation"), + ("4", "chatbot"), ], ) @patch("spawn.cli.prompts.typer.confirm", return_value=False) @@ -140,6 +141,8 @@ def test_all_template_choices(mock_confirm, choice, expected_template): side_effects = ["project", choice, "1", ""] # name, choice, framework, extras elif expected_template == "automation": side_effects = ["project", choice, ""] # name, choice, extras (skip) + elif expected_template == "chatbot": + side_effects = ["project", choice, "1", ""] # name, choice, framework, extras else: side_effects = ["project", choice, "1", "1", ""] # name, choice, framework, cli_type, extras with patch( From 2964b8c97e28c17e873dff92712b0fed35daf5f5 Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Sat, 27 Jun 2026 15:53:32 +0530 Subject: [PATCH 04/35] Docs: update the docs --- README.md | 34 ++++++++++++++++++++++++++++++---- docs/architecture.md | 6 +++++- docs/changelog.md | 21 +++++++++++++++++++++ docs/commands.md | 31 ++++++++++++++++++++++++++++--- docs/getting-started.md | 8 +++++--- pyproject.toml | 2 +- src/spawn/__init__.py | 2 +- uv.lock | 2 +- 8 files changed, 92 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 6a5bc0d..8983c23 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ It's repetitive. It's inconsistent. And you haven't written a single line of *re | Feature | What it does | |---|---| -| **Intent-based templates** | Backend API (FastAPI / Flask / Django), CLI Application (Typer / Click / Argparse), Automation Tool | +| **Intent-based templates** | Backend API (FastAPI / Flask / Django), CLI Application (Typer / Click / Argparse), Automation Tool, AI Chatbot | | **Extras system** | Opt-in ruff, pytest, Docker, GitHub Actions — installed and wired automatically | | **Dependency installation** | `uv add` runs automatically with the right packages for your choices | | **Git + uv** | Optionally runs `git init`, `uv init`, and `uv venv` | @@ -91,8 +91,9 @@ Spawn rejects names with spaces or special characters, and tells you immediately 1 Backend API 2 CLI Application 3 Automation Tool + 4 AI Chatbot -Choose Template [1-3]: 1 +Choose Template [1-4]: 1 ``` **Step 3 — Additional prompts** *(template-dependent)* @@ -273,6 +274,31 @@ uv run python -m src.main --- +### `[4]` AI Chatbot + +Best for: customer support bots, study assistants, conversational AI tools. + +``` +my-chatbot/ +├── src/ +│ ├── chatbot/ +│ ├── providers/ +│ ├── prompts/ +│ ├── utils/ +│ └── main.py +├── tests/ +├── .env.example +└── README.md +``` + +```bash +cd my-chatbot +# Add API_KEY to .env +uv run python src/main.py +``` + +--- + ## Other Commands ### `spawn doctor` @@ -315,7 +341,7 @@ spawn doctor ./path/to/project ```bash spawn version -# → Spawn v0.5.0 +# → Spawn v0.6.0 ``` ### Publish to GitHub @@ -351,7 +377,7 @@ All tests should pass. If they don't, please [open an issue](https://github.com/ - [x] **Dependency installation** — `uv add` runs automatically after generation (v0.3.0) - [x] **CLI Application intent** — Typer, Click, Argparse with Utility/Interactive sub-types (v0.4.0) - [x] **Automation Tool intent** — workflow-based automation with logging, tasks, and integrations (v0.5.0) -- [ ] **AI Chatbot intent** — LLM-integrated chat app scaffold (v0.6.0) +- [x] **AI Chatbot intent** — PydanticAI and OpenAI SDK with provider abstraction (v0.6.0) - [ ] **AI Agent intent** — tool-calling agent scaffold (v0.7.0) - [ ] **RAG System intent** — retrieval-augmented generation scaffold (v0.8.0) - [ ] **Data Project intent** — analysis, dashboard, ETL, ML sub-options (v0.9.0) diff --git a/docs/architecture.md b/docs/architecture.md index 3b8e8eb..0dc11ab 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -23,7 +23,7 @@ Entry point: `spawn.cli.app:main` (defined in `pyproject.toml` as `[project.scri ``` src/spawn/ -├── __init__.py # __version__ via importlib.metadata, fallback "0.5.0" +├── __init__.py # __version__ via importlib.metadata, fallback "0.6.0" ├── cli/ │ ├── app.py # Typer app: create, version, doctor commands │ └── prompts.py # Interactive prompts; derives menu from registry @@ -50,6 +50,9 @@ src/spawn/ │ ├── automation/ │ │ ├── __init__.py # AutomationTemplate │ │ └── content.py # Workflow, task, logger, test, README content strings +│ ├── chatbot/ +│ │ ├── __init__.py # ChatbotTemplate (branches on framework) +│ │ └── content.py # LLM provider, chat, prompt, test, README strings │ ├── python_script/ # Reserved — not in active menu │ ├── data_science/ # Reserved — not in active menu │ └── ml_project/ # Reserved — not in active menu @@ -137,6 +140,7 @@ Each template lives in its own subdirectory with an `__init__.py` (class) and `c | `backend-api` | `BackendAPITemplate` | fastapi, flask, django | ruff, pytest, docker, github-actions | | `cli` | `CLITemplate` | typer, click, argparse | ruff, pytest, github-actions | | `automation` | `AutomationTemplate` | none | ruff, pytest, github-actions | +| `chatbot` | `ChatbotTemplate` | pydantic-ai, openai-sdk | ruff, pytest, github-actions | `get_template(slug)` returns a default-constructed instance. `instantiate_template(config)` forwards `framework`, `extras`, and `cli_type` from `ProjectConfig` to templates whose constructors accept them, using signature introspection. diff --git a/docs/changelog.md b/docs/changelog.md index 863a6d7..aee80a8 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,6 +3,27 @@ All notable changes to Spawn are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## v0.6.0 — 2026 + +### New Features + +- **AI Chatbot intent** — generates a provider-agnostic conversational AI project + with PydanticAI or OpenAI SDK; run `uv run python src/main.py` immediately after + adding an API key +- **Provider abstraction layer** — `src/providers/llm.py` isolates the AI provider; + switching from OpenAI to Groq/Anthropic/OpenRouter requires only env var changes +- **Centralized prompt management** — `src/prompts/system_prompt.py` keeps system + prompts out of application logic +- **Session-level conversation history** — message history is maintained across + turns within a single run without external storage + +### Internal + +- Registry expanded to four active templates: backend-api, cli, automation, chatbot +- ChatbotTemplate branches on framework to select provider implementation; + chatbot/, prompts/, utils/, and main.py are shared between both variants +- Version bumped to 0.6.0 + ## v0.5.0 — 2026 ### New Features diff --git a/docs/commands.md b/docs/commands.md index 4e46536..bd19e5a 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -19,7 +19,7 @@ Creates a new project directory from a template, writes starter files, installs | Step | Prompt | When shown | |---|---|---| | 1 | `Project Name` | Always | -| 2 | Template list → `Choose Template [1-3]` | Always | +| 2 | Template list → `Choose Template [1-4]` | Always | | 3 | CLI Type list → `Choose CLI Type [1-2]` | Only for CLI Application | | 4 | Framework list → `Choose Framework [1-N]` | Only for templates with frameworks | | 5 | Extras list → `Extras` | Only for templates with extras | @@ -49,6 +49,7 @@ Templates are displayed as a numbered list. The current registry order: | `1` | `backend-api` | Backend API — production-ready FastAPI, Flask, or Django | | `2` | `cli` | CLI Application — Typer, Click, or Argparse with Utility or Interactive type | | `3` | `automation` | Automation Tool — workflow-based automation with logging and tasks | +| `4` | `chatbot` | AI Chatbot — PydanticAI or OpenAI SDK with provider abstraction | **Invalid input error (exact):** ``` @@ -361,6 +362,30 @@ uv run python -m src.main --- +### AI Chatbot intent + +Selecting AI Chatbot triggers a framework prompt then extras. + +#### Framework selection + +``` + 1 pydantic-ai + 2 openai-sdk + +Choose Framework [1-2]: +``` + +| Input | Framework | Key dependency | +|---|---|---| +| `1` (default) | PydanticAI | `pydantic-ai` | +| `2` | OpenAI SDK | `openai` | + +Both variants install `python-dotenv` and use the same project structure. + +Provider switching requires changes only in `src/providers/llm.py` and `.env`. + +--- + ### `.spawn/meta.json` Every generated project receives a `.spawn/meta.json` file: @@ -369,7 +394,7 @@ Every generated project receives a `.spawn/meta.json` file: { "intent": "backend-api", "framework": "fastapi", - "spawn_version": "0.5.0" + "spawn_version": "0.6.0" } ``` @@ -443,7 +468,7 @@ Prints the installed package version. **Output (exact):** ``` -Spawn v0.5.0 +Spawn v0.6.0 ``` --- diff --git a/docs/getting-started.md b/docs/getting-started.md index 9e6379a..f57fb61 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -31,8 +31,9 @@ $ spawn create 1 Backend API 2 CLI Application 3 Automation Tool + 4 AI Chatbot -Choose Template [1-3]: 2 +Choose Template [1-4]: 2 1 utility 2 interactive @@ -81,8 +82,9 @@ $ spawn create 1 Backend API 2 CLI Application 3 Automation Tool + 4 AI Chatbot -Choose Template [1-3]: 1 +Choose Template [1-4]: 1 1 fastapi 2 flask @@ -165,7 +167,7 @@ spawn version ``` ``` -Spawn v0.5.0 +Spawn v0.6.0 ``` ```bash diff --git a/pyproject.toml b/pyproject.toml index 1b14482..88a8f47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "spawn" -version = "0.5.0" +version = "0.6.0" description = "Spawn a ready-to-use Python project in seconds" readme = "README.md" requires-python = ">=3.12" diff --git a/src/spawn/__init__.py b/src/spawn/__init__.py index 864eaa1..30fcb35 100644 --- a/src/spawn/__init__.py +++ b/src/spawn/__init__.py @@ -3,4 +3,4 @@ try: __version__ = version("spawn") except PackageNotFoundError: - __version__ = "0.5.0" + __version__ = "0.6.0" diff --git a/uv.lock b/uv.lock index ac2c371..8c92aba 100644 --- a/uv.lock +++ b/uv.lock @@ -142,7 +142,7 @@ wheels = [ [[package]] name = "spawn" -version = "0.5.0" +version = "0.6.0" source = { editable = "." } dependencies = [ { name = "rich" }, From 86e41ea0e3e49b7439f97134287cc849cd7c5900 Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Sat, 27 Jun 2026 16:26:27 +0530 Subject: [PATCH 05/35] chatbot v1 --- README.md | 2 +- src/spawn/templates/chatbot/__init__.py | 2 +- src/spawn/templates/chatbot/content.py | 12 +++++++----- tests/test_chatbot_template.py | 2 +- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 8983c23..2e93773 100644 --- a/README.md +++ b/README.md @@ -294,7 +294,7 @@ my-chatbot/ ```bash cd my-chatbot # Add API_KEY to .env -uv run python src/main.py +uv run python -m src.main ``` --- diff --git a/src/spawn/templates/chatbot/__init__.py b/src/spawn/templates/chatbot/__init__.py index c91b16d..9194af0 100644 --- a/src/spawn/templates/chatbot/__init__.py +++ b/src/spawn/templates/chatbot/__init__.py @@ -73,7 +73,7 @@ def __init__( next_steps=[ "cd {project_name}", "Add your API_KEY to .env", - "uv run python src/main.py", + "uv run python -m src.main", ], ) diff --git a/src/spawn/templates/chatbot/content.py b/src/spawn/templates/chatbot/content.py index 045a284..d8bd35d 100644 --- a/src/spawn/templates/chatbot/content.py +++ b/src/spawn/templates/chatbot/content.py @@ -7,7 +7,7 @@ def main() -> None: load_env() - print("Chatbot ready. Type 'quit' to exit.\n") + print("Chatbot ready. Type 'quit' to exit.\\n") while True: user_input = input("You: ").strip() if not user_input: @@ -15,7 +15,7 @@ def main() -> None: if user_input.lower() in ("quit", "exit"): break response = get_response(user_input) - print(f"Bot: {{response}}\n") + print(f"Bot: {{response}}\\n") if __name__ == "__main__": @@ -33,7 +33,9 @@ def main() -> None: def get_llm_response(messages: list[dict], system_prompt: str) -> str: api_key = os.getenv("API_KEY", "") model = os.getenv("MODEL", "openai:gpt-4o-mini") - agent = Agent(model, system_prompt=system_prompt, api_key=api_key) + if api_key: + os.environ.setdefault("OPENAI_API_KEY", api_key) + agent = Agent(model, system_prompt=system_prompt) history = [m["content"] for m in messages if m["role"] == "user"] prompt = history[-1] if history else "" result = agent.run_sync(prompt) @@ -134,7 +136,7 @@ def test_get_response_non_empty(): 2. Run the chatbot: ```bash -uv run python src/main.py +uv run python -m src.main ``` ## Project Structure @@ -192,7 +194,7 @@ def test_get_response_non_empty(): 2. Run the chatbot: ```bash -uv run python src/main.py +uv run python -m src.main ``` ## Project Structure diff --git a/tests/test_chatbot_template.py b/tests/test_chatbot_template.py index 4abf0b5..3e8002c 100644 --- a/tests/test_chatbot_template.py +++ b/tests/test_chatbot_template.py @@ -94,7 +94,7 @@ def test_chatbot_starter_file_paths_are_strings(): def test_chatbot_next_steps_contain_run_command(): t = ChatbotTemplate() - assert any("src/main.py" in step for step in t.next_steps) + assert any("src.main" in step for step in t.next_steps) def test_chatbot_next_steps_contain_env_instruction(): From 3a1b4f5023d9d815bce63fc3f75d093bafda4c7b Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Sat, 27 Jun 2026 16:38:11 +0530 Subject: [PATCH 06/35] Framework-Specific .env.example + Content Fix --- src/spawn/templates/chatbot/__init__.py | 18 ++++++++++--- src/spawn/templates/chatbot/content.py | 8 +++++- tests/test_chatbot_template.py | 34 +++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/src/spawn/templates/chatbot/__init__.py b/src/spawn/templates/chatbot/__init__.py index 9194af0..e596458 100644 --- a/src/spawn/templates/chatbot/__init__.py +++ b/src/spawn/templates/chatbot/__init__.py @@ -10,7 +10,8 @@ CHAT_CONTENT, SYSTEM_PROMPT_CONTENT, ENV_UTIL_CONTENT, - ENV_EXAMPLE_CONTENT, + PYDANTIC_AI_ENV_EXAMPLE_CONTENT, + OPENAI_ENV_EXAMPLE_CONTENT, PYDANTIC_AI_TEST_CONTENT, OPENAI_TEST_CONTENT, PYDANTIC_AI_README_CONTENT, @@ -29,7 +30,12 @@ ] -def _build_files(main_content: str, llm_content: str, test_content: str) -> list: +def _build_files( + main_content: str, + llm_content: str, + test_content: str, + env_example_content: str, +) -> list: return [ ("src/__init__.py", INIT_CONTENT), ("src/chatbot/__init__.py", INIT_CONTENT), @@ -43,7 +49,7 @@ def _build_files(main_content: str, llm_content: str, test_content: str) -> list ("src/main.py", main_content), ("tests/__init__.py", INIT_CONTENT), ("tests/test_chatbot.py", test_content), - (".env.example", ENV_EXAMPLE_CONTENT), + (".env.example", env_example_content), ] @@ -60,16 +66,20 @@ def __init__( main_content = OPENAI_MAIN_CONTENT llm_content = OPENAI_LLM_CONTENT test_content = OPENAI_TEST_CONTENT + env_example_content = OPENAI_ENV_EXAMPLE_CONTENT else: # Default: pydantic-ai main_content = PYDANTIC_AI_MAIN_CONTENT llm_content = PYDANTIC_AI_LLM_CONTENT test_content = PYDANTIC_AI_TEST_CONTENT + env_example_content = PYDANTIC_AI_ENV_EXAMPLE_CONTENT super().__init__( name="AI Chatbot", folders=list(CHATBOT_FOLDERS), - starter_files=_build_files(main_content, llm_content, test_content), + starter_files=_build_files( + main_content, llm_content, test_content, env_example_content + ), next_steps=[ "cd {project_name}", "Add your API_KEY to .env", diff --git a/src/spawn/templates/chatbot/content.py b/src/spawn/templates/chatbot/content.py index d8bd35d..5eeedda 100644 --- a/src/spawn/templates/chatbot/content.py +++ b/src/spawn/templates/chatbot/content.py @@ -91,7 +91,13 @@ def load_env() -> None: load_dotenv() """ -ENV_EXAMPLE_CONTENT = """\ +PYDANTIC_AI_ENV_EXAMPLE_CONTENT = """\ +APP_NAME={project_name} +API_KEY= +MODEL=openai:gpt-4o-mini +""" + +OPENAI_ENV_EXAMPLE_CONTENT = """\ APP_NAME={project_name} API_KEY= MODEL=gpt-4o-mini diff --git a/tests/test_chatbot_template.py b/tests/test_chatbot_template.py index 3e8002c..475aee2 100644 --- a/tests/test_chatbot_template.py +++ b/tests/test_chatbot_template.py @@ -100,3 +100,37 @@ def test_chatbot_next_steps_contain_run_command(): def test_chatbot_next_steps_contain_env_instruction(): t = ChatbotTemplate() assert any("API_KEY" in step for step in t.next_steps) + + +def test_pydantic_ai_env_example_has_provider_prefixed_model(): + """pydantic-ai requires 'openai:gpt-4o-mini' not 'gpt-4o-mini'.""" + t = ChatbotTemplate(framework="pydantic-ai") + env_file = dict(t.starter_files)[".env.example"] + result = env_file.format_map({"project_name": "test"}) + assert "MODEL=openai:gpt-4o-mini" in result + assert "MODEL=gpt-4o-mini\n" not in result + + +def test_pydantic_ai_env_example_has_no_base_url(): + """pydantic-ai does not use BASE_URL - should not appear in its .env.""" + t = ChatbotTemplate(framework="pydantic-ai") + env_file = dict(t.starter_files)[".env.example"] + result = env_file.format_map({"project_name": "test"}) + assert "BASE_URL" not in result + + +def test_openai_sdk_env_example_has_plain_model(): + """OpenAI SDK uses plain model name, no provider prefix.""" + t = ChatbotTemplate(framework="openai-sdk") + env_file = dict(t.starter_files)[".env.example"] + result = env_file.format_map({"project_name": "test"}) + assert "MODEL=gpt-4o-mini" in result + assert "MODEL=openai:" not in result + + +def test_openai_sdk_env_example_has_base_url(): + """OpenAI SDK supports BASE_URL for provider switching.""" + t = ChatbotTemplate(framework="openai-sdk") + env_file = dict(t.starter_files)[".env.example"] + result = env_file.format_map({"project_name": "test"}) + assert "BASE_URL" in result From 14eae01ad23d640db6916ff555328154b5166f5a Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Sat, 27 Jun 2026 17:07:32 +0530 Subject: [PATCH 07/35] fix: llm content --- src/spawn/templates/chatbot/content.py | 99 +++++++++- tests/test_chatbot_generator.py | 254 +++++++++++++++++++++++++ tests/test_chatbot_template.py | 19 ++ 3 files changed, 362 insertions(+), 10 deletions(-) create mode 100644 tests/test_chatbot_generator.py diff --git a/src/spawn/templates/chatbot/content.py b/src/spawn/templates/chatbot/content.py index 5eeedda..5f42c63 100644 --- a/src/spawn/templates/chatbot/content.py +++ b/src/spawn/templates/chatbot/content.py @@ -33,13 +33,11 @@ def main() -> None: def get_llm_response(messages: list[dict], system_prompt: str) -> str: api_key = os.getenv("API_KEY", "") model = os.getenv("MODEL", "openai:gpt-4o-mini") - if api_key: - os.environ.setdefault("OPENAI_API_KEY", api_key) - agent = Agent(model, system_prompt=system_prompt) history = [m["content"] for m in messages if m["role"] == "user"] prompt = history[-1] if history else "" - result = agent.run_sync(prompt) - return result.data + agent = Agent(model, system_prompt=system_prompt) + result = agent.run_sync(prompt, api_key=api_key) + return result.output """ OPENAI_LLM_CONTENT = """\ @@ -93,21 +91,40 @@ def load_env() -> None: PYDANTIC_AI_ENV_EXAMPLE_CONTENT = """\ APP_NAME={project_name} + +# Your API key — passed directly to the provider via pydantic-ai API_KEY= + +# Model string with provider prefix (pydantic-ai format) +# Examples: +# openai:gpt-4o-mini +# groq:llama-3.3-70b-versatile +# anthropic:claude-3-5-haiku-latest MODEL=openai:gpt-4o-mini """ OPENAI_ENV_EXAMPLE_CONTENT = """\ APP_NAME={project_name} + +# Your API key for the provider API_KEY= + +# Plain model name (no provider prefix for OpenAI SDK) MODEL=gpt-4o-mini + +# Base URL — change to use Groq, OpenRouter, Ollama, etc. +# Examples: +# https://api.groq.com/openai/v1 +# https://openrouter.ai/api/v1 +# http://localhost:11434/v1 BASE_URL=https://api.openai.com/v1 """ PYDANTIC_AI_TEST_CONTENT = """\ -from unittest.mock import patch +from unittest.mock import MagicMock, patch from src.chatbot.chat import get_response +from src.providers.llm import get_llm_response def test_get_response_returns_string(): @@ -121,6 +138,34 @@ def test_get_response_non_empty(): with patch("src.chatbot.chat.get_llm_response", return_value="Sure!"): result = get_response("Tell me something") assert result != "" + + +def test_get_llm_response_uses_output_attribute(): + mock_result = MagicMock() + mock_result.output = "test response" + with patch("src.providers.llm.Agent") as mock_agent_class: + mock_agent = MagicMock() + mock_agent.run_sync.return_value = mock_result + mock_agent_class.return_value = mock_agent + messages = [{{"role": "user", "content": "hello"}}] + response = get_llm_response(messages, "You are helpful.") + assert response == "test response" + mock_agent.run_sync.assert_called_once() + + +def test_get_llm_response_passes_api_key(): + mock_result = MagicMock() + mock_result.output = "ok" + with patch("src.providers.llm.Agent") as mock_agent_class: + mock_agent = MagicMock() + mock_agent.run_sync.return_value = mock_result + mock_agent_class.return_value = mock_agent + with patch("src.providers.llm.os.getenv", side_effect=lambda k, d="": {{ + "API_KEY": "test-key", "MODEL": "openai:gpt-4o-mini" + }}.get(k, d)): + get_llm_response([{{"role": "user", "content": "hi"}}], "prompt") + call_kwargs = mock_agent.run_sync.call_args + assert call_kwargs.kwargs.get("api_key") == "test-key" """ OPENAI_TEST_CONTENT = PYDANTIC_AI_TEST_CONTENT @@ -136,7 +181,10 @@ def test_get_response_non_empty(): ```env API_KEY=your-api-key-here +# Provider-prefixed model string: MODEL=openai:gpt-4o-mini +# or: groq:llama-3.3-70b-versatile +# or: anthropic:claude-3-5-haiku-latest ``` 2. Run the chatbot: @@ -168,9 +216,23 @@ def test_get_response_non_empty(): ## Switching Providers -Edit `src/providers/llm.py` to change model or provider. +Change the MODEL in `.env` to switch providers: -Update `MODEL` and `API_KEY` in `.env`. +```env +# OpenAI +MODEL=openai:gpt-4o-mini +API_KEY=your-openai-key + +# Groq (fast, free tier available) +MODEL=groq:llama-3.3-70b-versatile +API_KEY=your-groq-key + +# Anthropic +MODEL=anthropic:claude-3-5-haiku-latest +API_KEY=your-anthropic-key +``` + +No code changes needed — `src/providers/llm.py` handles all providers. ## Future Expansions @@ -226,9 +288,26 @@ def test_get_response_non_empty(): ## Switching Providers -Edit `src/providers/llm.py` and update `BASE_URL`, `MODEL`, and `API_KEY` in `.env`. +Edit `BASE_URL` and `MODEL` in `.env` to switch providers. + +`API_KEY` is passed directly — no code changes needed. -Compatible with any OpenAI-compatible API (Groq, OpenRouter, Ollama). +```env +# Groq +BASE_URL=https://api.groq.com/openai/v1 +MODEL=llama-3.3-70b-versatile +API_KEY=your-groq-key + +# OpenRouter +BASE_URL=https://openrouter.ai/api/v1 +MODEL=openai/gpt-4o-mini +API_KEY=your-openrouter-key + +# Ollama (local) +BASE_URL=http://localhost:11434/v1 +MODEL=llama3.2 +API_KEY=ollama +``` ## Future Expansions diff --git a/tests/test_chatbot_generator.py b/tests/test_chatbot_generator.py new file mode 100644 index 0000000..4aee53c --- /dev/null +++ b/tests/test_chatbot_generator.py @@ -0,0 +1,254 @@ +"""Generator integration tests for the AI Chatbot template.""" +import json + +from contextlib import contextmanager +from unittest.mock import patch + +from spawn.core.models import ProjectConfig +from spawn.generators.project_generator import ProjectGenerator +from spawn.templates.chatbot import ChatbotTemplate + + +def _cfg( + name: str = "my-bot", + framework: str = "pydantic-ai", + extras: list[str] | None = None, +) -> ProjectConfig: + return ProjectConfig( + name=name, + template="chatbot", + use_git=False, + framework=framework, + extras=extras or [], + ) + + +@contextmanager +def _mock_uv_and_install(): + with patch("spawn.generators.project_generator.install_packages"), \ + patch("spawn.generators.project_generator.initialize_uv"), \ + patch.object(ChatbotTemplate, "post_install"): + yield + + +# --------------------------------------------------------------------------- +# Structure tests +# --------------------------------------------------------------------------- + + +def test_chatbot_creates_root_dir(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-bot").is_dir() + + +def test_chatbot_creates_chatbot_dir(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-bot" / "src" / "chatbot").is_dir() + + +def test_chatbot_creates_providers_dir(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-bot" / "src" / "providers").is_dir() + + +def test_chatbot_creates_prompts_dir(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-bot" / "src" / "prompts").is_dir() + + +def test_chatbot_creates_utils_dir(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-bot" / "src" / "utils").is_dir() + + +def test_chatbot_creates_tests_dir(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-bot" / "tests").is_dir() + + +# --------------------------------------------------------------------------- +# File tests +# --------------------------------------------------------------------------- + + +def test_chatbot_creates_main(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-bot" / "src" / "main.py").exists() + + +def test_chatbot_creates_chat_py(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-bot" / "src" / "chatbot" / "chat.py").exists() + + +def test_chatbot_creates_llm_py(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-bot" / "src" / "providers" / "llm.py").exists() + + +def test_chatbot_creates_system_prompt(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-bot" / "src" / "prompts" / "system_prompt.py").exists() + + +def test_chatbot_creates_env_util(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-bot" / "src" / "utils" / "env.py").exists() + + +def test_chatbot_creates_test_file(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-bot" / "tests" / "test_chatbot.py").exists() + + +def test_chatbot_creates_env_example(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-bot" / ".env.example").exists() + + +# --------------------------------------------------------------------------- +# Content tests +# --------------------------------------------------------------------------- + + +def test_chatbot_pydantic_ai_env_has_correct_model_format(tmp_path, monkeypatch): + """pydantic-ai .env.example must use openai:gpt-4o-mini prefix.""" + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg(framework="pydantic-ai")) + env = (tmp_path / "my-bot" / ".env.example").read_text(encoding="utf-8") + assert "MODEL=openai:gpt-4o-mini" in env + assert "BASE_URL" not in env + + +def test_chatbot_openai_sdk_env_has_plain_model(tmp_path, monkeypatch): + """openai-sdk .env.example must use plain model name.""" + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg(framework="openai-sdk")) + env = (tmp_path / "my-bot" / ".env.example").read_text(encoding="utf-8") + assert "MODEL=gpt-4o-mini" in env + assert "BASE_URL" in env + + +def test_chatbot_readme_contains_project_name(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg(name="my-assistant")) + readme = (tmp_path / "my-assistant" / "README.md").read_text(encoding="utf-8") + assert "my-assistant" in readme + + +def test_chatbot_pydantic_ai_llm_uses_agent(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg(framework="pydantic-ai")) + llm = (tmp_path / "my-bot" / "src" / "providers" / "llm.py").read_text( + encoding="utf-8" + ) + assert "pydantic_ai" in llm + assert "Agent" in llm + + +def test_chatbot_openai_sdk_llm_uses_openai_client(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg(framework="openai-sdk")) + llm = (tmp_path / "my-bot" / "src" / "providers" / "llm.py").read_text( + encoding="utf-8" + ) + assert "OpenAI" in llm + assert "pydantic_ai" not in llm + + +# --------------------------------------------------------------------------- +# Meta +# --------------------------------------------------------------------------- + + +def test_chatbot_creates_spawn_meta_json(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg(framework="pydantic-ai")) + meta = json.loads( + (tmp_path / "my-bot" / ".spawn" / "meta.json").read_text(encoding="utf-8") + ) + assert meta["intent"] == "chatbot" + assert meta["framework"] == "pydantic-ai" + + +def test_chatbot_openai_sdk_meta_json(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg(framework="openai-sdk")) + meta = json.loads( + (tmp_path / "my-bot" / ".spawn" / "meta.json").read_text(encoding="utf-8") + ) + assert meta["intent"] == "chatbot" + assert meta["framework"] == "openai-sdk" + + +# --------------------------------------------------------------------------- +# Dependencies +# --------------------------------------------------------------------------- + + +def test_chatbot_pydantic_ai_install_packages_called(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with patch("spawn.generators.project_generator.install_packages") as mock_install, \ + patch("spawn.generators.project_generator.initialize_uv"), \ + patch.object(ChatbotTemplate, "post_install"): + ProjectGenerator().generate(_cfg(framework="pydantic-ai")) + args = mock_install.call_args[0][1] + assert "pydantic-ai" in args + assert "python-dotenv" in args + assert "openai" not in args + + +def test_chatbot_openai_sdk_install_packages_called(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with patch("spawn.generators.project_generator.install_packages") as mock_install, \ + patch("spawn.generators.project_generator.initialize_uv"), \ + patch.object(ChatbotTemplate, "post_install"): + ProjectGenerator().generate(_cfg(framework="openai-sdk")) + args = mock_install.call_args[0][1] + assert "openai" in args + assert "python-dotenv" in args + assert "pydantic-ai" not in args + + +def test_chatbot_extras_reach_install_packages(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with patch("spawn.generators.project_generator.install_packages") as mock_install, \ + patch("spawn.generators.project_generator.initialize_uv"), \ + patch.object(ChatbotTemplate, "post_install"): + ProjectGenerator().generate(_cfg(extras=["ruff", "pytest"])) + args = mock_install.call_args[0][1] + assert "ruff" in args + assert "pytest" in args diff --git a/tests/test_chatbot_template.py b/tests/test_chatbot_template.py index 475aee2..2e02fe4 100644 --- a/tests/test_chatbot_template.py +++ b/tests/test_chatbot_template.py @@ -134,3 +134,22 @@ def test_openai_sdk_env_example_has_base_url(): env_file = dict(t.starter_files)[".env.example"] result = env_file.format_map({"project_name": "test"}) assert "BASE_URL" in result + + +def test_pydantic_ai_llm_content_uses_result_output(): + """Generated llm.py must access result.output, not result.data.""" + t = ChatbotTemplate(framework="pydantic-ai") + files = dict(t.starter_files) + llm_content = files["src/providers/llm.py"] + assert "result.output" in llm_content + assert "result.data" not in llm_content + + +def test_pydantic_ai_llm_content_passes_api_key_to_run_sync(): + """Generated llm.py must pass api_key to run_sync, not set env vars.""" + t = ChatbotTemplate(framework="pydantic-ai") + files = dict(t.starter_files) + llm_content = files["src/providers/llm.py"] + assert "run_sync(prompt, api_key=api_key)" in llm_content + assert "OPENAI_API_KEY" not in llm_content + assert "setdefault" not in llm_content From 7b716bc3be48e2301569968762d397403e746fd3 Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Sun, 28 Jun 2026 11:43:41 +0530 Subject: [PATCH 08/35] fix: chatbot v2 --- src/spawn/templates/chatbot/__init__.py | 2 +- tests/test_chatbot_generator.py | 4 ++-- tests/test_chatbot_template.py | 4 ++-- tests/test_generator.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/spawn/templates/chatbot/__init__.py b/src/spawn/templates/chatbot/__init__.py index e596458..63b7560 100644 --- a/src/spawn/templates/chatbot/__init__.py +++ b/src/spawn/templates/chatbot/__init__.py @@ -96,7 +96,7 @@ def get_dependencies(self) -> list[str]: if self.framework == "openai-sdk": base = ["openai", "python-dotenv"] else: - base = ["pydantic-ai", "python-dotenv"] + base = ["pydantic-ai[openai]", "python-dotenv"] if "pytest" in self.extras: base += ["pytest"] if "ruff" in self.extras: diff --git a/tests/test_chatbot_generator.py b/tests/test_chatbot_generator.py index 4aee53c..b077b6d 100644 --- a/tests/test_chatbot_generator.py +++ b/tests/test_chatbot_generator.py @@ -226,7 +226,7 @@ def test_chatbot_pydantic_ai_install_packages_called(tmp_path, monkeypatch): patch.object(ChatbotTemplate, "post_install"): ProjectGenerator().generate(_cfg(framework="pydantic-ai")) args = mock_install.call_args[0][1] - assert "pydantic-ai" in args + assert "pydantic-ai[openai]" in args assert "python-dotenv" in args assert "openai" not in args @@ -240,7 +240,7 @@ def test_chatbot_openai_sdk_install_packages_called(tmp_path, monkeypatch): args = mock_install.call_args[0][1] assert "openai" in args assert "python-dotenv" in args - assert "pydantic-ai" not in args + assert "pydantic-ai[openai]" not in args def test_chatbot_extras_reach_install_packages(tmp_path, monkeypatch): diff --git a/tests/test_chatbot_template.py b/tests/test_chatbot_template.py index 2e02fe4..4a976e5 100644 --- a/tests/test_chatbot_template.py +++ b/tests/test_chatbot_template.py @@ -37,7 +37,7 @@ def test_chatbot_template_starter_files(): def test_chatbot_pydantic_ai_dependencies(): t = ChatbotTemplate(framework="pydantic-ai") deps = t.get_dependencies() - assert "pydantic-ai" in deps + assert "pydantic-ai[openai]" in deps assert "python-dotenv" in deps assert "openai" not in deps @@ -47,7 +47,7 @@ def test_chatbot_openai_sdk_dependencies(): deps = t.get_dependencies() assert "openai" in deps assert "python-dotenv" in deps - assert "pydantic-ai" not in deps + assert "pydantic-ai[openai]" not in deps def test_chatbot_extras_add_deps(): diff --git a/tests/test_generator.py b/tests/test_generator.py index b538b8d..f44d7f2 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -598,7 +598,7 @@ def test_chatbot_pydantic_ai_extras_in_dependencies(): framework="pydantic-ai", extras=["ruff", "pytest"], ) deps = instantiate_template(config).get_dependencies() - assert "pydantic-ai" in deps + assert "pydantic-ai[openai]" in deps assert "python-dotenv" in deps assert "ruff" in deps assert "pytest" in deps From 1e4abee5f62bf1245c5cf62ba487dc6ac4463f30 Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Sun, 28 Jun 2026 12:31:16 +0530 Subject: [PATCH 09/35] Add provider field to ProjectConfig and TemplateMetadata --- src/spawn/core/models.py | 1 + src/spawn/core/registry.py | 10 +++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/spawn/core/models.py b/src/spawn/core/models.py index edec7e4..1dba0b9 100644 --- a/src/spawn/core/models.py +++ b/src/spawn/core/models.py @@ -9,3 +9,4 @@ class ProjectConfig: framework: str | None = None extras: list[str] = field(default_factory=list) cli_type: str | None = None + provider: str | None = None diff --git a/src/spawn/core/registry.py b/src/spawn/core/registry.py index c55a5a8..1453968 100644 --- a/src/spawn/core/registry.py +++ b/src/spawn/core/registry.py @@ -22,6 +22,7 @@ class TemplateMetadata: available_frameworks: list[str] = field(default_factory=list) available_extras: list[str] = field(default_factory=list) available_cli_types: list[str] = field(default_factory=list) + available_providers: list[str] = field(default_factory=list) # Slugs that existed in previous versions but have been superseded. @@ -57,10 +58,11 @@ class TemplateMetadata: "chatbot": TemplateMetadata( slug="chatbot", display_name="AI Chatbot", - description="Conversational AI with PydanticAI or OpenAI SDK", + description="Conversational AI with PydanticAI, OpenAI SDK, or LiteLLM", template_class=ChatbotTemplate, - available_frameworks=["pydantic-ai", "openai-sdk"], - available_extras=["ruff", "pytest", "github-actions"], + available_frameworks=["pydantic-ai", "openai-sdk", "litellm"], + available_providers=["openai", "anthropic", "gemini", "openrouter", "ollama"], + available_extras=["ruff", "pytest", "rich", "github-actions"], ), } @@ -102,6 +104,8 @@ def instantiate_template(config: ProjectConfig) -> BaseTemplate | None: kwargs["extras"] = config.extras if "cli_type" in params: kwargs["cli_type"] = config.cli_type + if "provider" in params: + kwargs["provider"] = config.provider return cls(**kwargs) From 0de241ae668f5b52ffd710f32cb08f8a5a36fb32 Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Sun, 28 Jun 2026 12:32:51 +0530 Subject: [PATCH 10/35] Add provider prompt to prompts.py --- src/spawn/cli/prompts.py | 41 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/spawn/cli/prompts.py b/src/spawn/cli/prompts.py index 9fed0fa..7f1558a 100644 --- a/src/spawn/cli/prompts.py +++ b/src/spawn/cli/prompts.py @@ -69,6 +69,7 @@ def get_project_config() -> ProjectConfig: # --- Framework selection --- selected_framework: str | None = None selected_cli_type: str | None = None + selected_provider: str | None = None meta = get_metadata(template) # --- CLI type selection --- @@ -130,6 +131,45 @@ def get_project_config() -> ProjectConfig: selected_framework = framework_map[fw_choice] + # --- Provider selection --- + PROVIDER_MAP: dict[str, list[str]] = { + "pydantic-ai": ["openai", "anthropic", "gemini", "openrouter", "ollama"], + "openai-sdk": ["openai", "openrouter", "gemini"], + "litellm": ["openai", "anthropic", "gemini", "openrouter", "ollama"], + } + + if meta and meta.available_providers and selected_framework: + provider_options = PROVIDER_MAP.get(selected_framework, meta.available_providers) + provider_choice_map = { + str(i): p for i, p in enumerate(provider_options, start=1) + } + + _print_list(provider_options) + + valid_prov_range = len(provider_options) + prov_choice = typer.prompt( + typer.style( + f"Choose Provider [1-{valid_prov_range}]", + fg=typer.colors.CYAN, + ), + default="1", + ) + + while prov_choice not in provider_choice_map: + typer.secho( + "Invalid choice. Please select a valid number.", + fg=typer.colors.RED, + ) + prov_choice = typer.prompt( + typer.style( + f"Choose Provider [1-{valid_prov_range}]", + fg=typer.colors.CYAN, + ), + default="1", + ) + + selected_provider = provider_choice_map[prov_choice] + # --- Extras selection --- selected_extras: list[str] = [] @@ -175,4 +215,5 @@ def get_project_config() -> ProjectConfig: framework=selected_framework, extras=selected_extras, cli_type=selected_cli_type, + provider=selected_provider, ) From dbddb7d0b1a02783f19e0c0f2074e31090ab404e Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Sun, 28 Jun 2026 12:35:00 +0530 Subject: [PATCH 11/35] =?UTF-8?q?Rebuild=20chatbot/content.py=20with=20all?= =?UTF-8?q?=20framework=C3=97provider=20combinations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/spawn/templates/chatbot/content.py | 597 ++++++++++++++++--------- 1 file changed, 379 insertions(+), 218 deletions(-) diff --git a/src/spawn/templates/chatbot/content.py b/src/spawn/templates/chatbot/content.py index 5f42c63..363ef58 100644 --- a/src/spawn/templates/chatbot/content.py +++ b/src/spawn/templates/chatbot/content.py @@ -1,38 +1,84 @@ INIT_CONTENT = "" -PYDANTIC_AI_MAIN_CONTENT = """\ -from src.chatbot.chat import get_response -from src.utils.env import load_env +# ─── Shared content ─────────────────────────────────────────────────────── +CHAT_CONTENT = """\ +from src.providers.llm import get_llm_response +from src.memory.history import append_user, append_assistant, get_history +from src.config.settings import get_system_prompt -def main() -> None: - load_env() - print("Chatbot ready. Type 'quit' to exit.\\n") - while True: - user_input = input("You: ").strip() - if not user_input: - continue - if user_input.lower() in ("quit", "exit"): - break - response = get_response(user_input) - print(f"Bot: {{response}}\\n") + +def get_response(user_input: str) -> str: + append_user(user_input) + history = get_history() + system_prompt = get_system_prompt() + response = get_llm_response(history, system_prompt) + append_assistant(response) + return response +""" + +MEMORY_HISTORY_CONTENT = """\ +_history: list[dict] = [] -if __name__ == "__main__": - main() +def append_user(content: str) -> None: + _history.append({{"role": "user", "content": content}}) + + +def append_assistant(content: str) -> None: + _history.append({{"role": "assistant", "content": content}}) + + +def get_history() -> list[dict]: + return list(_history) + + +def clear() -> None: + _history.clear() """ -OPENAI_MAIN_CONTENT = PYDANTIC_AI_MAIN_CONTENT +SYSTEM_PROMPT_TXT_CONTENT = """\ +You are a helpful AI assistant. +Answer questions clearly and concisely. +""" -PYDANTIC_AI_LLM_CONTENT = """\ +SETTINGS_CONTENT = """\ +import os +from pathlib import Path + +from dotenv import load_dotenv + + +def load_env() -> None: + load_dotenv() + + +def get_model() -> str: + return os.getenv("MODEL", "") + + +def get_system_prompt() -> str: + prompt_path = Path(__file__).parent.parent / "prompts" / "system.txt" + return prompt_path.read_text(encoding="utf-8").strip() +""" + +ENV_UTIL_CONTENT = """\ +from src.config.settings import load_env + +__all__ = ["load_env"] +""" + +# ─── PydanticAI ─────────────────────────────────────────────────────────── + +PYDANTIC_AI_OPENAI_LLM_CONTENT = """\ import os from pydantic_ai import Agent def get_llm_response(messages: list[dict], system_prompt: str) -> str: - api_key = os.getenv("API_KEY", "") model = os.getenv("MODEL", "openai:gpt-4o-mini") + api_key = os.getenv("OPENAI_API_KEY", "") history = [m["content"] for m in messages if m["role"] == "user"] prompt = history[-1] if history else "" agent = Agent(model, system_prompt=system_prompt) @@ -40,285 +86,400 @@ def get_llm_response(messages: list[dict], system_prompt: str) -> str: return result.output """ -OPENAI_LLM_CONTENT = """\ +PYDANTIC_AI_ANTHROPIC_LLM_CONTENT = """\ import os -from openai import OpenAI +from pydantic_ai import Agent def get_llm_response(messages: list[dict], system_prompt: str) -> str: - client = OpenAI( - api_key=os.getenv("API_KEY", ""), - base_url=os.getenv("BASE_URL", "https://api.openai.com/v1"), - ) - model = os.getenv("MODEL", "gpt-4o-mini") - full_messages = [{{"role": "system", "content": system_prompt}}] + messages - response = client.chat.completions.create( - model=model, - messages=full_messages, - ) - return response.choices[0].message.content or "" + model = os.getenv("MODEL", "anthropic:claude-3-5-haiku-latest") + api_key = os.getenv("ANTHROPIC_API_KEY", "") + history = [m["content"] for m in messages if m["role"] == "user"] + prompt = history[-1] if history else "" + agent = Agent(model, system_prompt=system_prompt) + result = agent.run_sync(prompt, api_key=api_key) + return result.output """ -CHAT_CONTENT = """\ -from src.providers.llm import get_llm_response -from src.prompts.system_prompt import SYSTEM_PROMPT +PYDANTIC_AI_GEMINI_LLM_CONTENT = """\ +import os -_history: list[dict] = [] +from pydantic_ai import Agent -def get_response(user_input: str) -> str: - _history.append({{"role": "user", "content": user_input}}) - response = get_llm_response(_history, SYSTEM_PROMPT) - _history.append({{"role": "assistant", "content": response}}) - return response +def get_llm_response(messages: list[dict], system_prompt: str) -> str: + model = os.getenv("MODEL", "google-gla:gemini-1.5-flash") + api_key = os.getenv("GOOGLE_API_KEY", "") + history = [m["content"] for m in messages if m["role"] == "user"] + prompt = history[-1] if history else "" + agent = Agent(model, system_prompt=system_prompt) + result = agent.run_sync(prompt, api_key=api_key) + return result.output """ -SYSTEM_PROMPT_CONTENT = """\ -SYSTEM_PROMPT = ( - "You are a helpful assistant. " - "Answer questions clearly and concisely." -) -""" +PYDANTIC_AI_OPENROUTER_LLM_CONTENT = """\ +import os -ENV_UTIL_CONTENT = """\ -from dotenv import load_dotenv +from openai import AsyncOpenAI +from pydantic_ai import Agent +from pydantic_ai.providers.openai import OpenAIProvider -def load_env() -> None: - load_dotenv() +def get_llm_response(messages: list[dict], system_prompt: str) -> str: + model_name = os.getenv("MODEL", "openai/gpt-4o-mini") + api_key = os.getenv("OPENROUTER_API_KEY", "") + history = [m["content"] for m in messages if m["role"] == "user"] + prompt = history[-1] if history else "" + openai_client = AsyncOpenAI( + api_key=api_key, + base_url="https://openrouter.ai/api/v1", + ) + provider = OpenAIProvider(openai_client=openai_client) + agent = Agent(f"openai:{{model_name}}", system_prompt=system_prompt, model_settings={{"provider": provider}}) + result = agent.run_sync(prompt) + return result.output """ -PYDANTIC_AI_ENV_EXAMPLE_CONTENT = """\ -APP_NAME={project_name} +PYDANTIC_AI_OLLAMA_LLM_CONTENT = """\ +import os -# Your API key — passed directly to the provider via pydantic-ai -API_KEY= +from openai import AsyncOpenAI +from pydantic_ai import Agent +from pydantic_ai.providers.openai import OpenAIProvider -# Model string with provider prefix (pydantic-ai format) -# Examples: -# openai:gpt-4o-mini -# groq:llama-3.3-70b-versatile -# anthropic:claude-3-5-haiku-latest -MODEL=openai:gpt-4o-mini + +def get_llm_response(messages: list[dict], system_prompt: str) -> str: + model_name = os.getenv("MODEL", "llama3.2") + base_url = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434/v1") + history = [m["content"] for m in messages if m["role"] == "user"] + prompt = history[-1] if history else "" + openai_client = AsyncOpenAI(api_key="ollama", base_url=base_url) + provider = OpenAIProvider(openai_client=openai_client) + agent = Agent(f"openai:{{model_name}}", system_prompt=system_prompt, model_settings={{"provider": provider}}) + result = agent.run_sync(prompt) + return result.output """ -OPENAI_ENV_EXAMPLE_CONTENT = """\ -APP_NAME={project_name} +# ─── OpenAI SDK ─────────────────────────────────────────────────────────── -# Your API key for the provider -API_KEY= +OPENAI_SDK_OPENAI_LLM_CONTENT = """\ +import os + +from openai import OpenAI -# Plain model name (no provider prefix for OpenAI SDK) -MODEL=gpt-4o-mini -# Base URL — change to use Groq, OpenRouter, Ollama, etc. -# Examples: -# https://api.groq.com/openai/v1 -# https://openrouter.ai/api/v1 -# http://localhost:11434/v1 -BASE_URL=https://api.openai.com/v1 +def get_llm_response(messages: list[dict], system_prompt: str) -> str: + client = OpenAI(api_key=os.getenv("OPENAI_API_KEY", "")) + model = os.getenv("MODEL", "gpt-4o-mini") + full_messages = [{{"role": "system", "content": system_prompt}}] + messages + response = client.chat.completions.create(model=model, messages=full_messages) + return response.choices[0].message.content or "" """ -PYDANTIC_AI_TEST_CONTENT = """\ -from unittest.mock import MagicMock, patch +OPENAI_SDK_OPENROUTER_LLM_CONTENT = """\ +import os -from src.chatbot.chat import get_response -from src.providers.llm import get_llm_response +from openai import OpenAI -def test_get_response_returns_string(): - with patch("src.chatbot.chat.get_llm_response", return_value="Hello!"): - result = get_response("Hi") - assert isinstance(result, str) - assert result == "Hello!" +def get_llm_response(messages: list[dict], system_prompt: str) -> str: + client = OpenAI( + api_key=os.getenv("OPENROUTER_API_KEY", ""), + base_url="https://openrouter.ai/api/v1", + ) + model = os.getenv("MODEL", "openai/gpt-4o-mini") + full_messages = [{{"role": "system", "content": system_prompt}}] + messages + response = client.chat.completions.create(model=model, messages=full_messages) + return response.choices[0].message.content or "" +""" +OPENAI_SDK_GEMINI_LLM_CONTENT = """\ +import os + +from openai import OpenAI -def test_get_response_non_empty(): - with patch("src.chatbot.chat.get_llm_response", return_value="Sure!"): - result = get_response("Tell me something") - assert result != "" +def get_llm_response(messages: list[dict], system_prompt: str) -> str: + client = OpenAI( + api_key=os.getenv("GOOGLE_API_KEY", ""), + base_url="https://generativelanguage.googleapis.com/v1beta/openai/", + ) + model = os.getenv("MODEL", "gemini-1.5-flash") + full_messages = [{{"role": "system", "content": system_prompt}}] + messages + response = client.chat.completions.create(model=model, messages=full_messages) + return response.choices[0].message.content or "" +""" + +# ─── LiteLLM ────────────────────────────────────────────────────────────── + +LITELLM_OPENAI_LLM_CONTENT = """\ +import os -def test_get_llm_response_uses_output_attribute(): - mock_result = MagicMock() - mock_result.output = "test response" - with patch("src.providers.llm.Agent") as mock_agent_class: - mock_agent = MagicMock() - mock_agent.run_sync.return_value = mock_result - mock_agent_class.return_value = mock_agent - messages = [{{"role": "user", "content": "hello"}}] - response = get_llm_response(messages, "You are helpful.") - assert response == "test response" - mock_agent.run_sync.assert_called_once() +import litellm -def test_get_llm_response_passes_api_key(): - mock_result = MagicMock() - mock_result.output = "ok" - with patch("src.providers.llm.Agent") as mock_agent_class: - mock_agent = MagicMock() - mock_agent.run_sync.return_value = mock_result - mock_agent_class.return_value = mock_agent - with patch("src.providers.llm.os.getenv", side_effect=lambda k, d="": {{ - "API_KEY": "test-key", "MODEL": "openai:gpt-4o-mini" - }}.get(k, d)): - get_llm_response([{{"role": "user", "content": "hi"}}], "prompt") - call_kwargs = mock_agent.run_sync.call_args - assert call_kwargs.kwargs.get("api_key") == "test-key" +def get_llm_response(messages: list[dict], system_prompt: str) -> str: + model = os.getenv("MODEL", "gpt-4o-mini") + full_messages = [{{"role": "system", "content": system_prompt}}] + messages + response = litellm.completion(model=model, messages=full_messages) + return response.choices[0].message.content or "" """ -OPENAI_TEST_CONTENT = PYDANTIC_AI_TEST_CONTENT +LITELLM_ANTHROPIC_LLM_CONTENT = """\ +import os -PYDANTIC_AI_README_CONTENT = """\ -# {project_name} +import litellm -An AI chatbot generated with Spawn, using PydanticAI. -## Getting Started +def get_llm_response(messages: list[dict], system_prompt: str) -> str: + model = os.getenv("MODEL", "claude-3-5-haiku-latest") + full_messages = [{{"role": "system", "content": system_prompt}}] + messages + response = litellm.completion(model=model, messages=full_messages) + return response.choices[0].message.content or "" +""" -1. Add your API key to `.env`: +LITELLM_GEMINI_LLM_CONTENT = """\ +import os -```env -API_KEY=your-api-key-here -# Provider-prefixed model string: -MODEL=openai:gpt-4o-mini -# or: groq:llama-3.3-70b-versatile -# or: anthropic:claude-3-5-haiku-latest -``` +import litellm -2. Run the chatbot: -```bash -uv run python -m src.main -``` +def get_llm_response(messages: list[dict], system_prompt: str) -> str: + model = os.getenv("MODEL", "gemini/gemini-1.5-flash") + full_messages = [{{"role": "system", "content": system_prompt}}] + messages + response = litellm.completion(model=model, messages=full_messages) + return response.choices[0].message.content or "" +""" -## Project Structure +LITELLM_OPENROUTER_LLM_CONTENT = """\ +import os -``` -{project_name}/ -├── src/ -│ ├── chatbot/ # Conversation logic -│ ├── providers/ # LLM provider (llm.py) -│ ├── prompts/ # System prompt definitions -│ ├── utils/ # Shared helpers -│ └── main.py # Entry point -├── tests/ -├── .env.example -└── README.md -``` +import litellm -## Running Tests -```bash -uv run pytest -``` +def get_llm_response(messages: list[dict], system_prompt: str) -> str: + model = os.getenv("MODEL", "openrouter/openai/gpt-4o-mini") + full_messages = [{{"role": "system", "content": system_prompt}}] + messages + response = litellm.completion(model=model, messages=full_messages) + return response.choices[0].message.content or "" +""" -## Switching Providers +LITELLM_OLLAMA_LLM_CONTENT = """\ +import os -Change the MODEL in `.env` to switch providers: +import litellm -```env -# OpenAI -MODEL=openai:gpt-4o-mini -API_KEY=your-openai-key -# Groq (fast, free tier available) -MODEL=groq:llama-3.3-70b-versatile -API_KEY=your-groq-key +def get_llm_response(messages: list[dict], system_prompt: str) -> str: + model = os.getenv("MODEL", "ollama/llama3.2") + full_messages = [{{"role": "system", "content": system_prompt}}] + messages + response = litellm.completion(model=model, messages=full_messages) + return response.choices[0].message.content or "" +""" -# Anthropic -MODEL=anthropic:claude-3-5-haiku-latest -API_KEY=your-anthropic-key -``` +# ─── Main content ───────────────────────────────────────────────────────── -No code changes needed — `src/providers/llm.py` handles all providers. +MAIN_CONTENT_NO_RICH = """\ +from src.chatbot.chat import get_response +from src.config.settings import load_env -## Future Expansions -```bash -spawn add memory -spawn add tools -spawn add rag -spawn add discord -``` +def main() -> None: + load_env() + print("Chatbot ready. Type 'quit' to exit.\\n") + while True: + user_input = input("You: ").strip() + if not user_input: + continue + if user_input.lower() in ("quit", "exit"): + break + response = get_response(user_input) + print(f"Bot: {{response}}\\n") + + +if __name__ == "__main__": + main() """ -OPENAI_README_CONTENT = """\ -# {project_name} +MAIN_CONTENT_RICH = """\ +from rich.console import Console +from rich.panel import Panel + +from src.chatbot.chat import get_response +from src.config.settings import load_env -An AI chatbot generated with Spawn, using the OpenAI SDK. +console = Console() -## Getting Started -1. Add your API key to `.env`: +def main() -> None: + load_env() + console.print(Panel.fit("[bold cyan]Chatbot[/bold cyan] — type [dim]quit[/dim] to exit")) + while True: + user_input = console.input("[bold yellow]You:[/bold yellow] ").strip() + if not user_input: + continue + if user_input.lower() in ("quit", "exit"): + break + response = get_response(user_input) + console.print(f"[bold green]Bot:[/bold green] {{response}}\\n") -```env -API_KEY=your-api-key-here -MODEL=gpt-4o-mini -BASE_URL=https://api.openai.com/v1 -``` -2. Run the chatbot: +if __name__ == "__main__": + main() +""" -```bash -uv run python -m src.main -``` +# ─── Env examples (provider-specific) ───────────────────────────────────── -## Project Structure +ENV_OPENAI = """\ +APP_NAME={project_name} +OPENAI_API_KEY= +MODEL=gpt-4o-mini +""" -``` -{project_name}/ -├── src/ -│ ├── chatbot/ # Conversation logic -│ ├── providers/ # LLM provider (llm.py) -│ ├── prompts/ # System prompt definitions -│ ├── utils/ # Shared helpers -│ └── main.py # Entry point -├── tests/ -├── .env.example -└── README.md -``` +ENV_ANTHROPIC = """\ +APP_NAME={project_name} +ANTHROPIC_API_KEY= +MODEL=claude-3-5-haiku-latest +""" -## Running Tests +ENV_GEMINI = """\ +APP_NAME={project_name} +GOOGLE_API_KEY= +MODEL=gemini-1.5-flash +""" -```bash -uv run pytest -``` +ENV_OPENROUTER = """\ +APP_NAME={project_name} +OPENROUTER_API_KEY= +MODEL=openai/gpt-4o-mini +""" -## Switching Providers +ENV_OLLAMA = """\ +APP_NAME={project_name} +OLLAMA_BASE_URL=http://localhost:11434 +MODEL=llama3.2 +""" -Edit `BASE_URL` and `MODEL` in `.env` to switch providers. +# Provider-prefixed MODEL for pydantic-ai variants -`API_KEY` is passed directly — no code changes needed. +ENV_PYDANTIC_OPENAI = """\ +APP_NAME={project_name} +OPENAI_API_KEY= +MODEL=openai:gpt-4o-mini +""" -```env -# Groq -BASE_URL=https://api.groq.com/openai/v1 -MODEL=llama-3.3-70b-versatile -API_KEY=your-groq-key +ENV_PYDANTIC_ANTHROPIC = """\ +APP_NAME={project_name} +ANTHROPIC_API_KEY= +MODEL=anthropic:claude-3-5-haiku-latest +""" + +ENV_PYDANTIC_GEMINI = """\ +APP_NAME={project_name} +GOOGLE_API_KEY= +MODEL=google-gla:gemini-1.5-flash +""" -# OpenRouter -BASE_URL=https://openrouter.ai/api/v1 +ENV_PYDANTIC_OPENROUTER = """\ +APP_NAME={project_name} +OPENROUTER_API_KEY= MODEL=openai/gpt-4o-mini -API_KEY=your-openrouter-key +""" -# Ollama (local) -BASE_URL=http://localhost:11434/v1 +ENV_PYDANTIC_OLLAMA = """\ +APP_NAME={project_name} +OLLAMA_BASE_URL=http://localhost:11434 MODEL=llama3.2 -API_KEY=ollama -``` +""" + +# ─── Test content ───────────────────────────────────────────────────────── + +TEST_CONTENT = """\ +from unittest.mock import MagicMock, patch + +from src.chatbot.chat import get_response +from src.providers.llm import get_llm_response -## Future Expansions -```bash -spawn add memory -spawn add tools -spawn add rag -spawn add discord -``` +def test_get_response_returns_string(): + with patch("src.chatbot.chat.get_llm_response", return_value="Hello!"): + with patch("src.chatbot.chat.get_system_prompt", return_value="You are helpful."): + result = get_response("Hi") + assert isinstance(result, str) + assert result == "Hello!" + + +def test_generate_response_mock(): + with patch("src.chatbot.chat.get_llm_response", return_value="Mocked response"): + with patch("src.chatbot.chat.get_system_prompt", return_value="prompt"): + result = get_response("test input") + assert result == "Mocked response" + + +def test_get_response_non_empty(): + with patch("src.chatbot.chat.get_llm_response", return_value="Sure!"): + with patch("src.chatbot.chat.get_system_prompt", return_value="prompt"): + result = get_response("Tell me something") + assert result != "" """ +# ─── README content ─────────────────────────────────────────────────────── + +def make_readme(framework: str, provider: str) -> str: + provider_key_map = { + "openai": "OPENAI_API_KEY=your-key", + "anthropic": "ANTHROPIC_API_KEY=your-key", + "gemini": "GOOGLE_API_KEY=your-key", + "openrouter": "OPENROUTER_API_KEY=your-key", + "ollama": "OLLAMA_BASE_URL=http://localhost:11434", + } + key_line = provider_key_map.get(provider, "API_KEY=your-key") + return ( + "# {project_name}\n\n" + f"An AI chatbot generated with Spawn, using {framework.title()} + {provider.title()}.\n\n" + "## Getting Started\n\n" + "1. Copy `.env.example` to `.env` and fill in your credentials:\n\n" + "```env\n" + f"{key_line}\n" + "```\n\n" + "2. Run the chatbot:\n\n" + "```bash\n" + "uv run python -m src.main\n" + "```\n\n" + "## Example\n\n" + "```\n" + "You: Hello\n" + "Bot: Hello! How can I help?\n" + "```\n\n" + "## Project Structure\n\n" + "```\n" + "{project_name}/\n" + "├── src/\n" + "│ ├── chatbot/ # Conversation orchestration\n" + "│ ├── providers/ # LLM provider (llm.py)\n" + "│ ├── prompts/ # system.txt\n" + "│ ├── memory/ # Runtime conversation history\n" + "│ ├── config/ # Settings and env loading\n" + "│ └── main.py\n" + "├── tests/\n" + "├── .env.example\n" + "└── README.md\n" + "```\n\n" + "## Running Tests\n\n" + "```bash\n" + "uv run pytest\n" + "```\n\n" + "## Future Expansions\n\n" + "```bash\n" + "spawn add memory\n" + "spawn add tools\n" + "spawn add rag\n" + "```\n" + ) + +# ─── GitHub Actions ─────────────────────────────────────────────────────── + GITHUB_ACTIONS_CI_BASE = """\ name: CI From 8dedd7d407f4408fdf3442c7a90ba4cbd02f092f Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Sun, 28 Jun 2026 12:36:36 +0530 Subject: [PATCH 12/35] Rebuild chatbot/__init__.py --- src/spawn/templates/chatbot/__init__.py | 199 ++++++++++++++++-------- 1 file changed, 133 insertions(+), 66 deletions(-) diff --git a/src/spawn/templates/chatbot/__init__.py b/src/spawn/templates/chatbot/__init__.py index 63b7560..01115b5 100644 --- a/src/spawn/templates/chatbot/__init__.py +++ b/src/spawn/templates/chatbot/__init__.py @@ -3,53 +3,117 @@ from spawn.templates.base import BaseTemplate from spawn.templates.chatbot.content import ( INIT_CONTENT, - PYDANTIC_AI_MAIN_CONTENT, - OPENAI_MAIN_CONTENT, - PYDANTIC_AI_LLM_CONTENT, - OPENAI_LLM_CONTENT, CHAT_CONTENT, - SYSTEM_PROMPT_CONTENT, + MEMORY_HISTORY_CONTENT, + SYSTEM_PROMPT_TXT_CONTENT, + SETTINGS_CONTENT, ENV_UTIL_CONTENT, - PYDANTIC_AI_ENV_EXAMPLE_CONTENT, - OPENAI_ENV_EXAMPLE_CONTENT, - PYDANTIC_AI_TEST_CONTENT, - OPENAI_TEST_CONTENT, - PYDANTIC_AI_README_CONTENT, - OPENAI_README_CONTENT, + MAIN_CONTENT_NO_RICH, + MAIN_CONTENT_RICH, + TEST_CONTENT, GITHUB_ACTIONS_CI_BASE, GITHUB_ACTIONS_CI_RUFF_STEP, GITHUB_ACTIONS_CI_PYTEST_STEP, + PYDANTIC_AI_OPENAI_LLM_CONTENT, + PYDANTIC_AI_ANTHROPIC_LLM_CONTENT, + PYDANTIC_AI_GEMINI_LLM_CONTENT, + PYDANTIC_AI_OPENROUTER_LLM_CONTENT, + PYDANTIC_AI_OLLAMA_LLM_CONTENT, + OPENAI_SDK_OPENAI_LLM_CONTENT, + OPENAI_SDK_OPENROUTER_LLM_CONTENT, + OPENAI_SDK_GEMINI_LLM_CONTENT, + LITELLM_OPENAI_LLM_CONTENT, + LITELLM_ANTHROPIC_LLM_CONTENT, + LITELLM_GEMINI_LLM_CONTENT, + LITELLM_OPENROUTER_LLM_CONTENT, + LITELLM_OLLAMA_LLM_CONTENT, + ENV_OPENAI, + ENV_ANTHROPIC, + ENV_GEMINI, + ENV_OPENROUTER, + ENV_OLLAMA, + ENV_PYDANTIC_OPENAI, + ENV_PYDANTIC_ANTHROPIC, + ENV_PYDANTIC_GEMINI, + ENV_PYDANTIC_OPENROUTER, + ENV_PYDANTIC_OLLAMA, + make_readme, ) CHATBOT_FOLDERS = [ "src/chatbot", "src/providers", "src/prompts", + "src/memory", + "src/config", "src/utils", "tests", ] +# Maps (framework, provider) → llm content +_LLM_MAP: dict[tuple[str, str], str] = { + ("pydantic-ai", "openai"): PYDANTIC_AI_OPENAI_LLM_CONTENT, + ("pydantic-ai", "anthropic"): PYDANTIC_AI_ANTHROPIC_LLM_CONTENT, + ("pydantic-ai", "gemini"): PYDANTIC_AI_GEMINI_LLM_CONTENT, + ("pydantic-ai", "openrouter"): PYDANTIC_AI_OPENROUTER_LLM_CONTENT, + ("pydantic-ai", "ollama"): PYDANTIC_AI_OLLAMA_LLM_CONTENT, + ("openai-sdk", "openai"): OPENAI_SDK_OPENAI_LLM_CONTENT, + ("openai-sdk", "openrouter"): OPENAI_SDK_OPENROUTER_LLM_CONTENT, + ("openai-sdk", "gemini"): OPENAI_SDK_GEMINI_LLM_CONTENT, + ("litellm", "openai"): LITELLM_OPENAI_LLM_CONTENT, + ("litellm", "anthropic"): LITELLM_ANTHROPIC_LLM_CONTENT, + ("litellm", "gemini"): LITELLM_GEMINI_LLM_CONTENT, + ("litellm", "openrouter"): LITELLM_OPENROUTER_LLM_CONTENT, + ("litellm", "ollama"): LITELLM_OLLAMA_LLM_CONTENT, +} -def _build_files( - main_content: str, - llm_content: str, - test_content: str, - env_example_content: str, -) -> list: +# Maps provider → env example (pydantic-ai uses prefixed MODEL format) +_ENV_MAP_PYDANTIC: dict[str, str] = { + "openai": ENV_PYDANTIC_OPENAI, + "anthropic": ENV_PYDANTIC_ANTHROPIC, + "gemini": ENV_PYDANTIC_GEMINI, + "openrouter": ENV_PYDANTIC_OPENROUTER, + "ollama": ENV_PYDANTIC_OLLAMA, +} + +_ENV_MAP_GENERIC: dict[str, str] = { + "openai": ENV_OPENAI, + "anthropic": ENV_ANTHROPIC, + "gemini": ENV_GEMINI, + "openrouter": ENV_OPENROUTER, + "ollama": ENV_OLLAMA, +} + + +def _resolve_llm(framework: str, provider: str) -> str: + return _LLM_MAP.get((framework, provider), PYDANTIC_AI_OPENAI_LLM_CONTENT) + + +def _resolve_env(framework: str, provider: str) -> str: + if framework == "pydantic-ai": + return _ENV_MAP_PYDANTIC.get(provider, ENV_PYDANTIC_OPENAI) + return _ENV_MAP_GENERIC.get(provider, ENV_OPENAI) + + +def _build_files(main_content: str, llm_content: str, env_content: str) -> list: return [ - ("src/__init__.py", INIT_CONTENT), - ("src/chatbot/__init__.py", INIT_CONTENT), - ("src/chatbot/chat.py", CHAT_CONTENT), + ("src/__init__.py", INIT_CONTENT), + ("src/chatbot/__init__.py", INIT_CONTENT), + ("src/chatbot/chat.py", CHAT_CONTENT), ("src/providers/__init__.py", INIT_CONTENT), - ("src/providers/llm.py", llm_content), - ("src/prompts/__init__.py", INIT_CONTENT), - ("src/prompts/system_prompt.py", SYSTEM_PROMPT_CONTENT), - ("src/utils/__init__.py", INIT_CONTENT), - ("src/utils/env.py", ENV_UTIL_CONTENT), - ("src/main.py", main_content), - ("tests/__init__.py", INIT_CONTENT), - ("tests/test_chatbot.py", test_content), - (".env.example", env_example_content), + ("src/providers/llm.py", llm_content), + ("src/prompts/__init__.py", INIT_CONTENT), + ("src/prompts/system.txt", SYSTEM_PROMPT_TXT_CONTENT), + ("src/memory/__init__.py", INIT_CONTENT), + ("src/memory/history.py", MEMORY_HISTORY_CONTENT), + ("src/config/__init__.py", INIT_CONTENT), + ("src/config/settings.py", SETTINGS_CONTENT), + ("src/utils/__init__.py", INIT_CONTENT), + ("src/utils/env.py", ENV_UTIL_CONTENT), + ("src/main.py", main_content), + ("tests/__init__.py", INIT_CONTENT), + ("tests/test_chatbot.py", TEST_CONTENT), + (".env.example", env_content), ] @@ -57,62 +121,67 @@ class ChatbotTemplate(BaseTemplate): def __init__( self, framework: str | None = None, + provider: str | None = None, extras: list[str] | None = None, ) -> None: self.framework = framework or "pydantic-ai" - self.extras = extras or [] - - if self.framework == "openai-sdk": - main_content = OPENAI_MAIN_CONTENT - llm_content = OPENAI_LLM_CONTENT - test_content = OPENAI_TEST_CONTENT - env_example_content = OPENAI_ENV_EXAMPLE_CONTENT - else: - # Default: pydantic-ai - main_content = PYDANTIC_AI_MAIN_CONTENT - llm_content = PYDANTIC_AI_LLM_CONTENT - test_content = PYDANTIC_AI_TEST_CONTENT - env_example_content = PYDANTIC_AI_ENV_EXAMPLE_CONTENT + self.provider = provider or "openai" + self.extras = extras or [] + + llm_content = _resolve_llm(self.framework, self.provider) + env_content = _resolve_env(self.framework, self.provider) + main_content = MAIN_CONTENT_RICH if "rich" in self.extras else MAIN_CONTENT_NO_RICH super().__init__( name="AI Chatbot", folders=list(CHATBOT_FOLDERS), - starter_files=_build_files( - main_content, llm_content, test_content, env_example_content - ), + starter_files=_build_files(main_content, llm_content, env_content), next_steps=[ "cd {project_name}", - "Add your API_KEY to .env", + "cp .env.example .env # then fill in your API key", "uv run python -m src.main", ], ) def get_readme_content(self, context: dict) -> str | None: - if self.framework == "openai-sdk": - return OPENAI_README_CONTENT.format_map(context) - return PYDANTIC_AI_README_CONTENT.format_map(context) + raw = make_readme(self.framework, self.provider) + return raw.format_map(context) def get_dependencies(self) -> list[str]: - if self.framework == "openai-sdk": - base = ["openai", "python-dotenv"] - else: - base = ["pydantic-ai[openai]", "python-dotenv"] + dep_map: dict[tuple[str, str], list[str]] = { + ("pydantic-ai", "openai"): ["pydantic-ai", "openai", "python-dotenv"], + ("pydantic-ai", "anthropic"): ["pydantic-ai", "anthropic", "python-dotenv"], + ("pydantic-ai", "gemini"): ["pydantic-ai", "google-genai", "python-dotenv"], + ("pydantic-ai", "openrouter"): ["pydantic-ai", "openai", "python-dotenv"], + ("pydantic-ai", "ollama"): ["pydantic-ai", "openai", "python-dotenv"], + ("openai-sdk", "openai"): ["openai", "python-dotenv"], + ("openai-sdk", "openrouter"): ["openai", "python-dotenv"], + ("openai-sdk", "gemini"): ["openai", "python-dotenv"], + ("litellm", "openai"): ["litellm", "python-dotenv"], + ("litellm", "anthropic"): ["litellm", "python-dotenv"], + ("litellm", "gemini"): ["litellm", "python-dotenv"], + ("litellm", "openrouter"): ["litellm", "python-dotenv"], + ("litellm", "ollama"): ["litellm", "python-dotenv"], + } + + base = list(dep_map.get((self.framework, self.provider), ["pydantic-ai", "openai", "python-dotenv"])) + if "pytest" in self.extras: - base += ["pytest"] + base.append("pytest") if "ruff" in self.extras: - base += ["ruff"] + base.append("ruff") + if "rich" in self.extras: + base.append("rich") + return base def post_install(self, project_path: Path) -> None: pyproject = project_path / "pyproject.toml" - current = pyproject.read_text(encoding="utf-8") + current = pyproject.read_text(encoding="utf-8") additions = "" if "pytest" in self.extras: - additions += ( - "\n[tool.pytest.ini_options]\n" - 'testpaths = ["tests"]\n' - ) + additions += "\n[tool.pytest.ini_options]\ntestpaths = [\"tests\"]\n" if "ruff" in self.extras: additions += "\n[tool.ruff]\nline-length = 88\n" @@ -123,11 +192,9 @@ def post_install(self, project_path: Path) -> None: if "github-actions" in self.extras: workflows_path = project_path / ".github" / "workflows" workflows_path.mkdir(parents=True, exist_ok=True) - ci_content = GITHUB_ACTIONS_CI_BASE + ci = GITHUB_ACTIONS_CI_BASE if "ruff" in self.extras: - ci_content += GITHUB_ACTIONS_CI_RUFF_STEP + ci += GITHUB_ACTIONS_CI_RUFF_STEP if "pytest" in self.extras: - ci_content += GITHUB_ACTIONS_CI_PYTEST_STEP - (workflows_path / "ci.yml").write_text( - ci_content, encoding="utf-8" - ) + ci += GITHUB_ACTIONS_CI_PYTEST_STEP + (workflows_path / "ci.yml").write_text(ci, encoding="utf-8") From 25349bff591217cecc8df0d1924c953875d8cd5d Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Sun, 28 Jun 2026 12:40:57 +0530 Subject: [PATCH 13/35] Rewrite test_chatbot_template.py --- tests/test_chatbot_template.py | 304 +++++++++++++++++++++------------ 1 file changed, 199 insertions(+), 105 deletions(-) diff --git a/tests/test_chatbot_template.py b/tests/test_chatbot_template.py index 4a976e5..578ea87 100644 --- a/tests/test_chatbot_template.py +++ b/tests/test_chatbot_template.py @@ -1,155 +1,249 @@ +import pytest + from spawn.templates.chatbot import ChatbotTemplate +# ─── Construction ───────────────────────────────────────────────────────── + -def test_chatbot_template_default(): +def test_default_framework_and_provider(): t = ChatbotTemplate() - assert t.name == "AI Chatbot" assert t.framework == "pydantic-ai" - assert t.extras == [] + assert t.provider == "openai" + assert t.extras == [] + + +def test_explicit_framework_and_provider(): + t = ChatbotTemplate(framework="litellm", provider="anthropic") + assert t.framework == "litellm" + assert t.provider == "anthropic" -def test_chatbot_template_openai_sdk(): - t = ChatbotTemplate(framework="openai-sdk") +def test_openai_sdk_openrouter(): + t = ChatbotTemplate(framework="openai-sdk", provider="openrouter") assert t.framework == "openai-sdk" + assert t.provider == "openrouter" -def test_chatbot_template_folders(): +# ─── Folders ────────────────────────────────────────────────────────────── + + +def test_folders_include_all_required(): t = ChatbotTemplate() - assert "src/chatbot" in t.folders + assert "src/chatbot" in t.folders assert "src/providers" in t.folders - assert "src/prompts" in t.folders - assert "src/utils" in t.folders - assert "tests" in t.folders + assert "src/prompts" in t.folders + assert "src/memory" in t.folders + assert "src/config" in t.folders + assert "src/utils" in t.folders + assert "tests" in t.folders + +# ─── Starter files ──────────────────────────────────────────────────────── -def test_chatbot_template_starter_files(): + +def test_starter_files_include_all_required(): t = ChatbotTemplate() - paths = [path for path, _ in t.starter_files] - assert "src/main.py" in paths - assert "src/chatbot/chat.py" in paths - assert "src/providers/llm.py" in paths - assert "src/prompts/system_prompt.py" in paths - assert "src/utils/env.py" in paths - assert "tests/test_chatbot.py" in paths - assert ".env.example" in paths - - -def test_chatbot_pydantic_ai_dependencies(): - t = ChatbotTemplate(framework="pydantic-ai") - deps = t.get_dependencies() - assert "pydantic-ai[openai]" in deps - assert "python-dotenv" in deps - assert "openai" not in deps + paths = [p for p, _ in t.starter_files] + assert "src/main.py" in paths + assert "src/chatbot/chat.py" in paths + assert "src/providers/llm.py" in paths + assert "src/prompts/system.txt" in paths + assert "src/memory/history.py" in paths + assert "src/config/settings.py" in paths + assert "tests/test_chatbot.py" in paths + assert ".env.example" in paths + + +def test_starter_file_paths_are_strings(): + t = ChatbotTemplate() + for path, _ in t.starter_files: + assert isinstance(path, str) -def test_chatbot_openai_sdk_dependencies(): - t = ChatbotTemplate(framework="openai-sdk") - deps = t.get_dependencies() - assert "openai" in deps - assert "python-dotenv" in deps - assert "pydantic-ai[openai]" not in deps +def test_system_prompt_is_txt_not_py(): + t = ChatbotTemplate() + paths = [p for p, _ in t.starter_files] + assert "src/prompts/system.txt" in paths + assert "src/prompts/system_prompt.py" not in paths + + +# ─── LLM content per combination ────────────────────────────────────────── + + +@pytest.mark.parametrize("framework,provider,expected_import", [ + ("pydantic-ai", "openai", "from pydantic_ai import Agent"), + ("pydantic-ai", "anthropic", "from pydantic_ai import Agent"), + ("pydantic-ai", "gemini", "from pydantic_ai import Agent"), + ("pydantic-ai", "openrouter", "from pydantic_ai import Agent"), + ("pydantic-ai", "ollama", "from pydantic_ai import Agent"), + ("openai-sdk", "openai", "from openai import OpenAI"), + ("openai-sdk", "openrouter", "from openai import OpenAI"), + ("openai-sdk", "gemini", "from openai import OpenAI"), + ("litellm", "openai", "import litellm"), + ("litellm", "anthropic", "import litellm"), + ("litellm", "gemini", "import litellm"), + ("litellm", "openrouter", "import litellm"), + ("litellm", "ollama", "import litellm"), +]) +def test_llm_content_import(framework, provider, expected_import): + t = ChatbotTemplate(framework=framework, provider=provider) + files = dict(t.starter_files) + llm = files["src/providers/llm.py"] + assert expected_import in llm + + +def test_pydantic_ai_llm_uses_result_output(): + t = ChatbotTemplate(framework="pydantic-ai", provider="openai") + files = dict(t.starter_files) + llm = files["src/providers/llm.py"] + assert "result.output" in llm + assert "result.data" not in llm + + +def test_pydantic_ai_no_setdefault_openai_key(): + t = ChatbotTemplate(framework="pydantic-ai", provider="openai") + files = dict(t.starter_files) + llm = files["src/providers/llm.py"] + assert "setdefault" not in llm + + +# ─── Env example per provider ───────────────────────────────────────────── + + +@pytest.mark.parametrize("framework,provider,expected_key", [ + ("pydantic-ai", "openai", "OPENAI_API_KEY"), + ("pydantic-ai", "anthropic", "ANTHROPIC_API_KEY"), + ("pydantic-ai", "gemini", "GOOGLE_API_KEY"), + ("pydantic-ai", "openrouter", "OPENROUTER_API_KEY"), + ("pydantic-ai", "ollama", "OLLAMA_BASE_URL"), + ("openai-sdk", "openai", "OPENAI_API_KEY"), + ("openai-sdk", "openrouter", "OPENROUTER_API_KEY"), + ("openai-sdk", "gemini", "GOOGLE_API_KEY"), + ("litellm", "openai", "OPENAI_API_KEY"), + ("litellm", "anthropic", "ANTHROPIC_API_KEY"), + ("litellm", "gemini", "GOOGLE_API_KEY"), + ("litellm", "openrouter", "OPENROUTER_API_KEY"), + ("litellm", "ollama", "OLLAMA_BASE_URL"), +]) +def test_env_example_correct_key(framework, provider, expected_key): + t = ChatbotTemplate(framework=framework, provider=provider) + files = dict(t.starter_files) + env = files[".env.example"].format_map({"project_name": "test"}) + assert expected_key in env + + +def test_pydantic_ai_env_has_prefixed_model(): + t = ChatbotTemplate(framework="pydantic-ai", provider="openai") + files = dict(t.starter_files) + env = files[".env.example"].format_map({"project_name": "test"}) + assert "MODEL=openai:" in env + + +def test_openai_sdk_env_has_plain_model(): + t = ChatbotTemplate(framework="openai-sdk", provider="openai") + files = dict(t.starter_files) + env = files[".env.example"].format_map({"project_name": "test"}) + assert "MODEL=gpt-4o-mini" in env + assert "MODEL=openai:" not in env -def test_chatbot_extras_add_deps(): - t = ChatbotTemplate(extras=["pytest", "ruff"]) +# ─── Dependencies ───────────────────────────────────────────────────────── + + +@pytest.mark.parametrize("framework,provider,expected_dep", [ + ("pydantic-ai", "openai", "pydantic-ai"), + ("pydantic-ai", "anthropic", "anthropic"), + ("pydantic-ai", "gemini", "google-genai"), + ("openai-sdk", "openai", "openai"), + ("litellm", "openai", "litellm"), + ("litellm", "anthropic", "litellm"), +]) +def test_dependencies_include_correct_package(framework, provider, expected_dep): + t = ChatbotTemplate(framework=framework, provider=provider) + assert expected_dep in t.get_dependencies() + + +def test_all_variants_include_python_dotenv(): + combos = [ + ("pydantic-ai", "openai"), ("pydantic-ai", "anthropic"), + ("openai-sdk", "openai"), ("litellm", "openai"), + ] + for fw, pv in combos: + assert "python-dotenv" in ChatbotTemplate(framework=fw, provider=pv).get_dependencies() + + +def test_extras_add_deps(): + t = ChatbotTemplate(extras=["pytest", "ruff", "rich"]) deps = t.get_dependencies() assert "pytest" in deps - assert "ruff" in deps + assert "ruff" in deps + assert "rich" in deps -def test_chatbot_no_extras_excludes_optional_deps(): +def test_no_extras_excludes_optional(): t = ChatbotTemplate() deps = t.get_dependencies() assert "pytest" not in deps - assert "ruff" not in deps - + assert "ruff" not in deps + assert "rich" not in deps -def test_chatbot_pydantic_ai_readme_contains_project_name(): - t = ChatbotTemplate(framework="pydantic-ai") - readme = t.get_readme_content({"project_name": "my-bot"}) - assert "my-bot" in readme - assert "PydanticAI" in readme +# ─── Rich extra ─────────────────────────────────────────────────────────── -def test_chatbot_openai_sdk_readme_contains_project_name(): - t = ChatbotTemplate(framework="openai-sdk") - readme = t.get_readme_content({"project_name": "my-bot"}) - assert "my-bot" in readme - assert "OpenAI" in readme - -def test_chatbot_pydantic_ai_llm_file_differs_from_openai(): - t1 = ChatbotTemplate(framework="pydantic-ai") - t2 = ChatbotTemplate(framework="openai-sdk") - files1 = dict(t1.starter_files) - files2 = dict(t2.starter_files) - assert files1["src/providers/llm.py"] != files2["src/providers/llm.py"] +def test_rich_extra_uses_rich_main(): + t = ChatbotTemplate(extras=["rich"]) + files = dict(t.starter_files) + main = files["src/main.py"] + assert "from rich" in main + assert "Console" in main -def test_chatbot_starter_file_paths_are_strings(): +def test_no_rich_uses_plain_main(): t = ChatbotTemplate() - for path, _ in t.starter_files: - assert isinstance(path, str) + files = dict(t.starter_files) + main = files["src/main.py"] + assert "from rich" not in main + assert 'print(f"Bot:' in main -def test_chatbot_next_steps_contain_run_command(): - t = ChatbotTemplate() - assert any("src.main" in step for step in t.next_steps) +# ─── Memory ─────────────────────────────────────────────────────────────── -def test_chatbot_next_steps_contain_env_instruction(): +def test_memory_history_has_required_functions(): t = ChatbotTemplate() - assert any("API_KEY" in step for step in t.next_steps) + files = dict(t.starter_files) + mem = files["src/memory/history.py"] + assert "append_user" in mem + assert "append_assistant" in mem + assert "get_history" in mem + assert "clear" in mem -def test_pydantic_ai_env_example_has_provider_prefixed_model(): - """pydantic-ai requires 'openai:gpt-4o-mini' not 'gpt-4o-mini'.""" - t = ChatbotTemplate(framework="pydantic-ai") - env_file = dict(t.starter_files)[".env.example"] - result = env_file.format_map({"project_name": "test"}) - assert "MODEL=openai:gpt-4o-mini" in result - assert "MODEL=gpt-4o-mini\n" not in result +# ─── README ─────────────────────────────────────────────────────────────── -def test_pydantic_ai_env_example_has_no_base_url(): - """pydantic-ai does not use BASE_URL - should not appear in its .env.""" - t = ChatbotTemplate(framework="pydantic-ai") - env_file = dict(t.starter_files)[".env.example"] - result = env_file.format_map({"project_name": "test"}) - assert "BASE_URL" not in result +def test_readme_contains_project_name(): + t = ChatbotTemplate() + readme = t.get_readme_content({"project_name": "my-bot"}) + assert "my-bot" in readme -def test_openai_sdk_env_example_has_plain_model(): - """OpenAI SDK uses plain model name, no provider prefix.""" - t = ChatbotTemplate(framework="openai-sdk") - env_file = dict(t.starter_files)[".env.example"] - result = env_file.format_map({"project_name": "test"}) - assert "MODEL=gpt-4o-mini" in result - assert "MODEL=openai:" not in result +def test_readme_mentions_framework_and_provider(): + t = ChatbotTemplate(framework="litellm", provider="anthropic") + readme = t.get_readme_content({"project_name": "test"}) + assert "Litellm" in readme or "litellm" in readme.lower() + assert "Anthropic" in readme or "anthropic" in readme.lower() -def test_openai_sdk_env_example_has_base_url(): - """OpenAI SDK supports BASE_URL for provider switching.""" - t = ChatbotTemplate(framework="openai-sdk") - env_file = dict(t.starter_files)[".env.example"] - result = env_file.format_map({"project_name": "test"}) - assert "BASE_URL" in result +# ─── Next steps ─────────────────────────────────────────────────────────── -def test_pydantic_ai_llm_content_uses_result_output(): - """Generated llm.py must access result.output, not result.data.""" - t = ChatbotTemplate(framework="pydantic-ai") - files = dict(t.starter_files) - llm_content = files["src/providers/llm.py"] - assert "result.output" in llm_content - assert "result.data" not in llm_content +def test_next_steps_contain_run_command(): + t = ChatbotTemplate() + assert any("src.main" in step for step in t.next_steps) -def test_pydantic_ai_llm_content_passes_api_key_to_run_sync(): - """Generated llm.py must pass api_key to run_sync, not set env vars.""" - t = ChatbotTemplate(framework="pydantic-ai") - files = dict(t.starter_files) - llm_content = files["src/providers/llm.py"] - assert "run_sync(prompt, api_key=api_key)" in llm_content - assert "OPENAI_API_KEY" not in llm_content - assert "setdefault" not in llm_content +def test_next_steps_mention_env(): + t = ChatbotTemplate() + assert any(".env" in step for step in t.next_steps) From 217d74da95483d4709dec297efd2e9951fed9537 Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Sun, 28 Jun 2026 12:42:17 +0530 Subject: [PATCH 14/35] Update test_prompts.py for provider prompt --- tests/test_prompts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_prompts.py b/tests/test_prompts.py index 292acf7..13debeb 100644 --- a/tests/test_prompts.py +++ b/tests/test_prompts.py @@ -142,7 +142,7 @@ def test_all_template_choices(mock_confirm, choice, expected_template): elif expected_template == "automation": side_effects = ["project", choice, ""] # name, choice, extras (skip) elif expected_template == "chatbot": - side_effects = ["project", choice, "1", ""] # name, choice, framework, extras + side_effects = ["project", choice, "1", "1", ""] # name, choice, framework, provider, extras else: side_effects = ["project", choice, "1", "1", ""] # name, choice, framework, cli_type, extras with patch( From 510482df7d67013ad9be7bfbdfbd00e81093123c Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Sun, 28 Jun 2026 12:43:28 +0530 Subject: [PATCH 15/35] Update changelog --- docs/changelog.md | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index aee80a8..b390e66 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -7,21 +7,35 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### New Features -- **AI Chatbot intent** — generates a provider-agnostic conversational AI project - with PydanticAI or OpenAI SDK; run `uv run python src/main.py` immediately after - adding an API key -- **Provider abstraction layer** — `src/providers/llm.py` isolates the AI provider; - switching from OpenAI to Groq/Anthropic/OpenRouter requires only env var changes -- **Centralized prompt management** — `src/prompts/system_prompt.py` keeps system - prompts out of application logic -- **Session-level conversation history** — message history is maintained across - turns within a single run without external storage +- **AI Chatbot intent** — generates a fully runnable conversational AI project + with runtime memory, centralized prompt management, and provider abstraction +- **3 frameworks**: PydanticAI, OpenAI SDK, LiteLLM +- **5 providers**: OpenAI, Anthropic, Gemini, OpenRouter, Ollama +- **13 supported combinations** — each generates correct dependencies, + provider-specific env vars, and working llm.py out of the box +- **Runtime memory** — `src/memory/history.py` maintains conversation + context across turns within a session; no database required +- **Plain-text prompt system** — `src/prompts/system.txt` is editable + without touching Python code; loaded dynamically at runtime +- **Rich extra** — opt-in `rich` terminal UI with colored panels and + styled input/output +- **Provider-specific env vars** — generated `.env.example` uses the + correct key name for each provider (OPENAI_API_KEY, ANTHROPIC_API_KEY, + GOOGLE_API_KEY, OPENROUTER_API_KEY, OLLAMA_BASE_URL) + +### Bug Fixes + +- Fixed `result.data` → `result.output` in PydanticAI provider + (AgentRunResult attribute name in current pydantic-ai) +- Fixed `setdefault("OPENAI_API_KEY")` pattern that silently broke + non-OpenAI providers; api_key is now passed directly to run_sync() ### Internal -- Registry expanded to four active templates: backend-api, cli, automation, chatbot -- ChatbotTemplate branches on framework to select provider implementation; - chatbot/, prompts/, utils/, and main.py are shared between both variants +- `ProjectConfig` gains `provider: str | None = None` field +- `TemplateMetadata` gains `available_providers: list[str]` field +- `instantiate_template()` forwards `provider` to template constructors +- Registry: chatbot updated to 3 frameworks, 5 providers, 4 extras - Version bumped to 0.6.0 ## v0.5.0 — 2026 From f6b9055e6a2f7ba42b0887838243c9a36b92c11a Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Sun, 28 Jun 2026 12:47:21 +0530 Subject: [PATCH 16/35] Tests: updates the test --- tests/test_chatbot_generator.py | 7 +++---- tests/test_generator.py | 3 ++- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_chatbot_generator.py b/tests/test_chatbot_generator.py index b077b6d..2115e93 100644 --- a/tests/test_chatbot_generator.py +++ b/tests/test_chatbot_generator.py @@ -108,7 +108,7 @@ def test_chatbot_creates_system_prompt(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) with _mock_uv_and_install(): ProjectGenerator().generate(_cfg()) - assert (tmp_path / "my-bot" / "src" / "prompts" / "system_prompt.py").exists() + assert (tmp_path / "my-bot" / "src" / "prompts" / "system.txt").exists() def test_chatbot_creates_env_util(tmp_path, monkeypatch): @@ -154,7 +154,6 @@ def test_chatbot_openai_sdk_env_has_plain_model(tmp_path, monkeypatch): ProjectGenerator().generate(_cfg(framework="openai-sdk")) env = (tmp_path / "my-bot" / ".env.example").read_text(encoding="utf-8") assert "MODEL=gpt-4o-mini" in env - assert "BASE_URL" in env def test_chatbot_readme_contains_project_name(tmp_path, monkeypatch): @@ -226,9 +225,9 @@ def test_chatbot_pydantic_ai_install_packages_called(tmp_path, monkeypatch): patch.object(ChatbotTemplate, "post_install"): ProjectGenerator().generate(_cfg(framework="pydantic-ai")) args = mock_install.call_args[0][1] - assert "pydantic-ai[openai]" in args + assert "pydantic-ai" in args + assert "openai" in args assert "python-dotenv" in args - assert "openai" not in args def test_chatbot_openai_sdk_install_packages_called(tmp_path, monkeypatch): diff --git a/tests/test_generator.py b/tests/test_generator.py index f44d7f2..c87d107 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -598,7 +598,8 @@ def test_chatbot_pydantic_ai_extras_in_dependencies(): framework="pydantic-ai", extras=["ruff", "pytest"], ) deps = instantiate_template(config).get_dependencies() - assert "pydantic-ai[openai]" in deps + assert "pydantic-ai" in deps + assert "openai" in deps assert "python-dotenv" in deps assert "ruff" in deps assert "pytest" in deps From c2ef9a2361acf662a4949e63df362e2172b7cb18 Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Sun, 28 Jun 2026 13:51:18 +0530 Subject: [PATCH 17/35] Fix pydantic-ai LLM Content Strings --- src/spawn/templates/chatbot/content.py | 29 ++++++++++++-------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/src/spawn/templates/chatbot/content.py b/src/spawn/templates/chatbot/content.py index 363ef58..5e0048e 100644 --- a/src/spawn/templates/chatbot/content.py +++ b/src/spawn/templates/chatbot/content.py @@ -78,11 +78,10 @@ def get_system_prompt() -> str: def get_llm_response(messages: list[dict], system_prompt: str) -> str: model = os.getenv("MODEL", "openai:gpt-4o-mini") - api_key = os.getenv("OPENAI_API_KEY", "") history = [m["content"] for m in messages if m["role"] == "user"] prompt = history[-1] if history else "" agent = Agent(model, system_prompt=system_prompt) - result = agent.run_sync(prompt, api_key=api_key) + result = agent.run_sync(prompt) return result.output """ @@ -94,11 +93,10 @@ def get_llm_response(messages: list[dict], system_prompt: str) -> str: def get_llm_response(messages: list[dict], system_prompt: str) -> str: model = os.getenv("MODEL", "anthropic:claude-3-5-haiku-latest") - api_key = os.getenv("ANTHROPIC_API_KEY", "") history = [m["content"] for m in messages if m["role"] == "user"] prompt = history[-1] if history else "" agent = Agent(model, system_prompt=system_prompt) - result = agent.run_sync(prompt, api_key=api_key) + result = agent.run_sync(prompt) return result.output """ @@ -109,20 +107,19 @@ def get_llm_response(messages: list[dict], system_prompt: str) -> str: def get_llm_response(messages: list[dict], system_prompt: str) -> str: - model = os.getenv("MODEL", "google-gla:gemini-1.5-flash") - api_key = os.getenv("GOOGLE_API_KEY", "") + model = os.getenv("MODEL", "google:gemini-2.0-flash") history = [m["content"] for m in messages if m["role"] == "user"] prompt = history[-1] if history else "" agent = Agent(model, system_prompt=system_prompt) - result = agent.run_sync(prompt, api_key=api_key) + result = agent.run_sync(prompt) return result.output """ PYDANTIC_AI_OPENROUTER_LLM_CONTENT = """\ import os -from openai import AsyncOpenAI from pydantic_ai import Agent +from pydantic_ai.models.openai import OpenAIChatModel from pydantic_ai.providers.openai import OpenAIProvider @@ -131,12 +128,12 @@ def get_llm_response(messages: list[dict], system_prompt: str) -> str: api_key = os.getenv("OPENROUTER_API_KEY", "") history = [m["content"] for m in messages if m["role"] == "user"] prompt = history[-1] if history else "" - openai_client = AsyncOpenAI( + provider = OpenAIProvider( api_key=api_key, base_url="https://openrouter.ai/api/v1", ) - provider = OpenAIProvider(openai_client=openai_client) - agent = Agent(f"openai:{{model_name}}", system_prompt=system_prompt, model_settings={{"provider": provider}}) + model = OpenAIChatModel(model_name, provider=provider) + agent = Agent(model, system_prompt=system_prompt) result = agent.run_sync(prompt) return result.output """ @@ -144,8 +141,8 @@ def get_llm_response(messages: list[dict], system_prompt: str) -> str: PYDANTIC_AI_OLLAMA_LLM_CONTENT = """\ import os -from openai import AsyncOpenAI from pydantic_ai import Agent +from pydantic_ai.models.openai import OpenAIChatModel from pydantic_ai.providers.openai import OpenAIProvider @@ -154,9 +151,9 @@ def get_llm_response(messages: list[dict], system_prompt: str) -> str: base_url = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434/v1") history = [m["content"] for m in messages if m["role"] == "user"] prompt = history[-1] if history else "" - openai_client = AsyncOpenAI(api_key="ollama", base_url=base_url) - provider = OpenAIProvider(openai_client=openai_client) - agent = Agent(f"openai:{{model_name}}", system_prompt=system_prompt, model_settings={{"provider": provider}}) + provider = OpenAIProvider(api_key="ollama", base_url=base_url) + model = OpenAIChatModel(model_name, provider=provider) + agent = Agent(model, system_prompt=system_prompt) result = agent.run_sync(prompt) return result.output """ @@ -378,7 +375,7 @@ def main() -> None: ENV_PYDANTIC_GEMINI = """\ APP_NAME={project_name} GOOGLE_API_KEY= -MODEL=google-gla:gemini-1.5-flash +MODEL=google:gemini-2.0-flash """ ENV_PYDANTIC_OPENROUTER = """\ From e8c561c3319589a451810db144bc1c19ca60d7cd Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Sun, 28 Jun 2026 14:16:03 +0530 Subject: [PATCH 18/35] Content String Tests --- tests/test_chatbot_template.py | 122 +++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/tests/test_chatbot_template.py b/tests/test_chatbot_template.py index 578ea87..6f9b177 100644 --- a/tests/test_chatbot_template.py +++ b/tests/test_chatbot_template.py @@ -247,3 +247,125 @@ def test_next_steps_contain_run_command(): def test_next_steps_mention_env(): t = ChatbotTemplate() assert any(".env" in step for step in t.next_steps) + + +# ─── pydantic-ai API correctness ──────────────────────────────────────── + +import os +import py_compile +import tempfile + +from spawn.templates.chatbot.content import ( + ENV_PYDANTIC_GEMINI, + PYDANTIC_AI_ANTHROPIC_LLM_CONTENT, + PYDANTIC_AI_GEMINI_LLM_CONTENT, + PYDANTIC_AI_OLLAMA_LLM_CONTENT, + PYDANTIC_AI_OPENAI_LLM_CONTENT, + PYDANTIC_AI_OPENROUTER_LLM_CONTENT, +) + + +def test_pydantic_ai_openai_no_api_key_in_run_sync(): + """api_key is not a valid run_sync() kwarg in pydantic-ai 2.x.""" + assert "run_sync(prompt, api_key" not in PYDANTIC_AI_OPENAI_LLM_CONTENT + + +def test_pydantic_ai_anthropic_no_api_key_in_run_sync(): + """api_key is not a valid run_sync() kwarg in pydantic-ai 2.x.""" + assert "run_sync(prompt, api_key" not in PYDANTIC_AI_ANTHROPIC_LLM_CONTENT + + +def test_pydantic_ai_gemini_no_api_key_in_run_sync(): + """api_key is not a valid run_sync() kwarg in pydantic-ai 2.x.""" + assert "run_sync(prompt, api_key" not in PYDANTIC_AI_GEMINI_LLM_CONTENT + + +def test_pydantic_ai_gemini_uses_correct_provider_prefix(): + """pydantic-ai 2.0.0 uses google: not google-gla: for Gemini.""" + assert "google-gla" not in PYDANTIC_AI_GEMINI_LLM_CONTENT + assert "google:" in PYDANTIC_AI_GEMINI_LLM_CONTENT + + +def test_env_pydantic_gemini_uses_correct_model_prefix(): + """ENV_PYDANTIC_GEMINI MODEL must match the provider prefix in llm.py.""" + assert "google-gla" not in ENV_PYDANTIC_GEMINI + assert "MODEL=google:" in ENV_PYDANTIC_GEMINI + + +def test_pydantic_ai_openrouter_uses_openai_chat_model(): + """Correct pydantic-ai 2.0.0 pattern for custom base_url providers.""" + assert "OpenAIChatModel" in PYDANTIC_AI_OPENROUTER_LLM_CONTENT + assert "OpenAIProvider" in PYDANTIC_AI_OPENROUTER_LLM_CONTENT + assert 'model_settings={"provider"' not in PYDANTIC_AI_OPENROUTER_LLM_CONTENT + assert "model_settings" not in PYDANTIC_AI_OPENROUTER_LLM_CONTENT + + +def test_pydantic_ai_ollama_uses_openai_chat_model(): + """Correct pydantic-ai 2.0.0 pattern for local Ollama provider.""" + assert "OpenAIChatModel" in PYDANTIC_AI_OLLAMA_LLM_CONTENT + assert "OpenAIProvider" in PYDANTIC_AI_OLLAMA_LLM_CONTENT + assert "model_settings" not in PYDANTIC_AI_OLLAMA_LLM_CONTENT + + +# ─── Python syntax validity ────────────────────────────────────────────── + + +def _assert_valid_python(content: str, label: str) -> None: + """Write content to a temp file and verify it compiles.""" + with tempfile.NamedTemporaryFile( + mode="w", suffix=".py", delete=False, encoding="utf-8" + ) as f: + f.write(content) + fname = f.name + try: + py_compile.compile(fname, doraise=True) + except py_compile.PyCompileError as e: + raise AssertionError(f"{label} is not valid Python: {e}") from e + finally: + os.unlink(fname) + + +def test_pydantic_ai_openai_llm_is_valid_python(): + _assert_valid_python(PYDANTIC_AI_OPENAI_LLM_CONTENT, "PYDANTIC_AI_OPENAI_LLM_CONTENT") + + +def test_pydantic_ai_anthropic_llm_is_valid_python(): + _assert_valid_python(PYDANTIC_AI_ANTHROPIC_LLM_CONTENT, "PYDANTIC_AI_ANTHROPIC_LLM_CONTENT") + + +def test_pydantic_ai_gemini_llm_is_valid_python(): + _assert_valid_python(PYDANTIC_AI_GEMINI_LLM_CONTENT, "PYDANTIC_AI_GEMINI_LLM_CONTENT") + + +def test_pydantic_ai_openrouter_llm_is_valid_python(): + _assert_valid_python(PYDANTIC_AI_OPENROUTER_LLM_CONTENT, "PYDANTIC_AI_OPENROUTER_LLM_CONTENT") + + +def test_pydantic_ai_ollama_llm_is_valid_python(): + _assert_valid_python(PYDANTIC_AI_OLLAMA_LLM_CONTENT, "PYDANTIC_AI_OLLAMA_LLM_CONTENT") + + +# ─── result.output used (not result.data) ──────────────────────────────── + + +def test_pydantic_ai_openai_uses_result_output(): + assert "result.output" in PYDANTIC_AI_OPENAI_LLM_CONTENT + assert "result.data" not in PYDANTIC_AI_OPENAI_LLM_CONTENT + + +def test_pydantic_ai_anthropic_uses_result_output(): + assert "result.output" in PYDANTIC_AI_ANTHROPIC_LLM_CONTENT + assert "result.data" not in PYDANTIC_AI_ANTHROPIC_LLM_CONTENT + + +def test_pydantic_ai_gemini_uses_result_output(): + assert "result.output" in PYDANTIC_AI_GEMINI_LLM_CONTENT + assert "result.data" not in PYDANTIC_AI_GEMINI_LLM_CONTENT + + +def test_pydantic_ai_openrouter_uses_result_output(): + assert "result.output" in PYDANTIC_AI_OPENROUTER_LLM_CONTENT + + +def test_pydantic_ai_ollama_uses_result_output(): + assert "result.output" in PYDANTIC_AI_OLLAMA_LLM_CONTENT From 85bb2951dd33498817fbb4a183ba8e79736d069a Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Sun, 28 Jun 2026 14:19:50 +0530 Subject: [PATCH 19/35] Content String Tests 1 --- tests/test_chatbot_template.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/tests/test_chatbot_template.py b/tests/test_chatbot_template.py index 6f9b177..c0bf344 100644 --- a/tests/test_chatbot_template.py +++ b/tests/test_chatbot_template.py @@ -1,6 +1,18 @@ +import os +import py_compile +import tempfile + import pytest from spawn.templates.chatbot import ChatbotTemplate +from spawn.templates.chatbot.content import ( + ENV_PYDANTIC_GEMINI, + PYDANTIC_AI_ANTHROPIC_LLM_CONTENT, + PYDANTIC_AI_GEMINI_LLM_CONTENT, + PYDANTIC_AI_OLLAMA_LLM_CONTENT, + PYDANTIC_AI_OPENAI_LLM_CONTENT, + PYDANTIC_AI_OPENROUTER_LLM_CONTENT, +) # ─── Construction ───────────────────────────────────────────────────────── @@ -251,19 +263,6 @@ def test_next_steps_mention_env(): # ─── pydantic-ai API correctness ──────────────────────────────────────── -import os -import py_compile -import tempfile - -from spawn.templates.chatbot.content import ( - ENV_PYDANTIC_GEMINI, - PYDANTIC_AI_ANTHROPIC_LLM_CONTENT, - PYDANTIC_AI_GEMINI_LLM_CONTENT, - PYDANTIC_AI_OLLAMA_LLM_CONTENT, - PYDANTIC_AI_OPENAI_LLM_CONTENT, - PYDANTIC_AI_OPENROUTER_LLM_CONTENT, -) - def test_pydantic_ai_openai_no_api_key_in_run_sync(): """api_key is not a valid run_sync() kwarg in pydantic-ai 2.x.""" From 5d6e99419d03a2e72b7c1d719df011548ff6e84c Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Sat, 4 Jul 2026 17:37:40 +0530 Subject: [PATCH 20/35] Chatbot v3 --- src/spawn/templates/chatbot/__init__.py | 14 +++++++++++++- src/spawn/templates/chatbot/content.py | 17 +++++++++++++---- tests/test_chatbot_template.py | 2 +- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/spawn/templates/chatbot/__init__.py b/src/spawn/templates/chatbot/__init__.py index 01115b5..f20c2bc 100644 --- a/src/spawn/templates/chatbot/__init__.py +++ b/src/spawn/templates/chatbot/__init__.py @@ -32,6 +32,7 @@ ENV_GEMINI, ENV_OPENROUTER, ENV_OLLAMA, + ENV_LITELLM_OLLAMA, ENV_PYDANTIC_OPENAI, ENV_PYDANTIC_ANTHROPIC, ENV_PYDANTIC_GEMINI, @@ -84,6 +85,15 @@ "ollama": ENV_OLLAMA, } +# litellm+ollama uses OLLAMA_API_BASE (litellm convention) instead of OLLAMA_BASE_URL +_ENV_MAP_LITELLM: dict[str, str] = { + "openai": ENV_OPENAI, + "anthropic": ENV_ANTHROPIC, + "gemini": ENV_GEMINI, + "openrouter": ENV_OPENROUTER, + "ollama": ENV_LITELLM_OLLAMA, +} + def _resolve_llm(framework: str, provider: str) -> str: return _LLM_MAP.get((framework, provider), PYDANTIC_AI_OPENAI_LLM_CONTENT) @@ -92,6 +102,8 @@ def _resolve_llm(framework: str, provider: str) -> str: def _resolve_env(framework: str, provider: str) -> str: if framework == "pydantic-ai": return _ENV_MAP_PYDANTIC.get(provider, ENV_PYDANTIC_OPENAI) + if framework == "litellm": + return _ENV_MAP_LITELLM.get(provider, ENV_OPENAI) return _ENV_MAP_GENERIC.get(provider, ENV_OPENAI) @@ -197,4 +209,4 @@ def post_install(self, project_path: Path) -> None: ci += GITHUB_ACTIONS_CI_RUFF_STEP if "pytest" in self.extras: ci += GITHUB_ACTIONS_CI_PYTEST_STEP - (workflows_path / "ci.yml").write_text(ci, encoding="utf-8") + (workflows_path / "ci.yml").write_text(ci, encoding="utf-8") \ No newline at end of file diff --git a/src/spawn/templates/chatbot/content.py b/src/spawn/templates/chatbot/content.py index 5e0048e..5915272 100644 --- a/src/spawn/templates/chatbot/content.py +++ b/src/spawn/templates/chatbot/content.py @@ -270,8 +270,9 @@ def get_llm_response(messages: list[dict], system_prompt: str) -> str: def get_llm_response(messages: list[dict], system_prompt: str) -> str: model = os.getenv("MODEL", "ollama/llama3.2") + base_url = os.getenv("OLLAMA_API_BASE", "http://localhost:11434") full_messages = [{{"role": "system", "content": system_prompt}}] + messages - response = litellm.completion(model=model, messages=full_messages) + response = litellm.completion(model=model, messages=full_messages, api_base=base_url) return response.choices[0].message.content or "" """ @@ -358,6 +359,13 @@ def main() -> None: MODEL=llama3.2 """ +# litellm+ollama uses a different env var for the base URL +ENV_LITELLM_OLLAMA = """\ +APP_NAME={project_name} +OLLAMA_API_BASE=http://localhost:11434 +MODEL=ollama/llama3.2 +""" + # Provider-prefixed MODEL for pydantic-ai variants ENV_PYDANTIC_OPENAI = """\ @@ -392,11 +400,12 @@ def main() -> None: # ─── Test content ───────────────────────────────────────────────────────── +# BUG FIX: removed unused imports MagicMock and get_llm_response +# which caused ruff F401 failures in generated projects with ruff extra selected TEST_CONTENT = """\ -from unittest.mock import MagicMock, patch +from unittest.mock import patch from src.chatbot.chat import get_response -from src.providers.llm import get_llm_response def test_get_response_returns_string(): @@ -507,4 +516,4 @@ def make_readme(framework: str, provider: str) -> str: GITHUB_ACTIONS_CI_PYTEST_STEP = """\ - name: Test run: uv run pytest -""" +""" \ No newline at end of file diff --git a/tests/test_chatbot_template.py b/tests/test_chatbot_template.py index c0bf344..cc50e80 100644 --- a/tests/test_chatbot_template.py +++ b/tests/test_chatbot_template.py @@ -135,7 +135,7 @@ def test_pydantic_ai_no_setdefault_openai_key(): ("litellm", "anthropic", "ANTHROPIC_API_KEY"), ("litellm", "gemini", "GOOGLE_API_KEY"), ("litellm", "openrouter", "OPENROUTER_API_KEY"), - ("litellm", "ollama", "OLLAMA_BASE_URL"), + ("litellm", "ollama", "OLLAMA_API_BASE"), ]) def test_env_example_correct_key(framework, provider, expected_key): t = ChatbotTemplate(framework=framework, provider=provider) From 7a959b6f43c51457b9f9323f88f14fc273dd29f5 Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Sat, 4 Jul 2026 19:46:52 +0530 Subject: [PATCH 21/35] PydanticAI Multi-Turn Memory --- src/spawn/templates/chatbot/content.py | 62 +++++++++++++++++++------- 1 file changed, 47 insertions(+), 15 deletions(-) diff --git a/src/spawn/templates/chatbot/content.py b/src/spawn/templates/chatbot/content.py index 5915272..428bb59 100644 --- a/src/spawn/templates/chatbot/content.py +++ b/src/spawn/templates/chatbot/content.py @@ -18,7 +18,15 @@ def get_response(user_input: str) -> str: """ MEMORY_HISTORY_CONTENT = """\ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pydantic_ai.messages import ModelMessage + _history: list[dict] = [] +_pai_history: list[ModelMessage] = [] def append_user(content: str) -> None: @@ -33,8 +41,17 @@ def get_history() -> list[dict]: return list(_history) +def get_pai_history() -> list[ModelMessage]: + return list(_pai_history) + + +def append_pai_messages(messages: list[ModelMessage]) -> None: + _pai_history.extend(messages) + + def clear() -> None: _history.clear() + _pai_history.clear() """ SYSTEM_PROMPT_TXT_CONTENT = """\ @@ -75,13 +92,16 @@ def get_system_prompt() -> str: from pydantic_ai import Agent +from src.memory.history import get_pai_history, append_pai_messages + def get_llm_response(messages: list[dict], system_prompt: str) -> str: model = os.getenv("MODEL", "openai:gpt-4o-mini") - history = [m["content"] for m in messages if m["role"] == "user"] - prompt = history[-1] if history else "" + user_messages = [m["content"] for m in messages if m["role"] == "user"] + prompt = user_messages[-1] if user_messages else "" agent = Agent(model, system_prompt=system_prompt) - result = agent.run_sync(prompt) + result = agent.run_sync(prompt, message_history=get_pai_history()) + append_pai_messages(result.new_messages()) return result.output """ @@ -90,13 +110,16 @@ def get_llm_response(messages: list[dict], system_prompt: str) -> str: from pydantic_ai import Agent +from src.memory.history import get_pai_history, append_pai_messages + def get_llm_response(messages: list[dict], system_prompt: str) -> str: model = os.getenv("MODEL", "anthropic:claude-3-5-haiku-latest") - history = [m["content"] for m in messages if m["role"] == "user"] - prompt = history[-1] if history else "" + user_messages = [m["content"] for m in messages if m["role"] == "user"] + prompt = user_messages[-1] if user_messages else "" agent = Agent(model, system_prompt=system_prompt) - result = agent.run_sync(prompt) + result = agent.run_sync(prompt, message_history=get_pai_history()) + append_pai_messages(result.new_messages()) return result.output """ @@ -105,13 +128,16 @@ def get_llm_response(messages: list[dict], system_prompt: str) -> str: from pydantic_ai import Agent +from src.memory.history import get_pai_history, append_pai_messages + def get_llm_response(messages: list[dict], system_prompt: str) -> str: model = os.getenv("MODEL", "google:gemini-2.0-flash") - history = [m["content"] for m in messages if m["role"] == "user"] - prompt = history[-1] if history else "" + user_messages = [m["content"] for m in messages if m["role"] == "user"] + prompt = user_messages[-1] if user_messages else "" agent = Agent(model, system_prompt=system_prompt) - result = agent.run_sync(prompt) + result = agent.run_sync(prompt, message_history=get_pai_history()) + append_pai_messages(result.new_messages()) return result.output """ @@ -122,19 +148,22 @@ def get_llm_response(messages: list[dict], system_prompt: str) -> str: from pydantic_ai.models.openai import OpenAIChatModel from pydantic_ai.providers.openai import OpenAIProvider +from src.memory.history import get_pai_history, append_pai_messages + def get_llm_response(messages: list[dict], system_prompt: str) -> str: model_name = os.getenv("MODEL", "openai/gpt-4o-mini") api_key = os.getenv("OPENROUTER_API_KEY", "") - history = [m["content"] for m in messages if m["role"] == "user"] - prompt = history[-1] if history else "" + user_messages = [m["content"] for m in messages if m["role"] == "user"] + prompt = user_messages[-1] if user_messages else "" provider = OpenAIProvider( api_key=api_key, base_url="https://openrouter.ai/api/v1", ) model = OpenAIChatModel(model_name, provider=provider) agent = Agent(model, system_prompt=system_prompt) - result = agent.run_sync(prompt) + result = agent.run_sync(prompt, message_history=get_pai_history()) + append_pai_messages(result.new_messages()) return result.output """ @@ -145,16 +174,19 @@ def get_llm_response(messages: list[dict], system_prompt: str) -> str: from pydantic_ai.models.openai import OpenAIChatModel from pydantic_ai.providers.openai import OpenAIProvider +from src.memory.history import get_pai_history, append_pai_messages + def get_llm_response(messages: list[dict], system_prompt: str) -> str: model_name = os.getenv("MODEL", "llama3.2") base_url = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434/v1") - history = [m["content"] for m in messages if m["role"] == "user"] - prompt = history[-1] if history else "" + user_messages = [m["content"] for m in messages if m["role"] == "user"] + prompt = user_messages[-1] if user_messages else "" provider = OpenAIProvider(api_key="ollama", base_url=base_url) model = OpenAIChatModel(model_name, provider=provider) agent = Agent(model, system_prompt=system_prompt) - result = agent.run_sync(prompt) + result = agent.run_sync(prompt, message_history=get_pai_history()) + append_pai_messages(result.new_messages()) return result.output """ From 5af8e71f339c4dbd2b806d97dddb1c277ef15898 Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Sat, 4 Jul 2026 19:52:14 +0530 Subject: [PATCH 22/35] Dependencies + Remove Utils --- src/spawn/templates/chatbot/__init__.py | 14 +++++--------- src/spawn/templates/chatbot/content.py | 6 ------ tests/test_chatbot_generator.py | 15 --------------- tests/test_chatbot_template.py | 3 --- tests/test_generator.py | 2 -- 5 files changed, 5 insertions(+), 35 deletions(-) diff --git a/src/spawn/templates/chatbot/__init__.py b/src/spawn/templates/chatbot/__init__.py index f20c2bc..f088f53 100644 --- a/src/spawn/templates/chatbot/__init__.py +++ b/src/spawn/templates/chatbot/__init__.py @@ -7,7 +7,6 @@ MEMORY_HISTORY_CONTENT, SYSTEM_PROMPT_TXT_CONTENT, SETTINGS_CONTENT, - ENV_UTIL_CONTENT, MAIN_CONTENT_NO_RICH, MAIN_CONTENT_RICH, TEST_CONTENT, @@ -47,7 +46,6 @@ "src/prompts", "src/memory", "src/config", - "src/utils", "tests", ] @@ -120,8 +118,6 @@ def _build_files(main_content: str, llm_content: str, env_content: str) -> list: ("src/memory/history.py", MEMORY_HISTORY_CONTENT), ("src/config/__init__.py", INIT_CONTENT), ("src/config/settings.py", SETTINGS_CONTENT), - ("src/utils/__init__.py", INIT_CONTENT), - ("src/utils/env.py", ENV_UTIL_CONTENT), ("src/main.py", main_content), ("tests/__init__.py", INIT_CONTENT), ("tests/test_chatbot.py", TEST_CONTENT), @@ -161,11 +157,11 @@ def get_readme_content(self, context: dict) -> str | None: def get_dependencies(self) -> list[str]: dep_map: dict[tuple[str, str], list[str]] = { - ("pydantic-ai", "openai"): ["pydantic-ai", "openai", "python-dotenv"], - ("pydantic-ai", "anthropic"): ["pydantic-ai", "anthropic", "python-dotenv"], - ("pydantic-ai", "gemini"): ["pydantic-ai", "google-genai", "python-dotenv"], - ("pydantic-ai", "openrouter"): ["pydantic-ai", "openai", "python-dotenv"], - ("pydantic-ai", "ollama"): ["pydantic-ai", "openai", "python-dotenv"], + ("pydantic-ai", "openai"): ["pydantic-ai", "python-dotenv"], + ("pydantic-ai", "anthropic"): ["pydantic-ai", "python-dotenv"], + ("pydantic-ai", "gemini"): ["pydantic-ai", "python-dotenv"], + ("pydantic-ai", "openrouter"): ["pydantic-ai", "python-dotenv"], + ("pydantic-ai", "ollama"): ["pydantic-ai", "python-dotenv"], ("openai-sdk", "openai"): ["openai", "python-dotenv"], ("openai-sdk", "openrouter"): ["openai", "python-dotenv"], ("openai-sdk", "gemini"): ["openai", "python-dotenv"], diff --git a/src/spawn/templates/chatbot/content.py b/src/spawn/templates/chatbot/content.py index 428bb59..665d054 100644 --- a/src/spawn/templates/chatbot/content.py +++ b/src/spawn/templates/chatbot/content.py @@ -79,12 +79,6 @@ def get_system_prompt() -> str: return prompt_path.read_text(encoding="utf-8").strip() """ -ENV_UTIL_CONTENT = """\ -from src.config.settings import load_env - -__all__ = ["load_env"] -""" - # ─── PydanticAI ─────────────────────────────────────────────────────────── PYDANTIC_AI_OPENAI_LLM_CONTENT = """\ diff --git a/tests/test_chatbot_generator.py b/tests/test_chatbot_generator.py index 2115e93..1ae2837 100644 --- a/tests/test_chatbot_generator.py +++ b/tests/test_chatbot_generator.py @@ -64,13 +64,6 @@ def test_chatbot_creates_prompts_dir(tmp_path, monkeypatch): assert (tmp_path / "my-bot" / "src" / "prompts").is_dir() -def test_chatbot_creates_utils_dir(tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - with _mock_uv_and_install(): - ProjectGenerator().generate(_cfg()) - assert (tmp_path / "my-bot" / "src" / "utils").is_dir() - - def test_chatbot_creates_tests_dir(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) with _mock_uv_and_install(): @@ -111,13 +104,6 @@ def test_chatbot_creates_system_prompt(tmp_path, monkeypatch): assert (tmp_path / "my-bot" / "src" / "prompts" / "system.txt").exists() -def test_chatbot_creates_env_util(tmp_path, monkeypatch): - monkeypatch.chdir(tmp_path) - with _mock_uv_and_install(): - ProjectGenerator().generate(_cfg()) - assert (tmp_path / "my-bot" / "src" / "utils" / "env.py").exists() - - def test_chatbot_creates_test_file(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) with _mock_uv_and_install(): @@ -226,7 +212,6 @@ def test_chatbot_pydantic_ai_install_packages_called(tmp_path, monkeypatch): ProjectGenerator().generate(_cfg(framework="pydantic-ai")) args = mock_install.call_args[0][1] assert "pydantic-ai" in args - assert "openai" in args assert "python-dotenv" in args diff --git a/tests/test_chatbot_template.py b/tests/test_chatbot_template.py index cc50e80..664c4f0 100644 --- a/tests/test_chatbot_template.py +++ b/tests/test_chatbot_template.py @@ -46,7 +46,6 @@ def test_folders_include_all_required(): assert "src/prompts" in t.folders assert "src/memory" in t.folders assert "src/config" in t.folders - assert "src/utils" in t.folders assert "tests" in t.folders @@ -164,8 +163,6 @@ def test_openai_sdk_env_has_plain_model(): @pytest.mark.parametrize("framework,provider,expected_dep", [ ("pydantic-ai", "openai", "pydantic-ai"), - ("pydantic-ai", "anthropic", "anthropic"), - ("pydantic-ai", "gemini", "google-genai"), ("openai-sdk", "openai", "openai"), ("litellm", "openai", "litellm"), ("litellm", "anthropic", "litellm"), diff --git a/tests/test_generator.py b/tests/test_generator.py index c87d107..d7a537d 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -530,7 +530,6 @@ def test_chatbot_generator_creates_folders(mock_uv, mock_install, tmp_path, monk assert (tmp_path / "demo" / "src" / "chatbot").exists() assert (tmp_path / "demo" / "src" / "providers").exists() assert (tmp_path / "demo" / "src" / "prompts").exists() - assert (tmp_path / "demo" / "src" / "utils").exists() assert (tmp_path / "demo" / "tests").exists() @@ -599,7 +598,6 @@ def test_chatbot_pydantic_ai_extras_in_dependencies(): ) deps = instantiate_template(config).get_dependencies() assert "pydantic-ai" in deps - assert "openai" in deps assert "python-dotenv" in deps assert "ruff" in deps assert "pytest" in deps From 1c216e6febab4f644d4e8bb32ff2f34ed427d441 Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Sat, 4 Jul 2026 19:55:43 +0530 Subject: [PATCH 23/35] meta.json Provider + next_steps Fix --- src/spawn/generators/project_generator.py | 1 + src/spawn/templates/chatbot/__init__.py | 2 +- tests/test_chatbot_generator.py | 9 ++++++++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/spawn/generators/project_generator.py b/src/spawn/generators/project_generator.py index 04a08a9..861a520 100644 --- a/src/spawn/generators/project_generator.py +++ b/src/spawn/generators/project_generator.py @@ -76,6 +76,7 @@ def generate(self, config: ProjectConfig) -> Path: { "intent": config.template, "framework": config.framework, + "provider": config.provider, "spawn_version": __version__, }, indent=2, diff --git a/src/spawn/templates/chatbot/__init__.py b/src/spawn/templates/chatbot/__init__.py index f088f53..f96ea1f 100644 --- a/src/spawn/templates/chatbot/__init__.py +++ b/src/spawn/templates/chatbot/__init__.py @@ -146,7 +146,7 @@ def __init__( starter_files=_build_files(main_content, llm_content, env_content), next_steps=[ "cd {project_name}", - "cp .env.example .env # then fill in your API key", + "Rename .env.example to .env and fill in your API key", "uv run python -m src.main", ], ) diff --git a/tests/test_chatbot_generator.py b/tests/test_chatbot_generator.py index 1ae2837..f52ae1f 100644 --- a/tests/test_chatbot_generator.py +++ b/tests/test_chatbot_generator.py @@ -179,13 +179,19 @@ def test_chatbot_openai_sdk_llm_uses_openai_client(tmp_path, monkeypatch): def test_chatbot_creates_spawn_meta_json(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) + config = ProjectConfig( + name="my-bot", template="chatbot", use_git=False, + framework="pydantic-ai", provider="openai", extras=[], + ) with _mock_uv_and_install(): - ProjectGenerator().generate(_cfg(framework="pydantic-ai")) + ProjectGenerator().generate(config) meta = json.loads( (tmp_path / "my-bot" / ".spawn" / "meta.json").read_text(encoding="utf-8") ) assert meta["intent"] == "chatbot" assert meta["framework"] == "pydantic-ai" + assert "provider" in meta + assert meta["provider"] == "openai" def test_chatbot_openai_sdk_meta_json(tmp_path, monkeypatch): @@ -197,6 +203,7 @@ def test_chatbot_openai_sdk_meta_json(tmp_path, monkeypatch): ) assert meta["intent"] == "chatbot" assert meta["framework"] == "openai-sdk" + assert "provider" in meta # --------------------------------------------------------------------------- From b18c27b0d62af2012de2eee7e2a39d05c8336082 Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Sat, 4 Jul 2026 19:57:41 +0530 Subject: [PATCH 24/35] conftest.py + TEST_CONTENT Update --- src/spawn/templates/chatbot/__init__.py | 2 ++ src/spawn/templates/chatbot/content.py | 14 ++++++++++++++ tests/test_chatbot_generator.py | 7 +++++++ tests/test_chatbot_template.py | 23 +++++++++++++++++++++++ 4 files changed, 46 insertions(+) diff --git a/src/spawn/templates/chatbot/__init__.py b/src/spawn/templates/chatbot/__init__.py index f96ea1f..24b1c3a 100644 --- a/src/spawn/templates/chatbot/__init__.py +++ b/src/spawn/templates/chatbot/__init__.py @@ -7,6 +7,7 @@ MEMORY_HISTORY_CONTENT, SYSTEM_PROMPT_TXT_CONTENT, SETTINGS_CONTENT, + CONFTEST_CONTENT, MAIN_CONTENT_NO_RICH, MAIN_CONTENT_RICH, TEST_CONTENT, @@ -120,6 +121,7 @@ def _build_files(main_content: str, llm_content: str, env_content: str) -> list: ("src/config/settings.py", SETTINGS_CONTENT), ("src/main.py", main_content), ("tests/__init__.py", INIT_CONTENT), + ("tests/conftest.py", CONFTEST_CONTENT), ("tests/test_chatbot.py", TEST_CONTENT), (".env.example", env_content), ] diff --git a/src/spawn/templates/chatbot/content.py b/src/spawn/templates/chatbot/content.py index 665d054..f6d710f 100644 --- a/src/spawn/templates/chatbot/content.py +++ b/src/spawn/templates/chatbot/content.py @@ -428,6 +428,20 @@ def main() -> None: # BUG FIX: removed unused imports MagicMock and get_llm_response # which caused ruff F401 failures in generated projects with ruff extra selected +CONFTEST_CONTENT = """\ +import pytest + +from src.memory.history import clear + + +@pytest.fixture(autouse=True) +def reset_memory(): + \"\"\"Reset conversation history before every test.\"\"\" + clear() + yield + clear() +""" + TEST_CONTENT = """\ from unittest.mock import patch diff --git a/tests/test_chatbot_generator.py b/tests/test_chatbot_generator.py index f52ae1f..b1582c1 100644 --- a/tests/test_chatbot_generator.py +++ b/tests/test_chatbot_generator.py @@ -111,6 +111,13 @@ def test_chatbot_creates_test_file(tmp_path, monkeypatch): assert (tmp_path / "my-bot" / "tests" / "test_chatbot.py").exists() +def test_chatbot_creates_conftest(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-bot" / "tests" / "conftest.py").exists() + + def test_chatbot_creates_env_example(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) with _mock_uv_and_install(): diff --git a/tests/test_chatbot_template.py b/tests/test_chatbot_template.py index 664c4f0..10ac864 100644 --- a/tests/test_chatbot_template.py +++ b/tests/test_chatbot_template.py @@ -229,6 +229,29 @@ def test_memory_history_has_required_functions(): assert "clear" in mem +# ─── Conftest ───────────────────────────────────────────────────────────── + + +def test_conftest_py_is_generated(): + t = ChatbotTemplate() + paths = [p for p, _ in t.starter_files] + assert "tests/conftest.py" in paths + + +def test_conftest_has_autouse_fixture(): + t = ChatbotTemplate() + files = dict(t.starter_files) + conftest = files["tests/conftest.py"] + assert "autouse=True" in conftest + assert "clear()" in conftest + assert "reset_memory" in conftest + + +def test_conftest_is_valid_python(): + from spawn.templates.chatbot.content import CONFTEST_CONTENT + _assert_valid_python(CONFTEST_CONTENT, "CONFTEST_CONTENT") + + # ─── README ─────────────────────────────────────────────────────────────── From 1f05092a3830a5dbc5aef92841d11164bd1b4111 Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Sat, 4 Jul 2026 20:00:15 +0530 Subject: [PATCH 25/35] Full test suite --- tests/test_chatbot_generator.py | 42 ++++++++ tests/test_chatbot_template.py | 174 ++++++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+) diff --git a/tests/test_chatbot_generator.py b/tests/test_chatbot_generator.py index b1582c1..9d6a995 100644 --- a/tests/test_chatbot_generator.py +++ b/tests/test_chatbot_generator.py @@ -250,3 +250,45 @@ def test_chatbot_extras_reach_install_packages(tmp_path, monkeypatch): args = mock_install.call_args[0][1] assert "ruff" in args assert "pytest" in args + + +def test_chatbot_meta_json_has_provider(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with patch("spawn.generators.project_generator.install_packages"), \ + patch("spawn.generators.project_generator.initialize_uv"), \ + patch.object(ChatbotTemplate, "post_install"): + config = ProjectConfig( + name="my-bot", template="chatbot", use_git=False, + framework="pydantic-ai", provider="gemini", extras=[], + ) + ProjectGenerator().generate(config) + meta = json.loads( + (tmp_path / "my-bot" / ".spawn" / "meta.json").read_text(encoding="utf-8") + ) + assert meta["provider"] == "gemini" + + +def test_chatbot_pydantic_ai_only_pydantic_dep(tmp_path, monkeypatch): + """pydantic-ai projects should not install openai/anthropic separately.""" + monkeypatch.chdir(tmp_path) + with patch("spawn.generators.project_generator.install_packages") as mock_install, \ + patch("spawn.generators.project_generator.initialize_uv"), \ + patch.object(ChatbotTemplate, "post_install"): + ProjectGenerator().generate(_cfg(framework="pydantic-ai")) + args = mock_install.call_args[0][1] + assert "pydantic-ai" in args + assert "openai" not in args + assert "anthropic" not in args + assert "google-genai" not in args + + +def test_chatbot_memory_history_has_pai_functions(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + mem = (tmp_path / "my-bot" / "src" / "memory" / "history.py").read_text( + encoding="utf-8" + ) + assert "get_pai_history" in mem + assert "append_pai_messages" in mem + assert "_pai_history" in mem diff --git a/tests/test_chatbot_template.py b/tests/test_chatbot_template.py index 10ac864..41e4a2d 100644 --- a/tests/test_chatbot_template.py +++ b/tests/test_chatbot_template.py @@ -388,3 +388,177 @@ def test_pydantic_ai_openrouter_uses_result_output(): def test_pydantic_ai_ollama_uses_result_output(): assert "result.output" in PYDANTIC_AI_OLLAMA_LLM_CONTENT + + +# ─── Memory dual-store ──────────────────────────────────────────────────── + + +def test_memory_has_get_pai_history(): + t = ChatbotTemplate() + files = dict(t.starter_files) + mem = files["src/memory/history.py"] + assert "get_pai_history" in mem + assert "_pai_history" in mem + + +def test_memory_has_append_pai_messages(): + t = ChatbotTemplate() + files = dict(t.starter_files) + mem = files["src/memory/history.py"] + assert "append_pai_messages" in mem + + +def test_memory_clear_resets_both_stores(): + t = ChatbotTemplate() + files = dict(t.starter_files) + mem = files["src/memory/history.py"] + assert "_history.clear()" in mem + assert "_pai_history.clear()" in mem + + +def test_memory_type_checking_guard(): + """pydantic_ai import is guarded by TYPE_CHECKING to avoid + import errors in openai-sdk/litellm projects.""" + t = ChatbotTemplate() + files = dict(t.starter_files) + mem = files["src/memory/history.py"] + assert "TYPE_CHECKING" in mem + assert "from pydantic_ai.messages import ModelMessage" in mem + + +# ─── PydanticAI multi-turn memory ──────────────────────────────────────── + + +@pytest.mark.parametrize("provider", [ + "openai", "anthropic", "gemini", "openrouter", "ollama" +]) +def test_pydantic_ai_sends_message_history(provider): + """All pydantic-ai variants must pass message_history to run_sync().""" + t = ChatbotTemplate(framework="pydantic-ai", provider=provider) + files = dict(t.starter_files) + llm = files["src/providers/llm.py"] + assert "message_history=get_pai_history()" in llm, \ + f"pydantic-ai+{provider} missing message_history param" + + +@pytest.mark.parametrize("provider", [ + "openai", "anthropic", "gemini", "openrouter", "ollama" +]) +def test_pydantic_ai_appends_new_messages(provider): + """All pydantic-ai variants must store new messages after each call.""" + t = ChatbotTemplate(framework="pydantic-ai", provider=provider) + files = dict(t.starter_files) + llm = files["src/providers/llm.py"] + assert "append_pai_messages(result.new_messages())" in llm, \ + f"pydantic-ai+{provider} missing append_pai_messages call" + + +@pytest.mark.parametrize("provider", [ + "openai", "anthropic", "gemini", "openrouter", "ollama" +]) +def test_pydantic_ai_imports_memory_functions(provider): + """All pydantic-ai variants must import the pai history functions.""" + t = ChatbotTemplate(framework="pydantic-ai", provider=provider) + files = dict(t.starter_files) + llm = files["src/providers/llm.py"] + assert "from src.memory.history import get_pai_history, append_pai_messages" in llm + + +# ─── Dependency simplification ─────────────────────────────────────────── + + +@pytest.mark.parametrize("provider", [ + "openai", "anthropic", "gemini", "openrouter", "ollama" +]) +def test_pydantic_ai_uses_metapackage_only(provider): + """pydantic-ai metapackage bundles all providers — no separate installs.""" + t = ChatbotTemplate(framework="pydantic-ai", provider=provider) + deps = t.get_dependencies() + assert "pydantic-ai" in deps + assert "openai" not in deps, \ + f"pydantic-ai+{provider} should not list openai separately" + assert "anthropic" not in deps, \ + f"pydantic-ai+{provider} should not list anthropic separately" + assert "google-genai" not in deps, \ + f"pydantic-ai+{provider} should not list google-genai separately" + + +# ─── All 13 combinations generate complete file sets ───────────────────── + +ALL_COMBINATIONS = [ + ("pydantic-ai", "openai"), + ("pydantic-ai", "anthropic"), + ("pydantic-ai", "gemini"), + ("pydantic-ai", "openrouter"), + ("pydantic-ai", "ollama"), + ("openai-sdk", "openai"), + ("openai-sdk", "openrouter"), + ("openai-sdk", "gemini"), + ("litellm", "openai"), + ("litellm", "anthropic"), + ("litellm", "gemini"), + ("litellm", "openrouter"), + ("litellm", "ollama"), +] + +REQUIRED_FILES = [ + "src/__init__.py", + "src/chatbot/__init__.py", + "src/chatbot/chat.py", + "src/providers/__init__.py", + "src/providers/llm.py", + "src/prompts/__init__.py", + "src/prompts/system.txt", + "src/memory/__init__.py", + "src/memory/history.py", + "src/config/__init__.py", + "src/config/settings.py", + "src/main.py", + "tests/__init__.py", + "tests/conftest.py", + "tests/test_chatbot.py", + ".env.example", +] + + +@pytest.mark.parametrize("framework,provider", ALL_COMBINATIONS) +def test_all_combinations_generate_required_files(framework, provider): + t = ChatbotTemplate(framework=framework, provider=provider) + generated = [p for p, _ in t.starter_files] + for required in REQUIRED_FILES: + assert required in generated, \ + f"{framework}+{provider} missing {required}" + + +@pytest.mark.parametrize("framework,provider", ALL_COMBINATIONS) +def test_all_combinations_llm_compiles(framework, provider): + """Every llm.py variant must be syntactically valid Python.""" + t = ChatbotTemplate(framework=framework, provider=provider) + files = dict(t.starter_files) + llm = files["src/providers/llm.py"] + _assert_valid_python(llm, f"{framework}+{provider} llm.py") + + +@pytest.mark.parametrize("framework,provider", ALL_COMBINATIONS) +def test_all_combinations_memory_compiles(framework, provider): + """memory/history.py must compile for all combinations.""" + t = ChatbotTemplate(framework=framework, provider=provider) + files = dict(t.starter_files) + mem = files["src/memory/history.py"] + _assert_valid_python(mem, f"{framework}+{provider} memory/history.py") + + +@pytest.mark.parametrize("framework,provider", ALL_COMBINATIONS) +def test_all_combinations_have_python_dotenv(framework, provider): + t = ChatbotTemplate(framework=framework, provider=provider) + assert "python-dotenv" in t.get_dependencies(), \ + f"{framework}+{provider} missing python-dotenv" + + +@pytest.mark.parametrize("framework,provider", ALL_COMBINATIONS) +def test_no_utils_dir_generated(framework, provider): + t = ChatbotTemplate(framework=framework, provider=provider) + paths = [p for p, _ in t.starter_files] + assert "src/utils/env.py" not in paths + assert "src/utils/__init__.py" not in paths + assert "src/utils" not in t.folders From 5c37c428070d2351c17926a707b23e12ce778d53 Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Sun, 5 Jul 2026 20:16:57 +0530 Subject: [PATCH 26/35] Add Groq --- src/spawn/templates/chatbot/content.py | 61 ++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/spawn/templates/chatbot/content.py b/src/spawn/templates/chatbot/content.py index f6d710f..4722e06 100644 --- a/src/spawn/templates/chatbot/content.py +++ b/src/spawn/templates/chatbot/content.py @@ -184,6 +184,24 @@ def get_llm_response(messages: list[dict], system_prompt: str) -> str: return result.output """ +PYDANTIC_AI_GROQ_LLM_CONTENT = """\ +import os + +from pydantic_ai import Agent + +from src.memory.history import get_pai_history, append_pai_messages + + +def get_llm_response(messages: list[dict], system_prompt: str) -> str: + model = os.getenv("MODEL", "groq:llama-3.1-8b-instant") + user_messages = [m["content"] for m in messages if m["role"] == "user"] + prompt = user_messages[-1] if user_messages else "" + agent = Agent(model, system_prompt=system_prompt) + result = agent.run_sync(prompt, message_history=get_pai_history()) + append_pai_messages(result.new_messages()) + return result.output +""" + # ─── OpenAI SDK ─────────────────────────────────────────────────────────── OPENAI_SDK_OPENAI_LLM_CONTENT = """\ @@ -234,6 +252,23 @@ def get_llm_response(messages: list[dict], system_prompt: str) -> str: return response.choices[0].message.content or "" """ +OPENAI_SDK_GROQ_LLM_CONTENT = """\ +import os + +from openai import OpenAI + + +def get_llm_response(messages: list[dict], system_prompt: str) -> str: + client = OpenAI( + api_key=os.getenv("GROQ_API_KEY", ""), + base_url="https://api.groq.com/openai/v1", + ) + model = os.getenv("MODEL", "llama-3.1-8b-instant") + full_messages = [{{"role": "system", "content": system_prompt}}] + messages + response = client.chat.completions.create(model=model, messages=full_messages) + return response.choices[0].message.content or "" +""" + # ─── LiteLLM ────────────────────────────────────────────────────────────── LITELLM_OPENAI_LLM_CONTENT = """\ @@ -302,6 +337,19 @@ def get_llm_response(messages: list[dict], system_prompt: str) -> str: return response.choices[0].message.content or "" """ +LITELLM_GROQ_LLM_CONTENT = """\ +import os + +import litellm + + +def get_llm_response(messages: list[dict], system_prompt: str) -> str: + model = os.getenv("MODEL", "groq/llama-3.1-8b-instant") + full_messages = [{{"role": "system", "content": system_prompt}}] + messages + response = litellm.completion(model=model, messages=full_messages) + return response.choices[0].message.content or "" +""" + # ─── Main content ───────────────────────────────────────────────────────── MAIN_CONTENT_NO_RICH = """\ @@ -392,6 +440,12 @@ def main() -> None: MODEL=ollama/llama3.2 """ +ENV_GROQ = """\ +APP_NAME={project_name} +GROQ_API_KEY= +MODEL=llama-3.1-8b-instant +""" + # Provider-prefixed MODEL for pydantic-ai variants ENV_PYDANTIC_OPENAI = """\ @@ -424,6 +478,12 @@ def main() -> None: MODEL=llama3.2 """ +ENV_PYDANTIC_GROQ = """\ +APP_NAME={project_name} +GROQ_API_KEY= +MODEL=groq:llama-3.1-8b-instant +""" + # ─── Test content ───────────────────────────────────────────────────────── # BUG FIX: removed unused imports MagicMock and get_llm_response @@ -479,6 +539,7 @@ def make_readme(framework: str, provider: str) -> str: "gemini": "GOOGLE_API_KEY=your-key", "openrouter": "OPENROUTER_API_KEY=your-key", "ollama": "OLLAMA_BASE_URL=http://localhost:11434", + "groq": "GROQ_API_KEY=your-key", } key_line = provider_key_map.get(provider, "API_KEY=your-key") return ( From a64e72853cf6bdf2795757f169d8696f07f8b130 Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Sun, 5 Jul 2026 20:18:58 +0530 Subject: [PATCH 27/35] Wire Groq into __init__.py --- src/spawn/templates/chatbot/__init__.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/spawn/templates/chatbot/__init__.py b/src/spawn/templates/chatbot/__init__.py index 24b1c3a..7861804 100644 --- a/src/spawn/templates/chatbot/__init__.py +++ b/src/spawn/templates/chatbot/__init__.py @@ -19,25 +19,30 @@ PYDANTIC_AI_GEMINI_LLM_CONTENT, PYDANTIC_AI_OPENROUTER_LLM_CONTENT, PYDANTIC_AI_OLLAMA_LLM_CONTENT, + PYDANTIC_AI_GROQ_LLM_CONTENT, OPENAI_SDK_OPENAI_LLM_CONTENT, OPENAI_SDK_OPENROUTER_LLM_CONTENT, OPENAI_SDK_GEMINI_LLM_CONTENT, + OPENAI_SDK_GROQ_LLM_CONTENT, LITELLM_OPENAI_LLM_CONTENT, LITELLM_ANTHROPIC_LLM_CONTENT, LITELLM_GEMINI_LLM_CONTENT, LITELLM_OPENROUTER_LLM_CONTENT, LITELLM_OLLAMA_LLM_CONTENT, + LITELLM_GROQ_LLM_CONTENT, ENV_OPENAI, ENV_ANTHROPIC, ENV_GEMINI, ENV_OPENROUTER, ENV_OLLAMA, ENV_LITELLM_OLLAMA, + ENV_GROQ, ENV_PYDANTIC_OPENAI, ENV_PYDANTIC_ANTHROPIC, ENV_PYDANTIC_GEMINI, ENV_PYDANTIC_OPENROUTER, ENV_PYDANTIC_OLLAMA, + ENV_PYDANTIC_GROQ, make_readme, ) @@ -57,14 +62,17 @@ ("pydantic-ai", "gemini"): PYDANTIC_AI_GEMINI_LLM_CONTENT, ("pydantic-ai", "openrouter"): PYDANTIC_AI_OPENROUTER_LLM_CONTENT, ("pydantic-ai", "ollama"): PYDANTIC_AI_OLLAMA_LLM_CONTENT, + ("pydantic-ai", "groq"): PYDANTIC_AI_GROQ_LLM_CONTENT, ("openai-sdk", "openai"): OPENAI_SDK_OPENAI_LLM_CONTENT, ("openai-sdk", "openrouter"): OPENAI_SDK_OPENROUTER_LLM_CONTENT, ("openai-sdk", "gemini"): OPENAI_SDK_GEMINI_LLM_CONTENT, + ("openai-sdk", "groq"): OPENAI_SDK_GROQ_LLM_CONTENT, ("litellm", "openai"): LITELLM_OPENAI_LLM_CONTENT, ("litellm", "anthropic"): LITELLM_ANTHROPIC_LLM_CONTENT, ("litellm", "gemini"): LITELLM_GEMINI_LLM_CONTENT, ("litellm", "openrouter"): LITELLM_OPENROUTER_LLM_CONTENT, ("litellm", "ollama"): LITELLM_OLLAMA_LLM_CONTENT, + ("litellm", "groq"): LITELLM_GROQ_LLM_CONTENT, } # Maps provider → env example (pydantic-ai uses prefixed MODEL format) @@ -74,6 +82,7 @@ "gemini": ENV_PYDANTIC_GEMINI, "openrouter": ENV_PYDANTIC_OPENROUTER, "ollama": ENV_PYDANTIC_OLLAMA, + "groq": ENV_PYDANTIC_GROQ, } _ENV_MAP_GENERIC: dict[str, str] = { @@ -82,6 +91,7 @@ "gemini": ENV_GEMINI, "openrouter": ENV_OPENROUTER, "ollama": ENV_OLLAMA, + "groq": ENV_GROQ, } # litellm+ollama uses OLLAMA_API_BASE (litellm convention) instead of OLLAMA_BASE_URL @@ -91,6 +101,7 @@ "gemini": ENV_GEMINI, "openrouter": ENV_OPENROUTER, "ollama": ENV_LITELLM_OLLAMA, + "groq": ENV_GROQ, } @@ -164,14 +175,17 @@ def get_dependencies(self) -> list[str]: ("pydantic-ai", "gemini"): ["pydantic-ai", "python-dotenv"], ("pydantic-ai", "openrouter"): ["pydantic-ai", "python-dotenv"], ("pydantic-ai", "ollama"): ["pydantic-ai", "python-dotenv"], + ("pydantic-ai", "groq"): ["pydantic-ai", "python-dotenv"], ("openai-sdk", "openai"): ["openai", "python-dotenv"], ("openai-sdk", "openrouter"): ["openai", "python-dotenv"], ("openai-sdk", "gemini"): ["openai", "python-dotenv"], + ("openai-sdk", "groq"): ["openai", "python-dotenv"], ("litellm", "openai"): ["litellm", "python-dotenv"], ("litellm", "anthropic"): ["litellm", "python-dotenv"], ("litellm", "gemini"): ["litellm", "python-dotenv"], ("litellm", "openrouter"): ["litellm", "python-dotenv"], ("litellm", "ollama"): ["litellm", "python-dotenv"], + ("litellm", "groq"): ["litellm", "python-dotenv"], } base = list(dep_map.get((self.framework, self.provider), ["pydantic-ai", "openai", "python-dotenv"])) From ee92d04ea0d56ddf77fdde32933e0b58e6de173f Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Sun, 5 Jul 2026 20:21:51 +0530 Subject: [PATCH 28/35] Registry + prompts.py PROVIDER_MAP --- src/spawn/cli/prompts.py | 6 +++--- src/spawn/core/registry.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/spawn/cli/prompts.py b/src/spawn/cli/prompts.py index 7f1558a..5b38b05 100644 --- a/src/spawn/cli/prompts.py +++ b/src/spawn/cli/prompts.py @@ -133,9 +133,9 @@ def get_project_config() -> ProjectConfig: # --- Provider selection --- PROVIDER_MAP: dict[str, list[str]] = { - "pydantic-ai": ["openai", "anthropic", "gemini", "openrouter", "ollama"], - "openai-sdk": ["openai", "openrouter", "gemini"], - "litellm": ["openai", "anthropic", "gemini", "openrouter", "ollama"], + "pydantic-ai": ["openai", "anthropic", "gemini", "openrouter", "ollama", "groq"], + "openai-sdk": ["openai", "openrouter", "gemini", "groq"], + "litellm": ["openai", "anthropic", "gemini", "openrouter", "ollama", "groq"], } if meta and meta.available_providers and selected_framework: diff --git a/src/spawn/core/registry.py b/src/spawn/core/registry.py index 1453968..6f4c14b 100644 --- a/src/spawn/core/registry.py +++ b/src/spawn/core/registry.py @@ -61,7 +61,7 @@ class TemplateMetadata: description="Conversational AI with PydanticAI, OpenAI SDK, or LiteLLM", template_class=ChatbotTemplate, available_frameworks=["pydantic-ai", "openai-sdk", "litellm"], - available_providers=["openai", "anthropic", "gemini", "openrouter", "ollama"], + available_providers=["openai", "anthropic", "gemini", "openrouter", "ollama", "groq"], available_extras=["ruff", "pytest", "rich", "github-actions"], ), } From 26925a7381fc6065959d90f69c9bb9e0ee884830 Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Sun, 5 Jul 2026 20:24:08 +0530 Subject: [PATCH 29/35] tests for groq --- tests/test_chatbot_template.py | 53 ++++++++++++++++++++++++++++++++++ tests/test_registry.py | 2 ++ 2 files changed, 55 insertions(+) diff --git a/tests/test_chatbot_template.py b/tests/test_chatbot_template.py index 41e4a2d..4f56788 100644 --- a/tests/test_chatbot_template.py +++ b/tests/test_chatbot_template.py @@ -491,14 +491,17 @@ def test_pydantic_ai_uses_metapackage_only(provider): ("pydantic-ai", "gemini"), ("pydantic-ai", "openrouter"), ("pydantic-ai", "ollama"), + ("pydantic-ai", "groq"), ("openai-sdk", "openai"), ("openai-sdk", "openrouter"), ("openai-sdk", "gemini"), + ("openai-sdk", "groq"), ("litellm", "openai"), ("litellm", "anthropic"), ("litellm", "gemini"), ("litellm", "openrouter"), ("litellm", "ollama"), + ("litellm", "groq"), ] REQUIRED_FILES = [ @@ -562,3 +565,53 @@ def test_no_utils_dir_generated(framework, provider): assert "src/utils/env.py" not in paths assert "src/utils/__init__.py" not in paths assert "src/utils" not in t.folders + + +# ─── Groq-specific ─────────────────────────────────────────────────────── + + +def test_pydantic_ai_groq_uses_groq_prefix(): + t = ChatbotTemplate(framework="pydantic-ai", provider="groq") + files = dict(t.starter_files) + llm = files["src/providers/llm.py"] + assert "groq:llama-3.1-8b-instant" in llm + + +def test_pydantic_ai_groq_has_multi_turn_memory(): + t = ChatbotTemplate(framework="pydantic-ai", provider="groq") + files = dict(t.starter_files) + llm = files["src/providers/llm.py"] + assert "message_history=get_pai_history()" in llm + assert "append_pai_messages(result.new_messages())" in llm + + +def test_openai_sdk_groq_uses_groq_base_url(): + t = ChatbotTemplate(framework="openai-sdk", provider="groq") + files = dict(t.starter_files) + llm = files["src/providers/llm.py"] + assert "api.groq.com" in llm + assert "GROQ_API_KEY" in llm + + +def test_litellm_groq_uses_groq_prefix(): + t = ChatbotTemplate(framework="litellm", provider="groq") + files = dict(t.starter_files) + llm = files["src/providers/llm.py"] + assert "groq/llama-3.1-8b-instant" in llm + + +@pytest.mark.parametrize("framework", ["pydantic-ai", "openai-sdk", "litellm"]) +def test_groq_env_has_groq_api_key(framework): + t = ChatbotTemplate(framework=framework, provider="groq") + files = dict(t.starter_files) + env = files[".env.example"].format_map({"project_name": "test"}) + assert "GROQ_API_KEY" in env + + +@pytest.mark.parametrize("framework", ["pydantic-ai", "openai-sdk", "litellm"]) +def test_groq_deps_correct(framework): + t = ChatbotTemplate(framework=framework, provider="groq") + deps = t.get_dependencies() + assert "python-dotenv" in deps + # groq is bundled in pydantic-ai metapackage, no separate dep needed + assert "groq" not in deps diff --git a/tests/test_registry.py b/tests/test_registry.py index d450df4..e2920d1 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -141,6 +141,8 @@ def test_chatbot_metadata(): assert "ruff" in meta.available_extras assert "pytest" in meta.available_extras assert "github-actions" in meta.available_extras + assert "ollama" in meta.available_providers + assert "groq" in meta.available_providers assert meta.available_cli_types == [] From db17fbe3f98888c0970ed93abae6585ef7c37051 Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Mon, 6 Jul 2026 06:30:59 +0530 Subject: [PATCH 30/35] GROQ dep issues --- src/spawn/templates/chatbot/__init__.py | 5 +++-- src/spawn/templates/chatbot/content.py | 6 ++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/spawn/templates/chatbot/__init__.py b/src/spawn/templates/chatbot/__init__.py index 7861804..79fe379 100644 --- a/src/spawn/templates/chatbot/__init__.py +++ b/src/spawn/templates/chatbot/__init__.py @@ -43,6 +43,7 @@ ENV_PYDANTIC_OPENROUTER, ENV_PYDANTIC_OLLAMA, ENV_PYDANTIC_GROQ, + ENV_LITELLM_GROQ, make_readme, ) @@ -101,7 +102,7 @@ "gemini": ENV_GEMINI, "openrouter": ENV_OPENROUTER, "ollama": ENV_LITELLM_OLLAMA, - "groq": ENV_GROQ, + "groq": ENV_LITELLM_GROQ, } @@ -175,7 +176,7 @@ def get_dependencies(self) -> list[str]: ("pydantic-ai", "gemini"): ["pydantic-ai", "python-dotenv"], ("pydantic-ai", "openrouter"): ["pydantic-ai", "python-dotenv"], ("pydantic-ai", "ollama"): ["pydantic-ai", "python-dotenv"], - ("pydantic-ai", "groq"): ["pydantic-ai", "python-dotenv"], + ("pydantic-ai", "groq"): ["pydantic-ai[groq]", "python-dotenv"], ("openai-sdk", "openai"): ["openai", "python-dotenv"], ("openai-sdk", "openrouter"): ["openai", "python-dotenv"], ("openai-sdk", "gemini"): ["openai", "python-dotenv"], diff --git a/src/spawn/templates/chatbot/content.py b/src/spawn/templates/chatbot/content.py index 4722e06..067d008 100644 --- a/src/spawn/templates/chatbot/content.py +++ b/src/spawn/templates/chatbot/content.py @@ -484,6 +484,12 @@ def main() -> None: MODEL=groq:llama-3.1-8b-instant """ +ENV_LITELLM_GROQ = """\ +APP_NAME={project_name} +GROQ_API_KEY= +MODEL=groq/llama-3.1-8b-instant +""" + # ─── Test content ───────────────────────────────────────────────────────── # BUG FIX: removed unused imports MagicMock and get_llm_response From 630bd754633c051cb6f9820e0dae62b6203c4427 Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Mon, 6 Jul 2026 19:58:47 +0530 Subject: [PATCH 31/35] Fix README --- src/spawn/templates/chatbot/content.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/spawn/templates/chatbot/content.py b/src/spawn/templates/chatbot/content.py index 067d008..6ecec87 100644 --- a/src/spawn/templates/chatbot/content.py +++ b/src/spawn/templates/chatbot/content.py @@ -583,11 +583,17 @@ def make_readme(framework: str, provider: str) -> str: "```bash\n" "uv run pytest\n" "```\n\n" - "## Future Expansions\n\n" + "## Roadmap (Not Yet Available)\n\n" "```bash\n" - "spawn add memory\n" - "spawn add tools\n" "spawn add rag\n" + "spawn add tools\n" + "spawn add vector-db\n" + "spawn add memory\n" + "spawn add mcp\n" + "spawn add voice\n" + "spawn add web-ui\n" + "spawn add streaming\n" + "spawn add observability\n" "```\n" ) From bf4f1be86e188399b050aabf97923bb2334b3a14 Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Mon, 6 Jul 2026 20:35:29 +0530 Subject: [PATCH 32/35] Make memory/history.py framework-conditional --- src/spawn/templates/chatbot/__init__.py | 14 ++++++++++---- src/spawn/templates/chatbot/content.py | 22 +++++++++++++++++++++- tests/test_chatbot_template.py | 20 ++++++++++++++++++++ 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/spawn/templates/chatbot/__init__.py b/src/spawn/templates/chatbot/__init__.py index 79fe379..abc96d2 100644 --- a/src/spawn/templates/chatbot/__init__.py +++ b/src/spawn/templates/chatbot/__init__.py @@ -4,7 +4,8 @@ from spawn.templates.chatbot.content import ( INIT_CONTENT, CHAT_CONTENT, - MEMORY_HISTORY_CONTENT, + MEMORY_HISTORY_CONTENT_BASE, + MEMORY_HISTORY_CONTENT_PYDANTIC, SYSTEM_PROMPT_TXT_CONTENT, SETTINGS_CONTENT, CONFTEST_CONTENT, @@ -118,7 +119,7 @@ def _resolve_env(framework: str, provider: str) -> str: return _ENV_MAP_GENERIC.get(provider, ENV_OPENAI) -def _build_files(main_content: str, llm_content: str, env_content: str) -> list: +def _build_files(main_content: str, llm_content: str, env_content: str, memory_content: str) -> list: return [ ("src/__init__.py", INIT_CONTENT), ("src/chatbot/__init__.py", INIT_CONTENT), @@ -128,7 +129,7 @@ def _build_files(main_content: str, llm_content: str, env_content: str) -> list: ("src/prompts/__init__.py", INIT_CONTENT), ("src/prompts/system.txt", SYSTEM_PROMPT_TXT_CONTENT), ("src/memory/__init__.py", INIT_CONTENT), - ("src/memory/history.py", MEMORY_HISTORY_CONTENT), + ("src/memory/history.py", memory_content), ("src/config/__init__.py", INIT_CONTENT), ("src/config/settings.py", SETTINGS_CONTENT), ("src/main.py", main_content), @@ -153,11 +154,16 @@ def __init__( llm_content = _resolve_llm(self.framework, self.provider) env_content = _resolve_env(self.framework, self.provider) main_content = MAIN_CONTENT_RICH if "rich" in self.extras else MAIN_CONTENT_NO_RICH + memory_content = ( + MEMORY_HISTORY_CONTENT_PYDANTIC + if self.framework == "pydantic-ai" + else MEMORY_HISTORY_CONTENT_BASE + ) super().__init__( name="AI Chatbot", folders=list(CHATBOT_FOLDERS), - starter_files=_build_files(main_content, llm_content, env_content), + starter_files=_build_files(main_content, llm_content, env_content, memory_content), next_steps=[ "cd {project_name}", "Rename .env.example to .env and fill in your API key", diff --git a/src/spawn/templates/chatbot/content.py b/src/spawn/templates/chatbot/content.py index 6ecec87..0426a6c 100644 --- a/src/spawn/templates/chatbot/content.py +++ b/src/spawn/templates/chatbot/content.py @@ -17,7 +17,27 @@ def get_response(user_input: str) -> str: return response """ -MEMORY_HISTORY_CONTENT = """\ +MEMORY_HISTORY_CONTENT_BASE = """\ +_history: list[dict] = [] + + +def append_user(content: str) -> None: + _history.append({{"role": "user", "content": content}}) + + +def append_assistant(content: str) -> None: + _history.append({{"role": "assistant", "content": content}}) + + +def get_history() -> list[dict]: + return list(_history) + + +def clear() -> None: + _history.clear() +""" + +MEMORY_HISTORY_CONTENT_PYDANTIC = """\ from __future__ import annotations from typing import TYPE_CHECKING diff --git a/tests/test_chatbot_template.py b/tests/test_chatbot_template.py index 4f56788..1ce2c59 100644 --- a/tests/test_chatbot_template.py +++ b/tests/test_chatbot_template.py @@ -426,6 +426,26 @@ def test_memory_type_checking_guard(): assert "from pydantic_ai.messages import ModelMessage" in mem +def test_openai_sdk_memory_has_no_pai_history(): + """openai-sdk projects get the base history without pydantic-ai dead code.""" + t = ChatbotTemplate(framework="openai-sdk") + files = dict(t.starter_files) + mem = files["src/memory/history.py"] + assert "get_pai_history" not in mem + assert "_pai_history" not in mem + assert "append_pai_messages" not in mem + + +def test_litellm_memory_has_no_pai_history(): + """litellm projects get the base history without pydantic-ai dead code.""" + t = ChatbotTemplate(framework="litellm") + files = dict(t.starter_files) + mem = files["src/memory/history.py"] + assert "get_pai_history" not in mem + assert "_pai_history" not in mem + assert "append_pai_messages" not in mem + + # ─── PydanticAI multi-turn memory ──────────────────────────────────────── From a7f6ec3ab8a436620c05d2d666c85787a2318f43 Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Mon, 6 Jul 2026 20:37:40 +0530 Subject: [PATCH 33/35] Remove dead get_model() --- src/spawn/templates/chatbot/content.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/spawn/templates/chatbot/content.py b/src/spawn/templates/chatbot/content.py index 0426a6c..386b4b5 100644 --- a/src/spawn/templates/chatbot/content.py +++ b/src/spawn/templates/chatbot/content.py @@ -80,7 +80,6 @@ def clear() -> None: """ SETTINGS_CONTENT = """\ -import os from pathlib import Path from dotenv import load_dotenv @@ -90,10 +89,6 @@ def load_env() -> None: load_dotenv() -def get_model() -> str: - return os.getenv("MODEL", "") - - def get_system_prompt() -> str: prompt_path = Path(__file__).parent.parent / "prompts" / "system.txt" return prompt_path.read_text(encoding="utf-8").strip() From de64d1788a58ca07bac56fdd38793ee42b0f0b69 Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Mon, 6 Jul 2026 22:04:54 +0530 Subject: [PATCH 34/35] Single source of truth for framework/provider validity --- src/spawn/cli/prompts.py | 13 ++++++------- src/spawn/templates/chatbot/__init__.py | 7 +++++++ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/spawn/cli/prompts.py b/src/spawn/cli/prompts.py index 5b38b05..6f6b570 100644 --- a/src/spawn/cli/prompts.py +++ b/src/spawn/cli/prompts.py @@ -5,6 +5,7 @@ from spawn.utils.console import console from spawn.core.models import ProjectConfig from spawn.core.registry import list_templates, get_metadata +from spawn.templates.chatbot import get_supported_providers from spawn.utils.validators import validate_project_name from spawn.core.exceptions import SpawnError @@ -132,14 +133,12 @@ def get_project_config() -> ProjectConfig: selected_framework = framework_map[fw_choice] # --- Provider selection --- - PROVIDER_MAP: dict[str, list[str]] = { - "pydantic-ai": ["openai", "anthropic", "gemini", "openrouter", "ollama", "groq"], - "openai-sdk": ["openai", "openrouter", "gemini", "groq"], - "litellm": ["openai", "anthropic", "gemini", "openrouter", "ollama", "groq"], - } - if meta and meta.available_providers and selected_framework: - provider_options = PROVIDER_MAP.get(selected_framework, meta.available_providers) + provider_options = ( + get_supported_providers(selected_framework) + if selected_framework + else meta.available_providers + ) provider_choice_map = { str(i): p for i, p in enumerate(provider_options, start=1) } diff --git a/src/spawn/templates/chatbot/__init__.py b/src/spawn/templates/chatbot/__init__.py index abc96d2..fabbb8f 100644 --- a/src/spawn/templates/chatbot/__init__.py +++ b/src/spawn/templates/chatbot/__init__.py @@ -111,6 +111,13 @@ def _resolve_llm(framework: str, provider: str) -> str: return _LLM_MAP.get((framework, provider), PYDANTIC_AI_OPENAI_LLM_CONTENT) +def get_supported_providers(framework: str) -> list[str]: + """Return providers supported for a given framework, in menu order.""" + order = ["openai", "anthropic", "gemini", "openrouter", "ollama", "groq"] + supported = {provider for (fw, provider) in _LLM_MAP if fw == framework} + return [p for p in order if p in supported] + + def _resolve_env(framework: str, provider: str) -> str: if framework == "pydantic-ai": return _ENV_MAP_PYDANTIC.get(provider, ENV_PYDANTIC_OPENAI) From e2cfcd77e953266f13b5b108c60dd3d64056a71f Mon Sep 17 00:00:00 2001 From: Abhiix0 <24r21a6778@mlrit.ac.in> Date: Mon, 6 Jul 2026 22:22:26 +0530 Subject: [PATCH 35/35] Changelog update --- docs/changelog.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index b390e66..6c6fc9b 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -10,8 +10,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - **AI Chatbot intent** — generates a fully runnable conversational AI project with runtime memory, centralized prompt management, and provider abstraction - **3 frameworks**: PydanticAI, OpenAI SDK, LiteLLM -- **5 providers**: OpenAI, Anthropic, Gemini, OpenRouter, Ollama -- **13 supported combinations** — each generates correct dependencies, +- **6 providers**: OpenAI, Anthropic, Gemini, OpenRouter, Ollama, Groq +- **16 supported combinations** — each generates correct dependencies, provider-specific env vars, and working llm.py out of the box - **Runtime memory** — `src/memory/history.py` maintains conversation context across turns within a session; no database required