497 lines
21 KiB
Python
497 lines
21 KiB
Python
"""测试侧独立 HTTP 取证装配;无环境自读取或成功 SSE 预读。"""
|
|
|
|
from collections.abc import AsyncIterator, Iterator, Mapping
|
|
from contextlib import AsyncExitStack, asynccontextmanager, contextmanager
|
|
from contextvars import ContextVar
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from polygateway import GatewayClient, GatewaySettings
|
|
from polygateway.client import (
|
|
_aclose_component,
|
|
_build_breaker,
|
|
_build_limiter,
|
|
_build_selector,
|
|
_build_structured,
|
|
)
|
|
from polygateway.providers import get_provider
|
|
from polygateway.transports.openai_compat import OpenAICompatTransport
|
|
from polygateway.types import Effort, EmbeddingTransportResult, SourceConfig, TransportResult
|
|
from tests.live_evidence import (
|
|
AttemptEvidence,
|
|
HttpEvidence,
|
|
LiveVerdict,
|
|
assess_model_identity,
|
|
classify_live_failure,
|
|
messages_digest,
|
|
request_is_valid,
|
|
safe_attempts,
|
|
strict_json,
|
|
write_live_round,
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class _Exchange:
|
|
"""仅在 attempt 生命周期持有原始响应引用。"""
|
|
|
|
request: httpx.Request
|
|
checks: tuple[tuple[str, bool], ...]
|
|
response: httpx.Response | None = None
|
|
|
|
|
|
@dataclass
|
|
class _Attempt:
|
|
"""任务内可变收集器,结束时转换为冻结快照。"""
|
|
|
|
call_id: str
|
|
exchanges: list[_Exchange] = field(default_factory=list)
|
|
|
|
|
|
class LiveCapture:
|
|
"""矩阵显式预期与按逻辑轮次关联的独立证据。"""
|
|
|
|
def __init__(self, *, expectations: Mapping[str, Mapping[str, Any]]) -> None:
|
|
"""预期缺项即配置错误,不从实发 payload 补齐。"""
|
|
for expected in expectations.values():
|
|
required = {"model", "origin", "path", "control", "messages_digest"}
|
|
if not required <= expected.keys() or ("stream" in expected) == (
|
|
"input_shape" in expected
|
|
):
|
|
raise ValueError("取证矩阵缺少必需预期或混用 chat/embed")
|
|
if not isinstance(expected["control"], dict):
|
|
raise ValueError("control 必须是显式对象")
|
|
self._expectations = {name: dict(value) for name, value in expectations.items()}
|
|
self._round: ContextVar[tuple[str, str]] = ContextVar("live_round")
|
|
self._attempt: ContextVar[_Attempt] = ContextVar("live_attempt")
|
|
self._records: dict[tuple[str, str], list[AttemptEvidence]] = {}
|
|
self._owners: dict[str, tuple[str, str]] = {}
|
|
self._notes: dict[tuple[str, str], list[str]] = {}
|
|
|
|
@contextmanager
|
|
def round_context(self, *, session_id: str, parent_call_id: str) -> Iterator[None]:
|
|
"""外围逻辑轮次绑定,异常和取消均复位。"""
|
|
key = session_id, parent_call_id
|
|
token = self._round.set(key)
|
|
self._records.setdefault(key, [])
|
|
self._notes.setdefault(key, [])
|
|
try:
|
|
yield
|
|
finally:
|
|
self._round.reset(token)
|
|
|
|
@contextmanager
|
|
def attempt_context(self, call_id: str) -> Iterator[None]:
|
|
"""零 HTTP 尝试也有快照;重复/跨轮 UUID 是契约错误。"""
|
|
key = self._round.get()
|
|
if call_id in self._owners:
|
|
raise ValueError("重复或跨轮 call_id")
|
|
self._owners[call_id] = key
|
|
attempt = _Attempt(call_id)
|
|
token = self._attempt.set(attempt)
|
|
error = None
|
|
try:
|
|
yield
|
|
except Exception as exc:
|
|
error = exc
|
|
raise
|
|
finally:
|
|
try:
|
|
events = tuple(
|
|
self._snapshot(exchange, call_id, key) for exchange in attempt.exchanges
|
|
)
|
|
self._records[key].append(AttemptEvidence(call_id, events, error))
|
|
finally:
|
|
self._attempt.reset(token)
|
|
|
|
def _snapshot(self, exchange: _Exchange, call_id: str, key: tuple[str, str]) -> HttpEvidence:
|
|
"""complete 结束后只读已缓冲内容,拒绝截断和歧义身份。"""
|
|
response = exchange.response
|
|
identity: tuple[bool, str | None] = (False, None)
|
|
body = None
|
|
status = response.status_code if response is not None else 0
|
|
if response is None:
|
|
self._notes[key].append("无可配对响应")
|
|
else:
|
|
try:
|
|
content = response.content
|
|
except httpx.ResponseNotRead:
|
|
self._notes[key].append("响应未缓冲,证据不足")
|
|
else:
|
|
if status >= 400:
|
|
if len(content) <= 65536:
|
|
body = content
|
|
else:
|
|
self._notes[key].append("错误体超过 64 KiB,不接受截断证据")
|
|
elif 200 <= status < 300 and dict(exchange.checks).get("stream") is not None:
|
|
try:
|
|
payload = strict_json(exchange.request.content)
|
|
except (ValueError, UnicodeError):
|
|
payload = {}
|
|
if isinstance(payload, dict) and payload.get("stream") is False:
|
|
try:
|
|
data = strict_json(content)
|
|
if not isinstance(data, dict):
|
|
raise ValueError("原始 JSON 非对象")
|
|
model = data.get("model")
|
|
if model is not None and not isinstance(model, str):
|
|
raise ValueError("原始 model 类型非法")
|
|
identity = (True, model)
|
|
except (ValueError, UnicodeError):
|
|
self._notes[key].append("原始 JSON 身份无法独立解析")
|
|
return HttpEvidence(call_id, exchange.checks, status, body, identity)
|
|
|
|
def client_factory(self, source: SourceConfig) -> httpx.AsyncClient:
|
|
"""鉴权仅内存比较;沿已校验源 timeout/trust_env。"""
|
|
expected = self._expectations[source.name]
|
|
|
|
async def request_hook(request: httpx.Request) -> None:
|
|
"""校验实发请求而不修正它。"""
|
|
attempt = self._attempt.get()
|
|
try:
|
|
payload = strict_json(request.content)
|
|
except (ValueError, UnicodeError):
|
|
payload = {}
|
|
if not isinstance(payload, dict):
|
|
payload = {}
|
|
url = request.url
|
|
origin = str(
|
|
url.copy_with(path="", query=None, fragment=None, username=None, password=None)
|
|
).rstrip("/")
|
|
# control 是本轮完整附加字段;基础键以外均比较,漏/多键都失败。
|
|
basic = {"model", "messages", "stream", "stream_options", "input"}
|
|
control = {k: v for k, v in payload.items() if k not in basic}
|
|
checks = {
|
|
"method": request.method == "POST",
|
|
"origin": origin == expected["origin"]
|
|
and not url.username
|
|
and not url.password
|
|
and not url.query,
|
|
"path": url.path == expected["path"],
|
|
"model": payload.get("model") == expected["model"],
|
|
"authorization": request.headers.get("Authorization") == f"Bearer {source.api_key}",
|
|
"control": control == expected["control"],
|
|
"messages_digest": messages_digest(payload.get("messages", payload.get("input")))
|
|
== expected["messages_digest"],
|
|
}
|
|
if "stream" in expected:
|
|
checks["stream"] = payload.get("stream") is expected["stream"] and (
|
|
payload.get("stream_options") == {"include_usage": True}
|
|
if expected["stream"]
|
|
else "stream_options" not in payload
|
|
)
|
|
else:
|
|
texts = payload.get("input")
|
|
checks["input_shape"] = (
|
|
isinstance(texts, list)
|
|
and all(isinstance(text, str) for text in texts)
|
|
and len(texts) == expected["input_shape"]
|
|
)
|
|
attempt.exchanges.append(_Exchange(request, tuple(checks.items())))
|
|
|
|
async def response_hook(response: httpx.Response) -> None:
|
|
"""只持有引用,绝不提前读取成功 SSE。"""
|
|
attempt = self._attempt.get()
|
|
matching = [event for event in attempt.exchanges if event.request is response.request]
|
|
if len(matching) != 1 or matching[0].response is not None:
|
|
raise ValueError("响应无法唯一配对")
|
|
matching[0].response = response
|
|
|
|
return httpx.AsyncClient(
|
|
headers={"Authorization": f"Bearer {source.api_key}"},
|
|
timeout=source.timeout_s,
|
|
trust_env=source.trust_env,
|
|
event_hooks={"request": [request_hook], "response": [response_hook]},
|
|
)
|
|
|
|
def attempts(self, *, session_id: str, parent_call_id: str) -> tuple[AttemptEvidence, ...]:
|
|
"""按逻辑轮次返回不可变快照。"""
|
|
return tuple(self._records.get((session_id, parent_call_id), ()))
|
|
|
|
def notes(self, *, session_id: str, parent_call_id: str) -> tuple[str, ...]:
|
|
"""只含固定安全原因,不包含响应正文。"""
|
|
return tuple(self._notes.get((session_id, parent_call_id), ()))
|
|
|
|
def raw_identity(
|
|
self, *, session_id: str, parent_call_id: str, call_id: str
|
|
) -> tuple[bool, str | None]:
|
|
"""精确取最终成功 attempt,不猜本轮最后一条响应。"""
|
|
key = session_id, parent_call_id
|
|
if call_id in self._owners and self._owners[call_id] != key:
|
|
raise ValueError("跨轮 call_id 身份查询")
|
|
matches = [attempt for attempt in self._records.get(key, []) if attempt.call_id == call_id]
|
|
if len(matches) > 1:
|
|
raise ValueError("重复 call_id 身份查询")
|
|
if not matches:
|
|
return False, None
|
|
events = [event for event in matches[0].http if 200 <= event.status_code < 300]
|
|
if len(events) > 1:
|
|
raise ValueError("多个成功 HTTP 身份候选")
|
|
return events[0].raw_identity if events else (False, None)
|
|
|
|
|
|
class ObservedTransport:
|
|
"""原样委托同一个真实 transport,无重试、payload 修正或异常翻译。"""
|
|
|
|
def __init__(self, transport: OpenAICompatTransport, capture: LiveCapture) -> None:
|
|
"""资源所有权留给装配者。"""
|
|
self._transport = transport
|
|
self._capture = capture
|
|
|
|
async def complete(
|
|
self,
|
|
*,
|
|
messages: list[dict[str, Any]],
|
|
source: SourceConfig,
|
|
stream: bool,
|
|
overlay: dict[str, Any],
|
|
call_id: str,
|
|
reasoning_effort: Effort | None,
|
|
) -> TransportResult:
|
|
"""与生产端口逐参数同签名。"""
|
|
with self._capture.attempt_context(call_id):
|
|
return await self._transport.complete(
|
|
messages=messages,
|
|
source=source,
|
|
stream=stream,
|
|
overlay=overlay,
|
|
call_id=call_id,
|
|
reasoning_effort=reasoning_effort,
|
|
)
|
|
|
|
async def embed(
|
|
self, *, texts: list[str], source: SourceConfig, call_id: str
|
|
) -> EmbeddingTransportResult:
|
|
"""embedding 使用同一取证关联,不套 chat 推理判据。"""
|
|
with self._capture.attempt_context(call_id):
|
|
return await self._transport.embed(texts=texts, source=source, call_id=call_id)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def observed_client(
|
|
settings: GatewaySettings, capture: LiveCapture, *, capabilities=None
|
|
) -> AsyncIterator[GatewayClient]:
|
|
"""全量注入复用生产装配函数;自建组件显式关闭,不启用响应缓存。"""
|
|
async with AsyncExitStack() as stack:
|
|
sources = list(settings.sources)
|
|
limiter = _build_limiter(settings, sources)
|
|
stack.push_async_callback(_aclose_component, limiter)
|
|
breaker = _build_breaker(settings)
|
|
stack.push_async_callback(_aclose_component, breaker)
|
|
real = OpenAICompatTransport(
|
|
client_factory=capture.client_factory, capabilities=capabilities
|
|
)
|
|
stack.push_async_callback(real.aclose)
|
|
strategy, escalation = _build_structured(
|
|
[get_provider(source.provider) for source in sources]
|
|
)
|
|
client = GatewayClient(
|
|
scope=settings.scope,
|
|
sources=sources,
|
|
selector=_build_selector(settings.selector),
|
|
limiter=limiter,
|
|
breaker=breaker,
|
|
transport=ObservedTransport(real, capture),
|
|
retry=settings.retry,
|
|
backpressure=settings.backpressure,
|
|
quota_full=settings.quota_full,
|
|
circuit_open=settings.circuit_open,
|
|
structured_strategy=strategy,
|
|
structured_escalation=escalation,
|
|
structured_max_retries=settings.structured_max_retries,
|
|
)
|
|
stack.push_async_callback(client.aclose)
|
|
yield client
|
|
|
|
|
|
def chat_expectations(
|
|
settings: GatewaySettings,
|
|
*,
|
|
messages: list[dict[str, Any]],
|
|
stream: bool,
|
|
controls: Mapping[str, dict[str, Any]],
|
|
) -> dict[str, dict[str, Any]]:
|
|
"""URL 从源配置声明,控制片段必须由矩阵独立给出。"""
|
|
result = {}
|
|
for source in settings.sources:
|
|
url = httpx.URL(source.base_url)
|
|
result[source.name] = {
|
|
"model": source.model,
|
|
"origin": str(
|
|
url.copy_with(path="", query=None, fragment=None, username=None, password=None)
|
|
).rstrip("/"),
|
|
"path": url.path.rstrip("/") + "/chat/completions",
|
|
"stream": stream,
|
|
"control": controls[source.name],
|
|
"messages_digest": messages_digest(messages),
|
|
}
|
|
return result
|
|
|
|
|
|
def enforce_verdict(verdict: LiveVerdict) -> None:
|
|
"""仅在报告已写入后调用;默认失败,不打印上游异常正文。"""
|
|
if verdict.status == "UNCOVERED":
|
|
pytest.skip(verdict.reason)
|
|
assert verdict.status == "PASS", verdict.reason
|
|
|
|
|
|
async def captured_chat_round(
|
|
client: GatewayClient,
|
|
capture: LiveCapture,
|
|
*,
|
|
run_id: str,
|
|
matrix_id: str,
|
|
round_index: int,
|
|
output_dir: Path,
|
|
messages: list[dict[str, Any]],
|
|
models: Mapping[str, str],
|
|
aliases: Mapping[str, frozenset[str]],
|
|
validate=None,
|
|
providers: Mapping[str, str] | None = None,
|
|
source_efforts: Mapping[str, Effort | None] | None = None,
|
|
**kwargs: Any,
|
|
) -> tuple[Any, LiveVerdict]:
|
|
"""请求、身份与行为断言均先记逐轮证据;异常不漏轮。"""
|
|
parent = uuid4().hex
|
|
response = None
|
|
error = None
|
|
verdict = LiveVerdict("FAIL", "轮次未完成")
|
|
with capture.round_context(session_id=run_id, parent_call_id=parent):
|
|
try:
|
|
response = await client.chat(
|
|
messages, session_id=run_id, parent_call_id=parent, **kwargs
|
|
)
|
|
attempts = capture.attempts(session_id=run_id, parent_call_id=parent)
|
|
events = [event for attempt in attempts for event in attempt.http]
|
|
successful = [attempt for attempt in attempts if attempt.call_id == response.call_id]
|
|
success_paired = (
|
|
len(successful) == 1
|
|
and successful[0].error is None
|
|
and len(successful[0].http) == 1
|
|
and 200 <= successful[0].http[0].status_code < 300
|
|
)
|
|
verdict = assess_model_identity(
|
|
requested=models[response.source_name],
|
|
aliases=aliases.get(models[response.source_name], frozenset()),
|
|
reported=response.model_reported,
|
|
raw_identity=capture.raw_identity(
|
|
session_id=run_id, parent_call_id=parent, call_id=response.call_id
|
|
),
|
|
request_valid=success_paired
|
|
and bool(events)
|
|
and all(request_is_valid(event) for event in events),
|
|
)
|
|
if verdict.status == "PASS" and validate is not None:
|
|
validate(response)
|
|
except Exception as exc:
|
|
error = exc
|
|
verdict = classify_live_failure(
|
|
exc, capture.attempts(session_id=run_id, parent_call_id=parent)
|
|
)
|
|
finally:
|
|
attempts = capture.attempts(session_id=run_id, parent_call_id=parent)
|
|
# 上游 model 可能回显提示词;仅输出允许集合内的名字,其他统一省略。
|
|
allowed = set(models.values()) | {
|
|
alias for values in aliases.values() for alias in values
|
|
}
|
|
write_live_round(
|
|
output_dir,
|
|
run_id=run_id,
|
|
matrix_id=matrix_id,
|
|
round_index=round_index,
|
|
safe_fields={
|
|
"session_id": run_id,
|
|
"parent_call_id": parent,
|
|
"requested_model": list(models.values()),
|
|
"provider": list(providers.values()) if providers is not None else None,
|
|
"attempts": safe_attempts(attempts),
|
|
"status": verdict.status,
|
|
"reason": verdict.reason,
|
|
"stream": kwargs.get("stream", True),
|
|
"requested_effort": kwargs.get("reasoning_effort")
|
|
or (list(source_efforts.values()) if source_efforts is not None else None),
|
|
"completed_rounds": 1,
|
|
"prompt_tokens": response.prompt_tokens if response else None,
|
|
"completion_tokens": response.completion_tokens if response else None,
|
|
"reasoning_tokens": response.reasoning_tokens if response else None,
|
|
"thinking_chars": len(response.thinking) if response else None,
|
|
"evidence_notes": (
|
|
"原始异常/正文摘要省略以避免回显泄露",
|
|
*capture.notes(session_id=run_id, parent_call_id=parent),
|
|
),
|
|
"reported_model": response.model_reported
|
|
if response and response.model_reported in allowed
|
|
else None,
|
|
"applied_effort": response.applied_effort if response else None,
|
|
"thinking_observation": response.thinking_observation if response else None,
|
|
"error_type": type(error).__name__ if error else None,
|
|
"error_status": getattr(error, "status_code", None),
|
|
},
|
|
)
|
|
return response, verdict
|
|
|
|
|
|
def declared_control(provider: str, effort: Effort | None) -> dict[str, Any]:
|
|
"""测试矩阵的独立 wire 声明;不调用 resolver 或生产 payload 构造器。"""
|
|
if effort is None:
|
|
return {}
|
|
if provider == "qwen":
|
|
if effort not in (Effort.AUTO, Effort.NONE):
|
|
raise ValueError("测试矩阵未声明 qwen 强度映射")
|
|
return {"enable_thinking": effort is not Effort.NONE}
|
|
if provider in {"deepseek", "zhipu", "moonshot"}:
|
|
result: dict[str, Any] = {
|
|
"thinking": {"type": "disabled" if effort is Effort.NONE else "enabled"}
|
|
}
|
|
if effort not in (Effort.AUTO, Effort.NONE):
|
|
result["reasoning_effort"] = effort.value
|
|
return result
|
|
if provider not in {"minimax", "openai", "anthropic", "google"}:
|
|
raise ValueError("测试矩阵没有该 provider 的控制声明")
|
|
return {} if effort is Effort.AUTO else {"reasoning_effort": effort.value}
|
|
|
|
|
|
def source_controls(settings: GatewaySettings) -> dict[str, dict[str, Any]]:
|
|
"""源级矩阵预期独立表达;受管 raw 冲突由生产路径拒绝。"""
|
|
result = {}
|
|
for source in settings.sources:
|
|
effort = source.reasoning_effort
|
|
if effort is None and source.enable_thinking is not None:
|
|
effort = Effort.AUTO if source.enable_thinking else Effort.NONE
|
|
control = declared_control(source.provider, effort)
|
|
if effort is None:
|
|
control.update(source.extra_body)
|
|
else:
|
|
# 不用 update 覆盖控制声明,否则会掩盖所有权回归。
|
|
for key, value in source.extra_body.items():
|
|
if key in control:
|
|
raise ValueError("取证矩阵有双来源控制")
|
|
control[key] = value
|
|
result[source.name] = control
|
|
return result
|
|
|
|
|
|
# pytest 用例终态补充网络前缺配置、装配失败及未完成轮次;不代替逐轮报告。
|
|
@pytest.hookimpl(wrapper=True)
|
|
def pytest_runtest_makereport(item, call):
|
|
"""仅记录安全矩阵标识和阶段结果,不序列化 pytest 异常长文本。"""
|
|
report = yield
|
|
if report.skipped or report.failed:
|
|
write_live_round(
|
|
Path("tests/outputs/134/live"),
|
|
run_id=uuid4().hex,
|
|
matrix_id="pytest-" + messages_digest(item.nodeid)[:16],
|
|
round_index=0,
|
|
safe_fields={
|
|
"status": "UNCOVERED" if report.skipped else "FAIL",
|
|
"reason": "用例阶段未覆盖或失败;详情按逐轮安全证据核验,不能视为能力通过",
|
|
"evidence_notes": [report.when],
|
|
},
|
|
)
|
|
return report
|