Files

1184 lines
37 KiB
Python

"""Focused regression tests for administrator model configuration helpers."""
from __future__ import annotations
import errno
from pathlib import Path
from types import SimpleNamespace
import pytest
import yaml
from fastapi import HTTPException
from langchain_core.messages import HumanMessage
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from starlette.routing import Match
from app.gateway import services
from app.gateway.routers import model_management as router
from app.gateway.routers import models as public_models
from deerflow.config.app_config import AppConfig
from deerflow.config.model_config import ModelConfig
from deerflow.models.factory import create_chat_model
from deerflow.persistence.base import Base
from deerflow.persistence.model_capabilities import (
MemoryModelCapabilityStore,
ModelCapabilityRepository,
ModelCapabilityRow,
import_legacy_reasoning_effort_capabilities,
)
def test_config_atomic_replace_retries_transient_busy_filesystem_error(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.yaml"
original = "models:\n - name: old\n"
updated = "models:\n - name: new\n"
config_path.write_text(original, encoding="utf-8")
real_replace = router.os.replace
attempts = 0
def replace_with_transient_busy(source: str, target: Path) -> None:
nonlocal attempts
attempts += 1
if attempts < 3:
raise OSError(errno.EBUSY, "resource busy", str(target))
real_replace(source, target)
monkeypatch.setattr(router.os, "replace", replace_with_transient_busy)
monkeypatch.setattr(router, "sleep", lambda _delay: None)
backup = router._backup_and_write_config(config_path, original, updated)
assert attempts == 3
assert config_path.read_text(encoding="utf-8") == updated
assert backup.read_text(encoding="utf-8") == original
def test_config_atomic_replace_does_not_retry_permanent_error(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.yaml"
original = "models:\n - name: old\n"
config_path.write_text(original, encoding="utf-8")
attempts = 0
def fail_replace(_source: str, _target: Path) -> None:
nonlocal attempts
attempts += 1
raise OSError(errno.EACCES, "permission denied")
monkeypatch.setattr(router.os, "replace", fail_replace)
with pytest.raises(OSError, match="permission denied"):
router._backup_and_write_config(config_path, original, "models:\n - name: new\n")
assert attempts == 1
assert config_path.read_text(encoding="utf-8") == original
assert not list(tmp_path.glob(".config.yaml.*.tmp"))
def test_config_single_file_mount_is_updated_without_atomic_rename(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.yaml"
original = "models:\n - name: old\n"
updated = "models:\n - name: mounted-new\n"
config_path.write_text(original, encoding="utf-8")
monkeypatch.setattr(router, "_is_file_mountpoint", lambda _path: True)
def unexpected_replace(_source: str, _target: Path) -> None:
pytest.fail("single-file bind mount must not be replaced by rename")
monkeypatch.setattr(router.os, "replace", unexpected_replace)
backup = router._backup_and_write_config(config_path, original, updated)
assert config_path.read_text(encoding="utf-8") == updated
assert backup.read_text(encoding="utf-8") == original
assert not list(tmp_path.glob(".config.yaml.*.tmp"))
def test_replace_models_block_keeps_other_sections_and_comments() -> None:
raw = """config_version: 1
models:
# Existing provider note
- name: old-model
use: langchain_openai:ChatOpenAI
model: old-model
# Disabled provider example remains useful to operators.
# - name: disabled-model
# model: disabled-model
agents:
default: lead_agent
"""
replaced = router._replace_models_block(
raw,
[
{
"name": "new-model",
"use": "langchain_openai:ChatOpenAI",
"model": "new-model",
}
],
)
parsed = yaml.safe_load(replaced)
assert parsed["models"] == [
{
"name": "new-model",
"use": "langchain_openai:ChatOpenAI",
"model": "new-model",
}
]
assert parsed["agents"] == {"default": "lead_agent"}
assert "# Existing provider note" in replaced
assert "# Disabled provider example remains useful to operators." in replaced
def test_connection_error_redaction_hides_literal_and_resolved_keys() -> None:
message = "provider rejected raw-secret and resolved-secret"
assert router._redact_text(message, ["raw-secret", "resolved-secret"]) == "provider rejected *** and ***"
def test_legacy_model_edit_preserves_write_file_transport() -> None:
existing = {
"name": "bridge-model",
"use": "langchain_openai:ChatOpenAI",
"model": "bridge-model",
"write_file_transport": "text_bridge",
}
merged = router._request_to_raw_model(
router.ModelUpsertRequest(
name="bridge-model",
use="langchain_openai:ChatOpenAI",
model="bridge-model-v2",
),
existing,
)
assert merged["model"] == "bridge-model-v2"
assert merged["write_file_transport"] == "text_bridge"
changed = router._request_to_raw_model(
router.ModelUpsertRequest(
name="bridge-model",
use="langchain_openai:ChatOpenAI",
model="bridge-model-v2",
write_file_transport="native",
),
merged,
)
assert changed["write_file_transport"] == "native"
assert type(changed["write_file_transport"]) is str
def test_model_yaml_render_strips_database_owned_capability_from_every_row() -> None:
assert router._strip_database_owned_fields(
[
{"name": "first", "supports_reasoning_effort": True},
{"name": "second", "supports_reasoning_effort": False},
]
) == [{"name": "first"}, {"name": "second"}]
def test_model_mutation_routes_accept_names_with_slashes() -> None:
for method in ("PUT", "DELETE"):
route = next(item for item in router.router.routes if getattr(item, "methods", set()) == {method} and getattr(item, "path", "") == "/api/admin/models/{model_name:path}")
match, child_scope = route.matches(
{
"type": "http",
"method": method,
"path": "/api/admin/models/zai-org/GLM-5-FP8",
}
)
assert match is Match.FULL
assert child_scope["path_params"] == {"model_name": "zai-org/GLM-5-FP8"}
def test_reasoning_capability_route_precedes_generic_model_update_and_accepts_slashes() -> None:
scope = {
"type": "http",
"method": "PUT",
"path": "/api/admin/models/capabilities/reasoning-effort/zai-org/GLM-5-FP8",
}
matching_paths = [route.path for route in router.router.routes if route.matches(scope)[0] is Match.FULL]
assert matching_paths[:2] == [
"/api/admin/models/capabilities/reasoning-effort/{model_name:path}",
"/api/admin/models/{model_name:path}",
]
def test_write_file_transport_route_precedes_generic_model_update() -> None:
scope = {
"type": "http",
"method": "PUT",
"path": "/api/admin/models/write-file-transport/zai-org/GLM-5-FP8",
}
matching_paths = [route.path for route in router.router.routes if route.matches(scope)[0] is Match.FULL]
assert matching_paths[:2] == [
"/api/admin/models/write-file-transport/{model_name:path}",
"/api/admin/models/{model_name:path}",
]
def _admin_request():
return SimpleNamespace(
state=SimpleNamespace(user=SimpleNamespace(system_role="admin")),
app=SimpleNamespace(state=SimpleNamespace()),
)
@pytest.mark.asyncio
async def test_write_file_transport_toggle_persists_native_and_buffered_gateway(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.yaml"
config_path.write_text(
"""models:
- name: flash
use: langchain_openai:ChatOpenAI
model: flash
""",
encoding="utf-8",
)
saved: dict[str, object] = {}
async def save_models(_request, raw_before: str, models: list[dict[str, object]]) -> list[dict[str, object]]:
saved["raw_before"] = raw_before
saved["models"] = models
return models
monkeypatch.setattr(router, "_resolve_config_path", lambda: config_path)
monkeypatch.setattr(router, "_save_models", save_models)
enabled = await router.update_write_file_transport(
"flash",
router.WriteFileTransportToggleRequest(write_file_transport="buffered_gateway"),
_admin_request(),
)
assert enabled.write_file_transport == "buffered_gateway"
assert saved["models"] == [
{
"name": "flash",
"use": "langchain_openai:ChatOpenAI",
"model": "flash",
"write_file_transport": "buffered_gateway",
}
]
disabled = await router.update_write_file_transport(
"flash",
router.WriteFileTransportToggleRequest(write_file_transport="native"),
_admin_request(),
)
assert disabled.write_file_transport == "native"
assert saved["models"][0]["write_file_transport"] == "native"
@pytest.mark.asyncio
@pytest.mark.parametrize(
("use", "use_responses_api"),
[
("langchain_anthropic:ChatAnthropic", None),
("langchain_openai:ChatOpenAI", True),
],
)
async def test_write_file_transport_rejects_incompatible_models(
use: str,
use_responses_api: bool | None,
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.yaml"
original = f"""models:
- name: incompatible
use: {use}
model: incompatible
"""
if use_responses_api:
original = original.replace(
" model: incompatible\n",
" model: incompatible\n use_responses_api: true\n",
)
config_path.write_text(original, encoding="utf-8")
monkeypatch.setattr(router, "_resolve_config_path", lambda: config_path)
async def fail_save_models(_request, _raw_before, _models):
raise AssertionError("incompatible models must not be persisted")
monkeypatch.setattr(router, "_save_models", fail_save_models)
with pytest.raises(HTTPException) as exc_info:
await router.update_write_file_transport(
"incompatible",
router.WriteFileTransportToggleRequest(write_file_transport="buffered_gateway"),
_admin_request(),
)
assert exc_info.value.status_code == 422
assert config_path.read_text(encoding="utf-8") == original
def test_strict_stream_integrity_route_precedes_generic_model_update() -> None:
scope = {
"type": "http",
"method": "PUT",
"path": "/api/admin/models/strict-stream-integrity/zai-org/GLM-5-FP8",
}
matching_paths = [route.path for route in router.router.routes if route.matches(scope)[0] is Match.FULL]
assert matching_paths[:2] == [
"/api/admin/models/strict-stream-integrity/{model_name:path}",
"/api/admin/models/{model_name:path}",
]
@pytest.mark.asyncio
async def test_strict_stream_integrity_toggle_persists_explicit_bool(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.yaml"
config_path.write_text(
"""models:
- name: flash
use: langchain_openai:ChatOpenAI
model: flash
""",
encoding="utf-8",
)
saved: dict[str, object] = {}
async def save_models(_request, raw_before: str, models: list[dict[str, object]]) -> list[dict[str, object]]:
saved["models"] = models
return models
monkeypatch.setattr(router, "_resolve_config_path", lambda: config_path)
monkeypatch.setattr(router, "_save_models", save_models)
enabled = await router.update_strict_stream_integrity(
"flash",
router.StrictStreamIntegrityToggleRequest(strict_stream_integrity=True),
_admin_request(),
)
assert enabled.strict_stream_integrity is True
assert saved["models"] == [
{
"name": "flash",
"use": "langchain_openai:ChatOpenAI",
"model": "flash",
"strict_stream_integrity": True,
}
]
disabled = await router.update_strict_stream_integrity(
"flash",
router.StrictStreamIntegrityToggleRequest(strict_stream_integrity=False),
_admin_request(),
)
assert disabled.strict_stream_integrity is False
assert saved["models"][0]["strict_stream_integrity"] is False
@pytest.mark.asyncio
@pytest.mark.parametrize(
("use", "use_responses_api"),
[
("langchain_anthropic:ChatAnthropic", None),
("langchain_openai:ChatOpenAI", True),
],
)
async def test_strict_stream_integrity_rejects_incompatible_models(
use: str,
use_responses_api: bool | None,
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.yaml"
original = f"""models:
- name: incompatible
use: {use}
model: incompatible
"""
if use_responses_api:
original = original.replace(
" model: incompatible\n",
" model: incompatible\n use_responses_api: true\n",
)
config_path.write_text(original, encoding="utf-8")
monkeypatch.setattr(router, "_resolve_config_path", lambda: config_path)
async def fail_save_models(_request, _raw_before, _models):
raise AssertionError("incompatible models must not be persisted")
monkeypatch.setattr(router, "_save_models", fail_save_models)
with pytest.raises(HTTPException) as exc_info:
await router.update_strict_stream_integrity(
"incompatible",
router.StrictStreamIntegrityToggleRequest(strict_stream_integrity=True),
_admin_request(),
)
assert exc_info.value.status_code == 422
assert config_path.read_text(encoding="utf-8") == original
def test_legacy_model_edit_preserves_strict_stream_integrity() -> None:
existing = {
"name": "relay-model",
"use": "langchain_openai:ChatOpenAI",
"model": "relay-model",
"strict_stream_integrity": True,
}
merged = router._request_to_raw_model(
router.ModelUpsertRequest(
name="relay-model",
use="langchain_openai:ChatOpenAI",
model="relay-model-v2",
),
existing,
)
assert merged["model"] == "relay-model-v2"
assert merged["strict_stream_integrity"] is True
changed = router._request_to_raw_model(
router.ModelUpsertRequest(
name="relay-model",
use="langchain_openai:ChatOpenAI",
model="relay-model-v2",
strict_stream_integrity=False,
),
merged,
)
assert changed["strict_stream_integrity"] is False
def _reasoning_request(store, *, config: AppConfig | None = None):
return SimpleNamespace(
state=SimpleNamespace(
user=SimpleNamespace(id="admin-1", system_role="admin"),
),
app=SimpleNamespace(
state=SimpleNamespace(
model_capability_store=store,
config=config,
)
),
)
def _qwen_model(
*,
name: str = "zai-org/GLM-5-FP8",
supports_thinking: bool = True,
legacy_reasoning_effort: bool = False,
) -> ModelConfig:
return ModelConfig(
name=name,
use="langchain_openai:ChatOpenAI",
model="Qwen/Qwen3.8-Flash-Next",
api_key="test-key",
base_url="http://models.invalid/v1",
supports_thinking=supports_thinking,
# This legacy parser field is deliberately the opposite of the DB row.
supports_reasoning_effort=legacy_reasoning_effort,
)
@pytest.mark.asyncio
async def test_legacy_true_capability_is_seeded_when_database_row_is_missing() -> None:
store = MemoryModelCapabilityStore()
imported = await import_legacy_reasoning_effort_capabilities(
store,
[_qwen_model(name="legacy", legacy_reasoning_effort=True)],
)
assert imported == ["legacy"]
assert (await store.get_capability("legacy"))["supports_reasoning_effort"] is True
@pytest.mark.asyncio
@pytest.mark.parametrize("persisted_value", [False, True])
async def test_legacy_true_never_overwrites_existing_database_capability(
persisted_value: bool,
) -> None:
store = MemoryModelCapabilityStore()
await store.set_reasoning_effort_support(
"legacy",
supports_reasoning_effort=persisted_value,
updated_by="admin-1",
)
imported = await import_legacy_reasoning_effort_capabilities(
store,
[_qwen_model(name="legacy", legacy_reasoning_effort=True)],
)
persisted = await store.get_capability("legacy")
assert imported == []
assert persisted["supports_reasoning_effort"] is persisted_value
assert persisted["updated_by"] == "admin-1"
@pytest.mark.asyncio
async def test_legacy_false_config_does_not_create_database_capability() -> None:
store = MemoryModelCapabilityStore()
imported = await import_legacy_reasoning_effort_capabilities(
store,
[_qwen_model(name="legacy", legacy_reasoning_effort=False)],
)
assert imported == []
assert await store.get_capability("legacy") is None
@pytest.mark.asyncio
async def test_sql_model_capability_store_persists_across_repository_instances(tmp_path) -> None:
engine = create_async_engine(
f"sqlite+aiosqlite:///{tmp_path / 'model-capabilities.db'}",
)
async with engine.begin() as connection:
await connection.run_sync(
lambda sync_connection: Base.metadata.create_all(
sync_connection,
tables=[ModelCapabilityRow.__table__],
)
)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
first = ModelCapabilityRepository(session_factory)
await first.set_reasoning_effort_support(
"zai-org/GLM-5-FP8",
supports_reasoning_effort=True,
updated_by="admin-1",
)
second = ModelCapabilityRepository(session_factory)
persisted = await second.get_capability("zai-org/GLM-5-FP8")
assert persisted is not None
assert persisted["model_name"] == "zai-org/GLM-5-FP8"
assert persisted["supports_reasoning_effort"] is True
assert persisted["updated_by"] == "admin-1"
assert persisted["updated_at"]
await engine.dispose()
@pytest.mark.asyncio
async def test_reasoning_capability_endpoint_and_model_apis_use_database_not_yaml(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.yaml"
original = """models:
- name: zai-org/GLM-5-FP8
use: langchain_openai:ChatOpenAI
model: Qwen/Qwen3.8-Flash-Next
supports_thinking: true
supports_reasoning_effort: false
"""
config_path.write_text(original, encoding="utf-8")
monkeypatch.setattr(router, "_resolve_config_path", lambda: config_path)
store = MemoryModelCapabilityStore()
config = AppConfig.model_construct(models=[_qwen_model()])
request = _reasoning_request(store, config=config)
updated = await router.update_reasoning_effort_capability(
"zai-org/GLM-5-FP8",
router.ReasoningEffortCapabilityRequest(
supports_reasoning_effort=True,
default_reasoning_effort="low",
),
request,
)
admin_list = await router.list_model_configurations(request)
public_list = await public_models.list_models(request, config)
assert updated.supports_reasoning_effort is True
assert updated.default_reasoning_effort == "low"
assert admin_list.models[0].supports_reasoning_effort is True
assert public_list.models[0].supports_reasoning_effort is True
assert public_list.models[0].default_reasoning_effort == "low"
assert (await store.get_capability("zai-org/GLM-5-FP8"))["default_reasoning_effort"] == "low"
assert config_path.read_text(encoding="utf-8") == original
@pytest.mark.asyncio
async def test_reasoning_capability_can_only_be_enabled_for_thinking_models(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.yaml"
config_path.write_text(
"""models:
- name: flash
use: langchain_openai:ChatOpenAI
model: flash
supports_thinking: false
""",
encoding="utf-8",
)
monkeypatch.setattr(router, "_resolve_config_path", lambda: config_path)
store = MemoryModelCapabilityStore()
with pytest.raises(HTTPException) as exc_info:
await router.update_reasoning_effort_capability(
"flash",
router.ReasoningEffortCapabilityRequest(supports_reasoning_effort=True),
_reasoning_request(store),
)
assert exc_info.value.status_code == 422
assert await store.get_capability("flash") is None
@pytest.mark.asyncio
@pytest.mark.parametrize(
("supports_thinking", "expected_capability"),
[(True, True), (False, False)],
)
async def test_model_update_preserves_capability_unless_thinking_is_disabled(
supports_thinking: bool,
expected_capability: bool,
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.yaml"
config_path.write_text(
"""models:
- name: qwen
use: langchain_openai:ChatOpenAI
model: Qwen/Qwen3.8-Flash-Next
supports_thinking: true
""",
encoding="utf-8",
)
monkeypatch.setattr(router, "_resolve_config_path", lambda: config_path)
async def _save_models(_request, _raw_before, rows):
return rows
monkeypatch.setattr(router, "_save_models", _save_models)
store = MemoryModelCapabilityStore()
await store.set_reasoning_effort_support(
"qwen",
supports_reasoning_effort=True,
)
body = router.ModelUpsertRequest(
name="qwen",
use="langchain_openai:ChatOpenAI",
model="Qwen/Qwen3.8-Flash-Next",
supports_thinking=supports_thinking,
)
assert "supports_reasoning_effort" not in body.model_fields_set
result = await router.update_model_configuration(
"qwen",
body,
_reasoning_request(store),
)
assert result.supports_reasoning_effort is expected_capability
assert (await store.get_capability("qwen"))["supports_reasoning_effort"] is expected_capability
@pytest.mark.asyncio
async def test_delete_model_configuration_removes_matching_model_and_activates_config(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.yaml"
config_path.write_text(
"""models:
- name: zai-org/GLM-5-FP8
use: langchain_openai:ChatOpenAI
model: GLM-5-FP8
- name: keep-me
use: langchain_openai:ChatOpenAI
model: keep-me
""",
encoding="utf-8",
)
saved: dict[str, object] = {}
async def save_models(_request, raw_before: str, models: list[dict[str, object]]) -> list[dict[str, object]]:
saved["raw_before"] = raw_before
saved["models"] = models
return models
monkeypatch.setattr(router, "_resolve_config_path", lambda: config_path)
monkeypatch.setattr(router, "_save_models", save_models)
request = SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(system_role="admin")))
await router.delete_model_configuration("zai-org/GLM-5-FP8", request)
assert "zai-org/GLM-5-FP8" in str(saved["raw_before"])
assert saved["models"] == [
{
"name": "keep-me",
"use": "langchain_openai:ChatOpenAI",
"model": "keep-me",
}
]
@pytest.mark.asyncio
async def test_reorder_model_configurations_persists_a_complete_permutation(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.yaml"
config_path.write_text(
"""models:
- name: first
use: langchain_openai:ChatOpenAI
model: first
- name: second
use: langchain_openai:ChatOpenAI
model: second
""",
encoding="utf-8",
)
saved: dict[str, object] = {}
async def save_models(_request, raw_before: str, models: list[dict[str, object]]) -> list[dict[str, object]]:
saved["raw_before"] = raw_before
saved["models"] = models
return models
monkeypatch.setattr(router, "_resolve_config_path", lambda: config_path)
monkeypatch.setattr(router, "_save_models", save_models)
request = SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(system_role="admin")))
result = await router.reorder_model_configurations(
router.ModelOrderRequest(names=["second", "first"]),
request,
)
assert [item.name for item in result] == ["second", "first"]
assert [item["name"] for item in saved["models"]] == ["second", "first"]
@pytest.mark.asyncio
async def test_reorder_model_configurations_rejects_stale_or_partial_orders(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.yaml"
config_path.write_text(
"""models:
- name: first
use: langchain_openai:ChatOpenAI
model: first
- name: second
use: langchain_openai:ChatOpenAI
model: second
""",
encoding="utf-8",
)
monkeypatch.setattr(router, "_resolve_config_path", lambda: config_path)
request = SimpleNamespace(state=SimpleNamespace(user=SimpleNamespace(system_role="admin")))
with pytest.raises(Exception) as exc_info:
await router.reorder_model_configurations(router.ModelOrderRequest(names=["first"]), request)
assert getattr(exc_info.value, "status_code", None) == 409
@pytest.mark.asyncio
async def test_connection_test_uses_one_minimal_prompt_without_writing_config(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.yaml"
original = "models: []\nagent: lead_agent\n"
config_path.write_text(original, encoding="utf-8")
messages_seen = []
class FakeChatModel:
async def ainvoke(self, messages):
messages_seen.extend(messages)
return SimpleNamespace(content="OK")
monkeypatch.setattr(router, "_resolve_config_path", lambda: config_path)
monkeypatch.setattr(router, "create_chat_model", lambda *_args, **_kwargs: FakeChatModel())
request = SimpleNamespace(
state=SimpleNamespace(user=SimpleNamespace(system_role="admin")),
app=SimpleNamespace(state=SimpleNamespace(config=SimpleNamespace(model_copy=lambda **_kwargs: SimpleNamespace()))),
)
body = router.ModelTestRequest(
model=router.ModelUpsertRequest(
name="test-model",
use="langchain_openai:ChatOpenAI",
model="test-model",
api_key="raw-secret",
)
)
result = await router.test_model_connection(body, request)
assert result.success is True
assert result.preview == "OK"
assert len(messages_seen) == 1
assert messages_seen[0].content == "Reply with exactly: OK"
assert config_path.read_text(encoding="utf-8") == original
def test_force_disable_thinking_skips_generic_openai_models_without_thinking_support() -> None:
"""LiteLLM proxies reject vendor-specific thinking payloads for ordinary chat models."""
config = AppConfig.model_construct(
models=[
ModelConfig(
name="litellm-qwen",
use="langchain_openai:ChatOpenAI",
model="qwen3.6",
api_key="test-key",
base_url="http://litellm.example/v1",
supports_thinking=False,
)
]
)
chat_model = create_chat_model(
"litellm-qwen",
app_config=config,
force_disable_thinking=True,
)
assert chat_model.extra_body is None
def test_openai_compatible_thinking_sends_enable_flag_by_default() -> None:
config = AppConfig.model_construct(
models=[
ModelConfig(
name="qwen-thinking",
use="langchain_openai:ChatOpenAI",
model="Qwen3.8-27B",
api_key="test-key",
base_url="http://vllm.example/v1",
supports_thinking=True,
)
]
)
chat_model = create_chat_model(
"qwen-thinking",
app_config=config,
thinking_enabled=True,
)
assert chat_model.extra_body == {
"enable_thinking": True,
"chat_template_kwargs": {"enable_thinking": True},
}
@pytest.mark.parametrize(
("requested", "expected"),
[
("minimal", "low"),
("low", "low"),
("medium", "medium"),
("high", "xhigh"),
("xhigh", "xhigh"),
],
)
def test_qwen_reasoning_effort_is_top_level_only(requested: str, expected: str) -> None:
config = AppConfig.model_construct(models=[_qwen_model(name="qwen")])
chat_model = create_chat_model(
"qwen",
app_config=config,
thinking_enabled=True,
reasoning_effort=requested,
)
payload = chat_model._get_request_payload([HumanMessage(content="hello")])
assert payload["reasoning_effort"] == expected
assert "reasoning_effort" not in payload["extra_body"]["chat_template_kwargs"]
assert payload["extra_body"]["chat_template_kwargs"]["enable_thinking"] is True
def test_final_reasoning_policy_overrides_both_preseeded_runtime_containers() -> None:
config = {
"configurable": {
"thinking_enabled": True,
"reasoning_effort": "high",
},
"context": {
"reasoning_effort": "minimal",
},
}
# The public body context merges with setdefault. The capability gate must
# therefore run afterwards and assign its normalized value to both views.
services.merge_run_context_overrides(
config,
{
"thinking_enabled": True,
"reasoning_effort": "medium",
},
)
normalized = services.enforce_reasoning_effort_run_config(
config,
capability_enabled=True,
model_supports_thinking=True,
default_reasoning_effort="low",
)
assert normalized == "low"
assert config["configurable"]["reasoning_effort"] == "low"
assert config["context"]["reasoning_effort"] == "low"
def test_reasoning_policy_defaults_supported_thinking_model_to_xhigh() -> None:
config = {
"configurable": {"thinking_enabled": True},
"context": {},
}
normalized = services.enforce_reasoning_effort_run_config(
config,
capability_enabled=True,
model_supports_thinking=True,
)
assert normalized == "xhigh"
assert config["configurable"]["reasoning_effort"] == "xhigh"
assert config["context"]["reasoning_effort"] == "xhigh"
@pytest.mark.parametrize(
("capability_enabled", "model_supports_thinking", "thinking_enabled"),
[
(False, True, True),
(True, False, True),
(True, True, False),
],
)
def test_reasoning_policy_strips_effort_when_selection_is_not_available(
capability_enabled: bool,
model_supports_thinking: bool,
thinking_enabled: bool,
) -> None:
config = {
"configurable": {"reasoning_effort": "low"},
"context": {
"thinking_enabled": thinking_enabled,
"reasoning_effort": "medium",
},
}
normalized = services.enforce_reasoning_effort_run_config(
config,
capability_enabled=capability_enabled,
model_supports_thinking=model_supports_thinking,
)
assert normalized is None
assert "reasoning_effort" not in config["configurable"]
assert "reasoning_effort" not in config["context"]
def test_reasoning_policy_uses_global_default_instead_of_stale_thread_value() -> None:
config = {
"configurable": {},
"context": {
"thinking_enabled": True,
"reasoning_effort": "extreme",
},
}
normalized = services.enforce_reasoning_effort_run_config(
config,
capability_enabled=True,
model_supports_thinking=True,
default_reasoning_effort="low",
)
assert normalized == "low"
assert config["configurable"]["reasoning_effort"] == "low"
assert config["context"]["reasoning_effort"] == "low"
@pytest.mark.asyncio
@pytest.mark.parametrize(
("request_config", "body_context", "expected_model", "expected_effort"),
[
(
{"context": {}},
{
"model_name": "qwen-a",
"thinking_enabled": True,
"reasoning_effort": "minimal",
},
"qwen-a",
"medium",
),
(
{
"context": {
"model_name": "qwen-b",
"thinking_enabled": True,
"reasoning_effort": "high",
}
},
{
"model_name": "qwen-a",
"thinking_enabled": True,
"reasoning_effort": "xhigh",
},
"qwen-b",
None,
),
],
)
async def test_start_run_applies_database_reasoning_policy_after_final_context_merge(
request_config: dict,
body_context: dict,
expected_model: str,
expected_effort: str | None,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from app.gateway import analysis_projects
from app.gateway.llmwiki_rag import LlmWikiRagContext
app_config = AppConfig.model_construct(
models=[
_qwen_model(name="qwen-a"),
_qwen_model(name="qwen-b"),
]
)
store = MemoryModelCapabilityStore()
await store.set_reasoning_effort_support(
"qwen-a",
supports_reasoning_effort=True,
default_reasoning_effort="medium",
)
class ThreadStore:
async def get(self, _thread_id):
return None
async def create(self, *_args, **_kwargs):
return None
async def update_status(self, *_args, **_kwargs):
return None
async def update_metadata(self, *_args, **_kwargs):
return None
async def update_assistant_id(self, *_args, **_kwargs):
return None
record = SimpleNamespace(
run_id="run-1",
metadata={},
task=None,
)
class RunManager:
async def create_or_reject(self, *_args, **kwargs):
record.metadata = dict(kwargs["metadata"])
return record
async def set_status(self, *_args, **_kwargs):
return None
def watch_completion(self, _record):
return None
run_context = SimpleNamespace(
app_config=app_config,
thread_store=ThreadStore(),
)
captured: dict[str, object] = {}
async def prepare_project_run(body, _thread_id, _request):
return body, None
async def no_fixed_answer(**_kwargs):
return None
async def empty_rag_context(**_kwargs):
return LlmWikiRagContext([], [], 0, "", [], "")
async def capture_run(**kwargs):
captured.update(kwargs["run_agent_kwargs"])
monkeypatch.setattr(analysis_projects, "prepare_project_run", prepare_project_run)
monkeypatch.setattr(services, "get_stream_bridge", lambda _request: object())
monkeypatch.setattr(services, "get_run_manager", lambda _request: RunManager())
monkeypatch.setattr(services, "get_run_context", lambda _request: run_context)
monkeypatch.setattr(services, "get_concurrency_gate", lambda _request: None)
monkeypatch.setattr(services, "get_effective_user_id", lambda: "user-1")
monkeypatch.setattr(services, "_find_fixed_answer", no_fixed_answer)
monkeypatch.setattr(services, "_get_model_concurrency_limit", lambda _name: None)
monkeypatch.setattr(services, "_get_user_model_concurrency_limit", lambda _name: None)
monkeypatch.setattr(services, "resolve_agent_factory", lambda _assistant_id: object())
monkeypatch.setattr(services, "build_llmwiki_rag_context", empty_rag_context)
monkeypatch.setattr(services, "apply_knowledge_space_strict_scope", lambda *_args, **_kwargs: False)
monkeypatch.setattr(services, "_run_agent_with_concurrency_lease", capture_run)
body = SimpleNamespace(
input={"messages": [{"role": "user", "content": "hello"}]},
config=request_config,
context=body_context,
metadata={},
assistant_id=None,
on_disconnect="continue",
multitask_strategy="reject",
stream_mode=["values"],
stream_subgraphs=False,
interrupt_before=None,
interrupt_after=None,
command=None,
)
request = SimpleNamespace(
app=SimpleNamespace(
state=SimpleNamespace(
config=app_config,
model_capability_store=store,
)
),
client=None,
headers={},
)
result = await services.start_run(
body,
"thread-1",
request,
enforce_access=False,
)
await result.task
final_config = captured["config"]
assert result.metadata["model_name"] == expected_model
if expected_effort is None:
assert "reasoning_effort" not in final_config["configurable"]
assert "reasoning_effort" not in final_config["context"]
assert "reasoning_effort" not in result.metadata
else:
assert final_config["configurable"]["reasoning_effort"] == expected_effort
assert final_config["context"]["reasoning_effort"] == expected_effort
assert result.metadata["reasoning_effort"] == expected_effort