Files
X-Financial/server/tests/test_runtime_chat_attempts.py

717 lines
24 KiB
Python
Raw Permalink Normal View History

from __future__ import annotations
import pytest
from runtime_chat_testkit import (
DenyingAttemptObserver,
FailingCompletionObserver,
FailingPermitObserver,
RecordingAttemptObserver,
build_operation_context,
patch_single_slot,
)
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.db.base import Base
from app.services import runtime_chat as runtime_chat_module
from app.services.model_connectivity import ConnectivityCheckError
from app.services.runtime_chat import (
RuntimeChatOperationContext,
RuntimeChatService,
)
def build_session_factory() -> sessionmaker[Session]:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
return sessionmaker(bind=engine, autoflush=False, autocommit=False)
def _clear_runtime_chat_cooldown() -> None:
runtime_chat_module._slot_failure_until.clear()
@pytest.mark.parametrize(
("provider", "configured_model", "response_payload", "expected_usage"),
[
(
"OpenAI Compatible",
"gpt-configured",
{
"id": "chatcmpl-openai-001",
"model": "gpt-response",
"choices": [{"message": {"content": "openai answer"}}],
"usage": {
"prompt_tokens": 17,
"completion_tokens": 5,
"total_tokens": 22,
},
},
{
"prompt_tokens": 17,
"completion_tokens": 5,
"total_tokens": 22,
"prompt_eval_count": None,
"eval_count": None,
},
),
(
"Azure OpenAI",
"azure-deployment",
{
"id": "chatcmpl-azure-001",
"model": "azure-response-model",
"choices": [{"message": {"content": "azure answer"}}],
"usage": {
"prompt_tokens": 23,
"completion_tokens": 7,
"total_tokens": 30,
},
},
{
"prompt_tokens": 23,
"completion_tokens": 7,
"total_tokens": 30,
"prompt_eval_count": None,
"eval_count": None,
},
),
(
"Ollama",
"llama-configured",
{
"model": "llama-response",
"message": {"content": "ollama answer"},
"prompt_eval_count": 31,
"eval_count": 11,
},
{
"prompt_tokens": None,
"completion_tokens": None,
"total_tokens": None,
"prompt_eval_count": 31,
"eval_count": 11,
},
),
],
)
def test_runtime_chat_preserves_authoritative_provider_usage(
monkeypatch,
provider: str,
configured_model: str,
response_payload: dict[str, object],
expected_usage: dict[str, int | None],
) -> None:
_clear_runtime_chat_cooldown()
session_factory = build_session_factory()
observer = RecordingAttemptObserver()
with session_factory() as db:
service = RuntimeChatService(db, attempt_observer=observer)
patch_single_slot(
monkeypatch,
service,
provider=provider,
model=configured_model,
)
monkeypatch.setattr(
"app.services.runtime_chat._send_json_request",
lambda *_args, **_kwargs: (200, response_payload),
)
result = service.complete_with_trace(
[{"role": "user", "content": "hello"}],
slot_priority=("main",),
max_attempts=1,
operation_context=build_operation_context(),
)
assert result.text is not None
assert len(result.calls) == 1
trace = result.calls[0]
assert trace.provider == provider
assert trace.model == configured_model
assert trace.attempt == 1
assert trace.response_id == response_payload.get("id")
assert trace.response_model == response_payload.get("model")
assert trace.outcome == "succeeded"
assert trace.started_at is not None
assert trace.completed_at is not None
assert trace.completed_at >= trace.started_at
assert trace.usage.availability == "available"
for field_name, expected_value in expected_usage.items():
assert getattr(trace.usage, field_name) == expected_value
assert trace.observer_status == "completed_notified"
assert len(observer.permits) == 1
assert len(observer.completed) == 1
assert observer.completed[0].identity.tenant_id == "tenant-runtime-chat"
assert observer.completed[0].identity.invocation_seq == 7
assert observer.completed[0].usage == trace.usage
def test_runtime_chat_keeps_missing_usage_unavailable_without_estimation(
monkeypatch,
) -> None:
_clear_runtime_chat_cooldown()
session_factory = build_session_factory()
observer = RecordingAttemptObserver()
with session_factory() as db:
service = RuntimeChatService(db, attempt_observer=observer)
patch_single_slot(
monkeypatch,
service,
provider="OpenAI Compatible",
model="gpt-no-usage",
)
monkeypatch.setattr(
"app.services.runtime_chat._send_json_request",
lambda *_args, **_kwargs: (
200,
{
"id": "chatcmpl-no-usage",
"model": "gpt-no-usage",
"choices": [{"message": {"content": "answer"}}],
},
),
)
result = service.complete_with_trace(
[{"role": "user", "content": "hello"}],
slot_priority=("main",),
max_tokens=9876,
max_attempts=1,
operation_context=build_operation_context(),
)
usage = result.calls[0].usage
assert usage.availability == "unavailable"
assert usage.source == "unavailable"
assert usage.prompt_tokens is None
assert usage.completion_tokens is None
assert usage.total_tokens is None
assert observer.completed[0].usage == usage
def test_runtime_chat_notifies_every_real_retry_attempt_and_marks_timeout_unknown(
monkeypatch,
) -> None:
_clear_runtime_chat_cooldown()
session_factory = build_session_factory()
timeline: list[str] = []
observer = RecordingAttemptObserver(timeline)
request_count = 0
def fake_send_json_request(*_args, **_kwargs):
nonlocal request_count
request_count += 1
timeline.append(f"send:{request_count}")
if request_count == 1:
raise TimeoutError("provider response timed out")
return 200, {
"id": "chatcmpl-retry-002",
"model": "gpt-retry-response",
"choices": [
{
"message": {
"tool_calls": [
{
"id": "call-retry-002",
"function": {
"name": "submit_plan",
"arguments": "{}",
},
}
]
}
}
],
"usage": {
"prompt_tokens": 41,
"completion_tokens": 3,
"total_tokens": 44,
},
}
with session_factory() as db:
service = RuntimeChatService(db, attempt_observer=observer)
patch_single_slot(
monkeypatch,
service,
provider="OpenAI Compatible",
model="gpt-retry",
)
monkeypatch.setattr(
"app.services.runtime_chat._send_json_request",
fake_send_json_request,
)
monkeypatch.setattr("app.services.runtime_chat.sleep", lambda *_args: None)
result = service.complete_with_tool_call(
[{"role": "user", "content": "build plan"}],
tools=[{"type": "function", "function": {"name": "submit_plan"}}],
slot_priority=("main",),
max_attempts=2,
use_failure_cooldown=False,
operation_context=build_operation_context(),
)
assert result.tool_call is not None
assert [item.attempt for item in result.calls] == [1, 2]
assert [item.outcome for item in result.calls] == [
"outcome_unknown",
"succeeded",
]
assert result.calls[0].usage.availability == "unavailable"
assert result.calls[1].usage.total_tokens == 44
assert [item.identity.attempt for item in observer.permits] == [1, 2]
assert [item.identity.attempt for item in observer.completed] == [1, 2]
assert observer.completed[0].request_may_have_been_sent is True
assert observer.completed[0].requires_reconciliation is True
assert result.calls[0].requires_reconciliation is True
assert observer.permits[0].identity.attempt_key != observer.permits[1].identity.attempt_key
assert timeline == [
"permit:1",
"send:1",
"completed:1:outcome_unknown",
"permit:2",
"send:2",
"completed:2:succeeded",
]
def test_runtime_chat_preserves_usage_when_tool_response_postprocessing_fails(
monkeypatch,
) -> None:
_clear_runtime_chat_cooldown()
session_factory = build_session_factory()
observer = RecordingAttemptObserver()
with session_factory() as db:
service = RuntimeChatService(db, attempt_observer=observer)
patch_single_slot(
monkeypatch,
service,
provider="OpenAI Compatible",
model="gpt-postprocess",
)
monkeypatch.setattr(
"app.services.runtime_chat._send_json_request",
lambda *_args, **_kwargs: (
200,
{
"id": "chatcmpl-postprocess",
"model": "gpt-postprocess-response",
"choices": [
{
"message": {
"tool_calls": [
{
"id": "call-invalid-json",
"function": {
"name": "submit_plan",
"arguments": "{invalid-json",
},
}
]
}
}
],
"usage": {
"prompt_tokens": 13,
"completion_tokens": 2,
"total_tokens": 15,
},
},
),
)
result = service.complete_with_tool_call(
[{"role": "user", "content": "build plan"}],
tools=[{"type": "function", "function": {"name": "submit_plan"}}],
slot_priority=("main",),
max_attempts=1,
use_failure_cooldown=False,
operation_context=build_operation_context(),
)
assert result.tool_call is None
assert result.calls[0].outcome == "postprocess_failed"
assert result.calls[0].response_id == "chatcmpl-postprocess"
assert result.calls[0].usage.total_tokens == 15
assert observer.completed[0].outcome == "postprocess_failed"
assert observer.completed[0].usage.total_tokens == 15
def test_runtime_chat_observer_failure_keeps_success_and_reports_reconciliation(
monkeypatch,
) -> None:
_clear_runtime_chat_cooldown()
session_factory = build_session_factory()
observer = FailingCompletionObserver()
with session_factory() as db:
service = RuntimeChatService(db, attempt_observer=observer)
patch_single_slot(
monkeypatch,
service,
provider="OpenAI Compatible",
model="gpt-observer-failure",
)
monkeypatch.setattr(
"app.services.runtime_chat._send_json_request",
lambda *_args, **_kwargs: (
200,
{
"id": "chatcmpl-observer-failure",
"model": "gpt-observer-response",
"choices": [{"message": {"content": "valid answer"}}],
"usage": {
"prompt_tokens": 8,
"completion_tokens": 2,
"total_tokens": 10,
},
},
),
)
result = service.complete_with_trace(
[{"role": "user", "content": "hello"}],
slot_priority=("main",),
max_attempts=1,
operation_context=build_operation_context(),
)
assert result.text == "valid answer"
trace = result.calls[0]
assert trace.outcome == "succeeded"
assert trace.usage.total_tokens == 10
assert trace.observer_status == "reconciliation_required"
assert [item.phase for item in trace.observer_failures] == ["completion"]
assert len(observer.failures) == 1
assert observer.failures[0].identity.operation_id == "operation-runtime-chat-001"
def test_runtime_chat_without_operation_context_does_not_invoke_observer(
monkeypatch,
) -> None:
_clear_runtime_chat_cooldown()
session_factory = build_session_factory()
observer = RecordingAttemptObserver()
with session_factory() as db:
service = RuntimeChatService(db, attempt_observer=observer)
patch_single_slot(
monkeypatch,
service,
provider="OpenAI Compatible",
model="gpt-no-context",
)
monkeypatch.setattr(
"app.services.runtime_chat._send_json_request",
lambda *_args, **_kwargs: (
200,
{
"id": "chatcmpl-no-context",
"choices": [{"message": {"content": "legacy answer"}}],
"usage": {
"prompt_tokens": 5,
"completion_tokens": 2,
"total_tokens": 7,
},
},
),
)
result = service.complete_with_trace(
[{"role": "user", "content": "hello"}],
slot_priority=("main",),
max_attempts=1,
)
assert result.text == "legacy answer"
assert result.calls[0].usage.total_tokens == 7
assert result.calls[0].observer_status == "not_applicable"
assert observer.permits == []
assert observer.completed == []
assert observer.failures == []
def test_runtime_chat_explicit_permit_denial_is_not_sent(monkeypatch) -> None:
_clear_runtime_chat_cooldown()
session_factory = build_session_factory()
observer = DenyingAttemptObserver()
with session_factory() as db:
service = RuntimeChatService(db, attempt_observer=observer)
patch_single_slot(
monkeypatch,
service,
provider="OpenAI Compatible",
model="gpt-denied",
)
monkeypatch.setattr(
"app.services.runtime_chat._send_json_request",
lambda *_args, **_kwargs: pytest.fail("permit 拒绝后不应发送请求"),
)
result = service.complete_with_trace(
[{"role": "user", "content": "hello"}],
slot_priority=("main",),
max_attempts=1,
operation_context=build_operation_context(),
)
assert result.text is None
assert result.calls[0].status == "blocked"
assert result.calls[0].outcome == "not_sent"
assert observer.completed[0].request_may_have_been_sent is False
assert observer.completed[0].usage.availability == "unavailable"
def test_runtime_chat_provider_http_error_is_rejected_with_status(
monkeypatch,
) -> None:
_clear_runtime_chat_cooldown()
observer = RecordingAttemptObserver()
session_factory = build_session_factory()
with session_factory() as db:
service = RuntimeChatService(db, attempt_observer=observer)
patch_single_slot(
monkeypatch,
service,
provider="OpenAI Compatible",
model="gpt-rate-limited",
)
def reject_request(*_args, **_kwargs):
raise ConnectivityCheckError("rate limited", status_code=429)
monkeypatch.setattr(
"app.services.runtime_chat._send_json_request",
reject_request,
)
result = service.complete_with_trace(
[{"role": "user", "content": "hello"}],
slot_priority=("main",),
max_attempts=1,
operation_context=build_operation_context(),
)
assert result.text is None
trace = result.calls[0]
assert trace.outcome == "provider_rejected"
assert trace.provider_status_code == 429
assert trace.requires_reconciliation is False
assert observer.completed[0].provider_status_code == 429
assert observer.completed[0].request_may_have_been_sent is True
def test_runtime_chat_rejected_response_preserves_authoritative_usage(
monkeypatch,
) -> None:
_clear_runtime_chat_cooldown()
observer = RecordingAttemptObserver()
session_factory = build_session_factory()
with session_factory() as db:
service = RuntimeChatService(db, attempt_observer=observer)
patch_single_slot(
monkeypatch,
service,
provider="OpenAI Compatible",
model="gpt-rejected-with-usage",
)
monkeypatch.setattr(
"app.services.runtime_chat._send_json_request",
lambda *_args, **_kwargs: (
429,
{
"id": "chatcmpl-rejected",
"model": "gpt-rejected-response",
"usage": {
"prompt_tokens": 9,
"completion_tokens": 0,
"total_tokens": 9,
},
},
),
)
result = service.complete_with_trace(
[{"role": "user", "content": "hello"}],
slot_priority=("main",),
max_attempts=1,
operation_context=build_operation_context(),
)
trace = result.calls[0]
assert trace.outcome == "provider_rejected"
assert trace.provider_status_code == 429
assert trace.response_id == "chatcmpl-rejected"
assert trace.response_model == "gpt-rejected-response"
assert trace.usage.availability == "available"
assert trace.usage.total_tokens == 9
assert observer.completed[0].usage == trace.usage
def test_runtime_chat_pre_send_adapter_failure_is_not_sent(monkeypatch) -> None:
_clear_runtime_chat_cooldown()
observer = RecordingAttemptObserver()
session_factory = build_session_factory()
with session_factory() as db:
service = RuntimeChatService(db, attempt_observer=observer)
patch_single_slot(
monkeypatch,
service,
provider="OpenAI Compatible",
model="gpt-invalid-endpoint",
)
monkeypatch.setattr(
"app.services.runtime_chat.request_openai_compatible_completion",
lambda **_kwargs: (_ for _ in ()).throw(ValueError("invalid endpoint")),
)
result = service.complete_with_trace(
[{"role": "user", "content": "hello"}],
slot_priority=("main",),
max_attempts=1,
operation_context=build_operation_context(),
)
assert result.calls[0].outcome == "not_sent"
assert result.calls[0].provider_status_code is None
assert observer.completed[0].request_may_have_been_sent is False
def test_runtime_chat_permit_observer_failure_blocks_provider_and_reconciles(
monkeypatch,
) -> None:
_clear_runtime_chat_cooldown()
observer = FailingPermitObserver()
session_factory = build_session_factory()
with session_factory() as db:
service = RuntimeChatService(db, attempt_observer=observer)
patch_single_slot(
monkeypatch,
service,
provider="OpenAI Compatible",
model="gpt-permit-failure",
)
monkeypatch.setattr(
"app.services.runtime_chat._send_json_request",
lambda *_args, **_kwargs: pytest.fail(
"permit 状态未知时不应发送 provider 请求"
),
)
result = service.complete_with_trace(
[{"role": "user", "content": "hello"}],
slot_priority=("main",),
max_attempts=1,
operation_context=build_operation_context(),
)
trace = result.calls[0]
assert result.text is None
assert trace.status == "blocked"
assert trace.outcome == "not_sent"
assert trace.observer_status == "reconciliation_required"
assert trace.requires_reconciliation is True
assert [failure.phase for failure in trace.observer_failures] == ["permit"]
assert len(observer.completed) == 1
assert observer.completed[0].request_may_have_been_sent is False
def test_runtime_chat_trusted_context_without_observer_requires_reconciliation(
monkeypatch,
) -> None:
_clear_runtime_chat_cooldown()
session_factory = build_session_factory()
with session_factory() as db:
service = RuntimeChatService(db)
patch_single_slot(
monkeypatch,
service,
provider="OpenAI Compatible",
model="gpt-unobserved",
)
monkeypatch.setattr(
"app.services.runtime_chat._send_json_request",
lambda *_args, **_kwargs: (
200,
{
"id": "chatcmpl-unobserved",
"choices": [{"message": {"content": "answer"}}],
"usage": {
"prompt_tokens": 3,
"completion_tokens": 1,
"total_tokens": 4,
},
},
),
)
result = service.complete_with_trace(
[{"role": "user", "content": "hello"}],
slot_priority=("main",),
max_attempts=1,
operation_context=build_operation_context(),
)
assert result.text == "answer"
assert result.calls[0].observer_status == "not_configured"
assert result.calls[0].requires_reconciliation is True
@pytest.mark.parametrize(
"context_kwargs",
[
{"tenant_id": ""},
{"operation_id": ""},
{"invocation_seq": 0},
{"invocation_seq": True},
{"invocation_seq": "1"},
{"attempt_scope": ""},
],
)
def test_runtime_chat_operation_context_rejects_untrusted_empty_identity(
context_kwargs,
) -> None:
values = {
"tenant_id": "tenant-trusted",
"operation_id": "operation-trusted",
"invocation_seq": 1,
"attempt_scope": "trusted-entry",
}
values.update(context_kwargs)
with pytest.raises(ValueError):
RuntimeChatOperationContext(**values)
def test_runtime_chat_attempt_key_has_unambiguous_opaque_identity() -> None:
common = {
"run_id": "run",
"invocation_seq": 1,
"attempt_scope": "scope",
}
first = RuntimeChatOperationContext(
tenant_id="tenant|operation",
operation_id="id",
**common,
).build_attempt_identity(
slot="main",
provider="OpenAI Compatible",
model="gpt-test",
attempt=1,
)
second = RuntimeChatOperationContext(
tenant_id="tenant",
operation_id="operation|id",
**common,
).build_attempt_identity(
slot="main",
provider="OpenAI Compatible",
model="gpt-test",
attempt=1,
)
assert first.attempt_key != second.attempt_key
assert first.attempt_key.startswith("runtime-chat-attempt:v1:")
assert "tenant" not in first.attempt_key
assert "operation" not in first.attempt_key