from __future__ import annotations from datetime import datetime from types import SimpleNamespace import pytest import pytest_asyncio from fastapi import FastAPI from httpx import ASGITransport, AsyncClient from sqlalchemy import JSON from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app.api import ai_call_traces_api from app.core.database import get_db from app.dependencies.permissions import require_platform_ai_ops from app.models.ai_call_log import AICallLog from app.models.base import Base from app.models.integration import Integration def _use_sqlite_json_types(*tables) -> None: for table in tables: for column in table.columns: if column.type.__class__.__name__ in {"JSONB", "ARRAY", "Vector"}: column.type = JSON() @pytest_asyncio.fixture async def db_session(): engine = create_async_engine("sqlite+aiosqlite:///:memory:") _use_sqlite_json_types(AICallLog.__table__, Integration.__table__) async with engine.begin() as conn: await conn.run_sync(lambda sync_conn: Base.metadata.create_all(sync_conn, tables=[AICallLog.__table__, Integration.__table__])) maker = async_sessionmaker(engine, expire_on_commit=False) async with maker() as session: yield session await engine.dispose() @pytest.mark.asyncio async def test_ai_call_trace_list_and_detail_redact_payload(db_session) -> None: db_session.add( Integration( tenant_id=1, type="llm_engine", is_active=True, config={ "engines": [ { "id": "engine-qwen-plus", "name": "阿里云-qwen-plus", "provider": "dashscope", "model": "qwen-plus", } ] }, ) ) db_session.add( AICallLog( tenant_id=1, user_id=7, function_name="pmflow_launch_draft_v1", prompt="用户手机号 13800138000,key=sk-secretsecretsecretsecret,请生成项目草案", response='{"ok":true,"routing_meta":{"scene_key":"project_launch","slot_key":"structured"}}', tokens_used=128, cost=0.001, status="success", trace_id="trace-1", provider="dashscope", model="qwen-plus", latency_ms=345, routing_meta={"scene_key": "project_launch", "slot_key": "structured", "route_source": "active_slot"}, prompt_key="PROMPT_PM_FLOW_LAUNCH_DRAFT", prompt_release_id=12, prompt_source="active_release", provider_request_payload={ "model": "qwen-plus", "messages": [ {"role": "system", "content": "你是项目助手"}, {"role": "user", "content": "手机号 13800138000,key=sk-secretsecretsecretsecret"}, ], "temperature": 0.0, "max_tokens": 5600, }, provider_response_payload={ "id": "cmpl-test", "choices": [{"message": {"content": "{\"ok\":true}"}, "finish_reason": "stop"}], "usage": {"prompt_tokens": 90, "completion_tokens": 38, "total_tokens": 128}, }, provider_attempts=[ { "provider": "dashscope", "model": "qwen-plus", "request_payload": {"model": "qwen-plus", "messages": [{"role": "user", "content": "sk-secretsecretsecretsecret"}]}, "response_payload": {"choices": [{"message": {"content": "{\"ok\":true}"}}]}, "status": "success", } ], planned_tier="structured", executed_tier="structured", routing_decision="slot", route_source="active_slot", route_rule_name="project_launch", created_at=datetime(2026, 4, 29, 10, 0, 0), ) ) await db_session.commit() app = FastAPI() app.include_router(ai_call_traces_api.router) async def override_get_db(): yield db_session app.dependency_overrides[get_db] = override_get_db app.dependency_overrides[require_platform_ai_ops] = lambda: SimpleNamespace(id=1, role="OWNER") async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: list_response = await client.get("/ai-call-traces?provider=dashscope&scene_key=project_launch&prompt_key=PROMPT_PM_FLOW_LAUNCH_DRAFT") detail_response = await client.get("/ai-call-traces/trace-1") assert list_response.status_code == 200 payload = list_response.json()["data"] assert payload["summary"]["total_calls"] == 1 assert payload["items"][0]["provider"] == "dashscope" assert payload["items"][0]["provider_code"] == "dashscope" assert payload["items"][0]["provider_label"] == "Qwen / DashScope" assert payload["items"][0]["runtime_label"] == "阿里云-qwen-plus" assert payload["items"][0]["engine_name"] == "阿里云-qwen-plus" assert payload["items"][0]["function_label"] == "新项目草案生成" assert payload["items"][0]["scene_key"] == "project_launch" assert payload["items"][0]["slot_key"] == "structured" assert payload["items"][0]["prompt_key"] == "PROMPT_PM_FLOW_LAUNCH_DRAFT" assert payload["items"][0]["prompt_release_id"] == 12 assert payload["items"][0]["prompt_source"] == "active_release" assert payload["items"][0]["prompt_tokens"] == 90 assert payload["items"][0]["completion_tokens"] == 38 assert payload["items"][0]["request_max_tokens"] == 5600 assert payload["items"][0]["finish_reason"] == "stop" assert "13800138000" not in payload["items"][0]["prompt_preview"] assert "sk-secret" not in payload["items"][0]["prompt_preview"] assert detail_response.status_code == 200 detail = detail_response.json()["data"] assert detail["request"]["prompt_chars"] > 0 assert detail["runtime_label"] == "阿里云-qwen-plus" assert "[REDACTED]" in detail["request"]["prompt_redacted"] assert detail["request"]["provider_request_payload_chars"] > 0 assert "qwen-plus" in detail["request"]["provider_request_payload_redacted"] assert "13800138000" not in detail["request"]["provider_request_payload_redacted"] assert "sk-secret" not in detail["request"]["provider_request_payload_redacted"] assert detail["response"]["provider_response_payload_chars"] > 0 assert detail["response"]["provider_attempts_count"] == 1 assert detail["routing"]["routing_meta"]["slot_key"] == "structured" assert detail["audit"]["raw_payload_available"] is True @pytest.mark.asyncio async def test_ai_call_trace_list_shows_real_capability_probe_calls_by_default(db_session) -> None: db_session.add_all( [ AICallLog( tenant_id=1, user_id=7, function_name="probe_engine_capabilities", prompt="basic_chat,structured_output", response='{"basic_chat":{"status":"verified"}}', tokens_used=100, cost=0.001, status="capability_probe_partial", trace_id="trace-probe-summary", provider="dashscope", model="qwen-plus", routing_meta={ "scene_key": "platform_ai_ops", "routing_decision": "direct_capability_probe", "route_source": "provider_console", "probe_run_id": "cap-1", "probe_targets": ["basic_chat", "structured_output"], }, provider_attempts=[ {"probe_target": "basic_chat", "status": "success"}, {"probe_target": "structured_output", "status": "success"}, ], created_at=datetime(2026, 4, 29, 10, 1, 0), ), AICallLog( tenant_id=1, user_id=7, function_name="probe_capability_basic_chat", prompt="发送最小连通 prompt,要求模型只返回 OK。", response="OK", tokens_used=6, cost=0.00001, status="capability_verified", trace_id="trace-probe-basic-chat", provider="dashscope", model="qwen-plus", routing_meta={ "scene_key": "platform_ai_ops", "routing_decision": "direct_capability_probe", "route_source": "provider_console", "probe_run_id": "cap-1", "probe_target": "basic_chat", }, created_at=datetime(2026, 4, 29, 10, 1, 1), ), ] ) await db_session.commit() app = FastAPI() app.include_router(ai_call_traces_api.router) async def override_get_db(): yield db_session app.dependency_overrides[get_db] = override_get_db app.dependency_overrides[require_platform_ai_ops] = lambda: SimpleNamespace(id=1, role="OWNER") async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: default_response = await client.get("/ai-call-traces?provider=dashscope") legacy_summary_response = await client.get("/ai-call-traces?provider=dashscope&function_name=probe_engine_capabilities") assert default_response.status_code == 200 default_payload = default_response.json()["data"] assert default_payload["total"] == 1 assert default_payload["summary"]["total_calls"] == 1 assert default_payload["items"][0]["function_name"] == "probe_capability_basic_chat" assert default_payload["items"][0]["function_label"] == "能力探测:基础聊天" assert legacy_summary_response.status_code == 200 legacy_summary_payload = legacy_summary_response.json()["data"] assert legacy_summary_payload["total"] == 1 assert legacy_summary_payload["items"][0]["function_name"] == "probe_engine_capabilities" assert legacy_summary_payload["items"][0]["function_label"] == "供应商能力探测汇总(历史兼容)" @pytest.mark.asyncio async def test_ai_call_trace_structured_timeout_has_specific_diagnosis(db_session) -> None: db_session.add( AICallLog( tenant_id=1, user_id=7, function_name="pmflow_launch_draft_v1", prompt="请生成项目草案", response="模型调用失败:function=pmflow_launch_draft_v1, reason=upstream_empty_or_invalid_response", tokens_used=128, cost=0.001, status="structured_timeout", trace_id="trace-timeout", provider="dashscope", model="qwen-plus", routing_meta={ "scene_key": "project_launch", "slot_key": "structured", "provider_timeout_seconds": 90, "job_timeout_seconds": 180, "input_bucket": "normal", "scene_timeout_policy_id": "project_launch:pmflow_launch_draft_v1:normal", "scene_timeout_policy_version": "scene-timeout-v1", }, provider_attempts=[ {"status": "request_error", "error_type": "ReadTimeout", "error": ""}, {"status": "request_error", "error_type": "ReadTimeout", "error": ""}, ], planned_tier="structured", executed_tier="structured", route_source="slot_binding", created_at=datetime(2026, 5, 1, 19, 37, 42), ) ) await db_session.commit() app = FastAPI() app.include_router(ai_call_traces_api.router) async def override_get_db(): yield db_session app.dependency_overrides[get_db] = override_get_db app.dependency_overrides[require_platform_ai_ops] = lambda: SimpleNamespace(id=1, role="OWNER") async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: list_response = await client.get("/ai-call-traces?status=structured_timeout") detail_response = await client.get("/ai-call-traces/trace-timeout") assert list_response.status_code == 200 payload = list_response.json()["data"] assert payload["summary"]["failed_calls"] == 1 assert payload["items"][0]["status_label"] == "模型超时" assert payload["items"][0]["error_diagnosis"]["code"] == "model_read_timeout" assert payload["items"][0]["error_diagnosis"]["attempt_count"] == 2 assert detail_response.status_code == 200 detail = detail_response.json()["data"] assert detail["runtime_policy"]["provider_timeout_seconds"] == 90 assert detail["runtime_policy"]["job_timeout_seconds"] == 180 assert detail["runtime_policy"]["input_bucket"] == "normal" assert detail["error_diagnosis"]["attempt_errors"][0]["error_type"] == "ReadTimeout" assert "fallback runtime" in " ".join(detail["error_diagnosis"]["recommendation"]["recommended_actions"])