test(llm): pin 401 short-circuit + 400 try-next-model behavior (red)
Browse files- tests/llm/test_explainer.py +122 -0
tests/llm/test_explainer.py
CHANGED
|
@@ -128,3 +128,125 @@ class TestModalityDispatch:
|
|
| 128 |
# Should not raise; should produce a non-empty rationale
|
| 129 |
assert result["source"] == "template"
|
| 130 |
assert result["rationale"], "rationale must be non-empty"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
# Should not raise; should produce a non-empty rationale
|
| 129 |
assert result["source"] == "template"
|
| 130 |
assert result["rationale"], "rationale must be non-empty"
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
class TestAuthFailureShortCircuits:
|
| 134 |
+
"""A 401 from OpenRouter means the key is unauthorized — every model
|
| 135 |
+
in the chain will fail the same way, so we must short-circuit instead
|
| 136 |
+
of burning the full chain on every request."""
|
| 137 |
+
|
| 138 |
+
def test_401_short_circuits_to_template_after_one_attempt(self, monkeypatch):
|
| 139 |
+
from src.llm import explainer as ex
|
| 140 |
+
from openai import APIStatusError
|
| 141 |
+
import httpx
|
| 142 |
+
|
| 143 |
+
monkeypatch.delenv("NEUROBRIDGE_DISABLE_LLM", raising=False)
|
| 144 |
+
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-v1-deliberately-bad")
|
| 145 |
+
|
| 146 |
+
attempts: list[str] = []
|
| 147 |
+
|
| 148 |
+
def _raise_401(**kwargs):
|
| 149 |
+
attempts.append(kwargs["model"])
|
| 150 |
+
req = httpx.Request("POST", "https://openrouter.ai/api/v1/chat/completions")
|
| 151 |
+
resp = httpx.Response(status_code=401, request=req)
|
| 152 |
+
raise APIStatusError(message="No auth credentials found", response=resp, body={})
|
| 153 |
+
|
| 154 |
+
class _StubCompletions:
|
| 155 |
+
create = staticmethod(_raise_401)
|
| 156 |
+
|
| 157 |
+
class _StubChat:
|
| 158 |
+
completions = _StubCompletions()
|
| 159 |
+
|
| 160 |
+
class _StubClient:
|
| 161 |
+
chat = _StubChat()
|
| 162 |
+
def __init__(self, **kwargs):
|
| 163 |
+
pass
|
| 164 |
+
|
| 165 |
+
# Must patch on the `openai` module — the explainer does
|
| 166 |
+
# `from openai import OpenAI` *inside* the function (see
|
| 167 |
+
# src/llm/explainer.py:269-275), so any module-level attribute
|
| 168 |
+
# on `src.llm.explainer` would be a no-op.
|
| 169 |
+
monkeypatch.setattr("openai.OpenAI", _StubClient)
|
| 170 |
+
|
| 171 |
+
out = ex._llm_explain(_payload(), modality="bbb")
|
| 172 |
+
|
| 173 |
+
assert out is None, "401 must surface as a None return (caller falls back to template)"
|
| 174 |
+
assert len(attempts) == 1, f"401 must short-circuit; tried {len(attempts)} models: {attempts}"
|
| 175 |
+
|
| 176 |
+
def test_explain_returns_template_source_on_401(self, monkeypatch):
|
| 177 |
+
from src.llm import explainer as ex
|
| 178 |
+
from openai import APIStatusError
|
| 179 |
+
import httpx
|
| 180 |
+
|
| 181 |
+
monkeypatch.delenv("NEUROBRIDGE_DISABLE_LLM", raising=False)
|
| 182 |
+
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-v1-deliberately-bad")
|
| 183 |
+
|
| 184 |
+
def _raise_401(**kwargs):
|
| 185 |
+
req = httpx.Request("POST", "https://openrouter.ai/api/v1/chat/completions")
|
| 186 |
+
raise APIStatusError(
|
| 187 |
+
message="auth",
|
| 188 |
+
response=httpx.Response(401, request=req),
|
| 189 |
+
body={},
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
class _Comp:
|
| 193 |
+
create = staticmethod(_raise_401)
|
| 194 |
+
|
| 195 |
+
class _Chat:
|
| 196 |
+
completions = _Comp()
|
| 197 |
+
|
| 198 |
+
class _Client:
|
| 199 |
+
chat = _Chat()
|
| 200 |
+
def __init__(self, **kwargs):
|
| 201 |
+
pass
|
| 202 |
+
|
| 203 |
+
monkeypatch.setattr("openai.OpenAI", _Client)
|
| 204 |
+
|
| 205 |
+
result = ex.explain(_payload(), modality="bbb")
|
| 206 |
+
|
| 207 |
+
assert result["source"] == "template"
|
| 208 |
+
assert result["model"] is None
|
| 209 |
+
assert result["rationale"], "rationale must never be empty"
|
| 210 |
+
|
| 211 |
+
def test_400_advances_to_next_model_instead_of_short_circuiting(self, monkeypatch):
|
| 212 |
+
"""A 400 from one model is a prompt-shape mismatch with THAT model
|
| 213 |
+
(some models reject system roles, etc.) — try the next, don't give up."""
|
| 214 |
+
from src.llm import explainer as ex
|
| 215 |
+
from openai import APIStatusError
|
| 216 |
+
import httpx
|
| 217 |
+
|
| 218 |
+
monkeypatch.delenv("NEUROBRIDGE_DISABLE_LLM", raising=False)
|
| 219 |
+
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-v1-anything")
|
| 220 |
+
|
| 221 |
+
attempts: list[str] = []
|
| 222 |
+
# Force a known multi-model chain so we can count attempts deterministically
|
| 223 |
+
monkeypatch.setenv("OPENROUTER_FREE_MODELS", "model-a:free,model-b:free,model-c:free")
|
| 224 |
+
|
| 225 |
+
def _raise_400(**kwargs):
|
| 226 |
+
attempts.append(kwargs["model"])
|
| 227 |
+
req = httpx.Request("POST", "https://openrouter.ai/api/v1/chat/completions")
|
| 228 |
+
raise APIStatusError(
|
| 229 |
+
message="bad request",
|
| 230 |
+
response=httpx.Response(400, request=req),
|
| 231 |
+
body={},
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
class _Comp:
|
| 235 |
+
create = staticmethod(_raise_400)
|
| 236 |
+
|
| 237 |
+
class _Chat:
|
| 238 |
+
completions = _Comp()
|
| 239 |
+
|
| 240 |
+
class _Client:
|
| 241 |
+
chat = _Chat()
|
| 242 |
+
def __init__(self, **kwargs):
|
| 243 |
+
pass
|
| 244 |
+
|
| 245 |
+
monkeypatch.setattr("openai.OpenAI", _Client)
|
| 246 |
+
|
| 247 |
+
out = ex._llm_explain(_payload(), modality="bbb")
|
| 248 |
+
|
| 249 |
+
assert out is None, "all models 400'd → must return None for template fallback"
|
| 250 |
+
assert attempts == ["model-a:free", "model-b:free", "model-c:free"], (
|
| 251 |
+
f"400 must advance to next model; got attempts={attempts}"
|
| 252 |
+
)
|