mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-13 21:33:32 +08:00
GUI Created, Dark Mode Created, npm script added, styling changed, Decoupled from Claude made more open source.
This commit is contained in:
3
.opencode/package-lock.json
generated
3
.opencode/package-lock.json
generated
@@ -8,6 +8,9 @@
|
|||||||
"name": "ecc-universal",
|
"name": "ecc-universal",
|
||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@opencode-ai/plugin": "1.3.17"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@opencode-ai/plugin": "^1.0.0",
|
"@opencode-ai/plugin": "^1.0.0",
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
|
|||||||
@@ -66,5 +66,8 @@
|
|||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@opencode-ai/plugin": "1.4.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
18
README.md
18
README.md
@@ -84,6 +84,7 @@ This repo is the raw code only. The guides explain everything.
|
|||||||
|
|
||||||
### v1.10.0 — Surface Refresh, Operator Workflows, and ECC 2.0 Alpha (Apr 2026)
|
### v1.10.0 — Surface Refresh, Operator Workflows, and ECC 2.0 Alpha (Apr 2026)
|
||||||
|
|
||||||
|
- **Dashboard GUI** — New Tkinter-based desktop application (`ecc_dashboard.py` or `npm run dashboard`) with dark/light theme toggle, font customization, and project logo in header and taskbar.
|
||||||
- **Public surface synced to the live repo** — metadata, catalog counts, plugin manifests, and install-facing docs now match the actual OSS surface: 38 agents, 156 skills, and 72 legacy command shims.
|
- **Public surface synced to the live repo** — metadata, catalog counts, plugin manifests, and install-facing docs now match the actual OSS surface: 38 agents, 156 skills, and 72 legacy command shims.
|
||||||
- **Operator and outbound workflow expansion** — `brand-voice`, `social-graph-ranker`, `connections-optimizer`, `customer-billing-ops`, `ecc-tools-cost-audit`, `google-workspace-ops`, `project-flow-ops`, and `workspace-surface-audit` round out the operator lane.
|
- **Operator and outbound workflow expansion** — `brand-voice`, `social-graph-ranker`, `connections-optimizer`, `customer-billing-ops`, `ecc-tools-cost-audit`, `google-workspace-ops`, `project-flow-ops`, and `workspace-surface-audit` round out the operator lane.
|
||||||
- **Media and launch tooling** — `manim-video`, `remotion-video-creation`, and upgraded social publishing surfaces make technical explainers and launch content part of the same system.
|
- **Media and launch tooling** — `manim-video`, `remotion-video-creation`, and upgraded social publishing surfaces make technical explainers and launch content part of the same system.
|
||||||
@@ -240,6 +241,23 @@ For manual install instructions see the README in the `rules/` folder. When copy
|
|||||||
|
|
||||||
**That's it!** You now have access to 47 agents, 181 skills, and 79 legacy command shims.
|
**That's it!** You now have access to 47 agents, 181 skills, and 79 legacy command shims.
|
||||||
|
|
||||||
|
### Dashboard GUI
|
||||||
|
|
||||||
|
Launch the desktop dashboard to visually explore ECC components:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dashboard
|
||||||
|
# or
|
||||||
|
python3 ./ecc_dashboard.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Tabbed interface: Agents, Skills, Commands, Rules, Settings
|
||||||
|
- Dark/Light theme toggle
|
||||||
|
- Font customization (family & size)
|
||||||
|
- Project logo in header and taskbar
|
||||||
|
- Search and filter across all components
|
||||||
|
|
||||||
### Multi-model commands require additional setup
|
### Multi-model commands require additional setup
|
||||||
|
|
||||||
> WARNING: `multi-*` commands are **not** covered by the base plugin/rules install above.
|
> WARNING: `multi-*` commands are **not** covered by the base plugin/rules install above.
|
||||||
|
|||||||
10
ecc2/Cargo.lock
generated
10
ecc2/Cargo.lock
generated
@@ -1286,6 +1286,15 @@ version = "0.1.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-src"
|
||||||
|
version = "300.6.0+3.6.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl-sys"
|
name = "openssl-sys"
|
||||||
version = "0.9.112"
|
version = "0.9.112"
|
||||||
@@ -1294,6 +1303,7 @@ checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"cc",
|
"cc",
|
||||||
"libc",
|
"libc",
|
||||||
|
"openssl-src",
|
||||||
"pkg-config",
|
"pkg-config",
|
||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ tokio = { version = "1", features = ["full"] }
|
|||||||
# State store
|
# State store
|
||||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
|
|
||||||
# Git integration
|
# Git integration - use vendored openssl to avoid system dependency
|
||||||
git2 = "0.20"
|
git2 = { version = "0.20", features = ["ssh", "vendored-openssl"] }
|
||||||
|
|
||||||
# Serialization
|
# Serialization
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
|||||||
84
pyproject.toml
Normal file
84
pyproject.toml
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
[project]
|
||||||
|
name = "llm-abstraction"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Provider-agnostic LLM abstraction layer"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
license = {text = "MIT"}
|
||||||
|
authors = [
|
||||||
|
{name = "Affaan Mustafa", email = "affaan@example.com"}
|
||||||
|
]
|
||||||
|
keywords = ["llm", "openai", "anthropic", "ollama", "ai"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 3 - Alpha",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"anthropic>=0.25.0",
|
||||||
|
"openai>=1.30.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"pytest-asyncio>=0.23",
|
||||||
|
"pytest-cov>=4.1",
|
||||||
|
"ruff>=0.4",
|
||||||
|
"mypy>=1.10",
|
||||||
|
"ruff>=0.4",
|
||||||
|
]
|
||||||
|
test = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"pytest-asyncio>=0.23",
|
||||||
|
"pytest-cov>=4.1",
|
||||||
|
"pytest-mock>=3.12",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://github.com/affaan-m/everything-claude-code"
|
||||||
|
Repository = "https://github.com/affaan-m/everything-claude-code"
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
llm-select = "llm.cli.selector:main"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/llm"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
filterwarnings = ["ignore::DeprecationWarning"]
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
source = ["src/llm"]
|
||||||
|
branch = true
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
exclude_lines = [
|
||||||
|
"pragma: no cover",
|
||||||
|
"if TYPE_CHECKING:",
|
||||||
|
"raise NotImplementedError",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
src-path = ["src"]
|
||||||
|
target-version = "py311"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "N", "W", "UP"]
|
||||||
|
ignore = ["E501"]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.11"
|
||||||
|
src_paths = ["src"]
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unused_ignores = true
|
||||||
33
src/llm/__init__.py
Normal file
33
src/llm/__init__.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""
|
||||||
|
LLM Abstraction Layer
|
||||||
|
|
||||||
|
Provider-agnostic interface for multiple LLM backends.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from llm.core.interface import LLMProvider
|
||||||
|
from llm.core.types import LLMInput, LLMOutput, Message, ToolCall, ToolDefinition, ToolResult
|
||||||
|
from llm.providers import get_provider
|
||||||
|
from llm.tools import ToolExecutor, ToolRegistry
|
||||||
|
from llm.cli.selector import interactive_select
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"LLMProvider",
|
||||||
|
"LLMInput",
|
||||||
|
"LLMOutput",
|
||||||
|
"Message",
|
||||||
|
"get_provider",
|
||||||
|
"ToolCall",
|
||||||
|
"ToolDefinition",
|
||||||
|
"ToolResult",
|
||||||
|
"ToolExecutor",
|
||||||
|
"ToolRegistry",
|
||||||
|
"interactive_select",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def gui() -> None:
|
||||||
|
from llm.gui.selector import main
|
||||||
|
main()
|
||||||
|
|
||||||
7
src/llm/__main__.py
Normal file
7
src/llm/__main__.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Entry point for llm CLI."""
|
||||||
|
|
||||||
|
from llm.cli.selector import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
0
src/llm/cli/__init__.py
Normal file
0
src/llm/cli/__init__.py
Normal file
154
src/llm/cli/selector.py
Normal file
154
src/llm/cli/selector.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class Color(str, Enum):
|
||||||
|
RESET = "\033[0m"
|
||||||
|
BOLD = "\033[1m"
|
||||||
|
GREEN = "\033[92m"
|
||||||
|
YELLOW = "\033[93m"
|
||||||
|
BLUE = "\033[94m"
|
||||||
|
CYAN = "\033[96m"
|
||||||
|
|
||||||
|
|
||||||
|
def print_banner() -> None:
|
||||||
|
banner = f"""{Color.CYAN}
|
||||||
|
╔═══════════════════════════════════════════╗
|
||||||
|
║ LLM Provider Selector ║
|
||||||
|
║ Provider-agnostic AI interactions ║
|
||||||
|
╚═══════════════════════════════════════════╝{Color.RESET}"""
|
||||||
|
print(banner)
|
||||||
|
|
||||||
|
|
||||||
|
def print_providers(providers: list[tuple[str, str]]) -> None:
|
||||||
|
print(f"\n{Color.BOLD}Available Providers:{Color.RESET}\n")
|
||||||
|
for i, (name, desc) in enumerate(providers, 1):
|
||||||
|
print(f" {Color.GREEN}{i}{Color.RESET}. {Color.BOLD}{name}{Color.RESET} - {desc}")
|
||||||
|
|
||||||
|
|
||||||
|
def select_provider(providers: list[tuple[str, str]]) -> str | None:
|
||||||
|
if not providers:
|
||||||
|
print("No providers available.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
print_providers(providers)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
choice = input(f"\n{Color.YELLOW}Select provider (1-{len(providers)}): {Color.RESET}").strip()
|
||||||
|
if not choice:
|
||||||
|
return None
|
||||||
|
idx = int(choice) - 1
|
||||||
|
if 0 <= idx < len(providers):
|
||||||
|
return providers[idx][0]
|
||||||
|
print(f"{Color.YELLOW}Invalid selection. Try again.{Color.RESET}")
|
||||||
|
except ValueError:
|
||||||
|
print(f"{Color.YELLOW}Please enter a number.{Color.RESET}")
|
||||||
|
|
||||||
|
|
||||||
|
def select_model(models: list[tuple[str, str]]) -> str | None:
|
||||||
|
if not models:
|
||||||
|
print("No models available.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
print(f"\n{Color.BOLD}Available Models:{Color.RESET}\n")
|
||||||
|
for i, (name, desc) in enumerate(models, 1):
|
||||||
|
print(f" {Color.GREEN}{i}{Color.RESET}. {Color.BOLD}{name}{Color.RESET} - {desc}")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
choice = input(f"\n{Color.YELLOW}Select model (1-{len(models)}): {Color.RESET}").strip()
|
||||||
|
if not choice:
|
||||||
|
return None
|
||||||
|
idx = int(choice) - 1
|
||||||
|
if 0 <= idx < len(models):
|
||||||
|
return models[idx][0]
|
||||||
|
print(f"{Color.YELLOW}Invalid selection. Try again.{Color.RESET}")
|
||||||
|
except ValueError:
|
||||||
|
print(f"{Color.YELLOW}Please enter a number.{Color.RESET}")
|
||||||
|
|
||||||
|
|
||||||
|
def save_config(provider: str, model: str, persist: bool = False) -> None:
|
||||||
|
config = f"LLM_PROVIDER={provider}\nLLM_MODEL={model}\n"
|
||||||
|
env_file = ".llm.env"
|
||||||
|
|
||||||
|
with open(env_file, "w") as f:
|
||||||
|
f.write(config)
|
||||||
|
|
||||||
|
print(f"\n{Color.GREEN}✓{Color.RESET} Config saved to {Color.CYAN}{env_file}{Color.RESET}")
|
||||||
|
|
||||||
|
if persist:
|
||||||
|
os.environ["LLM_PROVIDER"] = provider
|
||||||
|
os.environ["LLM_MODEL"] = model
|
||||||
|
print(f"{Color.GREEN}✓{Color.RESET} Config loaded to current session")
|
||||||
|
|
||||||
|
|
||||||
|
def interactive_select(
|
||||||
|
providers: list[tuple[str, str]] | None = None,
|
||||||
|
models_per_provider: dict[str, list[tuple[str, str]]] | None = None,
|
||||||
|
persist: bool = False,
|
||||||
|
) -> tuple[str, str] | None:
|
||||||
|
print_banner()
|
||||||
|
|
||||||
|
if providers is None:
|
||||||
|
providers = [
|
||||||
|
("claude", "Anthropic Claude ( Sonnet, Opus, Haiku)"),
|
||||||
|
("openai", "OpenAI GPT (4o, 4o-mini, 3.5-turbo)"),
|
||||||
|
("ollama", "Local Ollama models"),
|
||||||
|
]
|
||||||
|
|
||||||
|
if models_per_provider is None:
|
||||||
|
models_per_provider = {
|
||||||
|
"claude": [
|
||||||
|
("claude-opus-4-5", "Claude Opus 4.5 - Most capable"),
|
||||||
|
("claude-sonnet-4-7", "Claude Sonnet 4.7 - Balanced"),
|
||||||
|
("claude-haiku-4-7", "Claude Haiku 4.7 - Fast"),
|
||||||
|
],
|
||||||
|
"openai": [
|
||||||
|
("gpt-4o", "GPT-4o - Most capable"),
|
||||||
|
("gpt-4o-mini", "GPT-4o-mini - Fast & affordable"),
|
||||||
|
("gpt-4-turbo", "GPT-4 Turbo - Legacy powerful"),
|
||||||
|
("gpt-3.5-turbo", "GPT-3.5 - Legacy fast"),
|
||||||
|
],
|
||||||
|
"ollama": [
|
||||||
|
("llama3.2", "Llama 3.2 - General purpose"),
|
||||||
|
("mistral", "Mistral - Fast & efficient"),
|
||||||
|
("codellama", "CodeLlama - Code specialized"),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
provider = select_provider(providers)
|
||||||
|
if not provider:
|
||||||
|
return None
|
||||||
|
|
||||||
|
models = models_per_provider.get(provider, [])
|
||||||
|
model = select_model(models)
|
||||||
|
if not model:
|
||||||
|
return None
|
||||||
|
|
||||||
|
print(f"\n{Color.GREEN}Selected: {Color.BOLD}{provider}{Color.RESET} / {Color.BOLD}{model}{Color.RESET}")
|
||||||
|
|
||||||
|
save_config(provider, model, persist)
|
||||||
|
|
||||||
|
return (provider, model)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
result = interactive_select(persist=True)
|
||||||
|
|
||||||
|
if result:
|
||||||
|
print(f"\n{Color.GREEN}Ready to use!{Color.RESET}")
|
||||||
|
print(f" export LLM_PROVIDER={result[0]}")
|
||||||
|
print(f" export LLM_MODEL={result[1]}")
|
||||||
|
else:
|
||||||
|
print("\nSelection cancelled.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
1
src/llm/core/__init__.py
Normal file
1
src/llm/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Core module for LLM abstraction layer."""
|
||||||
60
src/llm/core/interface.py
Normal file
60
src/llm/core/interface.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""LLM Provider interface definition."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from llm.core.types import LLMInput, LLMOutput, ModelInfo, ProviderType
|
||||||
|
|
||||||
|
|
||||||
|
class LLMProvider(ABC):
|
||||||
|
provider_type: ProviderType
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def generate(self, input: LLMInput) -> LLMOutput: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def list_models(self) -> list[ModelInfo]: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def validate_config(self) -> bool: ...
|
||||||
|
|
||||||
|
def supports_tools(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def supports_vision(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_default_model(self) -> str:
|
||||||
|
raise NotImplementedError(f"{self.__class__.__name__} must implement get_default_model")
|
||||||
|
|
||||||
|
|
||||||
|
class LLMError(Exception):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
provider: ProviderType | None = None,
|
||||||
|
code: str | None = None,
|
||||||
|
details: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.message = message
|
||||||
|
self.provider = provider
|
||||||
|
self.code = code
|
||||||
|
self.details = details or {}
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticationError(LLMError): ...
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitError(LLMError): ...
|
||||||
|
|
||||||
|
|
||||||
|
class ContextLengthError(LLMError): ...
|
||||||
|
|
||||||
|
|
||||||
|
class ModelNotFoundError(LLMError): ...
|
||||||
|
|
||||||
|
|
||||||
|
class ToolExecutionError(LLMError): ...
|
||||||
140
src/llm/core/types.py
Normal file
140
src/llm/core/types.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
"""Core type definitions for LLM abstraction layer."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class Role(str, Enum):
|
||||||
|
SYSTEM = "system"
|
||||||
|
USER = "user"
|
||||||
|
ASSISTANT = "assistant"
|
||||||
|
TOOL = "tool"
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderType(str, Enum):
|
||||||
|
CLAUDE = "claude"
|
||||||
|
OPENAI = "openai"
|
||||||
|
OLLAMA = "ollama"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Message:
|
||||||
|
role: Role
|
||||||
|
content: str
|
||||||
|
name: str | None = None
|
||||||
|
tool_call_id: str | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
result: dict[str, Any] = {"role": self.role.value, "content": self.content}
|
||||||
|
if self.name:
|
||||||
|
result["name"] = self.name
|
||||||
|
if self.tool_call_id:
|
||||||
|
result["tool_call_id"] = self.tool_call_id
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ToolDefinition:
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
parameters: dict[str, Any]
|
||||||
|
strict: bool = True
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"description": self.description,
|
||||||
|
"parameters": self.parameters,
|
||||||
|
"strict": self.strict,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ToolCall:
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
arguments: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ToolResult:
|
||||||
|
tool_call_id: str
|
||||||
|
content: str
|
||||||
|
is_error: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LLMInput:
|
||||||
|
messages: list[Message]
|
||||||
|
model: str | None = None
|
||||||
|
temperature: float = 1.0
|
||||||
|
max_tokens: int | None = None
|
||||||
|
tools: list[ToolDefinition] | None = None
|
||||||
|
stream: bool = False
|
||||||
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"messages": [msg.to_dict() for msg in self.messages],
|
||||||
|
"temperature": self.temperature,
|
||||||
|
"stream": self.stream,
|
||||||
|
}
|
||||||
|
if self.model:
|
||||||
|
result["model"] = self.model
|
||||||
|
if self.max_tokens is not None:
|
||||||
|
result["max_tokens"] = self.max_tokens
|
||||||
|
if self.tools:
|
||||||
|
result["tools"] = [tool.to_dict() for tool in self.tools]
|
||||||
|
return result | self.metadata
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class LLMOutput:
|
||||||
|
content: str
|
||||||
|
tool_calls: list[ToolCall] | None = None
|
||||||
|
model: str | None = None
|
||||||
|
usage: dict[str, int] | None = None
|
||||||
|
stop_reason: str | None = None
|
||||||
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_tool_calls(self) -> bool:
|
||||||
|
return bool(self.tool_calls)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
result: dict[str, Any] = {"content": self.content}
|
||||||
|
if self.tool_calls:
|
||||||
|
result["tool_calls"] = [
|
||||||
|
{"id": tc.id, "name": tc.name, "arguments": tc.arguments}
|
||||||
|
for tc in self.tool_calls
|
||||||
|
]
|
||||||
|
if self.model:
|
||||||
|
result["model"] = self.model
|
||||||
|
if self.usage:
|
||||||
|
result["usage"] = self.usage
|
||||||
|
if self.stop_reason:
|
||||||
|
result["stop_reason"] = self.stop_reason
|
||||||
|
return result | self.metadata
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ModelInfo:
|
||||||
|
name: str
|
||||||
|
provider: ProviderType
|
||||||
|
supports_tools: bool = True
|
||||||
|
supports_vision: bool = False
|
||||||
|
max_tokens: int | None = None
|
||||||
|
context_window: int | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"provider": self.provider.value,
|
||||||
|
"supports_tools": self.supports_tools,
|
||||||
|
"supports_vision": self.supports_vision,
|
||||||
|
"max_tokens": self.max_tokens,
|
||||||
|
"context_window": self.context_window,
|
||||||
|
}
|
||||||
13
src/llm/prompt/__init__.py
Normal file
13
src/llm/prompt/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
"""Prompt module for prompt building and normalization."""
|
||||||
|
|
||||||
|
from llm.prompt.builder import PromptBuilder, adapt_messages_for_provider, get_provider_builder
|
||||||
|
from llm.prompt.templates import TEMPLATES, get_template, get_template_or_default
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PromptBuilder",
|
||||||
|
"adapt_messages_for_provider",
|
||||||
|
"get_provider_builder",
|
||||||
|
"TEMPLATES",
|
||||||
|
"get_template",
|
||||||
|
"get_template_or_default",
|
||||||
|
]
|
||||||
101
src/llm/prompt/builder.py
Normal file
101
src/llm/prompt/builder.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
"""Prompt builder for normalizing prompts across providers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from llm.core.types import LLMInput, Message, Role, ToolDefinition
|
||||||
|
from llm.providers.claude import ClaudeProvider
|
||||||
|
from llm.providers.openai import OpenAIProvider
|
||||||
|
from llm.providers.ollama import OllamaProvider
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PromptConfig:
|
||||||
|
system_template: str | None = None
|
||||||
|
user_template: str | None = None
|
||||||
|
include_tools_in_system: bool = True
|
||||||
|
tool_format: str = "native"
|
||||||
|
|
||||||
|
|
||||||
|
class PromptBuilder:
|
||||||
|
def __init__(self, config: PromptConfig | None = None) -> None:
|
||||||
|
self.config = config or PromptConfig()
|
||||||
|
|
||||||
|
def build(self, messages: list[Message], tools: list[ToolDefinition] | None = None) -> list[Message]:
|
||||||
|
if not messages:
|
||||||
|
return []
|
||||||
|
|
||||||
|
result: list[Message] = []
|
||||||
|
system_parts: list[str] = []
|
||||||
|
|
||||||
|
if self.config.system_template:
|
||||||
|
system_parts.append(self.config.system_template)
|
||||||
|
|
||||||
|
if tools and self.config.include_tools_in_system:
|
||||||
|
tools_desc = self._format_tools(tools)
|
||||||
|
system_parts.append(f"\n\n## Available Tools\n{tools_desc}")
|
||||||
|
|
||||||
|
if messages[0].role == Role.SYSTEM:
|
||||||
|
system_parts.insert(0, messages[0].content)
|
||||||
|
result.extend(messages[1:])
|
||||||
|
else:
|
||||||
|
if system_parts:
|
||||||
|
result.insert(0, Message(role=Role.SYSTEM, content="\n\n".join(system_parts)))
|
||||||
|
result.extend(messages)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _format_tools(self, tools: list[ToolDefinition]) -> str:
|
||||||
|
lines = []
|
||||||
|
for tool in tools:
|
||||||
|
lines.append(f"### {tool.name}")
|
||||||
|
lines.append(tool.description)
|
||||||
|
if tool.parameters:
|
||||||
|
lines.append("Parameters:")
|
||||||
|
lines.append(self._format_parameters(tool.parameters))
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def _format_parameters(self, params: dict[str, Any]) -> str:
|
||||||
|
if "properties" not in params:
|
||||||
|
return str(params)
|
||||||
|
lines = []
|
||||||
|
required = params.get("required", [])
|
||||||
|
for name, spec in params["properties"].items():
|
||||||
|
prop_type = spec.get("type", "any")
|
||||||
|
desc = spec.get("description", "")
|
||||||
|
required_mark = "(required)" if name in required else "(optional)"
|
||||||
|
lines.append(f" - {name}: {prop_type} {required_mark} - {desc}")
|
||||||
|
return "\n".join(lines) if lines else str(params)
|
||||||
|
|
||||||
|
|
||||||
|
_PROVIDER_TEMPLATE_MAP: dict[str, dict[str, Any]] = {
|
||||||
|
"claude": {
|
||||||
|
"include_tools_in_system": False,
|
||||||
|
"tool_format": "anthropic",
|
||||||
|
},
|
||||||
|
"openai": {
|
||||||
|
"include_tools_in_system": False,
|
||||||
|
"tool_format": "openai",
|
||||||
|
},
|
||||||
|
"ollama": {
|
||||||
|
"include_tools_in_system": True,
|
||||||
|
"tool_format": "text",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_provider_builder(provider_name: str) -> PromptBuilder:
|
||||||
|
config_dict = _PROVIDER_TEMPLATE_MAP.get(provider_name.lower(), {})
|
||||||
|
config = PromptConfig(**config_dict)
|
||||||
|
return PromptBuilder(config)
|
||||||
|
|
||||||
|
|
||||||
|
def adapt_messages_for_provider(
|
||||||
|
messages: list[Message],
|
||||||
|
provider: str,
|
||||||
|
tools: list[ToolDefinition] | None = None,
|
||||||
|
) -> list[Message]:
|
||||||
|
builder = get_provider_builder(provider)
|
||||||
|
return builder.build(messages, tools)
|
||||||
1
src/llm/prompt/templates/__init__.py
Normal file
1
src/llm/prompt/templates/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Templates module for provider-specific prompt templates
|
||||||
14
src/llm/providers/__init__.py
Normal file
14
src/llm/providers/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
"""Provider adapters for multiple LLM backends."""
|
||||||
|
|
||||||
|
from llm.providers.claude import ClaudeProvider
|
||||||
|
from llm.providers.openai import OpenAIProvider
|
||||||
|
from llm.providers.ollama import OllamaProvider
|
||||||
|
from llm.providers.resolver import get_provider, register_provider
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ClaudeProvider",
|
||||||
|
"OpenAIProvider",
|
||||||
|
"OllamaProvider",
|
||||||
|
"get_provider",
|
||||||
|
"register_provider",
|
||||||
|
]
|
||||||
103
src/llm/providers/claude.py
Normal file
103
src/llm/providers/claude.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"""Claude provider adapter."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from anthropic import Anthropic
|
||||||
|
|
||||||
|
from llm.core.interface import (
|
||||||
|
AuthenticationError,
|
||||||
|
ContextLengthError,
|
||||||
|
LLMProvider,
|
||||||
|
RateLimitError,
|
||||||
|
)
|
||||||
|
from llm.core.types import LLMInput, LLMOutput, Message, ModelInfo, ProviderType, ToolCall
|
||||||
|
|
||||||
|
|
||||||
|
class ClaudeProvider(LLMProvider):
|
||||||
|
provider_type = ProviderType.CLAUDE
|
||||||
|
|
||||||
|
def __init__(self, api_key: str | None = None, base_url: str | None = None) -> None:
|
||||||
|
self.client = Anthropic(api_key=api_key or os.environ.get("ANTHROPIC_API_KEY"), base_url=base_url)
|
||||||
|
self._models = [
|
||||||
|
ModelInfo(
|
||||||
|
name="claude-opus-4-5",
|
||||||
|
provider=ProviderType.CLAUDE,
|
||||||
|
supports_tools=True,
|
||||||
|
supports_vision=True,
|
||||||
|
max_tokens=8192,
|
||||||
|
context_window=200000,
|
||||||
|
),
|
||||||
|
ModelInfo(
|
||||||
|
name="claude-sonnet-4-7",
|
||||||
|
provider=ProviderType.CLAUDE,
|
||||||
|
supports_tools=True,
|
||||||
|
supports_vision=True,
|
||||||
|
max_tokens=8192,
|
||||||
|
context_window=200000,
|
||||||
|
),
|
||||||
|
ModelInfo(
|
||||||
|
name="claude-haiku-4-7",
|
||||||
|
provider=ProviderType.CLAUDE,
|
||||||
|
supports_tools=True,
|
||||||
|
supports_vision=False,
|
||||||
|
max_tokens=4096,
|
||||||
|
context_window=200000,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def generate(self, input: LLMInput) -> LLMOutput:
|
||||||
|
try:
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"model": input.model or "claude-sonnet-4-7",
|
||||||
|
"messages": [msg.to_dict() for msg in input.messages],
|
||||||
|
"temperature": input.temperature,
|
||||||
|
}
|
||||||
|
if input.max_tokens:
|
||||||
|
params["max_tokens"] = input.max_tokens
|
||||||
|
if input.tools:
|
||||||
|
params["tools"] = [tool.to_dict() for tool in input.tools]
|
||||||
|
|
||||||
|
response = self.client.messages.create(**params)
|
||||||
|
|
||||||
|
tool_calls = None
|
||||||
|
if response.content and hasattr(response.content[0], "type"):
|
||||||
|
if response.content[0].type == "tool_use":
|
||||||
|
tool_calls = [
|
||||||
|
ToolCall(
|
||||||
|
id=getattr(response.content[0], "id", ""),
|
||||||
|
name=getattr(response.content[0], "name", ""),
|
||||||
|
arguments=getattr(response.content[0].input, "__dict__", {}),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
return LLMOutput(
|
||||||
|
content=response.content[0].text if response.content else "",
|
||||||
|
tool_calls=tool_calls,
|
||||||
|
model=response.model,
|
||||||
|
usage={
|
||||||
|
"input_tokens": response.usage.input_tokens,
|
||||||
|
"output_tokens": response.usage.output_tokens,
|
||||||
|
},
|
||||||
|
stop_reason=response.stop_reason,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
msg = str(e)
|
||||||
|
if "401" in msg or "authentication" in msg.lower():
|
||||||
|
raise AuthenticationError(msg, provider=ProviderType.CLAUDE) from e
|
||||||
|
if "429" in msg or "rate_limit" in msg.lower():
|
||||||
|
raise RateLimitError(msg, provider=ProviderType.CLAUDE) from e
|
||||||
|
if "context" in msg.lower() and "length" in msg.lower():
|
||||||
|
raise ContextLengthError(msg, provider=ProviderType.CLAUDE) from e
|
||||||
|
raise
|
||||||
|
|
||||||
|
def list_models(self) -> list[ModelInfo]:
|
||||||
|
return self._models.copy()
|
||||||
|
|
||||||
|
def validate_config(self) -> bool:
|
||||||
|
return bool(self.client.api_key)
|
||||||
|
|
||||||
|
def get_default_model(self) -> str:
|
||||||
|
return "claude-sonnet-4-7"
|
||||||
112
src/llm/providers/ollama.py
Normal file
112
src/llm/providers/ollama.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
"""Ollama provider adapter for local models."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from llm.core.interface import (
|
||||||
|
AuthenticationError,
|
||||||
|
ContextLengthError,
|
||||||
|
LLMProvider,
|
||||||
|
RateLimitError,
|
||||||
|
)
|
||||||
|
from llm.core.types import LLMInput, LLMOutput, Message, ModelInfo, ProviderType, ToolCall
|
||||||
|
|
||||||
|
|
||||||
|
class OllamaProvider(LLMProvider):
|
||||||
|
provider_type = ProviderType.OLLAMA
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str | None = None,
|
||||||
|
default_model: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.base_url = base_url or os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||||
|
self.default_model = default_model or os.environ.get("OLLAMA_MODEL", "llama3.2")
|
||||||
|
self._models = [
|
||||||
|
ModelInfo(
|
||||||
|
name="llama3.2",
|
||||||
|
provider=ProviderType.OLLAMA,
|
||||||
|
supports_tools=False,
|
||||||
|
supports_vision=False,
|
||||||
|
max_tokens=4096,
|
||||||
|
context_window=128000,
|
||||||
|
),
|
||||||
|
ModelInfo(
|
||||||
|
name="mistral",
|
||||||
|
provider=ProviderType.OLLAMA,
|
||||||
|
supports_tools=False,
|
||||||
|
supports_vision=False,
|
||||||
|
max_tokens=4096,
|
||||||
|
context_window=8192,
|
||||||
|
),
|
||||||
|
ModelInfo(
|
||||||
|
name="codellama",
|
||||||
|
provider=ProviderType.OLLAMA,
|
||||||
|
supports_tools=False,
|
||||||
|
supports_vision=False,
|
||||||
|
max_tokens=4096,
|
||||||
|
context_window=16384,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def generate(self, input: LLMInput) -> LLMOutput:
|
||||||
|
import urllib.request
|
||||||
|
import json
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = f"{self.base_url}/api/chat"
|
||||||
|
model = input.model or self.default_model
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"model": model,
|
||||||
|
"messages": [msg.to_dict() for msg in input.messages],
|
||||||
|
"stream": False,
|
||||||
|
}
|
||||||
|
if input.temperature != 1.0:
|
||||||
|
payload["options"] = {"temperature": input.temperature}
|
||||||
|
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
|
||||||
|
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as response:
|
||||||
|
result = json.loads(response.read().decode("utf-8"))
|
||||||
|
|
||||||
|
content = result.get("message", {}).get("content", "")
|
||||||
|
|
||||||
|
tool_calls = None
|
||||||
|
if result.get("message", {}).get("tool_calls"):
|
||||||
|
tool_calls = [
|
||||||
|
ToolCall(
|
||||||
|
id=tc.get("id", ""),
|
||||||
|
name=tc.get("function", {}).get("name", ""),
|
||||||
|
arguments=tc.get("function", {}).get("arguments", {}),
|
||||||
|
)
|
||||||
|
for tc in result["message"]["tool_calls"]
|
||||||
|
]
|
||||||
|
|
||||||
|
return LLMOutput(
|
||||||
|
content=content,
|
||||||
|
tool_calls=tool_calls,
|
||||||
|
model=model,
|
||||||
|
stop_reason=result.get("done_reason"),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
msg = str(e)
|
||||||
|
if "401" in msg or "connection" in msg.lower():
|
||||||
|
raise AuthenticationError(f"Ollama connection failed: {msg}", provider=ProviderType.OLLAMA) from e
|
||||||
|
if "429" in msg or "rate_limit" in msg.lower():
|
||||||
|
raise RateLimitError(msg, provider=ProviderType.OLLAMA) from e
|
||||||
|
if "context" in msg.lower() and "length" in msg.lower():
|
||||||
|
raise ContextLengthError(msg, provider=ProviderType.OLLAMA) from e
|
||||||
|
raise
|
||||||
|
|
||||||
|
def list_models(self) -> list[ModelInfo]:
|
||||||
|
return self._models.copy()
|
||||||
|
|
||||||
|
def validate_config(self) -> bool:
|
||||||
|
return bool(self.base_url)
|
||||||
|
|
||||||
|
def get_default_model(self) -> str:
|
||||||
|
return self.default_model
|
||||||
113
src/llm/providers/openai.py
Normal file
113
src/llm/providers/openai.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"""OpenAI provider adapter."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from openai import OpenAI
|
||||||
|
|
||||||
|
from llm.core.interface import (
|
||||||
|
AuthenticationError,
|
||||||
|
ContextLengthError,
|
||||||
|
LLMProvider,
|
||||||
|
RateLimitError,
|
||||||
|
)
|
||||||
|
from llm.core.types import LLMInput, LLMOutput, Message, ModelInfo, ProviderType, ToolCall
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAIProvider(LLMProvider):
|
||||||
|
provider_type = ProviderType.OPENAI
|
||||||
|
|
||||||
|
def __init__(self, api_key: str | None = None, base_url: str | None = None) -> None:
|
||||||
|
self.client = OpenAI(api_key=api_key or os.environ.get("OPENAI_API_KEY"), base_url=base_url)
|
||||||
|
self._models = [
|
||||||
|
ModelInfo(
|
||||||
|
name="gpt-4o",
|
||||||
|
provider=ProviderType.OPENAI,
|
||||||
|
supports_tools=True,
|
||||||
|
supports_vision=True,
|
||||||
|
max_tokens=4096,
|
||||||
|
context_window=128000,
|
||||||
|
),
|
||||||
|
ModelInfo(
|
||||||
|
name="gpt-4o-mini",
|
||||||
|
provider=ProviderType.OPENAI,
|
||||||
|
supports_tools=True,
|
||||||
|
supports_vision=True,
|
||||||
|
max_tokens=4096,
|
||||||
|
context_window=128000,
|
||||||
|
),
|
||||||
|
ModelInfo(
|
||||||
|
name="gpt-4-turbo",
|
||||||
|
provider=ProviderType.OPENAI,
|
||||||
|
supports_tools=True,
|
||||||
|
supports_vision=True,
|
||||||
|
max_tokens=4096,
|
||||||
|
context_window=128000,
|
||||||
|
),
|
||||||
|
ModelInfo(
|
||||||
|
name="gpt-3.5-turbo",
|
||||||
|
provider=ProviderType.OPENAI,
|
||||||
|
supports_tools=True,
|
||||||
|
supports_vision=False,
|
||||||
|
max_tokens=4096,
|
||||||
|
context_window=16385,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def generate(self, input: LLMInput) -> LLMOutput:
|
||||||
|
try:
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"model": input.model or "gpt-4o-mini",
|
||||||
|
"messages": [msg.to_dict() for msg in input.messages],
|
||||||
|
"temperature": input.temperature,
|
||||||
|
}
|
||||||
|
if input.max_tokens:
|
||||||
|
params["max_tokens"] = input.max_tokens
|
||||||
|
if input.tools:
|
||||||
|
params["tools"] = [tool.to_dict() for tool in input.tools]
|
||||||
|
|
||||||
|
response = self.client.chat.completions.create(**params)
|
||||||
|
choice = response.choices[0]
|
||||||
|
|
||||||
|
tool_calls = None
|
||||||
|
if choice.message.tool_calls:
|
||||||
|
tool_calls = [
|
||||||
|
ToolCall(
|
||||||
|
id=tc.id or "",
|
||||||
|
name=tc.function.name,
|
||||||
|
arguments={} if tc.function.arguments == "" else tc.function.arguments,
|
||||||
|
)
|
||||||
|
for tc in choice.message.tool_calls
|
||||||
|
]
|
||||||
|
|
||||||
|
return LLMOutput(
|
||||||
|
content=choice.message.content or "",
|
||||||
|
tool_calls=tool_calls,
|
||||||
|
model=response.model,
|
||||||
|
usage={
|
||||||
|
"prompt_tokens": response.usage.prompt_tokens,
|
||||||
|
"completion_tokens": response.usage.completion_tokens,
|
||||||
|
"total_tokens": response.usage.total_tokens,
|
||||||
|
},
|
||||||
|
stop_reason=choice.finish_reason,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
msg = str(e)
|
||||||
|
if "401" in msg or "authentication" in msg.lower():
|
||||||
|
raise AuthenticationError(msg, provider=ProviderType.OPENAI) from e
|
||||||
|
if "429" in msg or "rate_limit" in msg.lower():
|
||||||
|
raise RateLimitError(msg, provider=ProviderType.OPENAI) from e
|
||||||
|
if "context" in msg.lower() and "length" in msg.lower():
|
||||||
|
raise ContextLengthError(msg, provider=ProviderType.OPENAI) from e
|
||||||
|
raise
|
||||||
|
|
||||||
|
def list_models(self) -> list[ModelInfo]:
|
||||||
|
return self._models.copy()
|
||||||
|
|
||||||
|
def validate_config(self) -> bool:
|
||||||
|
return bool(self.client.api_key)
|
||||||
|
|
||||||
|
def get_default_model(self) -> str:
|
||||||
|
return "gpt-4o-mini"
|
||||||
39
src/llm/providers/resolver.py
Normal file
39
src/llm/providers/resolver.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""Provider factory and resolver."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from llm.core.interface import LLMProvider
|
||||||
|
from llm.core.types import ProviderType
|
||||||
|
from llm.providers.claude import ClaudeProvider
|
||||||
|
from llm.providers.openai import OpenAIProvider
|
||||||
|
from llm.providers.ollama import OllamaProvider
|
||||||
|
|
||||||
|
|
||||||
|
_PROVIDER_MAP: dict[ProviderType, type[LLMProvider]] = {
|
||||||
|
ProviderType.CLAUDE: ClaudeProvider,
|
||||||
|
ProviderType.OPENAI: OpenAIProvider,
|
||||||
|
ProviderType.OLLAMA: OllamaProvider,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_provider(provider_type: ProviderType | str | None = None, **kwargs: str) -> LLMProvider:
|
||||||
|
if provider_type is None:
|
||||||
|
provider_type = os.environ.get("LLM_PROVIDER", "claude").lower()
|
||||||
|
|
||||||
|
if isinstance(provider_type, str):
|
||||||
|
try:
|
||||||
|
provider_type = ProviderType(provider_type)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"Unknown provider type: {provider_type}. Valid types: {[p.value for p in ProviderType]}")
|
||||||
|
|
||||||
|
provider_cls = _PROVIDER_MAP.get(provider_type)
|
||||||
|
if not provider_cls:
|
||||||
|
raise ValueError(f"No provider registered for type: {provider_type}")
|
||||||
|
|
||||||
|
return provider_cls(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def register_provider(provider_type: ProviderType, provider_cls: type[LLMProvider]) -> None:
|
||||||
|
_PROVIDER_MAP[provider_type] = provider_cls
|
||||||
9
src/llm/tools/__init__.py
Normal file
9
src/llm/tools/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
"""Tools module for tool/function calling abstraction."""
|
||||||
|
|
||||||
|
from llm.tools.executor import ReActAgent, ToolExecutor, ToolRegistry
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ToolRegistry",
|
||||||
|
"ToolExecutor",
|
||||||
|
"ReActAgent",
|
||||||
|
]
|
||||||
108
src/llm/tools/executor.py
Normal file
108
src/llm/tools/executor.py
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
"""Tool executor for handling tool calls from LLM responses."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from llm.core.interface import ToolExecutionError
|
||||||
|
from llm.core.types import LLMInput, LLMOutput, Message, Role, ToolCall, ToolDefinition, ToolResult
|
||||||
|
|
||||||
|
|
||||||
|
ToolFunc = Callable[..., Any]
|
||||||
|
|
||||||
|
|
||||||
|
class ToolRegistry:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._tools: dict[str, ToolFunc] = {}
|
||||||
|
self._definitions: dict[str, ToolDefinition] = {}
|
||||||
|
|
||||||
|
def register(self, definition: ToolDefinition, func: ToolFunc) -> None:
|
||||||
|
self._tools[definition.name] = func
|
||||||
|
self._definitions[definition.name] = definition
|
||||||
|
|
||||||
|
def get(self, name: str) -> ToolFunc | None:
|
||||||
|
return self._tools.get(name)
|
||||||
|
|
||||||
|
def get_definition(self, name: str) -> ToolDefinition | None:
|
||||||
|
return self._definitions.get(name)
|
||||||
|
|
||||||
|
def list_tools(self) -> list[ToolDefinition]:
|
||||||
|
return list(self._definitions.values())
|
||||||
|
|
||||||
|
def has(self, name: str) -> bool:
|
||||||
|
return name in self._tools
|
||||||
|
|
||||||
|
|
||||||
|
class ToolExecutor:
|
||||||
|
def __init__(self, registry: ToolRegistry | None = None) -> None:
|
||||||
|
self.registry = registry or ToolRegistry()
|
||||||
|
|
||||||
|
def execute(self, tool_call: ToolCall) -> ToolResult:
|
||||||
|
func = self.registry.get(tool_call.name)
|
||||||
|
if not func:
|
||||||
|
return ToolResult(
|
||||||
|
tool_call_id=tool_call.id,
|
||||||
|
content=f"Error: Tool '{tool_call.name}' not found",
|
||||||
|
is_error=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = func(**tool_call.arguments)
|
||||||
|
content = result if isinstance(result, str) else str(result)
|
||||||
|
return ToolResult(tool_call_id=tool_call.id, content=content)
|
||||||
|
except Exception as e:
|
||||||
|
return ToolResult(
|
||||||
|
tool_call_id=tool_call.id,
|
||||||
|
content=f"Error executing {tool_call.name}: {e}",
|
||||||
|
is_error=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def execute_all(self, tool_calls: list[ToolCall]) -> list[ToolResult]:
|
||||||
|
return [self.execute(tc) for tc in tool_calls]
|
||||||
|
|
||||||
|
|
||||||
|
class ReActAgent:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
provider: Any,
|
||||||
|
executor: ToolExecutor,
|
||||||
|
max_iterations: int = 10,
|
||||||
|
) -> None:
|
||||||
|
self.provider = provider
|
||||||
|
self.executor = executor
|
||||||
|
self.max_iterations = max_iterations
|
||||||
|
|
||||||
|
async def run(self, input: LLMInput) -> LLMOutput:
|
||||||
|
messages = list(input.messages)
|
||||||
|
tools = input.tools or []
|
||||||
|
|
||||||
|
for _ in range(self.max_iterations):
|
||||||
|
input_copy = LLMInput(
|
||||||
|
messages=messages,
|
||||||
|
model=input.model,
|
||||||
|
temperature=input.temperature,
|
||||||
|
max_tokens=input.max_tokens,
|
||||||
|
tools=tools,
|
||||||
|
)
|
||||||
|
|
||||||
|
output = self.provider.generate(input_copy)
|
||||||
|
|
||||||
|
if not output.has_tool_calls:
|
||||||
|
return output
|
||||||
|
|
||||||
|
results = self.executor.execute_all(output.tool_calls)
|
||||||
|
|
||||||
|
for result in results:
|
||||||
|
messages.append(
|
||||||
|
Message(
|
||||||
|
role=Role.TOOL,
|
||||||
|
content=result.content,
|
||||||
|
tool_call_id=result.tool_call_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return LLMOutput(
|
||||||
|
content="Max iterations reached",
|
||||||
|
stop_reason="max_iterations",
|
||||||
|
)
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
4
tests/conftest.py
Normal file
4
tests/conftest.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
61
tests/test_builder.py
Normal file
61
tests/test_builder.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import pytest
|
||||||
|
from llm.core.types import LLMInput, Message, Role, ToolDefinition
|
||||||
|
from llm.prompt import PromptBuilder, adapt_messages_for_provider
|
||||||
|
|
||||||
|
|
||||||
|
class TestPromptBuilder:
|
||||||
|
def test_build_without_system(self):
|
||||||
|
messages = [Message(role=Role.USER, content="Hello")]
|
||||||
|
builder = PromptBuilder()
|
||||||
|
result = builder.build(messages)
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0].role == Role.USER
|
||||||
|
|
||||||
|
def test_build_with_system(self):
|
||||||
|
messages = [
|
||||||
|
Message(role=Role.SYSTEM, content="You are helpful."),
|
||||||
|
Message(role=Role.USER, content="Hello"),
|
||||||
|
]
|
||||||
|
builder = PromptBuilder()
|
||||||
|
result = builder.build(messages)
|
||||||
|
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[0].role == Role.SYSTEM
|
||||||
|
|
||||||
|
def test_build_adds_system_from_config(self):
|
||||||
|
messages = [Message(role=Role.USER, content="Hello")]
|
||||||
|
builder = PromptBuilder(system_template="You are a pirate.")
|
||||||
|
result = builder.build(messages)
|
||||||
|
|
||||||
|
assert len(result) == 2
|
||||||
|
assert "pirate" in result[0].content
|
||||||
|
|
||||||
|
def test_build_with_tools(self):
|
||||||
|
messages = [Message(role=Role.USER, content="Search for something")]
|
||||||
|
tools = [
|
||||||
|
ToolDefinition(name="search", description="Search the web", parameters={}),
|
||||||
|
]
|
||||||
|
builder = PromptBuilder(include_tools_in_system=True)
|
||||||
|
result = builder.build(messages, tools)
|
||||||
|
|
||||||
|
assert len(result) == 2
|
||||||
|
assert "search" in result[0].content
|
||||||
|
assert "Available Tools" in result[0].content
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdaptMessagesForProvider:
|
||||||
|
def test_adapt_for_claude(self):
|
||||||
|
messages = [Message(role=Role.USER, content="Hello")]
|
||||||
|
result = adapt_messages_for_provider(messages, "claude")
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
def test_adapt_for_openai(self):
|
||||||
|
messages = [Message(role=Role.USER, content="Hello")]
|
||||||
|
result = adapt_messages_for_provider(messages, "openai")
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
def test_adapt_for_ollama(self):
|
||||||
|
messages = [Message(role=Role.USER, content="Hello")]
|
||||||
|
result = adapt_messages_for_provider(messages, "ollama")
|
||||||
|
assert len(result) == 1
|
||||||
86
tests/test_executor.py
Normal file
86
tests/test_executor.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import pytest
|
||||||
|
from llm.core.types import ToolCall, ToolDefinition, ToolResult
|
||||||
|
from llm.tools import ToolExecutor, ToolRegistry
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolRegistry:
|
||||||
|
def test_register_and_get(self):
|
||||||
|
registry = ToolRegistry()
|
||||||
|
|
||||||
|
def dummy_func() -> str:
|
||||||
|
return "result"
|
||||||
|
|
||||||
|
tool_def = ToolDefinition(
|
||||||
|
name="dummy",
|
||||||
|
description="A dummy tool",
|
||||||
|
parameters={"type": "object"},
|
||||||
|
)
|
||||||
|
registry.register(tool_def, dummy_func)
|
||||||
|
|
||||||
|
assert registry.has("dummy") is True
|
||||||
|
assert registry.get("dummy") is dummy_func
|
||||||
|
assert registry.get_definition("dummy") == tool_def
|
||||||
|
|
||||||
|
def test_list_tools(self):
|
||||||
|
registry = ToolRegistry()
|
||||||
|
tool_def = ToolDefinition(name="test", description="Test", parameters={})
|
||||||
|
registry.register(tool_def, lambda: None)
|
||||||
|
|
||||||
|
tools = registry.list_tools()
|
||||||
|
assert len(tools) == 1
|
||||||
|
assert tools[0].name == "test"
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolExecutor:
|
||||||
|
def test_execute_success(self):
|
||||||
|
registry = ToolRegistry()
|
||||||
|
|
||||||
|
def search(query: str) -> str:
|
||||||
|
return f"Results for: {query}"
|
||||||
|
|
||||||
|
registry.register(
|
||||||
|
ToolDefinition(
|
||||||
|
name="search",
|
||||||
|
description="Search",
|
||||||
|
parameters={"type": "object", "properties": {"query": {"type": "string"}}},
|
||||||
|
),
|
||||||
|
search,
|
||||||
|
)
|
||||||
|
|
||||||
|
executor = ToolExecutor(registry)
|
||||||
|
result = executor.execute(ToolCall(id="1", name="search", arguments={"query": "test"}))
|
||||||
|
|
||||||
|
assert result.tool_call_id == "1"
|
||||||
|
assert result.content == "Results for: test"
|
||||||
|
assert result.is_error is False
|
||||||
|
|
||||||
|
def test_execute_unknown_tool(self):
|
||||||
|
registry = ToolRegistry()
|
||||||
|
executor = ToolExecutor(registry)
|
||||||
|
|
||||||
|
result = executor.execute(ToolCall(id="1", name="unknown", arguments={}))
|
||||||
|
|
||||||
|
assert result.is_error is True
|
||||||
|
assert "not found" in result.content
|
||||||
|
|
||||||
|
def test_execute_all(self):
|
||||||
|
registry = ToolRegistry()
|
||||||
|
|
||||||
|
def tool1() -> str:
|
||||||
|
return "result1"
|
||||||
|
|
||||||
|
def tool2() -> str:
|
||||||
|
return "result2"
|
||||||
|
|
||||||
|
registry.register(ToolDefinition(name="t1", description="", parameters={}), tool1)
|
||||||
|
registry.register(ToolDefinition(name="t2", description="", parameters={}), tool2)
|
||||||
|
|
||||||
|
executor = ToolExecutor(registry)
|
||||||
|
results = executor.execute_all([
|
||||||
|
ToolCall(id="1", name="t1", arguments={}),
|
||||||
|
ToolCall(id="2", name="t2", arguments={}),
|
||||||
|
])
|
||||||
|
|
||||||
|
assert len(results) == 2
|
||||||
|
assert results[0].content == "result1"
|
||||||
|
assert results[1].content == "result2"
|
||||||
28
tests/test_resolver.py
Normal file
28
tests/test_resolver.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import pytest
|
||||||
|
from llm.core.types import ProviderType
|
||||||
|
from llm.providers import ClaudeProvider, OpenAIProvider, OllamaProvider, get_provider
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetProvider:
|
||||||
|
def test_get_claude_provider(self):
|
||||||
|
provider = get_provider("claude")
|
||||||
|
assert isinstance(provider, ClaudeProvider)
|
||||||
|
assert provider.provider_type == ProviderType.CLAUDE
|
||||||
|
|
||||||
|
def test_get_openai_provider(self):
|
||||||
|
provider = get_provider("openai")
|
||||||
|
assert isinstance(provider, OpenAIProvider)
|
||||||
|
assert provider.provider_type == ProviderType.OPENAI
|
||||||
|
|
||||||
|
def test_get_ollama_provider(self):
|
||||||
|
provider = get_provider("ollama")
|
||||||
|
assert isinstance(provider, OllamaProvider)
|
||||||
|
assert provider.provider_type == ProviderType.OLLAMA
|
||||||
|
|
||||||
|
def test_get_provider_by_enum(self):
|
||||||
|
provider = get_provider(ProviderType.CLAUDE)
|
||||||
|
assert isinstance(provider, ClaudeProvider)
|
||||||
|
|
||||||
|
def test_invalid_provider_raises(self):
|
||||||
|
with pytest.raises(ValueError, match="Unknown provider type"):
|
||||||
|
get_provider("invalid")
|
||||||
117
tests/test_types.py
Normal file
117
tests/test_types.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import pytest
|
||||||
|
from llm.core.types import (
|
||||||
|
LLMInput,
|
||||||
|
LLMOutput,
|
||||||
|
Message,
|
||||||
|
ModelInfo,
|
||||||
|
ProviderType,
|
||||||
|
Role,
|
||||||
|
ToolCall,
|
||||||
|
ToolDefinition,
|
||||||
|
ToolResult,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRole:
|
||||||
|
def test_role_values(self):
|
||||||
|
assert Role.SYSTEM.value == "system"
|
||||||
|
assert Role.USER.value == "user"
|
||||||
|
assert Role.ASSISTANT.value == "assistant"
|
||||||
|
assert Role.TOOL.value == "tool"
|
||||||
|
|
||||||
|
|
||||||
|
class TestProviderType:
|
||||||
|
def test_provider_values(self):
|
||||||
|
assert ProviderType.CLAUDE.value == "claude"
|
||||||
|
assert ProviderType.OPENAI.value == "openai"
|
||||||
|
assert ProviderType.OLLAMA.value == "ollama"
|
||||||
|
|
||||||
|
|
||||||
|
class TestMessage:
|
||||||
|
def test_create_message(self):
|
||||||
|
msg = Message(role=Role.USER, content="Hello")
|
||||||
|
assert msg.role == Role.USER
|
||||||
|
assert msg.content == "Hello"
|
||||||
|
assert msg.name is None
|
||||||
|
assert msg.tool_call_id is None
|
||||||
|
|
||||||
|
def test_message_to_dict(self):
|
||||||
|
msg = Message(role=Role.USER, content="Hello", name="test")
|
||||||
|
result = msg.to_dict()
|
||||||
|
assert result["role"] == "user"
|
||||||
|
assert result["content"] == "Hello"
|
||||||
|
assert result["name"] == "test"
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolDefinition:
|
||||||
|
def test_create_tool(self):
|
||||||
|
tool = ToolDefinition(
|
||||||
|
name="search",
|
||||||
|
description="Search the web",
|
||||||
|
parameters={"type": "object", "properties": {}},
|
||||||
|
)
|
||||||
|
assert tool.name == "search"
|
||||||
|
assert tool.strict is True
|
||||||
|
|
||||||
|
def test_tool_to_dict(self):
|
||||||
|
tool = ToolDefinition(
|
||||||
|
name="search",
|
||||||
|
description="Search",
|
||||||
|
parameters={"type": "object"},
|
||||||
|
)
|
||||||
|
result = tool.to_dict()
|
||||||
|
assert result["name"] == "search"
|
||||||
|
assert result["strict"] is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolCall:
|
||||||
|
def test_create_tool_call(self):
|
||||||
|
tc = ToolCall(id="1", name="search", arguments={"query": "test"})
|
||||||
|
assert tc.id == "1"
|
||||||
|
assert tc.name == "search"
|
||||||
|
assert tc.arguments == {"query": "test"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestToolResult:
|
||||||
|
def test_create_tool_result(self):
|
||||||
|
result = ToolResult(tool_call_id="1", content="result")
|
||||||
|
assert result.tool_call_id == "1"
|
||||||
|
assert result.is_error is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestLLMInput:
|
||||||
|
def test_create_input(self):
|
||||||
|
messages = [Message(role=Role.USER, content="Hello")]
|
||||||
|
input_obj = LLMInput(messages=messages, temperature=0.7)
|
||||||
|
assert len(input_obj.messages) == 1
|
||||||
|
assert input_obj.temperature == 0.7
|
||||||
|
|
||||||
|
def test_input_to_dict(self):
|
||||||
|
messages = [Message(role=Role.USER, content="Hello")]
|
||||||
|
input_obj = LLMInput(messages=messages)
|
||||||
|
result = input_obj.to_dict()
|
||||||
|
assert "messages" in result
|
||||||
|
assert result["temperature"] == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestLLMOutput:
|
||||||
|
def test_create_output(self):
|
||||||
|
output = LLMOutput(content="Hello!")
|
||||||
|
assert output.content == "Hello!"
|
||||||
|
assert output.has_tool_calls is False
|
||||||
|
|
||||||
|
def test_output_with_tool_calls(self):
|
||||||
|
tc = ToolCall(id="1", name="search", arguments={})
|
||||||
|
output = LLMOutput(content="", tool_calls=[tc])
|
||||||
|
assert output.has_tool_calls is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestModelInfo:
|
||||||
|
def test_create_model_info(self):
|
||||||
|
info = ModelInfo(
|
||||||
|
name="gpt-4",
|
||||||
|
provider=ProviderType.OPENAI,
|
||||||
|
)
|
||||||
|
assert info.name == "gpt-4"
|
||||||
|
assert info.supports_tools is True
|
||||||
|
assert info.supports_vision is False
|
||||||
Reference in New Issue
Block a user