hexastack-ai 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hexastack_ai-0.1.0/PKG-INFO +168 -0
- hexastack_ai-0.1.0/README.md +152 -0
- hexastack_ai-0.1.0/pyproject.toml +53 -0
- hexastack_ai-0.1.0/pyproject.toml.orig +50 -0
- hexastack_ai-0.1.0/src/hexastack_ai/__init__.py +7 -0
- hexastack_ai-0.1.0/src/hexastack_ai/adapters/__init__.py +7 -0
- hexastack_ai-0.1.0/src/hexastack_ai/adapters/litellm.py +226 -0
- hexastack_ai-0.1.0/src/hexastack_ai/adapters/pydantic_ai.py +66 -0
- hexastack_ai-0.1.0/src/hexastack_ai/domain/__init__.py +13 -0
- hexastack_ai-0.1.0/src/hexastack_ai/domain/exceptions.py +53 -0
- hexastack_ai-0.1.0/src/hexastack_ai/infra/__init__.py +27 -0
- hexastack_ai-0.1.0/src/hexastack_ai/infra/bootstrap.py +80 -0
- hexastack_ai-0.1.0/src/hexastack_ai/infra/config.py +110 -0
- hexastack_ai-0.1.0/src/hexastack_ai/infra/tools.py +111 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hexastack-ai
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: AI engine, LLM provider integration (LiteLLM, Instructor, PydanticAI), and CQRS agent reflection for Hexastack
|
|
5
|
+
Author: Richard West
|
|
6
|
+
Author-email: Richard West <dopplereffect.us@gmail.com>
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
Requires-Dist: hexastack-core
|
|
9
|
+
Requires-Dist: hexastack-cqrs
|
|
10
|
+
Requires-Dist: instructor>=1.7.0
|
|
11
|
+
Requires-Dist: litellm>=1.50.0
|
|
12
|
+
Requires-Dist: pydantic>=2.10.0
|
|
13
|
+
Requires-Dist: pydantic-ai>=0.0.24
|
|
14
|
+
Requires-Python: >=3.13
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+

|
|
18
|
+
|
|
19
|
+
# hexastack-ai
|
|
20
|
+
|
|
21
|
+
AI engine, LLM provider integration (LiteLLM, Instructor, PydanticAI), and CQRS agent tool reflection for the **Hexastack** hexagonal architecture framework.
|
|
22
|
+
|
|
23
|
+
[](https://pypi.org/project/hexastack-ai/)
|
|
24
|
+
[](https://www.python.org/downloads/)
|
|
25
|
+
[](https://codecov.io/github/TheTrueSCU/hexastack)
|
|
26
|
+
[](../../LICENSE)
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## 1. Overview
|
|
31
|
+
|
|
32
|
+
`hexastack-ai` integrates an agnostic, production-grade AI stack directly into the Hexastack architecture:
|
|
33
|
+
- **LiteLLM**: Unified driver abstraction across 100+ LLM providers (OpenAI, Anthropic Claude, Google Gemini, Ollama, Groq, Bedrock).
|
|
34
|
+
- **Instructor**: Self-correcting structured output validation returning strongly typed Pydantic models.
|
|
35
|
+
- **PydanticAI**: Type-safe agent loop orchestration and tool execution.
|
|
36
|
+
- **CQRS Tool Reflection**: Automatically turns Hexastack CQRS Commands and Queries into callable AI Agent tools.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## 2. Architecture & Relationships
|
|
41
|
+
|
|
42
|
+
```mermaid
|
|
43
|
+
graph TD
|
|
44
|
+
subgraph Core ["hexastack-core"]
|
|
45
|
+
PORT["LlmProviderPort"]
|
|
46
|
+
MEM["InMemoryLlmProvider"]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
subgraph AI ["hexastack-ai"]
|
|
50
|
+
BOOT["AiBootstrapper (order=18)"]
|
|
51
|
+
ADAPTER["LiteLlmAdapter"]
|
|
52
|
+
TOOLS["create_cqrs_agent / create_tool_for_message"]
|
|
53
|
+
AGENT_ADAPTER["PydanticAiAgentAdapter"]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
subgraph CQRS ["hexastack-cqrs"]
|
|
57
|
+
PIPELINE["ExecutionPipeline"]
|
|
58
|
+
CMDS["Commands & Queries"]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
subgraph UpstreamAI ["Agnostic AI Stack"]
|
|
62
|
+
LITE["LiteLLM (100+ Providers)"]
|
|
63
|
+
INST["Instructor (Schema Validation)"]
|
|
64
|
+
PY_AI["PydanticAI (Agent Execution)"]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
BOOT -->|binds into DI| PORT
|
|
68
|
+
ADAPTER -. implements .-> PORT
|
|
69
|
+
ADAPTER --> LITE
|
|
70
|
+
ADAPTER --> INST
|
|
71
|
+
TOOLS --> PIPELINE
|
|
72
|
+
TOOLS --> PY_AI
|
|
73
|
+
AGENT_ADAPTER --> PY_AI
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## 3. Installation
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
# Standalone install
|
|
82
|
+
pip install hexastack-ai
|
|
83
|
+
|
|
84
|
+
# Via umbrella package
|
|
85
|
+
pip install "hexastack[ai]"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## 4. Configuration Reference
|
|
91
|
+
|
|
92
|
+
```toml
|
|
93
|
+
[hexastack.ai]
|
|
94
|
+
# Provider: "memory" (default for testing), "litellm", "openai", "anthropic", "gemini", "ollama"
|
|
95
|
+
provider = "litellm"
|
|
96
|
+
model = "gpt-4o-mini"
|
|
97
|
+
temperature = 0.2
|
|
98
|
+
max_tokens = 2048
|
|
99
|
+
api_key = "sk-..." # Or set standard env var OPENAI_API_KEY / ANTHROPIC_API_KEY
|
|
100
|
+
|
|
101
|
+
# LiteLLM Dialect Settings
|
|
102
|
+
[hexastack.ai.litellm]
|
|
103
|
+
drop_params = true
|
|
104
|
+
num_retries = 3
|
|
105
|
+
timeout = 60.0
|
|
106
|
+
api_base = "http://localhost:4000" # Optional LiteLLM proxy URL
|
|
107
|
+
|
|
108
|
+
# Ollama Local Dialect Settings
|
|
109
|
+
[hexastack.ai.ollama]
|
|
110
|
+
base_url = "http://localhost:11434"
|
|
111
|
+
|
|
112
|
+
# PydanticAI Agent Settings
|
|
113
|
+
[hexastack.ai.agent]
|
|
114
|
+
max_turns = 10
|
|
115
|
+
system_prompt = "You are a helpful AI assistant."
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## 5. Usage Examples
|
|
121
|
+
|
|
122
|
+
### 1. Structured Output Extraction
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from pydantic import BaseModel
|
|
126
|
+
from hexastack_core.ports.ai import LlmProviderPort
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class InvoiceDTO(BaseModel):
|
|
130
|
+
customer_id: str
|
|
131
|
+
total_amount: float
|
|
132
|
+
items: list[str]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def extract_invoice(llm: LlmProviderPort, text: str) -> InvoiceDTO:
|
|
136
|
+
return llm.generate_structured(
|
|
137
|
+
prompt=f"Extract invoice details from: {text}",
|
|
138
|
+
response_schema=InvoiceDTO,
|
|
139
|
+
)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### 2. Auto-Reflecting CQRS Commands as Agent Tools
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from hexastack_core.domain import Command, Query
|
|
146
|
+
from hexastack_ai.infra.tools import create_cqrs_agent
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class CancelSubscriptionCommand(Command):
|
|
150
|
+
user_id: str
|
|
151
|
+
reason: str
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class GetUserPlanQuery(Query[str]):
|
|
155
|
+
user_id: str
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# Create PydanticAI agent with CQRS handlers as native tools:
|
|
159
|
+
agent = create_cqrs_agent(
|
|
160
|
+
pipeline=runtime.pipeline,
|
|
161
|
+
messages=[CancelSubscriptionCommand, GetUserPlanQuery],
|
|
162
|
+
model="anthropic/claude-3-5-sonnet",
|
|
163
|
+
system_prompt="You are a customer support agent.",
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# Agent selects appropriate tools and executes them via Hexastack ExecutionPipeline:
|
|
167
|
+
result = agent.run_sync("Cancel subscription for user 123 due to pricing.")
|
|
168
|
+
```
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
3
|
+
# hexastack-ai
|
|
4
|
+
|
|
5
|
+
AI engine, LLM provider integration (LiteLLM, Instructor, PydanticAI), and CQRS agent tool reflection for the **Hexastack** hexagonal architecture framework.
|
|
6
|
+
|
|
7
|
+
[](https://pypi.org/project/hexastack-ai/)
|
|
8
|
+
[](https://www.python.org/downloads/)
|
|
9
|
+
[](https://codecov.io/github/TheTrueSCU/hexastack)
|
|
10
|
+
[](../../LICENSE)
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## 1. Overview
|
|
15
|
+
|
|
16
|
+
`hexastack-ai` integrates an agnostic, production-grade AI stack directly into the Hexastack architecture:
|
|
17
|
+
- **LiteLLM**: Unified driver abstraction across 100+ LLM providers (OpenAI, Anthropic Claude, Google Gemini, Ollama, Groq, Bedrock).
|
|
18
|
+
- **Instructor**: Self-correcting structured output validation returning strongly typed Pydantic models.
|
|
19
|
+
- **PydanticAI**: Type-safe agent loop orchestration and tool execution.
|
|
20
|
+
- **CQRS Tool Reflection**: Automatically turns Hexastack CQRS Commands and Queries into callable AI Agent tools.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## 2. Architecture & Relationships
|
|
25
|
+
|
|
26
|
+
```mermaid
|
|
27
|
+
graph TD
|
|
28
|
+
subgraph Core ["hexastack-core"]
|
|
29
|
+
PORT["LlmProviderPort"]
|
|
30
|
+
MEM["InMemoryLlmProvider"]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
subgraph AI ["hexastack-ai"]
|
|
34
|
+
BOOT["AiBootstrapper (order=18)"]
|
|
35
|
+
ADAPTER["LiteLlmAdapter"]
|
|
36
|
+
TOOLS["create_cqrs_agent / create_tool_for_message"]
|
|
37
|
+
AGENT_ADAPTER["PydanticAiAgentAdapter"]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
subgraph CQRS ["hexastack-cqrs"]
|
|
41
|
+
PIPELINE["ExecutionPipeline"]
|
|
42
|
+
CMDS["Commands & Queries"]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
subgraph UpstreamAI ["Agnostic AI Stack"]
|
|
46
|
+
LITE["LiteLLM (100+ Providers)"]
|
|
47
|
+
INST["Instructor (Schema Validation)"]
|
|
48
|
+
PY_AI["PydanticAI (Agent Execution)"]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
BOOT -->|binds into DI| PORT
|
|
52
|
+
ADAPTER -. implements .-> PORT
|
|
53
|
+
ADAPTER --> LITE
|
|
54
|
+
ADAPTER --> INST
|
|
55
|
+
TOOLS --> PIPELINE
|
|
56
|
+
TOOLS --> PY_AI
|
|
57
|
+
AGENT_ADAPTER --> PY_AI
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## 3. Installation
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
# Standalone install
|
|
66
|
+
pip install hexastack-ai
|
|
67
|
+
|
|
68
|
+
# Via umbrella package
|
|
69
|
+
pip install "hexastack[ai]"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## 4. Configuration Reference
|
|
75
|
+
|
|
76
|
+
```toml
|
|
77
|
+
[hexastack.ai]
|
|
78
|
+
# Provider: "memory" (default for testing), "litellm", "openai", "anthropic", "gemini", "ollama"
|
|
79
|
+
provider = "litellm"
|
|
80
|
+
model = "gpt-4o-mini"
|
|
81
|
+
temperature = 0.2
|
|
82
|
+
max_tokens = 2048
|
|
83
|
+
api_key = "sk-..." # Or set standard env var OPENAI_API_KEY / ANTHROPIC_API_KEY
|
|
84
|
+
|
|
85
|
+
# LiteLLM Dialect Settings
|
|
86
|
+
[hexastack.ai.litellm]
|
|
87
|
+
drop_params = true
|
|
88
|
+
num_retries = 3
|
|
89
|
+
timeout = 60.0
|
|
90
|
+
api_base = "http://localhost:4000" # Optional LiteLLM proxy URL
|
|
91
|
+
|
|
92
|
+
# Ollama Local Dialect Settings
|
|
93
|
+
[hexastack.ai.ollama]
|
|
94
|
+
base_url = "http://localhost:11434"
|
|
95
|
+
|
|
96
|
+
# PydanticAI Agent Settings
|
|
97
|
+
[hexastack.ai.agent]
|
|
98
|
+
max_turns = 10
|
|
99
|
+
system_prompt = "You are a helpful AI assistant."
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## 5. Usage Examples
|
|
105
|
+
|
|
106
|
+
### 1. Structured Output Extraction
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from pydantic import BaseModel
|
|
110
|
+
from hexastack_core.ports.ai import LlmProviderPort
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class InvoiceDTO(BaseModel):
|
|
114
|
+
customer_id: str
|
|
115
|
+
total_amount: float
|
|
116
|
+
items: list[str]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def extract_invoice(llm: LlmProviderPort, text: str) -> InvoiceDTO:
|
|
120
|
+
return llm.generate_structured(
|
|
121
|
+
prompt=f"Extract invoice details from: {text}",
|
|
122
|
+
response_schema=InvoiceDTO,
|
|
123
|
+
)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### 2. Auto-Reflecting CQRS Commands as Agent Tools
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
from hexastack_core.domain import Command, Query
|
|
130
|
+
from hexastack_ai.infra.tools import create_cqrs_agent
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class CancelSubscriptionCommand(Command):
|
|
134
|
+
user_id: str
|
|
135
|
+
reason: str
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class GetUserPlanQuery(Query[str]):
|
|
139
|
+
user_id: str
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# Create PydanticAI agent with CQRS handlers as native tools:
|
|
143
|
+
agent = create_cqrs_agent(
|
|
144
|
+
pipeline=runtime.pipeline,
|
|
145
|
+
messages=[CancelSubscriptionCommand, GetUserPlanQuery],
|
|
146
|
+
model="anthropic/claude-3-5-sonnet",
|
|
147
|
+
system_prompt="You are a customer support agent.",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Agent selects appropriate tools and executes them via Hexastack ExecutionPipeline:
|
|
151
|
+
result = agent.run_sync("Cancel subscription for user 123 due to pricing.")
|
|
152
|
+
```
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "hexastack-ai"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "AI engine, LLM provider integration (LiteLLM, Instructor, PydanticAI), and CQRS agent reflection for Hexastack"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "Apache-2.0"
|
|
7
|
+
requires-python = ">=3.13"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"hexastack-core",
|
|
10
|
+
"hexastack-cqrs",
|
|
11
|
+
"instructor>=1.7.0",
|
|
12
|
+
"litellm>=1.50.0",
|
|
13
|
+
"pydantic>=2.10.0",
|
|
14
|
+
"pydantic-ai>=0.0.24",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[[project.authors]]
|
|
18
|
+
name = "Richard West"
|
|
19
|
+
email = "dopplereffect.us@gmail.com"
|
|
20
|
+
|
|
21
|
+
[project.entry-points."hexastack.bootstrappers"]
|
|
22
|
+
ai = "hexastack_ai.infra.bootstrap:AiBootstrapper"
|
|
23
|
+
|
|
24
|
+
[build-system]
|
|
25
|
+
requires = ["uv_build>=0.12.3,<0.13.0"]
|
|
26
|
+
build-backend = "uv_build"
|
|
27
|
+
|
|
28
|
+
[tool.uv.sources.hexastack-core]
|
|
29
|
+
workspace = true
|
|
30
|
+
|
|
31
|
+
[tool.uv.sources.hexastack-cqrs]
|
|
32
|
+
workspace = true
|
|
33
|
+
|
|
34
|
+
[tool.importlinter]
|
|
35
|
+
root_packages = ["hexastack_ai"]
|
|
36
|
+
|
|
37
|
+
[[tool.importlinter.contracts]]
|
|
38
|
+
name = "Hexagonal architecture layer hierarchy"
|
|
39
|
+
type = "layers"
|
|
40
|
+
containers = ["hexastack_ai"]
|
|
41
|
+
layers = [
|
|
42
|
+
"adapters",
|
|
43
|
+
"domain",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[[tool.importlinter.contracts]]
|
|
47
|
+
name = "Forbidden imports for domain"
|
|
48
|
+
type = "forbidden"
|
|
49
|
+
source_modules = ["hexastack_ai.domain"]
|
|
50
|
+
forbidden_modules = [
|
|
51
|
+
"hexastack_ai.adapters",
|
|
52
|
+
"hexastack_ai.infra",
|
|
53
|
+
]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "hexastack-ai"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "AI engine, LLM provider integration (LiteLLM, Instructor, PydanticAI), and CQRS agent reflection for Hexastack"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "Apache-2.0"
|
|
7
|
+
authors = [
|
|
8
|
+
{ name = "Richard West", email = "dopplereffect.us@gmail.com" }
|
|
9
|
+
]
|
|
10
|
+
requires-python = ">=3.13"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"hexastack-core",
|
|
13
|
+
"hexastack-cqrs",
|
|
14
|
+
"instructor>=1.7.0",
|
|
15
|
+
"litellm>=1.50.0",
|
|
16
|
+
"pydantic>=2.10.0",
|
|
17
|
+
"pydantic-ai>=0.0.24",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.entry-points."hexastack.bootstrappers"]
|
|
21
|
+
ai = "hexastack_ai.infra.bootstrap:AiBootstrapper"
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["uv_build>=0.12.3,<0.13.0"]
|
|
25
|
+
build-backend = "uv_build"
|
|
26
|
+
|
|
27
|
+
[tool.uv.sources]
|
|
28
|
+
hexastack-core = { workspace = true }
|
|
29
|
+
hexastack-cqrs = { workspace = true }
|
|
30
|
+
|
|
31
|
+
[tool.importlinter]
|
|
32
|
+
root_packages = ["hexastack_ai"]
|
|
33
|
+
|
|
34
|
+
[[tool.importlinter.contracts]]
|
|
35
|
+
name = "Hexagonal architecture layer hierarchy"
|
|
36
|
+
type = "layers"
|
|
37
|
+
containers = ["hexastack_ai"]
|
|
38
|
+
layers = [
|
|
39
|
+
"adapters",
|
|
40
|
+
"domain",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[[tool.importlinter.contracts]]
|
|
44
|
+
name = "Forbidden imports for domain"
|
|
45
|
+
type = "forbidden"
|
|
46
|
+
source_modules = ["hexastack_ai.domain"]
|
|
47
|
+
forbidden_modules = [
|
|
48
|
+
"hexastack_ai.adapters",
|
|
49
|
+
"hexastack_ai.infra",
|
|
50
|
+
]
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
from hexastack_ai.domain.exceptions import (
|
|
7
|
+
LlmProviderError,
|
|
8
|
+
StructuredOutputParsingError,
|
|
9
|
+
)
|
|
10
|
+
from hexastack_ai.infra.config import HexastackAiConfig
|
|
11
|
+
from hexastack_core.ports.ai import LlmProviderPort
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LiteLlmAdapter(LlmProviderPort):
|
|
15
|
+
"""LiteLLM and Instructor adapter implementing LlmProviderPort.
|
|
16
|
+
|
|
17
|
+
Notes/Architectural Intent:
|
|
18
|
+
Wraps LiteLLM to provide vendor-agnostic LLM calls across 100+ providers
|
|
19
|
+
(OpenAI, Claude, Gemini, Ollama, Bedrock) while leveraging Instructor
|
|
20
|
+
for self-correcting structured output validation.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, config: HexastackAiConfig | None = None) -> None:
|
|
24
|
+
"""Initialize LiteLlmAdapter with configuration.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
config: HexastackAiConfig instance.
|
|
28
|
+
"""
|
|
29
|
+
self._config = config or HexastackAiConfig()
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def _resolved_model(self) -> str:
|
|
33
|
+
"""Resolve full model identifier ensuring provider prefix like 'gemini/' is handled."""
|
|
34
|
+
model = self._config.model
|
|
35
|
+
provider = self._config.provider.lower()
|
|
36
|
+
if provider == "gemini" and not model.startswith("gemini/"):
|
|
37
|
+
return f"gemini/{model}"
|
|
38
|
+
if provider == "anthropic" and not model.startswith("anthropic/"):
|
|
39
|
+
return f"anthropic/{model}"
|
|
40
|
+
if provider == "ollama" and not model.startswith("ollama/"):
|
|
41
|
+
return f"ollama/{model}"
|
|
42
|
+
return model
|
|
43
|
+
|
|
44
|
+
def generate_structured[T: BaseModel](
|
|
45
|
+
self, prompt: str, response_schema: type[T]
|
|
46
|
+
) -> T:
|
|
47
|
+
"""Generate structured Pydantic output using Instructor over LiteLLM.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
prompt: The user prompt text.
|
|
51
|
+
response_schema: Target Pydantic model class.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
Validated instance of response_schema.
|
|
55
|
+
|
|
56
|
+
Raises:
|
|
57
|
+
StructuredOutputParsingError: If schema validation fails.
|
|
58
|
+
LlmProviderError: If API call fails.
|
|
59
|
+
"""
|
|
60
|
+
import instructor
|
|
61
|
+
import litellm
|
|
62
|
+
from instructor.core import InstructorRetryException
|
|
63
|
+
|
|
64
|
+
client = instructor.from_litellm(litellm.completion)
|
|
65
|
+
|
|
66
|
+
kwargs: dict[str, Any] = {
|
|
67
|
+
"model": self._resolved_model,
|
|
68
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
69
|
+
"response_model": response_schema,
|
|
70
|
+
"temperature": self._config.temperature,
|
|
71
|
+
"max_tokens": self._config.max_tokens,
|
|
72
|
+
}
|
|
73
|
+
if self._config.api_key:
|
|
74
|
+
kwargs["api_key"] = self._config.api_key
|
|
75
|
+
if self._config.litellm.api_base:
|
|
76
|
+
kwargs["api_base"] = self._config.litellm.api_base
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
return client.chat.completions.create(**kwargs)
|
|
80
|
+
except InstructorRetryException as exc:
|
|
81
|
+
raise StructuredOutputParsingError(
|
|
82
|
+
f"Failed to generate structured {response_schema.__name__}: {exc}"
|
|
83
|
+
) from exc
|
|
84
|
+
except Exception as exc:
|
|
85
|
+
raise LlmProviderError(
|
|
86
|
+
str(exc),
|
|
87
|
+
provider=self._config.provider,
|
|
88
|
+
model=self._config.model,
|
|
89
|
+
) from exc
|
|
90
|
+
|
|
91
|
+
async def generate_structured_async[T: BaseModel](
|
|
92
|
+
self, prompt: str, response_schema: type[T]
|
|
93
|
+
) -> T:
|
|
94
|
+
"""Asynchronously generate structured Pydantic output using Instructor over LiteLLM."""
|
|
95
|
+
import instructor
|
|
96
|
+
import litellm
|
|
97
|
+
from instructor.core import InstructorRetryException
|
|
98
|
+
|
|
99
|
+
client = instructor.from_litellm(litellm.acompletion)
|
|
100
|
+
|
|
101
|
+
kwargs: dict[str, Any] = {
|
|
102
|
+
"model": self._resolved_model,
|
|
103
|
+
"messages": [{"role": "user", "content": prompt}],
|
|
104
|
+
"response_model": response_schema,
|
|
105
|
+
"temperature": self._config.temperature,
|
|
106
|
+
"max_tokens": self._config.max_tokens,
|
|
107
|
+
}
|
|
108
|
+
if self._config.api_key:
|
|
109
|
+
kwargs["api_key"] = self._config.api_key
|
|
110
|
+
if self._config.litellm.api_base:
|
|
111
|
+
kwargs["api_base"] = self._config.litellm.api_base
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
raw_result: Any = client.chat.completions.create(**kwargs)
|
|
115
|
+
if inspect.isawaitable(raw_result):
|
|
116
|
+
return await raw_result
|
|
117
|
+
return raw_result
|
|
118
|
+
except InstructorRetryException as exc:
|
|
119
|
+
raise StructuredOutputParsingError(
|
|
120
|
+
f"Failed to generate structured {response_schema.__name__}: {exc}"
|
|
121
|
+
) from exc
|
|
122
|
+
except Exception as exc:
|
|
123
|
+
raise LlmProviderError(
|
|
124
|
+
str(exc),
|
|
125
|
+
provider=self._config.provider,
|
|
126
|
+
model=self._config.model,
|
|
127
|
+
) from exc
|
|
128
|
+
|
|
129
|
+
def generate_text(self, prompt: str, system_prompt: str | None = None) -> str:
|
|
130
|
+
"""Generate unstructured text from a prompt via LiteLLM.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
prompt: The user prompt text.
|
|
134
|
+
system_prompt: Optional system instruction prompt.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Generated response text.
|
|
138
|
+
|
|
139
|
+
Raises:
|
|
140
|
+
LlmProviderError: If the upstream provider request fails.
|
|
141
|
+
"""
|
|
142
|
+
import litellm
|
|
143
|
+
|
|
144
|
+
messages: list[dict[str, str]] = []
|
|
145
|
+
if system_prompt or self._config.agent.system_prompt:
|
|
146
|
+
sys = system_prompt or self._config.agent.system_prompt
|
|
147
|
+
if sys:
|
|
148
|
+
messages.append({"role": "system", "content": sys})
|
|
149
|
+
messages.append({"role": "user", "content": prompt})
|
|
150
|
+
|
|
151
|
+
kwargs: dict[str, Any] = {
|
|
152
|
+
"model": self._resolved_model,
|
|
153
|
+
"messages": messages,
|
|
154
|
+
"temperature": self._config.temperature,
|
|
155
|
+
"max_tokens": self._config.max_tokens,
|
|
156
|
+
"drop_params": self._config.litellm.drop_params,
|
|
157
|
+
"num_retries": self._config.litellm.num_retries,
|
|
158
|
+
"timeout": self._config.litellm.timeout,
|
|
159
|
+
}
|
|
160
|
+
if self._config.api_key:
|
|
161
|
+
kwargs["api_key"] = self._config.api_key
|
|
162
|
+
if self._config.litellm.api_base:
|
|
163
|
+
kwargs["api_base"] = self._config.litellm.api_base
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
response = litellm.completion(**kwargs)
|
|
167
|
+
return response.choices[0].message.content or ""
|
|
168
|
+
except Exception as exc:
|
|
169
|
+
raise LlmProviderError(
|
|
170
|
+
str(exc),
|
|
171
|
+
provider=self._config.provider,
|
|
172
|
+
model=self._config.model,
|
|
173
|
+
) from exc
|
|
174
|
+
|
|
175
|
+
async def generate_text_async(
|
|
176
|
+
self, prompt: str, system_prompt: str | None = None
|
|
177
|
+
) -> str:
|
|
178
|
+
"""Asynchronously generate unstructured text from a prompt via LiteLLM.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
prompt: The user prompt text.
|
|
182
|
+
system_prompt: Optional system instruction prompt.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
Generated response text.
|
|
186
|
+
|
|
187
|
+
Raises:
|
|
188
|
+
LlmProviderError: If the upstream provider request fails.
|
|
189
|
+
"""
|
|
190
|
+
import litellm
|
|
191
|
+
|
|
192
|
+
messages: list[dict[str, str]] = []
|
|
193
|
+
if system_prompt or self._config.agent.system_prompt:
|
|
194
|
+
sys = system_prompt or self._config.agent.system_prompt
|
|
195
|
+
if sys:
|
|
196
|
+
messages.append({"role": "system", "content": sys})
|
|
197
|
+
messages.append({"role": "user", "content": prompt})
|
|
198
|
+
|
|
199
|
+
kwargs: dict[str, Any] = {
|
|
200
|
+
"model": self._resolved_model,
|
|
201
|
+
"messages": messages,
|
|
202
|
+
"temperature": self._config.temperature,
|
|
203
|
+
"max_tokens": self._config.max_tokens,
|
|
204
|
+
"drop_params": self._config.litellm.drop_params,
|
|
205
|
+
"num_retries": self._config.litellm.num_retries,
|
|
206
|
+
"timeout": self._config.litellm.timeout,
|
|
207
|
+
}
|
|
208
|
+
if self._config.api_key:
|
|
209
|
+
kwargs["api_key"] = self._config.api_key
|
|
210
|
+
if self._config.litellm.api_base:
|
|
211
|
+
kwargs["api_base"] = self._config.litellm.api_base
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
response = await litellm.acompletion(**kwargs)
|
|
215
|
+
return response.choices[0].message.content or ""
|
|
216
|
+
except Exception as exc:
|
|
217
|
+
raise LlmProviderError(
|
|
218
|
+
str(exc),
|
|
219
|
+
provider=self._config.provider,
|
|
220
|
+
model=self._config.model,
|
|
221
|
+
) from exc
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
__all__ = [
|
|
225
|
+
"LiteLlmAdapter",
|
|
226
|
+
]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from pydantic_ai import Agent
|
|
4
|
+
|
|
5
|
+
from hexastack_ai.domain.exceptions import AgentExecutionError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PydanticAiAgentAdapter:
|
|
9
|
+
"""Adapter wrapping a PydanticAI Agent instance.
|
|
10
|
+
|
|
11
|
+
Notes/Architectural Intent:
|
|
12
|
+
Encapsulates agent execution lifecycle, converting low-level exceptions
|
|
13
|
+
into Hexastack domain AgentExecutionError.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, agent: Agent[Any, Any]) -> None:
|
|
17
|
+
"""Initialize adapter with a configured PydanticAI Agent instance."""
|
|
18
|
+
self._agent = agent
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def agent(self) -> Agent[Any, Any]:
|
|
22
|
+
"""Access the underlying PydanticAI Agent instance."""
|
|
23
|
+
return self._agent
|
|
24
|
+
|
|
25
|
+
async def run(self, prompt: str, **kwargs: Any) -> Any:
|
|
26
|
+
"""Asynchronously run the agent against a user prompt.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
prompt: User request prompt.
|
|
30
|
+
**kwargs: Extra parameters passed to agent.run().
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
The agent result data.
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
AgentExecutionError: If agent execution or tool call fails.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
result = await self._agent.run(prompt, **kwargs)
|
|
40
|
+
return getattr(result, "output", getattr(result, "data", result))
|
|
41
|
+
except Exception as exc:
|
|
42
|
+
raise AgentExecutionError(str(exc)) from exc
|
|
43
|
+
|
|
44
|
+
def run_sync(self, prompt: str, **kwargs: Any) -> Any:
|
|
45
|
+
"""Synchronously run the agent against a user prompt.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
prompt: User request prompt.
|
|
49
|
+
**kwargs: Extra parameters passed to agent.run_sync().
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
The agent result data.
|
|
53
|
+
|
|
54
|
+
Raises:
|
|
55
|
+
AgentExecutionError: If agent execution fails.
|
|
56
|
+
"""
|
|
57
|
+
try:
|
|
58
|
+
result = self._agent.run_sync(prompt, **kwargs)
|
|
59
|
+
return getattr(result, "output", getattr(result, "data", result))
|
|
60
|
+
except Exception as exc:
|
|
61
|
+
raise AgentExecutionError(str(exc)) from exc
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
__all__ = [
|
|
65
|
+
"PydanticAiAgentAdapter",
|
|
66
|
+
]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from hexastack_core.domain import HexastackError
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AiError(HexastackError):
|
|
5
|
+
"""Base exception for all AI, LLM, and agent operations in Hexastack."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LlmProviderError(AiError):
|
|
9
|
+
"""Exception raised when an upstream LLM API call fails."""
|
|
10
|
+
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
message: str,
|
|
14
|
+
provider: str | None = None,
|
|
15
|
+
model: str | None = None,
|
|
16
|
+
) -> None:
|
|
17
|
+
"""Initialize LlmProviderError with model and provider context.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
message: Error description.
|
|
21
|
+
provider: Name of the LLM provider (e.g. 'openai', 'anthropic').
|
|
22
|
+
model: Name of the target model (e.g. 'gpt-4o', 'claude-3-5-sonnet').
|
|
23
|
+
"""
|
|
24
|
+
self.provider = provider
|
|
25
|
+
self.model = model
|
|
26
|
+
suffix = f" [provider={provider}, model={model}]" if provider or model else ""
|
|
27
|
+
super().__init__(f"{message}{suffix}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class StructuredOutputParsingError(AiError):
|
|
31
|
+
"""Exception raised when LLM output fails schema validation."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, message: str, raw_response: str | None = None) -> None:
|
|
34
|
+
"""Initialize exception with raw output text.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
message: Error description.
|
|
38
|
+
raw_response: Raw response string from the model.
|
|
39
|
+
"""
|
|
40
|
+
self.raw_response = raw_response
|
|
41
|
+
super().__init__(message)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AgentExecutionError(AiError):
|
|
45
|
+
"""Exception raised when an agent loop or tool execution fails."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"AgentExecutionError",
|
|
50
|
+
"AiError",
|
|
51
|
+
"LlmProviderError",
|
|
52
|
+
"StructuredOutputParsingError",
|
|
53
|
+
]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from hexastack_ai.infra.bootstrap import (
|
|
2
|
+
AiBootstrapper,
|
|
3
|
+
AiBootstrapResult,
|
|
4
|
+
)
|
|
5
|
+
from hexastack_ai.infra.config import (
|
|
6
|
+
HexastackAiConfig,
|
|
7
|
+
LiteLlmDialectConfig,
|
|
8
|
+
OllamaDialectConfig,
|
|
9
|
+
PydanticAiDialectConfig,
|
|
10
|
+
register_ai_config,
|
|
11
|
+
)
|
|
12
|
+
from hexastack_ai.infra.tools import (
|
|
13
|
+
create_cqrs_agent,
|
|
14
|
+
create_tool_for_message,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"AiBootstrapper",
|
|
19
|
+
"AiBootstrapResult",
|
|
20
|
+
"create_cqrs_agent",
|
|
21
|
+
"create_tool_for_message",
|
|
22
|
+
"HexastackAiConfig",
|
|
23
|
+
"LiteLlmDialectConfig",
|
|
24
|
+
"OllamaDialectConfig",
|
|
25
|
+
"PydanticAiDialectConfig",
|
|
26
|
+
"register_ai_config",
|
|
27
|
+
]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from hexastack_ai.infra.config import HexastackAiConfig, register_ai_config
|
|
4
|
+
from hexastack_core.adapters.ai import InMemoryLlmProvider
|
|
5
|
+
from hexastack_core.infra.bootstrap import BootstrapContext
|
|
6
|
+
from hexastack_core.infra.registries.config import ConfigRegistry
|
|
7
|
+
from hexastack_core.ports.ai import LlmProviderPort
|
|
8
|
+
from hexastack_core.ports.bootstrap import BootstrapperPort
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class AiBootstrapResult:
|
|
13
|
+
"""Dataclass holding initialized AI provider and configuration."""
|
|
14
|
+
|
|
15
|
+
config: HexastackAiConfig
|
|
16
|
+
llm_provider: LlmProviderPort
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AiBootstrapper(BootstrapperPort):
|
|
20
|
+
"""Bootstrap extension initializing LLM provider and agent integration.
|
|
21
|
+
|
|
22
|
+
Notes/Architectural Intent:
|
|
23
|
+
Implements BootstrapperPort at order=18, registering [hexastack.ai] config
|
|
24
|
+
and binding LlmProviderPort into the DI container. Automatically defaults
|
|
25
|
+
to InMemoryLlmProvider when provider='memory' for zero-infrastructure test isolation.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
name: str = "ai"
|
|
29
|
+
order: int = 18
|
|
30
|
+
|
|
31
|
+
def configure(self, context: BootstrapContext) -> None:
|
|
32
|
+
"""Phase 2: Assemble LLM provider adapter in DI container.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
context: BootstrapContext containing DI container and config.
|
|
36
|
+
"""
|
|
37
|
+
di = context.container
|
|
38
|
+
|
|
39
|
+
# 1. Read AI Configuration
|
|
40
|
+
if HexastackAiConfig in di:
|
|
41
|
+
ai_config = di.resolve(HexastackAiConfig)
|
|
42
|
+
else:
|
|
43
|
+
ai_config = context.get_config("ai", HexastackAiConfig)
|
|
44
|
+
|
|
45
|
+
# 2. Build Provider based on config
|
|
46
|
+
llm_provider: LlmProviderPort
|
|
47
|
+
if ai_config.provider == "memory":
|
|
48
|
+
llm_provider = InMemoryLlmProvider()
|
|
49
|
+
di.add_instance(llm_provider, declared_class=InMemoryLlmProvider)
|
|
50
|
+
else:
|
|
51
|
+
from hexastack_ai.adapters.litellm import LiteLlmAdapter
|
|
52
|
+
|
|
53
|
+
llm_provider = LiteLlmAdapter(config=ai_config)
|
|
54
|
+
di.add_instance(llm_provider, declared_class=LiteLlmAdapter)
|
|
55
|
+
|
|
56
|
+
# 3. Register in DI container
|
|
57
|
+
if LlmProviderPort not in di:
|
|
58
|
+
di.add_instance(llm_provider, declared_class=LlmProviderPort)
|
|
59
|
+
|
|
60
|
+
# 4. Store result in context properties
|
|
61
|
+
ai_result = AiBootstrapResult(
|
|
62
|
+
config=ai_config,
|
|
63
|
+
llm_provider=llm_provider,
|
|
64
|
+
)
|
|
65
|
+
context.properties["ai_result"] = ai_result
|
|
66
|
+
context.properties["ai_provider"] = llm_provider
|
|
67
|
+
|
|
68
|
+
def register_config(self, registry: ConfigRegistry) -> None:
|
|
69
|
+
"""Phase 1: Register AI configuration schema under 'ai'.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
registry: Target ConfigRegistry instance.
|
|
73
|
+
"""
|
|
74
|
+
register_ai_config(registry)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
__all__ = [
|
|
78
|
+
"AiBootstrapper",
|
|
79
|
+
"AiBootstrapResult",
|
|
80
|
+
]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
|
|
3
|
+
from hexastack_core.infra.decorators import config_section
|
|
4
|
+
from hexastack_core.infra.registries.config import ConfigRegistry
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LiteLlmDialectConfig(BaseModel):
|
|
8
|
+
"""Dialect configuration options specific to LiteLLM."""
|
|
9
|
+
|
|
10
|
+
drop_params: bool = Field(
|
|
11
|
+
default=True,
|
|
12
|
+
description="Automatically drop unmapped provider parameters to prevent errors.",
|
|
13
|
+
)
|
|
14
|
+
num_retries: int = Field(
|
|
15
|
+
default=3,
|
|
16
|
+
description="Number of retry attempts on rate limits or transient errors.",
|
|
17
|
+
)
|
|
18
|
+
timeout: float = Field(
|
|
19
|
+
default=60.0,
|
|
20
|
+
description="Request timeout in seconds.",
|
|
21
|
+
)
|
|
22
|
+
api_base: str | None = Field(
|
|
23
|
+
default=None,
|
|
24
|
+
description="Custom API base URL (e.g. for self-hosted LiteLLM proxy or local endpoint).",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class OllamaDialectConfig(BaseModel):
|
|
29
|
+
"""Dialect configuration options for local Ollama instances."""
|
|
30
|
+
|
|
31
|
+
base_url: str = Field(
|
|
32
|
+
default="http://localhost:11434",
|
|
33
|
+
description="Base URL for the local Ollama daemon.",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PydanticAiDialectConfig(BaseModel):
|
|
38
|
+
"""Configuration options for PydanticAI agents."""
|
|
39
|
+
|
|
40
|
+
max_turns: int = Field(
|
|
41
|
+
default=10,
|
|
42
|
+
description="Maximum turn limit for agent tool-calling loops.",
|
|
43
|
+
)
|
|
44
|
+
system_prompt: str | None = Field(
|
|
45
|
+
default=None,
|
|
46
|
+
description="Default system prompt for agent personas.",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@config_section("ai")
|
|
51
|
+
class HexastackAiConfig(BaseModel):
|
|
52
|
+
"""Configuration schema for Hexastack AI engine under [hexastack.ai].
|
|
53
|
+
|
|
54
|
+
Notes/Architectural Intent:
|
|
55
|
+
Partitions global model settings (model, temperature, tokens) from
|
|
56
|
+
provider/dialect-specific subsections (LiteLLM, Ollama, PydanticAI).
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
provider: str = Field(
|
|
60
|
+
default="memory",
|
|
61
|
+
description="Target LLM provider ('memory', 'litellm', 'openai', 'anthropic', 'gemini', 'ollama').",
|
|
62
|
+
)
|
|
63
|
+
model: str = Field(
|
|
64
|
+
default="gpt-4o-mini",
|
|
65
|
+
description="Default model identifier (e.g. 'gpt-4o', 'claude-3-5-sonnet-20241022', 'gemini/gemini-1.5-pro').",
|
|
66
|
+
)
|
|
67
|
+
temperature: float = Field(
|
|
68
|
+
default=0.2,
|
|
69
|
+
description="Sampling temperature between 0.0 and 2.0.",
|
|
70
|
+
)
|
|
71
|
+
max_tokens: int = Field(
|
|
72
|
+
default=2048,
|
|
73
|
+
description="Maximum tokens for text generation.",
|
|
74
|
+
)
|
|
75
|
+
api_key: str | None = Field(
|
|
76
|
+
default=None,
|
|
77
|
+
description="Optional explicit API key override (prefers environment variables by default).",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Dialect-specific sections
|
|
81
|
+
litellm: LiteLlmDialectConfig = Field(
|
|
82
|
+
default_factory=LiteLlmDialectConfig,
|
|
83
|
+
description="LiteLLM proxy and retry configuration.",
|
|
84
|
+
)
|
|
85
|
+
ollama: OllamaDialectConfig = Field(
|
|
86
|
+
default_factory=OllamaDialectConfig,
|
|
87
|
+
description="Ollama local model configuration.",
|
|
88
|
+
)
|
|
89
|
+
agent: PydanticAiDialectConfig = Field(
|
|
90
|
+
default_factory=PydanticAiDialectConfig,
|
|
91
|
+
description="PydanticAI agent configuration.",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
__all__ = [
|
|
96
|
+
"HexastackAiConfig",
|
|
97
|
+
"LiteLlmDialectConfig",
|
|
98
|
+
"OllamaDialectConfig",
|
|
99
|
+
"PydanticAiDialectConfig",
|
|
100
|
+
"register_ai_config",
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def register_ai_config(registry: ConfigRegistry) -> None:
|
|
105
|
+
"""Register AI configuration schema under 'ai' ([hexastack.ai]).
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
registry: Target ConfigRegistry instance.
|
|
109
|
+
"""
|
|
110
|
+
registry.register_config_section("ai", HexastackAiConfig)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from collections.abc import Sequence
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic_ai import Agent
|
|
6
|
+
from pydantic_ai.models import Model
|
|
7
|
+
from pydantic_core import PydanticUndefined
|
|
8
|
+
|
|
9
|
+
from hexastack_core.domain import Command, Generic, Query
|
|
10
|
+
from hexastack_cqrs.infra.pipeline import ExecutionPipeline
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"create_cqrs_agent",
|
|
14
|
+
"create_tool_for_message",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def create_cqrs_agent(
|
|
19
|
+
pipeline: ExecutionPipeline,
|
|
20
|
+
messages: Sequence[type[Command | Query[Any]]],
|
|
21
|
+
model: str | Model = "test",
|
|
22
|
+
system_prompt: str | None = None,
|
|
23
|
+
) -> Agent[Any, Any]:
|
|
24
|
+
"""Assemble a PydanticAI Agent with CQRS message handlers reflected as tools.
|
|
25
|
+
|
|
26
|
+
Notes/Architectural Intent:
|
|
27
|
+
Bridges the CQRS message bus with AI agent capabilities. The agent can
|
|
28
|
+
reason, select appropriate Commands/Queries, and invoke domain logic
|
|
29
|
+
through the standard Hexastack execution pipeline.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
pipeline: Target ExecutionPipeline.
|
|
33
|
+
messages: Sequence of Command/Query classes to expose as tools.
|
|
34
|
+
model: Target model string ('test', 'openai:gpt-4o', 'anthropic:claude-3-5-sonnet') or Model instance.
|
|
35
|
+
system_prompt: Optional initial persona instructions.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Configured PydanticAI Agent instance.
|
|
39
|
+
"""
|
|
40
|
+
sys_prompt = system_prompt or (
|
|
41
|
+
"You are an AI assistant capable of executing domain operations "
|
|
42
|
+
"using the provided tools."
|
|
43
|
+
)
|
|
44
|
+
agent: Agent[Any, Any] = Agent(model=model, system_prompt=sys_prompt)
|
|
45
|
+
|
|
46
|
+
for msg_cls in messages:
|
|
47
|
+
tool_fn = create_tool_for_message(msg_cls, pipeline)
|
|
48
|
+
agent.tool_plain(tool_fn)
|
|
49
|
+
|
|
50
|
+
return agent
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def create_tool_for_message(
|
|
54
|
+
msg_cls: type[Generic],
|
|
55
|
+
pipeline: ExecutionPipeline,
|
|
56
|
+
) -> Any:
|
|
57
|
+
"""Create a typed tool function that constructs a CQRS message and executes it.
|
|
58
|
+
|
|
59
|
+
Notes/Architectural Intent:
|
|
60
|
+
Reflects Pydantic model fields dynamically onto the generated tool function's
|
|
61
|
+
`__signature__` and `__annotations__`. This allows PydanticAI to generate
|
|
62
|
+
accurate function-calling schemas while dispatching directly through
|
|
63
|
+
the Hexastack ExecutionPipeline.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
msg_cls: Domain Generic, Command, or Query class.
|
|
67
|
+
pipeline: Target ExecutionPipeline instance.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Callable tool function with dynamic signature and execution dispatcher.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
async def tool_executor(**kwargs: Any) -> Any:
|
|
74
|
+
msg = msg_cls.model_validate(kwargs)
|
|
75
|
+
result = pipeline.execute(msg)
|
|
76
|
+
if inspect.isawaitable(result):
|
|
77
|
+
return await result
|
|
78
|
+
return result
|
|
79
|
+
|
|
80
|
+
# Reflect Pydantic model fields into parameter signature and annotations
|
|
81
|
+
parameters = [
|
|
82
|
+
inspect.Parameter(
|
|
83
|
+
name=field_name,
|
|
84
|
+
kind=inspect.Parameter.KEYWORD_ONLY,
|
|
85
|
+
annotation=field_info.annotation or Any,
|
|
86
|
+
default=(
|
|
87
|
+
field_info.default
|
|
88
|
+
if field_info.default is not PydanticUndefined
|
|
89
|
+
else inspect.Parameter.empty
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
for field_name, field_info in msg_cls.model_fields.items()
|
|
93
|
+
]
|
|
94
|
+
setattr( # noqa: B010
|
|
95
|
+
tool_executor,
|
|
96
|
+
"__signature__",
|
|
97
|
+
inspect.Signature(parameters=parameters),
|
|
98
|
+
)
|
|
99
|
+
setattr( # noqa: B010
|
|
100
|
+
tool_executor,
|
|
101
|
+
"__annotations__",
|
|
102
|
+
{
|
|
103
|
+
name: field_info.annotation or Any
|
|
104
|
+
for name, field_info in msg_cls.model_fields.items()
|
|
105
|
+
},
|
|
106
|
+
)
|
|
107
|
+
tool_executor.__name__ = msg_cls.__name__
|
|
108
|
+
tool_executor.__doc__ = (
|
|
109
|
+
msg_cls.__doc__ or f"Execute the {msg_cls.__name__} domain operation."
|
|
110
|
+
)
|
|
111
|
+
return tool_executor
|