mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/jxxghp/MoviePilot.git
synced 2026-09-20 08:03:34 +08:00
720 lines
40 KiB
Python
720 lines
40 KiB
Python
"""模型代理的离线协议、工具隔离、预算、凭据和资源回收回归。"""
|
|
|
|
import asyncio
|
|
import json
|
|
import signal
|
|
from collections.abc import AsyncIterator
|
|
from contextlib import asynccontextmanager
|
|
from typing import Any, Optional
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from scripts.evaluation import proxy as proxy_module
|
|
from scripts.evaluation.models import ModelSettings
|
|
from scripts.evaluation.proxy import EvaluationModelProxy, project_host_instructions, project_tools
|
|
|
|
SETTINGS = ModelSettings("test-model", "https://model.invalid/v1", "private-test-provider-key", max_model_calls=2)
|
|
USAGE = {"input_tokens": 12, "output_tokens": 8, "total_tokens": 20}
|
|
|
|
|
|
def _payload(**updates: Any) -> dict[str, Any]:
|
|
"""构造与原生 Responses 客户端相同形态的受控工具请求。"""
|
|
return {"model": SETTINGS.model, "tools": [
|
|
{"type": "function", "name": "update_plan", "parameters": {}},
|
|
{"type": "function", "name": "mcp__evaluation__moviepilot_api", "parameters": {}},
|
|
{"type": "custom", "name": "apply_patch", "format": {"type": "text"}},
|
|
], **updates}
|
|
|
|
|
|
def _completed(**updates: Any) -> dict[str, Any]:
|
|
"""提供真实协议的完成信号,用量缺失与返回模型可以独立变化。"""
|
|
return {"id": "response-test", "status": "completed", "model": SETTINGS.model, "usage": USAGE, "output": [], **updates}
|
|
|
|
|
|
def _event(value: Any) -> bytes:
|
|
"""生成标准 SSE 帧,测试仍可刻意跨任意字节拆分。"""
|
|
return f"data: {json.dumps(value)}\n\n".encode()
|
|
|
|
|
|
class _ByteStream(httpx.AsyncByteStream):
|
|
"""可检查关闭状态的离线字节流,允许模拟帧间故障。"""
|
|
|
|
def __init__(self, chunks: list[Any]) -> None:
|
|
"""保存测试字节及异常,不持有套接字。"""
|
|
self.chunks = chunks
|
|
self.closed = False
|
|
|
|
async def __aiter__(self) -> AsyncIterator[bytes]:
|
|
"""按固定顺序返回片段,以覆盖真正的增量解析路径。"""
|
|
for chunk in self.chunks:
|
|
if isinstance(chunk, Exception):
|
|
raise chunk
|
|
yield chunk
|
|
|
|
async def aclose(self) -> None:
|
|
"""记录代理是否在成功、拒绝和中途失败时回收上游。"""
|
|
self.closed = True
|
|
|
|
|
|
@asynccontextmanager
|
|
async def _client(proxy: EvaluationModelProxy) -> AsyncIterator[httpx.AsyncClient]:
|
|
"""ASGI 与 MockTransport 两侧都留在内存,不访问真实网络。"""
|
|
try:
|
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(proxy.app), base_url="http://127.0.0.1",
|
|
headers={"Authorization": f"Bearer {proxy.bearer_token}"}) as client:
|
|
yield client
|
|
finally:
|
|
await proxy.close()
|
|
|
|
|
|
def test_namespace_projection_preserves_allowed_schema_and_audits_removed_tools() -> None:
|
|
"""目录投影保留函数原定义,不能把原生文件能力混入对照世界。"""
|
|
tools = [{"type": "namespace", "name": "functions", "description": "native controls", "tools": _payload()["tools"]}]
|
|
projected, retained, removed = project_tools(tools)
|
|
assert retained == ["functions.update_plan", "functions.mcp__evaluation__moviepilot_api"]
|
|
assert removed == ["functions.apply_patch"]
|
|
assert projected[0]["description"] == "native controls"
|
|
assert projected[0]["tools"] == tools[0]["tools"][:2]
|
|
projected[0]["tools"][0]["parameters"]["changed"] = True
|
|
assert tools[0]["tools"][0]["parameters"] == {}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_probe_records_catalog_without_forwarding_or_spending_budget() -> None:
|
|
"""探测首个请求目录即可继续调试,统计不能把探测伪装为真实模型调用。"""
|
|
outbound = []
|
|
proxy = EvaluationModelProxy(SETTINGS, probe_only=True, transport=httpx.MockTransport(lambda request: outbound.append(request)))
|
|
async with _client(proxy) as client:
|
|
for _ in range(3):
|
|
assert (await client.post("/v1/responses", json=_payload())).status_code == 400
|
|
result = proxy.snapshot()
|
|
assert not outbound and result["model_calls"] == 0 and result["probe_requests"] == 3
|
|
assert result["tokens"] is None and result["blocked_model_calls"] == 0
|
|
result["model_requests"][0]["retained_tools"].append("forged")
|
|
assert "forged" not in proxy.snapshot()["model_requests"][0]["retained_tools"]
|
|
|
|
|
|
@pytest.mark.parametrize("updates", [
|
|
{"tools": None}, {"tools": [None]}, {"tools": [{"type": "function"}]},
|
|
{"tools": [{"type": "function", "name": 42}]}, {"tools": [{"type": "namespace", "name": "functions", "tools": {}}]},
|
|
{"tools": [{"type": "function", "name": "update_plan", "parameters": []}]},
|
|
{"tools": [{"type": "function", "name": "mcp__private__message"}]},
|
|
{"tools": [{"type": "mcp", "server_url": "https://private.invalid"}]},
|
|
{"max_output_tokens": "100"}, {"max_output_tokens": 0}, {"max_output_tokens": -1},
|
|
{"max_output_tokens": True}, {"max_output_tokens": 0.5}, {"max_output_tokens": None},
|
|
{"stream": "true"}, {"model": "different-model"}, {"reasoning": {"effort": "ultra"}},
|
|
{"reasoning": ["high"]}, {"reasoning": []}, {"reasoning": False}, {"tool_choice": {"type": "function", "name": "exec_command"}},
|
|
{"input": [{"type": "custom_tool_call", "name": "apply_patch"}]},
|
|
{"metadata": {SETTINGS.api_key: "hidden-in-key"}},
|
|
])
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_request_is_rejected_before_outbound_or_accounting(updates: dict[str, Any]) -> None:
|
|
"""恶意目录、错模型和非法预算必须在真正调用前明确失败。"""
|
|
outbound = []
|
|
proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(lambda request: outbound.append(request)))
|
|
async with _client(proxy) as client:
|
|
response = await client.post("/v1/responses", json=_payload(**updates))
|
|
assert response.status_code == 400 and not outbound and proxy.snapshot()["model_calls"] == 0
|
|
assert SETTINGS.api_key not in response.text + json.dumps(proxy.snapshot())
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_request_authentication_origin_and_size_fail_without_forwarding(monkeypatch) -> None:
|
|
"""本地地址也需要随机认证,错误来源与过大正文不消耗模型预算。"""
|
|
proxy = EvaluationModelProxy(SETTINGS, probe_only=True)
|
|
monkeypatch.setattr(proxy_module, "MAX_REQUEST_BYTES", 20)
|
|
async with _client(proxy) as client:
|
|
assert (await client.post("/v1/responses", json={}, headers={"Authorization": "Bearer wrong"})).status_code == 401
|
|
assert (await client.post("/v1/responses", json={}, headers={"Origin": "https://foreign.invalid"})).status_code == 403
|
|
assert (await client.post("/v1/responses", json=_payload())).status_code == 413
|
|
assert (await client.post("/v1/responses", content="malformed")).status_code == 400
|
|
assert proxy.snapshot()["probe_requests"] == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_forwarding_uses_only_fixed_provider_and_real_key_inside_transport() -> None:
|
|
"""真实凭据只进入固定供应商请求,原生客户端看到的令牌不会被转发。"""
|
|
outbound = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""捕获私有连接边界并返回完成响应。"""
|
|
outbound.append(request)
|
|
return httpx.Response(200, json=_completed(model="provider-reported-model"))
|
|
|
|
proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(handler))
|
|
async with _client(proxy) as client:
|
|
response = await client.post("/v1/responses", json=_payload(max_output_tokens=999999))
|
|
assert response.status_code == 200 and len(outbound) == 1
|
|
request = outbound[0]
|
|
assert str(request.url) == "https://model.invalid/v1/responses"
|
|
assert request.headers["Authorization"] == f"Bearer {SETTINGS.api_key}"
|
|
assert proxy.bearer_token not in str(request.headers)
|
|
payload = json.loads(request.content)
|
|
assert payload["max_output_tokens"] == SETTINGS.max_output_tokens
|
|
assert [tool["name"] for tool in payload["tools"]] == ["update_plan", "mcp__evaluation__moviepilot_api"]
|
|
result = proxy.snapshot()
|
|
assert result["tokens"] == USAGE and result["usage_complete"] is True
|
|
assert result["reported_models"] == ["provider-reported-model"]
|
|
assert SETTINGS.api_key not in response.text + json.dumps(result)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_codex_oauth_forwarding_adds_production_account_headers() -> None:
|
|
"""Codex OAuth 上游请求带生产所需的 originator 和账户标识,不泄露本地代理令牌。"""
|
|
settings = ModelSettings(
|
|
"gpt-5.6-luna", "https://chatgpt.com/backend-api/codex", "private-oauth-token",
|
|
auth_mode="codex_oauth", account_id="private-account", max_model_calls=1,
|
|
)
|
|
outbound = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""捕获请求头并返回一份合法 Responses 完成对象。"""
|
|
outbound.append(request)
|
|
return httpx.Response(200, json=_completed(model=settings.model))
|
|
|
|
proxy = EvaluationModelProxy(settings, transport=httpx.MockTransport(handler))
|
|
async with _client(proxy) as client:
|
|
response = await client.post("/v1/responses", json={
|
|
**_payload(), "model": settings.model,
|
|
})
|
|
assert response.status_code == 200 and len(outbound) == 1
|
|
request = outbound[0]
|
|
assert request.headers["originator"] == "moviepilot"
|
|
assert request.headers["ChatGPT-Account-Id"] == "private-account"
|
|
assert request.headers["Authorization"] == f"Bearer {settings.api_key}"
|
|
assert "max_output_tokens" not in json.loads(request.content)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_parallel_requests_share_atomic_hard_limit_and_unknown_failure_cost() -> None:
|
|
"""多子代理同时请求也不能超额,已发送失败请求保留为用量未知。"""
|
|
outbound = []
|
|
|
|
async def handler(request: httpx.Request) -> httpx.Response:
|
|
"""让请求挂起一个调度点以模拟重叠调用。"""
|
|
outbound.append(request)
|
|
await asyncio.sleep(0)
|
|
return httpx.Response(500, text=SETTINGS.api_key)
|
|
|
|
proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(handler))
|
|
async with _client(proxy) as client:
|
|
responses = await asyncio.gather(*(client.post("/v1/responses", json=_payload()) for _ in range(5)))
|
|
result = proxy.snapshot()
|
|
assert len(outbound) == result["model_calls"] == 2 and result["blocked_model_calls"] == 3
|
|
assert result["usage_complete"] is False and result["tokens"] is None
|
|
assert all(SETTINGS.api_key not in response.text for response in responses)
|
|
|
|
|
|
@pytest.mark.parametrize("usage", [None, {}, {"input_tokens": 2}, {**USAGE, "input_tokens": True},
|
|
{**USAGE, "input_tokens": -1}, {**USAGE, "input_tokens": 2.5}])
|
|
@pytest.mark.asyncio
|
|
async def test_partial_or_invalid_usage_stays_unknown(usage: Any) -> None:
|
|
"""完成响应缺少完整合法用量时,不能输出虚构的零 token。"""
|
|
proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(lambda _: httpx.Response(200, json=_completed(usage=usage))))
|
|
async with _client(proxy) as client:
|
|
assert (await client.post("/v1/responses", json=_payload())).status_code == 200
|
|
result = proxy.snapshot()
|
|
assert result["completed_model_calls"] == 1 and result["tokens"] is None and result["usage_complete"] is False
|
|
|
|
|
|
@pytest.mark.parametrize("output", [
|
|
[{"type": "function_call", "name": "exec_command", "arguments": "{}"}],
|
|
[{"type": "custom_tool_call", "name": "apply_patch", "input": "arbitrary"}],
|
|
[{"type": "computer_call", "action": {"type": "click"}}],
|
|
[{"type": "message", "content": [{"type": "output_text", "text": SETTINGS.api_key}]}],
|
|
])
|
|
@pytest.mark.asyncio
|
|
async def test_non_streaming_forbidden_calls_and_credentials_never_reach_client(output: Any) -> None:
|
|
"""非流式同样验证整个完成对象,不能依赖模型遵守已过滤目录。"""
|
|
proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(lambda _: httpx.Response(200, json=_completed(output=output))))
|
|
async with _client(proxy) as client:
|
|
response = await client.post("/v1/responses", json=_payload())
|
|
assert response.status_code == 400 and proxy.snapshot()["completed_model_calls"] == 0
|
|
assert SETTINGS.api_key not in response.text and "exec_command" not in response.text
|
|
|
|
|
|
@pytest.mark.parametrize("ending", [b"\n\n", b"\r\n\r\n", b""])
|
|
@pytest.mark.asyncio
|
|
async def test_sse_fragmented_frames_and_final_unterminated_event_are_captured(ending: bytes) -> None:
|
|
"""帧可以跨 TCP 片段,末尾无空行的完整 JSON 事件也必须处理。"""
|
|
completed = _event({"type": "response.completed", "response": _completed()}).rstrip(b"\n") + ending
|
|
stream = _ByteStream([completed[:3], completed[3:19], completed[19:]])
|
|
proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(lambda _: httpx.Response(200, stream=stream)))
|
|
async with _client(proxy) as client:
|
|
response = await client.post("/v1/responses", json=_payload(stream=True))
|
|
assert response.status_code == 200 and "response.completed" in response.text and stream.closed
|
|
assert proxy.snapshot()["tokens"] == USAGE and proxy.snapshot()["completed_model_calls"] == 1
|
|
|
|
|
|
@pytest.mark.parametrize("bad_event", [
|
|
{"type": "response.output_item.added", "item": {"type": "function_call", "name": "exec_command"}},
|
|
{"type": "response.output_item.done", "item": {"type": "custom_tool_call", "name": "apply_patch"}},
|
|
{"type": "response.completed", "response": _completed(output=[{"type": "function_call", "name": "exec_command"}])},
|
|
{"type": "response.output_text.delta", "delta": SETTINGS.api_key},
|
|
])
|
|
@pytest.mark.asyncio
|
|
async def test_sse_blocks_unsafe_frame_before_native_execution(bad_event: dict[str, Any]) -> None:
|
|
"""已经转发安全进度不代表后续帧可信,非法调用帧须阻断并标记失败。"""
|
|
safe = _event({"type": "response.created", "response": {"id": "safe-progress"}})
|
|
stream = _ByteStream([safe, _event(bad_event), _event({"type": "response.completed", "response": _completed()})])
|
|
proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(lambda _: httpx.Response(200, stream=stream)))
|
|
async with _client(proxy) as client:
|
|
response = await client.post("/v1/responses", json=_payload(stream=True))
|
|
assert "safe-progress" in response.text and "evaluation_error" in response.text and stream.closed
|
|
assert "exec_command" not in response.text and "apply_patch" not in response.text and SETTINGS.api_key not in response.text
|
|
result = proxy.snapshot()
|
|
assert result["completed_model_calls"] == 0 and result["model_requests"][0]["error_type"] == "invalid_stream"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_credentials_split_across_deltas_cannot_be_reassembled_by_client() -> None:
|
|
"""密钥拆成多个合法 JSON 帧时,形成完整秘密的帧仍必须被扣留。"""
|
|
key = SETTINGS.api_key
|
|
frames = [_event({"type": "response.output_text.delta", "item_id": "message-one", "delta": part})
|
|
for part in (key[:7], key[7:15], key[15:])]
|
|
stream = _ByteStream(frames)
|
|
proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(lambda _: httpx.Response(200, stream=stream)))
|
|
async with _client(proxy) as client:
|
|
response = await client.post("/v1/responses", json=_payload(stream=True))
|
|
events = [json.loads(line[6:]) for line in response.text.splitlines() if line.startswith("data: ")]
|
|
assert key not in "".join(event.get("delta", "") for event in events)
|
|
assert stream.closed and proxy.snapshot()["completed_model_calls"] == 0
|
|
|
|
|
|
@pytest.mark.parametrize("chunks", [[b"data: invalid\n\n"], [b"data: {"],
|
|
[_event({"type": "response.created"})], [httpx.ReadError("private failure")]])
|
|
@pytest.mark.asyncio
|
|
async def test_malformed_truncated_or_failed_stream_has_unknown_completion(chunks: list[Any]) -> None:
|
|
"""没有完整终态或网络中断的流必须保留失败证据,且关闭上游。"""
|
|
stream = _ByteStream(chunks)
|
|
proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(lambda _: httpx.Response(200, stream=stream)))
|
|
async with _client(proxy) as client:
|
|
response = await client.post("/v1/responses", json=_payload(stream=True))
|
|
assert "evaluation_error" in response.text and "private failure" not in response.text and stream.closed
|
|
assert proxy.snapshot()["completed_model_calls"] == 0 and proxy.snapshot()["tokens"] is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_oversized_event_and_nonstream_response_are_bounded(monkeypatch) -> None:
|
|
"""无分隔符流和大响应都不能无限累计内存。"""
|
|
monkeypatch.setattr(proxy_module, "MAX_EVENT_BYTES", 16)
|
|
monkeypatch.setattr(proxy_module, "MAX_RESPONSE_BYTES", 16)
|
|
for streaming in (False, True):
|
|
stream = _ByteStream([b"data: " + b"x" * 30])
|
|
proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(lambda _: httpx.Response(200, stream=stream)))
|
|
async with _client(proxy) as client:
|
|
response = await client.post("/v1/responses", json=_payload(stream=streaming))
|
|
assert "evaluation_error" in response.text and stream.closed
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_startup_failure_closes_client_and_socket(monkeypatch) -> None:
|
|
"""服务启动异常发生在上下文进入前,也必须清理预先分配的资源。"""
|
|
async def fail(_server: Any, **_kwargs: Any) -> None:
|
|
"""模拟监听服务子任务在报告就绪前失败。"""
|
|
raise RuntimeError("offline-startup-failure")
|
|
|
|
monkeypatch.setattr(proxy_module.uvicorn.Server, "serve", fail)
|
|
proxy = EvaluationModelProxy(SETTINGS)
|
|
with pytest.raises(RuntimeError, match="offline-startup-failure"):
|
|
await proxy.start()
|
|
assert proxy._client.is_closed and proxy._socket.fileno() == -1 and proxy._task.done()
|
|
await proxy.close()
|
|
assert proxy.snapshot()["server_error"] == "RuntimeError"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_native_host_instructions_are_removed_without_changing_core_or_user_task() -> None:
|
|
"""只移除精确原生用户说明块,不能改写原生系统提示或邻接的任务内容。"""
|
|
removed = "# AGENTS.md instructions for /synthetic/private\n\n<INSTRUCTIONS>\nsynthetic host preference\n</INSTRUCTIONS>"
|
|
outbound = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""保留供应商请求以断言正文仅含允许的评测上下文。"""
|
|
outbound.append(json.loads(request.content))
|
|
return httpx.Response(200, json=_completed())
|
|
|
|
proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(handler))
|
|
core = "native core instructions including literal # AGENTS.md instructions example"
|
|
ordinary = {"type": "input_text", "text": "business task must remain identical"}
|
|
nested = {"role": "user", "content": [{"type": "text", "kind": "agents_md.instructions", "text": removed}, ordinary]}
|
|
async with _client(proxy) as client:
|
|
response = await client.post("/v1/responses", json=_payload(
|
|
instructions=core, input=[{"role": "user", "content": [{"type": "input_text", "text": removed}, ordinary]},
|
|
{"nested_history": [nested]}],
|
|
))
|
|
assert response.status_code == 200 and outbound[0]["instructions"] == core
|
|
assert outbound[0]["input"][0]["content"] == [ordinary]
|
|
assert outbound[0]["input"][1]["nested_history"][0]["content"] == [ordinary]
|
|
records = proxy.snapshot()["model_requests"][0]["removed_host_instructions"]
|
|
assert len(records) == 2 and records[0]["bytes"] == len(removed.encode()) and len(records[0]["sha256"]) == 64
|
|
assert "synthetic host preference" not in json.dumps(proxy.snapshot())
|
|
|
|
|
|
@pytest.mark.parametrize("block", [
|
|
{"type": "input_text", "text": "# AGENTS.md instructions\n\n<INSTRUCTIONS>\nmissing closing tag"},
|
|
{"type": "input_text", "text": "quoted # AGENTS.md instructions should not be silently removed"},
|
|
{"type": "output_text", "text": "# AGENTS.md instructions\n\n<INSTRUCTIONS>\nbody\n</INSTRUCTIONS>"},
|
|
{"type": "text", "kind": "foreign.kind", "text": "# AGENTS.md instructions\n\n<INSTRUCTIONS>\nbody\n</INSTRUCTIONS>"},
|
|
{"type": "input_text", "kind": "agents_md.instructions", "text": "missing known envelope"},
|
|
])
|
|
@pytest.mark.asyncio
|
|
async def test_ambiguous_host_instructions_abort_before_model_request(block: dict[str, Any]) -> None:
|
|
"""无法确认的宿主文本必须停止评测,不能任意删改内容或泄漏给供应商。"""
|
|
outbound = []
|
|
proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(lambda request: outbound.append(request)))
|
|
async with _client(proxy) as client:
|
|
response = await client.post("/v1/responses", json=_payload(input=[{"role": "user", "content": [block]}]))
|
|
assert response.status_code == 400 and not outbound and proxy.snapshot()["model_calls"] == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_host_message_is_omitted_in_probe_audit() -> None:
|
|
"""完整用户消息只有宿主块时必须连空消息一起去除,避免产生无效 Responses 输入。"""
|
|
text = "# AGENTS.md instructions\n\n<INSTRUCTIONS>\nhost\n</INSTRUCTIONS>"
|
|
payload = _payload(input=[{"role": "user", "content": [{"type": "input_text", "text": text}]},
|
|
{"role": "user", "content": [{"type": "input_text", "text": "business"}]}])
|
|
projected, audit = project_host_instructions(payload)
|
|
assert len(projected["input"]) == 1 and len(audit) == 1
|
|
proxy = EvaluationModelProxy(SETTINGS, probe_only=True)
|
|
async with _client(proxy) as client:
|
|
assert (await client.post("/v1/responses", json=payload)).status_code == 400
|
|
assert len(proxy.snapshot()["model_requests"][0]["removed_host_instructions"]) == 1
|
|
|
|
|
|
@pytest.mark.parametrize("text", ["<skills_instructions>host skills</skills_instructions>",
|
|
"ordinary <INSTRUCTIONS>unrecognized host</INSTRUCTIONS>"])
|
|
@pytest.mark.asyncio
|
|
async def test_unknown_host_skill_boundaries_abort_with_text_fingerprint_only(text: str) -> None:
|
|
"""原生宿主技能残留未识别时拒绝出站,保留可私下比对的指纹证据。"""
|
|
outbound = []
|
|
proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(lambda request: outbound.append(request)))
|
|
async with _client(proxy) as client:
|
|
response = await client.post("/v1/responses", json=_payload(input=[{"role": "user", "content": [{"type": "input_text", "text": text}]}]))
|
|
result = proxy.snapshot()
|
|
assert response.status_code == 400 and not outbound
|
|
metadata = result["rejected_requests"][0]["input_blocks"][0]
|
|
assert metadata["role"] == "user" and metadata["type"] == "input_text" and metadata["bytes"] == len(text.encode())
|
|
assert text not in json.dumps(result) and len(metadata["sha256"]) == 64
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_clean_probe_reports_only_projected_input_fingerprints() -> None:
|
|
"""探测报告可核对送出任务完全相同,又不会持久化任务和宿主说明正文。"""
|
|
proxy = EvaluationModelProxy(SETTINGS, probe_only=True)
|
|
async with _client(proxy) as client:
|
|
await client.post("/v1/responses", json=_payload(input=[{"role": "user", "content": [{"type": "input_text", "text": "business task"}]}]))
|
|
result = proxy.snapshot()
|
|
assert result["model_requests"][0]["input_blocks"][0]["bytes"] == 13
|
|
fields = result["model_requests"][0]["request_field_types"]
|
|
assert fields["tools"] == {"type": "list", "length": 2}
|
|
assert fields["model"] == {"type": "str", "length": len(SETTINGS.model)}
|
|
assert "business task" not in json.dumps(result)
|
|
|
|
|
|
def _search_tool(description: str = 'Tools from the following sources:\n- evaluation: Controlled world') -> dict[str, Any]:
|
|
"""构造已在原生线协议观察到的客户端工具搜索定义。"""
|
|
return {"type": "tool_search", "execution": "client", "description": description,
|
|
"parameters": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}}
|
|
|
|
|
|
def _native_payload(**updates: Any) -> dict[str, Any]:
|
|
"""原生工具随 additional_tools 输入传递,不应被代理改成空的顶层 tools。"""
|
|
return {"model": SETTINGS.model, "input": [{"type": "additional_tools", "id": "synthetic-catalog", "role": "developer", "tools": [
|
|
{"type": "namespace", "name": "functions", "tools": _payload()["tools"]},
|
|
{"type": "namespace", "name": "clock", "tools": [{"type": "function", "name": "curr_time", "parameters": {}}]},
|
|
{"type": "namespace", "name": "collaboration", "tools": [
|
|
{"type": "function", "name": name, "parameters": {}} for name in
|
|
("spawn_agent", "followup_task", "interrupt_agent", "list_agents", "send_message", "wait_agent")
|
|
]}, _search_tool(),
|
|
]}], **updates}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_native_additional_tools_are_projected_in_place_and_audited() -> None:
|
|
"""真实原生目录位置必须执行同样隔离,不能只检查不存在的顶层字段。"""
|
|
outbound = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""捕获投影后的 wire 结构,不连接任何外部服务。"""
|
|
outbound.append(json.loads(request.content))
|
|
return httpx.Response(200, json=_completed())
|
|
|
|
proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(handler))
|
|
async with _client(proxy) as client:
|
|
assert (await client.post("/v1/responses", json=_native_payload())).status_code == 200
|
|
assert "tools" not in outbound[0]
|
|
catalog = outbound[0]["input"][0]["tools"]
|
|
assert [tool.get("name", tool["type"]) for tool in catalog] == ["functions", "collaboration", "tool_search"]
|
|
record = proxy.snapshot()["model_requests"][0]
|
|
assert record["removed_tools"] == ["functions.apply_patch", "clock.curr_time"]
|
|
assert len(record["retained_tools"]) == 9 and "collaboration.spawn_agent" in record["retained_tools"]
|
|
assert record["tool_catalogs"][0]["location"] == "input[0].tools"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_native_tool_search_accepts_the_bundled_multi_agent_source() -> None:
|
|
"""当前原生 CLI 会同时声明协作工具与评测源,已知两者都应保留并继续隔离。"""
|
|
outbound = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""接收投影后的目录,不访问真实模型服务。"""
|
|
outbound.append(json.loads(request.content))
|
|
return httpx.Response(200, json=_completed())
|
|
|
|
payload = _native_payload()
|
|
payload["input"][0]["tools"][-1] = _search_tool(
|
|
"Tools from the following sources:\n- Multi-agent tools: Bundled collaboration tools\n- evaluation: Controlled world"
|
|
)
|
|
proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(handler))
|
|
async with _client(proxy) as client:
|
|
assert (await client.post("/v1/responses", json=payload)).status_code == 200
|
|
assert "tool_search" in proxy.snapshot()["model_requests"][0]["retained_tools"]
|
|
assert outbound[0]["input"][0]["tools"][-1]["execution"] == "client"
|
|
|
|
|
|
@pytest.mark.parametrize("catalog", [
|
|
[{"type": "namespace", "name": "unknown", "tools": [{"type": "function", "name": "update_plan"}]}],
|
|
[_search_tool('Tools from sources:\n- evaluation: fake\n- private: external')],
|
|
[_search_tool('No explicit source list')],
|
|
[{**_search_tool(), "execution": "server"}],
|
|
])
|
|
@pytest.mark.asyncio
|
|
async def test_native_unknown_namespace_or_foreign_discovery_aborts_locally(catalog: list[dict[str, Any]]) -> None:
|
|
"""原生未知目录与搜索源污染必须拒绝,而非静默缩减后宣称隔离成功。"""
|
|
outbound = []
|
|
proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(lambda request: outbound.append(request)))
|
|
payload = _native_payload()
|
|
payload["input"][0]["tools"] = catalog
|
|
async with _client(proxy) as client:
|
|
response = await client.post("/v1/responses", json=payload)
|
|
assert response.status_code == 400 and not outbound
|
|
|
|
|
|
@pytest.mark.parametrize("updates", [{"type": "additional_tools_v2"}, {"role": "user"}, {"unexpected": "field"}, {"tools": None}])
|
|
@pytest.mark.asyncio
|
|
async def test_native_unknown_additional_tools_shape_aborts_locally(updates: dict[str, Any]) -> None:
|
|
"""额外工具消息未知变体不能绕过目录投影。"""
|
|
outbound = []
|
|
proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(lambda request: outbound.append(request)))
|
|
payload = _native_payload()
|
|
payload["input"][0].update(updates)
|
|
async with _client(proxy) as client:
|
|
response = await client.post("/v1/responses", json=payload)
|
|
assert response.status_code == 400 and not outbound
|
|
|
|
|
|
def _search_call(**updates: Any) -> dict[str, Any]:
|
|
"""原生工具搜索调用使用独立响应变体,参数不是 function_call.arguments 字符串。"""
|
|
return {"type": "tool_search_call", "id": "tool-search-one", "call_id": "search-one", "execution": "client",
|
|
"arguments": {"query": "MoviePilot API Skill", "limit": 3}, **updates}
|
|
|
|
|
|
def _search_output(**updates: Any) -> dict[str, Any]:
|
|
"""模拟原生搜索注册目录后合并出的 MCP 命名空间结果。"""
|
|
return {"type": "tool_search_output", "call_id": "search-one", "status": "completed", "execution": "client", "tools": [
|
|
{"type": "namespace", "name": "mcp__evaluation", "tools": [
|
|
{"type": "function", "name": name, "parameters": {}} for name in ("moviepilot_api", "read_skill", "read_tool_result")
|
|
]},
|
|
], **updates}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("limit", [3, None])
|
|
async def test_native_client_discovery_call_and_namespace_output_are_allowed(limit: Optional[int]) -> None:
|
|
"""真实搜索调用返回完整命名空间后,下一次模型请求仍只收到同一评测目录。"""
|
|
call = _search_call(arguments={"query": "MoviePilot API Skill", "limit": limit})
|
|
frames = [_event({"type": "response.output_item.added", "item": _search_call(arguments={})}),
|
|
_event({"type": "response.output_item.done", "item": call}),
|
|
_event({"type": "response.completed", "response": _completed(output=[call])})]
|
|
stream = _ByteStream(frames)
|
|
outbound = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""第一次返回搜索指令,第二次确认已发现工具的后续请求。"""
|
|
outbound.append(json.loads(request.content))
|
|
return httpx.Response(200, stream=stream) if len(outbound) == 1 else httpx.Response(200, json=_completed())
|
|
|
|
proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(handler))
|
|
async with _client(proxy) as client:
|
|
response = await client.post("/v1/responses", json=_native_payload(stream=True))
|
|
assert "tool_search_call" in response.text and "evaluation_error" not in response.text
|
|
payload = _native_payload()
|
|
payload["input"].extend([call, _search_output()])
|
|
assert (await client.post("/v1/responses", json=payload)).status_code == 200
|
|
assert "tools" not in outbound[1]
|
|
record = proxy.snapshot()["model_requests"][1]
|
|
assert record["tool_catalogs"][-1]["location"] == "input[2].tools"
|
|
assert record["tool_catalogs"][-1]["retained_tools"] == [
|
|
"mcp__evaluation.moviepilot_api", "mcp__evaluation.read_skill", "mcp__evaluation.read_tool_result",
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_native_discovery_accepts_multi_agent_collaboration_namespace() -> None:
|
|
"""原生协作命名空间的合法动作可通过目录投影并保留审计记录。"""
|
|
outbound = []
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
"""接收投影后的协作目录,不访问真实模型服务。"""
|
|
outbound.append(json.loads(request.content))
|
|
return httpx.Response(200, json=_completed())
|
|
|
|
tools = [{
|
|
"type": "namespace", "name": "multi_agent_v1",
|
|
"tools": [{"type": "function", "name": name, "parameters": {}}
|
|
for name in ("wait_agent", "send_input")],
|
|
}]
|
|
payload = _native_payload()
|
|
payload["input"].append(_search_output(tools=tools))
|
|
proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(handler))
|
|
async with _client(proxy) as client:
|
|
response = await client.post("/v1/responses", json=payload)
|
|
assert response.status_code == 200 and outbound
|
|
assert proxy.snapshot()["model_requests"][0]["tool_catalogs"][-1]["retained_tools"] == [
|
|
"multi_agent_v1.wait_agent", "multi_agent_v1.send_input",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("updates", [
|
|
{"execution": "server"}, {"call_id": None}, {"call_id": ""}, {"arguments": {"query": " "}},
|
|
{"arguments": {"query": "x", "sources": ["external"]}}, {"arguments": {"query": "x", "limit": 0}},
|
|
{"arguments": {"query": "x", "limit": True}}, {"arguments": {"query": "x", "limit": 1.5}},
|
|
{"arguments": '{"query":"x"}'},
|
|
])
|
|
@pytest.mark.asyncio
|
|
async def test_native_discovery_rejects_server_execution_and_unknown_arguments(updates: dict[str, Any]) -> None:
|
|
"""工具搜索只能操作已注册本地目录,不能让模型提供外部来源或服务端执行指令。"""
|
|
stream = _ByteStream([_event({"type": "response.output_item.done", "item": _search_call(**updates)})])
|
|
proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(lambda _: httpx.Response(200, stream=stream)))
|
|
async with _client(proxy) as client:
|
|
response = await client.post("/v1/responses", json=_native_payload(stream=True))
|
|
assert "evaluation_error" in response.text and '"type": "tool_search_call"' not in response.text
|
|
|
|
|
|
@pytest.mark.parametrize("updates", [
|
|
{"execution": "server"}, {"call_id": None}, {"status": "unknown"}, {"tools": None},
|
|
{"tools": [{"type": "function", "name": "mcp__private__read_secret", "parameters": {}}]},
|
|
{"tools": [{"type": "custom", "name": "apply_patch"}]},
|
|
{"tools": [{"type": "function", "name": "read_file", "parameters": {}}]},
|
|
])
|
|
@pytest.mark.asyncio
|
|
async def test_native_discovery_output_cannot_inject_other_tool_definitions(updates: dict[str, Any]) -> None:
|
|
"""工具搜索输出也是目录入口,不能通过历史结果或模型返回另行引入工具。"""
|
|
outbound = []
|
|
proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(lambda request: outbound.append(request)))
|
|
payload = _native_payload()
|
|
payload["input"].append(_search_output(**updates))
|
|
async with _client(proxy) as client:
|
|
response = await client.post("/v1/responses", json=payload)
|
|
assert response.status_code == 400 and not outbound
|
|
|
|
|
|
class _WaitingStream(_ByteStream):
|
|
"""发出首帧后等待,复现客户端不等供应商结束连接的真实 SSE 行为。"""
|
|
|
|
def __init__(self, first: bytes) -> None:
|
|
"""保留尾部读取事实,用于证明终态后代理主动停止读取。"""
|
|
super().__init__([first])
|
|
self.tail_read = False
|
|
|
|
async def __aiter__(self) -> AsyncIterator[bytes]:
|
|
"""尾部故意不结束,由客户端断开或代理主动关闭来结束本次响应。"""
|
|
yield self.chunks[0]
|
|
self.tail_read = True
|
|
await asyncio.Event().wait()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_terminal_frame_closes_upstream_without_waiting_for_done_or_eof() -> None:
|
|
"""原生收到完成事件即结束,代理不能继续等待尾帧并把正常结束误记为取消。"""
|
|
stream = _WaitingStream(_event({"type": "response.completed", "response": _completed()}))
|
|
proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(lambda _: httpx.Response(200, stream=stream)))
|
|
async with _client(proxy) as client:
|
|
async with asyncio.timeout(1):
|
|
response = await client.post("/v1/responses", json=_payload(stream=True))
|
|
assert "response.completed" in response.text and stream.closed and not stream.tail_read
|
|
result = proxy.snapshot()
|
|
assert result["completed_model_calls"] == 1 and result["tokens"] == USAGE
|
|
assert result["model_requests"][0]["error_type"] is None
|
|
|
|
|
|
@pytest.mark.parametrize("completed", [True, False])
|
|
@pytest.mark.parametrize("cancel", [True, False])
|
|
@pytest.mark.asyncio
|
|
async def test_generator_close_or_cancellation_preserves_only_verified_terminal(completed: bool, cancel: bool) -> None:
|
|
"""生成器取消与关闭都区分已观察终态和提前中断,不抹掉已完成模型的真实消耗。"""
|
|
event = {"type": "response.completed", "response": _completed()} if completed else {"type": "response.created"}
|
|
stream = _WaitingStream(_event(event))
|
|
upstream = httpx.Response(200, stream=stream)
|
|
proxy = EvaluationModelProxy(SETTINGS)
|
|
record = {"completed": False, "usage": None, "error_type": None}
|
|
body = proxy._stream(upstream, record)
|
|
await anext(body)
|
|
if cancel:
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await body.athrow(asyncio.CancelledError())
|
|
else:
|
|
await body.aclose()
|
|
await proxy.close()
|
|
assert stream.closed and record["completed"] is completed
|
|
if completed:
|
|
assert record["usage"] == USAGE and record["error_type"] is None and record["client_closed_after_terminal"] is True
|
|
else:
|
|
assert record["usage"] is None and record["error_type"] == "stream_cancelled"
|
|
|
|
|
|
@pytest.mark.parametrize("completed", [True, False])
|
|
@pytest.mark.asyncio
|
|
async def test_real_asgi_client_disconnect_keeps_terminal_and_rejects_early_close(completed: bool) -> None:
|
|
"""通过真实 ASGI 断开事件取消流式传输,验证客户端结束时的最终审计结果。"""
|
|
event = {"type": "response.completed", "response": _completed()} if completed else {"type": "response.created"}
|
|
stream = _WaitingStream(_event(event))
|
|
proxy = EvaluationModelProxy(SETTINGS, transport=httpx.MockTransport(lambda _: httpx.Response(200, stream=stream)))
|
|
disconnect = asyncio.Event()
|
|
request_sent = False
|
|
delivered = []
|
|
|
|
async def receive() -> dict[str, Any]:
|
|
"""首个事件传入请求,客户端收到首帧后立刻报告断开。"""
|
|
nonlocal request_sent
|
|
if not request_sent:
|
|
request_sent = True
|
|
return {"type": "http.request", "body": json.dumps(_payload(stream=True)).encode(), "more_body": False}
|
|
await disconnect.wait()
|
|
return {"type": "http.disconnect"}
|
|
|
|
async def send(message: dict[str, Any]) -> None:
|
|
"""完成交付首帧后让断开监听器抢占,覆盖真正传输生命周期中的取消。"""
|
|
if message["type"] == "http.response.body" and message.get("body"):
|
|
delivered.append(message["body"])
|
|
disconnect.set()
|
|
await asyncio.sleep(0)
|
|
|
|
scope = {"type": "http", "asgi": {"version": "3.0", "spec_version": "2.3"}, "method": "POST",
|
|
"scheme": "http", "path": "/v1/responses", "raw_path": b"/v1/responses", "query_string": b"",
|
|
"root_path": "", "http_version": "1.1", "server": ("127.0.0.1", 80), "client": ("127.0.0.1", 1234),
|
|
"headers": [(b"authorization", f"Bearer {proxy.bearer_token}".encode())]}
|
|
try:
|
|
async with asyncio.timeout(1):
|
|
await proxy.app(scope, receive, send)
|
|
await asyncio.sleep(0)
|
|
finally:
|
|
await proxy.close()
|
|
assert delivered and stream.closed
|
|
result = proxy.snapshot()
|
|
assert result["completed_model_calls"] == int(completed)
|
|
assert result["tokens"] == (USAGE if completed else None)
|
|
assert result["model_requests"][0]["error_type"] == (None if completed else "stream_cancelled")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_proxy_start_and_close_do_not_replace_host_signal_handlers() -> None:
|
|
"""本地模型服务与 MCP 共用控制器时,启动和关闭都不能接管进程全局信号。"""
|
|
signals = (signal.SIGINT, signal.SIGTERM)
|
|
previous = {item: signal.getsignal(item) for item in signals}
|
|
async with EvaluationModelProxy(SETTINGS, probe_only=True):
|
|
assert {item: signal.getsignal(item) for item in signals} == previous
|
|
assert {item: signal.getsignal(item) for item in signals} == previous
|