Merge pull request #3 from dannyjosephgarcia/WOOL-21
feat: add AskSage LLM provider integration
This commit is contained in:
commit
f497fde153
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue