Обе работы приняты, проверено исполнением. A24: ровно семь разделов, renderTeam и renderProviders удалены, кнопки «Изменить цепочку» нет, перетаскивание блоков есть. Перестановка цепочки проверена вживую: сохраняется в router_profiles.yaml и откатывается. A25: провайдер local подключён к настоящему серверу владельца через SSH-туннель к 127.0.0.1:8081. health_check проходит, /v1/models отдаёт модель, реальный вызов возвращает «ОК» за 3.1 с. Профили local-1 и local-2 добавлены. Квота отдаётся отдельным состоянием (source=local_provider, «Без ограничений»), а не как отсутствие данных. Адрес сервера нигде не зашит. Разрешение конфликта в action_handler: A25 внёс edit_route и assign_role в список «просто навигация», где они возвращают заглушку. В A24 это работающие обработчики — сохранение цепочки и назначение роли. Приняв версию A25 целиком, мы бы молча сломали перестановку блоков. Оставлены оба: локальный провайдер в add_account и рабочие обработчики ниже. Исправлено при слиянии: 1. Адаптер отдавал пустой ответ как успех. У сервера владельца --reasoning on --reasoning-budget 4096: при скромном max_tokens весь бюджет уходит на рассуждения, llama.cpp возвращает 200, заполняет reasoning_content и оставляет content пустым. Проверено на живой модели: max_tokens=40 — ответа нет, 200 — приходит «ОК». Роутер засчитал бы такой вызов, а пользователь не получил бы ничего. Теперь это явный отказ с объяснением, и срабатывает переключение. Проверка вынесена из блока перехвата: иначе оборачивалась в «Transport Error», хотя транспорт отработал штатно. 2. Заглушка в тесте A25 возвращала "choices": [] — такого настоящий сервер не отдаёт. Приведена к реальному виду. Тесты: 404 passed, ruff чисто. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
51 lines
2 KiB
Python
51 lines
2 KiB
Python
"""Пустой ответ локальной модели — отказ, а не успех.
|
||
|
||
Сервер владельца поднят с ``--reasoning on --reasoning-budget 4096``. При
|
||
скромном ``max_tokens`` весь бюджет уходит на рассуждения: llama.cpp
|
||
возвращает 200, заполняет ``reasoning_content`` и оставляет ``content``
|
||
пустым. Проверено на живой модели через SSH-туннель: с max_tokens=40
|
||
ответа нет, с 200 приходит «ОК».
|
||
|
||
Если отдать такой ответ дальше как успешный, роутер засчитает вызов, а
|
||
пользователь не получит ничего и не узнает почему.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
|
||
from antigravity_provider.router.adapters.local_adapter import LocalLLMAdapter
|
||
|
||
|
||
def test_answer_with_content_passes():
|
||
data = {
|
||
"choices": [
|
||
{"index": 0, "message": {"role": "assistant", "content": "ОК"}, "finish_reason": "stop"}
|
||
]
|
||
}
|
||
LocalLLMAdapter._reject_empty_answer(data) # не должно бросать
|
||
|
||
|
||
def test_reasoning_ate_the_budget_is_rejected():
|
||
data = {
|
||
"choices": [
|
||
{
|
||
"index": 0,
|
||
"message": {"role": "assistant", "content": "", "reasoning_content": "долгие раздумья"},
|
||
"finish_reason": "length",
|
||
}
|
||
]
|
||
}
|
||
with pytest.raises(RuntimeError, match="рассужд"):
|
||
LocalLLMAdapter._reject_empty_answer(data)
|
||
|
||
|
||
def test_empty_choices_is_rejected():
|
||
with pytest.raises(RuntimeError, match="choices"):
|
||
LocalLLMAdapter._reject_empty_answer({"choices": []})
|
||
|
||
|
||
def test_blank_content_without_reasoning_is_rejected():
|
||
data = {"choices": [{"index": 0, "message": {"content": " "}, "finish_reason": "stop"}]}
|
||
with pytest.raises(RuntimeError, match="пустой"):
|
||
LocalLLMAdapter._reject_empty_answer(data)
|