Skip to content

Commit e09a1c2

Browse files
authored
Merge pull request #472 from lbedner/ollama-part-1
Ollama Part 1
2 parents cedfeee + d0d0d6b commit e09a1c2

51 files changed

Lines changed: 4387 additions & 141 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

aegis/cli/interactive.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
AnswerKeys,
1818
ComponentNames,
1919
Messages,
20+
OllamaMode,
2021
StorageBackends,
2122
)
2223
from ..core.components import COMPONENTS, CORE_COMPONENTS, ComponentSpec, ComponentType
@@ -37,6 +38,9 @@
3738
# Global variable to store skip LLM sync selection for template generation
3839
_skip_llm_sync_selection: dict[str, bool] = {}
3940

41+
# Global variable to store Ollama mode selection for template generation
42+
_ollama_mode_selection: dict[str, str] = {}
43+
4044
# Global variable to store database engine selection for template generation
4145
_database_engine_selection: str | None = None
4246

@@ -437,6 +441,37 @@ def clear_skip_llm_sync_selection() -> None:
437441
_skip_llm_sync_selection.clear()
438442

439443

444+
def get_ollama_mode_selection(service_name: str = "ai") -> str:
445+
"""
446+
Get Ollama mode selection from interactive session.
447+
448+
Args:
449+
service_name: Name of the AI service (defaults to "ai")
450+
451+
Returns:
452+
Selected Ollama mode (host, docker, or none)
453+
"""
454+
return _ollama_mode_selection.get(service_name, OllamaMode.NONE)
455+
456+
457+
def set_ollama_mode_selection(service_name: str, mode: str) -> None:
458+
"""
459+
Set Ollama mode selection.
460+
461+
Args:
462+
service_name: Name of the AI service (defaults to "ai")
463+
mode: Ollama mode (host, docker, or none)
464+
"""
465+
global _ollama_mode_selection
466+
_ollama_mode_selection[service_name] = mode
467+
468+
469+
def clear_ollama_mode_selection() -> None:
470+
"""Clear stored Ollama mode selection (useful for testing)."""
471+
global _ollama_mode_selection
472+
_ollama_mode_selection.clear()
473+
474+
440475
def set_ai_service_config(
441476
service_name: str = "ai",
442477
framework: str | None = None,
@@ -455,13 +490,17 @@ def set_ai_service_config(
455490
providers: List of AI providers
456491
"""
457492
global _ai_framework_selection, _ai_backend_selection, _ai_provider_selection
493+
global _ollama_mode_selection
458494

459495
if framework is not None:
460496
_ai_framework_selection[service_name] = framework
461497
if backend is not None:
462498
_ai_backend_selection[service_name] = backend
463499
if providers is not None:
464500
_ai_provider_selection[service_name] = providers
501+
# Auto-set ollama_mode to "host" when ollama is a provider (non-interactive default)
502+
if AIProviders.OLLAMA in providers:
503+
_ollama_mode_selection[service_name] = OllamaMode.HOST
465504

466505

467506
def clear_all_ai_selections() -> None:
@@ -471,6 +510,7 @@ def clear_all_ai_selections() -> None:
471510
clear_ai_backend_selection()
472511
clear_ai_rag_selection()
473512
clear_skip_llm_sync_selection()
513+
clear_ollama_mode_selection()
474514
clear_database_engine_selection()
475515

476516

@@ -575,6 +615,36 @@ def interactive_ai_service_config(
575615
# Store provider selection in global context for template generation
576616
_ai_provider_selection[service_name] = providers
577617

618+
# Ollama deployment mode selection (only if Ollama was selected)
619+
if AIProviders.OLLAMA in providers:
620+
typer.echo("\nOllama Deployment Mode:")
621+
typer.echo(" How do you want to run Ollama?")
622+
typer.echo(
623+
" 1. Host - Connect to Ollama running on your machine (Mac/Windows)"
624+
)
625+
typer.echo(" 2. Docker - Run Ollama in a Docker container (Linux/Deploy)")
626+
627+
use_host = typer.confirm(
628+
" Connect to host Ollama? (recommended for Mac/Windows)",
629+
default=True,
630+
)
631+
ollama_mode = OllamaMode.HOST if use_host else OllamaMode.DOCKER
632+
_ollama_mode_selection[service_name] = ollama_mode
633+
634+
if ollama_mode == OllamaMode.HOST:
635+
typer.secho(
636+
" Ollama will connect to host.docker.internal:11434", fg="green"
637+
)
638+
typer.echo(" Make sure Ollama is running: ollama serve")
639+
else:
640+
typer.secho(
641+
" Ollama service will be added to docker-compose.yml", fg="green"
642+
)
643+
typer.echo(" Note: First startup may take time to download models")
644+
else:
645+
# No Ollama selected - set mode to none
646+
_ollama_mode_selection[service_name] = OllamaMode.NONE
647+
578648
# RAG selection with Python 3.14 compatibility check
579649
typer.echo("\nRAG (Retrieval-Augmented Generation):")
580650
if sys.version_info >= (3, 14):

aegis/constants.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,10 @@ class AIProviders:
5757
GROQ = "groq"
5858
MISTRAL = "mistral"
5959
COHERE = "cohere"
60+
OLLAMA = "ollama"
6061

6162
# All valid providers (used for validation)
62-
ALL = {PUBLIC, OPENAI, ANTHROPIC, GOOGLE, GROQ, MISTRAL, COHERE}
63+
ALL = {PUBLIC, OPENAI, ANTHROPIC, GOOGLE, GROQ, MISTRAL, COHERE, OLLAMA}
6364

6465
# Default providers for bracket syntax (non-interactive)
6566
DEFAULT = [PUBLIC]
@@ -75,9 +76,24 @@ class AIProviders:
7576
(GROQ, "Groq", "Fast inference", "Free tier", True),
7677
(MISTRAL, "Mistral", "Open models", "Mostly paid", False),
7778
(COHERE, "Cohere", "Enterprise focus", "Limited free", False),
79+
(OLLAMA, "Ollama", "Local inference", "Free (local)", True),
7880
]
7981

8082

83+
class OllamaMode:
84+
"""Ollama deployment mode options."""
85+
86+
HOST = "host" # Connect to Ollama running on host machine
87+
DOCKER = "docker" # Run Ollama in Docker container
88+
NONE = "none" # No Ollama (using cloud provider)
89+
90+
ALL = [HOST, DOCKER, NONE]
91+
92+
# Default URLs for each mode
93+
HOST_URL = "http://host.docker.internal:11434" # For Mac/Windows Docker
94+
DOCKER_URL = "http://ollama:11434" # For Docker service
95+
96+
8197
class AnswerKeys:
8298
"""Keys in Copier .copier-answers.yml configuration."""
8399

@@ -108,6 +124,7 @@ class AnswerKeys:
108124
AI_BACKEND = "ai_backend"
109125
AI_WITH_PERSISTENCE = "ai_with_persistence"
110126
AI_RAG = "ai_rag"
127+
OLLAMA_MODE = "ollama_mode"
111128
PROJECT_SLUG = "project_slug"
112129
SRC_PATH = "_src_path"
113130

aegis/core/copier_manager.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,9 @@ def generate_with_copier(
129129
)
130130
== "yes",
131131
AnswerKeys.AI_RAG: cookiecutter_context.get(AnswerKeys.AI_RAG, "no") == "yes",
132+
AnswerKeys.OLLAMA_MODE: cookiecutter_context.get(
133+
AnswerKeys.OLLAMA_MODE, "none"
134+
),
132135
}
133136

134137
# Detect dev vs production mode for template sourcing

aegis/core/post_gen_tasks.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,6 @@ def get_component_file_mapping() -> dict[str, list[str]]:
112112
# Note: alembic is now shared between auth and AI services
113113
# Frontend dashboard files
114114
"app/components/frontend/dashboard/cards/auth_card.py",
115-
"app/components/frontend/dashboard/cards/services_card.py",
116115
"app/components/frontend/dashboard/modals/auth_modal.py",
117116
],
118117
AnswerKeys.SERVICE_AI: [
@@ -410,24 +409,26 @@ def is_enabled(key: str) -> bool:
410409
ai_backend = context.get(AnswerKeys.AI_BACKEND, StorageBackends.MEMORY)
411410
if ai_backend == StorageBackends.MEMORY:
412411
remove_file(project_path, "app/models/conversation.py")
413-
# Remove LLM tracking models and ETL (only needed with persistence)
414-
remove_dir(project_path, "app/services/ai/models")
412+
# Remove LLM tracking models (only needed with persistence)
413+
# Keep app/services/ai/models/__init__.py - contains core types (AIProvider, ProviderConfig)
414+
remove_dir(project_path, "app/services/ai/models/llm")
415415
remove_dir(project_path, "app/services/ai/etl")
416416
remove_dir(project_path, "app/services/ai/fixtures")
417-
# Remove persistence-related contexts
417+
# Remove persistence-related contexts (keep usage_context.py - no DB deps)
418418
remove_file(project_path, "app/services/ai/llm_catalog_context.py")
419419
remove_file(project_path, "app/services/ai/llm_service.py")
420420
remove_file(project_path, "app/services/ai/provider_management.py")
421-
remove_file(project_path, "app/services/ai/usage_context.py")
422421
# Remove persistence-related tests
423422
remove_dir(project_path, "tests/services/ai/etl")
424423
remove_file(project_path, "tests/services/ai/test_usage_tracking.py")
425424
remove_file(project_path, "tests/services/ai/test_llm_catalog_context.py")
426425
remove_file(project_path, "tests/services/ai/test_llm_service.py")
427426
remove_file(project_path, "tests/services/ai/test_provider_management.py")
428-
# Remove LLM CLI (catalog management needs database)
427+
# Remove LLM CLI and API (catalog management needs database)
429428
remove_file(project_path, "app/cli/llm.py")
430429
remove_file(project_path, "tests/cli/test_llm_cli.py")
430+
remove_dir(project_path, "app/components/backend/api/llm")
431+
remove_file(project_path, "tests/api/test_llm_endpoints.py")
431432
# Remove analytics UI (needs database for usage tracking)
432433
remove_file(
433434
project_path, "app/components/frontend/dashboard/modals/ai_analytics_tab.py"
@@ -469,10 +470,18 @@ def is_enabled(key: str) -> bool:
469470
project_path, "app/components/frontend/dashboard/cards/auth_card.py"
470471
)
471472
remove_file(
472-
project_path, "app/components/frontend/dashboard/cards/services_card.py"
473+
project_path, "app/components/frontend/dashboard/modals/auth_modal.py"
473474
)
475+
476+
# Remove services_card.py only if NO services are enabled
477+
# ServicesCard shows all services (auth, AI, comms), so keep if ANY service is enabled
478+
if (
479+
not is_enabled(AnswerKeys.AUTH)
480+
and not is_enabled(AnswerKeys.AI)
481+
and not is_enabled(AnswerKeys.COMMS)
482+
):
474483
remove_file(
475-
project_path, "app/components/frontend/dashboard/modals/auth_modal.py"
484+
project_path, "app/components/frontend/dashboard/cards/services_card.py"
476485
)
477486

478487
# Remove Alembic directory only if NO service needs migrations

aegis/core/template_generator.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
AIFrameworks,
1515
AnswerKeys,
1616
ComponentNames,
17+
OllamaMode,
1718
StorageBackends,
1819
WorkerBackends,
1920
)
@@ -213,6 +214,8 @@ def get_template_context(self) -> dict[str, Any]:
213214
AnswerKeys.AI_PROVIDERS: self._get_ai_providers_string(),
214215
# AI RAG (Retrieval-Augmented Generation) selection
215216
AnswerKeys.AI_RAG: "yes" if self.ai_rag else "no",
217+
# Ollama deployment mode (host, docker, or none)
218+
AnswerKeys.OLLAMA_MODE: self._get_ollama_mode(),
216219
# Dependency lists for templates
217220
"selected_components": selected_only, # Original selection for context
218221
"docker_services": self._get_docker_services(),
@@ -352,6 +355,26 @@ def _get_ai_framework(self) -> str:
352355

353356
return get_ai_framework_selection("ai")
354357

358+
def _get_ollama_mode(self) -> str:
359+
"""
360+
Get Ollama deployment mode selection (host, docker, or none).
361+
362+
Returns:
363+
Ollama mode string
364+
"""
365+
# Check if AI service is selected (handle bracket syntax)
366+
has_ai = any(
367+
extract_base_service_name(s) == AnswerKeys.SERVICE_AI
368+
for s in self.selected_services
369+
)
370+
if not has_ai:
371+
return OllamaMode.NONE # Default when AI not selected
372+
373+
# Import here to avoid circular imports
374+
from ..cli.interactive import get_ollama_mode_selection
375+
376+
return get_ollama_mode_selection("ai")
377+
355378
def _get_ai_framework_deps(self) -> list[str]:
356379
"""
357380
Get AI framework-specific dependencies based on framework and provider selection.

aegis/templates/copier-aegis-project/{{ project_slug }}/.env.example.jinja

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,21 @@ AI_CONVERSATION_TIMEOUT_HOURS=24 # Auto-cleanup old conversations
114114
# MISTRAL_API_KEY=...
115115
# COHERE_API_KEY=...
116116

117+
{% if ollama_mode != "none" %}
118+
# Ollama Configuration (Local LLM Inference)
119+
{% if ollama_mode == "host" %}
120+
# Connecting to Ollama on host machine (Mac/Windows Docker Desktop)
121+
OLLAMA_BASE_URL=http://host.docker.internal:11434
122+
# For local CLI commands when not running in Docker:
123+
OLLAMA_BASE_URL_LOCAL=http://localhost:11434
124+
{% elif ollama_mode == "docker" %}
125+
# Connecting to Ollama Docker container
126+
OLLAMA_BASE_URL=http://ollama:11434
127+
# For local CLI commands when not running in Docker:
128+
OLLAMA_BASE_URL_LOCAL=http://localhost:11434
129+
{% endif %}
130+
{% endif %}
131+
117132
# Provider-Specific Notes:
118133
# - Public: Works immediately without API keys (uses free endpoints, actual model varies)
119134
# - Groq: Excellent free tier with fast inference (recommended for testing)
@@ -122,6 +137,7 @@ AI_CONVERSATION_TIMEOUT_HOURS=24 # Auto-cleanup old conversations
122137
# - Anthropic: Requires paid account, very good for reasoning
123138
# - Mistral: Good open source models, some free options
124139
# - Cohere: Enterprise focused, limited free tier
140+
# - Ollama: Free local inference, requires Ollama installed (https://ollama.com)
125141
{% endif %}
126142

127143
{% if ai_rag %}

aegis/templates/copier-aegis-project/{{ project_slug }}/app/cli/ai.py.jinja

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,10 @@ def providers() -> None:
152152
is_current = provider == ai_config.provider
153153

154154
# Determine API key status
155-
if provider == AIProvider.PUBLIC:
156-
has_api_key = True # PUBLIC doesn't need one
155+
# LOCAL providers (PUBLIC, OLLAMA) don't require API keys
156+
local_providers = {AIProvider.PUBLIC, AIProvider.OLLAMA}
157+
if provider in local_providers:
158+
has_api_key = True # Local providers don't need API keys
157159
api_key_display = "[dim]N/A[/dim]"
158160
else:
159161
env_var = f"{provider.value.upper()}_API_KEY"
@@ -164,14 +166,18 @@ def providers() -> None:
164166
if is_current:
165167
if not is_installed:
166168
status = "[bold red]Current (Not installed)[/bold red]"
167-
elif not has_api_key and provider != AIProvider.PUBLIC:
169+
elif not has_api_key and provider not in local_providers:
168170
status = "[bold yellow]Current (Need API key)[/bold yellow]"
171+
elif provider == AIProvider.OLLAMA:
172+
status = "[bold green]Current (Local)[/bold green]"
169173
else:
170174
status = "[bold green]Current[/bold green]"
171175
elif is_available:
172-
status = "Ready"
176+
status = "Ready" if provider not in local_providers else "Local"
173177
elif is_installed and not has_api_key:
174178
status = "[yellow]Need API key[/yellow]"
179+
elif is_installed and provider == AIProvider.OLLAMA:
180+
status = "[cyan]Local[/cyan]"
175181
elif not is_installed:
176182
status = "[red]Not installed[/red]"
177183
else:
@@ -1146,12 +1152,11 @@ async def _interactive_chat_session(
11461152
{% endif %}
11471153
)
11481154

1149-
{% if ai_backend != "memory" %}
1150-
# Pre-load model cache for tab completion
1155+
# Pre-load model cache for tab completion (includes Ollama models)
11511156
await command_handler.load_model_cache()
1157+
{% if ai_rag %}
11521158
# Pre-load collection cache for /rag tab completion
11531159
await command_handler.load_collection_cache()
1154-
11551160
{% endif %}
11561161
# Create completer for slash command autocomplete
11571162
chat_completer = ChatCompleter(command_handler)
@@ -1422,7 +1427,7 @@ async def _stream_chat_response(
14221427
try:
14231428
processed_content = set()
14241429

1425-
async with asyncio.timeout(30.0): # 30 second timeout
1430+
async with asyncio.timeout(settings.AI_TIMEOUT_SECONDS):
14261431
async for chunk in ai_service.stream_chat(
14271432
message=message,
14281433
conversation_id=conversation_id,

0 commit comments

Comments
 (0)