Skip to content

Commit 1163e36

Browse files
fix: repair LLM test-connection and make silent model/template fallbacks visible (#281)
* fix: repair admin LLM test-connection endpoint POST /settings/test-connection calls LLMService.test_connection(), which has never existed, so the endpoint always raised AttributeError and the UI reported a generic failure regardless of whether the key was valid. Add the missing method. It probes the provider's models endpoint without spending completion tokens and without falling back to other providers, so a failure identifies this provider/base URL/key combination specifically. Credentials are sent only in headers, never in the URL. OpenRouter's /models is public and therefore cannot validate a key, so use /auth/key there and flag provisioning/management keys, which pass /auth/key but are rejected by chat completions. Also surface the template fallback from indicator aiGenerate through debug.generation_error: the built-in template passes validation, so previously a failed model call was indistinguishable from a successful response and the client reported success. Refs #280 * test: cover LLM test_connection probe Cases: unconfigured key, OpenRouter authenticated endpoint selection, the provisioning/management key trap, HTTP failure surfacing, and that the Google key is sent as a header rather than in the URL. * feat: report the model that actually answered an LLM call A model-level fallback (requested model -> configured default -> static fallback) was only observable in the provider dashboard, so a misconfigured or unavailable model id silently produced answers from a different model while the client reported success. LLMService now records last_model_used / last_provider_used / model_fallback_used, the indicator generation path carries them in its edit plan, and aiGenerate returns them as generation_model / generation_provider / generation_model_fallback in the streamed debug frame. --------- Co-authored-by: TIANHE <TIANHE@GMAIL.COM>
1 parent 323cca9 commit 1163e36

6 files changed

Lines changed: 286 additions & 12 deletions

File tree

‎backend_api_python/app/routes/indicator.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1188,6 +1188,15 @@ def stream():
11881188

11891189
code_text, debug_info, edit_plan = _generate_final_code()
11901190

1191+
# Signal the template fallback to the client; otherwise a failed model
1192+
# call is indistinguishable from a successful template-shaped answer.
1193+
if edit_plan.get("error"):
1194+
debug_info["generation_error"] = str(edit_plan["error"])[:500]
1195+
if edit_plan.get("model"):
1196+
debug_info["generation_model"] = edit_plan["model"]
1197+
debug_info["generation_provider"] = edit_plan.get("provider") or ""
1198+
debug_info["generation_model_fallback"] = bool(edit_plan.get("model_fallback"))
1199+
11911200
if workspace_context:
11921201
validation = _validate_indicator_code_internal(code_text)
11931202
validation["edit_plan"] = edit_plan

‎backend_api_python/app/routes/settings.py‎

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2451,13 +2451,10 @@ def test_connection():
24512451
service = data.get('service')
24522452

24532453
if service == 'openrouter':
2454-
from app.services.llm import LLMService
2455-
llm = LLMService()
2456-
result = llm.test_connection()
2457-
if result:
2458-
return jsonify({'code': 1, 'msg': 'OpenRouter connection successful'})
2459-
else:
2460-
return jsonify({'code': 0, 'msg': 'OpenRouter connection failed'})
2454+
from app.services.llm import LLMProvider, LLMService
2455+
llm = LLMService(provider=LLMProvider.OPENROUTER)
2456+
ok, detail = llm.test_connection(LLMProvider.OPENROUTER)
2457+
return jsonify({'code': 1 if ok else 0, 'msg': detail})
24612458

24622459
elif service == 'finnhub':
24632460
import requests

‎backend_api_python/app/services/indicator_ai_generation.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ def generate_indicator_code_candidate(
156156
)
157157
if use_patch_response:
158158
try:
159-
return apply_model_code_edits(existing, content)
159+
code, plan = apply_model_code_edits(existing, content)
160160
except CodeEditError as exc:
161161
logger.warning("indicator model patch rejected, retrying full candidate: %s", exc)
162162
fallback_prompt = (
@@ -182,7 +182,13 @@ def generate_indicator_code_candidate(
182182
"operation": "generate_candidate",
183183
"patch_error": str(exc),
184184
}
185+
code = _strip_code_fences(content) or template_factory()
185186
else:
186187
plan = {"executor": "model", "operation": "generate_candidate"}
188+
code = _strip_code_fences(content) or template_factory()
187189

188-
return _strip_code_fences(content) or template_factory(), plan
190+
# A model-level fallback is otherwise only visible in the provider dashboard.
191+
plan["model"] = llm.last_model_used
192+
plan["provider"] = llm.last_provider_used
193+
plan["model_fallback"] = llm.model_fallback_used
194+
return code, plan

‎backend_api_python/app/services/llm.py‎

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,11 @@ def __init__(self, provider: str = None):
178178
provider: Override the default provider (openrouter, openai, google, deepseek, grok, atlascloud, custom, minimax)
179179
"""
180180
self._provider_override = provider
181+
# Populated by call_llm_api() so callers can report what actually answered;
182+
# a model-level fallback is otherwise only visible in provider dashboards.
183+
self.last_model_used = ""
184+
self.last_provider_used = ""
185+
self.model_fallback_used = False
181186

182187
@property
183188
def provider(self) -> LLMProvider:
@@ -299,6 +304,66 @@ def is_configured(self, provider: LLMProvider = None) -> bool:
299304
return bool((self.get_base_url(p) or "").strip())
300305
return p == LLMProvider.LITELLM
301306

307+
def _record_model_use(
308+
self,
309+
used_model: str,
310+
requested_model: str,
311+
provider: LLMProvider,
312+
) -> None:
313+
"""Remember which model/provider answered the most recent call."""
314+
self.last_model_used = used_model
315+
self.last_provider_used = provider.value
316+
self.model_fallback_used = used_model != requested_model
317+
318+
def test_connection(self, provider: LLMProvider = None) -> tuple[bool, str]:
319+
"""Probe the configured provider's models endpoint.
320+
321+
Intentionally does not spend completion tokens and does not fall back to
322+
other providers, so a failure here means *this* provider/base URL/key
323+
combination is unreachable or rejected. Returns ``(ok, detail)``.
324+
325+
Credentials are only ever sent in headers, never in the URL, so provider
326+
errors and request exceptions cannot leak the key.
327+
"""
328+
p = provider or self.provider
329+
model = self.get_default_model(p)
330+
if not self.is_configured(p):
331+
return False, f"{p.value}: API key is not configured"
332+
key = (self.get_api_key(p) or "").strip()
333+
base = (self.get_base_url(p) or "").rstrip("/")
334+
headers: Dict[str, str] = {}
335+
if p == LLMProvider.OPENROUTER:
336+
# OpenRouter's /models is public, so it cannot validate the key.
337+
# /auth/key requires the bearer token and 401s on a bad key.
338+
url = f"{base}/auth/key"
339+
headers["Authorization"] = f"Bearer {key}"
340+
elif p == LLMProvider.GOOGLE:
341+
url = f"{base}/models"
342+
headers["x-goog-api-key"] = key
343+
else:
344+
url = f"{base}/models"
345+
headers["Authorization"] = f"Bearer {key}"
346+
try:
347+
resp = requests.get(url, headers=headers, timeout=15)
348+
except Exception as exc:
349+
return False, f"{p.value}: {redact_secrets(exc)}"
350+
if resp.status_code == 200:
351+
if p == LLMProvider.OPENROUTER:
352+
# A provisioning/management key passes /auth/key but is rejected
353+
# (401 "User not found") by chat completions, so flag it here.
354+
try:
355+
info = (resp.json() or {}).get("data") or {}
356+
except Exception:
357+
info = {}
358+
if info.get("is_provisioning_key") or info.get("is_management_key"):
359+
return False, (
360+
f"{p.value}: this is a provisioning/management key, "
361+
"which cannot run inference. Create a normal API key instead."
362+
)
363+
return True, f"{p.value} reachable · model={model}"
364+
body = redact_secrets(resp.text[:200]) if resp.text else ""
365+
return False, f"{p.value}: HTTP {resp.status_code} {body}".strip()
366+
302367
# Legacy properties for backward compatibility
303368
@property
304369
def api_key(self):
@@ -1230,23 +1295,25 @@ def call_llm_api(self, messages: list, model: str = None, temperature: float = 0
12301295
for current_model in models_to_try:
12311296
try:
12321297
if p == LLMProvider.LITELLM:
1233-
return self._call_litellm(
1298+
result = self._call_litellm(
12341299
messages, current_model, temperature,
12351300
api_key, base_url, timeout,
12361301
use_json_mode=use_json_mode
12371302
)
12381303
elif p == LLMProvider.GOOGLE:
1239-
return self._call_google_gemini(
1304+
result = self._call_google_gemini(
12401305
messages, current_model, temperature,
12411306
api_key, base_url, timeout
12421307
)
12431308
else:
12441309
# OpenAI-compatible providers
1245-
return self._call_openai_compatible(
1310+
result = self._call_openai_compatible(
12461311
messages, current_model, temperature,
12471312
api_key, base_url, timeout,
12481313
use_json_mode=use_json_mode
12491314
)
1315+
self._record_model_use(current_model, models_to_try[0], p)
1316+
return result
12501317

12511318
except LLMAPIError as e:
12521319
status_code = e.status_code

‎backend_api_python/tests/test_llm_litellm_provider.py‎

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -841,3 +841,163 @@ def test_call_llm_api_redacts_request_error_before_reraising(monkeypatch):
841841
assert "Bearer <redacted>" in message
842842
assert exc_info.value.__cause__ is None
843843
assert exc_info.value.retryable is True
844+
845+
846+
class _FakeHttpGetResponse:
847+
def __init__(self, status_code, payload=None, text=""):
848+
self.status_code = status_code
849+
self._payload = payload
850+
self.text = text
851+
852+
def json(self):
853+
if self._payload is None:
854+
raise ValueError("not json")
855+
return self._payload
856+
857+
858+
def test_test_connection_requires_configured_key(monkeypatch):
859+
service = LLMService(provider="openrouter")
860+
monkeypatch.setattr(service, "get_api_key", lambda provider=None: "")
861+
862+
ok, detail = service.test_connection()
863+
864+
assert ok is False
865+
assert "not configured" in detail
866+
867+
868+
def test_test_connection_uses_authenticated_endpoint_for_openrouter(monkeypatch):
869+
captured = {}
870+
service = LLMService(provider="openrouter")
871+
monkeypatch.setattr(service, "get_api_key", lambda provider=None: "openrouter-key")
872+
monkeypatch.setattr(service, "get_base_url", lambda provider=None: "https://openrouter.ai/api/v1")
873+
874+
def fake_get(url, headers=None, timeout=None):
875+
captured.update({"url": url, "headers": headers, "timeout": timeout})
876+
return _FakeHttpGetResponse(200, {"data": {}})
877+
878+
monkeypatch.setattr("app.services.llm.requests.get", fake_get)
879+
880+
ok, detail = service.test_connection()
881+
882+
assert ok is True
883+
# /models is public on OpenRouter, so it cannot validate a key.
884+
assert captured["url"] == "https://openrouter.ai/api/v1/auth/key"
885+
assert captured["headers"]["Authorization"] == "Bearer openrouter-key"
886+
assert "reachable" in detail
887+
888+
889+
def test_test_connection_flags_openrouter_provisioning_key(monkeypatch):
890+
service = LLMService(provider="openrouter")
891+
monkeypatch.setattr(service, "get_api_key", lambda provider=None: "provisioning-key")
892+
monkeypatch.setattr(service, "get_base_url", lambda provider=None: "https://openrouter.ai/api/v1")
893+
monkeypatch.setattr(
894+
"app.services.llm.requests.get",
895+
lambda *args, **kwargs: _FakeHttpGetResponse(200, {"data": {"is_provisioning_key": True}}),
896+
)
897+
898+
ok, detail = service.test_connection()
899+
900+
assert ok is False
901+
# Such a key passes /auth/key but chat completions reject it with 401.
902+
assert "provisioning/management" in detail
903+
904+
905+
def test_test_connection_reports_http_failure(monkeypatch):
906+
secret = "sk-response-secret-123"
907+
service = LLMService(provider="openrouter")
908+
monkeypatch.setattr(service, "get_api_key", lambda provider=None: secret)
909+
monkeypatch.setattr(service, "get_base_url", lambda provider=None: "https://openrouter.ai/api/v1")
910+
monkeypatch.setattr(
911+
"app.services.llm.requests.get",
912+
lambda *args, **kwargs: _FakeHttpGetResponse(
913+
401,
914+
None,
915+
f'{{"error":"unauthorized ?api_key={secret}"}}',
916+
),
917+
)
918+
919+
ok, detail = service.test_connection()
920+
921+
assert ok is False
922+
assert "HTTP 401" in detail
923+
assert secret not in detail
924+
assert "api_key=<redacted>" in detail
925+
926+
927+
def test_test_connection_never_puts_google_key_in_url(monkeypatch):
928+
captured = {}
929+
service = LLMService(provider="google")
930+
monkeypatch.setattr(service, "get_api_key", lambda provider=None: "google-key")
931+
monkeypatch.setattr(
932+
service,
933+
"get_base_url",
934+
lambda provider=None: "https://generativelanguage.googleapis.com/v1beta",
935+
)
936+
937+
def fake_get(url, headers=None, timeout=None):
938+
captured.update({"url": url, "headers": headers})
939+
return _FakeHttpGetResponse(200, {})
940+
941+
monkeypatch.setattr("app.services.llm.requests.get", fake_get)
942+
943+
ok, _ = service.test_connection()
944+
945+
assert ok is True
946+
assert captured["headers"]["x-goog-api-key"] == "google-key"
947+
assert "google-key" not in captured["url"]
948+
949+
950+
def _openrouter_service_with(monkeypatch, default_model="deepseek/deepseek-v4.1-flash"):
951+
service = LLMService(provider="openrouter")
952+
monkeypatch.setattr(service, "get_api_key", lambda provider=None: "openrouter-key")
953+
monkeypatch.setattr(
954+
service, "get_base_url", lambda provider=None: "https://openrouter.ai/api/v1"
955+
)
956+
monkeypatch.setattr(service, "get_default_model", lambda provider=None: default_model)
957+
return service
958+
959+
960+
def test_model_fallback_is_recorded_when_primary_model_fails(monkeypatch):
961+
attempted = []
962+
service = _openrouter_service_with(monkeypatch)
963+
964+
def fake_call(messages, model, *args, **kwargs):
965+
attempted.append(model)
966+
if model == "does/not-exist":
967+
raise LLMAPIError(
968+
"OpenRouter API 400 (model=does/not-exist): not a valid model ID",
969+
status_code=400,
970+
)
971+
return "generated code"
972+
973+
monkeypatch.setattr(service, "_call_openai_compatible", fake_call)
974+
975+
out = service.call_llm_api(
976+
[{"role": "user", "content": "hi"}],
977+
model="does/not-exist",
978+
use_json_mode=False,
979+
try_alternative_providers=False,
980+
)
981+
982+
assert out == "generated code"
983+
assert attempted == ["does/not-exist", "deepseek/deepseek-v4.1-flash"]
984+
# The caller must be able to tell that a different model answered.
985+
assert service.last_model_used == "deepseek/deepseek-v4.1-flash"
986+
assert service.last_provider_used == "openrouter"
987+
assert service.model_fallback_used is True
988+
989+
990+
def test_model_fallback_flag_is_false_when_primary_model_answers(monkeypatch):
991+
service = _openrouter_service_with(monkeypatch)
992+
monkeypatch.setattr(service, "_call_openai_compatible", lambda *args, **kwargs: "generated code")
993+
994+
out = service.call_llm_api(
995+
[{"role": "user", "content": "hi"}],
996+
model="deepseek/deepseek-v4.1-flash",
997+
use_json_mode=False,
998+
try_alternative_providers=False,
999+
)
1000+
1001+
assert out == "generated code"
1002+
assert service.last_model_used == "deepseek/deepseek-v4.1-flash"
1003+
assert service.model_fallback_used is False
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from flask import Flask
2+
3+
4+
def _unwrap_handler(handler):
5+
while hasattr(handler, "__wrapped__"):
6+
handler = handler.__wrapped__
7+
return handler
8+
9+
10+
def test_openrouter_connection_route_pins_the_requested_provider(monkeypatch):
11+
import app.routes.settings as settings_route
12+
import app.services.llm as llm_module
13+
14+
calls = []
15+
16+
class FakeLLMService:
17+
def __init__(self, provider=None):
18+
calls.append(("init", provider))
19+
20+
def test_connection(self, provider=None):
21+
calls.append(("test", provider))
22+
return True, "ok"
23+
24+
monkeypatch.setattr(llm_module, "LLMService", FakeLLMService)
25+
app = Flask(__name__)
26+
handler = _unwrap_handler(settings_route.test_connection)
27+
28+
with app.test_request_context(json={"service": "openrouter"}):
29+
response = handler()
30+
31+
assert response.get_json()["code"] == 1
32+
assert calls == [
33+
("init", llm_module.LLMProvider.OPENROUTER),
34+
("test", llm_module.LLMProvider.OPENROUTER),
35+
]

0 commit comments

Comments
 (0)