diff --git a/.env.example b/.env.example index 4430f6b..af31b1c 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# LLM Mode: "local", "remote", or "openai" +# LLM Mode: "local", "remote", "openai", or "asksage" LLM_MODE=local # Remote LLM Configuration (required if LLM_MODE=remote) @@ -8,3 +8,8 @@ LLM_REMOTE_TOKEN= # OpenAI Configuration (required if LLM_MODE=openai) OPENAI_API_KEY=sk-your-api-key-here OPENAI_MODEL=gpt-4o-mini + +# AskSage Configuration (required if LLM_MODE=asksage) +ASKSAGE_EMAIL=your.email@example.com +ASKSAGE_API_KEY=your-api-key-here +ASKSAGE_MODEL=gpt-4o diff --git a/OPENAI_INTEGRATION_PLAN.md b/OPENAI_INTEGRATION_PLAN.md deleted file mode 100644 index 142c931..0000000 --- a/OPENAI_INTEGRATION_PLAN.md +++ /dev/null @@ -1,164 +0,0 @@ -# OpenAI Integration Plan for Tyndale AI Service - -## Summary -Replace the LocalAdapter stub with an OpenAI API client, using FastAPI's `Depends()` for dependency injection and keeping configuration minimal (API key + model only). - ---- - -## Files to Modify - -| File | Action | -|------|--------| -| `requirements.txt` | Add `openai>=1.0.0` | -| `app/config.py` | Add `openai_api_key`, `openai_model` settings | -| `app/llm/exceptions.py` | **Create**: Custom exception hierarchy | -| `app/llm/adapter.py` | Add `OpenAIAdapter` class, refactor `get_adapter()` for DI | -| `app/llm/__init__.py` | Export new components | -| `app/schemas.py` | Add `"openai"` to mode literal | -| `app/main.py` | Use `Depends()` for adapter injection | -| `.env.example` | Add OpenAI env vars | -| `Dockerfile` | Add `OPENAI_MODEL` default env | - ---- - -## Implementation Steps - -### 1. Add OpenAI dependency -**File**: `requirements.txt` -``` -openai>=1.0.0 -``` - -### 2. Extend configuration -**File**: `app/config.py` -- Add `llm_mode: Literal["local", "remote", "openai"]` -- Add `openai_api_key: str = ""` -- Add `openai_model: str = "gpt-4o-mini"` - -### 3. Create exception hierarchy -**File**: `app/llm/exceptions.py` (new) -- `LLMError` (base) -- `LLMAuthenticationError` (401) -- `LLMRateLimitError` (429) -- `LLMConnectionError` (503) -- `LLMConfigurationError` (500) -- `llm_exception_to_http()` helper - -### 4. Add OpenAIAdapter class -**File**: `app/llm/adapter.py` - -```python -class OpenAIAdapter(LLMAdapter): - def __init__(self, api_key: str, model: str = "gpt-4o-mini"): - self.client = OpenAI(api_key=api_key) - self.model = model - - async def generate(self, conversation_id: str, message: str) -> str: - # Use asyncio.to_thread() since OpenAI SDK is synchronous - response = await asyncio.to_thread( - self.client.chat.completions.create, - model=self.model, - messages=[{"role": "user", "content": message}], - ) - return response.choices[0].message.content -``` - -### 5. Refactor get_adapter() for dependency injection -**File**: `app/llm/adapter.py` - -```python -from functools import lru_cache -from typing import Annotated -from fastapi import Depends - -@lru_cache() -def get_settings() -> Settings: - return settings - -def get_adapter(settings: Annotated[Settings, Depends(get_settings)]) -> LLMAdapter: - if settings.llm_mode == "openai": - return OpenAIAdapter(api_key=settings.openai_api_key, model=settings.openai_model) - if settings.llm_mode == "remote": - return RemoteAdapter(url=settings.llm_remote_url, token=settings.llm_remote_token) - return LocalAdapter() - -# Type alias for clean injection -AdapterDependency = Annotated[LLMAdapter, Depends(get_adapter)] -``` - -### 6. Update /chat endpoint -**File**: `app/main.py` - -```python -from app.llm.adapter import AdapterDependency -from app.llm.exceptions import LLMError, llm_exception_to_http - -@app.post("/chat", response_model=ChatResponse) -async def chat(request: ChatRequest, adapter: AdapterDependency) -> ChatResponse: - # ... validation ... - try: - response_text = await adapter.generate(conversation_id, request.message) - except LLMError as e: - raise llm_exception_to_http(e) - # ... return response ... -``` - -### 7. Update mode type -**File**: `app/schemas.py` -- Change `mode: Literal["local", "remote"]` to `mode: Literal["local", "remote", "openai"]` - -### 8. Update environment examples -**File**: `.env.example` -``` -LLM_MODE=openai -OPENAI_API_KEY=sk-your-key-here -OPENAI_MODEL=gpt-4o-mini -``` - -### 9. Update Dockerfile -**File**: `Dockerfile` -- Add `ENV OPENAI_MODEL=gpt-4o-mini` (API key passed at runtime for security) - ---- - -## Error Handling - -| OpenAI Exception | Custom Exception | HTTP Status | -|------------------|------------------|-------------| -| `AuthenticationError` | `LLMAuthenticationError` | 401 | -| `RateLimitError` | `LLMRateLimitError` | 429 | -| `APIConnectionError` | `LLMConnectionError` | 503 | -| `APIError` | `LLMError` | varies | - ---- - -## Verification Steps - -1. **Install dependencies**: - ```bash - pip install -r requirements.txt - ``` - -2. **Run locally with OpenAI**: - ```bash - export LLM_MODE=openai - export OPENAI_API_KEY=sk-your-key - uvicorn app.main:app --reload - ``` - -3. **Test the /chat endpoint**: - ```bash - curl -X POST http://localhost:8000/chat \ - -H "Content-Type: application/json" \ - -d '{"message": "Hello, what is 2+2?"}' - ``` - -4. **Verify response contains OpenAI response and mode="openai"** - -5. **Test Docker build**: - ```bash - docker build -t tyndale-ai . - docker run -p 8080:8080 -e LLM_MODE=openai -e OPENAI_API_KEY=sk-your-key tyndale-ai - ``` - -6. **Test error handling** (optional): Use invalid API key to verify 401 response diff --git a/app/config.py b/app/config.py index 449160f..9d76872 100644 --- a/app/config.py +++ b/app/config.py @@ -6,7 +6,7 @@ from pydantic_settings import BaseSettings class Settings(BaseSettings): """Application configuration loaded from environment variables.""" - llm_mode: Literal["local", "remote", "openai"] = "local" + llm_mode: Literal["local", "remote", "openai", "asksage"] = "local" llm_remote_url: str = "" llm_remote_token: str = "" @@ -14,6 +14,11 @@ class Settings(BaseSettings): openai_api_key: str = "" openai_model: str = "gpt-4o-mini" + # AskSage configuration + asksage_email: str = "" + asksage_api_key: str = "" + asksage_model: str = "gpt-4o" + class Config: env_file = ".env" env_file_encoding = "utf-8" diff --git a/app/llm/adapter.py b/app/llm/adapter.py index dfd538c..c1a3613 100644 --- a/app/llm/adapter.py +++ b/app/llm/adapter.py @@ -1,10 +1,12 @@ """LLM adapter implementations with FastAPI dependency injection support.""" +import asyncio from abc import ABC, abstractmethod from functools import lru_cache from typing import Annotated import httpx +from asksageclient import AskSageClient from fastapi import Depends from openai import ( AsyncOpenAI, @@ -182,6 +184,63 @@ class OpenAIAdapter(LLMAdapter): ) +class AskSageAdapter(LLMAdapter): + """AskSage API adapter using the official asksageclient SDK.""" + + def __init__(self, email: str, api_key: str, model: str = "gpt-4o"): + """Initialize the AskSage adapter. + + Args: + email: AskSage account email + api_key: AskSage API key + model: Model identifier (e.g., "gpt-4o", "claude-3-opus") + """ + self.client = AskSageClient(email=email, api_key=api_key) + self.model = model + + async def generate(self, conversation_id: str, message: str) -> str: + """Generate a response using the AskSage API. + + Args: + conversation_id: The conversation identifier (for future use) + message: The user's message + + Returns: + The generated response string + + Raises: + LLMAuthenticationError: If credentials are invalid + LLMConnectionError: If connection fails + LLMResponseError: If response content is empty or invalid + LLMError: For other API errors + """ + try: + # AskSageClient is synchronous, run in thread pool to avoid blocking + response = await asyncio.to_thread( + self.client.query, + message=message, + model=self.model, + ) + + if not isinstance(response, dict): + raise LLMResponseError("AskSage returned invalid response format") + + content = response.get("response") + if content is None: + raise LLMResponseError("AskSage returned empty response content") + return content + + except LLMError: + raise + except Exception as e: + error_msg = str(e).lower() + if "auth" in error_msg or "401" in error_msg or "unauthorized" in error_msg: + raise LLMAuthenticationError(f"AskSage authentication failed: {e}") + if "connect" in error_msg or "timeout" in error_msg: + raise LLMConnectionError(f"Could not connect to AskSage: {e}") + raise LLMError(f"AskSage API error: {e}") + + # --- Dependency Injection Support --- @@ -225,6 +284,17 @@ def get_adapter(settings: Annotated[Settings, Depends(get_settings)]) -> LLMAdap token=settings.llm_remote_token or None, ) + if settings.llm_mode == "asksage": + if not settings.asksage_email or not settings.asksage_api_key: + raise LLMConfigurationError( + "ASKSAGE_EMAIL and ASKSAGE_API_KEY must be set when LLM_MODE is 'asksage'" + ) + return AskSageAdapter( + email=settings.asksage_email, + api_key=settings.asksage_api_key, + model=settings.asksage_model, + ) + return LocalAdapter() diff --git a/app/schemas.py b/app/schemas.py index 8e815a7..1bf1b98 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -17,7 +17,7 @@ class ChatResponse(BaseModel): conversation_id: str = Field(..., description="Conversation ID (generated if not provided)") response: str = Field(..., description="The LLM's response") - mode: Literal["local", "remote", "openai"] = Field(..., description="Which adapter was used") + mode: Literal["local", "remote", "openai", "asksage"] = Field(..., description="Which adapter was used") sources: list = Field(default_factory=list, description="Source references (empty for now)") diff --git a/requirements.txt b/requirements.txt index 4724e03..ef4b621 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,33 @@ -fastapi>=0.109.0 -uvicorn[standard]>=0.27.0 -pydantic>=2.5.0 -pydantic-settings>=2.1.0 -httpx>=0.26.0 -openai>=1.0.0 +annotated-doc==0.0.4 +annotated-types==0.7.0 +anyio==4.12.1 +asksageclient==1.42 +certifi==2026.1.4 +charset_normalizer==3.4.4 +click==8.3.1 +colorama==0.4.6 +distro==1.9.0 +fastapi==0.128.0 +h11==0.16.0 +httpcore==1.0.9 +httptools==0.7.1 +httpx==0.28.1 +idna==3.11 +jiter==0.12.0 +openai==2.15.0 +pip_system_certs==5.3 +pydantic==2.12.5 +pydantic-settings==2.12.0 +pydantic_core==2.41.5 +python-dotenv==1.2.1 +requests==2.32.5 +PyYAML==6.0.3 +sniffio==1.3.1 +starlette==0.50.0 +tqdm==4.67.1 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +urllib3==2.6.3 +uvicorn==0.40.0 +watchfiles==1.1.1 +websockets==16.0