atlas-chat 0.1.0__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 (250) hide show
  1. atlas/__init__.py +40 -0
  2. atlas/application/__init__.py +7 -0
  3. atlas/application/chat/__init__.py +7 -0
  4. atlas/application/chat/agent/__init__.py +10 -0
  5. atlas/application/chat/agent/act_loop.py +179 -0
  6. atlas/application/chat/agent/factory.py +142 -0
  7. atlas/application/chat/agent/protocols.py +46 -0
  8. atlas/application/chat/agent/react_loop.py +338 -0
  9. atlas/application/chat/agent/think_act_loop.py +171 -0
  10. atlas/application/chat/approval_manager.py +151 -0
  11. atlas/application/chat/elicitation_manager.py +191 -0
  12. atlas/application/chat/events/__init__.py +1 -0
  13. atlas/application/chat/events/agent_event_relay.py +112 -0
  14. atlas/application/chat/modes/__init__.py +1 -0
  15. atlas/application/chat/modes/agent.py +125 -0
  16. atlas/application/chat/modes/plain.py +74 -0
  17. atlas/application/chat/modes/rag.py +81 -0
  18. atlas/application/chat/modes/tools.py +179 -0
  19. atlas/application/chat/orchestrator.py +213 -0
  20. atlas/application/chat/policies/__init__.py +1 -0
  21. atlas/application/chat/policies/tool_authorization.py +99 -0
  22. atlas/application/chat/preprocessors/__init__.py +1 -0
  23. atlas/application/chat/preprocessors/message_builder.py +92 -0
  24. atlas/application/chat/preprocessors/prompt_override_service.py +104 -0
  25. atlas/application/chat/service.py +454 -0
  26. atlas/application/chat/utilities/__init__.py +6 -0
  27. atlas/application/chat/utilities/error_handler.py +367 -0
  28. atlas/application/chat/utilities/event_notifier.py +546 -0
  29. atlas/application/chat/utilities/file_processor.py +613 -0
  30. atlas/application/chat/utilities/tool_executor.py +789 -0
  31. atlas/atlas_chat_cli.py +347 -0
  32. atlas/atlas_client.py +238 -0
  33. atlas/core/__init__.py +0 -0
  34. atlas/core/auth.py +205 -0
  35. atlas/core/authorization_manager.py +27 -0
  36. atlas/core/capabilities.py +123 -0
  37. atlas/core/compliance.py +215 -0
  38. atlas/core/domain_whitelist.py +147 -0
  39. atlas/core/domain_whitelist_middleware.py +82 -0
  40. atlas/core/http_client.py +28 -0
  41. atlas/core/log_sanitizer.py +102 -0
  42. atlas/core/metrics_logger.py +59 -0
  43. atlas/core/middleware.py +131 -0
  44. atlas/core/otel_config.py +242 -0
  45. atlas/core/prompt_risk.py +200 -0
  46. atlas/core/rate_limit.py +0 -0
  47. atlas/core/rate_limit_middleware.py +64 -0
  48. atlas/core/security_headers_middleware.py +51 -0
  49. atlas/domain/__init__.py +37 -0
  50. atlas/domain/chat/__init__.py +1 -0
  51. atlas/domain/chat/dtos.py +85 -0
  52. atlas/domain/errors.py +96 -0
  53. atlas/domain/messages/__init__.py +12 -0
  54. atlas/domain/messages/models.py +160 -0
  55. atlas/domain/rag_mcp_service.py +664 -0
  56. atlas/domain/sessions/__init__.py +7 -0
  57. atlas/domain/sessions/models.py +36 -0
  58. atlas/domain/unified_rag_service.py +371 -0
  59. atlas/infrastructure/__init__.py +10 -0
  60. atlas/infrastructure/app_factory.py +135 -0
  61. atlas/infrastructure/events/__init__.py +1 -0
  62. atlas/infrastructure/events/cli_event_publisher.py +140 -0
  63. atlas/infrastructure/events/websocket_publisher.py +140 -0
  64. atlas/infrastructure/sessions/in_memory_repository.py +56 -0
  65. atlas/infrastructure/transport/__init__.py +7 -0
  66. atlas/infrastructure/transport/websocket_connection_adapter.py +33 -0
  67. atlas/init_cli.py +226 -0
  68. atlas/interfaces/__init__.py +15 -0
  69. atlas/interfaces/events.py +134 -0
  70. atlas/interfaces/llm.py +54 -0
  71. atlas/interfaces/rag.py +40 -0
  72. atlas/interfaces/sessions.py +75 -0
  73. atlas/interfaces/tools.py +57 -0
  74. atlas/interfaces/transport.py +24 -0
  75. atlas/main.py +564 -0
  76. atlas/mcp/api_key_demo/README.md +76 -0
  77. atlas/mcp/api_key_demo/main.py +172 -0
  78. atlas/mcp/api_key_demo/run.sh +56 -0
  79. atlas/mcp/basictable/main.py +147 -0
  80. atlas/mcp/calculator/main.py +149 -0
  81. atlas/mcp/code-executor/execution_engine.py +98 -0
  82. atlas/mcp/code-executor/execution_environment.py +95 -0
  83. atlas/mcp/code-executor/main.py +528 -0
  84. atlas/mcp/code-executor/result_processing.py +276 -0
  85. atlas/mcp/code-executor/script_generation.py +195 -0
  86. atlas/mcp/code-executor/security_checker.py +140 -0
  87. atlas/mcp/corporate_cars/main.py +437 -0
  88. atlas/mcp/csv_reporter/main.py +545 -0
  89. atlas/mcp/duckduckgo/main.py +182 -0
  90. atlas/mcp/elicitation_demo/README.md +171 -0
  91. atlas/mcp/elicitation_demo/main.py +262 -0
  92. atlas/mcp/env-demo/README.md +158 -0
  93. atlas/mcp/env-demo/main.py +199 -0
  94. atlas/mcp/file_size_test/main.py +284 -0
  95. atlas/mcp/filesystem/main.py +348 -0
  96. atlas/mcp/image_demo/main.py +113 -0
  97. atlas/mcp/image_demo/requirements.txt +4 -0
  98. atlas/mcp/logging_demo/README.md +72 -0
  99. atlas/mcp/logging_demo/main.py +103 -0
  100. atlas/mcp/many_tools_demo/main.py +50 -0
  101. atlas/mcp/order_database/__init__.py +0 -0
  102. atlas/mcp/order_database/main.py +369 -0
  103. atlas/mcp/order_database/signal_data.csv +1001 -0
  104. atlas/mcp/pdfbasic/main.py +394 -0
  105. atlas/mcp/pptx_generator/main.py +760 -0
  106. atlas/mcp/pptx_generator/requirements.txt +13 -0
  107. atlas/mcp/pptx_generator/run_test.sh +1 -0
  108. atlas/mcp/pptx_generator/test_pptx_generator_security.py +169 -0
  109. atlas/mcp/progress_demo/main.py +167 -0
  110. atlas/mcp/progress_updates_demo/QUICKSTART.md +273 -0
  111. atlas/mcp/progress_updates_demo/README.md +120 -0
  112. atlas/mcp/progress_updates_demo/main.py +497 -0
  113. atlas/mcp/prompts/main.py +222 -0
  114. atlas/mcp/public_demo/main.py +189 -0
  115. atlas/mcp/sampling_demo/README.md +169 -0
  116. atlas/mcp/sampling_demo/main.py +234 -0
  117. atlas/mcp/thinking/main.py +77 -0
  118. atlas/mcp/tool_planner/main.py +240 -0
  119. atlas/mcp/ui-demo/badmesh.png +0 -0
  120. atlas/mcp/ui-demo/main.py +383 -0
  121. atlas/mcp/ui-demo/templates/button_demo.html +32 -0
  122. atlas/mcp/ui-demo/templates/data_visualization.html +32 -0
  123. atlas/mcp/ui-demo/templates/form_demo.html +28 -0
  124. atlas/mcp/username-override-demo/README.md +320 -0
  125. atlas/mcp/username-override-demo/main.py +308 -0
  126. atlas/modules/__init__.py +0 -0
  127. atlas/modules/config/__init__.py +34 -0
  128. atlas/modules/config/cli.py +231 -0
  129. atlas/modules/config/config_manager.py +1096 -0
  130. atlas/modules/file_storage/__init__.py +22 -0
  131. atlas/modules/file_storage/cli.py +330 -0
  132. atlas/modules/file_storage/content_extractor.py +290 -0
  133. atlas/modules/file_storage/manager.py +295 -0
  134. atlas/modules/file_storage/mock_s3_client.py +402 -0
  135. atlas/modules/file_storage/s3_client.py +417 -0
  136. atlas/modules/llm/__init__.py +19 -0
  137. atlas/modules/llm/caller.py +287 -0
  138. atlas/modules/llm/litellm_caller.py +675 -0
  139. atlas/modules/llm/models.py +19 -0
  140. atlas/modules/mcp_tools/__init__.py +17 -0
  141. atlas/modules/mcp_tools/client.py +2123 -0
  142. atlas/modules/mcp_tools/token_storage.py +556 -0
  143. atlas/modules/prompts/prompt_provider.py +130 -0
  144. atlas/modules/rag/__init__.py +24 -0
  145. atlas/modules/rag/atlas_rag_client.py +336 -0
  146. atlas/modules/rag/client.py +129 -0
  147. atlas/routes/admin_routes.py +865 -0
  148. atlas/routes/config_routes.py +484 -0
  149. atlas/routes/feedback_routes.py +361 -0
  150. atlas/routes/files_routes.py +274 -0
  151. atlas/routes/health_routes.py +40 -0
  152. atlas/routes/mcp_auth_routes.py +223 -0
  153. atlas/server_cli.py +164 -0
  154. atlas/tests/conftest.py +20 -0
  155. atlas/tests/integration/test_mcp_auth_integration.py +152 -0
  156. atlas/tests/manual_test_sampling.py +87 -0
  157. atlas/tests/modules/mcp_tools/test_client_auth.py +226 -0
  158. atlas/tests/modules/mcp_tools/test_client_env.py +191 -0
  159. atlas/tests/test_admin_mcp_server_management_routes.py +141 -0
  160. atlas/tests/test_agent_roa.py +135 -0
  161. atlas/tests/test_app_factory_smoke.py +47 -0
  162. atlas/tests/test_approval_manager.py +439 -0
  163. atlas/tests/test_atlas_client.py +188 -0
  164. atlas/tests/test_atlas_rag_client.py +447 -0
  165. atlas/tests/test_atlas_rag_integration.py +224 -0
  166. atlas/tests/test_attach_file_flow.py +287 -0
  167. atlas/tests/test_auth_utils.py +165 -0
  168. atlas/tests/test_backend_public_url.py +185 -0
  169. atlas/tests/test_banner_logging.py +287 -0
  170. atlas/tests/test_capability_tokens_and_injection.py +203 -0
  171. atlas/tests/test_compliance_level.py +54 -0
  172. atlas/tests/test_compliance_manager.py +253 -0
  173. atlas/tests/test_config_manager.py +617 -0
  174. atlas/tests/test_config_manager_paths.py +12 -0
  175. atlas/tests/test_core_auth.py +18 -0
  176. atlas/tests/test_core_utils.py +190 -0
  177. atlas/tests/test_docker_env_sync.py +202 -0
  178. atlas/tests/test_domain_errors.py +329 -0
  179. atlas/tests/test_domain_whitelist.py +359 -0
  180. atlas/tests/test_elicitation_manager.py +408 -0
  181. atlas/tests/test_elicitation_routing.py +296 -0
  182. atlas/tests/test_env_demo_server.py +88 -0
  183. atlas/tests/test_error_classification.py +113 -0
  184. atlas/tests/test_error_flow_integration.py +116 -0
  185. atlas/tests/test_feedback_routes.py +333 -0
  186. atlas/tests/test_file_content_extraction.py +1134 -0
  187. atlas/tests/test_file_extraction_routes.py +158 -0
  188. atlas/tests/test_file_library.py +107 -0
  189. atlas/tests/test_file_manager_unit.py +18 -0
  190. atlas/tests/test_health_route.py +49 -0
  191. atlas/tests/test_http_client_stub.py +8 -0
  192. atlas/tests/test_imports_smoke.py +30 -0
  193. atlas/tests/test_interfaces_llm_response.py +9 -0
  194. atlas/tests/test_issue_access_denied_fix.py +136 -0
  195. atlas/tests/test_llm_env_expansion.py +836 -0
  196. atlas/tests/test_log_level_sensitive_data.py +285 -0
  197. atlas/tests/test_mcp_auth_routes.py +341 -0
  198. atlas/tests/test_mcp_client_auth.py +331 -0
  199. atlas/tests/test_mcp_data_injection.py +270 -0
  200. atlas/tests/test_mcp_get_authorized_servers.py +95 -0
  201. atlas/tests/test_mcp_hot_reload.py +512 -0
  202. atlas/tests/test_mcp_image_content.py +424 -0
  203. atlas/tests/test_mcp_logging.py +172 -0
  204. atlas/tests/test_mcp_progress_updates.py +313 -0
  205. atlas/tests/test_mcp_prompt_override_system_prompt.py +102 -0
  206. atlas/tests/test_mcp_prompts_server.py +39 -0
  207. atlas/tests/test_mcp_tool_result_parsing.py +296 -0
  208. atlas/tests/test_metrics_logger.py +56 -0
  209. atlas/tests/test_middleware_auth.py +379 -0
  210. atlas/tests/test_prompt_risk_and_acl.py +141 -0
  211. atlas/tests/test_rag_mcp_aggregator.py +204 -0
  212. atlas/tests/test_rag_mcp_service.py +224 -0
  213. atlas/tests/test_rate_limit_middleware.py +45 -0
  214. atlas/tests/test_routes_config_smoke.py +60 -0
  215. atlas/tests/test_routes_files_download_token.py +41 -0
  216. atlas/tests/test_routes_files_health.py +18 -0
  217. atlas/tests/test_runtime_imports.py +53 -0
  218. atlas/tests/test_sampling_integration.py +482 -0
  219. atlas/tests/test_security_admin_routes.py +61 -0
  220. atlas/tests/test_security_capability_tokens.py +65 -0
  221. atlas/tests/test_security_file_stats_scope.py +21 -0
  222. atlas/tests/test_security_header_injection.py +191 -0
  223. atlas/tests/test_security_headers_and_filename.py +63 -0
  224. atlas/tests/test_shared_session_repository.py +101 -0
  225. atlas/tests/test_system_prompt_loading.py +181 -0
  226. atlas/tests/test_token_storage.py +505 -0
  227. atlas/tests/test_tool_approval_config.py +93 -0
  228. atlas/tests/test_tool_approval_utils.py +356 -0
  229. atlas/tests/test_tool_authorization_group_filtering.py +223 -0
  230. atlas/tests/test_tool_details_in_config.py +108 -0
  231. atlas/tests/test_tool_planner.py +300 -0
  232. atlas/tests/test_unified_rag_service.py +398 -0
  233. atlas/tests/test_username_override_in_approval.py +258 -0
  234. atlas/tests/test_websocket_auth_header.py +168 -0
  235. atlas/version.py +6 -0
  236. atlas_chat-0.1.0.data/data/.env.example +253 -0
  237. atlas_chat-0.1.0.data/data/config/defaults/compliance-levels.json +44 -0
  238. atlas_chat-0.1.0.data/data/config/defaults/domain-whitelist.json +123 -0
  239. atlas_chat-0.1.0.data/data/config/defaults/file-extractors.json +74 -0
  240. atlas_chat-0.1.0.data/data/config/defaults/help-config.json +198 -0
  241. atlas_chat-0.1.0.data/data/config/defaults/llmconfig-buggy.yml +11 -0
  242. atlas_chat-0.1.0.data/data/config/defaults/llmconfig.yml +19 -0
  243. atlas_chat-0.1.0.data/data/config/defaults/mcp.json +138 -0
  244. atlas_chat-0.1.0.data/data/config/defaults/rag-sources.json +17 -0
  245. atlas_chat-0.1.0.data/data/config/defaults/splash-config.json +16 -0
  246. atlas_chat-0.1.0.dist-info/METADATA +236 -0
  247. atlas_chat-0.1.0.dist-info/RECORD +250 -0
  248. atlas_chat-0.1.0.dist-info/WHEEL +5 -0
  249. atlas_chat-0.1.0.dist-info/entry_points.txt +4 -0
  250. atlas_chat-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,836 @@
1
+ """Integration tests for LLM environment variable expansion."""
2
+
3
+ import importlib
4
+ import sys
5
+
6
+ import pytest
7
+
8
+ from atlas.modules.config.config_manager import LLMConfig, ModelConfig
9
+
10
+
11
+ def _get_real_litellm_caller():
12
+ """
13
+ Get the real LiteLLMCaller class, even if another test has patched sys.modules.
14
+
15
+ Some test files (e.g., test_capability_tokens_and_injection.py) patch the
16
+ litellm_caller module at import time. This function forces a reimport of
17
+ the real module to get the real LiteLLMCaller class.
18
+ """
19
+ module_name = "atlas.modules.llm.litellm_caller"
20
+
21
+ # Remove the potentially fake module from sys.modules
22
+ if module_name in sys.modules:
23
+ old_module = sys.modules.pop(module_name)
24
+ # Check if it's a fake by seeing if LiteLLMCaller has expected attributes
25
+ if hasattr(old_module, "LiteLLMCaller"):
26
+ caller_class = old_module.LiteLLMCaller
27
+ if not hasattr(caller_class, "_get_model_kwargs"):
28
+ # It's a fake, reimport the real one
29
+ real_module = importlib.import_module(module_name)
30
+ return real_module.LiteLLMCaller
31
+ else:
32
+ # It's real, restore it
33
+ sys.modules[module_name] = old_module
34
+ return caller_class
35
+
36
+ # Not in sys.modules, import fresh
37
+ real_module = importlib.import_module(module_name)
38
+ return real_module.LiteLLMCaller
39
+
40
+
41
+ # Get the real LiteLLMCaller at module load time
42
+ LiteLLMCaller = _get_real_litellm_caller()
43
+
44
+
45
+ class TestLLMEnvExpansionIntegration:
46
+ """Integration tests for LLM caller with environment variable expansion."""
47
+
48
+ def test_litellm_caller_resolves_api_key_env_var(self, monkeypatch):
49
+ """LiteLLMCaller should resolve environment variables in api_key."""
50
+ monkeypatch.setenv("TEST_OPENAI_KEY", "sk-test-12345")
51
+
52
+ # Create LLM config with env var in api_key
53
+ llm_config = LLMConfig(
54
+ models={
55
+ "test-model": ModelConfig(
56
+ model_name="gpt-4",
57
+ model_url="https://api.openai.com/v1",
58
+ api_key="${TEST_OPENAI_KEY}"
59
+ )
60
+ }
61
+ )
62
+
63
+ # Create LiteLLMCaller
64
+ caller = LiteLLMCaller(llm_config, debug_mode=True)
65
+
66
+ # Get model kwargs - this should resolve the env var
67
+ _ = caller._get_model_kwargs("test-model")
68
+
69
+ # Verify that the environment variable was set (LiteLLMCaller sets env vars for provider detection)
70
+ import os
71
+ assert os.environ.get("OPENAI_API_KEY") == "sk-test-12345"
72
+
73
+ # Cleanup to avoid leaking into other tests
74
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
75
+
76
+ def test_litellm_caller_raises_on_missing_api_key_env_var(self):
77
+ """LiteLLMCaller should raise ValueError when api_key env var is missing."""
78
+ # Create LLM config with missing env var in api_key
79
+ llm_config = LLMConfig(
80
+ models={
81
+ "test-model": ModelConfig(
82
+ model_name="gpt-4",
83
+ model_url="https://api.openai.com/v1",
84
+ api_key="${MISSING_OPENAI_KEY}"
85
+ )
86
+ }
87
+ )
88
+
89
+ # Create LiteLLMCaller
90
+ caller = LiteLLMCaller(llm_config, debug_mode=True)
91
+
92
+ # Get model kwargs - this should raise ValueError
93
+ with pytest.raises(ValueError, match="Environment variable 'MISSING_OPENAI_KEY' is not set"):
94
+ caller._get_model_kwargs("test-model")
95
+
96
+ def test_litellm_caller_handles_literal_api_key(self, monkeypatch):
97
+ """LiteLLMCaller should handle literal api_key values."""
98
+ # Create LLM config with literal api_key
99
+ llm_config = LLMConfig(
100
+ models={
101
+ "test-model": ModelConfig(
102
+ model_name="gpt-4",
103
+ model_url="https://api.openai.com/v1",
104
+ api_key="sk-literal-key-12345"
105
+ )
106
+ }
107
+ )
108
+
109
+ # Create LiteLLMCaller
110
+ caller = LiteLLMCaller(llm_config, debug_mode=True)
111
+
112
+ # Get model kwargs - this should work without errors
113
+ _ = caller._get_model_kwargs("test-model")
114
+
115
+ # Verify that the environment variable was set
116
+ import os
117
+ assert os.environ.get("OPENAI_API_KEY") == "sk-literal-key-12345"
118
+
119
+ # Cleanup to avoid leaking into other tests
120
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
121
+
122
+ def test_litellm_caller_resolves_extra_headers_env_vars(self, monkeypatch):
123
+ """LiteLLMCaller should resolve environment variables in extra_headers."""
124
+ monkeypatch.setenv("TEST_REFERER", "https://myapp.com")
125
+ monkeypatch.setenv("TEST_APP_NAME", "MyTestApp")
126
+
127
+ # Create LLM config with env vars in extra_headers
128
+ llm_config = LLMConfig(
129
+ models={
130
+ "test-model": ModelConfig(
131
+ model_name="llama-3-70b",
132
+ model_url="https://openrouter.ai/api/v1",
133
+ api_key="sk-test",
134
+ extra_headers={
135
+ "HTTP-Referer": "${TEST_REFERER}",
136
+ "X-Title": "${TEST_APP_NAME}"
137
+ }
138
+ )
139
+ }
140
+ )
141
+
142
+ # Create LiteLLMCaller
143
+ caller = LiteLLMCaller(llm_config, debug_mode=True)
144
+
145
+ # Get model kwargs - this should resolve the env vars
146
+ model_kwargs = caller._get_model_kwargs("test-model")
147
+
148
+ # Verify that extra_headers were resolved
149
+ assert "extra_headers" in model_kwargs
150
+ assert model_kwargs["extra_headers"]["HTTP-Referer"] == "https://myapp.com"
151
+ assert model_kwargs["extra_headers"]["X-Title"] == "MyTestApp"
152
+
153
+ def test_litellm_caller_raises_on_missing_extra_headers_env_var(self):
154
+ """LiteLLMCaller should raise ValueError when extra_headers env var is missing."""
155
+ # Create LLM config with missing env var in extra_headers
156
+ llm_config = LLMConfig(
157
+ models={
158
+ "test-model": ModelConfig(
159
+ model_name="llama-3-70b",
160
+ model_url="https://openrouter.ai/api/v1",
161
+ api_key="sk-test",
162
+ extra_headers={
163
+ "HTTP-Referer": "${MISSING_REFERER}"
164
+ }
165
+ )
166
+ }
167
+ )
168
+
169
+ # Create LiteLLMCaller
170
+ caller = LiteLLMCaller(llm_config, debug_mode=True)
171
+
172
+ # Get model kwargs - this should raise ValueError
173
+ with pytest.raises(ValueError, match="Environment variable 'MISSING_REFERER' is not set"):
174
+ caller._get_model_kwargs("test-model")
175
+
176
+ def test_litellm_caller_handles_literal_extra_headers(self):
177
+ """LiteLLMCaller should handle literal extra_headers values."""
178
+ # Create LLM config with literal extra_headers
179
+ llm_config = LLMConfig(
180
+ models={
181
+ "test-model": ModelConfig(
182
+ model_name="llama-3-70b",
183
+ model_url="https://openrouter.ai/api/v1",
184
+ api_key="sk-test",
185
+ extra_headers={
186
+ "HTTP-Referer": "https://literal-app.com",
187
+ "X-Title": "LiteralApp"
188
+ }
189
+ )
190
+ }
191
+ )
192
+
193
+ # Create LiteLLMCaller
194
+ caller = LiteLLMCaller(llm_config, debug_mode=True)
195
+
196
+ # Get model kwargs - this should work without errors
197
+ model_kwargs = caller._get_model_kwargs("test-model")
198
+
199
+ # Verify that extra_headers were passed through
200
+ assert "extra_headers" in model_kwargs
201
+ assert model_kwargs["extra_headers"]["HTTP-Referer"] == "https://literal-app.com"
202
+ assert model_kwargs["extra_headers"]["X-Title"] == "LiteralApp"
203
+
204
+ def test_custom_endpoint_with_env_var_api_key(self, monkeypatch):
205
+ """Custom endpoint should pass api_key in kwargs when using env var."""
206
+ monkeypatch.setenv("CUSTOM_LLM_KEY", "sk-custom-12345")
207
+
208
+ # Create LLM config for custom endpoint with env var in api_key
209
+ llm_config = LLMConfig(
210
+ models={
211
+ "custom-model": ModelConfig(
212
+ model_name="custom-model-name",
213
+ model_url="https://custom-llm.example.com/v1",
214
+ api_key="${CUSTOM_LLM_KEY}"
215
+ )
216
+ }
217
+ )
218
+
219
+ # Create LiteLLMCaller
220
+ caller = LiteLLMCaller(llm_config, debug_mode=True)
221
+
222
+ # Get model kwargs
223
+ model_kwargs = caller._get_model_kwargs("custom-model")
224
+
225
+ # Verify that api_key is in kwargs (critical for custom endpoints)
226
+ assert "api_key" in model_kwargs
227
+ assert model_kwargs["api_key"] == "sk-custom-12345"
228
+
229
+ # Verify that api_base is set for custom endpoint
230
+ assert "api_base" in model_kwargs
231
+ assert model_kwargs["api_base"] == "https://custom-llm.example.com/v1"
232
+
233
+ # Verify fallback env var is set for OpenAI-compatible endpoints
234
+ import os
235
+ assert os.environ.get("OPENAI_API_KEY") == "sk-custom-12345"
236
+
237
+ # Cleanup to avoid leaking into other tests
238
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
239
+
240
+ def test_custom_endpoint_with_literal_api_key(self):
241
+ """Custom endpoint should pass api_key in kwargs when using literal value."""
242
+ # Create LLM config for custom endpoint with literal api_key
243
+ llm_config = LLMConfig(
244
+ models={
245
+ "custom-model": ModelConfig(
246
+ model_name="custom-model-name",
247
+ model_url="https://custom-llm.example.com/v1",
248
+ api_key="sk-literal-custom-key"
249
+ )
250
+ }
251
+ )
252
+
253
+ # Create LiteLLMCaller
254
+ caller = LiteLLMCaller(llm_config, debug_mode=True)
255
+
256
+ # Get model kwargs
257
+ model_kwargs = caller._get_model_kwargs("custom-model")
258
+
259
+ # Verify that api_key is in kwargs (critical for custom endpoints)
260
+ assert "api_key" in model_kwargs
261
+ assert model_kwargs["api_key"] == "sk-literal-custom-key"
262
+
263
+ # Verify that api_base is set for custom endpoint
264
+ assert "api_base" in model_kwargs
265
+ assert model_kwargs["api_base"] == "https://custom-llm.example.com/v1"
266
+
267
+ def test_openai_env_not_overwritten_if_same_value(self, monkeypatch):
268
+ """OPENAI_API_KEY is left as-is when value matches."""
269
+ # Pre-set env to a specific value
270
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-same")
271
+
272
+ llm_config = LLMConfig(
273
+ models={
274
+ "openai-model": ModelConfig(
275
+ model_name="gpt-4",
276
+ model_url="https://api.openai.com/v1",
277
+ api_key="sk-openai-same",
278
+ )
279
+ }
280
+ )
281
+
282
+ caller = LiteLLMCaller(llm_config, debug_mode=True)
283
+ model_kwargs = caller._get_model_kwargs("openai-model")
284
+
285
+ import os
286
+ # Still should have correct key in kwargs
287
+ assert model_kwargs["api_key"] == "sk-openai-same"
288
+ # Env var should remain the same
289
+ assert os.environ.get("OPENAI_API_KEY") == "sk-openai-same"
290
+
291
+ def test_openai_env_overwritten_with_warning(self, monkeypatch, caplog):
292
+ """OPENAI_API_KEY overwrite should occur with a warning when value differs."""
293
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-original")
294
+
295
+ llm_config = LLMConfig(
296
+ models={
297
+ "openai-model": ModelConfig(
298
+ model_name="gpt-4",
299
+ model_url="https://api.openai.com/v1",
300
+ api_key="sk-openai-new",
301
+ )
302
+ }
303
+ )
304
+
305
+ caller = LiteLLMCaller(llm_config, debug_mode=True)
306
+
307
+ with caplog.at_level("WARNING"):
308
+ model_kwargs = caller._get_model_kwargs("openai-model")
309
+
310
+ import os
311
+ # kwargs should use the new key
312
+ assert model_kwargs["api_key"] == "sk-openai-new"
313
+ # Env var should be overwritten to the new value
314
+ assert os.environ.get("OPENAI_API_KEY") == "sk-openai-new"
315
+ # A warning about overwriting should be logged
316
+ assert any("Overwriting existing environment variable OPENAI_API_KEY" in rec.getMessage() for rec in caplog.records)
317
+
318
+ def test_openai_and_custom_models_resolved_in_succession(self, monkeypatch):
319
+ """Sequence of OpenAI then custom endpoint should keep last key in env while kwargs stay correct."""
320
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-preexisting")
321
+
322
+ llm_config = LLMConfig(
323
+ models={
324
+ "openai-model": ModelConfig(
325
+ model_name="gpt-4",
326
+ model_url="https://api.openai.com/v1",
327
+ api_key="sk-openai-1",
328
+ ),
329
+ "custom-model": ModelConfig(
330
+ model_name="custom-model-name",
331
+ model_url="https://custom-llm.example.com/v1",
332
+ api_key="sk-custom-2",
333
+ ),
334
+ }
335
+ )
336
+
337
+ caller = LiteLLMCaller(llm_config, debug_mode=True)
338
+
339
+ # First resolve OpenAI model
340
+ openai_kwargs = caller._get_model_kwargs("openai-model")
341
+ # Then resolve custom model
342
+ custom_kwargs = caller._get_model_kwargs("custom-model")
343
+
344
+ import os
345
+ # kwargs should always reflect model-specific keys
346
+ assert openai_kwargs["api_key"] == "sk-openai-1"
347
+ assert custom_kwargs["api_key"] == "sk-custom-2"
348
+ # Env var ends up with the last key used (custom model)
349
+ assert os.environ.get("OPENAI_API_KEY") == "sk-custom-2"
350
+
351
+ def test_custom_endpoint_with_extra_headers(self, monkeypatch):
352
+ """Custom endpoint should handle extra_headers correctly."""
353
+ monkeypatch.setenv("CUSTOM_API_KEY", "sk-custom-auth")
354
+ monkeypatch.setenv("CUSTOM_TENANT", "tenant-123")
355
+
356
+ # Create LLM config for custom endpoint with extra headers
357
+ llm_config = LLMConfig(
358
+ models={
359
+ "custom-model": ModelConfig(
360
+ model_name="custom-model-name",
361
+ model_url="https://custom-llm.example.com/v1",
362
+ api_key="${CUSTOM_API_KEY}",
363
+ extra_headers={
364
+ "X-Tenant-ID": "${CUSTOM_TENANT}",
365
+ "X-Custom-Header": "custom-value"
366
+ }
367
+ )
368
+ }
369
+ )
370
+
371
+ # Create LiteLLMCaller
372
+ caller = LiteLLMCaller(llm_config, debug_mode=True)
373
+
374
+ # Get model kwargs
375
+ model_kwargs = caller._get_model_kwargs("custom-model")
376
+
377
+ # Verify api_key is passed
378
+ assert "api_key" in model_kwargs
379
+ assert model_kwargs["api_key"] == "sk-custom-auth"
380
+
381
+ # Verify extra_headers are resolved and passed
382
+ assert "extra_headers" in model_kwargs
383
+ assert model_kwargs["extra_headers"]["X-Tenant-ID"] == "tenant-123"
384
+ assert model_kwargs["extra_headers"]["X-Custom-Header"] == "custom-value"
385
+
386
+ # Verify api_base is set
387
+ assert "api_base" in model_kwargs
388
+
389
+ def test_known_providers_still_get_api_key_in_kwargs(self, monkeypatch):
390
+ """Verify that known providers also get api_key in kwargs (backward compatibility)."""
391
+ # Test OpenAI
392
+ llm_config = LLMConfig(
393
+ models={
394
+ "openai-model": ModelConfig(
395
+ model_name="gpt-4",
396
+ model_url="https://api.openai.com/v1",
397
+ api_key="sk-openai-test"
398
+ )
399
+ }
400
+ )
401
+ caller = LiteLLMCaller(llm_config, debug_mode=True)
402
+ model_kwargs = caller._get_model_kwargs("openai-model")
403
+
404
+ # OpenAI should get api_key in kwargs
405
+ assert "api_key" in model_kwargs
406
+ assert model_kwargs["api_key"] == "sk-openai-test"
407
+
408
+ # cleanup any env var potentially set by implementation
409
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
410
+
411
+ # Test OpenRouter
412
+ llm_config = LLMConfig(
413
+ models={
414
+ "openrouter-model": ModelConfig(
415
+ model_name="meta-llama/llama-3-70b",
416
+ model_url="https://openrouter.ai/api/v1",
417
+ api_key="sk-or-test"
418
+ )
419
+ }
420
+ )
421
+ caller = LiteLLMCaller(llm_config, debug_mode=True)
422
+ model_kwargs = caller._get_model_kwargs("openrouter-model")
423
+
424
+ # OpenRouter should get api_key in kwargs
425
+ assert "api_key" in model_kwargs
426
+ assert model_kwargs["api_key"] == "sk-or-test"
427
+
428
+ # cleanup any env var potentially set by implementation
429
+ monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
430
+
431
+ def test_openai_key_from_dotenv_file(self, monkeypatch):
432
+ """Test that OPENAI_API_KEY set in .env file (loaded into os.environ) works correctly."""
433
+ # Simulate .env file loading by setting the env var
434
+ # In real scenarios, python-dotenv loads .env into os.environ
435
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-from-dotenv-file")
436
+
437
+ # Create LLM config that doesn't specify an api_key (relies on env)
438
+ llm_config = LLMConfig(
439
+ models={
440
+ "openai-model": ModelConfig(
441
+ model_name="gpt-4",
442
+ model_url="https://api.openai.com/v1",
443
+ api_key="${OPENAI_API_KEY}"
444
+ )
445
+ }
446
+ )
447
+
448
+ caller = LiteLLMCaller(llm_config, debug_mode=True)
449
+ model_kwargs = caller._get_model_kwargs("openai-model")
450
+
451
+ # Should use the key from .env
452
+ assert model_kwargs["api_key"] == "sk-from-dotenv-file"
453
+
454
+ import os
455
+ assert os.environ.get("OPENAI_API_KEY") == "sk-from-dotenv-file"
456
+
457
+ def test_multiple_custom_openai_compatible_endpoints_with_different_keys(self, monkeypatch):
458
+ """Test multiple OpenAI-compatible custom endpoints each with their own API key."""
459
+ monkeypatch.setenv("CUSTOM_LLM_A_KEY", "sk-custom-a-12345")
460
+ monkeypatch.setenv("CUSTOM_LLM_B_KEY", "sk-custom-b-67890")
461
+ monkeypatch.setenv("CUSTOM_LLM_C_KEY", "sk-custom-c-abcde")
462
+
463
+ llm_config = LLMConfig(
464
+ models={
465
+ "custom-llm-a": ModelConfig(
466
+ model_name="custom-model-a",
467
+ model_url="https://llm-a.example.com/v1",
468
+ api_key="${CUSTOM_LLM_A_KEY}"
469
+ ),
470
+ "custom-llm-b": ModelConfig(
471
+ model_name="custom-model-b",
472
+ model_url="https://llm-b.example.com/v1",
473
+ api_key="${CUSTOM_LLM_B_KEY}"
474
+ ),
475
+ "custom-llm-c": ModelConfig(
476
+ model_name="custom-model-c",
477
+ model_url="https://llm-c.example.com/v1",
478
+ api_key="${CUSTOM_LLM_C_KEY}"
479
+ ),
480
+ }
481
+ )
482
+
483
+ caller = LiteLLMCaller(llm_config, debug_mode=True)
484
+
485
+ # Get kwargs for each model
486
+ kwargs_a = caller._get_model_kwargs("custom-llm-a")
487
+ kwargs_b = caller._get_model_kwargs("custom-llm-b")
488
+ kwargs_c = caller._get_model_kwargs("custom-llm-c")
489
+
490
+ # Each should have its own correct API key in kwargs
491
+ assert kwargs_a["api_key"] == "sk-custom-a-12345"
492
+ assert kwargs_b["api_key"] == "sk-custom-b-67890"
493
+ assert kwargs_c["api_key"] == "sk-custom-c-abcde"
494
+
495
+ # Each should have its own api_base
496
+ assert kwargs_a["api_base"] == "https://llm-a.example.com/v1"
497
+ assert kwargs_b["api_base"] == "https://llm-b.example.com/v1"
498
+ assert kwargs_c["api_base"] == "https://llm-c.example.com/v1"
499
+
500
+ # OPENAI_API_KEY env var will be set to the last one resolved
501
+ import os
502
+ assert os.environ.get("OPENAI_API_KEY") == "sk-custom-c-abcde"
503
+
504
+ # Cleanup
505
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
506
+
507
+ def test_custom_and_real_openai_endpoints_use_correct_keys(self, monkeypatch):
508
+ """Test that custom endpoints and real OpenAI endpoints each use their correct API keys."""
509
+ monkeypatch.setenv("REAL_OPENAI_KEY", "sk-real-openai-xyz")
510
+ monkeypatch.setenv("CUSTOM_PROVIDER_KEY", "sk-custom-provider-abc")
511
+
512
+ llm_config = LLMConfig(
513
+ models={
514
+ "openai-gpt4": ModelConfig(
515
+ model_name="gpt-4",
516
+ model_url="https://api.openai.com/v1",
517
+ api_key="${REAL_OPENAI_KEY}"
518
+ ),
519
+ "custom-provider": ModelConfig(
520
+ model_name="custom-llm-7b",
521
+ model_url="https://custom-provider.example.com/v1",
522
+ api_key="${CUSTOM_PROVIDER_KEY}"
523
+ ),
524
+ }
525
+ )
526
+
527
+ caller = LiteLLMCaller(llm_config, debug_mode=True)
528
+
529
+ # Test OpenAI endpoint
530
+ openai_kwargs = caller._get_model_kwargs("openai-gpt4")
531
+ assert openai_kwargs["api_key"] == "sk-real-openai-xyz"
532
+ # Standard OpenAI endpoint doesn't set api_base (uses default)
533
+ assert "api_base" not in openai_kwargs
534
+
535
+ # Test custom endpoint
536
+ custom_kwargs = caller._get_model_kwargs("custom-provider")
537
+ assert custom_kwargs["api_key"] == "sk-custom-provider-abc"
538
+ assert custom_kwargs["api_base"] == "https://custom-provider.example.com/v1"
539
+
540
+ # Both should be callable with correct keys
541
+ import os
542
+ # Last one will be in OPENAI_API_KEY env var
543
+ assert os.environ.get("OPENAI_API_KEY") in ["sk-real-openai-xyz", "sk-custom-provider-abc"]
544
+
545
+ # Cleanup
546
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
547
+
548
+ def test_custom_endpoint_missing_api_key_env_var_raises_error(self):
549
+ """Test that missing custom API key env var raises appropriate error."""
550
+ # Create config with undefined env var
551
+ llm_config = LLMConfig(
552
+ models={
553
+ "custom-model": ModelConfig(
554
+ model_name="custom-model",
555
+ model_url="https://custom.example.com/v1",
556
+ api_key="${UNDEFINED_CUSTOM_KEY}"
557
+ )
558
+ }
559
+ )
560
+
561
+ caller = LiteLLMCaller(llm_config, debug_mode=True)
562
+
563
+ # Should raise ValueError about missing env var
564
+ with pytest.raises(ValueError, match="Environment variable 'UNDEFINED_CUSTOM_KEY' is not set"):
565
+ caller._get_model_kwargs("custom-model")
566
+
567
+ def test_multiple_endpoints_with_mixed_key_sources(self, monkeypatch):
568
+ """Test mixture of literal keys, env vars, and .env-loaded keys across multiple endpoints."""
569
+ # Simulate some keys from .env file
570
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-from-dotenv")
571
+ # Some from explicit env vars
572
+ monkeypatch.setenv("CUSTOM_A_KEY", "sk-custom-a-env")
573
+
574
+ llm_config = LLMConfig(
575
+ models={
576
+ "openai-from-dotenv": ModelConfig(
577
+ model_name="gpt-4",
578
+ model_url="https://api.openai.com/v1",
579
+ api_key="${OPENAI_API_KEY}" # Uses .env value
580
+ ),
581
+ "custom-from-env": ModelConfig(
582
+ model_name="custom-a",
583
+ model_url="https://custom-a.example.com/v1",
584
+ api_key="${CUSTOM_A_KEY}" # Uses explicit env var
585
+ ),
586
+ "custom-literal": ModelConfig(
587
+ model_name="custom-b",
588
+ model_url="https://custom-b.example.com/v1",
589
+ api_key="sk-literal-hardcoded" # Literal value
590
+ ),
591
+ }
592
+ )
593
+
594
+ caller = LiteLLMCaller(llm_config, debug_mode=True)
595
+
596
+ # Each should resolve correctly
597
+ kwargs_openai = caller._get_model_kwargs("openai-from-dotenv")
598
+ assert kwargs_openai["api_key"] == "sk-from-dotenv"
599
+
600
+ kwargs_custom_a = caller._get_model_kwargs("custom-from-env")
601
+ assert kwargs_custom_a["api_key"] == "sk-custom-a-env"
602
+
603
+ kwargs_custom_b = caller._get_model_kwargs("custom-literal")
604
+ assert kwargs_custom_b["api_key"] == "sk-literal-hardcoded"
605
+
606
+ # Cleanup
607
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
608
+
609
+ def test_empty_api_key_raises_appropriate_error(self):
610
+ """Test that empty API key (after env var expansion) raises error."""
611
+ # Note: Current implementation may not explicitly check for empty strings
612
+ # This test documents expected behavior
613
+ llm_config = LLMConfig(
614
+ models={
615
+ "model-with-empty-key": ModelConfig(
616
+ model_name="test-model",
617
+ model_url="https://api.example.com/v1",
618
+ api_key="" # Empty string
619
+ )
620
+ }
621
+ )
622
+
623
+ caller = LiteLLMCaller(llm_config, debug_mode=True)
624
+
625
+ # Get kwargs - empty api_key is treated as None/missing
626
+ # The implementation only sets api_key if it's truthy
627
+ kwargs = caller._get_model_kwargs("model-with-empty-key")
628
+
629
+ # Empty string is not passed through (falsy value)
630
+ assert "api_key" not in kwargs
631
+
632
+ def test_switching_between_models_updates_env_correctly(self, monkeypatch):
633
+ """Test that switching between different model types updates environment correctly."""
634
+ monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-initial")
635
+
636
+ llm_config = LLMConfig(
637
+ models={
638
+ "anthropic-model": ModelConfig(
639
+ model_name="claude-3",
640
+ model_url="https://api.anthropic.com/v1",
641
+ api_key="sk-ant-new"
642
+ ),
643
+ "openai-model": ModelConfig(
644
+ model_name="gpt-4",
645
+ model_url="https://api.openai.com/v1",
646
+ api_key="sk-openai-new"
647
+ ),
648
+ "custom-model": ModelConfig(
649
+ model_name="custom",
650
+ model_url="https://custom.example.com/v1",
651
+ api_key="sk-custom-new"
652
+ ),
653
+ }
654
+ )
655
+
656
+ caller = LiteLLMCaller(llm_config, debug_mode=True)
657
+
658
+ import os
659
+
660
+ # Call Anthropic
661
+ anthropic_kwargs = caller._get_model_kwargs("anthropic-model")
662
+ assert anthropic_kwargs["api_key"] == "sk-ant-new"
663
+ assert os.environ.get("ANTHROPIC_API_KEY") == "sk-ant-new"
664
+
665
+ # Call OpenAI
666
+ openai_kwargs = caller._get_model_kwargs("openai-model")
667
+ assert openai_kwargs["api_key"] == "sk-openai-new"
668
+ assert os.environ.get("OPENAI_API_KEY") == "sk-openai-new"
669
+
670
+ # Call custom (should also set OPENAI_API_KEY as fallback)
671
+ custom_kwargs = caller._get_model_kwargs("custom-model")
672
+ assert custom_kwargs["api_key"] == "sk-custom-new"
673
+ assert os.environ.get("OPENAI_API_KEY") == "sk-custom-new"
674
+
675
+ # Anthropic key should still be set
676
+ assert os.environ.get("ANTHROPIC_API_KEY") == "sk-ant-new"
677
+
678
+ # Cleanup
679
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
680
+ monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
681
+
682
+ def test_custom_endpoints_with_openai_prefix_in_model_name(self, monkeypatch):
683
+ """Test custom endpoints that use 'openai/' prefix in model name use correct API keys.
684
+
685
+ LiteLLM uses model name prefixes (e.g., 'openai/', 'anthropic/') to detect providers.
686
+ This test ensures that when we have custom endpoints with model names like
687
+ 'openai/custom-model1', each endpoint still gets its own correct API key.
688
+ """
689
+ monkeypatch.setenv("CUSTOM_ENDPOINT_A_KEY", "sk-custom-a-12345")
690
+ monkeypatch.setenv("CUSTOM_ENDPOINT_B_KEY", "sk-custom-b-67890")
691
+
692
+ llm_config = LLMConfig(
693
+ models={
694
+ "custom-a": ModelConfig(
695
+ model_name="openai/custom-model1", # Has openai/ prefix but custom endpoint
696
+ model_url="https://custom-a.example.com/v1",
697
+ api_key="${CUSTOM_ENDPOINT_A_KEY}"
698
+ ),
699
+ "custom-b": ModelConfig(
700
+ model_name="openai/custom-model2", # Has openai/ prefix but custom endpoint
701
+ model_url="https://custom-b.example.com/v1",
702
+ api_key="${CUSTOM_ENDPOINT_B_KEY}"
703
+ ),
704
+ }
705
+ )
706
+
707
+ caller = LiteLLMCaller(llm_config, debug_mode=True)
708
+
709
+ # Get kwargs for each custom endpoint
710
+ kwargs_a = caller._get_model_kwargs("custom-a")
711
+ kwargs_b = caller._get_model_kwargs("custom-b")
712
+
713
+ # Each should have its own correct API key in kwargs
714
+ assert kwargs_a["api_key"] == "sk-custom-a-12345"
715
+ assert kwargs_b["api_key"] == "sk-custom-b-67890"
716
+
717
+ # Each should have its own api_base set (custom endpoints)
718
+ assert kwargs_a["api_base"] == "https://custom-a.example.com/v1"
719
+ assert kwargs_b["api_base"] == "https://custom-b.example.com/v1"
720
+
721
+ # Verify the LiteLLM model names don't have prefixes for custom endpoints
722
+ litellm_name_a = caller._get_litellm_model_name("custom-a")
723
+ litellm_name_b = caller._get_litellm_model_name("custom-b")
724
+
725
+ # Custom endpoints should use model_id directly (not add prefix)
726
+ assert litellm_name_a == "openai/custom-model1"
727
+ assert litellm_name_b == "openai/custom-model2"
728
+
729
+ # Cleanup
730
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
731
+
732
+ def test_mixed_real_and_custom_openai_endpoints_with_same_prefix(self, monkeypatch):
733
+ """Test that real OpenAI and custom OpenAI-compatible endpoints are handled correctly.
734
+
735
+ When you have both:
736
+ - A real OpenAI endpoint (api.openai.com)
737
+ - Custom OpenAI-compatible endpoints
738
+
739
+ Each should use its own API key even though they might have similar model name patterns.
740
+
741
+ NOTE: If a custom endpoint URL contains 'openai' in the hostname (e.g.,
742
+ 'custom-openai.example.com'), it will be detected as an OpenAI endpoint and get
743
+ the 'openai/' prefix. To avoid this, use URLs without 'openai' in them.
744
+ """
745
+ monkeypatch.setenv("REAL_OPENAI_KEY", "sk-real-openai-xyz")
746
+ monkeypatch.setenv("CUSTOM_COMPAT_KEY", "sk-custom-compat-abc")
747
+
748
+ llm_config = LLMConfig(
749
+ models={
750
+ "real-openai": ModelConfig(
751
+ model_name="gpt-4o",
752
+ model_url="https://api.openai.com/v1",
753
+ api_key="${REAL_OPENAI_KEY}"
754
+ ),
755
+ "custom-compat": ModelConfig(
756
+ model_name="custom-gpt-4",
757
+ # Use URL without 'openai' in it to avoid provider detection
758
+ model_url="https://llm-provider.example.com/v1",
759
+ api_key="${CUSTOM_COMPAT_KEY}"
760
+ ),
761
+ }
762
+ )
763
+
764
+ caller = LiteLLMCaller(llm_config, debug_mode=True)
765
+
766
+ # Get kwargs for real OpenAI
767
+ real_kwargs = caller._get_model_kwargs("real-openai")
768
+ assert real_kwargs["api_key"] == "sk-real-openai-xyz"
769
+ # Real OpenAI doesn't set custom api_base
770
+ assert "api_base" not in real_kwargs
771
+
772
+ # Get kwargs for custom endpoint
773
+ custom_kwargs = caller._get_model_kwargs("custom-compat")
774
+ assert custom_kwargs["api_key"] == "sk-custom-compat-abc"
775
+ # Custom endpoint sets api_base
776
+ assert custom_kwargs["api_base"] == "https://llm-provider.example.com/v1"
777
+
778
+ # Verify LiteLLM model names
779
+ assert caller._get_litellm_model_name("real-openai") == "openai/gpt-4o"
780
+ # Custom endpoint without provider keywords in URL gets no prefix
781
+ assert caller._get_litellm_model_name("custom-compat") == "custom-gpt-4"
782
+
783
+ # Cleanup
784
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
785
+
786
+ def test_multiple_custom_endpoints_sequential_calls_preserve_keys(self, monkeypatch):
787
+ """Test that calling multiple custom endpoints in sequence preserves correct API keys.
788
+
789
+ This is critical because the implementation sets OPENAI_API_KEY as a fallback
790
+ for custom endpoints. We need to ensure that when switching between custom
791
+ endpoints, each call still gets the correct API key in kwargs even though
792
+ the env var might have been overwritten.
793
+ """
794
+ monkeypatch.setenv("CUSTOM_1_KEY", "sk-custom-1")
795
+ monkeypatch.setenv("CUSTOM_2_KEY", "sk-custom-2")
796
+ monkeypatch.setenv("CUSTOM_3_KEY", "sk-custom-3")
797
+
798
+ llm_config = LLMConfig(
799
+ models={
800
+ "custom-1": ModelConfig(
801
+ model_name="model-1",
802
+ model_url="https://custom1.example.com/v1",
803
+ api_key="${CUSTOM_1_KEY}"
804
+ ),
805
+ "custom-2": ModelConfig(
806
+ model_name="model-2",
807
+ model_url="https://custom2.example.com/v1",
808
+ api_key="${CUSTOM_2_KEY}"
809
+ ),
810
+ "custom-3": ModelConfig(
811
+ model_name="model-3",
812
+ model_url="https://custom3.example.com/v1",
813
+ api_key="${CUSTOM_3_KEY}"
814
+ ),
815
+ }
816
+ )
817
+
818
+ caller = LiteLLMCaller(llm_config, debug_mode=True)
819
+
820
+ # Call them in sequence multiple times
821
+ for _ in range(2):
822
+ kwargs_1 = caller._get_model_kwargs("custom-1")
823
+ assert kwargs_1["api_key"] == "sk-custom-1", "Custom-1 should always get its own key"
824
+
825
+ kwargs_2 = caller._get_model_kwargs("custom-2")
826
+ assert kwargs_2["api_key"] == "sk-custom-2", "Custom-2 should always get its own key"
827
+
828
+ kwargs_3 = caller._get_model_kwargs("custom-3")
829
+ assert kwargs_3["api_key"] == "sk-custom-3", "Custom-3 should always get its own key"
830
+
831
+ # Going back to custom-1 should still work
832
+ kwargs_1_again = caller._get_model_kwargs("custom-1")
833
+ assert kwargs_1_again["api_key"] == "sk-custom-1", "Custom-1 should still get its own key"
834
+
835
+ # Cleanup
836
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)