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.
Files changed (146) hide show
  1. mito_ai/__init__.py +49 -9
  2. mito_ai/_version.py +1 -1
  3. mito_ai/anthropic_client.py +142 -67
  4. mito_ai/{app_builder → app_deploy}/__init__.py +1 -1
  5. mito_ai/app_deploy/app_deploy_utils.py +44 -0
  6. mito_ai/app_deploy/handlers.py +345 -0
  7. mito_ai/{app_builder → app_deploy}/models.py +35 -22
  8. mito_ai/app_manager/__init__.py +4 -0
  9. mito_ai/app_manager/handlers.py +167 -0
  10. mito_ai/app_manager/models.py +71 -0
  11. mito_ai/app_manager/utils.py +24 -0
  12. mito_ai/auth/README.md +18 -0
  13. mito_ai/auth/__init__.py +6 -0
  14. mito_ai/auth/handlers.py +96 -0
  15. mito_ai/auth/urls.py +13 -0
  16. mito_ai/chat_history/handlers.py +63 -0
  17. mito_ai/chat_history/urls.py +32 -0
  18. mito_ai/completions/completion_handlers/agent_execution_handler.py +1 -1
  19. mito_ai/completions/completion_handlers/chat_completion_handler.py +4 -4
  20. mito_ai/completions/completion_handlers/utils.py +99 -37
  21. mito_ai/completions/handlers.py +57 -20
  22. mito_ai/completions/message_history.py +9 -1
  23. mito_ai/completions/models.py +31 -7
  24. mito_ai/completions/prompt_builders/agent_execution_prompt.py +21 -2
  25. mito_ai/completions/prompt_builders/agent_smart_debug_prompt.py +8 -0
  26. mito_ai/completions/prompt_builders/agent_system_message.py +115 -42
  27. mito_ai/completions/prompt_builders/chat_name_prompt.py +6 -6
  28. mito_ai/completions/prompt_builders/chat_prompt.py +18 -11
  29. mito_ai/completions/prompt_builders/chat_system_message.py +4 -0
  30. mito_ai/completions/prompt_builders/prompt_constants.py +23 -4
  31. mito_ai/completions/prompt_builders/utils.py +72 -10
  32. mito_ai/completions/providers.py +81 -47
  33. mito_ai/constants.py +25 -24
  34. mito_ai/file_uploads/__init__.py +3 -0
  35. mito_ai/file_uploads/handlers.py +248 -0
  36. mito_ai/file_uploads/urls.py +21 -0
  37. mito_ai/gemini_client.py +44 -48
  38. mito_ai/log/handlers.py +10 -3
  39. mito_ai/log/urls.py +3 -3
  40. mito_ai/openai_client.py +30 -44
  41. mito_ai/path_utils.py +70 -0
  42. mito_ai/streamlit_conversion/agent_utils.py +37 -0
  43. mito_ai/streamlit_conversion/prompts/prompt_constants.py +172 -0
  44. mito_ai/streamlit_conversion/prompts/prompt_utils.py +10 -0
  45. mito_ai/streamlit_conversion/prompts/streamlit_app_creation_prompt.py +46 -0
  46. mito_ai/streamlit_conversion/prompts/streamlit_error_correction_prompt.py +28 -0
  47. mito_ai/streamlit_conversion/prompts/streamlit_finish_todo_prompt.py +45 -0
  48. mito_ai/streamlit_conversion/prompts/streamlit_system_prompt.py +56 -0
  49. mito_ai/streamlit_conversion/prompts/update_existing_app_prompt.py +50 -0
  50. mito_ai/streamlit_conversion/search_replace_utils.py +94 -0
  51. mito_ai/streamlit_conversion/streamlit_agent_handler.py +144 -0
  52. mito_ai/streamlit_conversion/streamlit_utils.py +85 -0
  53. mito_ai/streamlit_conversion/validate_streamlit_app.py +105 -0
  54. mito_ai/streamlit_preview/__init__.py +6 -0
  55. mito_ai/streamlit_preview/handlers.py +111 -0
  56. mito_ai/streamlit_preview/manager.py +152 -0
  57. mito_ai/streamlit_preview/urls.py +22 -0
  58. mito_ai/streamlit_preview/utils.py +29 -0
  59. mito_ai/tests/chat_history/test_chat_history.py +211 -0
  60. mito_ai/tests/completions/completion_handlers_utils_test.py +190 -0
  61. mito_ai/tests/deploy_app/test_app_deploy_utils.py +89 -0
  62. mito_ai/tests/file_uploads/__init__.py +2 -0
  63. mito_ai/tests/file_uploads/test_handlers.py +282 -0
  64. mito_ai/tests/message_history/test_generate_short_chat_name.py +0 -4
  65. mito_ai/tests/message_history/test_message_history_utils.py +103 -23
  66. mito_ai/tests/open_ai_utils_test.py +18 -22
  67. mito_ai/tests/providers/test_anthropic_client.py +447 -0
  68. mito_ai/tests/providers/test_azure.py +2 -6
  69. mito_ai/tests/providers/test_capabilities.py +120 -0
  70. mito_ai/tests/{test_gemini_client.py → providers/test_gemini_client.py} +40 -36
  71. mito_ai/tests/providers/test_mito_server_utils.py +448 -0
  72. mito_ai/tests/providers/test_model_resolution.py +130 -0
  73. mito_ai/tests/providers/test_openai_client.py +57 -0
  74. mito_ai/tests/providers/test_provider_completion_exception.py +66 -0
  75. mito_ai/tests/providers/test_provider_limits.py +42 -0
  76. mito_ai/tests/providers/test_providers.py +382 -0
  77. mito_ai/tests/providers/test_retry_logic.py +389 -0
  78. mito_ai/tests/providers/test_stream_mito_server_utils.py +140 -0
  79. mito_ai/tests/providers/utils.py +85 -0
  80. mito_ai/tests/streamlit_conversion/__init__.py +3 -0
  81. mito_ai/tests/streamlit_conversion/test_apply_search_replace.py +240 -0
  82. mito_ai/tests/streamlit_conversion/test_streamlit_agent_handler.py +246 -0
  83. mito_ai/tests/streamlit_conversion/test_streamlit_utils.py +193 -0
  84. mito_ai/tests/streamlit_conversion/test_validate_streamlit_app.py +112 -0
  85. mito_ai/tests/streamlit_preview/test_streamlit_preview_handler.py +118 -0
  86. mito_ai/tests/streamlit_preview/test_streamlit_preview_manager.py +292 -0
  87. mito_ai/tests/test_constants.py +31 -3
  88. mito_ai/tests/test_telemetry.py +12 -0
  89. mito_ai/tests/user/__init__.py +2 -0
  90. mito_ai/tests/user/test_user.py +120 -0
  91. mito_ai/tests/utils/test_anthropic_utils.py +6 -6
  92. mito_ai/user/handlers.py +45 -0
  93. mito_ai/user/urls.py +21 -0
  94. mito_ai/utils/anthropic_utils.py +55 -121
  95. mito_ai/utils/create.py +17 -1
  96. mito_ai/utils/error_classes.py +42 -0
  97. mito_ai/utils/gemini_utils.py +39 -94
  98. mito_ai/utils/message_history_utils.py +7 -4
  99. mito_ai/utils/mito_server_utils.py +242 -0
  100. mito_ai/utils/open_ai_utils.py +38 -155
  101. mito_ai/utils/provider_utils.py +49 -0
  102. mito_ai/utils/server_limits.py +1 -1
  103. mito_ai/utils/telemetry_utils.py +137 -5
  104. {mito_ai-0.1.33.data → mito_ai-0.1.49.data}/data/share/jupyter/labextensions/mito_ai/build_log.json +102 -100
  105. {mito_ai-0.1.33.data → mito_ai-0.1.49.data}/data/share/jupyter/labextensions/mito_ai/package.json +4 -2
  106. {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
  107. {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
  108. 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
  109. mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.8f1845da6bf2b128c049.js.map +1 -0
  110. mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/node_modules_process_browser_js.4b128e94d31a81ebd209.js +198 -0
  111. mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/node_modules_process_browser_js.4b128e94d31a81ebd209.js.map +1 -0
  112. 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
  113. mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.8b24b5b3b93f95205b56.js.map +1 -0
  114. 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
  115. mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/style_index_js.5876024bb17dbd6a3ee6.js.map +1 -0
  116. 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
  117. 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
  118. 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
  119. 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
  120. 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
  121. 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
  122. 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
  123. 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
  124. 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
  125. 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
  126. 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
  127. mito_ai-0.1.49.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_semver_index_js.3f6754ac5116d47de76b.js.map +1 -0
  128. {mito_ai-0.1.33.dist-info → mito_ai-0.1.49.dist-info}/METADATA +5 -2
  129. mito_ai-0.1.49.dist-info/RECORD +205 -0
  130. mito_ai/app_builder/handlers.py +0 -218
  131. mito_ai/tests/providers_test.py +0 -438
  132. mito_ai/tests/test_anthropic_client.py +0 -270
  133. mito_ai-0.1.33.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.281f4b9af60d620c6fb1.js.map +0 -1
  134. mito_ai-0.1.33.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.4f1d00fd0c58fcc05d8d.js.map +0 -1
  135. mito_ai-0.1.33.data/data/share/jupyter/labextensions/mito_ai/static/style_index_js.06083e515de4862df010.js.map +0 -1
  136. mito_ai-0.1.33.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_html2canvas_dist_html2canvas_js.ea47e8c8c906197f8d19.js +0 -7842
  137. 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
  138. mito_ai-0.1.33.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_semver_index_js.9795f79265ddb416864b.js.map +0 -1
  139. mito_ai-0.1.33.dist-info/RECORD +0 -134
  140. {mito_ai-0.1.33.data → mito_ai-0.1.49.data}/data/etc/jupyter/jupyter_server_config.d/mito_ai.json +0 -0
  141. {mito_ai-0.1.33.data → mito_ai-0.1.49.data}/data/share/jupyter/labextensions/mito_ai/static/style.js +0 -0
  142. {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
  143. {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
  144. {mito_ai-0.1.33.dist-info → mito_ai-0.1.49.dist-info}/WHEEL +0 -0
  145. {mito_ai-0.1.33.dist-info → mito_ai-0.1.49.dist-info}/entry_points.txt +0 -0
  146. {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