mito-ai 0.1.58__py3-none-any.whl → 0.1.60__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 +5 -2
- mito_ai/_version.py +1 -1
- mito_ai/completions/prompt_builders/agent_system_message.py +7 -1
- mito_ai/completions/prompt_builders/chat_system_message.py +4 -0
- mito_ai/completions/prompt_builders/prompt_constants.py +17 -0
- mito_ai/constants.py +25 -3
- mito_ai/enterprise/litellm_client.py +12 -5
- mito_ai/enterprise/utils.py +16 -2
- mito_ai/openai_client.py +26 -6
- mito_ai/provider_manager.py +34 -2
- mito_ai/rules/handlers.py +46 -12
- mito_ai/rules/utils.py +170 -6
- mito_ai/tests/message_history/test_generate_short_chat_name.py +35 -4
- mito_ai/tests/open_ai_utils_test.py +34 -36
- mito_ai/tests/providers/test_azure.py +2 -2
- mito_ai/tests/providers/test_providers.py +5 -5
- mito_ai/tests/rules/rules_test.py +100 -4
- mito_ai/tests/test_constants.py +90 -0
- mito_ai/tests/test_enterprise_mode.py +55 -0
- mito_ai/tests/test_model_utils.py +116 -25
- mito_ai/utils/anthropic_utils.py +1 -2
- mito_ai/utils/model_utils.py +130 -53
- mito_ai/utils/open_ai_utils.py +29 -33
- mito_ai/utils/provider_utils.py +13 -7
- {mito_ai-0.1.58.data → mito_ai-0.1.60.data}/data/share/jupyter/labextensions/mito_ai/build_log.json +1 -1
- {mito_ai-0.1.58.data → mito_ai-0.1.60.data}/data/share/jupyter/labextensions/mito_ai/package.json +2 -2
- {mito_ai-0.1.58.data → mito_ai-0.1.60.data}/data/share/jupyter/labextensions/mito_ai/schemas/mito_ai/package.json.orig +1 -1
- mito_ai-0.1.58.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.03302cc521d72eb56b00.js → mito_ai-0.1.60.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.dccfa541c464ee0e5cd4.js +1064 -175
- mito_ai-0.1.60.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.dccfa541c464ee0e5cd4.js.map +1 -0
- mito_ai-0.1.58.data/data/share/jupyter/labextensions/mito_ai/static/style_index_js.f5d476ac514294615881.js → mito_ai-0.1.60.data/data/share/jupyter/labextensions/mito_ai/static/node_modules_css-loader_dist_cjs_js_style_base_css.3594c54c9d209e1ed56e.js +2 -460
- mito_ai-0.1.60.data/data/share/jupyter/labextensions/mito_ai/static/node_modules_css-loader_dist_cjs_js_style_base_css.3594c54c9d209e1ed56e.js.map +1 -0
- mito_ai-0.1.60.data/data/share/jupyter/labextensions/mito_ai/static/node_modules_css-loader_dist_runtime_api_js-node_modules_css-loader_dist_runtime_sourceMaps_j-49e54d.3972dd8e7542bba478ad.js +463 -0
- mito_ai-0.1.60.data/data/share/jupyter/labextensions/mito_ai/static/node_modules_css-loader_dist_runtime_api_js-node_modules_css-loader_dist_runtime_sourceMaps_j-49e54d.3972dd8e7542bba478ad.js.map +1 -0
- mito_ai-0.1.58.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.570df809a692f53a7ab7.js → mito_ai-0.1.60.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.9735d9bfc8891147fee0.js +6 -6
- mito_ai-0.1.60.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.9735d9bfc8891147fee0.js.map +1 -0
- {mito_ai-0.1.58.data → mito_ai-0.1.60.data}/data/share/jupyter/labextensions/mito_ai/themes/mito_ai/index.css +78 -78
- {mito_ai-0.1.58.dist-info → mito_ai-0.1.60.dist-info}/METADATA +1 -1
- {mito_ai-0.1.58.dist-info → mito_ai-0.1.60.dist-info}/RECORD +61 -59
- mito_ai-0.1.58.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.03302cc521d72eb56b00.js.map +0 -1
- mito_ai-0.1.58.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.570df809a692f53a7ab7.js.map +0 -1
- mito_ai-0.1.58.data/data/share/jupyter/labextensions/mito_ai/static/style_index_js.f5d476ac514294615881.js.map +0 -1
- {mito_ai-0.1.58.data → mito_ai-0.1.60.data}/data/etc/jupyter/jupyter_server_config.d/mito_ai.json +0 -0
- {mito_ai-0.1.58.data → mito_ai-0.1.60.data}/data/share/jupyter/labextensions/mito_ai/schemas/mito_ai/toolbar-buttons.json +0 -0
- {mito_ai-0.1.58.data → mito_ai-0.1.60.data}/data/share/jupyter/labextensions/mito_ai/static/node_modules_process_browser_js.4b128e94d31a81ebd209.js +0 -0
- {mito_ai-0.1.58.data → mito_ai-0.1.60.data}/data/share/jupyter/labextensions/mito_ai/static/node_modules_process_browser_js.4b128e94d31a81ebd209.js.map +0 -0
- {mito_ai-0.1.58.data → mito_ai-0.1.60.data}/data/share/jupyter/labextensions/mito_ai/static/style.js +0 -0
- {mito_ai-0.1.58.data → mito_ai-0.1.60.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 +0 -0
- {mito_ai-0.1.58.data → mito_ai-0.1.60.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 +0 -0
- {mito_ai-0.1.58.data → mito_ai-0.1.60.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_auth_dist_esm_providers_cognito_tokenProvider_tokenProvider_-72f1c8.a917210f057fcfe224ad.js +0 -0
- {mito_ai-0.1.58.data → mito_ai-0.1.60.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_auth_dist_esm_providers_cognito_tokenProvider_tokenProvider_-72f1c8.a917210f057fcfe224ad.js.map +0 -0
- {mito_ai-0.1.58.data → mito_ai-0.1.60.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_dist_esm_index_mjs.6bac1a8c4cc93f15f6b7.js +0 -0
- {mito_ai-0.1.58.data → mito_ai-0.1.60.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_dist_esm_index_mjs.6bac1a8c4cc93f15f6b7.js.map +0 -0
- {mito_ai-0.1.58.data → mito_ai-0.1.60.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_ui-react_dist_esm_index_mjs.4fcecd65bef9e9847609.js +0 -0
- {mito_ai-0.1.58.data → mito_ai-0.1.60.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_ui-react_dist_esm_index_mjs.4fcecd65bef9e9847609.js.map +0 -0
- {mito_ai-0.1.58.data → mito_ai-0.1.60.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 +0 -0
- {mito_ai-0.1.58.data → mito_ai-0.1.60.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 +0 -0
- {mito_ai-0.1.58.data → mito_ai-0.1.60.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_semver_index_js.3f6754ac5116d47de76b.js +0 -0
- {mito_ai-0.1.58.data → mito_ai-0.1.60.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_semver_index_js.3f6754ac5116d47de76b.js.map +0 -0
- {mito_ai-0.1.58.data → mito_ai-0.1.60.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_vscode-diff_dist_index_js.ea55f1f9346638aafbcf.js +0 -0
- {mito_ai-0.1.58.data → mito_ai-0.1.60.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.58.data → mito_ai-0.1.60.data}/data/share/jupyter/labextensions/mito_ai/themes/mito_ai/index.js +0 -0
- {mito_ai-0.1.58.dist-info → mito_ai-0.1.60.dist-info}/WHEEL +0 -0
- {mito_ai-0.1.58.dist-info → mito_ai-0.1.60.dist-info}/entry_points.txt +0 -0
- {mito_ai-0.1.58.dist-info → mito_ai-0.1.60.dist-info}/licenses/LICENSE +0 -0
mito_ai/rules/utils.py
CHANGED
|
@@ -1,37 +1,166 @@
|
|
|
1
1
|
# Copyright (c) Saga Inc.
|
|
2
2
|
# Distributed under the terms of the GNU Affero General Public License v3.0 License.
|
|
3
3
|
|
|
4
|
-
from typing import Any, Final, List, Optional
|
|
4
|
+
from typing import Any, Final, List, Optional, cast
|
|
5
5
|
import os
|
|
6
|
+
import json
|
|
6
7
|
from mito_ai.utils.schema import MITO_FOLDER
|
|
7
8
|
|
|
8
9
|
RULES_DIR_PATH: Final[str] = os.path.join(MITO_FOLDER, 'rules')
|
|
10
|
+
RULES_METADATA_FILENAME: Final[str] = '_metadata.json'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _sanitize_rule_name(rule_name: str) -> str:
|
|
14
|
+
"""
|
|
15
|
+
Sanitizes a rule name to prevent path traversal attacks.
|
|
16
|
+
Raises ValueError if the rule name contains unsafe characters.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
rule_name: The rule name to sanitize
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
The sanitized rule name (with .md extension stripped if present)
|
|
23
|
+
|
|
24
|
+
Raises:
|
|
25
|
+
ValueError: If the rule name contains path traversal sequences or other unsafe characters
|
|
26
|
+
"""
|
|
27
|
+
if not rule_name:
|
|
28
|
+
raise ValueError("Rule name cannot be empty")
|
|
29
|
+
|
|
30
|
+
# Strip .md extension if present
|
|
31
|
+
if rule_name.endswith('.md'):
|
|
32
|
+
rule_name = rule_name[:-3]
|
|
33
|
+
|
|
34
|
+
# Check for path traversal sequences
|
|
35
|
+
if '..' in rule_name or '/' in rule_name or '\\' in rule_name:
|
|
36
|
+
raise ValueError(f"Rule name contains invalid characters: {rule_name}")
|
|
37
|
+
|
|
38
|
+
# Check for absolute paths
|
|
39
|
+
if os.path.isabs(rule_name):
|
|
40
|
+
raise ValueError(f"Rule name cannot be an absolute path: {rule_name}")
|
|
41
|
+
|
|
42
|
+
# Check for null bytes or other control characters
|
|
43
|
+
if '\x00' in rule_name:
|
|
44
|
+
raise ValueError("Rule name cannot contain null bytes")
|
|
45
|
+
|
|
46
|
+
# Ensure it's a valid filename (no reserved characters on Windows)
|
|
47
|
+
# Windows reserved: < > : " | ? *
|
|
48
|
+
invalid_chars = set('<>:|?*"')
|
|
49
|
+
if any(c in rule_name for c in invalid_chars):
|
|
50
|
+
raise ValueError(f"Rule name contains invalid filename characters: {rule_name}")
|
|
51
|
+
|
|
52
|
+
return rule_name
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _validate_rule_path(file_path: str, rule_name: str) -> None:
|
|
56
|
+
"""
|
|
57
|
+
Validates that a rule file path is within the rules directory.
|
|
58
|
+
This provides defense-in-depth protection against path traversal attacks.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
file_path: The file path to validate
|
|
62
|
+
rule_name: The rule name (for error messages)
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
ValueError: If the resolved path is outside RULES_DIR_PATH
|
|
66
|
+
"""
|
|
67
|
+
resolved_path = os.path.abspath(file_path)
|
|
68
|
+
rules_dir_abs = os.path.abspath(RULES_DIR_PATH)
|
|
69
|
+
if not resolved_path.startswith(rules_dir_abs):
|
|
70
|
+
raise ValueError(f"Invalid rule name: {rule_name}")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _get_metadata_path() -> str:
|
|
74
|
+
return os.path.join(RULES_DIR_PATH, RULES_METADATA_FILENAME)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _load_metadata() -> dict[Any, Any]:
|
|
78
|
+
path = _get_metadata_path()
|
|
79
|
+
if not os.path.exists(path):
|
|
80
|
+
return {}
|
|
81
|
+
try:
|
|
82
|
+
with open(path, 'r') as f:
|
|
83
|
+
return cast(dict[Any, Any], json.load(f))
|
|
84
|
+
except (json.JSONDecodeError, OSError):
|
|
85
|
+
return {}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _save_metadata(metadata: dict) -> None:
|
|
89
|
+
if not os.path.exists(RULES_DIR_PATH):
|
|
90
|
+
os.makedirs(RULES_DIR_PATH)
|
|
91
|
+
path = _get_metadata_path()
|
|
92
|
+
with open(path, 'w') as f:
|
|
93
|
+
json.dump(metadata, f, indent=2)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_rule_default(rule_name: str) -> bool:
|
|
97
|
+
"""Returns whether the rule is marked as a default (auto-applied) rule."""
|
|
98
|
+
if rule_name.endswith('.md'):
|
|
99
|
+
rule_name = rule_name[:-3]
|
|
100
|
+
metadata = _load_metadata()
|
|
101
|
+
entry = metadata.get(rule_name, {})
|
|
102
|
+
return bool(entry.get('is_default', False))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def set_rule_default(rule_name: str, is_default: bool) -> None:
|
|
106
|
+
"""Sets whether the rule is a default (auto-applied) rule."""
|
|
107
|
+
if rule_name.endswith('.md'):
|
|
108
|
+
rule_name = rule_name[:-3]
|
|
109
|
+
metadata = _load_metadata()
|
|
110
|
+
metadata[rule_name] = {**metadata.get(rule_name, {}), 'is_default': is_default}
|
|
111
|
+
_save_metadata(metadata)
|
|
112
|
+
|
|
9
113
|
|
|
10
114
|
def set_rules_file(rule_name: str, value: Any) -> None:
|
|
11
115
|
"""
|
|
12
116
|
Updates the value of a specific rule file in the rules directory
|
|
13
117
|
"""
|
|
118
|
+
# Sanitize rule name to prevent path traversal
|
|
119
|
+
rule_name = _sanitize_rule_name(rule_name)
|
|
120
|
+
|
|
14
121
|
# Ensure the directory exists
|
|
15
122
|
if not os.path.exists(RULES_DIR_PATH):
|
|
16
123
|
os.makedirs(RULES_DIR_PATH)
|
|
17
|
-
|
|
124
|
+
|
|
18
125
|
# Create the file path to the rule name as a .md file
|
|
19
126
|
file_path = os.path.join(RULES_DIR_PATH, f"{rule_name}.md")
|
|
20
127
|
|
|
128
|
+
# Additional safety check: ensure the resolved path is still within RULES_DIR_PATH
|
|
129
|
+
_validate_rule_path(file_path, rule_name)
|
|
130
|
+
|
|
21
131
|
with open(file_path, 'w+') as f:
|
|
22
132
|
f.write(value)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def delete_rule(rule_name: str) -> None:
|
|
136
|
+
"""
|
|
137
|
+
Deletes a rule file from the rules directory. Normalizes rule_name (strips .md).
|
|
138
|
+
Metadata for this rule is removed by cleanup_rules_metadata().
|
|
139
|
+
"""
|
|
140
|
+
# Sanitize rule name to prevent path traversal
|
|
141
|
+
rule_name = _sanitize_rule_name(rule_name)
|
|
142
|
+
|
|
143
|
+
file_path = os.path.join(RULES_DIR_PATH, f"{rule_name}.md")
|
|
144
|
+
|
|
145
|
+
# Additional safety check: ensure the resolved path is still within RULES_DIR_PATH
|
|
146
|
+
_validate_rule_path(file_path, rule_name)
|
|
23
147
|
|
|
148
|
+
if os.path.exists(file_path):
|
|
149
|
+
os.remove(file_path)
|
|
150
|
+
|
|
24
151
|
|
|
25
152
|
def get_rule(rule_name: str) -> Optional[str]:
|
|
26
153
|
"""
|
|
27
154
|
Retrieves the value of a specific rule file from the rules directory
|
|
28
155
|
"""
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
rule_name = rule_name[:-3]
|
|
156
|
+
# Sanitize rule name to prevent path traversal
|
|
157
|
+
rule_name = _sanitize_rule_name(rule_name)
|
|
32
158
|
|
|
33
159
|
file_path = os.path.join(RULES_DIR_PATH, f"{rule_name}.md")
|
|
34
160
|
|
|
161
|
+
# Additional safety check: ensure the resolved path is still within RULES_DIR_PATH
|
|
162
|
+
_validate_rule_path(file_path, rule_name)
|
|
163
|
+
|
|
35
164
|
if not os.path.exists(file_path):
|
|
36
165
|
return None
|
|
37
166
|
|
|
@@ -47,10 +176,45 @@ def get_all_rules() -> List[str]:
|
|
|
47
176
|
if not os.path.exists(RULES_DIR_PATH):
|
|
48
177
|
os.makedirs(RULES_DIR_PATH)
|
|
49
178
|
return [] # Return empty list if directory didn't exist
|
|
50
|
-
|
|
179
|
+
|
|
51
180
|
try:
|
|
52
181
|
return [f for f in os.listdir(RULES_DIR_PATH) if f.endswith('.md')]
|
|
53
182
|
except OSError as e:
|
|
54
183
|
# Log the error if needed and return empty list
|
|
55
184
|
print(f"Error reading rules directory: {e}")
|
|
56
185
|
return []
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def cleanup_rules_metadata() -> None:
|
|
189
|
+
"""
|
|
190
|
+
Removes metadata entries for rules that no longer exist on disk (deleted or renamed).
|
|
191
|
+
Call after rule create/update so metadata stays in sync with actual rule files.
|
|
192
|
+
"""
|
|
193
|
+
current_files = get_all_rules()
|
|
194
|
+
current_rule_names = {f[:-3] if f.endswith('.md') else f for f in current_files}
|
|
195
|
+
metadata = _load_metadata()
|
|
196
|
+
if not metadata:
|
|
197
|
+
return
|
|
198
|
+
keys_to_remove = [k for k in metadata if k not in current_rule_names]
|
|
199
|
+
if not keys_to_remove:
|
|
200
|
+
return
|
|
201
|
+
for k in keys_to_remove:
|
|
202
|
+
del metadata[k]
|
|
203
|
+
_save_metadata(metadata)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def get_default_rules_content() -> str:
|
|
207
|
+
"""
|
|
208
|
+
Returns the concatenated content of all rules marked as default (auto-applied).
|
|
209
|
+
Each rule is included as "Rule name:\n\n{content}". Returns empty string if no default rules.
|
|
210
|
+
"""
|
|
211
|
+
rule_files = get_all_rules()
|
|
212
|
+
parts: List[str] = []
|
|
213
|
+
for f in rule_files:
|
|
214
|
+
rule_name = f[:-3] if f.endswith('.md') else f
|
|
215
|
+
if not get_rule_default(rule_name):
|
|
216
|
+
continue
|
|
217
|
+
content = get_rule(rule_name)
|
|
218
|
+
if content and content.strip():
|
|
219
|
+
parts.append(f"{rule_name}:\n\n{content}")
|
|
220
|
+
return '\n\n'.join(parts) if parts else ""
|
|
@@ -23,7 +23,8 @@ PROVIDER_TEST_CASES = [
|
|
|
23
23
|
("gpt-4.1", "mito_ai.provider_manager.OpenAIClient"),
|
|
24
24
|
("claude-sonnet-4-5-20250929", "mito_ai.provider_manager.AnthropicClient"),
|
|
25
25
|
("gemini-3-flash-preview", "mito_ai.provider_manager.GeminiClient"),
|
|
26
|
-
("openai/gpt-4o", "mito_ai.provider_manager.LiteLLMClient"), # LiteLLM test case
|
|
26
|
+
("litellm/openai/gpt-4o", "mito_ai.provider_manager.LiteLLMClient"), # LiteLLM test case
|
|
27
|
+
("Abacus/gpt-4.1", "mito_ai.provider_manager.OpenAIClient"), # Abacus test case (uses OpenAIClient)
|
|
27
28
|
]
|
|
28
29
|
|
|
29
30
|
@pytest.mark.parametrize("selected_model,client_patch_path", PROVIDER_TEST_CASES)
|
|
@@ -49,13 +50,27 @@ async def test_generate_short_chat_name_uses_correct_provider_and_fast_model(
|
|
|
49
50
|
# Patch constants both at the source and where they're imported in model_utils
|
|
50
51
|
monkeypatch.setattr("mito_ai.constants.LITELLM_BASE_URL", "https://litellm-server.com")
|
|
51
52
|
monkeypatch.setattr("mito_ai.constants.LITELLM_API_KEY", "fake-litellm-key")
|
|
52
|
-
monkeypatch.setattr("mito_ai.constants.LITELLM_MODELS", ["openai/gpt-4o", "anthropic/claude-3-5-sonnet"])
|
|
53
|
+
monkeypatch.setattr("mito_ai.constants.LITELLM_MODELS", ["litellm/openai/gpt-4o", "litellm/anthropic/claude-3-5-sonnet"])
|
|
53
54
|
# Also patch where constants is imported in model_utils (where get_available_models uses it)
|
|
54
55
|
monkeypatch.setattr("mito_ai.utils.model_utils.constants.LITELLM_BASE_URL", "https://litellm-server.com")
|
|
55
|
-
monkeypatch.setattr("mito_ai.utils.model_utils.constants.LITELLM_MODELS", ["openai/gpt-4o", "anthropic/claude-3-5-sonnet"])
|
|
56
|
+
monkeypatch.setattr("mito_ai.utils.model_utils.constants.LITELLM_MODELS", ["litellm/openai/gpt-4o", "litellm/anthropic/claude-3-5-sonnet"])
|
|
56
57
|
# Mock is_enterprise to return True so LiteLLM models are available
|
|
57
58
|
monkeypatch.setattr("mito_ai.utils.version_utils.is_enterprise", lambda: True)
|
|
58
59
|
|
|
60
|
+
# Set up Abacus constants if testing Abacus
|
|
61
|
+
if selected_model.startswith("Abacus/"):
|
|
62
|
+
# Patch constants both at the source and where they're imported in model_utils
|
|
63
|
+
monkeypatch.setattr("mito_ai.constants.ABACUS_BASE_URL", "https://routellm.abacus.ai/v1")
|
|
64
|
+
monkeypatch.setattr("mito_ai.constants.ABACUS_API_KEY", "fake-abacus-key")
|
|
65
|
+
monkeypatch.setattr("mito_ai.constants.ABACUS_MODELS", ["Abacus/gpt-4.1", "Abacus/claude-haiku-4-5-20251001"])
|
|
66
|
+
# Also patch where constants is imported in model_utils (where get_available_models uses it)
|
|
67
|
+
monkeypatch.setattr("mito_ai.utils.model_utils.constants.ABACUS_BASE_URL", "https://routellm.abacus.ai/v1")
|
|
68
|
+
monkeypatch.setattr("mito_ai.utils.model_utils.constants.ABACUS_MODELS", ["Abacus/gpt-4.1", "Abacus/claude-haiku-4-5-20251001"])
|
|
69
|
+
# Mock is_abacus_configured to return True so Abacus models are available
|
|
70
|
+
monkeypatch.setattr("mito_ai.utils.model_utils.is_abacus_configured", lambda: True)
|
|
71
|
+
# Mock is_enterprise to return True so enterprise models are available
|
|
72
|
+
monkeypatch.setattr("mito_ai.utils.version_utils.is_enterprise", lambda: True)
|
|
73
|
+
|
|
59
74
|
# Create mock client for the specific provider being tested
|
|
60
75
|
mock_client = MagicMock()
|
|
61
76
|
mock_client.request_completions = AsyncMock(return_value="Test Chat Name")
|
|
@@ -87,12 +102,28 @@ async def test_generate_short_chat_name_uses_correct_provider_and_fast_model(
|
|
|
87
102
|
# Patch LiteLLMClient where it's defined (it's imported inside request_completions)
|
|
88
103
|
# Also patch get_available_models to return LiteLLM models
|
|
89
104
|
with patch("mito_ai.enterprise.litellm_client.LiteLLMClient", return_value=mock_client), \
|
|
90
|
-
patch("mito_ai.provider_manager.get_available_models", return_value=["openai/gpt-4o", "anthropic/claude-3-5-sonnet"]):
|
|
105
|
+
patch("mito_ai.provider_manager.get_available_models", return_value=["litellm/openai/gpt-4o", "litellm/anthropic/claude-3-5-sonnet"]):
|
|
106
|
+
result = await generate_short_chat_name(
|
|
107
|
+
user_message="What is the capital of France?",
|
|
108
|
+
assistant_message="The capital of France is Paris.",
|
|
109
|
+
llm_provider=llm_provider
|
|
110
|
+
)
|
|
111
|
+
elif selected_model.startswith("Abacus/"):
|
|
112
|
+
# For Abacus, it uses OpenAIClient, so patch the instance's method
|
|
113
|
+
# Also patch get_available_models to return Abacus models
|
|
114
|
+
assert llm_provider._openai_client is not None, "OpenAI client should be initialized for Abacus"
|
|
115
|
+
with patch.object(llm_provider._openai_client, 'request_completions', new_callable=AsyncMock, return_value="Test Chat Name") as mock_abacus_request, \
|
|
116
|
+
patch("mito_ai.provider_manager.get_available_models", return_value=["Abacus/gpt-4.1", "Abacus/claude-haiku-4-5-20251001"]):
|
|
91
117
|
result = await generate_short_chat_name(
|
|
92
118
|
user_message="What is the capital of France?",
|
|
93
119
|
assistant_message="The capital of France is Paris.",
|
|
94
120
|
llm_provider=llm_provider
|
|
95
121
|
)
|
|
122
|
+
# Verify that the OpenAI client's request_completions was called (Abacus uses OpenAIClient)
|
|
123
|
+
mock_abacus_request.assert_called_once() # type: ignore
|
|
124
|
+
# As a double check, if we have used the correct client, then we must get the correct result
|
|
125
|
+
assert result == "Test Chat Name"
|
|
126
|
+
return
|
|
96
127
|
else: # OpenAI
|
|
97
128
|
# For OpenAI, patch the instance's method since the client is created in __init__
|
|
98
129
|
assert llm_provider._openai_client is not None, "OpenAI client should be initialized"
|
|
@@ -104,17 +104,16 @@ def test_prepare_request_data_and_headers_null_message() -> None:
|
|
|
104
104
|
with patch("mito_ai.utils.open_ai_utils.get_user_field") as mock_get_user_field:
|
|
105
105
|
mock_get_user_field.side_effect = ["test@example.com", "user123"]
|
|
106
106
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
assert data["user_input"] == ""
|
|
107
|
+
data, _ = _prepare_request_data_and_headers(
|
|
108
|
+
last_message_content=None,
|
|
109
|
+
ai_completion_data={},
|
|
110
|
+
timeout=30,
|
|
111
|
+
max_retries=3,
|
|
112
|
+
message_type=MessageType.CHAT
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Verify empty string is used for null message
|
|
116
|
+
assert data["user_input"] == ""
|
|
118
117
|
|
|
119
118
|
def test_prepare_request_data_and_headers_caches_user_info() -> None:
|
|
120
119
|
"""Test that user info is cached after first call"""
|
|
@@ -125,28 +124,27 @@ def test_prepare_request_data_and_headers_caches_user_info() -> None:
|
|
|
125
124
|
|
|
126
125
|
mock_get_user_field.side_effect = ["test@example.com", "user123"]
|
|
127
126
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
assert data1["user_id"] == data2["user_id"] == "user123"
|
|
127
|
+
# First call
|
|
128
|
+
data1, _ = _prepare_request_data_and_headers(
|
|
129
|
+
last_message_content="test",
|
|
130
|
+
ai_completion_data={},
|
|
131
|
+
timeout=30,
|
|
132
|
+
max_retries=3,
|
|
133
|
+
message_type=MessageType.CHAT
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Second call
|
|
137
|
+
data2, _ = _prepare_request_data_and_headers(
|
|
138
|
+
last_message_content="test",
|
|
139
|
+
ai_completion_data={},
|
|
140
|
+
timeout=30,
|
|
141
|
+
max_retries=3,
|
|
142
|
+
message_type=MessageType.CHAT
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Verify get_user_field was only called twice (once for email, once for user_id)
|
|
146
|
+
assert mock_get_user_field.call_count == 2
|
|
147
|
+
|
|
148
|
+
# Verify both calls return same user info
|
|
149
|
+
assert data1["email"] == data2["email"] == "test@example.com"
|
|
150
|
+
assert data1["user_id"] == data2["user_id"] == "user123"
|
|
@@ -176,11 +176,11 @@ class TestAzureOpenAIClientCreation:
|
|
|
176
176
|
openai_client = OpenAIClient(config=provider_config)
|
|
177
177
|
|
|
178
178
|
# Test with gpt-4.1 model
|
|
179
|
-
resolved_model = openai_client.
|
|
179
|
+
resolved_model = openai_client._adjust_model_for_provider("gpt-4.1")
|
|
180
180
|
assert resolved_model == FAKE_AZURE_MODEL
|
|
181
181
|
|
|
182
182
|
# Test with any other model
|
|
183
|
-
resolved_model = openai_client.
|
|
183
|
+
resolved_model = openai_client._adjust_model_for_provider("gpt-3.5-turbo")
|
|
184
184
|
assert resolved_model == FAKE_AZURE_MODEL
|
|
185
185
|
|
|
186
186
|
|
|
@@ -59,7 +59,7 @@ def reset_env_vars(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
59
59
|
"name": "claude",
|
|
60
60
|
"env_vars": {"ANTHROPIC_API_KEY": "claude-key"},
|
|
61
61
|
"constants": {"ANTHROPIC_API_KEY": "claude-key", "OPENAI_API_KEY": None},
|
|
62
|
-
"model": "claude-
|
|
62
|
+
"model": "claude-haiku-4-5-20251001",
|
|
63
63
|
"mock_patch": "mito_ai.provider_manager.AnthropicClient",
|
|
64
64
|
"mock_method": "request_completions",
|
|
65
65
|
"provider_name": "Claude",
|
|
@@ -143,7 +143,7 @@ async def test_completion_request(
|
|
|
143
143
|
"name": "claude",
|
|
144
144
|
"env_vars": {"ANTHROPIC_API_KEY": "claude-key"},
|
|
145
145
|
"constants": {"ANTHROPIC_API_KEY": "claude-key", "OPENAI_API_KEY": None},
|
|
146
|
-
"model": "claude-
|
|
146
|
+
"model": "claude-haiku-4-5-20251001",
|
|
147
147
|
"mock_patch": "mito_ai.provider_manager.AnthropicClient",
|
|
148
148
|
"mock_method": "stream_completions",
|
|
149
149
|
"provider_name": "Claude",
|
|
@@ -235,7 +235,7 @@ def test_claude_error_handling(monkeypatch: pytest.MonkeyPatch, provider_config:
|
|
|
235
235
|
|
|
236
236
|
mock_client = MagicMock()
|
|
237
237
|
mock_client.capabilities = AICapabilities(
|
|
238
|
-
configuration={"model": "claude-
|
|
238
|
+
configuration={"model": "claude-haiku-4-5-20251001"},
|
|
239
239
|
provider="Claude",
|
|
240
240
|
type="ai_capabilities"
|
|
241
241
|
)
|
|
@@ -258,7 +258,7 @@ def test_claude_error_handling(monkeypatch: pytest.MonkeyPatch, provider_config:
|
|
|
258
258
|
},
|
|
259
259
|
{
|
|
260
260
|
"name": "claude_fallback",
|
|
261
|
-
"model": "claude-
|
|
261
|
+
"model": "claude-haiku-4-5-20251001",
|
|
262
262
|
"mock_function": "mito_ai.anthropic_client.get_anthropic_completion_from_mito_server",
|
|
263
263
|
"provider_name": "Claude",
|
|
264
264
|
"key_type": "claude"
|
|
@@ -315,7 +315,7 @@ async def test_mito_server_fallback_completion_request(
|
|
|
315
315
|
},
|
|
316
316
|
{
|
|
317
317
|
"name": "claude_fallback",
|
|
318
|
-
"model": "claude-
|
|
318
|
+
"model": "claude-haiku-4-5-20251001",
|
|
319
319
|
"mock_function": "mito_ai.anthropic_client.stream_anthropic_completion_from_mito_server",
|
|
320
320
|
"provider_name": "Claude",
|
|
321
321
|
"key_type": "claude"
|
|
@@ -21,7 +21,7 @@ def test_put_rule_with_auth(jp_base_url):
|
|
|
21
21
|
|
|
22
22
|
response_json = response.json()
|
|
23
23
|
assert response_json["status"] == "updated"
|
|
24
|
-
assert response_json["
|
|
24
|
+
assert response_json["rules_file"] == RULE_NAME
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
def test_put_rule_with_no_auth(jp_base_url):
|
|
@@ -55,12 +55,22 @@ def test_put_rule_missing_content(jp_base_url):
|
|
|
55
55
|
|
|
56
56
|
|
|
57
57
|
def test_get_rule_with_auth(jp_base_url):
|
|
58
|
+
# First create the rule
|
|
59
|
+
requests.put(
|
|
60
|
+
jp_base_url + f"/mito-ai/rules/{RULE_NAME}",
|
|
61
|
+
headers={"Authorization": f"token {TOKEN}"},
|
|
62
|
+
json={"content": RULE_CONTENT},
|
|
63
|
+
)
|
|
64
|
+
# Then get it
|
|
58
65
|
response = requests.get(
|
|
59
66
|
jp_base_url + f"/mito-ai/rules/{RULE_NAME}",
|
|
60
67
|
headers={"Authorization": f"token {TOKEN}"},
|
|
61
68
|
)
|
|
62
69
|
assert response.status_code == 200
|
|
63
|
-
|
|
70
|
+
response_json = response.json()
|
|
71
|
+
assert response_json["key"] == RULE_NAME
|
|
72
|
+
assert response_json["content"] == RULE_CONTENT
|
|
73
|
+
assert "is_default" in response_json # is_default field should be present
|
|
64
74
|
|
|
65
75
|
|
|
66
76
|
def test_get_rule_with_no_auth(jp_base_url):
|
|
@@ -90,6 +100,13 @@ def test_get_nonexistent_rule_with_auth(jp_base_url):
|
|
|
90
100
|
|
|
91
101
|
|
|
92
102
|
def test_get_all_rules_with_auth(jp_base_url):
|
|
103
|
+
# First create the rule
|
|
104
|
+
requests.put(
|
|
105
|
+
jp_base_url + f"/mito-ai/rules/{RULE_NAME}",
|
|
106
|
+
headers={"Authorization": f"token {TOKEN}"},
|
|
107
|
+
json={"content": RULE_CONTENT},
|
|
108
|
+
)
|
|
109
|
+
# Then get all rules
|
|
93
110
|
response = requests.get(
|
|
94
111
|
jp_base_url + f"/mito-ai/rules",
|
|
95
112
|
headers={"Authorization": f"token {TOKEN}"},
|
|
@@ -98,8 +115,13 @@ def test_get_all_rules_with_auth(jp_base_url):
|
|
|
98
115
|
|
|
99
116
|
response_json = response.json()
|
|
100
117
|
assert isinstance(response_json, list)
|
|
101
|
-
# Should contain our test rule (with .md extension)
|
|
102
|
-
|
|
118
|
+
# Should contain our test rule (with .md extension) as an object with name and is_default
|
|
119
|
+
rule_names = [rule["name"] for rule in response_json]
|
|
120
|
+
assert f"{RULE_NAME}.md" in rule_names
|
|
121
|
+
# Verify structure of rule objects
|
|
122
|
+
test_rule = next((r for r in response_json if r["name"] == f"{RULE_NAME}.md"), None)
|
|
123
|
+
assert test_rule is not None
|
|
124
|
+
assert "is_default" in test_rule
|
|
103
125
|
|
|
104
126
|
|
|
105
127
|
def test_get_all_rules_with_no_auth(jp_base_url):
|
|
@@ -115,3 +137,77 @@ def test_get_all_rules_with_incorrect_auth(jp_base_url):
|
|
|
115
137
|
headers={"Authorization": f"token incorrect-token"},
|
|
116
138
|
)
|
|
117
139
|
assert response.status_code == 403 # Forbidden
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# --- DELETE RULES ---
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_delete_rule_with_auth(jp_base_url):
|
|
146
|
+
# First create the rule
|
|
147
|
+
requests.put(
|
|
148
|
+
jp_base_url + f"/mito-ai/rules/{RULE_NAME}",
|
|
149
|
+
headers={"Authorization": f"token {TOKEN}"},
|
|
150
|
+
json={"content": RULE_CONTENT},
|
|
151
|
+
)
|
|
152
|
+
# Verify it exists
|
|
153
|
+
get_response = requests.get(
|
|
154
|
+
jp_base_url + f"/mito-ai/rules/{RULE_NAME}",
|
|
155
|
+
headers={"Authorization": f"token {TOKEN}"},
|
|
156
|
+
)
|
|
157
|
+
assert get_response.status_code == 200
|
|
158
|
+
|
|
159
|
+
# Delete it
|
|
160
|
+
delete_response = requests.delete(
|
|
161
|
+
jp_base_url + f"/mito-ai/rules/{RULE_NAME}",
|
|
162
|
+
headers={"Authorization": f"token {TOKEN}"},
|
|
163
|
+
)
|
|
164
|
+
assert delete_response.status_code == 200
|
|
165
|
+
delete_json = delete_response.json()
|
|
166
|
+
assert delete_json["status"] == "deleted"
|
|
167
|
+
assert delete_json["key"] == RULE_NAME
|
|
168
|
+
|
|
169
|
+
# Verify it no longer exists
|
|
170
|
+
get_response_after_delete = requests.get(
|
|
171
|
+
jp_base_url + f"/mito-ai/rules/{RULE_NAME}",
|
|
172
|
+
headers={"Authorization": f"token {TOKEN}"},
|
|
173
|
+
)
|
|
174
|
+
assert get_response_after_delete.status_code == 404
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_delete_rule_with_no_auth(jp_base_url):
|
|
178
|
+
response = requests.delete(
|
|
179
|
+
jp_base_url + f"/mito-ai/rules/{RULE_NAME}",
|
|
180
|
+
)
|
|
181
|
+
assert response.status_code == 403 # Forbidden
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_delete_rule_with_incorrect_auth(jp_base_url):
|
|
185
|
+
response = requests.delete(
|
|
186
|
+
jp_base_url + f"/mito-ai/rules/{RULE_NAME}",
|
|
187
|
+
headers={"Authorization": f"token incorrect-token"}, # <- wrong token
|
|
188
|
+
)
|
|
189
|
+
assert response.status_code == 403 # Forbidden
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_delete_nonexistent_rule_with_auth(jp_base_url):
|
|
193
|
+
# Delete a rule that doesn't exist (should succeed - idempotent operation)
|
|
194
|
+
response = requests.delete(
|
|
195
|
+
jp_base_url + f"/mito-ai/rules/nonexistent_rule",
|
|
196
|
+
headers={"Authorization": f"token {TOKEN}"},
|
|
197
|
+
)
|
|
198
|
+
assert response.status_code == 200
|
|
199
|
+
response_json = response.json()
|
|
200
|
+
assert response_json["status"] == "deleted"
|
|
201
|
+
assert response_json["key"] == "nonexistent_rule"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def test_delete_rule_invalid_name(jp_base_url):
|
|
205
|
+
# Try to delete with invalid rule name (contains Windows reserved character ':')
|
|
206
|
+
# This will reach the handler but fail validation in _sanitize_rule_name
|
|
207
|
+
response = requests.delete(
|
|
208
|
+
jp_base_url + f"/mito-ai/rules/rule:with:colons",
|
|
209
|
+
headers={"Authorization": f"token {TOKEN}"},
|
|
210
|
+
)
|
|
211
|
+
assert response.status_code == 400 # Bad Request
|
|
212
|
+
response_json = response.json()
|
|
213
|
+
assert "error" in response_json
|