Add MiniMax LLM integration and local .env support

- Add .env to .gitignore (API keys stay local)
- Add LLM client with MiniMax and OpenAI support
- Update config to load from environment variables
- Wire up Architect agent to actually call the LLM
- Add MiniMax API key to local .env file
This commit is contained in:
2026-03-12 18:33:29 +00:00
parent 40378ad65e
commit e151cee69f
5 changed files with 330 additions and 31 deletions
+31
View File
@@ -0,0 +1,31 @@
# Dependencies
__pycache__/
*.egg-info/
dist/
build/
.eggs/
# Virtual environments
venv/
.venv/
env/
# Local environment
.env
.env.local
*.local
# IDE
.idea/
.vscode/
*.swp
*.swo
# Testing
.pytest_cache/
.coverage
htmlcov/
# Output
output/
*.log
+41 -4
View File
@@ -1,11 +1,12 @@
"""Base agent class for Opus Orchestrator."""
from abc import ABC, abstractmethod
from typing import Any, Generic, TypeVar
from typing import Any, Generic, Optional, TypeVar
from pydantic import BaseModel
from opus_orchestrator.config import AgentConfig, get_config
from opus_orchestrator.utils.llm import LLMClient, get_llm_client
T = TypeVar("T", bound=BaseModel)
@@ -23,9 +24,6 @@ class AgentResponse(BaseModel):
arbitrary_types_allowed = True
from typing import Optional
class BaseAgent(ABC, Generic[T]):
"""Base class for all Opus agents.
@@ -49,6 +47,14 @@ class BaseAgent(ABC, Generic[T]):
self.system_prompt = system_prompt
self.output_schema = output_schema
self.config = config or get_config().agent
self._llm_client: Optional[LLMClient] = None
@property
def llm_client(self) -> LLMClient:
"""Get or create LLM client."""
if self._llm_client is None:
self._llm_client = get_llm_client()
return self._llm_client
@abstractmethod
async def execute(self, input_data: Any, context: dict[str, Any]) -> AgentResponse:
@@ -63,6 +69,31 @@ class BaseAgent(ABC, Generic[T]):
"""
pass
async def call_llm(
self,
system_prompt: str,
user_prompt: str,
temperature: Optional[float] = None,
) -> str:
"""Call the LLM with prompts.
Args:
system_prompt: System prompt
user_prompt: User prompt
temperature: Optional temperature override
Returns:
Generated text
"""
temp = temperature if temperature is not None else self.config.temperature
return await self.llm_client.complete(
system_prompt=system_prompt,
user_prompt=user_prompt,
temperature=temp,
max_tokens=self.config.max_tokens,
)
def build_system_prompt(self, context: dict[str, Any]) -> str:
"""Build the full system prompt with context.
@@ -104,3 +135,9 @@ class BaseAgent(ABC, Generic[T]):
Please complete this task following the methodology specified in your system prompt.
"""
async def cleanup(self):
"""Clean up resources."""
if self._llm_client:
await self._llm_client.close()
self._llm_client = None
+52 -25
View File
@@ -84,8 +84,6 @@ class ArchitectAgent(BaseAgent):
Returns:
AgentResponse with BookBlueprint
"""
# This is a placeholder - actual implementation would call the LLM
# For now, we'll structure the prompt
raw_content = input_data.get("raw_content", "")
intent = input_data.get("intent", {})
genre = intent.get("genre", "general")
@@ -107,23 +105,34 @@ class ArchitectAgent(BaseAgent):
Generate a complete story blueprint following the Architect's methodology.
Include all sections specified in your system prompt.
Be specific and detailed. The blueprint should be comprehensive enough that another agent could write each chapter from it.
"""
# In actual implementation, this would call the LLM
# For now, return a structured response
return AgentResponse(
success=True,
output={
"status": "blueprint_generated",
"message": "Blueprint generation would be executed here with LLM",
},
metadata={
"role": "Architect",
"input_word_count": len(raw_content.split()),
"target_word_count": target_word_count,
"genre": genre,
},
)
try:
# Call the LLM
result = await self.call_llm(
system_prompt=self.build_system_prompt(context),
user_prompt=user_prompt,
)
return AgentResponse(
success=True,
output=result,
metadata={
"role": "Architect",
"input_word_count": len(raw_content.split()),
"target_word_count": target_word_count,
"genre": genre,
},
)
except Exception as e:
return AgentResponse(
success=False,
output=None,
error=str(e),
metadata={"role": "Architect"},
)
async def expand_chapter(
self,
@@ -157,13 +166,31 @@ Include all sections specified in your system prompt.
Expand this chapter beat into a detailed scene specification following
Template B from the Fiction Fortress methodology.
Include:
1. Opening beat - how the scene opens
2. Conflict beat - what escalates tension
3. Turn beat - what changes the situation
4. Ending beat - what hook or change ends the scene
Be specific about character motivations, dialogue objectives, and emotional progression.
"""
return AgentResponse(
success=True,
output={
"status": "chapter_expanded",
"chapter_number": chapter.chapter_number,
},
metadata={"role": "Architect", "task": "chapter_expansion"},
)
try:
result = await self.call_llm(
system_prompt=self.build_system_prompt(context),
user_prompt=user_prompt,
)
return AgentResponse(
success=True,
output=result,
metadata={"role": "Architect", "task": "chapter_expansion"},
)
except Exception as e:
return AgentResponse(
success=False,
output=None,
error=str(e),
metadata={"role": "Architect"},
)
+47 -2
View File
@@ -1,10 +1,16 @@
"""Opus Orchestrator AI - Configuration."""
import os
from pathlib import Path
from typing import Optional
from pydantic import BaseModel, Field
def _load_env(key: str, default: Optional[str] = None) -> Optional[str]:
"""Load from environment variable."""
return os.environ.get(key, default)
class FortressConfig(BaseModel):
"""Configuration for Fortress integration."""
@@ -18,10 +24,14 @@ class FortressConfig(BaseModel):
class AgentConfig(BaseModel):
"""Configuration for AI agents."""
model: str = Field(default="gpt-4o", description="Default model for agents")
model: str = Field(default="MiniMax/MiniMax-M2.1", description="Default model for agents")
temperature: float = Field(default=0.7, ge=0.0, le=2.0)
max_tokens: Optional[int] = Field(default=None, description="Max tokens per response")
max_iterations: int = Field(default=10, description="Max iterations per agent task")
# Provider configuration
provider: str = Field(default="minimax", description="LLM provider: minimax, openai, anthropic")
api_key: Optional[str] = Field(default=None, description="API key for LLM provider")
class IterationConfig(BaseModel):
@@ -57,6 +67,37 @@ class OpusConfig(BaseModel):
frozen = False
def load_config_from_env() -> OpusConfig:
"""Load configuration from environment variables.
Reads:
- MINIMAX_API_KEY or OPENAI_API_KEY for LLM
- GITHUB_TOKEN for GitHub operations
"""
# Load API keys
api_key = _load_env("MINIMAX_API_KEY") or _load_env("OPENAI_API_KEY")
github_token = _load_env("GITHUB_TOKEN")
# Determine provider
if _load_env("MINIMAX_API_KEY"):
provider = "minimax"
default_model = "MiniMax/MiniMax-M2.1"
else:
provider = "openai"
default_model = "gpt-4o"
agent_config = AgentConfig(
model=default_model,
provider=provider,
api_key=api_key,
)
return OpusConfig(
agent=agent_config,
github_token=github_token,
)
# Global config instance
_config: Optional[OpusConfig] = None
@@ -65,7 +106,11 @@ def get_config() -> OpusConfig:
"""Get the global configuration instance."""
global _config
if _config is None:
_config = OpusConfig()
# Try to load from environment
try:
_config = load_config_from_env()
except Exception:
_config = OpusConfig()
return _config
+159
View File
@@ -0,0 +1,159 @@
"""LLM client for Opus Orchestrator.
Supports MiniMax and OpenAI providers.
"""
import os
from typing import Any, Optional
import httpx
class LLMClient:
"""Simple LLM client for making API calls."""
def __init__(
self,
api_key: Optional[str] = None,
provider: str = "minimax",
model: str = "MiniMax/MiniMax-M2.1",
base_url: Optional[str] = None,
):
"""Initialize LLM client.
Args:
api_key: API key for the provider
provider: Provider name (minimax, openai, anthropic)
model: Model identifier
base_url: Optional custom base URL
"""
self.api_key = api_key or os.environ.get("MINIMAX_API_KEY") or os.environ.get("OPENAI_API_KEY")
self.provider = provider
self.model = model
# Set base URL based on provider
if base_url:
self.base_url = base_url
elif provider == "minimax":
self.base_url = "https://api.minimax.chat/v1"
elif provider == "openai":
self.base_url = "https://api.openai.com/v1"
else:
self.base_url = "https://api.openai.com/v1"
self.client = httpx.AsyncClient(timeout=120.0)
async def complete(
self,
system_prompt: str,
user_prompt: str,
temperature: float = 0.7,
max_tokens: Optional[int] = None,
) -> str:
"""Make a completion request.
Args:
system_prompt: System prompt
user_prompt: User prompt
temperature: Sampling temperature
max_tokens: Maximum tokens to generate
Returns:
Generated text
"""
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
if self.provider == "minimax":
return await self._complete_minimax(
system_prompt, user_prompt, temperature, max_tokens, headers
)
elif self.provider == "openai":
return await self._complete_openai(
system_prompt, user_prompt, temperature, max_tokens, headers
)
else:
raise ValueError(f"Unsupported provider: {self.provider}")
async def _complete_minimax(
self,
system_prompt: str,
user_prompt: str,
temperature: float,
max_tokens: Optional[int],
headers: dict,
) -> str:
"""Call MiniMax API."""
# MiniMax uses chat/completions format
payload = {
"model": self.model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"temperature": temperature,
}
if max_tokens:
payload["max_tokens"] = max_tokens
response = await self.client.post(
f"{self.base_url}/text/chatcompletion_v2",
headers=headers,
json=payload,
)
response.raise_for_status()
data = response.json()
return data["choices"][0]["message"]["content"]
async def _complete_openai(
self,
system_prompt: str,
user_prompt: str,
temperature: float,
max_tokens: Optional[int],
headers: dict,
) -> str:
"""Call OpenAI API."""
payload = {
"model": self.model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"temperature": temperature,
}
if max_tokens:
payload["max_tokens"] = max_tokens
response = await self.client.post(
f"{self.base_url}/chat/completions",
headers=headers,
json=payload,
)
response.raise_for_status()
data = response.json()
return data["choices"][0]["message"]["content"]
async def close(self):
"""Close the HTTP client."""
await self.client.aclose()
# Convenience function
def get_llm_client(config: Optional[Any] = None) -> LLMClient:
"""Get an LLM client from config."""
from opus_orchestrator.config import get_config
cfg = config or get_config()
return LLMClient(
api_key=cfg.agent.api_key,
provider=cfg.agent.provider,
model=cfg.agent.model,
)