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
@@ -7,6 +7,7 @@ from mito_ai.constants import (
7
7
  ACTIVE_BASE_URL, MITO_PROD_BASE_URL, MITO_DEV_BASE_URL,
8
8
  MITO_STREAMLIT_DEV_BASE_URL, MITO_STREAMLIT_TEST_BASE_URL, ACTIVE_STREAMLIT_BASE_URL,
9
9
  COGNITO_CONFIG_DEV, ACTIVE_COGNITO_CONFIG,
10
+ parse_comma_separated_models,
10
11
  )
11
12
 
12
13
 
@@ -45,3 +46,92 @@ def test_cognito_config() -> Any:
45
46
 
46
47
  assert COGNITO_CONFIG_DEV == expected_config
47
48
  assert ACTIVE_COGNITO_CONFIG == COGNITO_CONFIG_DEV
49
+
50
+
51
+ class TestParseCommaSeparatedModels:
52
+ """Tests for parse_comma_separated_models helper function."""
53
+
54
+ def test_parse_models_no_quotes(self) -> None:
55
+ """Test parsing models without quotes."""
56
+ models_str = "litellm/openai/gpt-4o,litellm/anthropic/claude-3-5-sonnet"
57
+ result = parse_comma_separated_models(models_str)
58
+ assert result == ["litellm/openai/gpt-4o", "litellm/anthropic/claude-3-5-sonnet"]
59
+
60
+ def test_parse_models_double_quotes(self) -> None:
61
+ """Test parsing models with double quotes."""
62
+ # Entire string quoted
63
+ models_str = '"litellm/openai/gpt-4o,litellm/anthropic/claude-3-5-sonnet"'
64
+ result = parse_comma_separated_models(models_str)
65
+ assert result == ["litellm/openai/gpt-4o", "litellm/anthropic/claude-3-5-sonnet"]
66
+
67
+ # Individual models quoted
68
+ models_str = '"litellm/openai/gpt-4o","litellm/anthropic/claude-3-5-sonnet"'
69
+ result = parse_comma_separated_models(models_str)
70
+ assert result == ["litellm/openai/gpt-4o", "litellm/anthropic/claude-3-5-sonnet"]
71
+
72
+ def test_parse_models_single_quotes(self) -> None:
73
+ """Test parsing models with single quotes."""
74
+ # Entire string quoted
75
+ models_str = "'litellm/openai/gpt-4o,litellm/anthropic/claude-3-5-sonnet'"
76
+ result = parse_comma_separated_models(models_str)
77
+ assert result == ["litellm/openai/gpt-4o", "litellm/anthropic/claude-3-5-sonnet"]
78
+
79
+ # Individual models quoted
80
+ models_str = "'litellm/openai/gpt-4o','litellm/anthropic/claude-3-5-sonnet'"
81
+ result = parse_comma_separated_models(models_str)
82
+ assert result == ["litellm/openai/gpt-4o", "litellm/anthropic/claude-3-5-sonnet"]
83
+
84
+ def test_parse_models_mixed_quotes(self) -> None:
85
+ """Test parsing models where some have single quotes and some have double quotes."""
86
+ # Some models with single quotes, some with double quotes
87
+ models_str = "'litellm/openai/gpt-4o',\"litellm/anthropic/claude-3-5-sonnet\""
88
+ result = parse_comma_separated_models(models_str)
89
+ # Should strip both types of quotes
90
+ assert result == ["litellm/openai/gpt-4o", "litellm/anthropic/claude-3-5-sonnet"]
91
+
92
+ def test_parse_models_with_whitespace(self) -> None:
93
+ """Test parsing models with whitespace around commas and model names."""
94
+ models_str = " litellm/openai/gpt-4o , litellm/anthropic/claude-3-5-sonnet "
95
+ result = parse_comma_separated_models(models_str)
96
+ assert result == ["litellm/openai/gpt-4o", "litellm/anthropic/claude-3-5-sonnet"]
97
+
98
+ def test_parse_models_empty_string(self) -> None:
99
+ """Test parsing empty string."""
100
+ result = parse_comma_separated_models("")
101
+ assert result == []
102
+
103
+ def test_parse_models_single_model(self) -> None:
104
+ """Test parsing single model."""
105
+ models_str = "litellm/openai/gpt-4o"
106
+ result = parse_comma_separated_models(models_str)
107
+ assert result == ["litellm/openai/gpt-4o"]
108
+
109
+ # With quotes
110
+ models_str = '"litellm/openai/gpt-4o"'
111
+ result = parse_comma_separated_models(models_str)
112
+ assert result == ["litellm/openai/gpt-4o"]
113
+
114
+ def test_parse_models_abacus_format(self) -> None:
115
+ """Test parsing Abacus model format."""
116
+ models_str = "Abacus/gpt-4.1,Abacus/claude-haiku-4-5-20251001"
117
+ result = parse_comma_separated_models(models_str)
118
+ assert result == ["Abacus/gpt-4.1", "Abacus/claude-haiku-4-5-20251001"]
119
+
120
+ # With quotes
121
+ models_str = '"Abacus/gpt-4.1","Abacus/claude-haiku-4-5-20251001"'
122
+ result = parse_comma_separated_models(models_str)
123
+ assert result == ["Abacus/gpt-4.1", "Abacus/claude-haiku-4-5-20251001"]
124
+
125
+ @pytest.mark.parametrize("models_str,description", [
126
+ ('"model1,model2"', 'Double quotes, no space after comma'),
127
+ ("'model1,model2'", 'Single quotes, no space after comma'),
128
+ ("model1,model2", 'No quotes, no space after comma'),
129
+ ('"model1, model2"', 'Double quotes, space after comma'),
130
+ ("'model1, model2'", 'Single quotes, space after comma'),
131
+ ("model1, model2", 'No quotes, space after comma'),
132
+ ])
133
+ def test_parse_models_all_scenarios(self, models_str: str, description: str) -> None:
134
+ """Test all specific scenarios: quotes with and without spaces after commas."""
135
+ expected = ["model1", "model2"]
136
+ result = parse_comma_separated_models(models_str)
137
+ assert result == expected, f"Failed for {description}: {repr(models_str)}"
@@ -137,6 +137,61 @@ class TestModelValidation:
137
137
 
138
138
  from mito_ai.utils.model_utils import STANDARD_MODELS
139
139
  assert result == STANDARD_MODELS
140
+
141
+ @patch('mito_ai.utils.model_utils.is_enterprise')
142
+ @patch('mito_ai.utils.model_utils.constants')
143
+ @patch('mito_ai.utils.model_utils.is_abacus_configured')
144
+ def test_provider_manager_validates_abacus_model(self, mock_is_abacus_configured, mock_constants, mock_is_enterprise, provider_config: Config):
145
+ """Test that ProviderManager validates Abacus models against available models."""
146
+ mock_is_abacus_configured.return_value = True
147
+ mock_is_enterprise.return_value = True
148
+ mock_constants.ABACUS_BASE_URL = "https://routellm.abacus.ai/v1"
149
+ mock_constants.ABACUS_MODELS = ["Abacus/gpt-4.1", "Abacus/gpt-5.2"]
150
+
151
+ provider_manager = ProviderManager(config=provider_config)
152
+ provider_manager.set_selected_model("Abacus/gpt-4.1")
153
+
154
+ # Should not raise an error for valid model
155
+ available_models = get_available_models()
156
+ assert "Abacus/gpt-4.1" in available_models
157
+
158
+ @patch('mito_ai.utils.model_utils.is_enterprise')
159
+ @patch('mito_ai.utils.model_utils.constants')
160
+ @patch('mito_ai.utils.model_utils.is_abacus_configured')
161
+ @pytest.mark.asyncio
162
+ async def test_provider_manager_rejects_invalid_abacus_model(self, mock_is_abacus_configured, mock_constants, mock_is_enterprise, provider_config: Config):
163
+ """Test that ProviderManager rejects invalid Abacus models."""
164
+ mock_is_abacus_configured.return_value = True
165
+ mock_is_enterprise.return_value = True
166
+ mock_constants.ABACUS_BASE_URL = "https://routellm.abacus.ai/v1"
167
+ mock_constants.ABACUS_MODELS = ["Abacus/gpt-4.1"]
168
+ mock_constants.ABACUS_API_KEY = "test-key"
169
+
170
+ provider_manager = ProviderManager(config=provider_config)
171
+ provider_manager.set_selected_model("invalid-model")
172
+
173
+ messages: list[ChatCompletionMessageParam] = [{"role": "user", "content": "test"}]
174
+
175
+ # Should raise ValueError for invalid model
176
+ with pytest.raises(ValueError, match="is not in the allowed model list"):
177
+ await provider_manager.request_completions(
178
+ message_type=MessageType.CHAT,
179
+ messages=messages
180
+ )
181
+
182
+ @patch('mito_ai.utils.model_utils.is_enterprise')
183
+ @patch('mito_ai.utils.model_utils.constants')
184
+ @patch('mito_ai.utils.model_utils.is_abacus_configured')
185
+ def test_available_models_endpoint_returns_abacus_models(self, mock_is_abacus_configured, mock_constants, mock_is_enterprise):
186
+ """Test that /available-models endpoint returns Abacus models when configured."""
187
+ mock_is_abacus_configured.return_value = True
188
+ mock_is_enterprise.return_value = True
189
+ mock_constants.ABACUS_BASE_URL = "https://routellm.abacus.ai/v1"
190
+ mock_constants.ABACUS_MODELS = ["Abacus/gpt-4.1", "Abacus/claude-haiku-4-5-20251001"]
191
+
192
+ result = get_available_models()
193
+
194
+ assert result == ["Abacus/gpt-4.1", "Abacus/claude-haiku-4-5-20251001"]
140
195
 
141
196
 
142
197
  class TestModelStorage:
@@ -22,11 +22,11 @@ class TestGetAvailableModels:
22
22
  """Test that LiteLLM models are returned when enterprise mode is enabled and LiteLLM is configured."""
23
23
  mock_is_enterprise.return_value = True
24
24
  mock_constants.LITELLM_BASE_URL = "https://litellm-server.com"
25
- mock_constants.LITELLM_MODELS = ["openai/gpt-4o", "anthropic/claude-3-5-sonnet"]
25
+ mock_constants.LITELLM_MODELS = ["litellm/openai/gpt-4o", "litellm/anthropic/claude-3-5-sonnet"]
26
26
 
27
27
  result = get_available_models()
28
28
 
29
- assert result == ["openai/gpt-4o", "anthropic/claude-3-5-sonnet"]
29
+ assert result == ["litellm/openai/gpt-4o", "litellm/anthropic/claude-3-5-sonnet"]
30
30
 
31
31
  @patch('mito_ai.utils.model_utils.is_enterprise')
32
32
  @patch('mito_ai.utils.model_utils.constants')
@@ -56,7 +56,7 @@ class TestGetAvailableModels:
56
56
  """Test that standard models are returned when enterprise mode is enabled but LITELLM_BASE_URL is not set."""
57
57
  mock_is_enterprise.return_value = True
58
58
  mock_constants.LITELLM_BASE_URL = None
59
- mock_constants.LITELLM_MODELS = ["openai/gpt-4o"]
59
+ mock_constants.LITELLM_MODELS = ["litellm/openai/gpt-4o"]
60
60
 
61
61
  result = get_available_models()
62
62
 
@@ -73,6 +73,20 @@ class TestGetAvailableModels:
73
73
  result = get_available_models()
74
74
 
75
75
  assert result == STANDARD_MODELS
76
+
77
+ @patch('mito_ai.utils.model_utils.is_abacus_configured')
78
+ @patch('mito_ai.utils.model_utils.is_enterprise')
79
+ @patch('mito_ai.utils.model_utils.constants')
80
+ def test_returns_abacus_models_when_configured(self, mock_constants, mock_is_enterprise, mock_is_abacus_configured):
81
+ """Test that Abacus models are returned when Abacus is configured (highest priority)."""
82
+ mock_is_abacus_configured.return_value = True
83
+ mock_is_enterprise.return_value = True
84
+ mock_constants.ABACUS_BASE_URL = "https://routellm.abacus.ai/v1"
85
+ mock_constants.ABACUS_MODELS = ["Abacus/gpt-4.1", "Abacus/claude-haiku-4-5-20251001"]
86
+
87
+ result = get_available_models()
88
+
89
+ assert result == ["Abacus/gpt-4.1", "Abacus/claude-haiku-4-5-20251001"]
76
90
 
77
91
 
78
92
  class TestGetFastModelForSelectedModel:
@@ -114,51 +128,51 @@ class TestGetFastModelForSelectedModel:
114
128
  [
115
129
  # Test case 1: LiteLLM OpenAI model returns fastest overall
116
130
  (
117
- "openai/gpt-5.2",
118
- ["openai/gpt-4.1", "openai/gpt-5.2", "anthropic/claude-sonnet-4-5-20250929"],
119
- "openai/gpt-4.1",
131
+ "litellm/openai/gpt-5.2",
132
+ ["litellm/openai/gpt-4.1", "litellm/openai/gpt-5.2", "litellm/anthropic/claude-sonnet-4-5-20250929"],
133
+ "litellm/openai/gpt-4.1",
120
134
  ),
121
135
  # Test case 2: LiteLLM Anthropic model returns fastest overall
122
136
  (
123
- "anthropic/claude-sonnet-4-5-20250929",
124
- ["openai/gpt-4.1", "anthropic/claude-sonnet-4-5-20250929", "anthropic/claude-haiku-4-5-20251001"],
125
- "openai/gpt-4.1",
137
+ "litellm/anthropic/claude-sonnet-4-5-20250929",
138
+ ["litellm/openai/gpt-4.1", "litellm/anthropic/claude-sonnet-4-5-20250929", "litellm/anthropic/claude-haiku-4-5-20251001"],
139
+ "litellm/openai/gpt-4.1",
126
140
  ),
127
141
  # Test case 3: LiteLLM Google model returns fastest overall
128
142
  (
129
- "google/gemini-3-pro-preview",
130
- ["google/gemini-3-pro-preview", "google/gemini-3-flash-preview"],
131
- "google/gemini-3-flash-preview",
143
+ "litellm/google/gemini-3-pro-preview",
144
+ ["litellm/google/gemini-3-pro-preview", "litellm/google/gemini-3-flash-preview"],
145
+ "litellm/google/gemini-3-flash-preview",
132
146
  ),
133
147
  # Test case 4: Unknown LiteLLM model returns fastest known
134
148
  (
135
149
  "unknown/provider/model",
136
- ["openai/gpt-4.1", "unknown/provider/model"],
137
- "openai/gpt-4.1",
150
+ ["litellm/openai/gpt-4.1", "unknown/provider/model"],
151
+ "litellm/openai/gpt-4.1",
138
152
  ),
139
153
  # Test case 5: Single LiteLLM model returns itself
140
154
  (
141
- "openai/gpt-4o",
142
- ["openai/gpt-4o"],
143
- "openai/gpt-4o",
155
+ "litellm/openai/gpt-4o",
156
+ ["litellm/openai/gpt-4o"],
157
+ "litellm/openai/gpt-4o",
144
158
  ),
145
159
  # Test case 6: Cross-provider comparison - OpenAI is faster
146
160
  (
147
- "anthropic/claude-sonnet-4-5-20250929",
161
+ "litellm/anthropic/claude-sonnet-4-5-20250929",
148
162
  [
149
- "openai/gpt-4.1", # Index 0 in OPENAI_MODEL_ORDER
150
- "anthropic/claude-sonnet-4-5-20250929", # Index 1 in ANTHROPIC_MODEL_ORDER
163
+ "litellm/openai/gpt-4.1", # Index 0 in OPENAI_MODEL_ORDER
164
+ "litellm/anthropic/claude-sonnet-4-5-20250929", # Index 1 in ANTHROPIC_MODEL_ORDER
151
165
  ],
152
- "openai/gpt-4.1",
166
+ "litellm/openai/gpt-4.1",
153
167
  ),
154
168
  # Test case 7: Cross-provider comparison - Anthropic is faster
155
169
  (
156
- "openai/gpt-5.2",
170
+ "litellm/openai/gpt-5.2",
157
171
  [
158
- "openai/gpt-5.2", # Index 1 in OPENAI_MODEL_ORDER
159
- "anthropic/claude-haiku-4-5-20251001", # Index 0 in ANTHROPIC_MODEL_ORDER
172
+ "litellm/openai/gpt-5.2", # Index 1 in OPENAI_MODEL_ORDER
173
+ "litellm/anthropic/claude-haiku-4-5-20251001", # Index 0 in ANTHROPIC_MODEL_ORDER
160
174
  ],
161
- "anthropic/claude-haiku-4-5-20251001",
175
+ "litellm/anthropic/claude-haiku-4-5-20251001",
162
176
  ),
163
177
  ],
164
178
  ids=[
@@ -269,3 +283,80 @@ class TestGetFastModelForSelectedModel:
269
283
  for model, expected in test_cases:
270
284
  result = get_fast_model_for_selected_model(model)
271
285
  assert result == expected, f"Case-insensitive matching failed for {model}"
286
+
287
+ @patch('mito_ai.utils.model_utils.get_available_models')
288
+ @pytest.mark.parametrize(
289
+ "selected_model,available_models,expected_result",
290
+ [
291
+ # Test case 1: Abacus GPT model returns fastest overall
292
+ (
293
+ "Abacus/gpt-5.2",
294
+ ["Abacus/gpt-4.1", "Abacus/gpt-5.2", "Abacus/claude-sonnet-4-5-20250929"],
295
+ "Abacus/gpt-4.1",
296
+ ),
297
+ # Test case 2: Abacus Claude model returns fastest overall
298
+ (
299
+ "Abacus/claude-sonnet-4-5-20250929",
300
+ ["Abacus/gpt-4.1", "Abacus/claude-sonnet-4-5-20250929", "Abacus/claude-haiku-4-5-20251001"],
301
+ "Abacus/gpt-4.1",
302
+ ),
303
+ # Test case 3: Abacus Gemini model returns fastest overall
304
+ (
305
+ "Abacus/gemini-3-pro-preview",
306
+ ["Abacus/gemini-3-pro-preview", "Abacus/gemini-3-flash-preview"],
307
+ "Abacus/gemini-3-flash-preview",
308
+ ),
309
+ # Test case 4: Unknown Abacus model returns fastest known
310
+ (
311
+ "Abacus/unknown-model",
312
+ ["Abacus/gpt-4.1", "Abacus/unknown-model"],
313
+ "Abacus/gpt-4.1",
314
+ ),
315
+ # Test case 5: Single Abacus model returns itself
316
+ (
317
+ "Abacus/gpt-4.1",
318
+ ["Abacus/gpt-4.1"],
319
+ "Abacus/gpt-4.1",
320
+ ),
321
+ # Test case 6: Cross-provider comparison - OpenAI is faster
322
+ (
323
+ "Abacus/claude-sonnet-4-5-20250929",
324
+ [
325
+ "Abacus/gpt-4.1", # Index 0 in OPENAI_MODEL_ORDER
326
+ "Abacus/claude-sonnet-4-5-20250929", # Index 1 in ANTHROPIC_MODEL_ORDER
327
+ ],
328
+ "Abacus/gpt-4.1",
329
+ ),
330
+ # Test case 7: Cross-provider comparison - Anthropic is faster
331
+ (
332
+ "Abacus/gpt-5.2",
333
+ [
334
+ "Abacus/gpt-5.2", # Index 1 in OPENAI_MODEL_ORDER
335
+ "Abacus/claude-haiku-4-5-20251001", # Index 0 in ANTHROPIC_MODEL_ORDER
336
+ ],
337
+ "Abacus/claude-haiku-4-5-20251001",
338
+ ),
339
+ ],
340
+ ids=[
341
+ "abacus_gpt_model_returns_fastest_overall",
342
+ "abacus_anthropic_model_returns_fastest_overall",
343
+ "abacus_google_model_returns_fastest_overall",
344
+ "abacus_unknown_model_returns_fastest_known",
345
+ "abacus_single_model_returns_itself",
346
+ "abacus_cross_provider_comparison_openai_faster",
347
+ "abacus_returns_fastest_when_anthropic_is_faster",
348
+ ]
349
+ )
350
+ def test_abacus_model_returns_fastest(
351
+ self,
352
+ mock_get_available_models,
353
+ selected_model,
354
+ available_models,
355
+ expected_result,
356
+ ):
357
+ """Test that Abacus models return fastest model from all available models."""
358
+ mock_get_available_models.return_value = available_models
359
+
360
+ result = get_fast_model_for_selected_model(selected_model)
361
+
362
+ assert result == expected_result
@@ -17,8 +17,7 @@ __user_id: Optional[str] = None
17
17
  ANTHROPIC_TIMEOUT = 60
18
18
  max_retries = 1
19
19
 
20
- FAST_ANTHROPIC_MODEL = "claude-haiku-4-5-20251001" # This should be in sync with ModelSelector.tsx
21
- LARGE_CONTEXT_MODEL = "claude-sonnet-4-5-20250929" # This should be in sync with ModelSelector.tsx
20
+ LARGE_CONTEXT_MODEL = "claude-sonnet-4-5-20250929"
22
21
  EXTENDED_CONTEXT_BETA = "context-1m-2025-08-07" # Beta feature for extended context window support
23
22
 
24
23
  def does_message_exceed_max_tokens(system: Union[str, List[TextBlockParam], anthropic.Omit], messages: List[MessageParam]) -> bool:
@@ -4,11 +4,11 @@
4
4
  from typing import List, Tuple, Union, Optional, cast
5
5
  from mito_ai import constants
6
6
  from mito_ai.utils.version_utils import is_enterprise
7
+ from mito_ai.enterprise.utils import is_abacus_configured
7
8
 
8
9
  # Model ordering: [fastest, ..., slowest] for each provider
9
10
  ANTHROPIC_MODEL_ORDER = [
10
11
  "claude-haiku-4-5-20251001", # Fastest
11
- "claude-sonnet-4-5-20250929", # Slower
12
12
  ]
13
13
 
14
14
  OPENAI_MODEL_ORDER = [
@@ -26,7 +26,6 @@ GEMINI_MODEL_ORDER = [
26
26
  STANDARD_MODELS = [
27
27
  "gpt-4.1",
28
28
  "gpt-5.2",
29
- "claude-sonnet-4-5-20250929",
30
29
  "claude-haiku-4-5-20251001",
31
30
  "gemini-3-flash-preview",
32
31
  "gemini-3-pro-preview",
@@ -35,15 +34,23 @@ STANDARD_MODELS = [
35
34
 
36
35
  def get_available_models() -> List[str]:
37
36
  """
38
- Determine which models are available based on enterprise mode and LiteLLM configuration.
37
+ Determine which models are available based on enterprise mode and router configuration.
38
+
39
+ Priority order:
40
+ 1. Abacus (if configured)
41
+ 2. LiteLLM (if configured)
42
+ 3. Standard models
39
43
 
40
44
  Returns:
41
- List of available model names. If enterprise mode is enabled AND LiteLLM is configured,
42
- returns LiteLLM models. Otherwise, returns standard models.
45
+ List of available model names with appropriate prefixes.
43
46
  """
47
+ # Check if enterprise mode is enabled AND Abacus is configured (highest priority)
48
+ if is_abacus_configured():
49
+ # Return Abacus models (with Abacus/ prefix)
50
+ return constants.ABACUS_MODELS
44
51
  # Check if enterprise mode is enabled AND LiteLLM is configured
45
- if is_enterprise() and constants.LITELLM_BASE_URL and constants.LITELLM_MODELS:
46
- # Return LiteLLM models (with provider prefixes)
52
+ elif is_enterprise() and constants.LITELLM_BASE_URL and constants.LITELLM_MODELS:
53
+ # Return LiteLLM models (with LiteLLM/provider/ prefix or legacy provider/ prefix)
47
54
  return constants.LITELLM_MODELS
48
55
  else:
49
56
  # Return standard models
@@ -55,39 +62,50 @@ def get_fast_model_for_selected_model(selected_model: str) -> str:
55
62
  Get the fastest model for the client of the selected model.
56
63
 
57
64
  - For standard providers, returns the first (fastest) model from that provider's order.
58
- - For LiteLLM models, finds the fastest available model from LiteLLM by comparing indices in the model order lists.
65
+ - For enterprise router models (Abacus/LiteLLM), finds the fastest available model by comparing indices.
59
66
  """
60
- # Check if this is a LiteLLM model (has provider prefix like "openai/gpt-4o")
61
- if "/" in selected_model:
62
-
63
- # Find the fastest model from available LiteLLM models
67
+ # Check if this is an enterprise router model (has "/" or router prefix)
68
+ if "/" in selected_model or selected_model.lower().startswith(('abacus/', 'litellm/')):
69
+ # Find the fastest model from available models
64
70
  available_models = get_available_models()
65
71
  if not available_models:
66
72
  return selected_model
67
73
 
68
- # Filter to only LiteLLM models (those with "/") before splitting
69
- litellm_models = [model for model in available_models if "/" in model]
70
- if not litellm_models:
74
+ # Filter to only router models (those with "/")
75
+ router_models = [model for model in available_models if "/" in model]
76
+ if not router_models:
71
77
  return selected_model
72
78
 
73
- available_provider_model_pairs: List[List[str]] = [model.split("/", 1) for model in litellm_models]
74
-
75
- # Get indices for all pairs and filter out None indices (unknown models)
76
- pairs_with_indices = [(pair, get_model_order_index(pair)) for pair in available_provider_model_pairs]
77
- valid_pairs_with_indices = [(pair, index) for pair, index in pairs_with_indices if index is not None]
79
+ # Extract provider/model pairs for ordering
80
+ pairs_with_indices = []
81
+ for model in router_models:
82
+ # Strip router prefix to get underlying model info
83
+ model_without_router = strip_router_prefix(model)
84
+
85
+ # For Abacus: model_without_router is just the model name (e.g., "gpt-4.1")
86
+ # For LiteLLM: model_without_router is "provider/model" (e.g., "openai/gpt-4.1")
87
+ if "/" in model_without_router:
88
+ # LiteLLM format: provider/model
89
+ pair = model_without_router.split("/", 1)
90
+ else:
91
+ # Abacus format: just model name, need to determine provider
92
+ provider = get_underlying_model_provider(model)
93
+ if provider:
94
+ pair = [provider, model_without_router]
95
+ else:
96
+ continue
97
+
98
+ index = get_model_order_index(pair)
99
+ if index is not None:
100
+ pairs_with_indices.append((model, index))
78
101
 
79
- if not valid_pairs_with_indices:
102
+ if not pairs_with_indices:
80
103
  return selected_model
81
104
 
82
- # Find the pair with the minimum index (fastest model)
83
- fastest_pair, _ = min(valid_pairs_with_indices, key=lambda x: x[1])
84
- fastest_model = f"{fastest_pair[0]}/{fastest_pair[1]}"
105
+ # Find the model with the minimum index (fastest model)
106
+ fastest_model, _ = min(pairs_with_indices, key=lambda x: x[1])
85
107
 
86
- # If we found a fastest model, return it. Otherwise, use the selected model
87
- if fastest_model:
88
- return fastest_model
89
- else:
90
- return selected_model
108
+ return fastest_model
91
109
 
92
110
  # Standard provider logic - ensure we return a model from the same provider
93
111
  model_lower = selected_model.lower()
@@ -107,42 +125,57 @@ def get_smartest_model_for_selected_model(selected_model: str) -> str:
107
125
  Get the smartest model for the client of the selected model.
108
126
 
109
127
  - For standard providers, returns the last (smartest) model from that provider's order.
110
- - For LiteLLM models, finds the smartest available model from LiteLLM by comparing indices in the model order lists.
128
+ - For enterprise router models (Abacus/LiteLLM), finds the smartest available model by comparing indices.
111
129
  """
112
- # Check if this is a LiteLLM model (has provider prefix like "openai/gpt-4o")
113
- if "/" in selected_model:
114
-
115
- # Extract provider from selected model
116
- selected_provider, _ = selected_model.split("/", 1)
130
+ # Check if this is an enterprise router model (has "/" or router prefix)
131
+ if "/" in selected_model or selected_model.lower().startswith(('abacus/', 'litellm/')):
132
+ # Extract underlying provider from selected model
133
+ selected_provider = get_underlying_model_provider(selected_model)
134
+ if not selected_provider:
135
+ return selected_model
117
136
 
118
- # Find the smartest model from available LiteLLM models
137
+ # Find the smartest model from available models
119
138
  available_models = get_available_models()
120
139
  if not available_models:
121
140
  return selected_model
122
141
 
123
- # Filter to only LiteLLM models (those with "/")
124
- litellm_models = [model for model in available_models if "/" in model and model.startswith(f"{selected_provider}/")]
125
- if not litellm_models:
126
- return selected_model
142
+ # Filter to only router models with the same underlying provider
143
+ router_models = []
144
+ for model in available_models:
145
+ if "/" in model:
146
+ model_provider = get_underlying_model_provider(model)
147
+ if model_provider == selected_provider:
148
+ router_models.append(model)
127
149
 
128
- available_provider_model_pairs: List[List[str]] = [model.split("/", 1) for model in litellm_models]
150
+ if not router_models:
151
+ return selected_model
129
152
 
130
- # Get indices for all pairs and filter out None indices (unknown models)
131
- pairs_with_indices = [(pair, get_model_order_index(pair)) for pair in available_provider_model_pairs]
132
- valid_pairs_with_indices = [(pair, index) for pair, index in pairs_with_indices if index is not None]
153
+ # Extract provider/model pairs for ordering
154
+ pairs_with_indices = []
155
+ for model in router_models:
156
+ # Strip router prefix to get underlying model info
157
+ model_without_router = strip_router_prefix(model)
158
+
159
+ # For Abacus: model_without_router is just the model name (e.g., "gpt-4.1")
160
+ # For LiteLLM: model_without_router is "provider/model" (e.g., "openai/gpt-4.1")
161
+ if "/" in model_without_router:
162
+ # LiteLLM format: provider/model
163
+ pair = model_without_router.split("/", 1)
164
+ else:
165
+ # Abacus format: just model name, provider already determined
166
+ pair = [selected_provider, model_without_router]
167
+
168
+ index = get_model_order_index(pair)
169
+ if index is not None:
170
+ pairs_with_indices.append((model, index))
133
171
 
134
- if not valid_pairs_with_indices:
172
+ if not pairs_with_indices:
135
173
  return selected_model
136
174
 
137
- # Find the pair with the maximum index (smartest model)
138
- smartest_pair, _ = max(valid_pairs_with_indices, key=lambda x: x[1])
139
- smartest_model = f"{smartest_pair[0]}/{smartest_pair[1]}"
175
+ # Find the model with the maximum index (smartest model)
176
+ smartest_model, _ = max(pairs_with_indices, key=lambda x: x[1])
140
177
 
141
- # If we found a smartest model, return it. Otherwise, use the selected model
142
- if smartest_model:
143
- return smartest_model
144
- else:
145
- return selected_model
178
+ return smartest_model
146
179
 
147
180
  # Standard provider logic
148
181
  model_lower = selected_model.lower()
@@ -157,6 +190,50 @@ def get_smartest_model_for_selected_model(selected_model: str) -> str:
157
190
 
158
191
  return selected_model
159
192
 
193
+ def strip_router_prefix(model: str) -> str:
194
+ """
195
+ Strip router prefix from model name.
196
+
197
+ Examples:
198
+ - "Abacus/gpt-4.1" -> "gpt-4.1"
199
+ - "LiteLLM/openai/gpt-4.1" -> "openai/gpt-4.1"
200
+ - "gpt-4.1" -> "gpt-4.1" (no prefix, return as-is)
201
+ """
202
+ if model.lower().startswith('abacus/'):
203
+ return model[7:] # Strip "Abacus/"
204
+ elif model.lower().startswith('litellm/'):
205
+ return model[8:] # Strip "LiteLLM/"
206
+ return model
207
+
208
+ def get_underlying_model_provider(full_model_provider_id: str) -> Optional[str]:
209
+ """
210
+ Determine the underlying AI provider from a model identifier.
211
+
212
+ For Abacus models (Abacus/model), determine the provider from model name pattern.
213
+ For LiteLLM models (LiteLLM/provider/model), extract the provider from the prefix.
214
+
215
+ Returns:
216
+ Provider name ("openai", "anthropic", "google") or None if cannot determine.
217
+ """
218
+ # Strip router prefix first
219
+ model_without_router = strip_router_prefix(full_model_provider_id)
220
+
221
+ # Check if it's a LiteLLM format (provider/model)
222
+ if "/" in model_without_router:
223
+ provider, _ = model_without_router.split("/", 1)
224
+ return provider.lower()
225
+
226
+ # For Abacus models without provider prefix, determine from model name
227
+ model_lower = model_without_router.lower()
228
+ if model_lower.startswith('gpt'):
229
+ return 'openai'
230
+ elif model_lower.startswith('claude'):
231
+ return 'anthropic'
232
+ elif model_lower.startswith('gemini'):
233
+ return 'google'
234
+
235
+ return None
236
+
160
237
  def get_model_order_index(pair: List[str]) -> Optional[int]:
161
238
  provider, model_name = pair
162
239
  if provider == "openai":