mirror of
https://github.com/superdesigndev/treg.git
synced 2026-10-02 03:24:35 +08:00
fix(money): reject invalid Tavily rate tables
This commit is contained in:
@@ -208,6 +208,9 @@ hold. Search reserves one or two credits and `settle._tavily_cost_micro` reads i
|
||||
mapping and extraction modes; Tavily does not expose every successfully mapped page, so treg absorbs
|
||||
any hidden mapping difference under the 20-page platform cap. Extract, Map and Crawl never use the
|
||||
provider account's grouped `usage.credits` to decide which team pays. BYOK bypasses all metering.
|
||||
Each endpoint's `tavily_rates` mapping has an exact mode-key contract. Catalog validation rejects an
|
||||
incomplete, extra, non-finite or non-positive rate, and runtime repeats that check before reserve or
|
||||
relay so catalog drift cannot silently turn a platform call into a free call.
|
||||
|
||||
A verification stamp proves the request shape, response shape, and paid behavior that the evidence
|
||||
actually observed. A placeholder path value or a free miss does not prove a paid hit. Such rows keep
|
||||
|
||||
@@ -503,7 +503,7 @@ Provider-specific calculation stays outside the faithful relay.
|
||||
| Evidence | Settlement behavior |
|
||||
|---|---|
|
||||
| Generic catalog-reported charge | A paid synchronous cost may name `reported_charge.path` with unit `usd`. A finite nonnegative response value, including zero, settles exactly; invalid or absent evidence falls through to the normal estimate/miss behavior |
|
||||
| Tavily Search | Reserve one credit for Basic, Fast and Ultra-fast or two for Advanced and an auto-selected depth; an explicit Basic depth overrides automatic selection. Platform Search requires caller-supplied `include_usage: true` and settles finite nonnegative per-request `usage.credits`. Empty results remain a paid routing miss. Missing or malformed usage keeps the frozen reserve. BYOK is unmetered and need not request usage |
|
||||
| Tavily Search | Reserve one credit for Basic, Fast and Ultra-fast or two for Advanced and an auto-selected depth; an explicit Basic depth overrides automatic selection. Platform Search requires caller-supplied `include_usage: true` and settles finite nonnegative per-request `usage.credits`. Empty results remain a paid routing miss. Missing or malformed usage keeps the frozen reserve. BYOK is unmetered and need not request usage. The endpoint-specific rate table must be complete, positive and finite; catalog validation rejects bad declarations and runtime refuses the call before reserve or relay instead of pricing it at zero |
|
||||
| Tavily Extract | Reserve the requested URL count (bounded by the documented 20-URL maximum) at 0.2 credit per Basic or 0.4 per Advanced extraction. Settle that fractional allocation for each valid entry in `results`; `failed_results` and grouped `usage.credits` do not charge the caller. A documented empty results list is free; malformed evidence keeps the frozen reserve |
|
||||
| Tavily Map | Platform calls require an explicit integer `limit` from 1 to 20. Reserve that many pages at 0.1 credit each, or 0.2 when the caller supplied nonempty `instructions`; settle valid URL strings in `results` at the frozen per-page unit. Empty results are free, malformed evidence keeps the reserve, and grouped `usage.credits` is ignored |
|
||||
| Tavily Crawl | Platform calls require the same 1-20 limit. Reserve per returned extraction at 0.3 credit (Basic), 0.4 (Basic + instructions), 0.5 (Advanced), or 0.6 (Advanced + instructions), then settle valid extracted entries in `results`. This is a conservative deterministic allocation, not the exact Tavily account charge: the response does not expose every page successfully mapped before extraction. treg absorbs any hidden mapping difference, bounded by the 20-page platform cap. Grouped `usage.credits` is ignored and BYOK remains unmetered |
|
||||
|
||||
@@ -257,6 +257,30 @@ def check_platform_request(rule: object, input_schema: object, where: str,
|
||||
fail(errors, where, "platform_request value must match the field's singleton enum")
|
||||
|
||||
|
||||
TAVILY_RATE_KEYS = {
|
||||
"tavily.web.search": {"basic", "fast", "ultra_fast", "advanced"},
|
||||
"tavily.web.extract": {"basic", "advanced"},
|
||||
"tavily.web.map": {"regular", "instructions"},
|
||||
"tavily.web.crawl": {
|
||||
"basic", "basic_instructions", "advanced", "advanced_instructions",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def check_tavily_rates(endpoint_id: str, cost: dict, where: str,
|
||||
errors: list[str]) -> None:
|
||||
"""Tavily's provider-specific formulas require a complete positive finite mode table."""
|
||||
expected = TAVILY_RATE_KEYS.get(endpoint_id)
|
||||
if expected is None:
|
||||
return
|
||||
rates = cost.get("tavily_rates")
|
||||
if not isinstance(rates, dict) or set(rates) != expected:
|
||||
fail(errors, where, "cost.tavily_rates must contain exactly " + ", ".join(sorted(expected)))
|
||||
return
|
||||
if any(not _finite_number(value) or value <= 0 for value in rates.values()):
|
||||
fail(errors, where, "cost.tavily_rates values must be positive finite numbers")
|
||||
|
||||
|
||||
def check_platform_auth(ep: dict, where: str, errors: list[str]) -> None:
|
||||
"""Anonymous platform fallback is intentionally narrow: proven public GETs that cost zero."""
|
||||
mode = ep.get("platform_auth")
|
||||
@@ -1045,6 +1069,8 @@ def main(argv: list[str]) -> int:
|
||||
fail(errors, where, f"cost.type missing or not one of {sorted(COST_TYPES)}")
|
||||
else:
|
||||
check_cost(cost, where, errors, warnings, inp, provider)
|
||||
if service == "tavily":
|
||||
check_tavily_rates(eid, cost, where, errors)
|
||||
effective_async = effective_async_descriptor(data.get("async"), ep.get("async"))
|
||||
if effective_async is not None:
|
||||
check_async_descriptor(effective_async, where, str(service), endpoint_index,
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from email import policy
|
||||
from email.parser import BytesParser
|
||||
@@ -575,6 +576,14 @@ _TAVILY_ENDPOINTS = frozenset({
|
||||
})
|
||||
_TAVILY_BOUNDED_SITE_ENDPOINTS = frozenset({"tavily.web.map", "tavily.web.crawl"})
|
||||
_TAVILY_PLATFORM_MAX_RESULTS = 20
|
||||
_TAVILY_RATE_KEYS = {
|
||||
"tavily.web.search": frozenset({"basic", "fast", "ultra_fast", "advanced"}),
|
||||
"tavily.web.extract": frozenset({"basic", "advanced"}),
|
||||
"tavily.web.map": frozenset({"regular", "instructions"}),
|
||||
"tavily.web.crawl": frozenset({
|
||||
"basic", "basic_instructions", "advanced", "advanced_instructions",
|
||||
}),
|
||||
}
|
||||
|
||||
|
||||
def _openmart_credits(records: int) -> int:
|
||||
@@ -602,31 +611,57 @@ def _openmart_requested_records(endpoint_id: str, body: bytes) -> int | None:
|
||||
return value if type(value) is int else None
|
||||
|
||||
|
||||
def _tavily_rate_credits(cost: dict, name: str) -> float:
|
||||
"""Read Tavily's endpoint/mode credit allocation from catalog data."""
|
||||
rates = cost.get("tavily_rates") or {}
|
||||
value = rates.get(name)
|
||||
return float(value) if isinstance(value, (int, float)) and not isinstance(value, bool) else 0.0
|
||||
def _tavily_rate_credits(endpoint_id: str, cost: dict, name: str) -> float:
|
||||
"""Read a complete positive Tavily rate set, or refuse the call before reserve/relay."""
|
||||
rates = cost.get("tavily_rates")
|
||||
expected = _TAVILY_RATE_KEYS[endpoint_id]
|
||||
valid = (
|
||||
isinstance(rates, dict)
|
||||
and set(rates) == expected
|
||||
and all(
|
||||
isinstance(value, (int, float))
|
||||
and not isinstance(value, bool)
|
||||
and math.isfinite(float(value))
|
||||
and value > 0
|
||||
for value in rates.values()
|
||||
)
|
||||
)
|
||||
if not valid:
|
||||
raise ResolutionFailed(
|
||||
"catalog_price_invalid", status_code=503, detail={
|
||||
"error": "catalog_price_invalid",
|
||||
"endpoint_id": endpoint_id,
|
||||
"message": "Tavily pricing is unavailable because its catalog rates are invalid",
|
||||
},
|
||||
)
|
||||
return float(rates[name])
|
||||
|
||||
|
||||
def _tavily_pricing(endpoint_id: str, cost: dict, body: bytes) -> tuple[int, int]:
|
||||
"""Return Tavily's bounded hold and frozen response unit without rewriting the request."""
|
||||
rate = catalog_store.load().credit_rates.get("tavily")
|
||||
if not rate:
|
||||
return 0, 0
|
||||
if (not isinstance(rate, (int, float)) or isinstance(rate, bool)
|
||||
or not math.isfinite(float(rate)) or rate <= 0):
|
||||
raise ResolutionFailed(
|
||||
"catalog_price_invalid", status_code=503, detail={
|
||||
"error": "catalog_price_invalid",
|
||||
"endpoint_id": endpoint_id,
|
||||
"message": "Tavily pricing is unavailable because its credit rate is invalid",
|
||||
},
|
||||
)
|
||||
credit_micro = _usd_to_micro(float(rate))
|
||||
document = _json_object(body)
|
||||
if endpoint_id == "tavily.web.search":
|
||||
depth = document.get("search_depth")
|
||||
if depth == "advanced":
|
||||
credits = _tavily_rate_credits(cost, "advanced")
|
||||
credits = _tavily_rate_credits(endpoint_id, cost, "advanced")
|
||||
elif depth in ("basic", "fast", "ultra-fast"):
|
||||
# An explicit depth overrides auto_parameters, including explicit basic.
|
||||
credits = _tavily_rate_credits(cost, str(depth).replace("-", "_"))
|
||||
credits = _tavily_rate_credits(endpoint_id, cost, str(depth).replace("-", "_"))
|
||||
elif document.get("auto_parameters") is True:
|
||||
credits = _tavily_rate_credits(cost, "advanced")
|
||||
credits = _tavily_rate_credits(endpoint_id, cost, "advanced")
|
||||
else:
|
||||
credits = _tavily_rate_credits(cost, "basic")
|
||||
credits = _tavily_rate_credits(endpoint_id, cost, "basic")
|
||||
return _usd_to_micro(float(rate) * credits), credit_micro
|
||||
|
||||
instructions = document.get("instructions")
|
||||
@@ -645,7 +680,8 @@ def _tavily_pricing(endpoint_id: str, cost: dict, body: bytes) -> tuple[int, int
|
||||
else:
|
||||
depth = "advanced" if document.get("extract_depth") == "advanced" else "basic"
|
||||
mode = f"{depth}_instructions" if instructed else depth
|
||||
per_result_micro = _usd_to_micro(float(rate) * _tavily_rate_credits(cost, mode))
|
||||
per_result_micro = _usd_to_micro(
|
||||
float(rate) * _tavily_rate_credits(endpoint_id, cost, mode))
|
||||
return count * per_result_micro, per_result_micro
|
||||
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ _BLAME_BY_KIND: dict[str, Blame] = {
|
||||
"trial_allowance_unavailable": "treg",
|
||||
"trial_allowance_reached": "caller",
|
||||
"platform_cap_unavailable": "treg",
|
||||
"catalog_price_invalid": "treg",
|
||||
"platform_daily_cap_reached": "caller",
|
||||
"tag_budget_unavailable": "treg",
|
||||
"tag_cardinality_exceeded": "caller",
|
||||
|
||||
@@ -560,6 +560,35 @@ def test_platform_request_requires_declared_fixed_body_value(rule, valid):
|
||||
assert (not errors) is valid
|
||||
|
||||
|
||||
def test_tavily_rates_require_complete_positive_finite_endpoint_tables():
|
||||
cat = catalog_store.load()
|
||||
for endpoint_id, expected in validator.TAVILY_RATE_KEYS.items():
|
||||
cost = cat.by_id[endpoint_id]["cost"]
|
||||
errors = []
|
||||
validator.check_tavily_rates(endpoint_id, cost, endpoint_id, errors)
|
||||
assert errors == []
|
||||
assert set(cost["tavily_rates"]) == expected
|
||||
|
||||
valid = cat.by_id["tavily.web.search"]["cost"]["tavily_rates"]
|
||||
broken = [
|
||||
None,
|
||||
{},
|
||||
{key: value for key, value in valid.items() if key != "advanced"},
|
||||
valid | {"typo": 1},
|
||||
valid | {"basic": 0},
|
||||
valid | {"basic": -1},
|
||||
valid | {"basic": True},
|
||||
valid | {"basic": "1"},
|
||||
valid | {"basic": float("nan")},
|
||||
valid | {"basic": float("inf")},
|
||||
]
|
||||
for rates in broken:
|
||||
errors = []
|
||||
validator.check_tavily_rates(
|
||||
"tavily.web.search", {"tavily_rates": rates}, "test", errors)
|
||||
assert errors
|
||||
|
||||
|
||||
# ---- ContactOut ----
|
||||
|
||||
def _contactout_cost(eid):
|
||||
|
||||
@@ -1526,6 +1526,29 @@ def test_tavily_search_reserve_honors_depth_and_explicit_basic_override(body, ex
|
||||
assert (reserve, unit) == (expected, 8_000)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rates", [
|
||||
None,
|
||||
{},
|
||||
{"basic": 1, "fast": 1, "ultra_fast": 1},
|
||||
{"basic": 1, "fast": 1, "ultra_fast": 1, "advanced": 2, "typo": 1},
|
||||
{"basic": 0, "fast": 1, "ultra_fast": 1, "advanced": 2},
|
||||
{"basic": -1, "fast": 1, "ultra_fast": 1, "advanced": 2},
|
||||
{"basic": True, "fast": 1, "ultra_fast": 1, "advanced": 2},
|
||||
{"basic": "1", "fast": 1, "ultra_fast": 1, "advanced": 2},
|
||||
{"basic": float("nan"), "fast": 1, "ultra_fast": 1, "advanced": 2},
|
||||
{"basic": float("inf"), "fast": 1, "ultra_fast": 1, "advanced": 2},
|
||||
])
|
||||
def test_tavily_pricing_fails_closed_on_incomplete_or_invalid_rates(rates):
|
||||
cost = _tavily_cost("tavily.web.search") | {"tavily_rates": rates}
|
||||
with pytest.raises(ResolutionFailed) as caught:
|
||||
call_resolution._marketplace_pricing(
|
||||
"tavily", "tavily.web.search", cost, {},
|
||||
b'{"query":"x","search_depth":"basic"}',
|
||||
)
|
||||
assert caught.value.kind == "catalog_price_invalid"
|
||||
assert caught.value.status_code == 503
|
||||
|
||||
|
||||
@pytest.mark.parametrize("count", [1, 4, 5, 6, 20])
|
||||
def test_tavily_extract_reserve_and_basic_settlement_are_fractional_per_success(count):
|
||||
urls = [f"https://example.com/{i}" for i in range(count)]
|
||||
@@ -1690,6 +1713,27 @@ async def test_tavily_platform_gates_search_usage_and_site_work_limits(
|
||||
assert await _balance(clients) == before
|
||||
|
||||
|
||||
async def test_tavily_invalid_runtime_rates_refuse_before_reserve_and_relay(
|
||||
clients, monkeypatch, tavily_platform_on,
|
||||
):
|
||||
rates = catalog_store.load().by_id["tavily.web.search"]["cost"]["tavily_rates"]
|
||||
monkeypatch.setitem(rates, "basic", 0)
|
||||
|
||||
async def must_not_relay(*args, **kwargs):
|
||||
raise AssertionError("invalid Tavily pricing must fail before upstream relay")
|
||||
|
||||
monkeypatch.setattr(call_service, "relay", must_not_relay)
|
||||
before_balance = await _balance(clients)
|
||||
before_entries = await _entries(clients)
|
||||
response = await clients.post("/call/tavily.web.search", json={
|
||||
"query": "x", "search_depth": "basic", "include_usage": True,
|
||||
})
|
||||
assert response.status_code == 503
|
||||
assert response.json()["detail"]["error"] == "catalog_price_invalid"
|
||||
assert await _balance(clients) == before_balance
|
||||
assert await _entries(clients) == before_entries
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [401, 422, 429, 432, 433])
|
||||
async def test_tavily_failures_release_the_hold_without_hiding_upstream_status(
|
||||
clients, monkeypatch, tavily_platform_on, status,
|
||||
|
||||
Reference in New Issue
Block a user