e0acc6155e
Build / build (push) Has been cancelled
Docker / build (push) Has been cancelled
Lint / ruff (push) Has been cancelled
Test / pytest (ubuntu-latest, 3.11) (push) Has been cancelled
Test / pytest (ubuntu-latest, 3.12) (push) Has been cancelled
Test / pytest (windows-latest, 3.11) (push) Has been cancelled
Test / pytest (windows-latest, 3.12) (push) Has been cancelled
305 lines
9.7 KiB
Python
305 lines
9.7 KiB
Python
"""Smoke test for the /api/models route mounted via langgraph.json's
|
|
``http`` field. We test the FastAPI app directly — no need to spin up
|
|
langgraph dev.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import patch
|
|
|
|
from langchain_core.messages import AIMessage, HumanMessage
|
|
from starlette.testclient import TestClient
|
|
|
|
from EvoScientist.config import EvoScientistConfig
|
|
from EvoScientist.langgraph_dev.http import app
|
|
|
|
client = TestClient(app)
|
|
|
|
|
|
def test_get_models_returns_entries_and_default():
|
|
mock_cfg = EvoScientistConfig(
|
|
model="claude-sonnet-4-6", provider="custom-anthropic"
|
|
)
|
|
with patch(
|
|
"EvoScientist.langgraph_dev.http.get_effective_config", return_value=mock_cfg
|
|
):
|
|
resp = client.get("/api/models")
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert "entries" in body
|
|
assert "default" in body
|
|
assert body["default"] == {
|
|
"name": "claude-sonnet-4-6",
|
|
"provider": "custom-anthropic",
|
|
}
|
|
assert isinstance(body["entries"], list)
|
|
assert len(body["entries"]) > 0
|
|
# Every entry has the three required keys
|
|
for entry in body["entries"]:
|
|
assert set(entry.keys()) == {"name", "model_id", "provider"}
|
|
assert isinstance(entry["name"], str)
|
|
assert entry["name"]
|
|
assert isinstance(entry["model_id"], str)
|
|
assert entry["model_id"]
|
|
assert isinstance(entry["provider"], str)
|
|
assert entry["provider"]
|
|
|
|
|
|
def test_entries_preserve_registry_order():
|
|
"""The picker uses position-in-list to rank providers per short name —
|
|
the JSON must preserve the order returned by ``list_models_by_provider``.
|
|
|
|
Stubs ``get_effective_config`` to keep the assertion focused on
|
|
registry order rather than implicitly depending on the ambient
|
|
deploy config.
|
|
"""
|
|
from EvoScientist.llm.models import list_models_by_provider
|
|
|
|
expected = [
|
|
{"name": n, "model_id": m, "provider": p}
|
|
for n, m, p in list_models_by_provider()
|
|
]
|
|
mock_cfg = EvoScientistConfig()
|
|
with patch(
|
|
"EvoScientist.langgraph_dev.http.get_effective_config", return_value=mock_cfg
|
|
):
|
|
resp = client.get("/api/models")
|
|
assert resp.json()["entries"] == expected
|
|
|
|
|
|
def test_default_passes_through_arbitrary_config_pair():
|
|
"""If config.yaml names a (name, provider) pair that isn't in the
|
|
registry (typo, retired model), still report it as default — the
|
|
picker labels it as the active selection regardless.
|
|
"""
|
|
mock_cfg = EvoScientistConfig(model="some-retired-name", provider="some-provider")
|
|
with patch(
|
|
"EvoScientist.langgraph_dev.http.get_effective_config", return_value=mock_cfg
|
|
):
|
|
resp = client.get("/api/models")
|
|
assert resp.json()["default"] == {
|
|
"name": "some-retired-name",
|
|
"provider": "some-provider",
|
|
}
|
|
|
|
|
|
def test_ollama_models_appended_when_base_url_configured():
|
|
"""Mirrors the TUI ``/model`` picker: when ``ollama_base_url`` is set,
|
|
locally-pulled Ollama models are appended after the static registry
|
|
as ``provider: "ollama"`` entries.
|
|
"""
|
|
mock_cfg = EvoScientistConfig(
|
|
model="claude-sonnet-4-6",
|
|
provider="custom-anthropic",
|
|
ollama_base_url="http://localhost:11434",
|
|
)
|
|
|
|
async def fake_discover(_base_url, *, timeout):
|
|
return ["llama3:8b", "mistral:7b"]
|
|
|
|
with (
|
|
patch(
|
|
"EvoScientist.langgraph_dev.http.get_effective_config",
|
|
return_value=mock_cfg,
|
|
),
|
|
patch(
|
|
"EvoScientist.llm.ollama_discovery.discover_ollama_models",
|
|
new=fake_discover,
|
|
),
|
|
):
|
|
body = client.get("/api/models").json()
|
|
|
|
# Assert the response is the static registry followed by the discovered
|
|
# Ollama suffix — robust to future static Ollama entries in the registry.
|
|
from EvoScientist.llm.models import list_models_by_provider
|
|
|
|
static_entries = [
|
|
{"name": n, "model_id": m, "provider": p}
|
|
for n, m, p in list_models_by_provider()
|
|
]
|
|
discovered_entries = [
|
|
{"name": "llama3:8b", "model_id": "llama3:8b", "provider": "ollama"},
|
|
{"name": "mistral:7b", "model_id": "mistral:7b", "provider": "ollama"},
|
|
]
|
|
assert body["entries"][: len(static_entries)] == static_entries
|
|
assert body["entries"][len(static_entries) :] == discovered_entries
|
|
# TUI's "Custom Ollama model…" sentinel is a widget-specific affordance —
|
|
# it must not appear on the HTTP surface.
|
|
assert not any(e["model_id"] == "__custom_ollama__" for e in body["entries"])
|
|
|
|
|
|
def test_ollama_discovery_skipped_when_base_url_absent():
|
|
"""No Ollama discovery should happen when ``ollama_base_url`` is unset —
|
|
matches the ``/model`` picker's gating. The probe function should never
|
|
be called in that case.
|
|
"""
|
|
mock_cfg = EvoScientistConfig(
|
|
model="claude-sonnet-4-6", provider="custom-anthropic"
|
|
)
|
|
calls: list[str | None] = []
|
|
|
|
async def spy_discover(base_url, *, timeout):
|
|
calls.append(base_url)
|
|
return []
|
|
|
|
with (
|
|
patch(
|
|
"EvoScientist.langgraph_dev.http.get_effective_config",
|
|
return_value=mock_cfg,
|
|
),
|
|
patch(
|
|
"EvoScientist.llm.ollama_discovery.discover_ollama_models",
|
|
new=spy_discover,
|
|
),
|
|
):
|
|
body = client.get("/api/models").json()
|
|
|
|
assert calls == []
|
|
# Response is exactly the static registry — no Ollama additions whatsoever.
|
|
from EvoScientist.llm.models import list_models_by_provider
|
|
|
|
assert body["entries"] == [
|
|
{"name": n, "model_id": m, "provider": p}
|
|
for n, m, p in list_models_by_provider()
|
|
]
|
|
|
|
|
|
def test_final_answer_extracts_latest_ai_text_blocks():
|
|
async def fake_metadata(_thread_id):
|
|
return {"updated_at": "2026-07-06T14:14:53+00:00"}
|
|
|
|
async def fake_messages(_thread_id):
|
|
return [
|
|
HumanMessage(content="question"),
|
|
AIMessage(content="old answer"),
|
|
AIMessage(
|
|
content=[
|
|
{"type": "reasoning", "text": "internal"},
|
|
{"type": "text", "text": "Part A"},
|
|
{"type": "tool_use", "name": "search"},
|
|
{"type": "output_text", "text": "Part B"},
|
|
]
|
|
),
|
|
]
|
|
|
|
async def fake_runtime(_request, _thread_id):
|
|
return {
|
|
"found": True,
|
|
"complete": True,
|
|
"completed_at": "2026-07-06T14:15:00+00:00",
|
|
}
|
|
|
|
with (
|
|
patch(
|
|
"EvoScientist.langgraph_dev.http._get_thread_metadata_for_http",
|
|
new=fake_metadata,
|
|
),
|
|
patch(
|
|
"EvoScientist.langgraph_dev.http._get_thread_messages_for_http",
|
|
new=fake_messages,
|
|
),
|
|
patch(
|
|
"EvoScientist.langgraph_dev.http._read_thread_runtime_state",
|
|
new=fake_runtime,
|
|
),
|
|
):
|
|
resp = client.get("/api/threads/thread-1/final-answer")
|
|
|
|
assert resp.status_code == 200
|
|
assert resp.json() == {
|
|
"content": "Part A\n\nPart B",
|
|
"completed_at": "2026-07-06T14:15:00+00:00",
|
|
"complete": True,
|
|
}
|
|
|
|
|
|
def test_final_answer_skips_tool_selection_json_text():
|
|
async def fake_metadata(_thread_id):
|
|
return {"updated_at": "2026-07-06T14:14:53+00:00"}
|
|
|
|
async def fake_messages(_thread_id):
|
|
return [
|
|
HumanMessage(content="question"),
|
|
AIMessage(content="stable answer"),
|
|
AIMessage(
|
|
content=(
|
|
'{"tools":["search_papers","get_abstract"]}'
|
|
'{"tools":["web_search_exa"]}'
|
|
)
|
|
),
|
|
]
|
|
|
|
async def fake_runtime(_request, _thread_id):
|
|
return {
|
|
"found": True,
|
|
"complete": True,
|
|
"completed_at": "2026-07-06T14:15:00+00:00",
|
|
}
|
|
|
|
with (
|
|
patch(
|
|
"EvoScientist.langgraph_dev.http._get_thread_metadata_for_http",
|
|
new=fake_metadata,
|
|
),
|
|
patch(
|
|
"EvoScientist.langgraph_dev.http._get_thread_messages_for_http",
|
|
new=fake_messages,
|
|
),
|
|
patch(
|
|
"EvoScientist.langgraph_dev.http._read_thread_runtime_state",
|
|
new=fake_runtime,
|
|
),
|
|
):
|
|
resp = client.get("/api/threads/thread-1/final-answer")
|
|
|
|
assert resp.status_code == 200
|
|
assert resp.json()["content"] == "stable answer"
|
|
|
|
|
|
def test_final_answer_returns_404_for_unknown_thread():
|
|
async def fake_metadata(_thread_id):
|
|
return None
|
|
|
|
with patch(
|
|
"EvoScientist.langgraph_dev.http._get_thread_metadata_for_http",
|
|
new=fake_metadata,
|
|
):
|
|
resp = client.get("/api/threads/missing/final-answer")
|
|
|
|
assert resp.status_code == 404
|
|
assert resp.json() == {"error": "thread not found"}
|
|
|
|
|
|
def test_final_answer_does_not_mark_complete_when_runtime_state_fails():
|
|
async def fake_metadata(_thread_id):
|
|
return {"updated_at": "2026-07-06T14:14:53+00:00"}
|
|
|
|
async def fake_messages(_thread_id):
|
|
return [AIMessage(content="checkpoint answer")]
|
|
|
|
async def fake_runtime(_request, _thread_id):
|
|
raise RuntimeError("langgraph runtime unavailable")
|
|
|
|
with (
|
|
patch(
|
|
"EvoScientist.langgraph_dev.http._get_thread_metadata_for_http",
|
|
new=fake_metadata,
|
|
),
|
|
patch(
|
|
"EvoScientist.langgraph_dev.http._get_thread_messages_for_http",
|
|
new=fake_messages,
|
|
),
|
|
patch(
|
|
"EvoScientist.langgraph_dev.http._read_thread_runtime_state",
|
|
new=fake_runtime,
|
|
),
|
|
):
|
|
resp = client.get("/api/threads/thread-1/final-answer")
|
|
|
|
assert resp.status_code == 200
|
|
assert resp.json() == {
|
|
"content": "checkpoint answer",
|
|
"completed_at": None,
|
|
"complete": False,
|
|
}
|