test: apply evidence-based live checks without hiding regressions

This commit is contained in:
2026-09-09 02:40:13 -04:00
parent 16fa0ca474
commit 73008ad7d5
9 changed files with 2350 additions and 1359 deletions
+496
View File
@@ -0,0 +1,496 @@
"""测试侧独立 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
+88 -67
View File
@@ -1,98 +1,119 @@
"""GovDoc 与 Video-Tree 最小接入冒烟(2026-07-20 拍板: 两个项目都做)。
复刻两项目的真实调用点形态,对真实网关跑一次治理调用,证明"调用点零改动
迁移"成立;并验证 VT 现有平铺键名(LLM_TIMEOUT 等)可直接装配。
reference/ 只读——本文件只 import 其 Protocol,绝不修改。
"""
"""历史接入调用形态的真实冒烟;不能替代缺失下游的现行配置验收。"""
import os
import sys
from pathlib import Path
from uuid import uuid4
import pytest
from dotenv import dotenv_values
from polygateway import GatewayClient
from polygateway import Effort, GatewayClient, GatewaySettings
from tests.e2e.conftest import (
LiveCapture,
captured_chat_round,
chat_expectations,
enforce_verdict,
observed_client,
source_controls,
)
from tests.live_evidence import write_live_round
_REPO = Path(__file__).resolve().parents[2]
_ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None}
_HAS_SOURCE = any(k.split("__")[0] == "LLM" and k.endswith("__API_KEY") for k in _ENV)
# 真实网关调用: 与 test_thinking_live.py 同待遇标 slow(pytest addopts 默认排除,
# 显式 `pytest -m slow` 运行)。理由是这些用例的成败取决于网关此刻快不快,而
# pre-commit 关卡跑全套件——网关一抖就挡住与之无关的提交,久了会把"测试红了
# 先怀疑网关"变成惯性,真 bug 也会被当成抖动重试掉。发版清单负责让它们真跑。
_HAS_SOURCE = any(k.startswith("LLM__") and k.endswith("__API_KEY") for k in _ENV)
pytestmark = [
pytest.mark.slow,
pytest.mark.skipif(
not _HAS_SOURCE, reason="需真实网关凭据: 在 .env 配置 LLM__{PROVIDER}__1__*"
),
pytest.mark.skipif(not _HAS_SOURCE, reason="缺少矩阵必需凭据,未覆盖"),
]
_OUT = Path("tests/outputs/134/live")
@pytest.fixture
async def client():
c = GatewayClient.from_env("LLM", env=_ENV)
yield c
await c.aclose()
async def _call_shape(matrix, **kwargs):
"""session/parent 总由逐轮 UUID 传入;保留 cache_salt 调用形态。"""
settings = GatewaySettings.from_env("LLM", env=_ENV)
messages = [{"role": "user", "content": "Reply with exactly: compatibility-ok"}]
capture = LiveCapture(
expectations=chat_expectations(
settings, messages=messages, stream=True, controls=source_controls(settings)
)
)
def validate(response):
assert response.content.strip() and response.call_id
async with observed_client(settings, capture) as client:
_, verdict = await captured_chat_round(
client,
capture,
run_id=uuid4().hex,
matrix_id=matrix,
round_index=1,
output_dir=_OUT,
messages=messages,
models={s.name: s.model for s in settings.sources},
providers={s.name: s.provider for s in settings.sources},
source_efforts={
s.name: s.reasoning_effort
if s.reasoning_effort is not None
else (Effort.AUTO if s.enable_thinking else Effort.NONE)
if s.enable_thinking is not None
else None
for s in settings.sources
},
aliases={},
validate=validate,
**kwargs,
)
enforce_verdict(verdict)
class TestGovDocOnboarding:
"""GovDoc agent/loop.py:377 调用形态: session_id + parent_call_id。"""
"""历史 session_idparent_call_id 调用点契约"""
async def test_call_site_shape_runs_governed(self, client):
response = await client.chat(
[{"role": "user", "content": "Reply with exactly: govdoc-ok"}],
session_id="govdoc-e2e",
parent_call_id="step-1",
)
assert response.content.strip()
assert response.call_id # GovernedLLMClient 契约字段全在
async def test_call_site_shape_runs_governed(self):
await _call_shape("compat-parent")
async def test_structural_protocol_match(self, client):
async def test_structural_protocol_match(self):
"""外部 Protocol 缺包单列未覆盖;合成契约另在 unit 跑。"""
run_id = uuid4().hex
sys.path.insert(0, str(_REPO / "reference/GovDoc-SaaS/packages/docagent-core/src"))
try:
from docagent_core.protocols import LLMProvider
except ImportError:
pytest.skip("GovDoc protocols 依赖不可导入(结构断言已由单测兜底覆盖)")
write_live_round(
_OUT,
run_id=run_id,
matrix_id="external-protocol",
round_index=0,
safe_fields={
"status": "UNCOVERED",
"reason": "外部 Protocol 包缺失;未验证真实下游",
},
)
pytest.skip("外部 Protocol 包缺失,未覆盖")
finally:
sys.path.pop(0)
assert isinstance(client, LLMProvider)
status = "FAIL"
client = None
try:
client = GatewayClient.from_env("LLM", env=_ENV)
assert isinstance(client, LLMProvider)
status = "PASS"
finally:
if client is not None:
await client.aclose()
write_live_round(
_OUT,
run_id=run_id,
matrix_id="external-protocol",
round_index=0,
safe_fields={"status": status, "reason": "外部 Protocol 结构契约,不是模型能力"},
)
class TestVideoTreeOnboarding:
"""VT loop.py:336 调用形态: session_id + cache_salt(跨 epoch 重采样)"""
"""历史 cache_salt 调用点契约,平铺键装配已移至 unit"""
async def test_call_site_shape_with_cache_salt(self, client):
response = await client.chat(
[{"role": "user", "content": "Reply with exactly: vt-ok"}],
session_id="vt-e2e",
cache_salt="epoch-1",
)
assert response.content.strip()
async def test_flat_legacy_keys_assemble(self):
"""VT 现有键名(LLM_TIMEOUT/LLM_MAX_RETRIES 等)零改名装配成功。"""
source_keys = {k: v for k, v in _ENV.items() if k.split("__")[0] == "LLM" and "__" in k}
flat_env = {
**source_keys,
# 与 .env 的 LLM__MINIMAX__1__TIMEOUT_S 同值。取 120(VT 旧值)会让本用例的
# 超时比生产配置还紧一半,在慢网关上必然间歇红——而本用例断言的是平铺
# 键名能否解析成 SourceConfig.timeout_s,超时取值本身不是被测对象
"LLM_TIMEOUT": "300",
"LLM_MAX_RETRIES": "3",
"LLM_RETRY_BASE_DELAY": "2.0",
"LLM_RETRY_MAX_DELAY": "30.0",
"LLM_CIRCUIT_BREAKER_THRESHOLD": "5",
"LLM_CIRCUIT_BREAKER_COOLDOWN": "60",
"LLM_TTFT_TIMEOUT": "30",
"LLM_INTER_TOKEN_TIMEOUT": "15",
"PGW_CACHE_BACKEND": "none",
"PGW_TELEMETRY_BACKEND": "none",
}
client = GatewayClient.from_env("LLM", env=flat_env)
try:
resp = await client.chat([{"role": "user", "content": "Reply: flat-ok"}])
assert resp.content.strip()
finally:
await client.aclose()
async def test_call_site_shape_with_cache_salt(self):
await _call_shape("compat-salt", cache_salt="epoch-1")
+72 -70
View File
@@ -1,89 +1,91 @@
"""真实网关 /embeddings 端点探测(M2 设计 §11.6;人类默认口径: 实现时探测)。
对 .env 的 LLM 源网关发一次真实 embeddings 请求: 支持则记录向量证据,
不支持(404/翻译为领域错误)则 skip 并把响应记录进 tests/outputs/
(降级证据)。无 EMBED scope 配置时复用 LLM 源的 base_url/api_key。
"""
from __future__ import annotations
"""真实 embedding 探测;404 仅证明请求型号不可用,不外推端点能力。"""
import dataclasses
import os
from datetime import datetime
from pathlib import Path
from uuid import uuid4
import httpx
import pytest
from dotenv import dotenv_values
from polygateway.errors import PolyGatewayError
from polygateway import GatewaySettings
from polygateway.transports.openai_compat import OpenAICompatTransport
from polygateway.types import SourceConfig
from tests.e2e.conftest import LiveCapture, ObservedTransport, enforce_verdict
from tests.live_evidence import (
LiveVerdict,
classify_live_failure,
messages_digest,
request_is_valid,
safe_attempts,
write_live_round,
)
_ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None}
# 真实网关调用: 与 test_thinking_live.py 同待遇标 slow(pytest addopts 默认排除,
# 显式 `pytest -m slow` 运行)。理由见 test_compat_projects.py 同处注释。
pytestmark = [
pytest.mark.slow,
pytest.mark.skipif(
"LLM__MINIMAX__1__BASE_URL" not in _ENV,
reason="缺真实网关配置(.env)",
),
pytest.mark.skipif("LLM__MINIMAX__1__BASE_URL" not in _ENV, reason="缺少矩阵必需配置,未覆盖"),
]
_OUT = Path("tests/outputs/embedding")
def _record(name: str, lines: list[str]) -> Path:
_OUT.mkdir(parents=True, exist_ok=True)
path = _OUT / f"{name}_{datetime.now():%Y%m%d_%H%M%S}.md"
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return path
async def test_probe_real_gateway_embeddings():
source = SourceConfig(
name="probe_1",
provider="minimax",
base_url=_ENV["LLM__MINIMAX__1__BASE_URL"],
api_key=_ENV["LLM__MINIMAX__1__API_KEY"],
model=_ENV.get("PGW_EMBED_PROBE_MODEL", "text-embedding-v1"),
timeout_s=30.0,
est_tokens=8,
"""沿已校验源的 timeout/trust_env,所有路径 finally 关闭。"""
settings = GatewaySettings.from_env("LLM", env=_ENV)
configured = next(s for s in settings.sources if s.name == "minimax_1")
source = dataclasses.replace(
configured, model=_ENV.get("PGW_EMBED_PROBE_MODEL", "text-embedding-v1")
)
transport = OpenAICompatTransport()
texts = ["polygateway embedding probe"]
url = httpx.URL(source.base_url)
capture = LiveCapture(
expectations={
source.name: {
"model": source.model,
"origin": str(url.copy_with(path="", query=None)).rstrip("/"),
"path": url.path.rstrip("/") + "/embeddings",
"input_shape": 1,
"control": {},
"messages_digest": messages_digest(texts),
}
}
)
real = OpenAICompatTransport(client_factory=capture.client_factory)
transport = ObservedTransport(real, capture)
run_id, parent, call_id = uuid4().hex, uuid4().hex, uuid4().hex
verdict = LiveVerdict("FAIL", "轮次未完成")
try:
result = await transport.embed(
texts=["polygateway embedding probe"], source=source, call_id="probe"
)
except PolyGatewayError as exc:
path = _record(
"probe_unsupported",
[
"# Embedding 端点探测: 网关不支持",
f"- base_url: {source.base_url}",
f"- model: {source.model}",
f"- 错误分类: {type(exc).__name__}",
f"- status_code: {exc.status_code}",
f"- 详情: {exc}",
"",
"结论: e2e 按设计 §11.6 降级,embedding 行为由 unit 全覆盖。",
],
)
await transport.aclose()
pytest.skip(f"网关不支持 embeddings({type(exc).__name__}),证据: {path}")
else:
await transport.aclose()
assert result.dim > 0 and len(result.vectors) == 1
_record(
"probe_supported",
[
"# Embedding 端点探测: 网关支持",
f"- base_url: {source.base_url}",
f"- model: {source.model}",
f"- dim: {result.dim}",
f"- usage: {result.prompt_tokens}({result.usage_source})",
f"- 向量前 5 维: {result.vectors[0][:5]}",
f"- raw: {dataclasses.asdict(result)['raw']}",
],
)
with capture.round_context(session_id=run_id, parent_call_id=parent):
try:
result = await transport.embed(texts=texts, source=source, call_id=call_id)
events = [
e
for a in capture.attempts(session_id=run_id, parent_call_id=parent)
for e in a.http
]
assert len(events) == 1 and request_is_valid(events[0])
assert result.dim > 0 and len(result.vectors) == 1
verdict = LiveVerdict("PASS", "向量形状与实发请求合格")
except Exception as error:
verdict = classify_live_failure(
error, capture.attempts(session_id=run_id, parent_call_id=parent)
)
finally:
write_live_round(
Path("tests/outputs/134/live"),
run_id=run_id,
matrix_id="embedding",
round_index=1,
safe_fields={
"status": verdict.status,
"reason": verdict.reason,
"session_id": run_id,
"parent_call_id": parent,
"attempts": safe_attempts(
capture.attempts(session_id=run_id, parent_call_id=parent)
),
"evidence_notes": capture.notes(session_id=run_id, parent_call_id=parent),
},
)
finally:
await real.aclose()
enforce_verdict(verdict)
+80 -93
View File
@@ -1,123 +1,110 @@
"""真实网关端到端冒烟(M1 验收第 7 步)。
"""真实网关冒烟:逐轮独立取证,行为断言失败也必须留档。"""
前置: `.env` 配置至少一个 `LLM__{PROVIDER}__1__*` 真实源 + 韧性键。
缺配置时 skip(验收前必须真跑)。输出结构化 Markdown 落
`tests/outputs/e2e/`(CLAUDE.md §4.6,不提交 git)。
"""
import json
import os
from datetime import datetime
from pathlib import Path
from uuid import uuid4
import pytest
from dotenv import dotenv_values
from pydantic import BaseModel
from polygateway import GatewayClient
from polygateway import Effort, GatewaySettings
from tests.e2e.conftest import (
LiveCapture,
captured_chat_round,
chat_expectations,
enforce_verdict,
observed_client,
source_controls,
)
_ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None}
_HAS_SOURCE = any(k.split("__")[0] == "LLM" and k.endswith("__API_KEY") for k in _ENV)
# 真实网关调用: 与 test_thinking_live.py 同待遇标 slow(pytest addopts 默认排除,
# 显式 `pytest -m slow` 运行)。理由见 test_compat_projects.py 同处注释。
_HAS_SOURCE = any(k.startswith("LLM__") and k.endswith("__API_KEY") for k in _ENV)
pytestmark = [
pytest.mark.slow,
pytest.mark.skipif(
not _HAS_SOURCE,
reason="需真实网关凭据: 在 .env 配置 LLM__{PROVIDER}__1__*(M1 验收前必须真跑)",
),
pytest.mark.skipif(not _HAS_SOURCE, reason="缺少矩阵必需凭据,未覆盖"),
]
_OUT_DIR = Path("tests/outputs/e2e")
class MiniAnswer(BaseModel):
"""最小结构化响应契约。"""
answer: int
reason: str
def _report(name: str, sections: list[tuple[str, str]]) -> Path:
_OUT_DIR.mkdir(parents=True, exist_ok=True)
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
path = _OUT_DIR / f"{name}_{ts}.md"
body = [f"# e2e 冒烟: {name}", ""]
for title, content in sections:
body += [f"## {title}", "", "```", content, "```", ""]
path.write_text("\n".join(body), encoding="utf-8")
return path
@pytest.fixture
async def client():
c = GatewayClient.from_env("LLM", env=_ENV)
yield c
await c.aclose()
async def _smoke(matrix, prompt, validate, *, stream=True, structured=None):
"""全量注入仅替换取证装配,仍调用生产结构化策略。"""
settings = GatewaySettings.from_env("LLM", env=_ENV)
messages = [{"role": "user", "content": prompt}]
controls = source_controls(settings)
capture = LiveCapture(
expectations=chat_expectations(
settings, messages=messages, stream=stream, controls=controls
)
)
async with observed_client(settings, capture) as client:
_, verdict = await captured_chat_round(
client,
capture,
run_id=uuid4().hex,
matrix_id=matrix,
round_index=1,
output_dir=Path("tests/outputs/134/live"),
messages=messages,
models={s.name: s.model for s in settings.sources},
providers={s.name: s.provider for s in settings.sources},
source_efforts={
s.name: s.reasoning_effort
if s.reasoning_effort is not None
else (Effort.AUTO if s.enable_thinking else Effort.NONE)
if s.enable_thinking is not None
else None
for s in settings.sources
},
aliases={},
validate=validate,
stream=stream,
structured=structured,
)
enforce_verdict(verdict)
class TestRealGatewaySmoke:
async def test_stream_chat(self, client):
resp = await client.chat(
[{"role": "user", "content": "Reply with exactly: pong"}], session_id="e2e-smoke"
)
path = _report(
"stream_chat",
[
("响应", resp.content),
(
"元数据",
json.dumps(
{
"model": resp.model,
"source": resp.source_name,
"usage_source": resp.usage_source,
"prompt_tokens": resp.prompt_tokens,
"completion_tokens": resp.completion_tokens,
"latency_ms": resp.latency_ms,
"ttft_ms": resp.ttft_ms,
},
ensure_ascii=False,
indent=2,
),
),
],
)
assert resp.content.strip()
assert resp.ttft_ms is not None and resp.latency_ms > 0
print(f"输出: {path}")
"""保留流/非流和结构化真实行为断言。"""
async def test_non_stream_fast_path(self, client):
resp = await client.chat(
[{"role": "user", "content": "Reply with exactly: pong"}], stream=False
)
_report("non_stream", [("响应", resp.content)])
assert resp.content.strip() and resp.ttft_ms is None
async def test_stream_chat(self):
def validate(response):
assert response.content.strip()
assert response.ttft_ms is not None and response.latency_ms > 0
async def test_structured_json_tier(self, client):
resp = await client.chat(
[{"role": "user", "content": 'Reply ONLY with JSON: {"ok": true}'}],
await _smoke("smoke-stream", "Reply with exactly: pong", validate)
async def test_non_stream_fast_path(self):
def validate(response):
assert response.content.strip() and response.ttft_ms is None
await _smoke("smoke-json", "Reply with exactly: pong", validate, stream=False)
async def test_structured_json_tier(self):
def validate(response):
assert isinstance(response.structured_data, dict | list)
await _smoke(
"smoke-structured-json",
'Reply ONLY with JSON: {"ok": true}',
validate,
structured="json",
)
_report("structured_json", [("解析产物", repr(resp.structured_data))])
assert isinstance(resp.structured_data, dict | list)
async def test_structured_model_ladder(self, client):
resp = await client.chat(
[
{
"role": "user",
"content": "What is 2+3? Reply ONLY with JSON matching "
'{"answer": <int>, "reason": <short string>}',
}
],
async def test_structured_model_ladder(self):
def validate(response):
assert isinstance(response.structured_data, MiniAnswer)
assert response.structured_data.answer == 5
await _smoke(
"smoke-structured-model",
'What is 2+3? Reply ONLY with JSON matching {"answer": <int>, "reason": <short string>}',
validate,
structured=MiniAnswer,
)
_report(
"structured_model",
[
("原始响应", resp.content),
("校验产物", resp.structured_data.model_dump_json()),
],
)
assert isinstance(resp.structured_data, MiniAnswer)
assert resp.structured_data.answer == 5
File diff suppressed because it is too large Load Diff