mito-ai 0.1.50__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 +114 -0
- mito_ai/_version.py +4 -0
- mito_ai/anthropic_client.py +334 -0
- mito_ai/app_deploy/__init__.py +6 -0
- mito_ai/app_deploy/app_deploy_utils.py +44 -0
- mito_ai/app_deploy/handlers.py +345 -0
- mito_ai/app_deploy/models.py +98 -0
- 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/__init__.py +3 -0
- mito_ai/completions/completion_handlers/agent_auto_error_fixup_handler.py +59 -0
- mito_ai/completions/completion_handlers/agent_execution_handler.py +66 -0
- mito_ai/completions/completion_handlers/chat_completion_handler.py +141 -0
- mito_ai/completions/completion_handlers/code_explain_handler.py +113 -0
- mito_ai/completions/completion_handlers/completion_handler.py +42 -0
- mito_ai/completions/completion_handlers/inline_completer_handler.py +48 -0
- mito_ai/completions/completion_handlers/smart_debug_handler.py +160 -0
- mito_ai/completions/completion_handlers/utils.py +147 -0
- mito_ai/completions/handlers.py +415 -0
- mito_ai/completions/message_history.py +401 -0
- mito_ai/completions/models.py +404 -0
- mito_ai/completions/prompt_builders/__init__.py +3 -0
- mito_ai/completions/prompt_builders/agent_execution_prompt.py +57 -0
- mito_ai/completions/prompt_builders/agent_smart_debug_prompt.py +160 -0
- mito_ai/completions/prompt_builders/agent_system_message.py +472 -0
- mito_ai/completions/prompt_builders/chat_name_prompt.py +15 -0
- mito_ai/completions/prompt_builders/chat_prompt.py +116 -0
- mito_ai/completions/prompt_builders/chat_system_message.py +92 -0
- mito_ai/completions/prompt_builders/explain_code_prompt.py +32 -0
- mito_ai/completions/prompt_builders/inline_completer_prompt.py +197 -0
- mito_ai/completions/prompt_builders/prompt_constants.py +170 -0
- mito_ai/completions/prompt_builders/smart_debug_prompt.py +199 -0
- mito_ai/completions/prompt_builders/utils.py +84 -0
- mito_ai/completions/providers.py +284 -0
- mito_ai/constants.py +63 -0
- mito_ai/db/__init__.py +3 -0
- mito_ai/db/crawlers/__init__.py +6 -0
- mito_ai/db/crawlers/base_crawler.py +61 -0
- mito_ai/db/crawlers/constants.py +43 -0
- mito_ai/db/crawlers/snowflake.py +71 -0
- mito_ai/db/handlers.py +168 -0
- mito_ai/db/models.py +31 -0
- mito_ai/db/urls.py +34 -0
- mito_ai/db/utils.py +185 -0
- mito_ai/docker/mssql/compose.yml +37 -0
- mito_ai/docker/mssql/init/setup.sql +21 -0
- mito_ai/docker/mysql/compose.yml +18 -0
- mito_ai/docker/mysql/init/setup.sql +13 -0
- mito_ai/docker/oracle/compose.yml +17 -0
- mito_ai/docker/oracle/init/setup.sql +20 -0
- mito_ai/docker/postgres/compose.yml +17 -0
- mito_ai/docker/postgres/init/setup.sql +13 -0
- mito_ai/enterprise/__init__.py +3 -0
- mito_ai/enterprise/utils.py +15 -0
- 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 +232 -0
- mito_ai/log/handlers.py +38 -0
- mito_ai/log/urls.py +21 -0
- mito_ai/logger.py +37 -0
- mito_ai/openai_client.py +382 -0
- mito_ai/path_utils.py +70 -0
- mito_ai/rules/handlers.py +44 -0
- mito_ai/rules/urls.py +22 -0
- mito_ai/rules/utils.py +56 -0
- mito_ai/settings/handlers.py +41 -0
- mito_ai/settings/urls.py +20 -0
- mito_ai/settings/utils.py +42 -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/__init__.py +3 -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/conftest.py +53 -0
- mito_ai/tests/create_agent_system_message_prompt_test.py +22 -0
- mito_ai/tests/data/prompt_lg.py +69 -0
- mito_ai/tests/data/prompt_sm.py +6 -0
- mito_ai/tests/data/prompt_xl.py +13 -0
- mito_ai/tests/data/stock_data.sqlite3 +0 -0
- mito_ai/tests/db/conftest.py +39 -0
- mito_ai/tests/db/connections_test.py +102 -0
- mito_ai/tests/db/mssql_test.py +29 -0
- mito_ai/tests/db/mysql_test.py +29 -0
- mito_ai/tests/db/oracle_test.py +29 -0
- mito_ai/tests/db/postgres_test.py +29 -0
- mito_ai/tests/db/schema_test.py +93 -0
- mito_ai/tests/db/sqlite_test.py +31 -0
- mito_ai/tests/db/test_db_constants.py +61 -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 +120 -0
- mito_ai/tests/message_history/test_message_history_utils.py +469 -0
- mito_ai/tests/open_ai_utils_test.py +152 -0
- mito_ai/tests/performance_test.py +329 -0
- mito_ai/tests/providers/test_anthropic_client.py +447 -0
- mito_ai/tests/providers/test_azure.py +631 -0
- mito_ai/tests/providers/test_capabilities.py +120 -0
- mito_ai/tests/providers/test_gemini_client.py +195 -0
- 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/rules/conftest.py +26 -0
- mito_ai/tests/rules/rules_test.py +117 -0
- mito_ai/tests/server_limits_test.py +406 -0
- mito_ai/tests/settings/conftest.py +26 -0
- mito_ai/tests/settings/settings_test.py +70 -0
- mito_ai/tests/settings/test_settings_constants.py +9 -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 +47 -0
- 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/__init__.py +3 -0
- mito_ai/tests/utils/test_anthropic_utils.py +162 -0
- mito_ai/tests/utils/test_gemini_utils.py +98 -0
- mito_ai/tests/version_check_test.py +169 -0
- mito_ai/user/handlers.py +45 -0
- mito_ai/user/urls.py +21 -0
- mito_ai/utils/__init__.py +3 -0
- mito_ai/utils/anthropic_utils.py +168 -0
- mito_ai/utils/create.py +94 -0
- mito_ai/utils/db.py +74 -0
- mito_ai/utils/error_classes.py +42 -0
- mito_ai/utils/gemini_utils.py +133 -0
- mito_ai/utils/message_history_utils.py +87 -0
- mito_ai/utils/mito_server_utils.py +242 -0
- mito_ai/utils/open_ai_utils.py +200 -0
- mito_ai/utils/provider_utils.py +49 -0
- mito_ai/utils/schema.py +86 -0
- mito_ai/utils/server_limits.py +152 -0
- mito_ai/utils/telemetry_utils.py +480 -0
- mito_ai/utils/utils.py +89 -0
- mito_ai/utils/version_utils.py +94 -0
- mito_ai/utils/websocket_base.py +88 -0
- mito_ai/version_check.py +60 -0
- mito_ai-0.1.50.data/data/etc/jupyter/jupyter_server_config.d/mito_ai.json +7 -0
- mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/build_log.json +728 -0
- mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/package.json +243 -0
- mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/schemas/mito_ai/package.json.orig +238 -0
- mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/schemas/mito_ai/toolbar-buttons.json +37 -0
- mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.8f1845da6bf2b128c049.js +21602 -0
- mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.8f1845da6bf2b128c049.js.map +1 -0
- mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/static/node_modules_process_browser_js.4b128e94d31a81ebd209.js +198 -0
- mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/static/node_modules_process_browser_js.4b128e94d31a81ebd209.js.map +1 -0
- mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.78d3ccb73e7ca1da3aae.js +619 -0
- mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.78d3ccb73e7ca1da3aae.js.map +1 -0
- mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/static/style.js +4 -0
- mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/static/style_index_js.5876024bb17dbd6a3ee6.js +712 -0
- mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/static/style_index_js.5876024bb17dbd6a3ee6.js.map +1 -0
- mito_ai-0.1.50.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.50.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.50.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.50.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.50.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.50.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.50.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.50.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.50.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.50.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.50.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_semver_index_js.3f6754ac5116d47de76b.js +2792 -0
- mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_semver_index_js.3f6754ac5116d47de76b.js.map +1 -0
- mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_vscode-diff_dist_index_js.ea55f1f9346638aafbcf.js +4859 -0
- mito_ai-0.1.50.data/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_vscode-diff_dist_index_js.ea55f1f9346638aafbcf.js.map +1 -0
- mito_ai-0.1.50.dist-info/METADATA +221 -0
- mito_ai-0.1.50.dist-info/RECORD +205 -0
- mito_ai-0.1.50.dist-info/WHEEL +4 -0
- mito_ai-0.1.50.dist-info/entry_points.txt +2 -0
- mito_ai-0.1.50.dist-info/licenses/LICENSE +3 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Copyright (c) Saga Inc.
|
|
2
|
+
# Distributed under the terms of the GNU Affero General Public License v3.0 License.
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import requests
|
|
7
|
+
import tempfile
|
|
8
|
+
from unittest.mock import patch
|
|
9
|
+
import pytest
|
|
10
|
+
from mito_ai.tests.conftest import TOKEN
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture
|
|
14
|
+
def mock_user_json():
|
|
15
|
+
"""Fixture that creates a temporary user.json file with test data"""
|
|
16
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
17
|
+
# Create the .mito directory
|
|
18
|
+
mito_dir = os.path.join(temp_dir, ".mito")
|
|
19
|
+
os.makedirs(mito_dir, exist_ok=True)
|
|
20
|
+
|
|
21
|
+
# Create a user.json file with test data
|
|
22
|
+
user_json_path = os.path.join(mito_dir, "user.json")
|
|
23
|
+
user_data = {
|
|
24
|
+
"user_email": "test@mail.com",
|
|
25
|
+
"static_user_id": "test_user_123",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
with open(user_json_path, "w") as f:
|
|
29
|
+
json.dump(user_data, f)
|
|
30
|
+
|
|
31
|
+
yield user_json_path
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# --- GET USER KEY ---
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_get_user_with_mocked_data_success(
|
|
38
|
+
jp_base_url: str, mock_user_json: str
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Test successful GET user endpoint with mocked data"""
|
|
41
|
+
with patch("mito_ai.utils.db.USER_JSON_PATH", mock_user_json):
|
|
42
|
+
response = requests.get(
|
|
43
|
+
jp_base_url + f"/mito-ai/user/user_email",
|
|
44
|
+
headers={"Authorization": f"token {TOKEN}"},
|
|
45
|
+
)
|
|
46
|
+
assert response.status_code == 200
|
|
47
|
+
|
|
48
|
+
response_json = response.json()
|
|
49
|
+
assert response_json["key"] == "user_email"
|
|
50
|
+
assert response_json["value"] == "test@mail.com"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_get_user_with_mocked_data_not_found(
|
|
54
|
+
jp_base_url: str, mock_user_json: str
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Test GET user endpoint with mocked data for non-existent key"""
|
|
57
|
+
with patch("mito_ai.utils.db.USER_JSON_PATH", mock_user_json):
|
|
58
|
+
response = requests.get(
|
|
59
|
+
jp_base_url + "/mito-ai/user/non_existent_key",
|
|
60
|
+
headers={"Authorization": f"token {TOKEN}"},
|
|
61
|
+
)
|
|
62
|
+
assert response.status_code == 404
|
|
63
|
+
|
|
64
|
+
response_json = response.json()
|
|
65
|
+
assert (
|
|
66
|
+
response_json["error"] == "User field with key 'non_existent_key' not found"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_get_user_with_no_auth(jp_base_url: str) -> None:
|
|
71
|
+
response = requests.get(
|
|
72
|
+
jp_base_url + f"/mito-ai/user/user_email",
|
|
73
|
+
)
|
|
74
|
+
assert response.status_code == 403 # Forbidden
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_get_user_with_incorrect_auth(jp_base_url: str) -> None:
|
|
78
|
+
response = requests.get(
|
|
79
|
+
jp_base_url + f"/mito-ai/user/user_email",
|
|
80
|
+
headers={"Authorization": f"token incorrect-token"},
|
|
81
|
+
)
|
|
82
|
+
assert response.status_code == 403 # Forbidden
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# --- PUT USER KEY ---
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_put_user_with_mocked_data_success(
|
|
89
|
+
jp_base_url: str, mock_user_json: str
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Test successful PUT user endpoint with mocked data"""
|
|
92
|
+
with patch("mito_ai.utils.db.USER_JSON_PATH", mock_user_json):
|
|
93
|
+
response = requests.put(
|
|
94
|
+
jp_base_url + f"/mito-ai/user/user_email",
|
|
95
|
+
headers={"Authorization": f"token {TOKEN}"},
|
|
96
|
+
json={"value": "jdoe@mail.com"},
|
|
97
|
+
)
|
|
98
|
+
assert response.status_code == 200
|
|
99
|
+
|
|
100
|
+
response_json = response.json()
|
|
101
|
+
assert response_json["status"] == "success"
|
|
102
|
+
assert response_json["key"] == "user_email"
|
|
103
|
+
assert response_json["value"] == "jdoe@mail.com"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_put_user_with_no_auth(jp_base_url: str) -> None:
|
|
107
|
+
response = requests.put(
|
|
108
|
+
jp_base_url + f"/mito-ai/user/user_email",
|
|
109
|
+
json={"value": "jdoe@mail.com"},
|
|
110
|
+
)
|
|
111
|
+
assert response.status_code == 403 # Forbidden
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_put_user_with_incorrect_auth(jp_base_url: str) -> None:
|
|
115
|
+
response = requests.put(
|
|
116
|
+
jp_base_url + f"/mito-ai/user/user_email",
|
|
117
|
+
headers={"Authorization": f"token incorrect-token"},
|
|
118
|
+
json={"value": "jdoe@mail.com"},
|
|
119
|
+
)
|
|
120
|
+
assert response.status_code == 403 # Forbidden
|
|
@@ -0,0 +1,162 @@
|
|
|
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 anthropic
|
|
6
|
+
from typing import List, Dict, Any, Tuple, Union, cast
|
|
7
|
+
from anthropic.types import MessageParam, ToolUnionParam, ToolParam
|
|
8
|
+
from mito_ai.utils.anthropic_utils import ANTHROPIC_TIMEOUT, _prepare_anthropic_request_data_and_headers
|
|
9
|
+
from mito_ai.completions.models import MessageType
|
|
10
|
+
from mito_ai.utils.schema import UJ_STATIC_USER_ID, UJ_USER_EMAIL
|
|
11
|
+
from mito_ai.utils.db import get_user_field
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Mock the get_user_field and set_user_field functions
|
|
15
|
+
@pytest.fixture(autouse=True)
|
|
16
|
+
def mock_user_functions(monkeypatch):
|
|
17
|
+
def mock_get_field(field):
|
|
18
|
+
if field == UJ_USER_EMAIL:
|
|
19
|
+
return "test@example.com"
|
|
20
|
+
elif field == UJ_STATIC_USER_ID:
|
|
21
|
+
return "test_user_id"
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
def mock_set_field(field, value):
|
|
25
|
+
# Do nothing in tests
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
monkeypatch.setattr("mito_ai.utils.anthropic_utils.get_user_field", mock_get_field)
|
|
29
|
+
monkeypatch.setattr("mito_ai.utils.server_limits.set_user_field", mock_set_field)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_basic_request_preparation():
|
|
33
|
+
"""Test basic request preparation with minimal parameters"""
|
|
34
|
+
model = "claude-3-sonnet"
|
|
35
|
+
max_tokens = 100
|
|
36
|
+
temperature = 0.7
|
|
37
|
+
# Use NotGiven to ensure system is not included in inner_data
|
|
38
|
+
system = anthropic.Omit()
|
|
39
|
+
messages: List[MessageParam] = [{"role": "user", "content": "Hello"}]
|
|
40
|
+
message_type = MessageType.CHAT
|
|
41
|
+
|
|
42
|
+
data, headers = _prepare_anthropic_request_data_and_headers(
|
|
43
|
+
model=model,
|
|
44
|
+
max_tokens=max_tokens,
|
|
45
|
+
temperature=temperature,
|
|
46
|
+
system=system,
|
|
47
|
+
messages=messages,
|
|
48
|
+
message_type=message_type,
|
|
49
|
+
tools=None,
|
|
50
|
+
tool_choice=None,
|
|
51
|
+
stream=None
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
assert headers == {"Content-Type": "application/json"}
|
|
55
|
+
assert data["timeout"] == ANTHROPIC_TIMEOUT
|
|
56
|
+
assert data["max_retries"] == 1
|
|
57
|
+
assert data["email"] == "test@example.com"
|
|
58
|
+
assert data["user_id"] == "test_user_id"
|
|
59
|
+
|
|
60
|
+
inner_data = data["data"]
|
|
61
|
+
assert inner_data["model"] == model
|
|
62
|
+
assert inner_data["max_tokens"] == max_tokens
|
|
63
|
+
assert inner_data["temperature"] == temperature
|
|
64
|
+
assert inner_data["messages"] == messages
|
|
65
|
+
# When system is NotGiven, it should not be included in inner_data
|
|
66
|
+
assert "system" not in inner_data
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_system_message_handling():
|
|
70
|
+
"""Test handling of system message when provided"""
|
|
71
|
+
system = "You are a helpful assistant"
|
|
72
|
+
messages: List[MessageParam] = [{"role": "user", "content": "Hello"}]
|
|
73
|
+
|
|
74
|
+
data, _ = _prepare_anthropic_request_data_and_headers(
|
|
75
|
+
model="claude-3-sonnet",
|
|
76
|
+
max_tokens=100,
|
|
77
|
+
temperature=0.7,
|
|
78
|
+
system=system,
|
|
79
|
+
messages=messages,
|
|
80
|
+
message_type=MessageType.CHAT,
|
|
81
|
+
tools=None,
|
|
82
|
+
tool_choice=None,
|
|
83
|
+
stream=None
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
assert data["data"]["system"] == system
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_tools_and_tool_choice():
|
|
90
|
+
"""Test handling of tools and tool_choice parameters"""
|
|
91
|
+
tools = cast(List[ToolUnionParam], [{
|
|
92
|
+
"type": "function",
|
|
93
|
+
"function": {
|
|
94
|
+
"name": "test_function",
|
|
95
|
+
"description": "A test function",
|
|
96
|
+
"parameters": {
|
|
97
|
+
"type": "object",
|
|
98
|
+
"properties": {},
|
|
99
|
+
"required": []
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}])
|
|
103
|
+
tool_choice: Dict[str, Any] = {"type": "function", "function": {"name": "test_function"}}
|
|
104
|
+
|
|
105
|
+
data, _ = _prepare_anthropic_request_data_and_headers(
|
|
106
|
+
model="claude-3-sonnet",
|
|
107
|
+
max_tokens=100,
|
|
108
|
+
temperature=0.7,
|
|
109
|
+
system=anthropic.Omit(),
|
|
110
|
+
messages=[{"role": "user", "content": "Hello"}],
|
|
111
|
+
message_type=MessageType.CHAT,
|
|
112
|
+
tools=tools,
|
|
113
|
+
tool_choice=tool_choice,
|
|
114
|
+
stream=None
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
assert data["data"]["tools"] == tools
|
|
118
|
+
assert data["data"]["tool_choice"] == tool_choice
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_stream_parameter():
|
|
122
|
+
"""Test handling of stream parameter"""
|
|
123
|
+
data, _ = _prepare_anthropic_request_data_and_headers(
|
|
124
|
+
model="claude-3-sonnet",
|
|
125
|
+
max_tokens=100,
|
|
126
|
+
temperature=0.7,
|
|
127
|
+
system=anthropic.Omit(),
|
|
128
|
+
messages=[{"role": "user", "content": "Hello"}],
|
|
129
|
+
message_type=MessageType.CHAT,
|
|
130
|
+
tools=None,
|
|
131
|
+
tool_choice=None,
|
|
132
|
+
stream=True
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
assert data["data"]["stream"] is True
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_missing_user_info(monkeypatch):
|
|
139
|
+
"""Test behavior when user email and ID are not available"""
|
|
140
|
+
|
|
141
|
+
def mock_get_field(field):
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
# Override the autouse fixture for this specific test
|
|
145
|
+
monkeypatch.setattr("mito_ai.utils.anthropic_utils.get_user_field", mock_get_field)
|
|
146
|
+
monkeypatch.setattr("mito_ai.utils.anthropic_utils.__user_email", None)
|
|
147
|
+
monkeypatch.setattr("mito_ai.utils.anthropic_utils.__user_id", None)
|
|
148
|
+
|
|
149
|
+
data, _ = _prepare_anthropic_request_data_and_headers(
|
|
150
|
+
model="claude-3-sonnet",
|
|
151
|
+
max_tokens=100,
|
|
152
|
+
temperature=0.7,
|
|
153
|
+
system=anthropic.Omit(),
|
|
154
|
+
messages=[{"role": "user", "content": "Hello"}],
|
|
155
|
+
message_type=MessageType.CHAT,
|
|
156
|
+
tools=None,
|
|
157
|
+
tool_choice=None,
|
|
158
|
+
stream=None
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
assert data["email"] is None
|
|
162
|
+
assert data["user_id"] is None
|
|
@@ -0,0 +1,98 @@
|
|
|
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
|
+
from mito_ai.utils.gemini_utils import _prepare_gemini_request_data_and_headers
|
|
6
|
+
from mito_ai.completions.models import MessageType
|
|
7
|
+
|
|
8
|
+
TEST_CONTENTS = [
|
|
9
|
+
{'role': 'system', 'content': 'You are Mito Data Copilot, an AI assistant for Jupyter.'},
|
|
10
|
+
{'role': 'user', 'content': 'Help me complete the following task. print 10'},
|
|
11
|
+
{'role': 'assistant', 'content': 'python\nprint(10)\n\nPrinted the number 10 '},
|
|
12
|
+
{'role': 'user', 'content': 'Update to print 11'}
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_basic_request_preparation():
|
|
17
|
+
"""Test basic request preparation with minimal parameters."""
|
|
18
|
+
model = "gemini-pro"
|
|
19
|
+
message_type = MessageType.CHAT
|
|
20
|
+
|
|
21
|
+
data, headers = _prepare_gemini_request_data_and_headers(
|
|
22
|
+
model=model,
|
|
23
|
+
contents=TEST_CONTENTS,
|
|
24
|
+
message_type=message_type
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
assert headers == {"Content-Type": "application/json"}
|
|
28
|
+
assert data["timeout"] == 30
|
|
29
|
+
assert data["max_retries"] == 1
|
|
30
|
+
assert data["data"]["model"] == model
|
|
31
|
+
assert data["data"]["contents"] == TEST_CONTENTS
|
|
32
|
+
assert data["data"]["message_type"] == message_type.value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_request_with_config():
|
|
36
|
+
"""Test request preparation with additional config parameters."""
|
|
37
|
+
model = "gemini-pro"
|
|
38
|
+
message_type = MessageType.CHAT
|
|
39
|
+
config = {
|
|
40
|
+
"temperature": 0.7,
|
|
41
|
+
"max_tokens": 100
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
data, headers = _prepare_gemini_request_data_and_headers(
|
|
45
|
+
model=model,
|
|
46
|
+
contents=TEST_CONTENTS,
|
|
47
|
+
message_type=message_type,
|
|
48
|
+
config=config
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
assert data["data"]["config"] == config
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_request_with_response_format():
|
|
55
|
+
"""Test request preparation with response format info."""
|
|
56
|
+
|
|
57
|
+
class TestFormat:
|
|
58
|
+
name = "test_format"
|
|
59
|
+
format = "json"
|
|
60
|
+
|
|
61
|
+
model = "gemini-pro"
|
|
62
|
+
message_type = MessageType.CHAT
|
|
63
|
+
response_format_info = TestFormat()
|
|
64
|
+
|
|
65
|
+
data, headers = _prepare_gemini_request_data_and_headers(
|
|
66
|
+
model=model,
|
|
67
|
+
contents=TEST_CONTENTS,
|
|
68
|
+
message_type=message_type,
|
|
69
|
+
response_format_info=response_format_info
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
expected_format = {
|
|
73
|
+
"name": "test_format",
|
|
74
|
+
"format": "json"
|
|
75
|
+
}
|
|
76
|
+
assert data["data"]["response_format_info"] == '{"name": "test_format", "format": "json"}'
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_request_with_complex_config():
|
|
80
|
+
"""Test request preparation with complex nested config."""
|
|
81
|
+
model = "gemini-pro"
|
|
82
|
+
message_type = MessageType.CHAT
|
|
83
|
+
config = {
|
|
84
|
+
"temperature": 0.7,
|
|
85
|
+
"nested": {
|
|
86
|
+
"key": "value",
|
|
87
|
+
"array": [1, 2, 3]
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
data, headers = _prepare_gemini_request_data_and_headers(
|
|
92
|
+
model=model,
|
|
93
|
+
contents=TEST_CONTENTS,
|
|
94
|
+
message_type=message_type,
|
|
95
|
+
config=config
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
assert data["data"]["config"] == config
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# Copyright (c) Saga Inc.
|
|
2
|
+
# Distributed under the terms of the GNU Affero General Public License v3.0 License.
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
import unittest
|
|
7
|
+
from unittest.mock import patch, MagicMock, PropertyMock
|
|
8
|
+
from typing import Any, Optional, Tuple, List, Dict, Callable, Union, cast
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
# Add the project root to path
|
|
14
|
+
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
|
|
15
|
+
if project_root not in sys.path:
|
|
16
|
+
sys.path.insert(0, project_root)
|
|
17
|
+
|
|
18
|
+
from mito_ai.version_check import VersionCheckHandler
|
|
19
|
+
|
|
20
|
+
class TestVersionCheckHandler(unittest.TestCase):
|
|
21
|
+
|
|
22
|
+
def setUp(self) -> None:
|
|
23
|
+
# Clear cache before each test
|
|
24
|
+
if hasattr(VersionCheckHandler, '_get_latest_version'):
|
|
25
|
+
VersionCheckHandler._get_latest_version.cache_clear()
|
|
26
|
+
|
|
27
|
+
@patch("mito_ai.version_check.requests.get")
|
|
28
|
+
def test_get_latest_version_method(self, mock_requests_get: MagicMock) -> None:
|
|
29
|
+
# Mock successful response
|
|
30
|
+
mock_response = MagicMock()
|
|
31
|
+
mock_response.json.return_value = {
|
|
32
|
+
"info": {"version": "1.2.3"}
|
|
33
|
+
}
|
|
34
|
+
mock_requests_get.return_value = mock_response
|
|
35
|
+
|
|
36
|
+
# Call the method
|
|
37
|
+
version, cache_time = VersionCheckHandler._get_latest_version()
|
|
38
|
+
|
|
39
|
+
# Verify results
|
|
40
|
+
self.assertEqual(version, "1.2.3")
|
|
41
|
+
self.assertIsInstance(cache_time, float)
|
|
42
|
+
|
|
43
|
+
# Call it again to use cache
|
|
44
|
+
version2, cache_time2 = VersionCheckHandler._get_latest_version()
|
|
45
|
+
|
|
46
|
+
# Should return same results without calling requests.get again
|
|
47
|
+
self.assertEqual(version2, "1.2.3")
|
|
48
|
+
self.assertEqual(cache_time, cache_time2)
|
|
49
|
+
mock_requests_get.assert_called_once()
|
|
50
|
+
|
|
51
|
+
@patch("mito_ai.version_check.requests.get")
|
|
52
|
+
def test_get_latest_version_error(self, mock_requests_get: MagicMock) -> None:
|
|
53
|
+
# Mock error response
|
|
54
|
+
mock_requests_get.side_effect = Exception("Connection error")
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
# The actual implementation lets the exception propagate when called directly
|
|
58
|
+
# So we need to catch it in our test
|
|
59
|
+
version, cache_time = VersionCheckHandler._get_latest_version()
|
|
60
|
+
self.fail("Expected an exception but none was raised")
|
|
61
|
+
except Exception as e:
|
|
62
|
+
# Just verify the exception was raised
|
|
63
|
+
self.assertEqual(str(e), "Connection error")
|
|
64
|
+
|
|
65
|
+
@patch("mito_ai.version_check.__version__", "1.0.0")
|
|
66
|
+
@patch("mito_ai.version_check.requests.get")
|
|
67
|
+
def test_successful_version_fetch(self, mock_requests_get: MagicMock) -> None:
|
|
68
|
+
# Mock the responses
|
|
69
|
+
mock_response = MagicMock()
|
|
70
|
+
mock_response.json.return_value = {
|
|
71
|
+
"info": {"version": "1.1.0"}
|
|
72
|
+
}
|
|
73
|
+
mock_requests_get.return_value = mock_response
|
|
74
|
+
|
|
75
|
+
# Create handler instance
|
|
76
|
+
handler = self._create_mocked_handler()
|
|
77
|
+
|
|
78
|
+
# Call the get method directly
|
|
79
|
+
handler.get()
|
|
80
|
+
|
|
81
|
+
# Get the response body and verify
|
|
82
|
+
response_body = json.loads(handler._write_buffer[0])
|
|
83
|
+
self.assertEqual(response_body["current_version"], "1.0.0")
|
|
84
|
+
self.assertEqual(response_body["latest_version"], "1.1.0")
|
|
85
|
+
self.assertIn("cache_age_seconds", response_body)
|
|
86
|
+
|
|
87
|
+
# Verify the requests were made correctly
|
|
88
|
+
mock_requests_get.assert_called_once_with(
|
|
89
|
+
"https://pypi.org/pypi/mito-ai/json", timeout=3
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
@patch("mito_ai.version_check.__version__", "1.0.0")
|
|
93
|
+
@patch("mito_ai.version_check.requests.get")
|
|
94
|
+
def test_cache_behavior(self, mock_requests_get: MagicMock) -> None:
|
|
95
|
+
# Mock the responses
|
|
96
|
+
mock_response = MagicMock()
|
|
97
|
+
mock_response.json.return_value = {
|
|
98
|
+
"info": {"version": "1.1.0"}
|
|
99
|
+
}
|
|
100
|
+
mock_requests_get.return_value = mock_response
|
|
101
|
+
|
|
102
|
+
# First request should make an external API call
|
|
103
|
+
handler1 = self._create_mocked_handler()
|
|
104
|
+
handler1.get()
|
|
105
|
+
|
|
106
|
+
# Second request should use the cache
|
|
107
|
+
handler2 = self._create_mocked_handler()
|
|
108
|
+
handler2.get()
|
|
109
|
+
|
|
110
|
+
# Verify requests.get was called only once
|
|
111
|
+
mock_requests_get.assert_called_once()
|
|
112
|
+
|
|
113
|
+
@patch("mito_ai.version_check.__version__", "1.0.0")
|
|
114
|
+
@patch("mito_ai.version_check.requests.get")
|
|
115
|
+
def test_pypi_request_failure(self, mock_requests_get: MagicMock) -> None:
|
|
116
|
+
# Mock the response
|
|
117
|
+
mock_requests_get.side_effect = Exception("Connection error")
|
|
118
|
+
|
|
119
|
+
# Create handler instance
|
|
120
|
+
handler = self._create_mocked_handler()
|
|
121
|
+
|
|
122
|
+
# Call the get method directly
|
|
123
|
+
handler.get()
|
|
124
|
+
|
|
125
|
+
# Get the response body and verify
|
|
126
|
+
response_body = json.loads(handler._write_buffer[0])
|
|
127
|
+
# When the request fails, the handler returns an error response
|
|
128
|
+
self.assertIn("error", response_body)
|
|
129
|
+
self.assertEqual(handler._status_code, 500)
|
|
130
|
+
|
|
131
|
+
@patch("mito_ai.version_check.__version__", "1.0.0")
|
|
132
|
+
def test_general_exception_handling(self) -> None:
|
|
133
|
+
# Create handler instance with a mock that will cause an exception
|
|
134
|
+
handler = self._create_mocked_handler()
|
|
135
|
+
|
|
136
|
+
with patch.object(VersionCheckHandler, '_get_latest_version', side_effect=Exception("Test error")):
|
|
137
|
+
# Call the get method directly
|
|
138
|
+
handler.get()
|
|
139
|
+
|
|
140
|
+
# Get the response body and verify
|
|
141
|
+
response_body = json.loads(handler._write_buffer[0])
|
|
142
|
+
self.assertIn("error", response_body)
|
|
143
|
+
self.assertEqual(handler._status_code, 500)
|
|
144
|
+
|
|
145
|
+
def _create_mocked_handler(self) -> VersionCheckHandler:
|
|
146
|
+
"""Create a mocked RequestHandler instance for testing."""
|
|
147
|
+
handler = VersionCheckHandler(MagicMock(), MagicMock())
|
|
148
|
+
handler._status_code = 200
|
|
149
|
+
handler._write_buffer = []
|
|
150
|
+
|
|
151
|
+
# We need to assign the mock methods to new variables first to avoid
|
|
152
|
+
# the mypy "Cannot assign to a method" error
|
|
153
|
+
def mock_write(chunk: str) -> None:
|
|
154
|
+
# Convert string to bytes to match expected type
|
|
155
|
+
encoded_chunk = chunk.encode('utf-8') if isinstance(chunk, str) else chunk
|
|
156
|
+
handler._write_buffer.append(encoded_chunk)
|
|
157
|
+
|
|
158
|
+
def mock_set_status(status_code: int) -> None:
|
|
159
|
+
handler._status_code = status_code
|
|
160
|
+
|
|
161
|
+
def mock_set_header(name: str, value: str) -> None:
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
# Use setattr instead of direct assignment to avoid mypy errors
|
|
165
|
+
setattr(handler, 'write', mock_write)
|
|
166
|
+
setattr(handler, 'set_status', mock_set_status)
|
|
167
|
+
setattr(handler, 'set_header', mock_set_header)
|
|
168
|
+
|
|
169
|
+
return handler
|
mito_ai/user/handlers.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Copyright (c) Saga Inc.
|
|
2
|
+
# Distributed under the terms of the GNU Affero General Public License v3.0 License.
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import tornado
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
from jupyter_server.base.handlers import APIHandler
|
|
8
|
+
from mito_ai.utils.db import get_user_field, set_user_field
|
|
9
|
+
from mito_ai.utils.telemetry_utils import identify
|
|
10
|
+
from mito_ai.utils.version_utils import is_pro
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class UserHandler(APIHandler):
|
|
14
|
+
"""Handler for operations on a specific user"""
|
|
15
|
+
|
|
16
|
+
@tornado.web.authenticated
|
|
17
|
+
def get(self, key: str) -> None:
|
|
18
|
+
value: Optional[Any] = None
|
|
19
|
+
|
|
20
|
+
if key == "is_pro":
|
|
21
|
+
# Special case, since we don't store this key
|
|
22
|
+
# in the user.json file.
|
|
23
|
+
value = str(is_pro())
|
|
24
|
+
else:
|
|
25
|
+
value = get_user_field(key)
|
|
26
|
+
|
|
27
|
+
if value is None:
|
|
28
|
+
self.set_status(404)
|
|
29
|
+
self.finish(json.dumps({"error": f"User field with key '{key}' not found"}))
|
|
30
|
+
else:
|
|
31
|
+
self.finish(json.dumps({"key": key, "value": value}))
|
|
32
|
+
|
|
33
|
+
@tornado.web.authenticated
|
|
34
|
+
def put(self, key: str) -> None:
|
|
35
|
+
data = json.loads(self.request.body)
|
|
36
|
+
if "value" not in data:
|
|
37
|
+
self.set_status(400)
|
|
38
|
+
self.finish(json.dumps({"error": "Value is required"}))
|
|
39
|
+
return
|
|
40
|
+
|
|
41
|
+
set_user_field(key, data["value"])
|
|
42
|
+
identify() # Log the new user
|
|
43
|
+
self.finish(
|
|
44
|
+
json.dumps({"status": "success", "key": key, "value": data["value"]})
|
|
45
|
+
)
|
mito_ai/user/urls.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
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.user.handlers import UserHandler
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_user_urls(base_url: str) -> List[Tuple[str, Any, dict]]:
|
|
10
|
+
"""Get all user related URL patterns.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
base_url: The base URL for the Jupyter server
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
List of (url_pattern, handler_class, handler_kwargs) tuples
|
|
17
|
+
"""
|
|
18
|
+
BASE_URL = base_url + "/mito-ai"
|
|
19
|
+
return [
|
|
20
|
+
(url_path_join(BASE_URL, "user/(.*)"), UserHandler, {}),
|
|
21
|
+
]
|