main
py 851 lines 33.5 KB
Raw
1 import json
2 import sys
3 import threading
4 import types
5 from pathlib import Path
6
7 import pytest
8 from flask import Flask
9
10
11 PROJECT_ROOT = Path(__file__).resolve().parents[1]
12 if str(PROJECT_ROOT) not in sys.path:
13 sys.path.insert(0, str(PROJECT_ROOT))
14
15 sys.modules["giturlparse"] = types.SimpleNamespace(parse=lambda *args, **kwargs: None)
16 sys.modules["whisper"] = types.SimpleNamespace(load_model=lambda *args, **kwargs: None)
17
18
19 class _DummyObserver:
20 def __init__(self):
21 self._alive = False
22
23 def is_alive(self):
24 return self._alive
25
26 def start(self):
27 self._alive = True
28
29 def stop(self):
30 self._alive = False
31
32 def join(self, *args, **kwargs):
33 return None
34
35 def unschedule_all(self):
36 return None
37
38 def schedule(self, *args, **kwargs):
39 return None
40
41
42 watchdog = types.ModuleType("watchdog")
43 watchdog.observers = types.SimpleNamespace(Observer=_DummyObserver)
44 watchdog.events = types.SimpleNamespace(FileSystemEventHandler=object)
45 sys.modules["watchdog"] = watchdog
46 sys.modules["watchdog.observers"] = watchdog.observers
47 sys.modules["watchdog.events"] = watchdog.events
48
49 from plugins._model_config.api.api_keys import ApiKeys
50 from plugins._model_config.extensions.python.banners import _20_missing_api_key as missing_key_banner
51 import models
52
53
54 def test_model_config_api_keys_can_be_cleared_via_backend(monkeypatch, tmp_path):
55 from helpers import dotenv
56
57 env_file = tmp_path / ".env"
58 monkeypatch.setattr(dotenv, "get_dotenv_file_path", lambda: str(env_file))
59
60 for key in ("API_KEY_OPENROUTER", "OPENROUTER_API_KEY", "OPENROUTER_API_TOKEN"):
61 monkeypatch.delenv(key, raising=False)
62
63 handler = ApiKeys(Flask(__name__), threading.Lock())
64
65 assert handler._set_keys({"keys": {"openrouter": "sk-test-openrouter"}}) == {"ok": True}
66 assert models.get_api_key("openrouter") == "sk-test-openrouter"
67
68 assert handler._set_keys({"keys": {"openrouter": ""}}) == {"ok": True}
69 assert models.get_api_key("openrouter") == "None"
70 assert handler._reveal_key({"provider": "openrouter"}) == {"ok": True, "value": ""}
71
72
73 def test_chat_model_configured_requires_identity_and_key(monkeypatch):
74 from plugins._model_config.helpers import model_config
75
76 monkeypatch.setattr(
77 model_config,
78 "has_provider_api_key",
79 lambda provider, configured_api_key="", model_type="chat": provider == "openrouter",
80 )
81
82 assert not model_config.is_chat_model_configured({"chat_model": {}})
83 assert not model_config.is_chat_model_configured({"chat_model": {"provider": "openrouter"}})
84 assert model_config.is_chat_model_configured(
85 {"chat_model": {"provider": "openrouter", "name": "anthropic/claude"}}
86 )
87 assert not model_config.is_chat_model_configured(
88 {"chat_model": {"provider": "openai", "name": "gpt-5"}}
89 )
90
91
92 def test_missing_api_key_checks_only_the_active_vision_model(monkeypatch):
93 from plugins._model_config.helpers import model_config
94
95 config = {
96 "chat_model": {"provider": "ollama", "name": "text-main", "vision": True},
97 "vision_model": {"provider": "openai", "name": "vision"},
98 "utility_model": {"provider": "ollama", "name": "utility"},
99 "embedding_model": {
100 "provider": "huggingface",
101 "name": "sentence-transformers/all-MiniLM-L6-v2",
102 },
103 }
104 monkeypatch.setattr(model_config, "get_effective_config", lambda _agent=None: config)
105 monkeypatch.setattr(
106 model_config,
107 "get_embedding_model_config",
108 lambda _agent=None: config["embedding_model"],
109 )
110 monkeypatch.setattr(model_config, "has_provider_api_key", lambda *args: False)
111
112 assert model_config.get_missing_api_key_providers() == []
113
114 config["chat_model"]["vision"] = False
115 assert model_config.get_missing_api_key_providers() == [
116 {"model_type": "Vision Model", "provider": "openai"}
117 ]
118
119
120 @pytest.mark.asyncio
121 async def test_missing_api_key_banner_exposes_only_effective_missing_providers(monkeypatch):
122 from plugins._model_config.helpers import model_config
123
124 fake = [{"model_type": "Chat Model", "provider": "openai"}]
125 monkeypatch.setattr(model_config, "get_missing_api_key_providers", lambda: fake)
126 monkeypatch.setattr(
127 model_config,
128 "get_presets",
129 lambda: [{"name": "Efficiency", "chat": {"provider": "openrouter"}}],
130 )
131 monkeypatch.setattr(model_config, "has_provider_api_key", lambda *args, **kwargs: False)
132
133 banners = []
134 await missing_key_banner.MissingApiKeyCheck(agent=None).execute(
135 banners=banners, frontend_context={}
136 )
137 assert [banner["id"] for banner in banners] == ["missing-api-key"]
138 row = next(b for b in banners if b.get("id") == "missing-api-key")
139 assert row.get("missing_providers") == fake
140 assert row["cta_text"] == "Start Onboarding"
141 assert row["cta_action"] == "open-modal:/plugins/_onboarding/webui/onboarding.html"
142 assert "onboarding-banner-btn-container" not in row["html"]
143
144
145 def test_model_config_frontend_tracks_provider_api_key_edits():
146 store_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "model-config-store.js"
147 api_keys_mixin_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "api-keys-mixin.js"
148 model_gate_path = PROJECT_ROOT / "webui" / "components" / "chat" / "model-gate-store.js"
149 config_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "config.html"
150 model_field_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "model-field.html"
151 modal_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "api-keys.html"
152
153 store_content = (
154 store_path.read_text(encoding="utf-8")
155 + "\n"
156 + api_keys_mixin_path.read_text(encoding="utf-8")
157 )
158 model_gate_content = model_gate_path.read_text(encoding="utf-8")
159 preset_modal_content = (
160 PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "main.html"
161 ).read_text(encoding="utf-8")
162 config_content = (
163 config_path.read_text(encoding="utf-8")
164 + "\n"
165 + model_field_path.read_text(encoding="utf-8")
166 + "\n"
167 + preset_modal_content
168 )
169 modal_content = modal_path.read_text(encoding="utf-8")
170
171 assert "apiKeyDirty" in store_content
172 assert "resetApiKeyDrafts()" in store_content
173 assert "!provider || seen.has(provider) || !this.apiKeyDirty[provider]" in store_content
174 assert "normalized[provider] = value.trim() ? value : '';" in store_content
175 assert 'callJsonApi("/plugins/_model_config/model_config_get"' in model_gate_content
176 assert "dispatchPendingIfConfigured()" in model_gate_content
177 assert "/plugins/_model_config/missing_api_key_status" not in model_gate_content
178 assert '@input="$store.modelConfig.setApiKeyValue(_prov, $el.value)"' in config_content
179 assert "apiKeyMode: 'none'" not in preset_modal_content
180 assert preset_modal_content.count("apiKeyMode: 'store'") == 4
181 assert "$store.modelConfig.resetApiKeyDrafts();" in preset_modal_content
182 assert "await $store.modelConfig.refreshApiKeyStatus();" in preset_modal_content
183 assert "await store.persistAllDirtyApiKeys();" in store_content
184 assert "persistAllDirtyApiKeys()" in modal_content
185 assert "$store.modelConfig.resetApiKeyDrafts();" in modal_content
186
187
188 def test_model_config_snapshot_sync_only_adjusts_clean_loaded_configs():
189 config_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "config.html"
190 store_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "model-config-store.js"
191 config_content = config_path.read_text(encoding="utf-8")
192 store_content = store_path.read_text(encoding="utf-8")
193
194 assert "x-effect" not in config_content
195 assert "syncContextConfigFields(context, true)" in store_content
196 assert "context.loadSettings = async" in store_content
197 assert "context.settingsSnapshotJson === snapshotBeforeInit" in store_content
198
199
200 def test_model_switcher_frontend_renders_custom_overrides():
201 switcher_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "switcher-mixin.js"
202 refresh_extension_path = (
203 PROJECT_ROOT
204 / "plugins"
205 / "_model_config"
206 / "extensions"
207 / "webui"
208 / "apply_snapshot_before"
209 / "refresh-switcher.js"
210 )
211
212 switcher_content = switcher_path.read_text(encoding="utf-8")
213 switcher_html = (
214 PROJECT_ROOT
215 / "plugins"
216 / "_model_config"
217 / "extensions"
218 / "webui"
219 / "chat-input-progress-start"
220 / "model-switcher.html"
221 ).read_text(encoding="utf-8")
222 refresh_extension_content = refresh_extension_path.read_text(encoding="utf-8")
223
224 assert "function normalizeModelIdentity(value)" in switcher_content
225 assert "export function getModelLeafName(value)" in switcher_content
226 assert 'name.lastIndexOf("/") + 1' in switcher_content
227 assert "`${presetName} ${mainModelName}`" in switcher_content
228 assert "formatModelIdentity(models.utility)" not in switcher_content
229 assert "normalizeModelIdentity(o.chat || o)" in switcher_content
230 assert "normalizeModelIdentity(o.utility)" in switcher_content
231 assert "$store.modelConfig.getSwitcherLabel()" in switcher_html
232 assert "loadAgentProfiles(true)" not in switcher_html
233 assert "model-switcher-active-pills" not in switcher_html
234 assert "model-pill-role" not in switcher_html
235 assert "_model_config_override_revision" in refresh_extension_content
236 assert "activeContext?.agent_profile" in refresh_extension_content
237 assert "activeContext?.project" in refresh_extension_content
238 assert "modelConfigStore.loadAgentProfiles(true)" in refresh_extension_content
239 assert "modelConfigStore.refreshSwitcher(contextId)" in refresh_extension_content
240
241
242 def test_model_override_notifies_state_sync(monkeypatch):
243 from helpers import state_monitor_integration
244 from plugins._model_config.api import model_override
245
246 calls = []
247
248 class FakeContext:
249 id = "ctx-1"
250
251 def __init__(self):
252 self.output_data = {}
253
254 def set_output_data(self, key, value):
255 self.output_data[key] = value
256
257 ctx = FakeContext()
258 monkeypatch.setattr(
259 state_monitor_integration,
260 "mark_dirty_for_context",
261 lambda context_id, *, reason=None: calls.append((context_id, reason)),
262 )
263
264 model_override._notify_model_override_changed(ctx)
265
266 assert "_model_config_override_revision" in ctx.output_data
267 assert calls == [("ctx-1", "model_config.model_override")]
268
269
270 def test_connector_model_switcher_notifies_state_sync(monkeypatch):
271 from helpers import state_monitor_integration
272 from plugins._a0_connector.api.v1 import model_switcher
273
274 calls = []
275
276 class FakeContext:
277 def __init__(self):
278 self.output_data = {}
279
280 def set_output_data(self, key, value):
281 self.output_data[key] = value
282
283 ctx = FakeContext()
284 monkeypatch.setattr(
285 state_monitor_integration,
286 "mark_dirty_for_context",
287 lambda context_id, *, reason=None: calls.append((context_id, reason)),
288 )
289
290 model_switcher._notify_model_override_changed(ctx, "ctx-1")
291
292 assert "_model_config_override_revision" in ctx.output_data
293 assert calls == [("ctx-1", "a0_connector.model_switcher")]
294
295
296 def test_model_config_provider_switch_resets_provider_specific_fields():
297 model_field_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "model-field.html"
298 content = model_field_path.read_text(encoding="utf-8")
299 select_start = content.index('<select x-model="model.provider"')
300 select_end = content.index("</select>", select_start)
301 provider_select = content[select_start:select_end]
302
303 assert 'x-model="model.provider"' in provider_select
304 assert "model.api_base = ''" in provider_select
305 assert "model.kwargs = {}" in provider_select
306 assert "model._kwargs_text = ''" in provider_select
307
308
309 def test_model_config_model_field_opens_search_on_click():
310 model_field_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "model-field.html"
311 content = model_field_path.read_text(encoding="utf-8")
312
313 assert '@click="openSearch($el)"' in content
314 assert '<button class="model-search-btn"' in content
315 assert 'aria-label="Search available models"' in content
316
317
318 def test_model_config_primary_context_controls_are_outside_advanced_settings():
319 model_field_path = PROJECT_ROOT / "plugins" / "_model_config" / "webui" / "model-field.html"
320 content = model_field_path.read_text(encoding="utf-8")
321
322 vision_start = content.index('<div class="field-title">Supports Vision</div>')
323 context_size_start = content.index('<div class="field-title">Context window size</div>')
324 advanced_start = content.index("<!-- Advanced Settings (collapsed by default) -->")
325 max_embeds_start = content.index('<div class="field-title">Max embeds</div>')
326
327 assert content.count('<div class="field-title">Supports Vision</div>') == 1
328 assert content.count('<div class="field-title">Context window size</div>') == 1
329 assert vision_start < advanced_start
330 assert context_size_start < advanced_start
331 assert advanced_start < max_embeds_start
332
333
334 def test_ollama_cloud_provider_config_requires_key_and_base_url():
335 import yaml
336
337 provider_path = PROJECT_ROOT / "conf/model_providers.yaml"
338 provider_config = yaml.safe_load(provider_path.read_text(encoding="utf-8"))
339 ollama_cloud = provider_config["chat"]["ollama_cloud"]
340
341 assert ollama_cloud["name"] == "Ollama Cloud"
342 assert ollama_cloud["kwargs"]["a0_api_mode"] == "chat"
343 assert ollama_cloud["kwargs"]["api_base"] == "https://ollama.com/v1"
344 assert ollama_cloud["models_list"]["endpoint_url"] == "/models"
345 assert "api_key_mode" not in ollama_cloud
346
347
348 def test_cerebras_provider_uses_chat_completions_and_live_model_catalog(monkeypatch):
349 import yaml
350
351 from plugins._model_config.helpers import model_config
352
353 monkeypatch.setattr(models, "get_api_key", lambda provider: "test-key")
354
355 provider_path = PROJECT_ROOT / "conf/model_providers.yaml"
356 provider_config = yaml.safe_load(provider_path.read_text(encoding="utf-8"))
357 cerebras = provider_config["chat"]["cerebras"]
358
359 assert cerebras["name"] == "Cerebras"
360 assert cerebras["litellm_provider"] == "cerebras"
361 assert cerebras["models_list"]["endpoint_url"] == "/models"
362 assert cerebras["kwargs"] == {
363 "a0_api_mode": "chat",
364 "api_base": "https://api.cerebras.ai/v1",
365 }
366 assert model_config.provider_requires_api_key("cerebras") is True
367
368 model = models.get_chat_model("cerebras", "gpt-oss-120b")
369 assert model.model_name == "cerebras/gpt-oss-120b"
370 assert model.kwargs["a0_api_mode"] == "chat"
371 assert model.kwargs["api_base"] == "https://api.cerebras.ai/v1"
372 assert model.kwargs["api_key"] == "test-key"
373
374
375 def test_direct_venice_chat_provider_defaults_to_chat_completions(monkeypatch):
376 import yaml
377
378 monkeypatch.setattr(models, "get_api_key", lambda provider: "None")
379
380 provider_path = PROJECT_ROOT / "conf/model_providers.yaml"
381 provider_config = yaml.safe_load(provider_path.read_text(encoding="utf-8"))
382
383 venice = provider_config["chat"]["venice"]
384 assert venice["kwargs"]["a0_api_mode"] == "chat"
385 assert venice["kwargs"]["api_base"] == "https://api.venice.ai/api/v1"
386 assert venice["kwargs"]["venice_parameters"] == {
387 "include_venice_system_prompt": False
388 }
389 assert provider_config["chat"]["a0_venice"]["kwargs"]["a0_api_mode"] == "chat"
390 assert "a0_api_mode" not in provider_config["embedding"]["venice"]["kwargs"]
391
392 model = models.get_chat_model("venice", "llama-3.3-70b")
393 assert model.kwargs["a0_api_mode"] == "chat"
394
395 custom = models.get_chat_model(
396 "venice",
397 "llama-3.3-70b",
398 a0_api_mode="responses",
399 )
400 assert custom.kwargs["a0_api_mode"] == "responses"
401
402
403 def test_model_config_migration_repairs_saved_venice_user_slots(monkeypatch, tmp_path):
404 import yaml
405
406 from helpers import files
407 from plugins._model_config.extensions.python.startup_migration._10_migrate_model_config import (
408 MigrateModelConfig,
409 )
410
411 monkeypatch.setattr(files, "_base_dir", str(tmp_path))
412 plugin_dir = tmp_path / "usr" / "plugins" / "_model_config"
413 plugin_dir.mkdir(parents=True)
414 expected = {
415 "a0_api_mode": "chat",
416 "venice_parameters": {"include_venice_system_prompt": False},
417 }
418
419 config_path = plugin_dir / "config.json"
420 config_path.write_text(
421 json.dumps(
422 {
423 "chat_model": {
424 "provider": "venice",
425 "name": "llama-3.3-70b",
426 "kwargs": {"a0_api_mode": "responses"},
427 },
428 "utility_model": {
429 "provider": "a0_venice",
430 "name": "venice-proxy",
431 "kwargs": {"a0_api_mode": "responses"},
432 },
433 "embedding_model": {
434 "provider": "venice",
435 "name": "embed",
436 "kwargs": {},
437 },
438 }
439 ),
440 encoding="utf-8",
441 )
442
443 presets_path = plugin_dir / "presets.yaml"
444 presets_path.write_text(
445 yaml.safe_dump(
446 [
447 {
448 "name": "Venice",
449 "chat": {
450 "provider": "venice",
451 "name": "llama-3.3-70b",
452 "kwargs": {"venice_parameters": {"include_venice_system_prompt": True}},
453 },
454 "utility": {
455 "provider": "a0_venice",
456 "name": "proxy",
457 "kwargs": {"keep": True},
458 },
459 },
460 {
461 "name": "Legacy raw preset",
462 "provider": "venice",
463 "name": "raw",
464 "kwargs": {"a0_api_mode": "responses"},
465 },
466 ],
467 sort_keys=False,
468 ),
469 encoding="utf-8",
470 )
471
472 MigrateModelConfig(agent=None).execute()
473
474 config = json.loads(config_path.read_text(encoding="utf-8"))
475 presets = yaml.safe_load(presets_path.read_text(encoding="utf-8"))
476
477 assert config == {"model_preset": "Default"}
478 assert presets[0]["name"] == "Default"
479 assert presets[0]["chat"]["kwargs"] == expected
480 assert presets[0]["embedding"]["kwargs"] == expected
481 assert presets[0]["utility"]["kwargs"] == {"a0_api_mode": "responses"}
482 assert presets[1]["chat"]["kwargs"] == expected
483 assert presets[1]["utility"]["kwargs"] == {"keep": True}
484 assert presets[2]["chat"]["kwargs"] == expected
485 assert (plugin_dir / "config.json.pre-unified-presets.bak").exists()
486
487
488 def test_local_chat_providers_default_to_chat_completions():
489 import yaml
490
491 provider_path = PROJECT_ROOT / "conf/model_providers.yaml"
492 provider_config = yaml.safe_load(provider_path.read_text(encoding="utf-8"))
493
494 chat_completions_default_providers = (
495 "lm_studio",
496 "llama_cpp",
497 "ollama",
498 "ollama_cloud",
499 "omlx",
500 "other",
501 "vllm",
502 )
503 local_embedding_providers = (
504 "lm_studio",
505 "llama_cpp",
506 "ollama",
507 "omlx",
508 "vllm",
509 )
510
511 for provider in chat_completions_default_providers:
512 assert provider_config["chat"][provider]["kwargs"]["a0_api_mode"] == "chat"
513
514 for provider in local_embedding_providers:
515 assert "a0_api_mode" not in provider_config["embedding"][provider]["kwargs"]
516
517
518 def test_provider_api_mode_defaults_use_intended_transport():
519 import yaml
520
521 provider_config = yaml.safe_load(
522 (PROJECT_ROOT / "conf" / "model_providers.yaml").read_text(encoding="utf-8")
523 )
524 oauth_provider_config = yaml.safe_load(
525 (
526 PROJECT_ROOT
527 / "plugins"
528 / "_oauth"
529 / "conf"
530 / "model_providers.yaml"
531 ).read_text(encoding="utf-8")
532 )
533
534 for provider in provider_config["chat"].values():
535 assert provider.get("kwargs", {}).get("a0_api_mode", "chat") == "chat"
536
537 responses_providers = {
538 provider
539 for provider, config in oauth_provider_config["chat"].items()
540 if config.get("kwargs", {}).get("a0_api_mode") == "responses"
541 }
542 assert responses_providers == {"codex_oauth", "xai_grok_oauth"}
543
544 for provider, config in oauth_provider_config["chat"].items():
545 if provider not in responses_providers:
546 assert config.get("kwargs", {}).get("a0_api_mode", "chat") == "chat"
547
548
549 def test_missing_api_key_banner_does_not_include_auto_modal_metadata(monkeypatch):
550 from plugins._model_config.helpers import model_config
551
552 fake = [{"model_type": "Chat Model", "provider": "openai"}]
553 monkeypatch.setattr(model_config, "get_missing_api_key_providers", lambda: fake)
554
555 async def run():
556 banners = []
557 await missing_key_banner.MissingApiKeyCheck(agent=None).execute(
558 banners=banners, frontend_context={}
559 )
560 return next(b for b in banners if b.get("id") == "missing-api-key")
561
562 import asyncio
563 row = asyncio.run(run())
564
565 assert "auto_modal_path" not in row
566 assert "auto_modal_reason" not in row
567 assert "auto_modal_priority" not in row
568 assert "auto_modal_surfaces" not in row
569 assert row["type"] == "warning"
570 assert row["dismissible"] is False
571 assert row["missing_providers"] == fake
572 assert row["cta_text"] == "Start Onboarding"
573 assert row["cta_action"] == "open-modal:/plugins/_onboarding/webui/onboarding.html"
574
575
576 def test_provider_key_modes_for_local_and_ollama_cloud():
577 from plugins._model_config.helpers import model_config
578
579 assert model_config.provider_requires_api_key("ollama") is False
580 assert model_config.provider_requires_api_key("lm_studio") is False
581 assert model_config.provider_requires_api_key("llama_cpp") is False
582 assert model_config.provider_requires_api_key("omlx") is False
583 assert model_config.provider_requires_api_key("vllm") is False
584 assert model_config.provider_requires_api_key("other") is False
585 assert model_config.provider_requires_api_key("ollama_cloud") is True
586
587
588 def test_local_provider_defaults_are_docker_friendly():
589 import yaml
590
591 provider_path = PROJECT_ROOT / "conf" / "model_providers.yaml"
592 provider_config = yaml.safe_load(provider_path.read_text(encoding="utf-8"))
593
594 assert provider_config["chat"]["lm_studio"]["kwargs"]["api_base"] == (
595 "http://host.docker.internal:1234/v1"
596 )
597 assert provider_config["chat"]["lm_studio"]["kwargs"]["api_key"] == "lm-studio"
598 assert provider_config["chat"]["lm_studio"]["models_list"]["default_base"] == (
599 "http://host.docker.internal:1234"
600 )
601 assert provider_config["chat"]["llama_cpp"]["litellm_provider"] == "hosted_vllm"
602 assert provider_config["chat"]["llama_cpp"]["kwargs"]["api_base"] == (
603 "http://host.docker.internal:8080/v1"
604 )
605 assert provider_config["chat"]["llama_cpp"]["kwargs"]["api_key"] == "llama-cpp"
606 assert provider_config["chat"]["llama_cpp"]["models_list"]["default_base"] == (
607 "http://host.docker.internal:8080"
608 )
609 assert provider_config["chat"]["llama_cpp"]["models_list"]["endpoint_url"] == "/v1/models"
610 assert provider_config["chat"]["ollama"]["kwargs"]["api_base"] == (
611 "http://host.docker.internal:11434"
612 )
613 assert provider_config["chat"]["ollama"]["models_list"]["default_base"] == (
614 "http://host.docker.internal:11434"
615 )
616 assert provider_config["chat"]["omlx"]["litellm_provider"] == "hosted_vllm"
617 assert provider_config["chat"]["omlx"]["kwargs"]["api_base"] == (
618 "http://host.docker.internal:8000/v1"
619 )
620 assert provider_config["chat"]["omlx"]["kwargs"]["api_key"] == "omlx"
621 assert provider_config["chat"]["omlx"]["models_list"]["default_base"] == (
622 "http://host.docker.internal:8000"
623 )
624 assert provider_config["chat"]["omlx"]["models_list"]["endpoint_url"] == "/v1/models"
625 assert provider_config["chat"]["vllm"]["litellm_provider"] == "hosted_vllm"
626 assert provider_config["chat"]["vllm"]["kwargs"]["api_base"] == (
627 "http://host.docker.internal:8000/v1"
628 )
629 assert provider_config["chat"]["vllm"]["kwargs"]["api_key"] == "vllm"
630 assert provider_config["chat"]["vllm"]["models_list"]["default_base"] == (
631 "http://host.docker.internal:8000"
632 )
633 assert provider_config["chat"]["vllm"]["models_list"]["endpoint_url"] == "/v1/models"
634 assert provider_config["embedding"]["lm_studio"]["kwargs"]["api_base"] == (
635 "http://host.docker.internal:1234/v1"
636 )
637 assert provider_config["embedding"]["lm_studio"]["kwargs"]["api_key"] == "lm-studio"
638 assert provider_config["embedding"]["llama_cpp"]["litellm_provider"] == "hosted_vllm"
639 assert provider_config["embedding"]["llama_cpp"]["kwargs"]["api_base"] == (
640 "http://host.docker.internal:8080/v1"
641 )
642 assert provider_config["embedding"]["llama_cpp"]["kwargs"]["api_key"] == "llama-cpp"
643 assert provider_config["embedding"]["ollama"]["kwargs"]["api_base"] == (
644 "http://host.docker.internal:11434"
645 )
646 assert provider_config["embedding"]["omlx"]["litellm_provider"] == "hosted_vllm"
647 assert provider_config["embedding"]["omlx"]["kwargs"]["api_base"] == (
648 "http://host.docker.internal:8000/v1"
649 )
650 assert provider_config["embedding"]["omlx"]["kwargs"]["api_key"] == "omlx"
651 assert provider_config["embedding"]["vllm"]["litellm_provider"] == "hosted_vllm"
652 assert provider_config["embedding"]["vllm"]["kwargs"]["api_base"] == (
653 "http://host.docker.internal:8000/v1"
654 )
655 assert provider_config["embedding"]["vllm"]["kwargs"]["api_key"] == "vllm"
656
657
658 def test_local_provider_runtime_defaults_and_overrides(monkeypatch):
659 monkeypatch.setattr(models, "get_api_key", lambda provider: "None")
660
661 lm_chat = models.get_chat_model("lm_studio", "local-chat-model")
662 assert lm_chat.model_name == "lm_studio/local-chat-model"
663 assert lm_chat.kwargs["api_base"] == "http://host.docker.internal:1234/v1"
664 assert lm_chat.kwargs["api_key"] == "lm-studio"
665
666 lm_embedding = models.get_embedding_model("lm_studio", "nomic-embed-text")
667 assert lm_embedding.model_name == "lm_studio/nomic-embed-text"
668 assert lm_embedding.kwargs["api_base"] == "http://host.docker.internal:1234/v1"
669 assert lm_embedding.kwargs["api_key"] == "lm-studio"
670
671 custom_lm_embedding = models.get_embedding_model(
672 "lm_studio",
673 "nomic-embed-text",
674 api_base="http://127.0.0.1:1234/v1",
675 api_key="real-local-key",
676 )
677 assert custom_lm_embedding.kwargs["api_base"] == "http://127.0.0.1:1234/v1"
678 assert custom_lm_embedding.kwargs["api_key"] == "real-local-key"
679
680 llama_cpp_chat = models.get_chat_model("llama_cpp", "local-chat-model")
681 assert llama_cpp_chat.model_name == "hosted_vllm/local-chat-model"
682 assert llama_cpp_chat.kwargs["api_base"] == "http://host.docker.internal:8080/v1"
683 assert llama_cpp_chat.kwargs["api_key"] == "llama-cpp"
684
685 llama_cpp_embedding = models.get_embedding_model("llama_cpp", "local-embedding-model")
686 assert llama_cpp_embedding.model_name == "hosted_vllm/local-embedding-model"
687 assert llama_cpp_embedding.kwargs["api_base"] == "http://host.docker.internal:8080/v1"
688 assert llama_cpp_embedding.kwargs["api_key"] == "llama-cpp"
689
690 ollama_embedding = models.get_embedding_model("ollama", "nomic-embed-text")
691 assert ollama_embedding.model_name == "ollama/nomic-embed-text"
692 assert ollama_embedding.kwargs["api_base"] == "http://host.docker.internal:11434"
693 assert "api_key" not in ollama_embedding.kwargs
694
695 omlx_chat = models.get_chat_model("omlx", "local-chat-model")
696 assert omlx_chat.model_name == "hosted_vllm/local-chat-model"
697 assert omlx_chat.kwargs["api_base"] == "http://host.docker.internal:8000/v1"
698 assert omlx_chat.kwargs["api_key"] == "omlx"
699
700 omlx_embedding = models.get_embedding_model("omlx", "local-embedding-model")
701 assert omlx_embedding.model_name == "hosted_vllm/local-embedding-model"
702 assert omlx_embedding.kwargs["api_base"] == "http://host.docker.internal:8000/v1"
703 assert omlx_embedding.kwargs["api_key"] == "omlx"
704
705 custom_omlx_chat = models.get_chat_model(
706 "omlx",
707 "local-chat-model",
708 api_base="http://127.0.0.1:8000/v1",
709 api_key="real-local-key",
710 )
711 assert custom_omlx_chat.kwargs["api_base"] == "http://127.0.0.1:8000/v1"
712 assert custom_omlx_chat.kwargs["api_key"] == "real-local-key"
713
714 vllm_chat = models.get_chat_model("vllm", "local-chat-model")
715 assert vllm_chat.model_name == "hosted_vllm/local-chat-model"
716 assert vllm_chat.kwargs["api_base"] == "http://host.docker.internal:8000/v1"
717 assert vllm_chat.kwargs["api_key"] == "vllm"
718
719 vllm_embedding = models.get_embedding_model("vllm", "local-embedding-model")
720 assert vllm_embedding.model_name == "hosted_vllm/local-embedding-model"
721 assert vllm_embedding.kwargs["api_base"] == "http://host.docker.internal:8000/v1"
722 assert vllm_embedding.kwargs["api_key"] == "vllm"
723
724 custom_vllm_chat = models.get_chat_model(
725 "vllm",
726 "local-chat-model",
727 api_base="http://127.0.0.1:8001/v1",
728 api_key="real-local-key",
729 )
730 assert custom_vllm_chat.kwargs["api_base"] == "http://127.0.0.1:8001/v1"
731 assert custom_vllm_chat.kwargs["api_key"] == "real-local-key"
732
733
734 def test_openai_compatible_embedding_keeps_gateway_model_string(monkeypatch):
735 """Gateway model ids must reach LiteLLM with an explicit `openai/` provider.
736
737 An OpenAI-compatible gateway owns its own model namespace, so ids such as
738 `nvidia/...` or `auto/embedding` are model names, not provider prefixes.
739 Without the prefix LiteLLM re-parses the first segment as a provider and
740 either raises "LLM Provider NOT provided" or routes to the wrong provider,
741 mangling the model id before the gateway ever sees it.
742 """
743 monkeypatch.setattr(models, "get_api_key", lambda provider: "None")
744
745 gateway = "https://gateway.example/v1"
746 for model in (
747 "nvidia/llama-nemotron-embed-vl-1b-v2:free",
748 "openrouter/openai/text-embedding-3-small",
749 "auto/embedding",
750 ):
751 embedding = models.get_embedding_model(
752 "other", model, api_base=gateway, api_key="gateway-key"
753 )
754 assert embedding.model_name == f"openai/{model}"
755 assert embedding.kwargs["api_base"] == gateway
756
757 # The bundled OpenRouter embedding provider is affected the same way: it
758 # resolves to litellm_provider `openai` against OpenRouter's api_base, so an
759 # OpenRouter-style id has to survive intact. Previously `openai/<model>` was
760 # handed to LiteLLM bare, which consumed the `openai/` segment and forwarded
761 # only `<model>` to OpenRouter.
762 for model in (
763 "openai/text-embedding-3-small",
764 "nvidia/llama-nemotron-embed-vl-1b-v2:free",
765 ):
766 openrouter_embedding = models.get_embedding_model("openrouter", model)
767 assert openrouter_embedding.model_name == f"openai/{model}"
768 assert openrouter_embedding.kwargs["api_base"] == "https://openrouter.ai/api/v1"
769
770 # Plain OpenAI behaviour is unchanged: LiteLLM strips the `openai/` prefix
771 # and forwards the bare model id, exactly as it did without a prefix.
772 openai_embedding = models.get_embedding_model("openai", "text-embedding-3-small")
773 assert openai_embedding.model_name == "openai/text-embedding-3-small"
774
775 # Providers that do not resolve to `openai` keep their existing prefix.
776 ollama_embedding = models.get_embedding_model("ollama", "nomic-embed-text")
777 assert ollama_embedding.model_name == "ollama/nomic-embed-text"
778
779
780 def test_embedding_config_repairs_sentence_transformer_aliases(monkeypatch):
781 from plugins._model_config.helpers import model_config
782
783 cases = [
784 (
785 {"provider": "", "name": "sentence-transformers/all-MiniLM-L6-v2"},
786 ("huggingface", "sentence-transformers/all-MiniLM-L6-v2"),
787 ),
788 (
789 {"provider": "openai", "name": "sentence-transformers/all-MiniLM-L6-v2"},
790 ("huggingface", "sentence-transformers/all-MiniLM-L6-v2"),
791 ),
792 (
793 {
794 "provider": "other",
795 "name": "huggingface/sentence-transformers/all-MiniLM-L6-v2",
796 },
797 ("huggingface", "sentence-transformers/all-MiniLM-L6-v2"),
798 ),
799 (
800 {"provider": "huggingface", "name": "all-MiniLM-L6-v2"},
801 ("huggingface", "sentence-transformers/all-MiniLM-L6-v2"),
802 ),
803 ]
804
805 for raw_embedding, expected in cases:
806 monkeypatch.setattr(
807 model_config,
808 "get_config",
809 lambda *args, raw_embedding=raw_embedding, **kwargs: {
810 "embedding_model": raw_embedding
811 },
812 )
813 cfg = model_config.get_embedding_model_config_object()
814
815 assert (cfg.provider, cfg.name) == expected
816
817 monkeypatch.setattr(
818 model_config,
819 "get_config",
820 lambda *args, **kwargs: {
821 "embedding_model": {
822 "provider": "openai",
823 "name": "text-embedding-3-small",
824 }
825 },
826 )
827 cfg = model_config.get_embedding_model_config_object()
828
829 assert (cfg.provider, cfg.name) == ("openai", "text-embedding-3-small")
830
831 monkeypatch.setattr(
832 model_config,
833 "get_config",
834 lambda *args, **kwargs: {
835 "embedding_model": {
836 "provider": "openai",
837 "name": "sentence-transformers/all-MiniLM-L6-v2",
838 }
839 },
840 )
841 assert model_config.get_missing_api_key_providers() == []
842
843
844 def test_docker_compose_maps_host_docker_internal_for_local_models():
845 import yaml
846
847 compose_path = PROJECT_ROOT / "docker" / "run" / "docker-compose.yml"
848 compose = yaml.safe_load(compose_path.read_text(encoding="utf-8"))
849 service = compose["services"]["agent-zero"]
850
851 assert "host.docker.internal:host-gateway" in service["extra_hosts"]