Skip to content

Commit

Permalink
feat(integration-service): Add integrations service (#520)
Browse files Browse the repository at this point in the history
Introduces a new integration service with FastAPI, Docker setup, and support for multiple integrations like DALL-E and Wikipedia.

- Integration Service:
  + Adds integrations-service with Docker setup in docker-compose.yml and Dockerfile.
  + FastAPI application in web.py with routers for execution and integration management.
- Models:
  + Defines models for DalleImageGenerator, DuckDuckGoSearch, HackerNews, Weather, and Wikipedia in models directory.
  + IntegrationExecutionRequest and IntegrationExecutionResponse for handling execution requests.
- Utilities:
  + Implements execute_integration in execute_integration.py to handle integration execution logic.
  + Integration utilities for DALL-E, DuckDuckGo, Hacker News, Weather, and Wikipedia in utils/integrations.
- Configuration:
  + Adds pyproject.toml for dependency management with Poetry.
  + Adds pytype.toml for type checking configuration.
  • Loading branch information
HamadaSalhab authored and creatorrr committed Sep 30, 2024
1 parent c216301 commit ecb1a12
Show file tree
Hide file tree
Showing 33 changed files with 2,688 additions and 0 deletions.
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ include:
- ./agents-api/docker-compose.yml
- ./scheduler/docker-compose.yml
- ./llm-proxy/docker-compose.yml
- ./integrations-service/docker-compose.yml

# TODO: Enable after testing
# - ./monitoring/docker-compose.yml
Expand Down
19 changes: 19 additions & 0 deletions integrations-service/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
FROM python:3.11-slim

WORKDIR /app

# Install Poetry
RUN pip install poetry

# Copy only requirements to cache them in docker layer
COPY pyproject.toml poetry.lock* /app/

# Project initialization:
RUN poetry config virtualenvs.create false \
&& poetry install --no-interaction --no-ansi

# Copy project
COPY . ./

# Run the application
CMD ["python", "-m", "integrations.web", "--host", "0.0.0.0", "--port", "8000"]
26 changes: 26 additions & 0 deletions integrations-service/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: julep-integrations

# Shared environment variables
x--shared-environment: &shared-environment
OPENAI_API_KEY: ${OPENAI_API_KEY}

services:
integrations:
environment:
<<: *shared-environment

build: .
ports:
- "8000:8000"

develop:
watch:
- action: sync+restart
path: ./
target: /app/
ignore:
- ./**/*.pyc
- action: rebuild
path: poetry.lock
- action: rebuild
path: Dockerfile
Empty file.
17 changes: 17 additions & 0 deletions integrations-service/integrations/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from .dalle_image_generator import (
DalleImageGeneratorArguments,
DalleImageGeneratorSetup,
)
from .duckduckgo_search import DuckDuckGoSearchExecutionArguments
from .hacker_news import HackerNewsExecutionArguments

# TODO: Move these models somewhere else
from .models import (
ExecuteIntegrationArguments,
ExecuteIntegrationSetup,
IntegrationDef,
IntegrationExecutionRequest,
IntegrationExecutionResponse,
)
from .weather import WeatherExecutionArguments, WeatherExecutionSetup
from .wikipedia import WikipediaExecutionArguments
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from pydantic import BaseModel, Field


class DalleImageGeneratorSetup(BaseModel):
api_key: str = Field(str, description="The API key for DALL-E")


class DalleImageGeneratorArguments(BaseModel):
prompt: str = Field(str, description="The image generation prompt")
5 changes: 5 additions & 0 deletions integrations-service/integrations/models/duckduckgo_search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from pydantic import BaseModel, Field


class DuckDuckGoSearchExecutionArguments(BaseModel):
query: str = Field(..., description="The search query string")
5 changes: 5 additions & 0 deletions integrations-service/integrations/models/hacker_news.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from pydantic import BaseModel, Field


class HackerNewsExecutionArguments(BaseModel):
url: str = Field(..., description="The URL of the Hacker News thread to fetch")
81 changes: 81 additions & 0 deletions integrations-service/integrations/models/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
from typing import Literal, Union

from pydantic import BaseModel

from .dalle_image_generator import (
DalleImageGeneratorArguments,
DalleImageGeneratorSetup,
)
from .duckduckgo_search import DuckDuckGoSearchExecutionArguments
from .hacker_news import HackerNewsExecutionArguments
from .weather import WeatherExecutionArguments, WeatherExecutionSetup
from .wikipedia import WikipediaExecutionArguments

ExecuteIntegrationArguments = Union[
WikipediaExecutionArguments,
DuckDuckGoSearchExecutionArguments,
DalleImageGeneratorArguments,
WeatherExecutionArguments,
HackerNewsExecutionArguments,
]

ExecuteIntegrationSetup = Union[
DalleImageGeneratorSetup,
WeatherExecutionSetup,
]


class IntegrationExecutionRequest(BaseModel):
setup: ExecuteIntegrationSetup | None = None
"""
The setup parameters the integration accepts (such as API keys)
"""
arguments: ExecuteIntegrationArguments
"""
The arguments to pass to the integration
"""


class IntegrationExecutionResponse(BaseModel):
result: str
"""
The result of the integration execution
"""


class IntegrationDef(BaseModel):
provider: (
Literal[
"dummy",
"dalle_image_generator",
"duckduckgo_search",
"hacker_news",
"weather",
"wikipedia",
"twitter",
"web_base",
"requests",
"gmail",
"tts_query",
]
| None
) = None
"""
The provider of the integration
"""
method: str | None = None
"""
The specific method of the integration to call
"""
description: str | None = None
"""
Optional description of the integration
"""
setup: dict | None = None
"""
The setup parameters the integration accepts
"""
arguments: dict | None = None
"""
The arguments to pre-apply to the integration call
"""
13 changes: 13 additions & 0 deletions integrations-service/integrations/models/weather.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from pydantic import BaseModel, Field


class WeatherExecutionSetup(BaseModel):
openweathermap_api_key: str = Field(
..., description="The location for which to fetch weather data"
)


class WeatherExecutionArguments(BaseModel):
location: str = Field(
..., description="The location for which to fetch weather data"
)
6 changes: 6 additions & 0 deletions integrations-service/integrations/models/wikipedia.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from pydantic import BaseModel, Field


class WikipediaExecutionArguments(BaseModel):
query: str = Field(..., description="The search query string")
load_max_docs: int = Field(2, description="Maximum number of documents to load")
2 changes: 2 additions & 0 deletions integrations-service/integrations/routers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from .execution.router import router as execution_router
from .integrations.router import router as integrations_router
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .execute import execute
19 changes: 19 additions & 0 deletions integrations-service/integrations/routers/execution/execute.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
from fastapi import Body, HTTPException, Path

from ...models import IntegrationExecutionRequest, IntegrationExecutionResponse
from ...utils.execute_integration import execute_integration
from .router import router


@router.post("/execute/{provider}", tags=["execution"])
async def execute(
provider: str = Path(..., description="The integration provider"),
request: IntegrationExecutionRequest = Body(
..., description="The integration execution request"
),
) -> IntegrationExecutionResponse:
try:
result = await execute_integration(provider, request.setup, request.arguments)
return IntegrationExecutionResponse(result=result)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
3 changes: 3 additions & 0 deletions integrations-service/integrations/routers/execution/router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from fastapi import APIRouter

router: APIRouter = APIRouter()
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from .get_integration_tool import get_integration_tool
from .get_integrations import get_integrations
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from typing import Optional

from fastapi import HTTPException

from ...models.models import IntegrationDef
from .get_integrations import get_integrations
from .router import router


def convert_to_openai_tool(integration: IntegrationDef) -> dict:
return {
"type": "function",
"function": {
"name": integration.provider,
"description": integration.description,
"parameters": {
"type": "object",
"properties": integration.arguments,
"required": [
k
for k, v in integration.arguments.items()
if v.get("required", False)
],
},
},
}


@router.get("/integrations/{provider}/tool", tags=["integration_tool"])
@router.get("/integrations/{provider}/{method}/tool", tags=["integration_tool"])
async def get_integration_tool(provider: str, method: Optional[str] = None):
integrations = await get_integrations()

for integration in integrations:
if integration.provider == provider and (
method is None or integration.method == method
):
return convert_to_openai_tool(integration)

raise HTTPException(status_code=404, detail="Integration not found")
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import importlib
import inspect
import os
from typing import Any, List

from pydantic import BaseModel

from ...models.models import IntegrationDef
from ...utils import integrations
from .router import router


def create_integration_def(module: Any) -> IntegrationDef:
module_parts = module.__name__.split(".")
if len(module_parts) > 4: # Nested integration
provider = module_parts[-2]
method = module_parts[-1]
else: # Top-level integration
provider = module_parts[-1]
method = None

# Find the first function in the module
function_name = next(
name
for name, obj in inspect.getmembers(module)
if inspect.isfunction(obj) and not name.startswith("_")
)
function = getattr(module, function_name)
signature = inspect.signature(function)

# Get the Pydantic model for the parameters
params_model = next(iter(signature.parameters.values())).annotation

# Check if the params_model is a Pydantic model
if issubclass(params_model, BaseModel):
arguments = {}
for field_name, field in params_model.model_fields.items():
field_type = field.annotation
arguments[field_name] = {
"type": field_type.__name__.lower(),
"description": field.description,
}
else:
# Fallback to a dictionary if it's not a Pydantic model
arguments = {
param.name: {"type": str(param.annotation.__name__).lower()}
for param in signature.parameters.values()
if param.name != "parameters"
}

return IntegrationDef(
provider=provider,
method=method,
description=function.__doc__.strip() if function.__doc__ else None,
arguments=arguments,
)


@router.get("/integrations", tags=["integrations"])
async def get_integrations() -> List[IntegrationDef]:
integration_defs = []
integrations_dir = os.path.dirname(integrations.__file__)

for item in os.listdir(integrations_dir):
item_path = os.path.join(integrations_dir, item)

if os.path.isdir(item_path):
# This is a toolkit
for file in os.listdir(item_path):
if file.endswith(".py") and not file.startswith("__"):
module = importlib.import_module(
f"...utils.integrations.{item}.{file[:-3]}", package=__package__
)
integration_defs.append(create_integration_def(module))
elif item.endswith(".py") and not item.startswith("__"):
# This is a single-file tool
module = importlib.import_module(
f"...utils.integrations.{item[:-3]}", package=__package__
)
integration_defs.append(create_integration_def(module))

return integration_defs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from fastapi import APIRouter

router: APIRouter = APIRouter()
26 changes: 26 additions & 0 deletions integrations-service/integrations/utils/execute_integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from ..models import ExecuteIntegrationArguments, ExecuteIntegrationSetup
from .integrations.dalle_image_generator import dalle_image_generator
from .integrations.duckduckgo_search import duckduckgo_search
from .integrations.hacker_news import hacker_news
from .integrations.weather import weather
from .integrations.wikipedia import wikipedia


async def execute_integration(
provider: str,
setup: ExecuteIntegrationSetup | None,
arguments: ExecuteIntegrationArguments,
) -> str:
match provider:
case "duckduckgo_search":
return await duckduckgo_search(arguments=arguments)
case "dalle_image_generator":
return await dalle_image_generator(setup=setup, arguments=arguments)
case "wikipedia":
return await wikipedia(arguments=arguments)
case "weather":
return await weather(setup=setup, arguments=arguments)
case "hacker_news":
return await hacker_news(arguments=arguments)
case _:
raise ValueError(f"Unknown integration: {provider}")
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from .dalle_image_generator import dalle_image_generator
from .duckduckgo_search import duckduckgo_search
from .hacker_news import hacker_news
from .weather import weather
from .wikipedia import wikipedia
Loading

0 comments on commit ecb1a12

Please sign in to comment.