Files
portainer-core/services/core-ai/main.py
T
jpmschweitzerandClaude 66f6e54fc3 refactor(core-ai): comprehensive cleanup - PydanticAI only architecture
Remove all obsolete agent implementations and framework references.
Keep only PydanticAI (primary) and SimpleLiteLLM (fallback).

This cleanup eliminates confusion between multiple frameworks that were
tried during development (LangChain, LangGraph, ADK, OllamaNative) and
establishes PydanticAI as the single agent framework going forward.

BREAKING CHANGES:
- Removed OllamaNativeAgent - use PydanticAgent instead
- Removed /test/ollama-tools diagnostic endpoint
- Default /v1/chat/completions now uses PydanticAgent

Files Deleted (32 total):
- Obsolete agents: ollama_native_agent.py
- Diagnostic files: ARCHITECTURE.md, DIAGNOSTIC_RESULTS.md, PHASE*.md
- Legacy tools: src/tools.py
- Test files: test_ai_flow_quality.py, test_02/03 (diagnostic layers)
- Documentation: ADK_Ollama_Research.md, agent-flow-diagrams.md
- Session docs: 3 files with LangChain/LangGraph implementations
- Plans: 5 completed plans about obsolete frameworks
- Migration docs: MIGRATION_PLAN_LANGCHAIN_TO_ADK.md

Files Modified (8 total):
- main.py: Refactored to PydanticAI only (305 lines vs 457 before)
- agents/__init__.py: Removed OllamaNativeAgent exports
- README.md: Complete rewrite for PydanticAI architecture
- prompts.py: Updated for PydanticAI (infrastructure tool guidance)
- STATUS.md: Updated to v0.11.0-pydantic-ai
- CHANGELOG.md: Added v0.11.0 entry documenting cleanup
- plans/active/*.md: Updated to reference PydanticAI

Current Architecture:
- Framework: PydanticAI with native Ollama SDK
- Agents: PydanticAgent (primary) + SimpleLiteLLMAgent (fallback)
- Model: mistral-nemo:latest
- Tools: 6 core + 28+ OpenAPI-discovered
- Memory: 3-tier system with Qdrant
- VRAM: ~4-6GB

Lines Removed: ~3000+ lines of obsolete code

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 14:24:52 +01:00

306 lines
9.7 KiB
Python

import os
import logging
import json
import time
from aiohttp import web
from aiohttp_cors import setup as cors_setup, ResourceOptions
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
# Set up logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(name)s - %(message)s')
logger = logging.getLogger(__name__)
# Import the agent logic
from src.agents import (
get_simple_litellm_agent,
get_pydantic_agent,
PYDANTIC_AI_AVAILABLE
)
from src.tools import get_all_tools
from src.utils import extract_user_id_from_request
async def chat_completions(request):
"""
Handles OpenAI-compatible chat completion requests using PydanticAI.
Default endpoint - uses PydanticAI with tools enabled.
"""
if not PYDANTIC_AI_AVAILABLE:
return web.json_response({
"error": {"message": "PydanticAI not available. Install with: pip install pydantic-ai"}
}, status=503)
try:
data = await request.json()
logger.info(f"[DEFAULT/PYDANTIC_AI] Received chat request")
# Extract relevant fields from the request
messages = data.get("messages")
model = data.get("model", "pydantic")
stream = data.get("stream", False)
conversation_id = data.get("conversation_id")
enable_tools = data.get("enable_tools", True)
# Extract user ID from request
user_id = extract_user_id_from_request(data)
if not messages:
raise web.HTTPBadRequest(reason="'messages' field is required")
# Get the agent instance
agent = get_pydantic_agent(discover_tools=enable_tools, user_id=user_id)
# For non-streaming requests, collect the full response
if not stream:
response_content = await agent.chat_completion(
messages=messages,
conversation_id=conversation_id
)
return web.json_response({
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": response_content
},
"finish_reason": "stop"
}],
"model": model,
"usage": {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0
},
"tools_enabled": enable_tools,
"tools_count": len(agent.tools_dict) if enable_tools else 0
})
else:
# Handle streaming response
response = web.StreamResponse(
status=200,
reason='OK',
headers={
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
}
)
await response.prepare(request)
try:
async for chunk in agent.chat(messages=messages, conversation_id=conversation_id, stream=True):
if chunk["type"] == "content":
chunk_data = {
"choices": [{
"index": 0,
"delta": {"content": chunk["content"]},
"finish_reason": chunk.get("finish_reason")
}],
"model": model
}
await response.write(f"data: {json.dumps(chunk_data)}\n\n".encode('utf-8'))
await response.write(b"data: [DONE]\n\n")
finally:
await response.write_eof()
return response
except web.HTTPBadRequest:
raise
except Exception as e:
logger.exception(f"Error in chat_completions: {e}")
return web.json_response({
"error": {"message": f"Internal server error: {str(e)}"}
}, status=500)
async def chat_simple(request):
"""
Handles chat requests using SimpleLiteLLMAgent (fallback, no tools).
Endpoint: /v1/chat/simple
"""
try:
data = await request.json()
logger.info(f"[SIMPLE/LITELLM] Received chat request")
# Extract relevant fields from the request
messages = data.get("messages")
model = data.get("model", "simple")
stream = data.get("stream", False)
conversation_id = data.get("conversation_id")
if not messages:
raise web.HTTPBadRequest(reason="'messages' field is required")
# Get simple agent instance (no tools)
agent = get_simple_litellm_agent()
# For non-streaming requests
if not stream:
response_content = await agent.chat_completion(
messages=messages,
conversation_id=conversation_id
)
return web.json_response({
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": response_content
},
"finish_reason": "stop"
}],
"model": model,
"usage": {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0
}
})
else:
# Streaming response
response = web.StreamResponse(
status=200,
reason='OK',
headers={
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
}
)
await response.prepare(request)
try:
async for chunk in agent.chat(messages=messages, conversation_id=conversation_id, stream=True):
if chunk["type"] == "content":
chunk_data = {
"choices": [{
"index": 0,
"delta": {"content": chunk["content"]},
"finish_reason": chunk.get("finish_reason")
}],
"model": model
}
await response.write(f"data: {json.dumps(chunk_data)}\n\n".encode('utf-8'))
await response.write(b"data: [DONE]\n\n")
finally:
await response.write_eof()
return response
except web.HTTPBadRequest:
raise
except Exception as e:
logger.exception(f"Error in chat_simple: {e}")
return web.json_response({
"error": {"message": f"Internal server error: {str(e)}"}
}, status=500)
async def list_models(request):
"""List available models"""
return web.json_response({
"object": "list",
"data": [
{
"id": "pydantic",
"object": "model",
"created": int(time.time()),
"owned_by": "core-ai"
},
{
"id": "simple",
"object": "model",
"created": int(time.time()),
"owned_by": "core-ai"
}
]
})
async def list_tools(request):
"""List all available tools"""
try:
tools = get_all_tools()
tool_list = []
for name, func in tools.items():
import inspect
doc = inspect.getdoc(func) or "No description"
tool_list.append({
"name": name,
"description": doc.split('\n')[0],
"type": "local" if not name.startswith("core-api__") else "openapi"
})
return web.json_response({
"tools": tool_list,
"tools_count": len(tool_list)
})
except Exception as e:
logger.exception(f"Error listing tools: {e}")
return web.json_response({
"error": {"message": str(e)}
}, status=500)
async def health_check(request):
"""Health check endpoint"""
return web.json_response({
"status": "ok",
"service": "core-ai",
"agents": {
"simple": True,
"pydantic": PYDANTIC_AI_AVAILABLE
},
"default_agent": "pydantic" if PYDANTIC_AI_AVAILABLE else "simple",
"tools_count": len(get_all_tools())
})
async def setup_routes(app):
# Chat endpoints
app.router.add_post("/chat/completions", chat_completions) # Alias without /v1
app.router.add_post("/v1/chat/completions", chat_completions) # Default: PydanticAI
app.router.add_post("/v1/chat/simple", chat_simple) # Fallback: Simple agent
# Models endpoint
app.router.add_get("/models", list_models)
app.router.add_get("/v1/models", list_models)
# Tools endpoint
app.router.add_get("/tools", list_tools)
app.router.add_get("/v1/tools", list_tools)
# Health check
app.router.add_get("/health", health_check)
# Setup CORS
cors = cors_setup(app, defaults={
"*": ResourceOptions(
allow_credentials=True,
expose_headers="*",
allow_headers="*",
allow_methods="*"
)
})
# Apply CORS to all routes
for route in list(app.router.routes()):
cors.add(route)
def main():
app = web.Application()
# Set up routes
import asyncio
loop = asyncio.get_event_loop()
loop.run_until_complete(setup_routes(app))
# Run the application
logger.info("Starting core-ai service on http://0.0.0.0:8086")
web.run_app(app, host='0.0.0.0', port=8086)
if __name__ == '__main__':
main()