dbb6b7abde
- BFF service token (constant-time, plaintext or SHA-256 hash) plus X-Evo-Actor delegation JWT verification (ES256/RS256, iss/aud, <=60s lifetime, required claims, thread binding) with atomic jti anti-replay - Config API: GET/PUT /api/model-registry, credential rotation endpoint, GET /api/models selector; PUT runs the section 9.2 save-time checks inside the registry write transaction after credential writes - Snapshot API: create/bind/delete routes delegating to SnapshotService with thread/deployment binding checks and 9.5 unified error payloads - Platform security config loader (config.yaml fields), OpenAPI export (scripts/export_model_registry_schema.py -> model_registry/openapi.json) - Mount new routes in langgraph_dev/http.py; retire the legacy GET /api/models and POST /api/runtime-snapshots handlers - Declare PyJWT>=2.8 (previously transitive); extend the 9.5 error code table with the HTTP-layer codes (400/401/403/422/500)
827 lines
27 KiB
Python
827 lines
27 KiB
Python
"""Smoke tests for the legacy admin routes mounted via langgraph.json's
|
|
``http`` field. We test the Starlette app directly — no need to spin up
|
|
langgraph dev.
|
|
|
|
``GET /api/models`` and ``POST /api/runtime-snapshots`` now belong to the
|
|
unified model registry API (``EvoScientist.model_registry.http_api``); their
|
|
contract tests live in ``tests/test_model_registry_http.py`` and
|
|
``tests/test_delegation_auth.py``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from langchain_core.messages import AIMessage, HumanMessage
|
|
from starlette.testclient import TestClient
|
|
|
|
from EvoScientist.config import EvoScientistConfig, load_config, save_config
|
|
from EvoScientist.config.provider_profiles import (
|
|
load_provider_profiles,
|
|
)
|
|
from EvoScientist.langgraph_dev.http import app
|
|
from EvoScientist.llm.provider_operations import (
|
|
DiscoveredProviderModel,
|
|
ProviderModelTestResult,
|
|
ProviderOperationError,
|
|
)
|
|
|
|
client = TestClient(app)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def isolate_xdg_config(tmp_path, monkeypatch):
|
|
"""Keep HTTP tests independent from the developer's saved providers."""
|
|
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
|
|
|
|
def test_provider_profiles_api_requires_admin_token_header(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
monkeypatch.delenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", raising=False)
|
|
response = client.get("/api/provider-profiles")
|
|
assert response.status_code == 403
|
|
|
|
|
|
def test_provider_profiles_api_round_trip_redacts_secret(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret")
|
|
headers = {"X-EvoScientist-Admin-Token": "admin-secret"}
|
|
payload = {
|
|
"providers": [
|
|
{
|
|
"id": "lab-openai",
|
|
"name": "Lab OpenAI",
|
|
"adapter": "openai-compatible",
|
|
"base_url": "https://llm.example.test/v1",
|
|
"api_key": "provider-secret",
|
|
"enabled": True,
|
|
"runtime": {"timeout_seconds": 90, "max_retries": 1},
|
|
"models": [
|
|
{
|
|
"id": "lab-model",
|
|
"name": "Lab Model",
|
|
"model_id": "vendor/model",
|
|
"enabled": True,
|
|
"runtime": {
|
|
"limit_mode": "combined",
|
|
"context_window_tokens": 32_768,
|
|
"max_output_tokens": 4_096,
|
|
"min_effective_input_tokens": 4_096,
|
|
"limits_status": "confirmed",
|
|
"limits_source": "provider",
|
|
},
|
|
}
|
|
],
|
|
}
|
|
]
|
|
}
|
|
|
|
put_response = client.put("/api/provider-profiles", headers=headers, json=payload)
|
|
assert put_response.status_code == 200
|
|
assert "provider-secret" not in put_response.text
|
|
assert put_response.json()["providers"][0]["api_key_configured"] is True
|
|
assert "openai" in put_response.json()["reserved_provider_ids"]
|
|
|
|
get_response = client.get("/api/provider-profiles", headers=headers)
|
|
assert get_response.status_code == 200
|
|
assert get_response.json()["providers"][0]["models"][0]["id"] == "lab-model"
|
|
assert "provider-secret" not in get_response.text
|
|
|
|
|
|
def test_llm_config_api_requires_admin_token_header(monkeypatch):
|
|
monkeypatch.delenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", raising=False)
|
|
|
|
response = client.get("/api/config")
|
|
|
|
assert response.status_code == 403
|
|
|
|
|
|
def test_llm_config_api_redacts_secrets_and_reports_env_overrides(monkeypatch):
|
|
monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret")
|
|
monkeypatch.setenv("OPENAI_API_KEY", "environment-secret")
|
|
save_config(
|
|
EvoScientistConfig(
|
|
provider="openai",
|
|
model="gpt-5.4",
|
|
openai_api_key="file-secret-1234",
|
|
default_workdir="/tmp/research",
|
|
)
|
|
)
|
|
|
|
response = client.get(
|
|
"/api/config",
|
|
headers={"X-EvoScientist-Admin-Token": "admin-secret"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["values"]["provider"] == "openai"
|
|
assert body["values"]["model"] == "gpt-5.4"
|
|
assert "openai_api_key" not in body["values"]
|
|
assert body["secrets"]["openai_api_key"] == {
|
|
"configured": True,
|
|
"hint": "...1234",
|
|
}
|
|
assert body["env_overrides"]["openai_api_key"] == "OPENAI_API_KEY"
|
|
assert "file-secret-1234" not in response.text
|
|
assert "environment-secret" not in response.text
|
|
assert "default_workdir" not in body["values"]
|
|
assert body["model_catalog"] is None
|
|
assert body["builtin_model_candidates"]
|
|
openai = next(
|
|
provider for provider in body["builtin_providers"] if provider["id"] == "openai"
|
|
)
|
|
assert openai["managed"] is False
|
|
assert openai["api_key_configured"] is True
|
|
assert "environment-secret" not in response.text
|
|
|
|
|
|
def test_llm_config_api_saves_builtin_registry_without_rewriting_legacy_secret(
|
|
monkeypatch,
|
|
):
|
|
monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret")
|
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
|
headers = {"X-EvoScientist-Admin-Token": "admin-secret"}
|
|
save_config(
|
|
EvoScientistConfig(
|
|
provider="openai",
|
|
model="chat-main",
|
|
openai_api_key="legacy-secret",
|
|
default_workdir="/tmp/research",
|
|
)
|
|
)
|
|
loaded = client.get("/api/config", headers=headers).json()
|
|
openai = next(
|
|
provider
|
|
for provider in loaded["builtin_providers"]
|
|
if provider["id"] == "openai"
|
|
)
|
|
openai.update(
|
|
{
|
|
"managed": True,
|
|
"base_url": "https://proxy.example.test/v1",
|
|
"models": [
|
|
{
|
|
"id": "chat-main",
|
|
"name": "Chat Main",
|
|
"model_id": "gpt-upstream",
|
|
"enabled": True,
|
|
}
|
|
],
|
|
}
|
|
)
|
|
|
|
response = client.patch(
|
|
"/api/config",
|
|
headers=headers,
|
|
json={
|
|
"revision": loaded["revision"],
|
|
"values": loaded["values"],
|
|
"secrets": {},
|
|
"clear_secrets": [],
|
|
"builtin_providers": [openai],
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["restart_required"] is False
|
|
assert "builtin_providers" in body["changed_fields"]
|
|
saved_profile = load_provider_profiles().builtins[0]
|
|
assert saved_profile.id == "openai"
|
|
assert saved_profile.api_key == "legacy-secret"
|
|
assert saved_profile.base_url == "https://proxy.example.test/v1"
|
|
saved_config = load_config()
|
|
assert saved_config.openai_api_key == "legacy-secret"
|
|
assert saved_config.default_workdir == "/tmp/research"
|
|
assert saved_config.model_catalog is None
|
|
|
|
|
|
def test_first_builtin_registry_save_migrates_legacy_default_model(monkeypatch):
|
|
monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret")
|
|
monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://environment.example.test")
|
|
headers = {"X-EvoScientist-Admin-Token": "admin-secret"}
|
|
save_config(EvoScientistConfig(provider="anthropic", model="claude-sonnet-4-6"))
|
|
loaded = client.get("/api/config", headers=headers).json()
|
|
ollama = next(
|
|
provider
|
|
for provider in loaded["builtin_providers"]
|
|
if provider["id"] == "ollama"
|
|
)
|
|
ollama.update(
|
|
{
|
|
"managed": True,
|
|
"base_url": "http://127.0.0.1:11434",
|
|
"models": [],
|
|
}
|
|
)
|
|
|
|
response = client.patch(
|
|
"/api/config",
|
|
headers=headers,
|
|
json={
|
|
"revision": loaded["revision"],
|
|
"values": loaded["values"],
|
|
"secrets": {},
|
|
"clear_secrets": [],
|
|
"builtin_providers": [ollama],
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
builtins = {profile.id: profile for profile in load_provider_profiles().builtins}
|
|
assert set(builtins) == {"anthropic", "ollama"}
|
|
assert builtins["anthropic"].models[0].id == "claude-sonnet-4-6"
|
|
assert builtins["anthropic"].base_url == ""
|
|
|
|
|
|
def test_llm_config_api_persists_model_catalog(monkeypatch):
|
|
monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret")
|
|
headers = {"X-EvoScientist-Admin-Token": "admin-secret"}
|
|
save_config(EvoScientistConfig(provider="openai", model="chat-main"))
|
|
revision = client.get("/api/config", headers=headers).json()["revision"]
|
|
catalog = [
|
|
{
|
|
"provider": "openai",
|
|
"id": "chat-main",
|
|
"name": "Chat Main",
|
|
"model_id": "gpt-upstream",
|
|
"enabled": True,
|
|
}
|
|
]
|
|
|
|
response = client.patch(
|
|
"/api/config",
|
|
headers=headers,
|
|
json={
|
|
"revision": revision,
|
|
"values": {},
|
|
"secrets": {},
|
|
"clear_secrets": [],
|
|
"model_catalog": catalog,
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json()["model_catalog"] == catalog
|
|
assert response.json()["restart_required"] is False
|
|
assert load_config().model_catalog == catalog
|
|
|
|
|
|
def test_llm_config_api_rejects_default_outside_model_catalog(monkeypatch):
|
|
monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret")
|
|
headers = {"X-EvoScientist-Admin-Token": "admin-secret"}
|
|
save_config(EvoScientistConfig(provider="openai", model="gpt-default"))
|
|
revision = client.get("/api/config", headers=headers).json()["revision"]
|
|
|
|
response = client.patch(
|
|
"/api/config",
|
|
headers=headers,
|
|
json={
|
|
"revision": revision,
|
|
"values": {},
|
|
"secrets": {},
|
|
"clear_secrets": [],
|
|
"model_catalog": [
|
|
{
|
|
"provider": "openai",
|
|
"id": "different-model",
|
|
"name": "Different",
|
|
"model_id": "gpt-different",
|
|
"enabled": True,
|
|
}
|
|
],
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert "Default model" in response.json()["error"]
|
|
assert load_config().model_catalog is None
|
|
|
|
|
|
def test_llm_config_api_patch_preserves_unrelated_fields_and_secret_by_default(
|
|
monkeypatch,
|
|
):
|
|
monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret")
|
|
headers = {"X-EvoScientist-Admin-Token": "admin-secret"}
|
|
save_config(
|
|
EvoScientistConfig(
|
|
openai_api_key="saved-openai-key",
|
|
anthropic_api_key="saved-anthropic-key",
|
|
default_workdir="/tmp/research",
|
|
)
|
|
)
|
|
revision = client.get("/api/config", headers=headers).json()["revision"]
|
|
|
|
response = client.patch(
|
|
"/api/config",
|
|
headers=headers,
|
|
json={
|
|
"revision": revision,
|
|
"values": {
|
|
"provider": "openai",
|
|
"model": "gpt-5.4",
|
|
"ollama_base_url": "http://127.0.0.1:11434",
|
|
},
|
|
"secrets": {"openai_api_key": "replacement-openai-key"},
|
|
"clear_secrets": ["anthropic_api_key"],
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["restart_required"] is True
|
|
assert set(body["changed_fields"]) == {
|
|
"anthropic_api_key",
|
|
"model",
|
|
"ollama_base_url",
|
|
"openai_api_key",
|
|
"provider",
|
|
}
|
|
assert "replacement-openai-key" not in response.text
|
|
saved = load_config()
|
|
assert saved.provider == "openai"
|
|
assert saved.model == "gpt-5.4"
|
|
assert saved.ollama_base_url == "http://127.0.0.1:11434"
|
|
assert saved.openai_api_key == "replacement-openai-key"
|
|
assert saved.anthropic_api_key == ""
|
|
assert saved.default_workdir == "/tmp/research"
|
|
|
|
|
|
def test_llm_config_api_rejects_stale_revision(monkeypatch):
|
|
monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret")
|
|
headers = {"X-EvoScientist-Admin-Token": "admin-secret"}
|
|
save_config(EvoScientistConfig(model="claude-sonnet-4-6"))
|
|
revision = client.get("/api/config", headers=headers).json()["revision"]
|
|
save_config(EvoScientistConfig(model="gpt-5.4", provider="openai"))
|
|
|
|
response = client.patch(
|
|
"/api/config",
|
|
headers=headers,
|
|
json={
|
|
"revision": revision,
|
|
"values": {"model": "claude-opus-4-8"},
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 409
|
|
assert "Reload and try again" in response.json()["error"]
|
|
assert load_config().model == "gpt-5.4"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("payload", "message"),
|
|
[
|
|
(
|
|
{"values": {"openai_auth_mode": "password"}},
|
|
"openai_auth_mode must be 'api_key' or 'oauth'",
|
|
),
|
|
(
|
|
{"values": {"default_workdir": "/tmp/other"}},
|
|
"Unsupported config fields: default_workdir",
|
|
),
|
|
(
|
|
{"values": {"ollama_base_url": "localhost:11434"}},
|
|
"ollama_base_url must use http:// or https://",
|
|
),
|
|
],
|
|
)
|
|
def test_llm_config_api_validates_updates(monkeypatch, payload, message):
|
|
monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret")
|
|
headers = {"X-EvoScientist-Admin-Token": "admin-secret"}
|
|
revision = client.get("/api/config", headers=headers).json()["revision"]
|
|
|
|
response = client.patch(
|
|
"/api/config",
|
|
headers=headers,
|
|
json={"revision": revision, **payload},
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert message in response.json()["error"]
|
|
|
|
|
|
def test_provider_actions_api_requires_admin_token_header(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
monkeypatch.delenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", raising=False)
|
|
|
|
response = client.post("/api/provider-actions", json={})
|
|
|
|
assert response.status_code == 403
|
|
|
|
|
|
def test_provider_actions_lists_models_with_saved_api_key(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret")
|
|
headers = {"X-EvoScientist-Admin-Token": "admin-secret"}
|
|
provider = {
|
|
"id": "lab-openai",
|
|
"name": "Lab OpenAI",
|
|
"adapter": "openai-compatible",
|
|
"base_url": "https://llm.example.test/v1",
|
|
"api_key": "provider-secret",
|
|
"enabled": True,
|
|
"models": [],
|
|
}
|
|
assert (
|
|
client.put(
|
|
"/api/provider-profiles",
|
|
headers=headers,
|
|
json={"providers": [provider]},
|
|
).status_code
|
|
== 200
|
|
)
|
|
provider["api_key"] = ""
|
|
|
|
async def fake_discover(profile):
|
|
assert profile.api_key == "provider-secret"
|
|
return [DiscoveredProviderModel("vendor/model", "Vendor Model")]
|
|
|
|
with patch(
|
|
"EvoScientist.langgraph_dev.http.discover_provider_models",
|
|
new=fake_discover,
|
|
):
|
|
response = client.post(
|
|
"/api/provider-actions",
|
|
headers=headers,
|
|
json={"action": "list_models", "provider": provider},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {
|
|
"models": [{"model_id": "vendor/model", "name": "Vendor Model"}]
|
|
}
|
|
|
|
|
|
def test_provider_actions_tests_model(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret")
|
|
headers = {"X-EvoScientist-Admin-Token": "admin-secret"}
|
|
|
|
async def fake_test(profile, model):
|
|
assert profile.adapter == "grok"
|
|
assert model.model_id == "grok-4"
|
|
return ProviderModelTestResult(latency_ms=123, response="OK")
|
|
|
|
with patch("EvoScientist.langgraph_dev.http.test_provider_model", new=fake_test):
|
|
response = client.post(
|
|
"/api/provider-actions",
|
|
headers=headers,
|
|
json={
|
|
"action": "test_model",
|
|
"provider": {
|
|
"id": "lab-grok",
|
|
"name": "Lab Grok",
|
|
"adapter": "grok",
|
|
"base_url": "",
|
|
"api_key": "xai-secret",
|
|
"enabled": True,
|
|
"models": [],
|
|
},
|
|
"model": {
|
|
"id": "grok",
|
|
"name": "Grok",
|
|
"model_id": "grok-4",
|
|
},
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {"ok": True, "latency_ms": 123, "response": "OK"}
|
|
|
|
|
|
def test_provider_actions_maps_provider_failures_to_bad_gateway(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret")
|
|
|
|
async def fake_discover(_profile):
|
|
raise ProviderOperationError("Provider returned HTTP 401")
|
|
|
|
with patch(
|
|
"EvoScientist.langgraph_dev.http.discover_provider_models",
|
|
new=fake_discover,
|
|
):
|
|
response = client.post(
|
|
"/api/provider-actions",
|
|
headers={"X-EvoScientist-Admin-Token": "admin-secret"},
|
|
json={
|
|
"action": "list_models",
|
|
"provider": {
|
|
"id": "lab-openai",
|
|
"name": "Lab OpenAI",
|
|
"adapter": "openai-compatible",
|
|
"base_url": "https://llm.example.test/v1",
|
|
"api_key": "bad-key",
|
|
"enabled": True,
|
|
"models": [],
|
|
},
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 502
|
|
assert response.json() == {"error": "Provider returned HTTP 401"}
|
|
|
|
|
|
def test_llm_config_action_discovers_models_with_effective_builtin_secret(
|
|
monkeypatch,
|
|
):
|
|
monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret")
|
|
monkeypatch.setenv("OPENAI_API_KEY", "environment-secret")
|
|
headers = {"X-EvoScientist-Admin-Token": "admin-secret"}
|
|
|
|
async def fake_discover(profile):
|
|
assert profile.id == "openai"
|
|
assert profile.adapter == "openai"
|
|
assert profile.api_key == "environment-secret"
|
|
return [DiscoveredProviderModel("gpt-discovered", "GPT Discovered")]
|
|
|
|
with patch(
|
|
"EvoScientist.langgraph_dev.http.discover_provider_models",
|
|
new=fake_discover,
|
|
):
|
|
response = client.post(
|
|
"/api/config",
|
|
headers=headers,
|
|
json={"action": "list_models", "provider": {"id": "openai"}},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {
|
|
"models": [{"model_id": "gpt-discovered", "name": "GPT Discovered"}]
|
|
}
|
|
|
|
|
|
def test_llm_config_action_tests_builtin_model_with_draft_connection(monkeypatch):
|
|
monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret")
|
|
headers = {"X-EvoScientist-Admin-Token": "admin-secret"}
|
|
|
|
async def fake_test(profile, model):
|
|
assert profile.id == "custom-openai"
|
|
assert profile.adapter == "openai-compatible"
|
|
assert profile.base_url == "https://proxy.example.test/v1"
|
|
assert profile.api_key == "draft-secret"
|
|
assert model.model_id == "vendor/model"
|
|
return ProviderModelTestResult(latency_ms=42, response="OK")
|
|
|
|
with patch("EvoScientist.langgraph_dev.http.test_provider_model", new=fake_test):
|
|
response = client.post(
|
|
"/api/config",
|
|
headers=headers,
|
|
json={
|
|
"action": "test_model",
|
|
"provider": {
|
|
"id": "custom-openai",
|
|
"base_url": "https://proxy.example.test/v1",
|
|
"api_key": "draft-secret",
|
|
},
|
|
"model": {
|
|
"id": "chat-main",
|
|
"name": "Chat Main",
|
|
"model_id": "vendor/model",
|
|
},
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {"ok": True, "latency_ms": 42, "response": "OK"}
|
|
|
|
|
|
def test_default_model_api_requires_admin_token_header(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
monkeypatch.delenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", raising=False)
|
|
response = client.put(
|
|
"/api/default-model",
|
|
json={"model": "gpt-5.4", "provider": "openai"},
|
|
)
|
|
assert response.status_code == 403
|
|
|
|
|
|
def test_default_model_api_persists_pair_and_preserves_config(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret")
|
|
save_config(EvoScientistConfig(default_workdir="/tmp/research"))
|
|
|
|
response = client.put(
|
|
"/api/default-model",
|
|
headers={"X-EvoScientist-Admin-Token": "admin-secret"},
|
|
json={"model": "gpt-5.4", "provider": "openai"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.json() == {"default": {"name": "gpt-5.4", "provider": "openai"}}
|
|
saved = load_config()
|
|
assert saved.model == "gpt-5.4"
|
|
assert saved.provider == "openai"
|
|
assert saved.default_workdir == "/tmp/research"
|
|
|
|
|
|
def test_default_model_api_accepts_configured_dynamic_model(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret")
|
|
headers = {"X-EvoScientist-Admin-Token": "admin-secret"}
|
|
provider_payload = {
|
|
"providers": [
|
|
{
|
|
"id": "lab-openai",
|
|
"name": "Lab OpenAI",
|
|
"adapter": "openai-compatible",
|
|
"base_url": "https://llm.example.test/v1",
|
|
"api_key": "provider-secret",
|
|
"enabled": True,
|
|
"models": [
|
|
{
|
|
"id": "lab-model",
|
|
"name": "Lab Model",
|
|
"model_id": "vendor/model",
|
|
"enabled": True,
|
|
}
|
|
],
|
|
}
|
|
]
|
|
}
|
|
assert (
|
|
client.put(
|
|
"/api/provider-profiles", headers=headers, json=provider_payload
|
|
).status_code
|
|
== 200
|
|
)
|
|
|
|
response = client.put(
|
|
"/api/default-model",
|
|
headers=headers,
|
|
json={"model": "lab-model", "provider": "lab-openai"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
saved = load_config()
|
|
assert (saved.model, saved.provider) == ("lab-model", "lab-openai")
|
|
|
|
|
|
def test_default_model_api_rejects_unconfigured_pair(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret")
|
|
|
|
response = client.put(
|
|
"/api/default-model",
|
|
headers={"X-EvoScientist-Admin-Token": "admin-secret"},
|
|
json={"model": "missing-model", "provider": "missing-provider"},
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert "is not configured" in response.json()["error"]
|
|
|
|
|
|
def test_default_model_api_rejects_invalid_json(monkeypatch):
|
|
monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret")
|
|
response = client.put(
|
|
"/api/default-model",
|
|
headers={
|
|
"X-EvoScientist-Admin-Token": "admin-secret",
|
|
"Content-Type": "application/json",
|
|
},
|
|
content="{invalid",
|
|
)
|
|
assert response.status_code == 400
|
|
assert response.json() == {"error": "Request body must be valid JSON."}
|
|
|
|
|
|
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,
|
|
}
|