diff --git a/ai-hub/app/llm_providers.py b/ai-hub/app/llm_providers.py new file mode 100644 index 0000000..75629c7 --- /dev/null +++ b/ai-hub/app/llm_providers.py @@ -0,0 +1,91 @@ +import os +import httpx +from abc import ABC, abstractmethod +from openai import OpenAI +from typing import final + +# --- 1. Load Configuration from Environment --- +# Best practice is to centralize configuration loading at the top. + +# API Keys (required) +DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY") +GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") + +# Model Names (optional, with defaults) +# Allows changing the model version without code changes. +DEEPSEEK_MODEL = os.getenv("DEEPSEEK_MODEL_NAME", "deepseek-chat") +GEMINI_MODEL = os.getenv("GEMINI_MODEL_NAME", "gemini-1.5-flash-latest") + +# --- 2. Initialize API Clients and URLs --- +# Initialize any clients or constants that will be used by the providers. +deepseek_client = OpenAI(api_key=DEEPSEEK_API_KEY, base_url="https://api.deepseek.com") +GEMINI_URL = f"https://generativelanguage.googleapis.com/v1beta/models/{GEMINI_MODEL}:generateContent?key={GEMINI_API_KEY}" + + +# --- 3. Provider Interface and Implementations --- + +class LLMProvider(ABC): + """Abstract base class ('Interface') for all LLM providers.""" + @abstractmethod + async def generate_response(self, prompt: str) -> str: + """Generates a response from the LLM.""" + pass + +@final +class DeepSeekProvider(LLMProvider): + """Provider for the DeepSeek API.""" + def __init__(self, model_name: str): + self.model = model_name + print(f"DeepSeekProvider initialized with model: {self.model}") + + async def generate_response(self, prompt: str) -> str: + try: + chat_completion = deepseek_client.chat.completions.create( + model=self.model, + messages=[ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": prompt}, + ], + stream=False + ) + return chat_completion.choices[0].message.content + except Exception as e: + print(f"DeepSeek Error: {e}") + raise # Re-raise the exception to be handled by the main app + +@final +class GeminiProvider(LLMProvider): + """Provider for the Google Gemini API.""" + def __init__(self, api_url: str): + self.url = api_url + print(f"GeminiProvider initialized for URL: {self.url.split('?')[0]}") + + async def generate_response(self, prompt: str) -> str: + payload = {"contents": [{"parts": [{"text": prompt}]}]} + headers = {"Content-Type": "application/json"} + + try: + async with httpx.AsyncClient() as client: + response = await client.post(self.url, json=payload, headers=headers) + response.raise_for_status() + data = response.json() + return data['candidates'][0]['content']['parts'][0]['text'] + except (httpx.HTTPStatusError, KeyError, IndexError) as e: + print(f"Gemini Error: {e}") + raise # Re-raise for the main app to handle + +# --- 4. The Factory Function --- +# This is where we instantiate our concrete providers with their configuration. + +_providers = { + "deepseek": DeepSeekProvider(model_name=DEEPSEEK_MODEL), + "gemini": GeminiProvider(api_url=GEMINI_URL) +} + +def get_llm_provider(model_name: str) -> LLMProvider: + """Factory function to get the appropriate, pre-configured LLM provider.""" + provider = _providers.get(model_name) + if not provider: + raise ValueError(f"Unsupported model provider: '{model_name}'. Supported providers are: {list(_providers.keys())}") + return provider + diff --git a/ai-hub/app/llm_providers.py b/ai-hub/app/llm_providers.py new file mode 100644 index 0000000..75629c7 --- /dev/null +++ b/ai-hub/app/llm_providers.py @@ -0,0 +1,91 @@ +import os +import httpx +from abc import ABC, abstractmethod +from openai import OpenAI +from typing import final + +# --- 1. Load Configuration from Environment --- +# Best practice is to centralize configuration loading at the top. + +# API Keys (required) +DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY") +GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") + +# Model Names (optional, with defaults) +# Allows changing the model version without code changes. +DEEPSEEK_MODEL = os.getenv("DEEPSEEK_MODEL_NAME", "deepseek-chat") +GEMINI_MODEL = os.getenv("GEMINI_MODEL_NAME", "gemini-1.5-flash-latest") + +# --- 2. Initialize API Clients and URLs --- +# Initialize any clients or constants that will be used by the providers. +deepseek_client = OpenAI(api_key=DEEPSEEK_API_KEY, base_url="https://api.deepseek.com") +GEMINI_URL = f"https://generativelanguage.googleapis.com/v1beta/models/{GEMINI_MODEL}:generateContent?key={GEMINI_API_KEY}" + + +# --- 3. Provider Interface and Implementations --- + +class LLMProvider(ABC): + """Abstract base class ('Interface') for all LLM providers.""" + @abstractmethod + async def generate_response(self, prompt: str) -> str: + """Generates a response from the LLM.""" + pass + +@final +class DeepSeekProvider(LLMProvider): + """Provider for the DeepSeek API.""" + def __init__(self, model_name: str): + self.model = model_name + print(f"DeepSeekProvider initialized with model: {self.model}") + + async def generate_response(self, prompt: str) -> str: + try: + chat_completion = deepseek_client.chat.completions.create( + model=self.model, + messages=[ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": prompt}, + ], + stream=False + ) + return chat_completion.choices[0].message.content + except Exception as e: + print(f"DeepSeek Error: {e}") + raise # Re-raise the exception to be handled by the main app + +@final +class GeminiProvider(LLMProvider): + """Provider for the Google Gemini API.""" + def __init__(self, api_url: str): + self.url = api_url + print(f"GeminiProvider initialized for URL: {self.url.split('?')[0]}") + + async def generate_response(self, prompt: str) -> str: + payload = {"contents": [{"parts": [{"text": prompt}]}]} + headers = {"Content-Type": "application/json"} + + try: + async with httpx.AsyncClient() as client: + response = await client.post(self.url, json=payload, headers=headers) + response.raise_for_status() + data = response.json() + return data['candidates'][0]['content']['parts'][0]['text'] + except (httpx.HTTPStatusError, KeyError, IndexError) as e: + print(f"Gemini Error: {e}") + raise # Re-raise for the main app to handle + +# --- 4. The Factory Function --- +# This is where we instantiate our concrete providers with their configuration. + +_providers = { + "deepseek": DeepSeekProvider(model_name=DEEPSEEK_MODEL), + "gemini": GeminiProvider(api_url=GEMINI_URL) +} + +def get_llm_provider(model_name: str) -> LLMProvider: + """Factory function to get the appropriate, pre-configured LLM provider.""" + provider = _providers.get(model_name) + if not provider: + raise ValueError(f"Unsupported model provider: '{model_name}'. Supported providers are: {list(_providers.keys())}") + return provider + diff --git a/ai-hub/app/main.py b/ai-hub/app/main.py index 58392c0..05d3952 100644 --- a/ai-hub/app/main.py +++ b/ai-hub/app/main.py @@ -1,61 +1,47 @@ # main.py -import os -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, Query from pydantic import BaseModel +from typing import Literal from dotenv import load_dotenv -from openai import OpenAI + +# Import our new factory function +from app.llm_providers import get_llm_provider # --- 1. Application Setup --- load_dotenv() app = FastAPI( title="AI Model Hub Service", - description="A central hub to route requests to various LLMs.", - version="0.1.2", + description="A extensible hub to route requests to various LLMs using a Factory Pattern.", + version="0.0.0", ) -# --- 2. Pydantic Models for Request/Response --- +# --- 2. Pydantic Models --- class ChatRequest(BaseModel): prompt: str -# --- 3. Configure DeepSeek API --- -api_key = os.getenv("DEEPSEEK_API_KEY") -if not api_key: - raise ValueError("DEEPSEEK_API_KEY not found in environment variables. Please set it in the .env file.") - -# MODIFIED: Updated base_url to match the primary documentation -client = OpenAI( - api_key=api_key, - base_url="https://api.deepseek.com" -) - -# --- 4. API Endpoint Definition --- -@app.post("/chat") -async def chat_handler(request: ChatRequest): - """ - Accepts a user prompt and returns a response from the DeepSeek model. - """ - try: - chat_completion = client.chat.completions.create( - model="deepseek-chat", - # MODIFIED: Added a system message for better model instruction - messages=[ - {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": request.prompt}, - ], - stream=False # Explicitly setting stream to false as in the example - ) - - response_text = chat_completion.choices[0].message.content - return {"response": response_text} - - except Exception as e: - print(f"An error occurred: {e}") - raise HTTPException(status_code=500, detail="Failed to get response from the model.") - +# --- 3. API Endpoints --- @app.get("/") def read_root(): - """ - Root endpoint to confirm the server is running. - """ - return {"status": "AI Model Hub is running!"} \ No newline at end of file + return {"status": "AI Model Hub is running!"} + +@app.post("/chat") +async def chat_handler( + request: ChatRequest, + model: Literal["deepseek", "gemini"] = Query("deepseek", description="The AI model to use.") +): + try: + # Use the factory to get the correct provider instance + provider = get_llm_provider(model) + + # Call the method on the instance. We don't need to know if it's + # Gemini or DeepSeek, only that it fulfills the "contract". + response_text = await provider.generate_response(request.prompt) + + return {"response": response_text, "model_used": model} + except ValueError as e: + # This catches errors from the factory (e.g., unsupported model) + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + # This catches errors from the provider's API call + raise HTTPException(status_code=500, detail=f"An error occurred with the {model} API: {e}") \ No newline at end of file diff --git a/ai-hub/app/llm_providers.py b/ai-hub/app/llm_providers.py new file mode 100644 index 0000000..75629c7 --- /dev/null +++ b/ai-hub/app/llm_providers.py @@ -0,0 +1,91 @@ +import os +import httpx +from abc import ABC, abstractmethod +from openai import OpenAI +from typing import final + +# --- 1. Load Configuration from Environment --- +# Best practice is to centralize configuration loading at the top. + +# API Keys (required) +DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY") +GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") + +# Model Names (optional, with defaults) +# Allows changing the model version without code changes. +DEEPSEEK_MODEL = os.getenv("DEEPSEEK_MODEL_NAME", "deepseek-chat") +GEMINI_MODEL = os.getenv("GEMINI_MODEL_NAME", "gemini-1.5-flash-latest") + +# --- 2. Initialize API Clients and URLs --- +# Initialize any clients or constants that will be used by the providers. +deepseek_client = OpenAI(api_key=DEEPSEEK_API_KEY, base_url="https://api.deepseek.com") +GEMINI_URL = f"https://generativelanguage.googleapis.com/v1beta/models/{GEMINI_MODEL}:generateContent?key={GEMINI_API_KEY}" + + +# --- 3. Provider Interface and Implementations --- + +class LLMProvider(ABC): + """Abstract base class ('Interface') for all LLM providers.""" + @abstractmethod + async def generate_response(self, prompt: str) -> str: + """Generates a response from the LLM.""" + pass + +@final +class DeepSeekProvider(LLMProvider): + """Provider for the DeepSeek API.""" + def __init__(self, model_name: str): + self.model = model_name + print(f"DeepSeekProvider initialized with model: {self.model}") + + async def generate_response(self, prompt: str) -> str: + try: + chat_completion = deepseek_client.chat.completions.create( + model=self.model, + messages=[ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": prompt}, + ], + stream=False + ) + return chat_completion.choices[0].message.content + except Exception as e: + print(f"DeepSeek Error: {e}") + raise # Re-raise the exception to be handled by the main app + +@final +class GeminiProvider(LLMProvider): + """Provider for the Google Gemini API.""" + def __init__(self, api_url: str): + self.url = api_url + print(f"GeminiProvider initialized for URL: {self.url.split('?')[0]}") + + async def generate_response(self, prompt: str) -> str: + payload = {"contents": [{"parts": [{"text": prompt}]}]} + headers = {"Content-Type": "application/json"} + + try: + async with httpx.AsyncClient() as client: + response = await client.post(self.url, json=payload, headers=headers) + response.raise_for_status() + data = response.json() + return data['candidates'][0]['content']['parts'][0]['text'] + except (httpx.HTTPStatusError, KeyError, IndexError) as e: + print(f"Gemini Error: {e}") + raise # Re-raise for the main app to handle + +# --- 4. The Factory Function --- +# This is where we instantiate our concrete providers with their configuration. + +_providers = { + "deepseek": DeepSeekProvider(model_name=DEEPSEEK_MODEL), + "gemini": GeminiProvider(api_url=GEMINI_URL) +} + +def get_llm_provider(model_name: str) -> LLMProvider: + """Factory function to get the appropriate, pre-configured LLM provider.""" + provider = _providers.get(model_name) + if not provider: + raise ValueError(f"Unsupported model provider: '{model_name}'. Supported providers are: {list(_providers.keys())}") + return provider + diff --git a/ai-hub/app/main.py b/ai-hub/app/main.py index 58392c0..05d3952 100644 --- a/ai-hub/app/main.py +++ b/ai-hub/app/main.py @@ -1,61 +1,47 @@ # main.py -import os -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, Query from pydantic import BaseModel +from typing import Literal from dotenv import load_dotenv -from openai import OpenAI + +# Import our new factory function +from app.llm_providers import get_llm_provider # --- 1. Application Setup --- load_dotenv() app = FastAPI( title="AI Model Hub Service", - description="A central hub to route requests to various LLMs.", - version="0.1.2", + description="A extensible hub to route requests to various LLMs using a Factory Pattern.", + version="0.0.0", ) -# --- 2. Pydantic Models for Request/Response --- +# --- 2. Pydantic Models --- class ChatRequest(BaseModel): prompt: str -# --- 3. Configure DeepSeek API --- -api_key = os.getenv("DEEPSEEK_API_KEY") -if not api_key: - raise ValueError("DEEPSEEK_API_KEY not found in environment variables. Please set it in the .env file.") - -# MODIFIED: Updated base_url to match the primary documentation -client = OpenAI( - api_key=api_key, - base_url="https://api.deepseek.com" -) - -# --- 4. API Endpoint Definition --- -@app.post("/chat") -async def chat_handler(request: ChatRequest): - """ - Accepts a user prompt and returns a response from the DeepSeek model. - """ - try: - chat_completion = client.chat.completions.create( - model="deepseek-chat", - # MODIFIED: Added a system message for better model instruction - messages=[ - {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": request.prompt}, - ], - stream=False # Explicitly setting stream to false as in the example - ) - - response_text = chat_completion.choices[0].message.content - return {"response": response_text} - - except Exception as e: - print(f"An error occurred: {e}") - raise HTTPException(status_code=500, detail="Failed to get response from the model.") - +# --- 3. API Endpoints --- @app.get("/") def read_root(): - """ - Root endpoint to confirm the server is running. - """ - return {"status": "AI Model Hub is running!"} \ No newline at end of file + return {"status": "AI Model Hub is running!"} + +@app.post("/chat") +async def chat_handler( + request: ChatRequest, + model: Literal["deepseek", "gemini"] = Query("deepseek", description="The AI model to use.") +): + try: + # Use the factory to get the correct provider instance + provider = get_llm_provider(model) + + # Call the method on the instance. We don't need to know if it's + # Gemini or DeepSeek, only that it fulfills the "contract". + response_text = await provider.generate_response(request.prompt) + + return {"response": response_text, "model_used": model} + except ValueError as e: + # This catches errors from the factory (e.g., unsupported model) + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + # This catches errors from the provider's API call + raise HTTPException(status_code=500, detail=f"An error occurred with the {model} API: {e}") \ No newline at end of file diff --git a/ai-hub/integration_tests/test_integration.py b/ai-hub/integration_tests/test_integration.py new file mode 100644 index 0000000..586f534 --- /dev/null +++ b/ai-hub/integration_tests/test_integration.py @@ -0,0 +1,83 @@ +import pytest +import httpx + +# The base URL for the local server started by the run_tests.sh script +BASE_URL = "http://127.0.0.1:8000" + +# A common prompt to be used for the tests +TEST_PROMPT = "Explain the theory of relativity in one sentence." + +async def test_root_endpoint(): + """Tests if the root endpoint is alive and returns the correct status.""" + async with httpx.AsyncClient() as client: + response = await client.get(f"{BASE_URL}/") + + assert response.status_code == 200 + assert response.json() == {"status": "AI Model Hub is running!"} + +async def test_chat_endpoint_deepseek(): + """ + Tests the /chat endpoint using the default 'deepseek' model. + """ + url = f"{BASE_URL}/chat?model=deepseek" + payload = {"prompt": TEST_PROMPT} + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post(url, json=payload) + + # 1. Check for a successful response + assert response.status_code == 200, f"Expected status 200, but got {response.status_code}. Response: {response.text}" + + # 2. Check the response structure + data = response.json() + assert "response" in data + assert "model_used" in data + + # 3. Validate the content + assert data["model_used"] == "deepseek" + assert isinstance(data["response"], str) + assert len(data["response"]) > 0 + print(f"\n✅ DeepSeek Response: {data['response'][:80]}...") + + +async def test_chat_endpoint_gemini(): + """ + Tests the /chat endpoint explicitly requesting the 'gemini' model. + """ + url = f"{BASE_URL}/chat?model=gemini" + payload = {"prompt": TEST_PROMPT} + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post(url, json=payload) + + # 1. Check for a successful response + assert response.status_code == 200, f"Expected status 200, but got {response.status_code}. Response: {response.text}" + + # 2. Check the response structure + data = response.json() + assert "response" in data + assert "model_used" in data + + # 3. Validate the content + assert data["model_used"] == "gemini" + assert isinstance(data["response"], str) + assert len(data["response"]) > 0 + print(f"\n✅ Gemini Response: {data['response'][:80]}...") + + +async def test_unsupported_model(): + """ + Tests the API's error handling for an invalid model name. + """ + # Note: The 'model' parameter is intentionally incorrect here. + url = f"{BASE_URL}/chat?model=unsupported_model_123" + payload = {"prompt": TEST_PROMPT} + + async with httpx.AsyncClient() as client: + response = await client.post(url, json=payload) + + # Expect a 422 Unprocessable Entity error because the 'model' query parameter + # does not match the allowed Literal["deepseek", "gemini"] values. + assert response.status_code == 422 + + diff --git a/ai-hub/app/llm_providers.py b/ai-hub/app/llm_providers.py new file mode 100644 index 0000000..75629c7 --- /dev/null +++ b/ai-hub/app/llm_providers.py @@ -0,0 +1,91 @@ +import os +import httpx +from abc import ABC, abstractmethod +from openai import OpenAI +from typing import final + +# --- 1. Load Configuration from Environment --- +# Best practice is to centralize configuration loading at the top. + +# API Keys (required) +DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY") +GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") + +# Model Names (optional, with defaults) +# Allows changing the model version without code changes. +DEEPSEEK_MODEL = os.getenv("DEEPSEEK_MODEL_NAME", "deepseek-chat") +GEMINI_MODEL = os.getenv("GEMINI_MODEL_NAME", "gemini-1.5-flash-latest") + +# --- 2. Initialize API Clients and URLs --- +# Initialize any clients or constants that will be used by the providers. +deepseek_client = OpenAI(api_key=DEEPSEEK_API_KEY, base_url="https://api.deepseek.com") +GEMINI_URL = f"https://generativelanguage.googleapis.com/v1beta/models/{GEMINI_MODEL}:generateContent?key={GEMINI_API_KEY}" + + +# --- 3. Provider Interface and Implementations --- + +class LLMProvider(ABC): + """Abstract base class ('Interface') for all LLM providers.""" + @abstractmethod + async def generate_response(self, prompt: str) -> str: + """Generates a response from the LLM.""" + pass + +@final +class DeepSeekProvider(LLMProvider): + """Provider for the DeepSeek API.""" + def __init__(self, model_name: str): + self.model = model_name + print(f"DeepSeekProvider initialized with model: {self.model}") + + async def generate_response(self, prompt: str) -> str: + try: + chat_completion = deepseek_client.chat.completions.create( + model=self.model, + messages=[ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": prompt}, + ], + stream=False + ) + return chat_completion.choices[0].message.content + except Exception as e: + print(f"DeepSeek Error: {e}") + raise # Re-raise the exception to be handled by the main app + +@final +class GeminiProvider(LLMProvider): + """Provider for the Google Gemini API.""" + def __init__(self, api_url: str): + self.url = api_url + print(f"GeminiProvider initialized for URL: {self.url.split('?')[0]}") + + async def generate_response(self, prompt: str) -> str: + payload = {"contents": [{"parts": [{"text": prompt}]}]} + headers = {"Content-Type": "application/json"} + + try: + async with httpx.AsyncClient() as client: + response = await client.post(self.url, json=payload, headers=headers) + response.raise_for_status() + data = response.json() + return data['candidates'][0]['content']['parts'][0]['text'] + except (httpx.HTTPStatusError, KeyError, IndexError) as e: + print(f"Gemini Error: {e}") + raise # Re-raise for the main app to handle + +# --- 4. The Factory Function --- +# This is where we instantiate our concrete providers with their configuration. + +_providers = { + "deepseek": DeepSeekProvider(model_name=DEEPSEEK_MODEL), + "gemini": GeminiProvider(api_url=GEMINI_URL) +} + +def get_llm_provider(model_name: str) -> LLMProvider: + """Factory function to get the appropriate, pre-configured LLM provider.""" + provider = _providers.get(model_name) + if not provider: + raise ValueError(f"Unsupported model provider: '{model_name}'. Supported providers are: {list(_providers.keys())}") + return provider + diff --git a/ai-hub/app/main.py b/ai-hub/app/main.py index 58392c0..05d3952 100644 --- a/ai-hub/app/main.py +++ b/ai-hub/app/main.py @@ -1,61 +1,47 @@ # main.py -import os -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, Query from pydantic import BaseModel +from typing import Literal from dotenv import load_dotenv -from openai import OpenAI + +# Import our new factory function +from app.llm_providers import get_llm_provider # --- 1. Application Setup --- load_dotenv() app = FastAPI( title="AI Model Hub Service", - description="A central hub to route requests to various LLMs.", - version="0.1.2", + description="A extensible hub to route requests to various LLMs using a Factory Pattern.", + version="0.0.0", ) -# --- 2. Pydantic Models for Request/Response --- +# --- 2. Pydantic Models --- class ChatRequest(BaseModel): prompt: str -# --- 3. Configure DeepSeek API --- -api_key = os.getenv("DEEPSEEK_API_KEY") -if not api_key: - raise ValueError("DEEPSEEK_API_KEY not found in environment variables. Please set it in the .env file.") - -# MODIFIED: Updated base_url to match the primary documentation -client = OpenAI( - api_key=api_key, - base_url="https://api.deepseek.com" -) - -# --- 4. API Endpoint Definition --- -@app.post("/chat") -async def chat_handler(request: ChatRequest): - """ - Accepts a user prompt and returns a response from the DeepSeek model. - """ - try: - chat_completion = client.chat.completions.create( - model="deepseek-chat", - # MODIFIED: Added a system message for better model instruction - messages=[ - {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": request.prompt}, - ], - stream=False # Explicitly setting stream to false as in the example - ) - - response_text = chat_completion.choices[0].message.content - return {"response": response_text} - - except Exception as e: - print(f"An error occurred: {e}") - raise HTTPException(status_code=500, detail="Failed to get response from the model.") - +# --- 3. API Endpoints --- @app.get("/") def read_root(): - """ - Root endpoint to confirm the server is running. - """ - return {"status": "AI Model Hub is running!"} \ No newline at end of file + return {"status": "AI Model Hub is running!"} + +@app.post("/chat") +async def chat_handler( + request: ChatRequest, + model: Literal["deepseek", "gemini"] = Query("deepseek", description="The AI model to use.") +): + try: + # Use the factory to get the correct provider instance + provider = get_llm_provider(model) + + # Call the method on the instance. We don't need to know if it's + # Gemini or DeepSeek, only that it fulfills the "contract". + response_text = await provider.generate_response(request.prompt) + + return {"response": response_text, "model_used": model} + except ValueError as e: + # This catches errors from the factory (e.g., unsupported model) + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + # This catches errors from the provider's API call + raise HTTPException(status_code=500, detail=f"An error occurred with the {model} API: {e}") \ No newline at end of file diff --git a/ai-hub/integration_tests/test_integration.py b/ai-hub/integration_tests/test_integration.py new file mode 100644 index 0000000..586f534 --- /dev/null +++ b/ai-hub/integration_tests/test_integration.py @@ -0,0 +1,83 @@ +import pytest +import httpx + +# The base URL for the local server started by the run_tests.sh script +BASE_URL = "http://127.0.0.1:8000" + +# A common prompt to be used for the tests +TEST_PROMPT = "Explain the theory of relativity in one sentence." + +async def test_root_endpoint(): + """Tests if the root endpoint is alive and returns the correct status.""" + async with httpx.AsyncClient() as client: + response = await client.get(f"{BASE_URL}/") + + assert response.status_code == 200 + assert response.json() == {"status": "AI Model Hub is running!"} + +async def test_chat_endpoint_deepseek(): + """ + Tests the /chat endpoint using the default 'deepseek' model. + """ + url = f"{BASE_URL}/chat?model=deepseek" + payload = {"prompt": TEST_PROMPT} + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post(url, json=payload) + + # 1. Check for a successful response + assert response.status_code == 200, f"Expected status 200, but got {response.status_code}. Response: {response.text}" + + # 2. Check the response structure + data = response.json() + assert "response" in data + assert "model_used" in data + + # 3. Validate the content + assert data["model_used"] == "deepseek" + assert isinstance(data["response"], str) + assert len(data["response"]) > 0 + print(f"\n✅ DeepSeek Response: {data['response'][:80]}...") + + +async def test_chat_endpoint_gemini(): + """ + Tests the /chat endpoint explicitly requesting the 'gemini' model. + """ + url = f"{BASE_URL}/chat?model=gemini" + payload = {"prompt": TEST_PROMPT} + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post(url, json=payload) + + # 1. Check for a successful response + assert response.status_code == 200, f"Expected status 200, but got {response.status_code}. Response: {response.text}" + + # 2. Check the response structure + data = response.json() + assert "response" in data + assert "model_used" in data + + # 3. Validate the content + assert data["model_used"] == "gemini" + assert isinstance(data["response"], str) + assert len(data["response"]) > 0 + print(f"\n✅ Gemini Response: {data['response'][:80]}...") + + +async def test_unsupported_model(): + """ + Tests the API's error handling for an invalid model name. + """ + # Note: The 'model' parameter is intentionally incorrect here. + url = f"{BASE_URL}/chat?model=unsupported_model_123" + payload = {"prompt": TEST_PROMPT} + + async with httpx.AsyncClient() as client: + response = await client.post(url, json=payload) + + # Expect a 422 Unprocessable Entity error because the 'model' query parameter + # does not match the allowed Literal["deepseek", "gemini"] values. + assert response.status_code == 422 + + diff --git a/ai-hub/integration_tests/test_service.py b/ai-hub/integration_tests/test_service.py deleted file mode 100644 index 7e77e25..0000000 --- a/ai-hub/integration_tests/test_service.py +++ /dev/null @@ -1,62 +0,0 @@ -# integration_tests/test_service.py - -import requests -import os -from dotenv import load_dotenv - -# --- Configuration --- -# The base URL for our running service. -# Note: We use http://ai-hub:8000 when running Docker-to-Docker, -# but http://127.0.0.1:8000 when running from the host machine. -# For simplicity, we will run this script from the host. -BASE_URL = "http://127.0.0.1:8000" - -# Load the .env file to check if the API key is set -load_dotenv() -API_KEY = os.getenv("DEEPSEEK_API_KEY") - -def test_root_endpoint(): - """Checks if the service is alive.""" - print("Testing root endpoint...") - response = requests.get(f"{BASE_URL}/") - - assert response.status_code == 200 - assert response.json()["status"] == "AI Model Hub is running!" - print("Root endpoint test: PASSED") - -def test_chat_endpoint(): - """ - Sends a real prompt to the /chat endpoint and verifies a valid response. - This will make a REAL API call to DeepSeek and requires a valid key. - """ - print("\nTesting /chat endpoint...") - if not API_KEY or "YOUR_API_KEY" in API_KEY: - print("SKIPPING test: DEEPSEEK_API_KEY not set in .env file.") - return - - json_payload = {"prompt": "Explain what an integration test is in one sentence."} - - try: - response = requests.post(f"{BASE_URL}/chat", json=json_payload, timeout=30) - - # Check for successful HTTP status - assert response.status_code == 200 - - # Check the response body - data = response.json() - assert "response" in data - assert isinstance(data["response"], str) - assert len(data["response"]) > 0 - - print(f"Received response: '{data['response']}'") - print("/chat endpoint test: PASSED") - - except requests.exceptions.RequestException as e: - print(f"/chat endpoint test: FAILED - {e}") - assert False, f"Request failed: {e}" - -if __name__ == "__main__": - print("--- Running Integration Tests ---") - test_root_endpoint() - test_chat_endpoint() - print("\n--- All tests completed ---") \ No newline at end of file diff --git a/ai-hub/app/llm_providers.py b/ai-hub/app/llm_providers.py new file mode 100644 index 0000000..75629c7 --- /dev/null +++ b/ai-hub/app/llm_providers.py @@ -0,0 +1,91 @@ +import os +import httpx +from abc import ABC, abstractmethod +from openai import OpenAI +from typing import final + +# --- 1. Load Configuration from Environment --- +# Best practice is to centralize configuration loading at the top. + +# API Keys (required) +DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY") +GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") + +# Model Names (optional, with defaults) +# Allows changing the model version without code changes. +DEEPSEEK_MODEL = os.getenv("DEEPSEEK_MODEL_NAME", "deepseek-chat") +GEMINI_MODEL = os.getenv("GEMINI_MODEL_NAME", "gemini-1.5-flash-latest") + +# --- 2. Initialize API Clients and URLs --- +# Initialize any clients or constants that will be used by the providers. +deepseek_client = OpenAI(api_key=DEEPSEEK_API_KEY, base_url="https://api.deepseek.com") +GEMINI_URL = f"https://generativelanguage.googleapis.com/v1beta/models/{GEMINI_MODEL}:generateContent?key={GEMINI_API_KEY}" + + +# --- 3. Provider Interface and Implementations --- + +class LLMProvider(ABC): + """Abstract base class ('Interface') for all LLM providers.""" + @abstractmethod + async def generate_response(self, prompt: str) -> str: + """Generates a response from the LLM.""" + pass + +@final +class DeepSeekProvider(LLMProvider): + """Provider for the DeepSeek API.""" + def __init__(self, model_name: str): + self.model = model_name + print(f"DeepSeekProvider initialized with model: {self.model}") + + async def generate_response(self, prompt: str) -> str: + try: + chat_completion = deepseek_client.chat.completions.create( + model=self.model, + messages=[ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": prompt}, + ], + stream=False + ) + return chat_completion.choices[0].message.content + except Exception as e: + print(f"DeepSeek Error: {e}") + raise # Re-raise the exception to be handled by the main app + +@final +class GeminiProvider(LLMProvider): + """Provider for the Google Gemini API.""" + def __init__(self, api_url: str): + self.url = api_url + print(f"GeminiProvider initialized for URL: {self.url.split('?')[0]}") + + async def generate_response(self, prompt: str) -> str: + payload = {"contents": [{"parts": [{"text": prompt}]}]} + headers = {"Content-Type": "application/json"} + + try: + async with httpx.AsyncClient() as client: + response = await client.post(self.url, json=payload, headers=headers) + response.raise_for_status() + data = response.json() + return data['candidates'][0]['content']['parts'][0]['text'] + except (httpx.HTTPStatusError, KeyError, IndexError) as e: + print(f"Gemini Error: {e}") + raise # Re-raise for the main app to handle + +# --- 4. The Factory Function --- +# This is where we instantiate our concrete providers with their configuration. + +_providers = { + "deepseek": DeepSeekProvider(model_name=DEEPSEEK_MODEL), + "gemini": GeminiProvider(api_url=GEMINI_URL) +} + +def get_llm_provider(model_name: str) -> LLMProvider: + """Factory function to get the appropriate, pre-configured LLM provider.""" + provider = _providers.get(model_name) + if not provider: + raise ValueError(f"Unsupported model provider: '{model_name}'. Supported providers are: {list(_providers.keys())}") + return provider + diff --git a/ai-hub/app/main.py b/ai-hub/app/main.py index 58392c0..05d3952 100644 --- a/ai-hub/app/main.py +++ b/ai-hub/app/main.py @@ -1,61 +1,47 @@ # main.py -import os -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, Query from pydantic import BaseModel +from typing import Literal from dotenv import load_dotenv -from openai import OpenAI + +# Import our new factory function +from app.llm_providers import get_llm_provider # --- 1. Application Setup --- load_dotenv() app = FastAPI( title="AI Model Hub Service", - description="A central hub to route requests to various LLMs.", - version="0.1.2", + description="A extensible hub to route requests to various LLMs using a Factory Pattern.", + version="0.0.0", ) -# --- 2. Pydantic Models for Request/Response --- +# --- 2. Pydantic Models --- class ChatRequest(BaseModel): prompt: str -# --- 3. Configure DeepSeek API --- -api_key = os.getenv("DEEPSEEK_API_KEY") -if not api_key: - raise ValueError("DEEPSEEK_API_KEY not found in environment variables. Please set it in the .env file.") - -# MODIFIED: Updated base_url to match the primary documentation -client = OpenAI( - api_key=api_key, - base_url="https://api.deepseek.com" -) - -# --- 4. API Endpoint Definition --- -@app.post("/chat") -async def chat_handler(request: ChatRequest): - """ - Accepts a user prompt and returns a response from the DeepSeek model. - """ - try: - chat_completion = client.chat.completions.create( - model="deepseek-chat", - # MODIFIED: Added a system message for better model instruction - messages=[ - {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": request.prompt}, - ], - stream=False # Explicitly setting stream to false as in the example - ) - - response_text = chat_completion.choices[0].message.content - return {"response": response_text} - - except Exception as e: - print(f"An error occurred: {e}") - raise HTTPException(status_code=500, detail="Failed to get response from the model.") - +# --- 3. API Endpoints --- @app.get("/") def read_root(): - """ - Root endpoint to confirm the server is running. - """ - return {"status": "AI Model Hub is running!"} \ No newline at end of file + return {"status": "AI Model Hub is running!"} + +@app.post("/chat") +async def chat_handler( + request: ChatRequest, + model: Literal["deepseek", "gemini"] = Query("deepseek", description="The AI model to use.") +): + try: + # Use the factory to get the correct provider instance + provider = get_llm_provider(model) + + # Call the method on the instance. We don't need to know if it's + # Gemini or DeepSeek, only that it fulfills the "contract". + response_text = await provider.generate_response(request.prompt) + + return {"response": response_text, "model_used": model} + except ValueError as e: + # This catches errors from the factory (e.g., unsupported model) + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + # This catches errors from the provider's API call + raise HTTPException(status_code=500, detail=f"An error occurred with the {model} API: {e}") \ No newline at end of file diff --git a/ai-hub/integration_tests/test_integration.py b/ai-hub/integration_tests/test_integration.py new file mode 100644 index 0000000..586f534 --- /dev/null +++ b/ai-hub/integration_tests/test_integration.py @@ -0,0 +1,83 @@ +import pytest +import httpx + +# The base URL for the local server started by the run_tests.sh script +BASE_URL = "http://127.0.0.1:8000" + +# A common prompt to be used for the tests +TEST_PROMPT = "Explain the theory of relativity in one sentence." + +async def test_root_endpoint(): + """Tests if the root endpoint is alive and returns the correct status.""" + async with httpx.AsyncClient() as client: + response = await client.get(f"{BASE_URL}/") + + assert response.status_code == 200 + assert response.json() == {"status": "AI Model Hub is running!"} + +async def test_chat_endpoint_deepseek(): + """ + Tests the /chat endpoint using the default 'deepseek' model. + """ + url = f"{BASE_URL}/chat?model=deepseek" + payload = {"prompt": TEST_PROMPT} + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post(url, json=payload) + + # 1. Check for a successful response + assert response.status_code == 200, f"Expected status 200, but got {response.status_code}. Response: {response.text}" + + # 2. Check the response structure + data = response.json() + assert "response" in data + assert "model_used" in data + + # 3. Validate the content + assert data["model_used"] == "deepseek" + assert isinstance(data["response"], str) + assert len(data["response"]) > 0 + print(f"\n✅ DeepSeek Response: {data['response'][:80]}...") + + +async def test_chat_endpoint_gemini(): + """ + Tests the /chat endpoint explicitly requesting the 'gemini' model. + """ + url = f"{BASE_URL}/chat?model=gemini" + payload = {"prompt": TEST_PROMPT} + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post(url, json=payload) + + # 1. Check for a successful response + assert response.status_code == 200, f"Expected status 200, but got {response.status_code}. Response: {response.text}" + + # 2. Check the response structure + data = response.json() + assert "response" in data + assert "model_used" in data + + # 3. Validate the content + assert data["model_used"] == "gemini" + assert isinstance(data["response"], str) + assert len(data["response"]) > 0 + print(f"\n✅ Gemini Response: {data['response'][:80]}...") + + +async def test_unsupported_model(): + """ + Tests the API's error handling for an invalid model name. + """ + # Note: The 'model' parameter is intentionally incorrect here. + url = f"{BASE_URL}/chat?model=unsupported_model_123" + payload = {"prompt": TEST_PROMPT} + + async with httpx.AsyncClient() as client: + response = await client.post(url, json=payload) + + # Expect a 422 Unprocessable Entity error because the 'model' query parameter + # does not match the allowed Literal["deepseek", "gemini"] values. + assert response.status_code == 422 + + diff --git a/ai-hub/integration_tests/test_service.py b/ai-hub/integration_tests/test_service.py deleted file mode 100644 index 7e77e25..0000000 --- a/ai-hub/integration_tests/test_service.py +++ /dev/null @@ -1,62 +0,0 @@ -# integration_tests/test_service.py - -import requests -import os -from dotenv import load_dotenv - -# --- Configuration --- -# The base URL for our running service. -# Note: We use http://ai-hub:8000 when running Docker-to-Docker, -# but http://127.0.0.1:8000 when running from the host machine. -# For simplicity, we will run this script from the host. -BASE_URL = "http://127.0.0.1:8000" - -# Load the .env file to check if the API key is set -load_dotenv() -API_KEY = os.getenv("DEEPSEEK_API_KEY") - -def test_root_endpoint(): - """Checks if the service is alive.""" - print("Testing root endpoint...") - response = requests.get(f"{BASE_URL}/") - - assert response.status_code == 200 - assert response.json()["status"] == "AI Model Hub is running!" - print("Root endpoint test: PASSED") - -def test_chat_endpoint(): - """ - Sends a real prompt to the /chat endpoint and verifies a valid response. - This will make a REAL API call to DeepSeek and requires a valid key. - """ - print("\nTesting /chat endpoint...") - if not API_KEY or "YOUR_API_KEY" in API_KEY: - print("SKIPPING test: DEEPSEEK_API_KEY not set in .env file.") - return - - json_payload = {"prompt": "Explain what an integration test is in one sentence."} - - try: - response = requests.post(f"{BASE_URL}/chat", json=json_payload, timeout=30) - - # Check for successful HTTP status - assert response.status_code == 200 - - # Check the response body - data = response.json() - assert "response" in data - assert isinstance(data["response"], str) - assert len(data["response"]) > 0 - - print(f"Received response: '{data['response']}'") - print("/chat endpoint test: PASSED") - - except requests.exceptions.RequestException as e: - print(f"/chat endpoint test: FAILED - {e}") - assert False, f"Request failed: {e}" - -if __name__ == "__main__": - print("--- Running Integration Tests ---") - test_root_endpoint() - test_chat_endpoint() - print("\n--- All tests completed ---") \ No newline at end of file diff --git a/ai-hub/requirements.txt b/ai-hub/requirements.txt index b45a893..4077554 100644 --- a/ai-hub/requirements.txt +++ b/ai-hub/requirements.txt @@ -4,4 +4,8 @@ python-dotenv openai pytest -requests \ No newline at end of file +requests +anyio +pytest-asyncio +pytest-tornasync +pytest-trio \ No newline at end of file diff --git a/ai-hub/app/llm_providers.py b/ai-hub/app/llm_providers.py new file mode 100644 index 0000000..75629c7 --- /dev/null +++ b/ai-hub/app/llm_providers.py @@ -0,0 +1,91 @@ +import os +import httpx +from abc import ABC, abstractmethod +from openai import OpenAI +from typing import final + +# --- 1. Load Configuration from Environment --- +# Best practice is to centralize configuration loading at the top. + +# API Keys (required) +DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY") +GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") + +# Model Names (optional, with defaults) +# Allows changing the model version without code changes. +DEEPSEEK_MODEL = os.getenv("DEEPSEEK_MODEL_NAME", "deepseek-chat") +GEMINI_MODEL = os.getenv("GEMINI_MODEL_NAME", "gemini-1.5-flash-latest") + +# --- 2. Initialize API Clients and URLs --- +# Initialize any clients or constants that will be used by the providers. +deepseek_client = OpenAI(api_key=DEEPSEEK_API_KEY, base_url="https://api.deepseek.com") +GEMINI_URL = f"https://generativelanguage.googleapis.com/v1beta/models/{GEMINI_MODEL}:generateContent?key={GEMINI_API_KEY}" + + +# --- 3. Provider Interface and Implementations --- + +class LLMProvider(ABC): + """Abstract base class ('Interface') for all LLM providers.""" + @abstractmethod + async def generate_response(self, prompt: str) -> str: + """Generates a response from the LLM.""" + pass + +@final +class DeepSeekProvider(LLMProvider): + """Provider for the DeepSeek API.""" + def __init__(self, model_name: str): + self.model = model_name + print(f"DeepSeekProvider initialized with model: {self.model}") + + async def generate_response(self, prompt: str) -> str: + try: + chat_completion = deepseek_client.chat.completions.create( + model=self.model, + messages=[ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": prompt}, + ], + stream=False + ) + return chat_completion.choices[0].message.content + except Exception as e: + print(f"DeepSeek Error: {e}") + raise # Re-raise the exception to be handled by the main app + +@final +class GeminiProvider(LLMProvider): + """Provider for the Google Gemini API.""" + def __init__(self, api_url: str): + self.url = api_url + print(f"GeminiProvider initialized for URL: {self.url.split('?')[0]}") + + async def generate_response(self, prompt: str) -> str: + payload = {"contents": [{"parts": [{"text": prompt}]}]} + headers = {"Content-Type": "application/json"} + + try: + async with httpx.AsyncClient() as client: + response = await client.post(self.url, json=payload, headers=headers) + response.raise_for_status() + data = response.json() + return data['candidates'][0]['content']['parts'][0]['text'] + except (httpx.HTTPStatusError, KeyError, IndexError) as e: + print(f"Gemini Error: {e}") + raise # Re-raise for the main app to handle + +# --- 4. The Factory Function --- +# This is where we instantiate our concrete providers with their configuration. + +_providers = { + "deepseek": DeepSeekProvider(model_name=DEEPSEEK_MODEL), + "gemini": GeminiProvider(api_url=GEMINI_URL) +} + +def get_llm_provider(model_name: str) -> LLMProvider: + """Factory function to get the appropriate, pre-configured LLM provider.""" + provider = _providers.get(model_name) + if not provider: + raise ValueError(f"Unsupported model provider: '{model_name}'. Supported providers are: {list(_providers.keys())}") + return provider + diff --git a/ai-hub/app/main.py b/ai-hub/app/main.py index 58392c0..05d3952 100644 --- a/ai-hub/app/main.py +++ b/ai-hub/app/main.py @@ -1,61 +1,47 @@ # main.py -import os -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, Query from pydantic import BaseModel +from typing import Literal from dotenv import load_dotenv -from openai import OpenAI + +# Import our new factory function +from app.llm_providers import get_llm_provider # --- 1. Application Setup --- load_dotenv() app = FastAPI( title="AI Model Hub Service", - description="A central hub to route requests to various LLMs.", - version="0.1.2", + description="A extensible hub to route requests to various LLMs using a Factory Pattern.", + version="0.0.0", ) -# --- 2. Pydantic Models for Request/Response --- +# --- 2. Pydantic Models --- class ChatRequest(BaseModel): prompt: str -# --- 3. Configure DeepSeek API --- -api_key = os.getenv("DEEPSEEK_API_KEY") -if not api_key: - raise ValueError("DEEPSEEK_API_KEY not found in environment variables. Please set it in the .env file.") - -# MODIFIED: Updated base_url to match the primary documentation -client = OpenAI( - api_key=api_key, - base_url="https://api.deepseek.com" -) - -# --- 4. API Endpoint Definition --- -@app.post("/chat") -async def chat_handler(request: ChatRequest): - """ - Accepts a user prompt and returns a response from the DeepSeek model. - """ - try: - chat_completion = client.chat.completions.create( - model="deepseek-chat", - # MODIFIED: Added a system message for better model instruction - messages=[ - {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": request.prompt}, - ], - stream=False # Explicitly setting stream to false as in the example - ) - - response_text = chat_completion.choices[0].message.content - return {"response": response_text} - - except Exception as e: - print(f"An error occurred: {e}") - raise HTTPException(status_code=500, detail="Failed to get response from the model.") - +# --- 3. API Endpoints --- @app.get("/") def read_root(): - """ - Root endpoint to confirm the server is running. - """ - return {"status": "AI Model Hub is running!"} \ No newline at end of file + return {"status": "AI Model Hub is running!"} + +@app.post("/chat") +async def chat_handler( + request: ChatRequest, + model: Literal["deepseek", "gemini"] = Query("deepseek", description="The AI model to use.") +): + try: + # Use the factory to get the correct provider instance + provider = get_llm_provider(model) + + # Call the method on the instance. We don't need to know if it's + # Gemini or DeepSeek, only that it fulfills the "contract". + response_text = await provider.generate_response(request.prompt) + + return {"response": response_text, "model_used": model} + except ValueError as e: + # This catches errors from the factory (e.g., unsupported model) + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + # This catches errors from the provider's API call + raise HTTPException(status_code=500, detail=f"An error occurred with the {model} API: {e}") \ No newline at end of file diff --git a/ai-hub/integration_tests/test_integration.py b/ai-hub/integration_tests/test_integration.py new file mode 100644 index 0000000..586f534 --- /dev/null +++ b/ai-hub/integration_tests/test_integration.py @@ -0,0 +1,83 @@ +import pytest +import httpx + +# The base URL for the local server started by the run_tests.sh script +BASE_URL = "http://127.0.0.1:8000" + +# A common prompt to be used for the tests +TEST_PROMPT = "Explain the theory of relativity in one sentence." + +async def test_root_endpoint(): + """Tests if the root endpoint is alive and returns the correct status.""" + async with httpx.AsyncClient() as client: + response = await client.get(f"{BASE_URL}/") + + assert response.status_code == 200 + assert response.json() == {"status": "AI Model Hub is running!"} + +async def test_chat_endpoint_deepseek(): + """ + Tests the /chat endpoint using the default 'deepseek' model. + """ + url = f"{BASE_URL}/chat?model=deepseek" + payload = {"prompt": TEST_PROMPT} + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post(url, json=payload) + + # 1. Check for a successful response + assert response.status_code == 200, f"Expected status 200, but got {response.status_code}. Response: {response.text}" + + # 2. Check the response structure + data = response.json() + assert "response" in data + assert "model_used" in data + + # 3. Validate the content + assert data["model_used"] == "deepseek" + assert isinstance(data["response"], str) + assert len(data["response"]) > 0 + print(f"\n✅ DeepSeek Response: {data['response'][:80]}...") + + +async def test_chat_endpoint_gemini(): + """ + Tests the /chat endpoint explicitly requesting the 'gemini' model. + """ + url = f"{BASE_URL}/chat?model=gemini" + payload = {"prompt": TEST_PROMPT} + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post(url, json=payload) + + # 1. Check for a successful response + assert response.status_code == 200, f"Expected status 200, but got {response.status_code}. Response: {response.text}" + + # 2. Check the response structure + data = response.json() + assert "response" in data + assert "model_used" in data + + # 3. Validate the content + assert data["model_used"] == "gemini" + assert isinstance(data["response"], str) + assert len(data["response"]) > 0 + print(f"\n✅ Gemini Response: {data['response'][:80]}...") + + +async def test_unsupported_model(): + """ + Tests the API's error handling for an invalid model name. + """ + # Note: The 'model' parameter is intentionally incorrect here. + url = f"{BASE_URL}/chat?model=unsupported_model_123" + payload = {"prompt": TEST_PROMPT} + + async with httpx.AsyncClient() as client: + response = await client.post(url, json=payload) + + # Expect a 422 Unprocessable Entity error because the 'model' query parameter + # does not match the allowed Literal["deepseek", "gemini"] values. + assert response.status_code == 422 + + diff --git a/ai-hub/integration_tests/test_service.py b/ai-hub/integration_tests/test_service.py deleted file mode 100644 index 7e77e25..0000000 --- a/ai-hub/integration_tests/test_service.py +++ /dev/null @@ -1,62 +0,0 @@ -# integration_tests/test_service.py - -import requests -import os -from dotenv import load_dotenv - -# --- Configuration --- -# The base URL for our running service. -# Note: We use http://ai-hub:8000 when running Docker-to-Docker, -# but http://127.0.0.1:8000 when running from the host machine. -# For simplicity, we will run this script from the host. -BASE_URL = "http://127.0.0.1:8000" - -# Load the .env file to check if the API key is set -load_dotenv() -API_KEY = os.getenv("DEEPSEEK_API_KEY") - -def test_root_endpoint(): - """Checks if the service is alive.""" - print("Testing root endpoint...") - response = requests.get(f"{BASE_URL}/") - - assert response.status_code == 200 - assert response.json()["status"] == "AI Model Hub is running!" - print("Root endpoint test: PASSED") - -def test_chat_endpoint(): - """ - Sends a real prompt to the /chat endpoint and verifies a valid response. - This will make a REAL API call to DeepSeek and requires a valid key. - """ - print("\nTesting /chat endpoint...") - if not API_KEY or "YOUR_API_KEY" in API_KEY: - print("SKIPPING test: DEEPSEEK_API_KEY not set in .env file.") - return - - json_payload = {"prompt": "Explain what an integration test is in one sentence."} - - try: - response = requests.post(f"{BASE_URL}/chat", json=json_payload, timeout=30) - - # Check for successful HTTP status - assert response.status_code == 200 - - # Check the response body - data = response.json() - assert "response" in data - assert isinstance(data["response"], str) - assert len(data["response"]) > 0 - - print(f"Received response: '{data['response']}'") - print("/chat endpoint test: PASSED") - - except requests.exceptions.RequestException as e: - print(f"/chat endpoint test: FAILED - {e}") - assert False, f"Request failed: {e}" - -if __name__ == "__main__": - print("--- Running Integration Tests ---") - test_root_endpoint() - test_chat_endpoint() - print("\n--- All tests completed ---") \ No newline at end of file diff --git a/ai-hub/requirements.txt b/ai-hub/requirements.txt index b45a893..4077554 100644 --- a/ai-hub/requirements.txt +++ b/ai-hub/requirements.txt @@ -4,4 +4,8 @@ python-dotenv openai pytest -requests \ No newline at end of file +requests +anyio +pytest-asyncio +pytest-tornasync +pytest-trio \ No newline at end of file diff --git a/ai-hub/run_integration_tests.sh b/ai-hub/run_integration_tests.sh index e5e4376..b96e1b4 100644 --- a/ai-hub/run_integration_tests.sh +++ b/ai-hub/run_integration_tests.sh @@ -29,7 +29,7 @@ echo "--- Running Integration Test Script ---" # Execute the Python integration test script -python3 integration_tests/test_service.py +pytest -s integration_tests/test_integration.py # Capture the exit code of the test script TEST_EXIT_CODE=$? diff --git a/ai-hub/app/llm_providers.py b/ai-hub/app/llm_providers.py new file mode 100644 index 0000000..75629c7 --- /dev/null +++ b/ai-hub/app/llm_providers.py @@ -0,0 +1,91 @@ +import os +import httpx +from abc import ABC, abstractmethod +from openai import OpenAI +from typing import final + +# --- 1. Load Configuration from Environment --- +# Best practice is to centralize configuration loading at the top. + +# API Keys (required) +DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY") +GEMINI_API_KEY = os.getenv("GEMINI_API_KEY") + +# Model Names (optional, with defaults) +# Allows changing the model version without code changes. +DEEPSEEK_MODEL = os.getenv("DEEPSEEK_MODEL_NAME", "deepseek-chat") +GEMINI_MODEL = os.getenv("GEMINI_MODEL_NAME", "gemini-1.5-flash-latest") + +# --- 2. Initialize API Clients and URLs --- +# Initialize any clients or constants that will be used by the providers. +deepseek_client = OpenAI(api_key=DEEPSEEK_API_KEY, base_url="https://api.deepseek.com") +GEMINI_URL = f"https://generativelanguage.googleapis.com/v1beta/models/{GEMINI_MODEL}:generateContent?key={GEMINI_API_KEY}" + + +# --- 3. Provider Interface and Implementations --- + +class LLMProvider(ABC): + """Abstract base class ('Interface') for all LLM providers.""" + @abstractmethod + async def generate_response(self, prompt: str) -> str: + """Generates a response from the LLM.""" + pass + +@final +class DeepSeekProvider(LLMProvider): + """Provider for the DeepSeek API.""" + def __init__(self, model_name: str): + self.model = model_name + print(f"DeepSeekProvider initialized with model: {self.model}") + + async def generate_response(self, prompt: str) -> str: + try: + chat_completion = deepseek_client.chat.completions.create( + model=self.model, + messages=[ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": prompt}, + ], + stream=False + ) + return chat_completion.choices[0].message.content + except Exception as e: + print(f"DeepSeek Error: {e}") + raise # Re-raise the exception to be handled by the main app + +@final +class GeminiProvider(LLMProvider): + """Provider for the Google Gemini API.""" + def __init__(self, api_url: str): + self.url = api_url + print(f"GeminiProvider initialized for URL: {self.url.split('?')[0]}") + + async def generate_response(self, prompt: str) -> str: + payload = {"contents": [{"parts": [{"text": prompt}]}]} + headers = {"Content-Type": "application/json"} + + try: + async with httpx.AsyncClient() as client: + response = await client.post(self.url, json=payload, headers=headers) + response.raise_for_status() + data = response.json() + return data['candidates'][0]['content']['parts'][0]['text'] + except (httpx.HTTPStatusError, KeyError, IndexError) as e: + print(f"Gemini Error: {e}") + raise # Re-raise for the main app to handle + +# --- 4. The Factory Function --- +# This is where we instantiate our concrete providers with their configuration. + +_providers = { + "deepseek": DeepSeekProvider(model_name=DEEPSEEK_MODEL), + "gemini": GeminiProvider(api_url=GEMINI_URL) +} + +def get_llm_provider(model_name: str) -> LLMProvider: + """Factory function to get the appropriate, pre-configured LLM provider.""" + provider = _providers.get(model_name) + if not provider: + raise ValueError(f"Unsupported model provider: '{model_name}'. Supported providers are: {list(_providers.keys())}") + return provider + diff --git a/ai-hub/app/main.py b/ai-hub/app/main.py index 58392c0..05d3952 100644 --- a/ai-hub/app/main.py +++ b/ai-hub/app/main.py @@ -1,61 +1,47 @@ # main.py -import os -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, Query from pydantic import BaseModel +from typing import Literal from dotenv import load_dotenv -from openai import OpenAI + +# Import our new factory function +from app.llm_providers import get_llm_provider # --- 1. Application Setup --- load_dotenv() app = FastAPI( title="AI Model Hub Service", - description="A central hub to route requests to various LLMs.", - version="0.1.2", + description="A extensible hub to route requests to various LLMs using a Factory Pattern.", + version="0.0.0", ) -# --- 2. Pydantic Models for Request/Response --- +# --- 2. Pydantic Models --- class ChatRequest(BaseModel): prompt: str -# --- 3. Configure DeepSeek API --- -api_key = os.getenv("DEEPSEEK_API_KEY") -if not api_key: - raise ValueError("DEEPSEEK_API_KEY not found in environment variables. Please set it in the .env file.") - -# MODIFIED: Updated base_url to match the primary documentation -client = OpenAI( - api_key=api_key, - base_url="https://api.deepseek.com" -) - -# --- 4. API Endpoint Definition --- -@app.post("/chat") -async def chat_handler(request: ChatRequest): - """ - Accepts a user prompt and returns a response from the DeepSeek model. - """ - try: - chat_completion = client.chat.completions.create( - model="deepseek-chat", - # MODIFIED: Added a system message for better model instruction - messages=[ - {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": request.prompt}, - ], - stream=False # Explicitly setting stream to false as in the example - ) - - response_text = chat_completion.choices[0].message.content - return {"response": response_text} - - except Exception as e: - print(f"An error occurred: {e}") - raise HTTPException(status_code=500, detail="Failed to get response from the model.") - +# --- 3. API Endpoints --- @app.get("/") def read_root(): - """ - Root endpoint to confirm the server is running. - """ - return {"status": "AI Model Hub is running!"} \ No newline at end of file + return {"status": "AI Model Hub is running!"} + +@app.post("/chat") +async def chat_handler( + request: ChatRequest, + model: Literal["deepseek", "gemini"] = Query("deepseek", description="The AI model to use.") +): + try: + # Use the factory to get the correct provider instance + provider = get_llm_provider(model) + + # Call the method on the instance. We don't need to know if it's + # Gemini or DeepSeek, only that it fulfills the "contract". + response_text = await provider.generate_response(request.prompt) + + return {"response": response_text, "model_used": model} + except ValueError as e: + # This catches errors from the factory (e.g., unsupported model) + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + # This catches errors from the provider's API call + raise HTTPException(status_code=500, detail=f"An error occurred with the {model} API: {e}") \ No newline at end of file diff --git a/ai-hub/integration_tests/test_integration.py b/ai-hub/integration_tests/test_integration.py new file mode 100644 index 0000000..586f534 --- /dev/null +++ b/ai-hub/integration_tests/test_integration.py @@ -0,0 +1,83 @@ +import pytest +import httpx + +# The base URL for the local server started by the run_tests.sh script +BASE_URL = "http://127.0.0.1:8000" + +# A common prompt to be used for the tests +TEST_PROMPT = "Explain the theory of relativity in one sentence." + +async def test_root_endpoint(): + """Tests if the root endpoint is alive and returns the correct status.""" + async with httpx.AsyncClient() as client: + response = await client.get(f"{BASE_URL}/") + + assert response.status_code == 200 + assert response.json() == {"status": "AI Model Hub is running!"} + +async def test_chat_endpoint_deepseek(): + """ + Tests the /chat endpoint using the default 'deepseek' model. + """ + url = f"{BASE_URL}/chat?model=deepseek" + payload = {"prompt": TEST_PROMPT} + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post(url, json=payload) + + # 1. Check for a successful response + assert response.status_code == 200, f"Expected status 200, but got {response.status_code}. Response: {response.text}" + + # 2. Check the response structure + data = response.json() + assert "response" in data + assert "model_used" in data + + # 3. Validate the content + assert data["model_used"] == "deepseek" + assert isinstance(data["response"], str) + assert len(data["response"]) > 0 + print(f"\n✅ DeepSeek Response: {data['response'][:80]}...") + + +async def test_chat_endpoint_gemini(): + """ + Tests the /chat endpoint explicitly requesting the 'gemini' model. + """ + url = f"{BASE_URL}/chat?model=gemini" + payload = {"prompt": TEST_PROMPT} + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post(url, json=payload) + + # 1. Check for a successful response + assert response.status_code == 200, f"Expected status 200, but got {response.status_code}. Response: {response.text}" + + # 2. Check the response structure + data = response.json() + assert "response" in data + assert "model_used" in data + + # 3. Validate the content + assert data["model_used"] == "gemini" + assert isinstance(data["response"], str) + assert len(data["response"]) > 0 + print(f"\n✅ Gemini Response: {data['response'][:80]}...") + + +async def test_unsupported_model(): + """ + Tests the API's error handling for an invalid model name. + """ + # Note: The 'model' parameter is intentionally incorrect here. + url = f"{BASE_URL}/chat?model=unsupported_model_123" + payload = {"prompt": TEST_PROMPT} + + async with httpx.AsyncClient() as client: + response = await client.post(url, json=payload) + + # Expect a 422 Unprocessable Entity error because the 'model' query parameter + # does not match the allowed Literal["deepseek", "gemini"] values. + assert response.status_code == 422 + + diff --git a/ai-hub/integration_tests/test_service.py b/ai-hub/integration_tests/test_service.py deleted file mode 100644 index 7e77e25..0000000 --- a/ai-hub/integration_tests/test_service.py +++ /dev/null @@ -1,62 +0,0 @@ -# integration_tests/test_service.py - -import requests -import os -from dotenv import load_dotenv - -# --- Configuration --- -# The base URL for our running service. -# Note: We use http://ai-hub:8000 when running Docker-to-Docker, -# but http://127.0.0.1:8000 when running from the host machine. -# For simplicity, we will run this script from the host. -BASE_URL = "http://127.0.0.1:8000" - -# Load the .env file to check if the API key is set -load_dotenv() -API_KEY = os.getenv("DEEPSEEK_API_KEY") - -def test_root_endpoint(): - """Checks if the service is alive.""" - print("Testing root endpoint...") - response = requests.get(f"{BASE_URL}/") - - assert response.status_code == 200 - assert response.json()["status"] == "AI Model Hub is running!" - print("Root endpoint test: PASSED") - -def test_chat_endpoint(): - """ - Sends a real prompt to the /chat endpoint and verifies a valid response. - This will make a REAL API call to DeepSeek and requires a valid key. - """ - print("\nTesting /chat endpoint...") - if not API_KEY or "YOUR_API_KEY" in API_KEY: - print("SKIPPING test: DEEPSEEK_API_KEY not set in .env file.") - return - - json_payload = {"prompt": "Explain what an integration test is in one sentence."} - - try: - response = requests.post(f"{BASE_URL}/chat", json=json_payload, timeout=30) - - # Check for successful HTTP status - assert response.status_code == 200 - - # Check the response body - data = response.json() - assert "response" in data - assert isinstance(data["response"], str) - assert len(data["response"]) > 0 - - print(f"Received response: '{data['response']}'") - print("/chat endpoint test: PASSED") - - except requests.exceptions.RequestException as e: - print(f"/chat endpoint test: FAILED - {e}") - assert False, f"Request failed: {e}" - -if __name__ == "__main__": - print("--- Running Integration Tests ---") - test_root_endpoint() - test_chat_endpoint() - print("\n--- All tests completed ---") \ No newline at end of file diff --git a/ai-hub/requirements.txt b/ai-hub/requirements.txt index b45a893..4077554 100644 --- a/ai-hub/requirements.txt +++ b/ai-hub/requirements.txt @@ -4,4 +4,8 @@ python-dotenv openai pytest -requests \ No newline at end of file +requests +anyio +pytest-asyncio +pytest-tornasync +pytest-trio \ No newline at end of file diff --git a/ai-hub/run_integration_tests.sh b/ai-hub/run_integration_tests.sh index e5e4376..b96e1b4 100644 --- a/ai-hub/run_integration_tests.sh +++ b/ai-hub/run_integration_tests.sh @@ -29,7 +29,7 @@ echo "--- Running Integration Test Script ---" # Execute the Python integration test script -python3 integration_tests/test_service.py +pytest -s integration_tests/test_integration.py # Capture the exit code of the test script TEST_EXIT_CODE=$? diff --git a/ai-hub/tests/test_main.py b/ai-hub/tests/test_main.py index e8ecf34..c3439a4 100644 --- a/ai-hub/tests/test_main.py +++ b/ai-hub/tests/test_main.py @@ -3,7 +3,7 @@ from fastapi.testclient import TestClient from unittest.mock import patch, MagicMock from app.main import app - +from app.llm_providers import get_llm_provider # Create a TestClient instance based on our FastAPI app client = TestClient(app)