mito-ai 0.1.33__py3-none-any.whl → 0.1.49__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mito_ai/__init__.py +49 -9
- mito_ai/_version.py +1 -1
- mito_ai/anthropic_client.py +142 -67
- mito_ai/{app_builder → app_deploy}/__init__.py +1 -1
- mito_ai/app_deploy/app_deploy_utils.py +44 -0
- mito_ai/app_deploy/handlers.py +345 -0
- mito_ai/{app_builder → app_deploy}/models.py +35 -22
- mito_ai/app_manager/__init__.py +4 -0
- mito_ai/app_manager/handlers.py +167 -0
- mito_ai/app_manager/models.py +71 -0
- mito_ai/app_manager/utils.py +24 -0
- mito_ai/auth/README.md +18 -0
- mito_ai/auth/__init__.py +6 -0
- mito_ai/auth/handlers.py +96 -0
- mito_ai/auth/urls.py +13 -0
- mito_ai/chat_history/handlers.py +63 -0
- mito_ai/chat_history/urls.py +32 -0
- mito_ai/completions/completion_handlers/agent_execution_handler.py +1 -1
- mito_ai/completions/completion_handlers/chat_completion_handler.py +4 -4
- mito_ai/completions/completion_handlers/utils.py +99 -37
- mito_ai/completions/handlers.py +57 -20
- mito_ai/completions/message_history.py +9 -1
- mito_ai/completions/models.py +31 -7
- mito_ai/completions/prompt_builders/agent_execution_prompt.py +21 -2
- mito_ai/completions/prompt_builders/agent_smart_debug_prompt.py +8 -0
- mito_ai/completions/prompt_builders/agent_system_message.py +115 -42
- mito_ai/completions/prompt_builders/chat_name_prompt.py +6 -6
- mito_ai/completions/prompt_builders/chat_prompt.py +18 -11
- mito_ai/completions/prompt_builders/chat_system_message.py +4 -0
- mito_ai/completions/prompt_builders/prompt_constants.py +23 -4
- mito_ai/completions/prompt_builders/utils.py +72 -10
- mito_ai/completions/providers.py +81 -47
- mito_ai/constants.py +25 -24
- mito_ai/file_uploads/__init__.py +3 -0
- mito_ai/file_uploads/handlers.py +248 -0
- mito_ai/file_uploads/urls.py +21 -0
- mito_ai/gemini_client.py +44 -48
- mito_ai/log/handlers.py +10 -3
- mito_ai/log/urls.py +3 -3
- mito_ai/openai_client.py +30 -44
- mito_ai/path_utils.py +70 -0
- mito_ai/streamlit_conversion/agent_utils.py +37 -0
- mito_ai/streamlit_conversion/prompts/prompt_constants.py +172 -0
- mito_ai/streamlit_conversion/prompts/prompt_utils.py +10 -0
- mito_ai/streamlit_conversion/prompts/streamlit_app_creation_prompt.py +46 -0
- mito_ai/streamlit_conversion/prompts/streamlit_error_correction_prompt.py +28 -0
- mito_ai/streamlit_conversion/prompts/streamlit_finish_todo_prompt.py +45 -0
- mito_ai/streamlit_conversion/prompts/streamlit_system_prompt.py +56 -0
- mito_ai/streamlit_conversion/prompts/update_existing_app_prompt.py +50 -0
- mito_ai/streamlit_conversion/search_replace_utils.py +94 -0
- mito_ai/streamlit_conversion/streamlit_agent_handler.py +144 -0
- mito_ai/streamlit_conversion/streamlit_utils.py +85 -0
- mito_ai/streamlit_conversion/validate_streamlit_app.py +105 -0
- mito_ai/streamlit_preview/__init__.py +6 -0
- mito_ai/streamlit_preview/handlers.py +111 -0
- mito_ai/streamlit_preview/manager.py +152 -0
- mito_ai/streamlit_preview/urls.py +22 -0
- mito_ai/streamlit_preview/utils.py +29 -0
- mito_ai/tests/chat_history/test_chat_history.py +211 -0
- mito_ai/tests/completions/completion_handlers_utils_test.py +190 -0
- mito_ai/tests/deploy_app/test_app_deploy_utils.py +89 -0
- mito_ai/tests/file_uploads/__init__.py +2 -0
- mito_ai/tests/file_uploads/test_handlers.py +282 -0
- mito_ai/tests/message_history/test_generate_short_chat_name.py +0 -4
- mito_ai/tests/message_history/test_message_history_utils.py +103 -23
- mito_ai/tests/open_ai_utils_test.py +18 -22
- mito_ai/tests/providers/test_anthropic_client.py +447 -0
- mito_ai/tests/providers/test_azure.py +2 -6
- mito_ai/tests/providers/test_capabilities.py +120 -0
- mito_ai/tests/{test_gemini_client.py → providers/test_gemini_client.py} +40 -36
- mito_ai/tests/providers/test_mito_server_utils.py +448 -0
- mito_ai/tests/providers/test_model_resolution.py +130 -0
- mito_ai/tests/providers/test_openai_client.py +57 -0
- mito_ai/tests/providers/test_provider_completion_exception.py +66 -0
- mito_ai/tests/providers/test_provider_limits.py +42 -0
- mito_ai/tests/providers/test_providers.py +382 -0
- mito_ai/tests/providers/test_retry_logic.py +389 -0
- mito_ai/tests/providers/test_stream_mito_server_utils.py +140 -0
- mito_ai/tests/providers/utils.py +85 -0
- mito_ai/tests/streamlit_conversion/__init__.py +3 -0
- mito_ai/tests/streamlit_conversion/test_apply_search_replace.py +240 -0
- mito_ai/tests/streamlit_conversion/test_streamlit_agent_handler.py +246 -0
- mito_ai/tests/streamlit_conversion/test_streamlit_utils.py +193 -0
- mito_ai/tests/streamlit_conversion/test_validate_streamlit_app.py +112 -0
- mito_ai/tests/streamlit_preview/test_streamlit_preview_handler.py +118 -0
- mito_ai/tests/streamlit_preview/test_streamlit_preview_manager.py +292 -0
- mito_ai/tests/test_constants.py +31 -3
- mito_ai/tests/test_telemetry.py +12 -0
- mito_ai/tests/user/__init__.py +2 -0
- mito_ai/tests/user/test_user.py +120 -0
- mito_ai/tests/utils/test_anthropic_utils.py +6 -6
- mito_ai/user/handlers.py +45 -0
- mito_ai/user/urls.py +21 -0
- mito_ai/utils/anthropic_utils.py +55 -121
- mito_ai/utils/create.py +17 -1
- mito_ai/utils/error_classes.py +42 -0
- mito_ai/utils/gemini_utils.py +39 -94
- mito_ai/utils/message_history_utils.py +7 -4
- mito_ai/utils/mito_server_utils.py +242 -0
- mito_ai/utils/open_ai_utils.py +38 -155
- mito_ai/utils/provider_utils.py +49 -0
- mito_ai/utils/server_limits.py +1 -1
- mito_ai/utils/telemetry_utils.py +137 -5
- {mito_ai-0.1.33.data → mito_ai-0.1.49.data}/data/share/jupyter/labextensions/mito_ai/build_log.json +102 -100
- {mito_ai-0.1.33.data → mito_ai-0.1.49.data}/data/share/jupyter/labextensions/mito_ai/package.json +4 -2
- {mito_ai-0.1.33.data → mito_ai-0.1.49.data}/data/share/jupyter/labextensions/mito_ai/schemas/mito_ai/package.json.orig +3 -1
- {mito_ai-0.1.33.data → mito_ai-0.1.49.data}/data/share/jupyter/labextensions/mito_ai/schemas/mito_ai/toolbar-buttons.json +2 -2
- mito_ai-0.1.33.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.281f4b9af60d620c6fb1.js → mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.8f1845da6bf2b128c049.js +15948 -8403
- mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.8f1845da6bf2b128c049.js.map +1 -0
- mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/node_modules_process_browser_js.4b128e94d31a81ebd209.js +198 -0
- mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/node_modules_process_browser_js.4b128e94d31a81ebd209.js.map +1 -0
- mito_ai-0.1.33.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.4f1d00fd0c58fcc05d8d.js → mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.8b24b5b3b93f95205b56.js +58 -33
- mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.8b24b5b3b93f95205b56.js.map +1 -0
- mito_ai-0.1.33.data/data/share/jupyter/labextensions/mito_ai/static/style_index_js.06083e515de4862df010.js → mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/style_index_js.5876024bb17dbd6a3ee6.js +10 -2
- mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/style_index_js.5876024bb17dbd6a3ee6.js.map +1 -0
- mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_auth_dist_esm_providers_cognito_apis_signOut_mjs-node_module-75790d.688c25857e7b81b1740f.js +533 -0
- mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_auth_dist_esm_providers_cognito_apis_signOut_mjs-node_module-75790d.688c25857e7b81b1740f.js.map +1 -0
- mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_auth_dist_esm_providers_cognito_tokenProvider_tokenProvider_-72f1c8.a917210f057fcfe224ad.js +6941 -0
- mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_auth_dist_esm_providers_cognito_tokenProvider_tokenProvider_-72f1c8.a917210f057fcfe224ad.js.map +1 -0
- mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_dist_esm_index_mjs.6bac1a8c4cc93f15f6b7.js +1021 -0
- mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_dist_esm_index_mjs.6bac1a8c4cc93f15f6b7.js.map +1 -0
- mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_ui-react_dist_esm_index_mjs.4fcecd65bef9e9847609.js +59698 -0
- mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_ui-react_dist_esm_index_mjs.4fcecd65bef9e9847609.js.map +1 -0
- mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_react-dom_client_js-node_modules_aws-amplify_ui-react_dist_styles_css.b43d4249e4d3dac9ad7b.js +7440 -0
- mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_react-dom_client_js-node_modules_aws-amplify_ui-react_dist_styles_css.b43d4249e4d3dac9ad7b.js.map +1 -0
- mito_ai-0.1.33.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_semver_index_js.9795f79265ddb416864b.js → mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_semver_index_js.3f6754ac5116d47de76b.js +2 -240
- mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_semver_index_js.3f6754ac5116d47de76b.js.map +1 -0
- {mito_ai-0.1.33.dist-info → mito_ai-0.1.49.dist-info}/METADATA +5 -2
- mito_ai-0.1.49.dist-info/RECORD +205 -0
- mito_ai/app_builder/handlers.py +0 -218
- mito_ai/tests/providers_test.py +0 -438
- mito_ai/tests/test_anthropic_client.py +0 -270
- mito_ai-0.1.33.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.281f4b9af60d620c6fb1.js.map +0 -1
- mito_ai-0.1.33.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.4f1d00fd0c58fcc05d8d.js.map +0 -1
- mito_ai-0.1.33.data/data/share/jupyter/labextensions/mito_ai/static/style_index_js.06083e515de4862df010.js.map +0 -1
- mito_ai-0.1.33.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_html2canvas_dist_html2canvas_js.ea47e8c8c906197f8d19.js +0 -7842
- mito_ai-0.1.33.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_html2canvas_dist_html2canvas_js.ea47e8c8c906197f8d19.js.map +0 -1
- mito_ai-0.1.33.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_semver_index_js.9795f79265ddb416864b.js.map +0 -1
- mito_ai-0.1.33.dist-info/RECORD +0 -134
- {mito_ai-0.1.33.data → mito_ai-0.1.49.data}/data/etc/jupyter/jupyter_server_config.d/mito_ai.json +0 -0
- {mito_ai-0.1.33.data → mito_ai-0.1.49.data}/data/share/jupyter/labextensions/mito_ai/static/style.js +0 -0
- {mito_ai-0.1.33.data → mito_ai-0.1.49.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_vscode-diff_dist_index_js.ea55f1f9346638aafbcf.js +0 -0
- {mito_ai-0.1.33.data → mito_ai-0.1.49.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_vscode-diff_dist_index_js.ea55f1f9346638aafbcf.js.map +0 -0
- {mito_ai-0.1.33.dist-info → mito_ai-0.1.49.dist-info}/WHEEL +0 -0
- {mito_ai-0.1.33.dist-info → mito_ai-0.1.49.dist-info}/entry_points.txt +0 -0
- {mito_ai-0.1.33.dist-info → mito_ai-0.1.49.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# Copyright (c) Saga Inc.
|
|
2
|
+
# Distributed under the terms of the GNU Affero General Public License v3.0 License.
|
|
3
|
+
|
|
4
|
+
import socket
|
|
5
|
+
import subprocess
|
|
6
|
+
import time
|
|
7
|
+
import threading
|
|
8
|
+
import requests
|
|
9
|
+
from typing import Dict, Optional, Tuple
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from mito_ai.logger import get_logger
|
|
12
|
+
from mito_ai.path_utils import AbsoluteNotebookDirPath, AppFileName
|
|
13
|
+
from mito_ai.utils.error_classes import StreamlitPreviewError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class PreviewProcess:
|
|
18
|
+
"""Data class to track a streamlit preview process."""
|
|
19
|
+
proc: subprocess.Popen
|
|
20
|
+
port: int
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class StreamlitPreviewManager:
|
|
24
|
+
"""Manages streamlit preview processes and their lifecycle."""
|
|
25
|
+
|
|
26
|
+
def __init__(self) -> None:
|
|
27
|
+
self._previews: Dict[str, PreviewProcess] = {}
|
|
28
|
+
self._lock = threading.Lock()
|
|
29
|
+
self.log = get_logger()
|
|
30
|
+
|
|
31
|
+
def get_free_port(self) -> int:
|
|
32
|
+
"""Get a free port for streamlit to use."""
|
|
33
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
34
|
+
s.bind(('', 0))
|
|
35
|
+
s.listen(1)
|
|
36
|
+
port = int(s.getsockname()[1])
|
|
37
|
+
|
|
38
|
+
return port
|
|
39
|
+
|
|
40
|
+
def start_streamlit_preview(self, app_directory: AbsoluteNotebookDirPath, app_file_name: AppFileName, preview_id: str) -> int:
|
|
41
|
+
"""Start a streamlit preview process.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
app_code: The streamlit app code to run
|
|
45
|
+
preview_id: Unique identifier for this preview
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Tuple of (success, message, port)
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
|
|
53
|
+
# Get free port
|
|
54
|
+
port = self.get_free_port()
|
|
55
|
+
|
|
56
|
+
# Start streamlit process
|
|
57
|
+
cmd = [
|
|
58
|
+
"streamlit", "run", app_file_name,
|
|
59
|
+
"--server.port", str(port),
|
|
60
|
+
"--server.headless", "true",
|
|
61
|
+
"--server.address", "localhost",
|
|
62
|
+
"--server.enableXsrfProtection", "false",
|
|
63
|
+
"--server.runOnSave", "true", # auto-reload when app is saved
|
|
64
|
+
"--logger.level", "error"
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
# TODO: Security considerations for production:
|
|
68
|
+
# - Consider enabling XSRF protection if needed, but we might already get this with the APIHandler?
|
|
69
|
+
# - Add authentication headers to streamlit
|
|
70
|
+
|
|
71
|
+
proc = subprocess.Popen(
|
|
72
|
+
cmd,
|
|
73
|
+
stdout=subprocess.PIPE,
|
|
74
|
+
stderr=subprocess.PIPE,
|
|
75
|
+
text=True,
|
|
76
|
+
cwd=app_directory
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Wait for app to be ready
|
|
80
|
+
ready = self._wait_for_app_ready(port)
|
|
81
|
+
if not ready:
|
|
82
|
+
proc.terminate()
|
|
83
|
+
proc.wait()
|
|
84
|
+
raise StreamlitPreviewError("Streamlit app failed to start as app is not ready", 500)
|
|
85
|
+
|
|
86
|
+
# Register the process
|
|
87
|
+
with self._lock:
|
|
88
|
+
self._previews[preview_id] = PreviewProcess(
|
|
89
|
+
proc=proc,
|
|
90
|
+
port=port,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
self.log.info(f"Started streamlit preview {preview_id} on port {port}")
|
|
94
|
+
return port
|
|
95
|
+
|
|
96
|
+
except Exception as e:
|
|
97
|
+
self.log.error(f"Error starting streamlit preview: {e}")
|
|
98
|
+
raise StreamlitPreviewError(f"Failed to start preview: {str(e)}", 500)
|
|
99
|
+
|
|
100
|
+
def _wait_for_app_ready(self, port: int, timeout: int = 30) -> bool:
|
|
101
|
+
"""Wait for streamlit app to be ready on the given port."""
|
|
102
|
+
start_time = time.time()
|
|
103
|
+
|
|
104
|
+
while time.time() - start_time < timeout:
|
|
105
|
+
try:
|
|
106
|
+
response = requests.get(f"http://localhost:{port}", timeout=5)
|
|
107
|
+
if response.status_code == 200:
|
|
108
|
+
return True
|
|
109
|
+
except requests.exceptions.RequestException as e:
|
|
110
|
+
self.log.info(f"Waiting for app to be ready...")
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
time.sleep(1)
|
|
114
|
+
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
def stop_preview(self, preview_id: str) -> bool:
|
|
118
|
+
"""Stop a streamlit preview process.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
preview_id: The preview ID to stop
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
True if stopped successfully, False if not found
|
|
125
|
+
"""
|
|
126
|
+
self.log.info(f"Stopping preview {preview_id}")
|
|
127
|
+
with self._lock:
|
|
128
|
+
if preview_id not in self._previews:
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
preview = self._previews[preview_id]
|
|
132
|
+
|
|
133
|
+
# Terminate process
|
|
134
|
+
try:
|
|
135
|
+
preview.proc.terminate()
|
|
136
|
+
preview.proc.wait(timeout=5)
|
|
137
|
+
except subprocess.TimeoutExpired:
|
|
138
|
+
preview.proc.kill()
|
|
139
|
+
preview.proc.wait()
|
|
140
|
+
except Exception as e:
|
|
141
|
+
self.log.error(f"Error terminating process {preview_id}: {e}")
|
|
142
|
+
|
|
143
|
+
# Remove from registry
|
|
144
|
+
del self._previews[preview_id]
|
|
145
|
+
|
|
146
|
+
self.log.info(f"Stopped streamlit preview {preview_id}")
|
|
147
|
+
return True
|
|
148
|
+
|
|
149
|
+
def get_preview(self, preview_id: str) -> Optional[PreviewProcess]:
|
|
150
|
+
"""Get a preview process by ID."""
|
|
151
|
+
with self._lock:
|
|
152
|
+
return self._previews.get(preview_id)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Copyright (c) Saga Inc.
|
|
2
|
+
# Distributed under the terms of the GNU Affero General Public License v3.0 License.
|
|
3
|
+
|
|
4
|
+
from typing import Any, List, Tuple
|
|
5
|
+
from jupyter_server.utils import url_path_join
|
|
6
|
+
from mito_ai.streamlit_preview.handlers import StreamlitPreviewHandler
|
|
7
|
+
|
|
8
|
+
def get_streamlit_preview_urls(base_url: str) -> List[Tuple[str, Any, dict]]:
|
|
9
|
+
"""Get all streamlit preview related URL patterns.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
base_url: The base URL for the Jupyter server
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
List of (url_pattern, handler_class, handler_kwargs) tuples
|
|
16
|
+
"""
|
|
17
|
+
BASE_URL = base_url + "/mito-ai"
|
|
18
|
+
|
|
19
|
+
return [
|
|
20
|
+
(url_path_join(BASE_URL, "streamlit-preview"), StreamlitPreviewHandler, {}),
|
|
21
|
+
(url_path_join(BASE_URL, "streamlit-preview/(.+)"), StreamlitPreviewHandler, {}),
|
|
22
|
+
]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Copyright (c) Saga Inc.
|
|
2
|
+
# Distributed under the terms of the GNU Affero General Public License v3.0 License.
|
|
3
|
+
|
|
4
|
+
from typing import Tuple, Optional
|
|
5
|
+
from mito_ai.utils.error_classes import StreamlitPreviewError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def validate_request_body(body: Optional[dict]) -> Tuple[str, str, bool, str]:
|
|
9
|
+
"""Validate the request body and extract notebook_path and force_recreate."""
|
|
10
|
+
if body is None:
|
|
11
|
+
raise StreamlitPreviewError("Invalid or missing JSON body", 400)
|
|
12
|
+
|
|
13
|
+
notebook_path = body.get("notebook_path")
|
|
14
|
+
if not notebook_path:
|
|
15
|
+
raise StreamlitPreviewError("Missing notebook_path parameter", 400)
|
|
16
|
+
|
|
17
|
+
notebook_id = body.get("notebook_id")
|
|
18
|
+
if not notebook_id:
|
|
19
|
+
raise StreamlitPreviewError("Missing notebook_id parameter", 400)
|
|
20
|
+
|
|
21
|
+
force_recreate = body.get("force_recreate", False)
|
|
22
|
+
if not isinstance(force_recreate, bool):
|
|
23
|
+
raise StreamlitPreviewError("force_recreate must be a boolean", 400)
|
|
24
|
+
|
|
25
|
+
edit_prompt = body.get("edit_prompt", "")
|
|
26
|
+
if not isinstance(edit_prompt, str):
|
|
27
|
+
raise StreamlitPreviewError("edit_prompt must be a string", 400)
|
|
28
|
+
|
|
29
|
+
return notebook_path, notebook_id, force_recreate, edit_prompt
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# Copyright (c) Saga Inc.
|
|
2
|
+
# Distributed under the terms of the GNU Affero General Public License v3.0 License.
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
import requests
|
|
6
|
+
import time
|
|
7
|
+
from unittest.mock import patch, MagicMock
|
|
8
|
+
from mito_ai.tests.conftest import TOKEN
|
|
9
|
+
from mito_ai.completions.message_history import GlobalMessageHistory, ChatThread
|
|
10
|
+
from mito_ai.completions.models import ThreadID
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture
|
|
14
|
+
def mock_chat_threads():
|
|
15
|
+
"""Fixture that creates mock chat threads for testing"""
|
|
16
|
+
thread_id_1 = ThreadID("test-thread-1")
|
|
17
|
+
thread_id_2 = ThreadID("test-thread-2")
|
|
18
|
+
|
|
19
|
+
# Create mock threads with different timestamps
|
|
20
|
+
thread_1 = ChatThread(
|
|
21
|
+
thread_id=thread_id_1,
|
|
22
|
+
creation_ts=time.time() - 3600, # 1 hour ago
|
|
23
|
+
last_interaction_ts=time.time() - 1800, # 30 minutes ago
|
|
24
|
+
name="Test Chat 1",
|
|
25
|
+
ai_optimized_history=[
|
|
26
|
+
{"role": "user", "content": "Hello"},
|
|
27
|
+
{"role": "assistant", "content": "Hi there!"},
|
|
28
|
+
],
|
|
29
|
+
display_history=[
|
|
30
|
+
{"role": "user", "content": "Hello"},
|
|
31
|
+
{"role": "assistant", "content": "Hi there!"},
|
|
32
|
+
],
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
thread_2 = ChatThread(
|
|
36
|
+
thread_id=thread_id_2,
|
|
37
|
+
creation_ts=time.time() - 7200, # 2 hours ago
|
|
38
|
+
last_interaction_ts=time.time() - 900, # 15 minutes ago (more recent)
|
|
39
|
+
name="Test Chat 2",
|
|
40
|
+
ai_optimized_history=[
|
|
41
|
+
{"role": "user", "content": "How are you?"},
|
|
42
|
+
{"role": "assistant", "content": "I'm doing well, thanks!"},
|
|
43
|
+
],
|
|
44
|
+
display_history=[
|
|
45
|
+
{"role": "user", "content": "How are you?"},
|
|
46
|
+
{"role": "assistant", "content": "I'm doing well, thanks!"},
|
|
47
|
+
],
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
return {thread_id_1: thread_1, thread_id_2: thread_2}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.fixture
|
|
54
|
+
def mock_message_history(mock_chat_threads):
|
|
55
|
+
"""Fixture that mocks the GlobalMessageHistory with test data"""
|
|
56
|
+
mock_history = MagicMock(spec=GlobalMessageHistory)
|
|
57
|
+
mock_history._chat_threads = mock_chat_threads
|
|
58
|
+
|
|
59
|
+
# Mock the get_threads method to return sorted threads
|
|
60
|
+
def mock_get_threads():
|
|
61
|
+
from mito_ai.completions.models import ChatThreadMetadata
|
|
62
|
+
|
|
63
|
+
threads = []
|
|
64
|
+
for thread in mock_chat_threads.values():
|
|
65
|
+
threads.append(
|
|
66
|
+
ChatThreadMetadata(
|
|
67
|
+
thread_id=thread.thread_id,
|
|
68
|
+
name=thread.name,
|
|
69
|
+
creation_ts=thread.creation_ts,
|
|
70
|
+
last_interaction_ts=thread.last_interaction_ts,
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
# Sort by last_interaction_ts (newest first)
|
|
74
|
+
threads.sort(key=lambda x: x.last_interaction_ts, reverse=True)
|
|
75
|
+
return threads
|
|
76
|
+
|
|
77
|
+
mock_history.get_threads = mock_get_threads
|
|
78
|
+
return mock_history
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# --- GET ALL THREADS ---
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_get_all_threads_success(jp_base_url: str, mock_message_history):
|
|
85
|
+
"""Test successful GET all threads endpoint"""
|
|
86
|
+
# Since the server extension is already loaded, we need to work with the actual instance
|
|
87
|
+
# Let's just test that the endpoint works and returns the expected structure
|
|
88
|
+
response = requests.get(
|
|
89
|
+
jp_base_url + "/mito-ai/chat-history/threads",
|
|
90
|
+
headers={"Authorization": f"token {TOKEN}"},
|
|
91
|
+
)
|
|
92
|
+
assert response.status_code == 200
|
|
93
|
+
|
|
94
|
+
response_json = response.json()
|
|
95
|
+
assert "threads" in response_json
|
|
96
|
+
# The actual number of threads will depend on what's in the .mito/ai-chats directory
|
|
97
|
+
# So we'll just check that it's a list
|
|
98
|
+
assert isinstance(response_json["threads"], list)
|
|
99
|
+
|
|
100
|
+
# Check thread structure for any threads that exist
|
|
101
|
+
for thread in response_json["threads"]:
|
|
102
|
+
assert "thread_id" in thread
|
|
103
|
+
assert "name" in thread
|
|
104
|
+
assert "creation_ts" in thread
|
|
105
|
+
assert "last_interaction_ts" in thread
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_get_all_threads_empty(jp_base_url: str):
|
|
109
|
+
"""Test GET all threads endpoint when no threads exist"""
|
|
110
|
+
# This test will work with whatever threads exist in the actual .mito/ai-chats directory
|
|
111
|
+
# We'll just verify the endpoint works and returns the expected structure
|
|
112
|
+
response = requests.get(
|
|
113
|
+
jp_base_url + "/mito-ai/chat-history/threads",
|
|
114
|
+
headers={"Authorization": f"token {TOKEN}"},
|
|
115
|
+
)
|
|
116
|
+
assert response.status_code == 200
|
|
117
|
+
|
|
118
|
+
response_json = response.json()
|
|
119
|
+
assert "threads" in response_json
|
|
120
|
+
assert isinstance(response_json["threads"], list)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_get_all_threads_with_no_auth(jp_base_url: str):
|
|
124
|
+
"""Test GET all threads endpoint without authentication"""
|
|
125
|
+
response = requests.get(
|
|
126
|
+
jp_base_url + "/mito-ai/chat-history/threads",
|
|
127
|
+
)
|
|
128
|
+
assert response.status_code == 403 # Forbidden
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_get_all_threads_with_incorrect_auth(jp_base_url: str):
|
|
132
|
+
"""Test GET all threads endpoint with incorrect authentication"""
|
|
133
|
+
response = requests.get(
|
|
134
|
+
jp_base_url + "/mito-ai/chat-history/threads",
|
|
135
|
+
headers={"Authorization": f"token incorrect-token"},
|
|
136
|
+
)
|
|
137
|
+
assert response.status_code == 403 # Forbidden
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# --- GET SPECIFIC THREAD ---
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_get_specific_thread_success(jp_base_url: str, mock_message_history):
|
|
144
|
+
"""Test successful GET specific thread endpoint"""
|
|
145
|
+
# First, get all threads to see what's available
|
|
146
|
+
response = requests.get(
|
|
147
|
+
jp_base_url + "/mito-ai/chat-history/threads",
|
|
148
|
+
headers={"Authorization": f"token {TOKEN}"},
|
|
149
|
+
)
|
|
150
|
+
assert response.status_code == 200
|
|
151
|
+
|
|
152
|
+
threads = response.json()["threads"]
|
|
153
|
+
if not threads:
|
|
154
|
+
# If no threads exist, skip this test
|
|
155
|
+
pytest.skip("No threads available for testing")
|
|
156
|
+
|
|
157
|
+
# Use the first available thread
|
|
158
|
+
thread_id = threads[0]["thread_id"]
|
|
159
|
+
|
|
160
|
+
response = requests.get(
|
|
161
|
+
jp_base_url + f"/mito-ai/chat-history/threads/{thread_id}",
|
|
162
|
+
headers={"Authorization": f"token {TOKEN}"},
|
|
163
|
+
)
|
|
164
|
+
assert response.status_code == 200
|
|
165
|
+
|
|
166
|
+
response_json = response.json()
|
|
167
|
+
assert response_json["thread_id"] == thread_id
|
|
168
|
+
assert "name" in response_json
|
|
169
|
+
assert "creation_ts" in response_json
|
|
170
|
+
assert "last_interaction_ts" in response_json
|
|
171
|
+
assert "display_history" in response_json
|
|
172
|
+
assert "ai_optimized_history" in response_json
|
|
173
|
+
|
|
174
|
+
# Check message history structure
|
|
175
|
+
display_history = response_json["display_history"]
|
|
176
|
+
assert isinstance(display_history, list)
|
|
177
|
+
ai_optimized_history = response_json["ai_optimized_history"]
|
|
178
|
+
assert isinstance(ai_optimized_history, list)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_get_specific_thread_not_found(jp_base_url: str, mock_message_history):
|
|
182
|
+
"""Test GET specific thread endpoint with non-existent thread ID"""
|
|
183
|
+
# Use a clearly non-existent thread ID
|
|
184
|
+
fake_thread_id = "non-existent-thread-12345"
|
|
185
|
+
|
|
186
|
+
response = requests.get(
|
|
187
|
+
jp_base_url + f"/mito-ai/chat-history/threads/{fake_thread_id}",
|
|
188
|
+
headers={"Authorization": f"token {TOKEN}"},
|
|
189
|
+
)
|
|
190
|
+
assert response.status_code == 404
|
|
191
|
+
|
|
192
|
+
response_json = response.json()
|
|
193
|
+
assert "error" in response_json
|
|
194
|
+
assert fake_thread_id in response_json["error"]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_get_specific_thread_with_no_auth(jp_base_url: str):
|
|
198
|
+
"""Test GET specific thread endpoint without authentication"""
|
|
199
|
+
response = requests.get(
|
|
200
|
+
jp_base_url + "/mito-ai/chat-history/threads/test-thread-1",
|
|
201
|
+
)
|
|
202
|
+
assert response.status_code == 403 # Forbidden
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_get_specific_thread_with_incorrect_auth(jp_base_url: str):
|
|
206
|
+
"""Test GET specific thread endpoint with incorrect authentication"""
|
|
207
|
+
response = requests.get(
|
|
208
|
+
jp_base_url + "/mito-ai/chat-history/threads/test-thread-1",
|
|
209
|
+
headers={"Authorization": f"token incorrect-token"},
|
|
210
|
+
)
|
|
211
|
+
assert response.status_code == 403 # Forbidden
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# Copyright (c) Saga Inc.
|
|
2
|
+
# Distributed under the terms of the GNU Affero General Public License v3.0 License.
|
|
3
|
+
|
|
4
|
+
import base64
|
|
5
|
+
import os
|
|
6
|
+
import tempfile
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from mito_ai.completions.completion_handlers.utils import (
|
|
9
|
+
create_ai_optimized_message,
|
|
10
|
+
extract_and_encode_images_from_additional_context,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@contextmanager
|
|
15
|
+
def temporary_image_file(suffix=".png", content=b"fake_image_data"):
|
|
16
|
+
"""Context manager that creates a temporary image file for testing."""
|
|
17
|
+
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as temp_file:
|
|
18
|
+
temp_file.write(content)
|
|
19
|
+
temp_file_path = temp_file.name
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
yield temp_file_path
|
|
23
|
+
finally:
|
|
24
|
+
# Clean up the temporary file
|
|
25
|
+
os.unlink(temp_file_path)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_text_only_message():
|
|
29
|
+
"""Test scenario where the user only inputs text"""
|
|
30
|
+
result = create_ai_optimized_message("Hello world")
|
|
31
|
+
|
|
32
|
+
assert result["role"] == "user"
|
|
33
|
+
assert result["content"] == "Hello world"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_message_with_uploaded_image():
|
|
37
|
+
"""Test scenario where the user uploads an image"""
|
|
38
|
+
with temporary_image_file() as temp_file_path:
|
|
39
|
+
result = create_ai_optimized_message(
|
|
40
|
+
text="Analyze this",
|
|
41
|
+
additional_context=[{"type": "image/png", "value": temp_file_path}],
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
assert result["role"] == "user"
|
|
45
|
+
assert isinstance(result["content"], list)
|
|
46
|
+
assert result["content"][0]["type"] == "text"
|
|
47
|
+
assert result["content"][1]["type"] == "image_url"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_message_with_multiple_uploaded_images():
|
|
51
|
+
"""Test scenario where the user uploads multiple images"""
|
|
52
|
+
with temporary_image_file(suffix=".png", content=b"image1_data") as temp_file1:
|
|
53
|
+
with temporary_image_file(suffix=".jpg", content=b"image2_data") as temp_file2:
|
|
54
|
+
result = create_ai_optimized_message(
|
|
55
|
+
text="Analyze these images",
|
|
56
|
+
additional_context=[
|
|
57
|
+
{"type": "image/png", "value": temp_file1},
|
|
58
|
+
{"type": "image/jpeg", "value": temp_file2},
|
|
59
|
+
],
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
assert result["role"] == "user"
|
|
63
|
+
assert isinstance(result["content"], list)
|
|
64
|
+
assert len(result["content"]) == 3 # text + 2 images
|
|
65
|
+
assert result["content"][0]["type"] == "text"
|
|
66
|
+
assert result["content"][0]["text"] == "Analyze these images"
|
|
67
|
+
assert result["content"][1]["type"] == "image_url"
|
|
68
|
+
assert result["content"][2]["type"] == "image_url"
|
|
69
|
+
|
|
70
|
+
# Verify the image URLs are properly formatted
|
|
71
|
+
assert result["content"][1]["image_url"]["url"].startswith("data:image/png;base64,")
|
|
72
|
+
assert result["content"][2]["image_url"]["url"].startswith("data:image/jpeg;base64,")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_message_with_active_cell_output():
|
|
76
|
+
"""Test scenario where the active cell has an output"""
|
|
77
|
+
result = create_ai_optimized_message(
|
|
78
|
+
text="Analyze this", base64EncodedActiveCellOutput="cell_output_data"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
assert result["role"] == "user"
|
|
82
|
+
assert isinstance(result["content"], list)
|
|
83
|
+
assert result["content"][0]["type"] == "text"
|
|
84
|
+
assert result["content"][1]["type"] == "image_url"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_message_with_uploaded_image_and_active_cell_output():
|
|
88
|
+
"""Test scenario where the user uploads an image and the active cell has an output"""
|
|
89
|
+
with temporary_image_file() as temp_file_path:
|
|
90
|
+
result = create_ai_optimized_message(
|
|
91
|
+
text="Analyze this",
|
|
92
|
+
additional_context=[{"type": "image/png", "value": temp_file_path}],
|
|
93
|
+
base64EncodedActiveCellOutput="cell_output_data",
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
assert result["role"] == "user"
|
|
97
|
+
assert isinstance(result["content"], list)
|
|
98
|
+
assert result["content"][0]["type"] == "text"
|
|
99
|
+
assert result["content"][1]["type"] == "image_url"
|
|
100
|
+
assert result["content"][2]["type"] == "image_url"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_extract_and_encode_images_from_additional_context_valid_image():
|
|
104
|
+
"""Test extracting and encoding a valid image file"""
|
|
105
|
+
with temporary_image_file() as temp_file_path:
|
|
106
|
+
additional_context = [{"type": "image/png", "value": temp_file_path}]
|
|
107
|
+
|
|
108
|
+
encoded_images = extract_and_encode_images_from_additional_context(
|
|
109
|
+
additional_context
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
assert len(encoded_images) == 1
|
|
113
|
+
assert encoded_images[0].startswith("data:image/png;base64,")
|
|
114
|
+
# Verify it's valid base64 by checking it can be decoded
|
|
115
|
+
base64_data = encoded_images[0].split(",")[1]
|
|
116
|
+
decoded_data = base64.b64decode(base64_data)
|
|
117
|
+
assert decoded_data == b"fake_image_data"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_extract_and_encode_images_from_additional_context_multiple_images():
|
|
121
|
+
"""Test extracting and encoding multiple image files"""
|
|
122
|
+
with temporary_image_file(suffix=".png", content=b"image1_data") as temp_file1:
|
|
123
|
+
with temporary_image_file(suffix=".jpg", content=b"image2_data") as temp_file2:
|
|
124
|
+
additional_context = [
|
|
125
|
+
{"type": "image/png", "value": temp_file1},
|
|
126
|
+
{"type": "image/jpeg", "value": temp_file2},
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
encoded_images = extract_and_encode_images_from_additional_context(
|
|
130
|
+
additional_context
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
assert len(encoded_images) == 2
|
|
134
|
+
assert encoded_images[0].startswith("data:image/png;base64,")
|
|
135
|
+
assert encoded_images[1].startswith("data:image/jpeg;base64,")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_extract_and_encode_images_from_additional_context_invalid_file():
|
|
139
|
+
"""Test handling of invalid/non-existent image files"""
|
|
140
|
+
additional_context = [{"type": "image/png", "value": "non_existent_file.png"}]
|
|
141
|
+
|
|
142
|
+
encoded_images = extract_and_encode_images_from_additional_context(
|
|
143
|
+
additional_context
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
assert len(encoded_images) == 0
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def test_extract_and_encode_images_from_additional_context_non_image_types():
|
|
150
|
+
"""Test that non-image types are ignored"""
|
|
151
|
+
with temporary_image_file(suffix=".txt", content=b"text_data") as temp_file:
|
|
152
|
+
additional_context = [
|
|
153
|
+
{"type": "text/plain", "value": temp_file},
|
|
154
|
+
{"type": "application/pdf", "value": "document.pdf"},
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
encoded_images = extract_and_encode_images_from_additional_context(
|
|
158
|
+
additional_context
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
assert len(encoded_images) == 0
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def test_extract_and_encode_images_from_additional_context_mixed_types():
|
|
165
|
+
"""Test handling of mixed image and non-image types"""
|
|
166
|
+
with temporary_image_file() as temp_image_file:
|
|
167
|
+
additional_context = [
|
|
168
|
+
{"type": "image/png", "value": temp_image_file},
|
|
169
|
+
{"type": "text/plain", "value": "document.txt"},
|
|
170
|
+
{"type": "image/jpeg", "value": "non_existent.jpg"},
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
encoded_images = extract_and_encode_images_from_additional_context(
|
|
174
|
+
additional_context
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Should only have the valid PNG image
|
|
178
|
+
assert len(encoded_images) == 1
|
|
179
|
+
assert encoded_images[0].startswith("data:image/png;base64,")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_extract_and_encode_images_from_additional_context_empty():
|
|
183
|
+
"""Test handling of empty or None additional_context"""
|
|
184
|
+
# Test with None
|
|
185
|
+
encoded_images = extract_and_encode_images_from_additional_context(None)
|
|
186
|
+
assert len(encoded_images) == 0
|
|
187
|
+
|
|
188
|
+
# Test with empty list
|
|
189
|
+
encoded_images = extract_and_encode_images_from_additional_context([])
|
|
190
|
+
assert len(encoded_images) == 0
|