Major Changes: - Replace Google ADK with PydanticAI framework for agent orchestration - Implement OpenAI-compatible API endpoint for Ollama integration - Fix streaming response to send deltas instead of cumulative text - Add /chat/completions route alias for Open-WebUI compatibility - Enable tool calling with 5 local tools (calculate, date/time utilities) Architecture: - Core-AI service: Standalone Python service with PydanticAI agent - PydanticAI: Uses OpenAI-compatible Ollama API at /v1 endpoint - Tool Registry: Shared tool system between core-ai and core-api - Streaming: Fixed async context issues and delta calculation Verified Working: ✅ Chat completion (streaming & non-streaming) ✅ Tool calling with mistral-nemo and mistral-tools models ✅ Open-WebUI integration via core-ai:8086 ✅ 5 tools: calculate, get_current_time, get_current_date, calculate_date_difference, add_days_to_date ✅ Proper streaming deltas (no repetition) Technical Details: - PydanticAI 1.25.0+ with full Ollama support - Async context manager issue resolved via chunk collection - Delta calculation: chunk[len(previous):] to extract new content only - Routes: /v1/chat/completions and /chat/completions (Open-WebUI compat) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
127 lines
5.2 KiB
Python
127 lines
5.2 KiB
Python
"""
|
|
Core AI Agent - Direct LiteLLM Chat Completion
|
|
This is a diagnostic file to test direct text generation via LiteLLM, bypassing Google ADK.
|
|
"""
|
|
import os
|
|
import logging
|
|
from typing import AsyncIterator, Dict, Any, List, Optional
|
|
from functools import lru_cache
|
|
|
|
# We will directly use litellm here
|
|
import litellm
|
|
|
|
# Adjusted import paths for the new core-ai service structure
|
|
from src.config import get_settings
|
|
from src.prompts import get_prompt
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# Simplified Agent for direct LiteLLM interaction
|
|
class SimpleLiteLLMAgent:
|
|
def __init__(self):
|
|
# Enable verbose logging for LiteLLM
|
|
litellm.set_verbose = True
|
|
logger.info("LiteLLM verbose logging enabled.")
|
|
|
|
self.settings = get_settings()
|
|
|
|
# Load system prompt
|
|
self.system_prompt = get_prompt(self.settings.system_prompt_variant)
|
|
logger.info(f"System prompt variant: {self.settings.system_prompt_variant}")
|
|
logger.info(f"System prompt: {self.system_prompt[:100]}...")
|
|
|
|
# Initialize LiteLLM for Ollama (format: "ollama/model_name")
|
|
model_name = self.settings.agent_model
|
|
litellm_model = f"ollama/{model_name}"
|
|
|
|
logger.info(f"Initializing LiteLLM direct model: {litellm_model}")
|
|
logger.info(f"Ollama base URL from settings: {self.settings.ollama_base_url}")
|
|
|
|
self.model_params = {
|
|
"model": litellm_model,
|
|
"api_base": self.settings.ollama_base_url,
|
|
"temperature": 0.1,
|
|
# No tool definitions passed here to force text generation
|
|
}
|
|
|
|
async def chat(
|
|
self,
|
|
messages: List[Dict[str, str]],
|
|
conversation_id: str = None, # Not used in this simple mode
|
|
stream: bool = True,
|
|
prompt_variant: Optional[str] = None # Not used in this simple mode
|
|
) -> AsyncIterator[Dict[str, Any]]:
|
|
"""
|
|
Processes a chat message using direct LiteLLM completion.
|
|
"""
|
|
logger.info(f"🚀 Starting direct LiteLLM completion for message: {messages[-1]['content'][:50]}...")
|
|
try:
|
|
# Prepare messages in LiteLLM format
|
|
litellm_messages = [{"role": m["role"], "content": m["content"]} for m in messages]
|
|
|
|
# Inject system prompt if not already present
|
|
if not litellm_messages or litellm_messages[0]["role"] != "system":
|
|
litellm_messages.insert(0, {"role": "system", "content": self.system_prompt})
|
|
logger.info("✓ System prompt injected")
|
|
|
|
# Log full message payload for debugging
|
|
logger.info(f"📤 Sending {len(litellm_messages)} messages to LiteLLM:")
|
|
for i, msg in enumerate(litellm_messages):
|
|
content_preview = msg['content'][:100] + "..." if len(msg['content']) > 100 else msg['content']
|
|
logger.info(f" [{i}] {msg['role']}: {content_preview}")
|
|
|
|
# Use acompletion for async environments
|
|
response = await litellm.acompletion(
|
|
messages=litellm_messages,
|
|
stream=stream,
|
|
**self.model_params
|
|
)
|
|
|
|
if stream:
|
|
chunk_count = 0
|
|
async for chunk in response:
|
|
chunk_count += 1
|
|
content_delta = chunk.choices[0].delta.content if chunk.choices[0].delta.content else ""
|
|
finish_reason = chunk.choices[0].finish_reason
|
|
if content_delta:
|
|
yield {"type": "content", "content": content_delta}
|
|
if finish_reason:
|
|
logger.info(f"📥 Stream completed after {chunk_count} chunks. Finish reason: {finish_reason}")
|
|
yield {"type": "content", "content": "", "finish_reason": finish_reason}
|
|
else:
|
|
content = response.choices[0].message.content
|
|
logger.info(f"📥 Response received: {content[:200]}..." if len(content) > 200 else f"📥 Response received: {content}")
|
|
yield {"type": "content", "content": content, "finish_reason": "stop"}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error in direct LiteLLM chat: {e}", exc_info=True)
|
|
yield {
|
|
"type": "error",
|
|
"content": f"Sorry, an error occurred during text generation: {str(e)}",
|
|
"finish_reason": "stop"
|
|
}
|
|
|
|
async def chat_completion(
|
|
self,
|
|
messages: List[Dict[str, str]],
|
|
conversation_id: str = None,
|
|
prompt_variant: Optional[str] = None
|
|
) -> str:
|
|
"""
|
|
Get a non-streaming response from the direct LiteLLM chat.
|
|
"""
|
|
final_content = ""
|
|
async for chunk in self.chat(messages=messages, conversation_id=conversation_id, stream=False, prompt_variant=prompt_variant):
|
|
if chunk["type"] == "content":
|
|
final_content += chunk["content"]
|
|
if chunk.get("finish_reason") == "stop":
|
|
break
|
|
return final_content if final_content else "I couldn't generate a response."
|
|
|
|
|
|
@lru_cache()
|
|
def get_simple_litellm_agent() -> SimpleLiteLLMAgent:
|
|
"""Get cached simple LiteLLM agent instance"""
|
|
return SimpleLiteLLMAgent()
|