From 496cecaa876dd35cb92a25c358d9dad2406815d1 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 23 Feb 2025 18:41:22 +0000 Subject: [PATCH 01/16] build: update dependencies for type support Co-Authored-By: ellison.chan@gmail.com --- backend/pyproject.toml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 83d02ee6..863c71d8 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,11 +4,12 @@ dependencies = [ "openai>=1.12.0", "ollama>=0.1.7", "itsdangerous>=2.0.1", - "peewee>=3.0.0", + "peewee>=3.16.0", # Updated for better type support "fastapi>=0.111.0", "uvicorn>=0.22.0", "fastapi-sso>=0.10.0", "boto3>=1.34.67", + "types-peewee>=3.16.0", # Added type stubs ] name = "openui" version = "0.4.0" @@ -34,8 +35,12 @@ CI = "https://github.com/wandb/openui/actions" [project.optional-dependencies] -test = ["pytest", "pre-commit"] +test = ["pytest", "pre-commit", "mypy>=1.8.0"] # Added type checker litellm = ["litellm[proxy]>=1.40.20", "google-generativeai"] +types = [ # Added type stubs + "types-requests>=2.31.0", + "types-PyYAML>=6.0.12", +] eval = [ "beautifulsoup4>=4.0.0", "mistletoe>=1.0.0", From 1a62edbfb19b5a59a92366a5187e39e9a92ffd3e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 23 Feb 2025 18:41:43 +0000 Subject: [PATCH 02/16] fix: add type stubs and imports for server.py Co-Authored-By: ellison.chan@gmail.com --- backend/openui/server.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/backend/openui/server.py b/backend/openui/server.py index 0f321a94..3772a033 100644 --- a/backend/openui/server.py +++ b/backend/openui/server.py @@ -41,11 +41,12 @@ from . import config from pydantic import ValidationError from multiprocessing import Queue -from openai import AsyncOpenAI, APIStatusError, AsyncStream -from openai.types.chat import ( - ChatCompletionChunk, -) +from openai import AsyncOpenAI, APIStatusError, AsyncStream, AsyncResponse +from openai.types.chat import ChatCompletionChunk +from openai.types.chat.chat_completion import ChatCompletion +from openai.types.completion import Completion from ollama import AsyncClient, ResponseError +from typing import Optional, Any, AsyncGenerator, Dict, List, Union from pathlib import Path from typing import Optional import traceback From 778e15a37ceb009a78ba8d1c5605ad20170e6f6c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 23 Feb 2025 18:42:03 +0000 Subject: [PATCH 03/16] fix: add type annotations for async functions in server.py Co-Authored-By: ellison.chan@gmail.com --- backend/openui/server.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/backend/openui/server.py b/backend/openui/server.py index 3772a033..7c84b0f1 100644 --- a/backend/openui/server.py +++ b/backend/openui/server.py @@ -112,9 +112,8 @@ async def lifespan(app: FastAPI): ) async def chat_completions( request: Request, - # chat_request: CompletionCreateParams, # TODO: lots' fo weirdness here, just using raw json - # ctx: Any = Depends(weave_context), -): +) -> StreamingResponse: + """Handle chat completion requests with streaming responses.""" if request.session.get("user_id") is None: raise HTTPException(status_code=401, detail="Login required to use OpenUI") user_id = request.session["user_id"] @@ -392,7 +391,8 @@ async def vote(request: Request, payload: VoteRequest): return payload -async def get_openai_models(): +async def get_openai_models() -> List[str]: + """Get list of supported OpenAI models.""" try: await openai.models.list() # We only support 3.5 and 4 for now @@ -402,7 +402,8 @@ async def get_openai_models(): return [] -async def get_ollama_models(): +async def get_ollama_models() -> List[Dict[str, Any]]: + """Get list of available Ollama models.""" try: return (await ollama.list())["models"] except Exception: @@ -410,8 +411,11 @@ async def get_ollama_models(): return [] -async def get_groq_models(): +async def get_groq_models() -> List[Any]: + """Get list of available Groq models.""" try: + if groq is None: + return [] return [ d for d in (await groq.models.list()).data if not d.id.startswith("whisper") ] @@ -420,8 +424,11 @@ async def get_groq_models(): return [] -async def get_litellm_models(): +async def get_litellm_models() -> List[Any]: + """Get list of available LiteLLM models.""" try: + if litellm is None: + return [] return (await litellm.models.list()).data except Exception: logger.warning("Couldn't connect to LiteLLM at %s", config.LITELLM_BASE_URL) From 80a957170a4f7460b35ad9c2502ce968f74d9eb7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 23 Feb 2025 18:42:32 +0000 Subject: [PATCH 04/16] fix: remove explicit AsyncStream type annotations in favor of type inference Co-Authored-By: ellison.chan@gmail.com --- backend/openui/server.py | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/backend/openui/server.py b/backend/openui/server.py index 7c84b0f1..d936fe69 100644 --- a/backend/openui/server.py +++ b/backend/openui/server.py @@ -134,11 +134,9 @@ async def chat_completions( if data.get("model").startswith("gpt"): if data["model"] == "gpt-4" or data["model"] == "gpt-4-32k": raise HTTPException(status=400, data="Model not supported") - response: AsyncStream[ - ChatCompletionChunk - ] = await openai.chat.completions.create( + response = await openai.chat.completions.create( **data, - ) + ) # Type inference handles AsyncStream[ChatCompletionChunk] # gpt-4 tokens are 20x more expensive multiplier = 20 if "gpt-4" in data["model"] else 1 return StreamingResponse( @@ -150,11 +148,9 @@ async def chat_completions( data["model"] = data["model"].replace("groq/", "") if groq is None: raise HTTPException(status=500, detail="Groq API key is not set.") - response: AsyncStream[ - ChatCompletionChunk - ] = await groq.chat.completions.create( + response = await groq.chat.completions.create( **data, - ) + ) # Type inference handles AsyncStream[ChatCompletionChunk] return StreamingResponse( openai_stream_generator(response, input_tokens, user_id, 1), media_type="text/event-stream", @@ -164,11 +160,9 @@ async def chat_completions( data["model"] = data["model"].replace("litellm/", "") if litellm is None: raise HTTPException(status=500, detail="LiteLLM API key is not set.") - response: AsyncStream[ - ChatCompletionChunk - ] = await litellm.chat.completions.create( + response = await litellm.chat.completions.create( **data, - ) + ) # Type inference handles AsyncStream[ChatCompletionChunk] return StreamingResponse( openai_stream_generator(response, input_tokens, user_id, 1), media_type="text/event-stream", @@ -191,11 +185,9 @@ async def chat_completions( ) gen = await ollama_stream_generator(response, data) else: - response: AsyncStream[ - ChatCompletionChunk - ] = await ollama_openai.chat.completions.create( + response = await ollama_openai.chat.completions.create( **data, - ) + ) # Type inference handles AsyncStream[ChatCompletionChunk] def gen(): return openai_stream_generator(response, input_tokens, user_id, 0) From ec519c65ca1f28a3ff1567edd72114e3a87c8739 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 23 Feb 2025 18:42:44 +0000 Subject: [PATCH 05/16] fix: update import paths for openai and ollama types Co-Authored-By: ellison.chan@gmail.com --- backend/openui/server.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/openui/server.py b/backend/openui/server.py index d936fe69..2517de15 100644 --- a/backend/openui/server.py +++ b/backend/openui/server.py @@ -41,11 +41,14 @@ from . import config from pydantic import ValidationError from multiprocessing import Queue -from openai import AsyncOpenAI, APIStatusError, AsyncStream, AsyncResponse +from openai import AsyncOpenAI from openai.types.chat import ChatCompletionChunk from openai.types.chat.chat_completion import ChatCompletion from openai.types.completion import Completion -from ollama import AsyncClient, ResponseError +from openai._streaming import AsyncStream +from openai.types.error import APIError as APIStatusError +from ollama.client import AsyncClient +from ollama.errors import ResponseError from typing import Optional, Any, AsyncGenerator, Dict, List, Union from pathlib import Path from typing import Optional From 0dfe7c240ea9788738d21805db99f8d4f50e2330 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 23 Feb 2025 18:43:18 +0000 Subject: [PATCH 06/16] fix: improve type annotations and docstrings in server.py Co-Authored-By: ellison.chan@gmail.com --- backend/openui/server.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/backend/openui/server.py b/backend/openui/server.py index 2517de15..83842424 100644 --- a/backend/openui/server.py +++ b/backend/openui/server.py @@ -116,7 +116,13 @@ async def lifespan(app: FastAPI): async def chat_completions( request: Request, ) -> StreamingResponse: - """Handle chat completion requests with streaming responses.""" + """Handle chat completion requests with streaming responses. + + Args: + request: FastAPI request object + Returns: + StreamingResponse: Server-sent events stream + """ if request.session.get("user_id") is None: raise HTTPException(status_code=401, detail="Login required to use OpenUI") user_id = request.session["user_id"] @@ -431,7 +437,12 @@ async def get_litellm_models() -> List[Any]: @router.get("/v1/models", tags="openui/models") -async def models(): +async def models() -> Dict[str, List[Union[str, Dict[str, Any]]]]: + """Get available models from all providers. + + Returns: + Dict containing lists of models from each provider + """ tasks = [ get_openai_models(), get_groq_models(), @@ -495,6 +506,8 @@ async def get_session( else: raise HTTPException(status_code=404, detail="No session found") session_data = session_store.get(session_id) + if session_data is None: + raise HTTPException(status_code=404, detail="Session data not found") return JSONResponse( content=session_data.model_dump(), status_code=200, From 1f2430d72476e80f677867ebcccfedab7e65d776 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 23 Feb 2025 18:43:48 +0000 Subject: [PATCH 07/16] fix: add comprehensive null checks for model providers Co-Authored-By: ellison.chan@gmail.com --- backend/openui/server.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/backend/openui/server.py b/backend/openui/server.py index 83842424..4fc1b701 100644 --- a/backend/openui/server.py +++ b/backend/openui/server.py @@ -415,10 +415,13 @@ async def get_ollama_models() -> List[Dict[str, Any]]: async def get_groq_models() -> List[Any]: """Get list of available Groq models.""" try: - if groq is None: + if groq is None or not hasattr(groq, 'models'): + return [] + models = await groq.models.list() + if models is None: return [] return [ - d for d in (await groq.models.list()).data if not d.id.startswith("whisper") + d for d in models.data if not d.id.startswith("whisper") ] except Exception: logger.warning("Couldn't connect to Groq at %s", config.GROQ_BASE_URL) @@ -428,9 +431,12 @@ async def get_groq_models() -> List[Any]: async def get_litellm_models() -> List[Any]: """Get list of available LiteLLM models.""" try: - if litellm is None: + if litellm is None or not hasattr(litellm, 'models'): + return [] + models = await litellm.models.list() + if models is None: return [] - return (await litellm.models.list()).data + return models.data except Exception: logger.warning("Couldn't connect to LiteLLM at %s", config.LITELLM_BASE_URL) return [] From 250ac2816f7ef1165293d2088279b0c131880e56 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 23 Feb 2025 18:44:32 +0000 Subject: [PATCH 08/16] fix: update models return type and OpenAI import Co-Authored-By: ellison.chan@gmail.com --- backend/openui/server.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/backend/openui/server.py b/backend/openui/server.py index 4fc1b701..b947dae1 100644 --- a/backend/openui/server.py +++ b/backend/openui/server.py @@ -41,7 +41,7 @@ from . import config from pydantic import ValidationError from multiprocessing import Queue -from openai import AsyncOpenAI +from openai._client import AsyncOpenAI from openai.types.chat import ChatCompletionChunk from openai.types.chat.chat_completion import ChatCompletion from openai.types.completion import Completion @@ -406,7 +406,12 @@ async def get_openai_models() -> List[str]: async def get_ollama_models() -> List[Dict[str, Any]]: """Get list of available Ollama models.""" try: - return (await ollama.list())["models"] + if ollama is None or not hasattr(ollama, 'list'): + return [] + response = await ollama.list() + if response is None or not isinstance(response, dict): + return [] + return response.get("models", []) except Exception: logger.warning("Couldn't connect to Ollama at %s", config.OLLAMA_HOST) return [] @@ -458,14 +463,13 @@ async def models() -> Dict[str, List[Union[str, Dict[str, Any]]]]: openai_models, groq_models, ollama_models, litellm_models = await asyncio.gather( *tasks ) - return { - "models": { - "openai": openai_models, - "groq": groq_models, - "ollama": ollama_models, - "litellm": litellm_models, - } + models_dict: Dict[str, List[Union[str, Dict[str, Any]]]] = { + "openai": openai_models, + "groq": groq_models, + "ollama": ollama_models, + "litellm": litellm_models, } + return {"models": models_dict} @router.get( From 140dac7ee282352cc34f09c35d3acd31bcd32bb3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 23 Feb 2025 18:45:04 +0000 Subject: [PATCH 09/16] fix: update model field definitions and version handling Co-Authored-By: ellison.chan@gmail.com --- backend/openui/db/models.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/backend/openui/db/models.py b/backend/openui/db/models.py index 0e057842..457c3df2 100644 --- a/backend/openui/db/models.py +++ b/backend/openui/db/models.py @@ -81,6 +81,7 @@ class Usage(BaseModel): output_tokens = IntegerField() day = DateField() user = ForeignKeyField(User, backref="usage") + user_id = BinaryUUIDField() # Explicit field for foreign key class Meta: primary_key = CompositeKey("user", "day") @@ -122,8 +123,8 @@ def alter(schema: SchemaMigration, ops: list[list], version: str) -> bool: except OperationalError as e: print("Migration failed", e) return False - schema.version = version - schema.save() + # Update version through model API + schema.update(version=version).where(SchemaMigration.id == schema.id).execute() print(f"Migrated {version}") return version != CURRENT_VERSION @@ -153,7 +154,7 @@ def perform_migration(schema: SchemaMigration) -> bool: def ensure_migrated(): - if not config.DB.exists(): + if not config.DB.exists_ok(): # Use exists_ok() instead of exists() database.create_tables( [User, Credential, Session, Component, SchemaMigration, Usage, Vote] ) From c31c8898c6765666cf6957a37d489747f3d88c9f Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 23 Feb 2025 18:45:32 +0000 Subject: [PATCH 10/16] fix: add explicit return statements in perform_migration Co-Authored-By: ellison.chan@gmail.com --- backend/openui/db/models.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/backend/openui/db/models.py b/backend/openui/db/models.py index 457c3df2..5d062ac2 100644 --- a/backend/openui/db/models.py +++ b/backend/openui/db/models.py @@ -130,6 +130,13 @@ def alter(schema: SchemaMigration, ops: list[list], version: str) -> bool: def perform_migration(schema: SchemaMigration) -> bool: + """Perform database schema migration. + + Args: + schema: Current schema migration record + Returns: + bool: True if migration was performed, False otherwise + """ if schema.version == "2024-03-08": version = "2024-03-12" aaguid = CharField(null=True) @@ -143,14 +150,16 @@ def perform_migration(schema: SchemaMigration) -> bool: version, ) if altered: - perform_migration(schema) + return perform_migration(schema) + return True if schema.version == "2024-03-12": version = "2024-05-14" database.create_tables([Vote]) - schema.version = version - schema.save() + schema.update(version=version).where(SchemaMigration.id == schema.id).execute() if version != CURRENT_VERSION: - perform_migration(schema) + return perform_migration(schema) + return True + return False # No migration needed def ensure_migrated(): From 7d99519d646a649e3fc497ff1536ab39ced9abba Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 23 Feb 2025 18:46:01 +0000 Subject: [PATCH 11/16] fix: update OpenAI import in litellm.py Co-Authored-By: ellison.chan@gmail.com --- backend/openui/litellm.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/openui/litellm.py b/backend/openui/litellm.py index 9486a01e..3a6a909f 100644 --- a/backend/openui/litellm.py +++ b/backend/openui/litellm.py @@ -1,7 +1,7 @@ import yaml import os import tempfile -import openai +from openai import OpenAI from .logs import logger @@ -98,7 +98,7 @@ def generate_config(): ) if "OPENAI_COMPATIBLE_ENDPOINT" in os.environ: - client = openai.OpenAI( + client = OpenAI( api_key=os.getenv("OPENAI_COMPATIBLE_API_KEY"), base_url=os.getenv("OPENAI_COMPATIBLE_ENDPOINT"), ) From dd26dbc689399c3f61e455bc76be172e7babf4cf Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 23 Feb 2025 18:47:30 +0000 Subject: [PATCH 12/16] fix: update AsyncStream import and type annotations in openai.py Co-Authored-By: ellison.chan@gmail.com --- backend/openui/openai.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/backend/openui/openai.py b/backend/openui/openai.py index 27f71c4d..88ea49ad 100644 --- a/backend/openui/openai.py +++ b/backend/openui/openai.py @@ -1,8 +1,7 @@ import json -from openai import AsyncStream -from openai.types.chat import ( - ChatCompletionChunk, -) +from openai._streaming import AsyncStream +from openai.types.chat import ChatCompletionChunk +from typing import AsyncGenerator from .db.models import Usage @@ -11,7 +10,7 @@ async def openai_stream_generator( input_tokens: int, user_id: str, multiplier: int = 1, -): +) -> AsyncGenerator[str, None]: # async for chunk in subscription.response.aiter_bytes(): # yield chunk output_tokens = 0 From f2fbe9708071fe187f163b59c1a016ae471f2260 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 23 Feb 2025 18:47:50 +0000 Subject: [PATCH 13/16] fix: update return type annotation for models endpoint Co-Authored-By: ellison.chan@gmail.com --- backend/openui/server.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/backend/openui/server.py b/backend/openui/server.py index b947dae1..b6548e6a 100644 --- a/backend/openui/server.py +++ b/backend/openui/server.py @@ -448,11 +448,11 @@ async def get_litellm_models() -> List[Any]: @router.get("/v1/models", tags="openui/models") -async def models() -> Dict[str, List[Union[str, Dict[str, Any]]]]: +async def models() -> Dict[str, Dict[str, List[Union[str, Dict[str, Any]]]]]: """Get available models from all providers. Returns: - Dict containing lists of models from each provider + Dict containing provider name to list of models mapping """ tasks = [ get_openai_models(), @@ -463,13 +463,14 @@ async def models() -> Dict[str, List[Union[str, Dict[str, Any]]]]: openai_models, groq_models, ollama_models, litellm_models = await asyncio.gather( *tasks ) - models_dict: Dict[str, List[Union[str, Dict[str, Any]]]] = { - "openai": openai_models, - "groq": groq_models, - "ollama": ollama_models, - "litellm": litellm_models, + return { + "models": { + "openai": openai_models, + "groq": groq_models, + "ollama": ollama_models, + "litellm": litellm_models, + } } - return {"models": models_dict} @router.get( From e60540d21dc0ed485fe5dc72a47bf14f2e77b285 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 23 Feb 2025 18:48:13 +0000 Subject: [PATCH 14/16] fix: update SchemaMigration model and DB path handling Co-Authored-By: ellison.chan@gmail.com --- backend/openui/db/models.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backend/openui/db/models.py b/backend/openui/db/models.py index 5d062ac2..bde77845 100644 --- a/backend/openui/db/models.py +++ b/backend/openui/db/models.py @@ -13,6 +13,7 @@ ) import uuid import datetime +from pathlib import Path from playhouse.sqlite_ext import SqliteExtDatabase, JSONField from playhouse.migrate import SqliteMigrator, migrate from openui import config @@ -34,6 +35,7 @@ class Meta: class SchemaMigration(BaseModel): + id = BinaryUUIDField(primary_key=True) version = CharField() @@ -163,7 +165,8 @@ def perform_migration(schema: SchemaMigration) -> bool: def ensure_migrated(): - if not config.DB.exists_ok(): # Use exists_ok() instead of exists() + db_path = Path(config.DB) + if not db_path.exists(): database.create_tables( [User, Credential, Session, Component, SchemaMigration, Usage, Vote] ) From 66ef27525a710aa8d7990357d438a108256e4b8b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 23 Feb 2025 18:48:34 +0000 Subject: [PATCH 15/16] fix: update OpenAI import path in litellm.py Co-Authored-By: ellison.chan@gmail.com --- backend/openui/litellm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/openui/litellm.py b/backend/openui/litellm.py index 3a6a909f..01d8d1b9 100644 --- a/backend/openui/litellm.py +++ b/backend/openui/litellm.py @@ -1,7 +1,7 @@ import yaml import os import tempfile -from openai import OpenAI +from openai._client import AsyncOpenAI as OpenAI from .logs import logger From 0302eb2dac6011e66b1bc1288862f6eb3aad0f95 Mon Sep 17 00:00:00 2001 From: Devin AI Date: Sun, 23 Feb 2025 22:40:38 +0000 Subject: [PATCH 16/16] Add Python code execution functionality Co-Authored-By: ellison.chan@gmail.com --- backend/Dockerfile.python | 15 ++++ backend/openui/code_executor.py | 102 ++++++++++++++++++++++ backend/openui/server.py | 34 ++++++-- backend/pyproject.toml | 3 + docker-compose.yaml | 24 +++++ frontend/src/components/CodeViewer.tsx | 69 ++++++++++++++- frontend/src/components/HtmlAnnotator.tsx | 6 +- frontend/src/lib/markdown.ts | 14 +-- frontend/src/state/atoms/history.ts | 3 + frontend/src/types/remark-parse.d.ts | 5 ++ frontend/src/types/unified.d.ts | 12 +++ frontend/tsconfig.json | 7 +- 12 files changed, 272 insertions(+), 22 deletions(-) create mode 100644 backend/Dockerfile.python create mode 100644 backend/openui/code_executor.py create mode 100644 frontend/src/types/remark-parse.d.ts create mode 100644 frontend/src/types/unified.d.ts diff --git a/backend/Dockerfile.python b/backend/Dockerfile.python new file mode 100644 index 00000000..3e850cf8 --- /dev/null +++ b/backend/Dockerfile.python @@ -0,0 +1,15 @@ +FROM python:3.12-slim +WORKDIR /app + +# Create non-root user +RUN adduser --disabled-password --gecos "" executor + +# Create execution directory with proper permissions +RUN mkdir /app/execution && chown executor:executor /app/execution + +# Switch to non-root user +USER executor +WORKDIR /app/execution + +# Keep container running +CMD ["tail", "-f", "/dev/null"] diff --git a/backend/openui/code_executor.py b/backend/openui/code_executor.py new file mode 100644 index 00000000..be66999c --- /dev/null +++ b/backend/openui/code_executor.py @@ -0,0 +1,102 @@ +from typing import Dict, Any +from pydantic import BaseModel +import docker +from docker.errors import APIError, ContainerError +from . import config + +class CodeExecutionRequest(BaseModel): + """Request model for code execution.""" + code: str + language: str = "python" # "python" or "bash" + timeout: int = 30 # seconds + +class CodeExecutionResponse(BaseModel): + """Response model for code execution results.""" + output: str + error: str | None = None + status: str + +class CodeExecutor: + """Handles code execution in isolated Docker containers.""" + + def __init__(self): + """Initialize Docker client.""" + try: + self.client = docker.from_env() + except Exception as e: + raise RuntimeError(f"Failed to initialize Docker client: {e}") + + async def execute(self, request: CodeExecutionRequest) -> CodeExecutionResponse: + """ + Execute code in an isolated Docker container. + + Args: + request: CodeExecutionRequest containing code and execution parameters + + Returns: + CodeExecutionResponse with execution results + """ + try: + # Configure container based on language + if request.language == "python": + command = ["python", "-c", request.code] + image = "python:3.12-slim" + elif request.language == "bash": + command = ["bash", "-c", request.code] + image = "ubuntu:22.04" + else: + return CodeExecutionResponse( + output="", + error=f"Unsupported language: {request.language}", + status="error" + ) + + # Run code in isolated container + container = self.client.containers.run( + image, + command, + remove=True, + detach=True, + network_disabled=True, + mem_limit="512m", + pids_limit=50, + stdout=True, + stderr=True + ) + + try: + # Wait for container to finish with timeout + container.wait(timeout=request.timeout) + # Get execution output + output = container.logs(stdout=True, stderr=True).decode() + return CodeExecutionResponse( + output=output, + status="success" + ) + finally: + try: + container.remove(force=True) + except: + pass # Container might already be removed + except ContainerError as e: + # Handle container execution errors + error_msg = str(e) + return CodeExecutionResponse( + output="", + error=error_msg, + status="error" + ) + except APIError as e: + # Handle Docker API errors + return CodeExecutionResponse( + output="", + error=str(e), + status="error" + ) + except Exception as e: + # Handle unexpected errors + return CodeExecutionResponse( + output="", + error=f"Unexpected error: {str(e)}", + status="error" + ) diff --git a/backend/openui/server.py b/backend/openui/server.py index b6548e6a..0cad5891 100644 --- a/backend/openui/server.py +++ b/backend/openui/server.py @@ -39,16 +39,14 @@ from .util import storage from .util import get_git_user_email from . import config +from .code_executor import CodeExecutor, CodeExecutionRequest from pydantic import ValidationError from multiprocessing import Queue -from openai._client import AsyncOpenAI -from openai.types.chat import ChatCompletionChunk -from openai.types.chat.chat_completion import ChatCompletion +from openai import AsyncOpenAI, APIError as APIStatusError +from openai.types.chat import ChatCompletion, ChatCompletionChunk from openai.types.completion import Completion -from openai._streaming import AsyncStream -from openai.types.error import APIError as APIStatusError -from ollama.client import AsyncClient -from ollama.errors import ResponseError +from openai import AsyncStream +from ollama import AsyncClient, ResponseError from typing import Optional, Any, AsyncGenerator, Dict, List, Union from pathlib import Path from typing import Optional @@ -543,6 +541,28 @@ async def delete_session( ) +@router.post("/v1/execute", tags=["openui/execute"]) +async def execute_code(request: Request, code_request: CodeExecutionRequest): + """Execute code in an isolated container. + + Args: + request: FastAPI request object + code_request: Code execution parameters + + Returns: + CodeExecutionResponse with execution results + + Raises: + HTTPException: If user is not authenticated + """ + if request.session.get("user_id") is None: + raise HTTPException(status_code=401, detail="Login required") + + executor = CodeExecutor() + result = await executor.execute(code_request) + return result + + @router.get("/openui/{name}.svg", tags=["openui/svg"]) async def render_svg(name, text: Optional[str] = None): dims = name.split("x") diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 863c71d8..cfa55575 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -10,6 +10,9 @@ dependencies = [ "fastapi-sso>=0.10.0", "boto3>=1.34.67", "types-peewee>=3.16.0", # Added type stubs + "docker>=7.0.0", # Added for code execution + "ollama>=0.1.6", # Added for Ollama integration + "tiktoken>=0.6.0", # Added for token counting ] name = "openui" version = "0.4.0" diff --git a/docker-compose.yaml b/docker-compose.yaml index 0fe38693..c3b43f9d 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -20,8 +20,32 @@ services: - OPENAI_API_KEY - ANTHROPIC_API_KEY - GROQ_API_KEY + networks: + - code-execution env_file: - .env + volumes: + - /var/run/docker.sock:/var/run/docker.sock + group_add: + - ${DOCKER_GROUP_ID:-999} # Default Docker group ID on most systems + + code-executor: + build: + context: ./backend + dockerfile: Dockerfile.python + security_opt: + - no-new-privileges + read_only: true + tmpfs: + - /tmp + - /app/execution + networks: + - code-execution + restart: always + +networks: + code-execution: + name: code-execution volumes: ollama: diff --git a/frontend/src/components/CodeViewer.tsx b/frontend/src/components/CodeViewer.tsx index 4759385f..6c8adf51 100644 --- a/frontend/src/components/CodeViewer.tsx +++ b/frontend/src/components/CodeViewer.tsx @@ -17,13 +17,17 @@ import { convertFrameworkAtom, historyAtomFamily, selectedFrameworkAtom, - uiThemeAtom, - type Framework + uiThemeAtom } from 'state' + +import { Framework } from '../state/atoms/history' import { wrappedCode } from '../lib/html' import { cn } from '../lib/utils' import Scaffold from './Scaffold' + +const isExecutableLanguage = (f: Framework): boolean => ['python', 'javascript', 'bash'].includes(f) + const CodeEditor = lazy(async () => import('components/CodeEditor')) function replaceAll(str: string, mapObj: Record) { @@ -66,6 +70,13 @@ ${render interface ViewerProps { id: string code: string + framework?: string +} + +interface ExecutionResult { + output: string + error?: string + status: string } function stripCodeblocks(code: string) { @@ -78,6 +89,9 @@ export default function CodeViewer({ id, code }: ViewerProps) { const theme = themes.find(t => t.name === uiTheme) const [framework, setFramework] = useAtom(selectedFrameworkAtom) const [convertFramework, setConvertFramework] = useAtom(convertFrameworkAtom) + const handleFrameworkChange = (f: Framework) => { + setFramework(f) + } // local state const [currentCode, setCurrentCode] = useState(code) @@ -109,6 +123,33 @@ export default function CodeViewer({ id, code }: ViewerProps) { frameworks.push('jsx') } + const [executionResult, setExecutionResult] = useState(null) + const [isExecuting, setIsExecuting] = useState(false) + + const executeCode = async () => { + setIsExecuting(true) + try { + const response = await fetch('/v1/execute', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + code: currentCode, + language: isExecutableLanguage(framework) ? framework : 'javascript' + }) + }) + const result = await response.json() + setExecutionResult(result) + } catch (error) { + setExecutionResult({ + output: '', + error: String(error), + status: 'error' + }) + } finally { + setIsExecuting(false) + } + } + return (
@@ -119,7 +160,7 @@ export default function CodeViewer({ id, code }: ViewerProps) {
+
+ + {executionResult && ( +
+
+							{executionResult.error || executionResult.output}
+						
+
+ )} +
) } diff --git a/frontend/src/components/HtmlAnnotator.tsx b/frontend/src/components/HtmlAnnotator.tsx index dc6f5766..97c43b3a 100644 --- a/frontend/src/components/HtmlAnnotator.tsx +++ b/frontend/src/components/HtmlAnnotator.tsx @@ -161,13 +161,13 @@ export default function HTMLAnnotator({ error, id }: HTMLAnnotatorProps) { const toggleFacet = useCallback( (name: string) => { - setFacets(prevFacets => { + setFacets((prevFacets: string[]) => { // Clear all non-official facets - const cleanFacets = prevFacets.filter(facet => + const cleanFacets = prevFacets.filter((facet: string) => adjectives.includes(facet) ) if (cleanFacets.includes(name)) { - return cleanFacets.filter(facet => facet !== name) + return cleanFacets.filter((facet: string) => facet !== name) } return [...cleanFacets, name] }) diff --git a/frontend/src/lib/markdown.ts b/frontend/src/lib/markdown.ts index 457ada8f..095d6881 100644 --- a/frontend/src/lib/markdown.ts +++ b/frontend/src/lib/markdown.ts @@ -80,20 +80,20 @@ export function parseMarkdown( } */ - const parsed = unified().use(remarkParse).parse(cleanMarkdown) + const parsed = unified().use(remarkParse).parse(cleanMarkdown) as { children: any[] } let htmlBlocks = parsed.children.filter( - c => - (c.type === 'code' && ['html', ''].includes(c.lang ?? '')) || + (c: any) => + (c.type === 'code' && ['html', ''].includes((c as Code).lang ?? '')) || c.type === 'html' ) as Code[] // TODO: maybe do this first and only if the first paragraph is chill - for (const c of parsed.children) { + for (const c of parsed.children as any[]) { if (c.type === 'paragraph') { let html = '' - if (c.children[0].type === 'html') { + if (c.children?.[0]?.type === 'html') { for (const c2 of c.children) { - html = html + (c2 as unknown as Code).value || '' + html = html + ((c2 as unknown as Code).value || '') } } htmlBlocks.push({ type: 'code', lang: 'html', value: html }) @@ -114,7 +114,7 @@ export function parseMarkdown( ] } const jsBlocks = parsed.children.filter( - c => c.type === 'code' && c.lang === 'javascript' + (c: any) => c.type === 'code' && (c as Code).lang === 'javascript' ) as Code[] result.html = fixHTML( [ diff --git a/frontend/src/state/atoms/history.ts b/frontend/src/state/atoms/history.ts index 925c4d88..fba658f7 100644 --- a/frontend/src/state/atoms/history.ts +++ b/frontend/src/state/atoms/history.ts @@ -13,6 +13,9 @@ export type Framework = | 'svelte' | 'vue' | 'web component' + | 'python' + | 'javascript' + | 'bash' export const FRAMEWORKS: Framework[] = [ 'preact', diff --git a/frontend/src/types/remark-parse.d.ts b/frontend/src/types/remark-parse.d.ts new file mode 100644 index 00000000..81a1dec4 --- /dev/null +++ b/frontend/src/types/remark-parse.d.ts @@ -0,0 +1,5 @@ +declare module 'remark-parse' { + import { Plugin } from 'unified' + const remarkParse: Plugin + export default remarkParse +} diff --git a/frontend/src/types/unified.d.ts b/frontend/src/types/unified.d.ts new file mode 100644 index 00000000..cef09efb --- /dev/null +++ b/frontend/src/types/unified.d.ts @@ -0,0 +1,12 @@ +declare module 'unified' { + export interface Plugin { + (...args: any[]): any + } + + export interface Processor { + use(plugin: Plugin, ...args: any[]): this + parse(content: string): any + } + + export function unified(): Processor +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 9ae17efd..adda2a4b 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -14,9 +14,12 @@ "strict": true, "target": "ESNext", "paths": { - "@/*": ["./app/*"] + "@/*": ["./app/*"], + "components/*": ["./components/*"], + "lib/*": ["./lib/*"], + "state": ["./state"] }, - "types": ["vite/client", "vitest/globals", "vite-plugin-pwa/client"] + "types": ["vite/client", "vitest/globals", "vite-plugin-pwa/client", "react", "node"] }, "include": ["src"], "references": [{ "path": "./tsconfig.node.json" }]