Merge pull request #3 from dannyjosephgarcia/WOOL-21

feat: add AskSage LLM provider integration
This commit is contained in:
Danny Garcia 2026-01-16 12:18:14 -06:00 committed by GitHub
commit f497fde153
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 116 additions and 173 deletions

View File

@ -1,4 +1,4 @@
# LLM Mode: "local", "remote", or "openai" # LLM Mode: "local", "remote", "openai", or "asksage"
LLM_MODE=local LLM_MODE=local
# Remote LLM Configuration (required if LLM_MODE=remote) # Remote LLM Configuration (required if LLM_MODE=remote)
@ -8,3 +8,8 @@ LLM_REMOTE_TOKEN=
# OpenAI Configuration (required if LLM_MODE=openai) # OpenAI Configuration (required if LLM_MODE=openai)
OPENAI_API_KEY=sk-your-api-key-here OPENAI_API_KEY=sk-your-api-key-here
OPENAI_MODEL=gpt-4o-mini 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

View File

@ -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

View File

@ -6,7 +6,7 @@ from pydantic_settings import BaseSettings
class Settings(BaseSettings): class Settings(BaseSettings):
"""Application configuration loaded from environment variables.""" """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_url: str = ""
llm_remote_token: str = "" llm_remote_token: str = ""
@ -14,6 +14,11 @@ class Settings(BaseSettings):
openai_api_key: str = "" openai_api_key: str = ""
openai_model: str = "gpt-4o-mini" openai_model: str = "gpt-4o-mini"
# AskSage configuration
asksage_email: str = ""
asksage_api_key: str = ""
asksage_model: str = "gpt-4o"
class Config: class Config:
env_file = ".env" env_file = ".env"
env_file_encoding = "utf-8" env_file_encoding = "utf-8"

View File

@ -1,10 +1,12 @@
"""LLM adapter implementations with FastAPI dependency injection support.""" """LLM adapter implementations with FastAPI dependency injection support."""
import asyncio
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from functools import lru_cache from functools import lru_cache
from typing import Annotated from typing import Annotated
import httpx import httpx
from asksageclient import AskSageClient
from fastapi import Depends from fastapi import Depends
from openai import ( from openai import (
AsyncOpenAI, 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 --- # --- Dependency Injection Support ---
@ -225,6 +284,17 @@ def get_adapter(settings: Annotated[Settings, Depends(get_settings)]) -> LLMAdap
token=settings.llm_remote_token or None, 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() return LocalAdapter()

View File

@ -17,7 +17,7 @@ class ChatResponse(BaseModel):
conversation_id: str = Field(..., description="Conversation ID (generated if not provided)") conversation_id: str = Field(..., description="Conversation ID (generated if not provided)")
response: str = Field(..., description="The LLM's response") 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)") sources: list = Field(default_factory=list, description="Source references (empty for now)")

View File

@ -1,6 +1,33 @@
fastapi>=0.109.0 annotated-doc==0.0.4
uvicorn[standard]>=0.27.0 annotated-types==0.7.0
pydantic>=2.5.0 anyio==4.12.1
pydantic-settings>=2.1.0 asksageclient==1.42
httpx>=0.26.0 certifi==2026.1.4
openai>=1.0.0 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