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.
Files changed (64) hide show
  1. mito_ai/__init__.py +5 -2
  2. mito_ai/_version.py +1 -1
  3. mito_ai/completions/prompt_builders/agent_system_message.py +7 -1
  4. mito_ai/completions/prompt_builders/chat_system_message.py +4 -0
  5. mito_ai/completions/prompt_builders/prompt_constants.py +17 -0
  6. mito_ai/constants.py +25 -3
  7. mito_ai/enterprise/litellm_client.py +12 -5
  8. mito_ai/enterprise/utils.py +16 -2
  9. mito_ai/openai_client.py +26 -6
  10. mito_ai/provider_manager.py +34 -2
  11. mito_ai/rules/handlers.py +46 -12
  12. mito_ai/rules/utils.py +170 -6
  13. mito_ai/tests/message_history/test_generate_short_chat_name.py +35 -4
  14. mito_ai/tests/open_ai_utils_test.py +34 -36
  15. mito_ai/tests/providers/test_azure.py +2 -2
  16. mito_ai/tests/providers/test_providers.py +5 -5
  17. mito_ai/tests/rules/rules_test.py +100 -4
  18. mito_ai/tests/test_constants.py +90 -0
  19. mito_ai/tests/test_enterprise_mode.py +55 -0
  20. mito_ai/tests/test_model_utils.py +116 -25
  21. mito_ai/utils/anthropic_utils.py +1 -2
  22. mito_ai/utils/model_utils.py +130 -53
  23. mito_ai/utils/open_ai_utils.py +29 -33
  24. mito_ai/utils/provider_utils.py +13 -7
  25. {mito_ai-0.1.58.data → mito_ai-0.1.60.data}/data/share/jupyter/labextensions/mito_ai/build_log.json +1 -1
  26. {mito_ai-0.1.58.data → mito_ai-0.1.60.data}/data/share/jupyter/labextensions/mito_ai/package.json +2 -2
  27. {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
  28. 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
  29. mito_ai-0.1.60.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.dccfa541c464ee0e5cd4.js.map +1 -0
  30. 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
  31. 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
  32. 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
  33. 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
  34. 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
  35. mito_ai-0.1.60.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.9735d9bfc8891147fee0.js.map +1 -0
  36. {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
  37. {mito_ai-0.1.58.dist-info → mito_ai-0.1.60.dist-info}/METADATA +1 -1
  38. {mito_ai-0.1.58.dist-info → mito_ai-0.1.60.dist-info}/RECORD +61 -59
  39. mito_ai-0.1.58.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.03302cc521d72eb56b00.js.map +0 -1
  40. mito_ai-0.1.58.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.570df809a692f53a7ab7.js.map +0 -1
  41. mito_ai-0.1.58.data/data/share/jupyter/labextensions/mito_ai/static/style_index_js.f5d476ac514294615881.js.map +0 -1
  42. {mito_ai-0.1.58.data → mito_ai-0.1.60.data}/data/etc/jupyter/jupyter_server_config.d/mito_ai.json +0 -0
  43. {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
  44. {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
  45. {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
  46. {mito_ai-0.1.58.data → mito_ai-0.1.60.data}/data/share/jupyter/labextensions/mito_ai/static/style.js +0 -0
  47. {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
  48. {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
  49. {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
  50. {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
  51. {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
  52. {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
  53. {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
  54. {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
  55. {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
  56. {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
  57. {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
  58. {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
  59. {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
  60. {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
  61. {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
  62. {mito_ai-0.1.58.dist-info → mito_ai-0.1.60.dist-info}/WHEEL +0 -0
  63. {mito_ai-0.1.58.dist-info → mito_ai-0.1.60.dist-info}/entry_points.txt +0 -0
  64. {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
- if rule_name.endswith('.md'):
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
- with patch("mito_ai.utils.open_ai_utils.check_mito_server_quota"):
108
- data, _ = _prepare_request_data_and_headers(
109
- last_message_content=None,
110
- ai_completion_data={},
111
- timeout=30,
112
- max_retries=3,
113
- message_type=MessageType.CHAT
114
- )
115
-
116
- # Verify empty string is used for null message
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
- with patch("mito_ai.utils.open_ai_utils.check_mito_server_quota"):
129
- # First call
130
- data1, _ = _prepare_request_data_and_headers(
131
- last_message_content="test",
132
- ai_completion_data={},
133
- timeout=30,
134
- max_retries=3,
135
- message_type=MessageType.CHAT
136
- )
137
-
138
- # Second call
139
- data2, _ = _prepare_request_data_and_headers(
140
- last_message_content="test",
141
- ai_completion_data={},
142
- timeout=30,
143
- max_retries=3,
144
- message_type=MessageType.CHAT
145
- )
146
-
147
- # Verify get_user_field was only called twice (once for email, once for user_id)
148
- assert mock_get_user_field.call_count == 2
149
-
150
- # Verify both calls return same user info
151
- assert data1["email"] == data2["email"] == "test@example.com"
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._adjust_model_for_azure_or_ollama("gpt-4.1")
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._adjust_model_for_azure_or_ollama("gpt-3.5-turbo")
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-sonnet-4-5-20250929",
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-sonnet-4-5-20250929",
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-sonnet-4-5-20250929"},
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-sonnet-4-5-20250929",
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-sonnet-4-5-20250929",
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["rules file "] == RULE_NAME
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
- assert response.json() == {"key": RULE_NAME, "content": RULE_CONTENT}
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
- assert f"{RULE_NAME}.md" in response_json
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