mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
feat(rules): 条件系统增强,支持 all/any 组合条件和 original/current 数据源切换
- evaluate_condition 支持递归 all/any 组合节点和 source 字段 - header_rules 支持 condition 条件触发,HeaderBuilder.apply_rules 透传 body/original_body - 提取 EndpointConditionEditor 组件统一请求头/请求体规则的条件编辑 UI - header_rules 新增服务端结构校验(action/key/from/to/condition) - 新增组合条件、source 切换、fail-closed 等测试用例
This commit is contained in:
@@ -1408,3 +1408,106 @@ class TestItemCondition:
|
||||
# flag=False,全局条件不满足,所有元素都不变
|
||||
assert "active" not in result["tools"][0]
|
||||
assert "active" not in result["tools"][1]
|
||||
|
||||
def test_nested_all_any_condition_with_item_ref(self) -> None:
|
||||
"""嵌套 all/any 中包含 $item 时,按元素递归评估。"""
|
||||
body = {
|
||||
"flag": True,
|
||||
"tools": [
|
||||
{"name": "a", "type": "read"},
|
||||
{"name": "b", "type": "write"},
|
||||
{"name": "c", "type": "other"},
|
||||
],
|
||||
}
|
||||
result = apply_body_rules(
|
||||
body,
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "tools[*].enabled",
|
||||
"value": True,
|
||||
"condition": {
|
||||
"all": [
|
||||
{"path": "flag", "op": "eq", "value": True},
|
||||
{
|
||||
"any": [
|
||||
{"path": "$item.type", "op": "eq", "value": "read"},
|
||||
{"path": "$item.type", "op": "eq", "value": "write"},
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
assert result["tools"][0]["enabled"] is True
|
||||
assert result["tools"][1]["enabled"] is True
|
||||
assert "enabled" not in result["tools"][2]
|
||||
|
||||
def test_condition_source_original_uses_original_body_after_current_mutation(self) -> None:
|
||||
"""source=original 在前序规则改写 current body 后仍读取原始请求体。"""
|
||||
original_body = {"metadata": {"mode": "prod"}}
|
||||
result = apply_body_rules(
|
||||
original_body,
|
||||
[
|
||||
{"action": "set", "path": "metadata.mode", "value": "test"},
|
||||
{
|
||||
"action": "set",
|
||||
"path": "audit.from_original",
|
||||
"value": True,
|
||||
"condition": {
|
||||
"path": "metadata.mode",
|
||||
"op": "eq",
|
||||
"value": "prod",
|
||||
"source": "original",
|
||||
},
|
||||
},
|
||||
{
|
||||
"action": "set",
|
||||
"path": "audit.from_current",
|
||||
"value": True,
|
||||
"condition": {
|
||||
"path": "metadata.mode",
|
||||
"op": "eq",
|
||||
"value": "prod",
|
||||
},
|
||||
},
|
||||
],
|
||||
original_body=original_body,
|
||||
)
|
||||
assert result["metadata"]["mode"] == "test"
|
||||
assert result["audit"]["from_original"] is True
|
||||
assert "from_current" not in result["audit"]
|
||||
|
||||
def test_condition_all_any_can_mix_original_and_current_sources(self) -> None:
|
||||
"""组合条件允许 current/original 混用。"""
|
||||
original_body = {"mode": "prod", "count": 0}
|
||||
result = apply_body_rules(
|
||||
original_body,
|
||||
[
|
||||
{"action": "set", "path": "count", "value": 3},
|
||||
{
|
||||
"action": "set",
|
||||
"path": "matched",
|
||||
"value": True,
|
||||
"condition": {
|
||||
"all": [
|
||||
{"path": "count", "op": "gte", "value": 1},
|
||||
{
|
||||
"any": [
|
||||
{
|
||||
"path": "mode",
|
||||
"op": "eq",
|
||||
"value": "prod",
|
||||
"source": "original",
|
||||
},
|
||||
{"path": "mode", "op": "eq", "value": "stage"},
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
],
|
||||
original_body=original_body,
|
||||
)
|
||||
assert result["matched"] is True
|
||||
|
||||
94
tests/unit/test_endpoint_models.py
Normal file
94
tests/unit/test_endpoint_models.py
Normal file
@@ -0,0 +1,94 @@
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from src.models.endpoint_models import ProviderEndpointCreate, ProviderEndpointUpdate
|
||||
|
||||
|
||||
def test_provider_endpoint_models_accept_nested_conditions_and_source() -> None:
|
||||
payload = {
|
||||
"provider_id": "provider-1",
|
||||
"api_format": "openai:chat",
|
||||
"base_url": "https://api.example.com",
|
||||
"header_rules": [
|
||||
{
|
||||
"action": "set",
|
||||
"key": "X-Test",
|
||||
"value": "1",
|
||||
"condition": {
|
||||
"all": [
|
||||
{"path": "mode", "op": "eq", "value": "prod", "source": "original"},
|
||||
{
|
||||
"any": [
|
||||
{"path": "tier", "op": "eq", "value": "gold"},
|
||||
{"path": "tier", "op": "eq", "value": "silver"},
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
],
|
||||
"body_rules": [
|
||||
{
|
||||
"action": "set",
|
||||
"path": "metadata.enabled",
|
||||
"value": True,
|
||||
"condition": {
|
||||
"all": [
|
||||
{"path": "metadata.kind", "op": "eq", "value": "chat"},
|
||||
{"path": "metadata.tags", "op": "contains", "value": "vip"},
|
||||
]
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
created = ProviderEndpointCreate(**payload)
|
||||
updated = ProviderEndpointUpdate(
|
||||
header_rules=payload["header_rules"],
|
||||
body_rules=payload["body_rules"],
|
||||
)
|
||||
|
||||
assert created.header_rules == payload["header_rules"]
|
||||
assert created.body_rules == payload["body_rules"]
|
||||
assert updated.header_rules == payload["header_rules"]
|
||||
assert updated.body_rules == payload["body_rules"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field_name", "rules"),
|
||||
[
|
||||
(
|
||||
"header_rules",
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"key": "X-Test",
|
||||
"value": "1",
|
||||
"condition": {"path": "mode", "op": "eq", "value": "prod", "source": "bad"},
|
||||
}
|
||||
],
|
||||
),
|
||||
(
|
||||
"body_rules",
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"path": "metadata.enabled",
|
||||
"value": True,
|
||||
"condition": {"all": []},
|
||||
}
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_provider_endpoint_models_reject_invalid_condition_shapes(
|
||||
field_name: str,
|
||||
rules: list[dict],
|
||||
) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ProviderEndpointCreate(
|
||||
provider_id="provider-1",
|
||||
api_format="openai:chat",
|
||||
base_url="https://api.example.com",
|
||||
**{field_name: rules},
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
|
||||
from src.api.handlers.base.request_builder import evaluate_condition
|
||||
from src.core.api_format import (
|
||||
CORE_REDACT_HEADERS,
|
||||
HeaderBuilder,
|
||||
@@ -93,6 +94,68 @@ class TestHeaderBuilder:
|
||||
parsed = json.loads(normalized)
|
||||
assert "d:\\桌面\\123\\Aether" in parsed["workspaces"]
|
||||
|
||||
def test_apply_rules_supports_nested_conditions(self) -> None:
|
||||
builder = HeaderBuilder()
|
||||
builder.apply_rules(
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"key": "X-Flag",
|
||||
"value": "1",
|
||||
"condition": {
|
||||
"all": [
|
||||
{"path": "metadata.mode", "op": "eq", "value": "prod"},
|
||||
{
|
||||
"any": [
|
||||
{"path": "tier", "op": "eq", "value": "gold"},
|
||||
{"path": "tier", "op": "eq", "value": "silver"},
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
],
|
||||
body={"metadata": {"mode": "prod"}, "tier": "silver"},
|
||||
condition_evaluator=evaluate_condition,
|
||||
)
|
||||
assert builder.build()["X-Flag"] == "1"
|
||||
|
||||
def test_apply_rules_supports_original_source(self) -> None:
|
||||
builder = HeaderBuilder()
|
||||
builder.apply_rules(
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"key": "X-Original",
|
||||
"value": "yes",
|
||||
"condition": {
|
||||
"path": "metadata.mode",
|
||||
"op": "eq",
|
||||
"value": "prod",
|
||||
"source": "original",
|
||||
},
|
||||
}
|
||||
],
|
||||
body={"metadata": {"mode": "test"}},
|
||||
original_body={"metadata": {"mode": "prod"}},
|
||||
condition_evaluator=evaluate_condition,
|
||||
)
|
||||
assert builder.build()["X-Original"] == "yes"
|
||||
|
||||
def test_apply_rules_fail_closed_without_body_or_evaluator(self) -> None:
|
||||
builder = HeaderBuilder()
|
||||
builder.apply_rules(
|
||||
[
|
||||
{
|
||||
"action": "set",
|
||||
"key": "X-Skip",
|
||||
"value": "1",
|
||||
"condition": {"path": "flag", "op": "eq", "value": True},
|
||||
}
|
||||
]
|
||||
)
|
||||
assert "X-Skip" not in builder.build()
|
||||
|
||||
|
||||
class TestBuildUpstreamHeaders:
|
||||
def test_priority_and_drop_headers(self) -> None:
|
||||
@@ -143,6 +206,24 @@ class TestBuildUpstreamHeaders:
|
||||
result = build_upstream_headers_for_endpoint({}, "openai:chat", "provider")
|
||||
assert result["Content-Type"] == "application/json"
|
||||
|
||||
def test_header_rules_can_use_condition_against_body(self) -> None:
|
||||
result = build_upstream_headers_for_endpoint(
|
||||
{},
|
||||
"openai:chat",
|
||||
"provider",
|
||||
header_rules=[
|
||||
{
|
||||
"action": "set",
|
||||
"key": "X-Conditional",
|
||||
"value": "1",
|
||||
"condition": {"path": "mode", "op": "eq", "value": "prod"},
|
||||
}
|
||||
],
|
||||
body={"mode": "prod"},
|
||||
condition_evaluator=evaluate_condition,
|
||||
)
|
||||
assert result["X-Conditional"] == "1"
|
||||
|
||||
|
||||
class TestFilterResponseHeaders:
|
||||
def test_drops_hop_by_hop_and_body_dependent_headers(self) -> None:
|
||||
|
||||
Reference in New Issue
Block a user