Spaces:
Running
Running
A3-R5G Gemini + GLM two-provider free pool benchmark availability
#106
by GuXiaopeng - opened
benchmarks/a3_provider_benchmark.py
CHANGED
|
@@ -25,6 +25,12 @@ if str(HF) not in sys.path:
|
|
| 25 |
|
| 26 |
from core.model_usage import JsonlModelUsageSink, ModelUsageEvent # noqa: E402
|
| 27 |
from core.pipeline_agent import SSciPipelineAgent # noqa: E402
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
from core.provider_adapters.gemini_rest_adapter import GeminiRestAdapter # noqa: E402
|
| 29 |
from core.provider_adapters.glm_rest_adapter import ZhipuGLMRestAdapter # noqa: E402
|
| 30 |
|
|
@@ -268,6 +274,7 @@ def run_case(
|
|
| 268 |
"currency": "",
|
| 269 |
"billing_window": "no_usage",
|
| 270 |
}
|
|
|
|
| 271 |
return {
|
| 272 |
"case_id": str(case.get("case_id") or ""),
|
| 273 |
"stage": str(case.get("stage") or ""),
|
|
@@ -284,6 +291,7 @@ def run_case(
|
|
| 284 |
"gate_results": evaluation["gate_results"],
|
| 285 |
"error_class": error.__class__.__name__ if error else "",
|
| 286 |
"error_message": str(error)[:500] if error else "",
|
|
|
|
| 287 |
"output_hash": _output_hash(output) if output else "",
|
| 288 |
"output_preview": _result_preview(output),
|
| 289 |
"usage": usage,
|
|
@@ -362,18 +370,36 @@ def _build_external_adapter(
|
|
| 362 |
raise ValueError(f"unsupported external adapter type for {provider}: {adapter_type or '<empty>'}")
|
| 363 |
|
| 364 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
def select_profile_by_health(
|
| 366 |
*,
|
| 367 |
fixture: Mapping[str, Any],
|
| 368 |
policy_id: str,
|
| 369 |
-
health: Mapping[str, Any],
|
|
|
|
|
|
|
| 370 |
) -> str:
|
| 371 |
-
"""
|
| 372 |
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
can be policy-driven without geography hard-coded in application logic.
|
| 377 |
"""
|
| 378 |
policies = fixture.get("routing_policies")
|
| 379 |
if not isinstance(policies, Mapping):
|
|
@@ -381,41 +407,54 @@ def select_profile_by_health(
|
|
| 381 |
policy = policies.get(policy_id)
|
| 382 |
if not isinstance(policy, Mapping):
|
| 383 |
raise ValueError(f"unknown routing policy: {policy_id}")
|
|
|
|
| 384 |
allowed_cost_classes = {str(v).strip().lower() for v in policy.get("allowed_cost_classes") or []}
|
| 385 |
profiles = fixture.get("profiles") if isinstance(fixture.get("profiles"), Mapping) else {}
|
| 386 |
max_latency_ms = float(policy.get("max_latency_ms") or 0)
|
| 387 |
max_error_rate = float(policy.get("max_error_rate") or 0)
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 391 |
profile = profiles.get(profile_id) if isinstance(profiles, Mapping) else None
|
| 392 |
if not isinstance(profile, Mapping):
|
| 393 |
continue
|
|
|
|
|
|
|
| 394 |
cost_class = str(profile.get("monetary_cost_class") or "").strip().lower()
|
| 395 |
if allowed_cost_classes and cost_class not in allowed_cost_classes:
|
| 396 |
continue
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
|
|
|
|
|
|
| 404 |
else:
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
|
|
|
| 412 |
continue
|
| 413 |
-
if max_latency_ms > 0 and latency_ms > max_latency_ms:
|
| 414 |
continue
|
| 415 |
-
if max_error_rate > 0 and error_rate > max_error_rate:
|
| 416 |
continue
|
| 417 |
return profile_id
|
| 418 |
-
|
|
|
|
| 419 |
|
| 420 |
|
| 421 |
def run_benchmark(
|
|
|
|
| 25 |
|
| 26 |
from core.model_usage import JsonlModelUsageSink, ModelUsageEvent # noqa: E402
|
| 27 |
from core.pipeline_agent import SSciPipelineAgent # noqa: E402
|
| 28 |
+
from core.provider_availability import ( # noqa: E402
|
| 29 |
+
HealthKey,
|
| 30 |
+
ProviderAvailabilityTracker,
|
| 31 |
+
classify_provider_error,
|
| 32 |
+
snapshot_from_legacy_health,
|
| 33 |
+
)
|
| 34 |
from core.provider_adapters.gemini_rest_adapter import GeminiRestAdapter # noqa: E402
|
| 35 |
from core.provider_adapters.glm_rest_adapter import ZhipuGLMRestAdapter # noqa: E402
|
| 36 |
|
|
|
|
| 274 |
"currency": "",
|
| 275 |
"billing_window": "no_usage",
|
| 276 |
}
|
| 277 |
+
availability_error = classify_provider_error(error, provider=provider).to_dict() if error else {}
|
| 278 |
return {
|
| 279 |
"case_id": str(case.get("case_id") or ""),
|
| 280 |
"stage": str(case.get("stage") or ""),
|
|
|
|
| 291 |
"gate_results": evaluation["gate_results"],
|
| 292 |
"error_class": error.__class__.__name__ if error else "",
|
| 293 |
"error_message": str(error)[:500] if error else "",
|
| 294 |
+
"availability_error": availability_error,
|
| 295 |
"output_hash": _output_hash(output) if output else "",
|
| 296 |
"output_preview": _result_preview(output),
|
| 297 |
"usage": usage,
|
|
|
|
| 370 |
raise ValueError(f"unsupported external adapter type for {provider}: {adapter_type or '<empty>'}")
|
| 371 |
|
| 372 |
|
| 373 |
+
def _profile_health_key(
|
| 374 |
+
*,
|
| 375 |
+
fixture: Mapping[str, Any],
|
| 376 |
+
profile_id: str,
|
| 377 |
+
profile: Mapping[str, Any],
|
| 378 |
+
runtime_egress_profile: str,
|
| 379 |
+
) -> HealthKey:
|
| 380 |
+
provider = str(profile.get("provider") or "").strip().lower()
|
| 381 |
+
adapter_config = _provider_config(fixture, provider)
|
| 382 |
+
endpoint_profile_id = str(adapter_config.get("endpoint_profile_id") or provider or "unknown").strip()
|
| 383 |
+
return HealthKey(
|
| 384 |
+
provider_profile_id=str(profile_id or "").strip(),
|
| 385 |
+
endpoint_profile_id=endpoint_profile_id,
|
| 386 |
+
runtime_egress_profile=str(runtime_egress_profile or "benchmark_default").strip(),
|
| 387 |
+
)
|
| 388 |
+
|
| 389 |
+
|
| 390 |
def select_profile_by_health(
|
| 391 |
*,
|
| 392 |
fixture: Mapping[str, Any],
|
| 393 |
policy_id: str,
|
| 394 |
+
health: Mapping[str, Any] | None = None,
|
| 395 |
+
tracker: ProviderAvailabilityTracker | None = None,
|
| 396 |
+
runtime_egress_profile: str | None = None,
|
| 397 |
) -> str:
|
| 398 |
+
"""Select a benchmark profile from versioned policy and server health.
|
| 399 |
|
| 400 |
+
R5G keeps backward compatibility with the original provider-keyed health
|
| 401 |
+
shape while delegating eligibility to the provider-neutral availability
|
| 402 |
+
snapshot contract. No country/browser location participates in routing.
|
|
|
|
| 403 |
"""
|
| 404 |
policies = fixture.get("routing_policies")
|
| 405 |
if not isinstance(policies, Mapping):
|
|
|
|
| 407 |
policy = policies.get(policy_id)
|
| 408 |
if not isinstance(policy, Mapping):
|
| 409 |
raise ValueError(f"unknown routing policy: {policy_id}")
|
| 410 |
+
|
| 411 |
allowed_cost_classes = {str(v).strip().lower() for v in policy.get("allowed_cost_classes") or []}
|
| 412 |
profiles = fixture.get("profiles") if isinstance(fixture.get("profiles"), Mapping) else {}
|
| 413 |
max_latency_ms = float(policy.get("max_latency_ms") or 0)
|
| 414 |
max_error_rate = float(policy.get("max_error_rate") or 0)
|
| 415 |
+
runtime_profile = str(
|
| 416 |
+
runtime_egress_profile
|
| 417 |
+
or policy.get("runtime_egress_profile")
|
| 418 |
+
or "benchmark_default"
|
| 419 |
+
).strip()
|
| 420 |
+
legacy_health = health if isinstance(health, Mapping) else {}
|
| 421 |
+
|
| 422 |
+
for raw_profile_id in policy.get("candidate_profiles") or []:
|
| 423 |
+
profile_id = str(raw_profile_id or "").strip()
|
| 424 |
profile = profiles.get(profile_id) if isinstance(profiles, Mapping) else None
|
| 425 |
if not isinstance(profile, Mapping):
|
| 426 |
continue
|
| 427 |
+
|
| 428 |
+
# Cost/entitlement boundary is enforced before availability.
|
| 429 |
cost_class = str(profile.get("monetary_cost_class") or "").strip().lower()
|
| 430 |
if allowed_cost_classes and cost_class not in allowed_cost_classes:
|
| 431 |
continue
|
| 432 |
+
|
| 433 |
+
key = _profile_health_key(
|
| 434 |
+
fixture=fixture,
|
| 435 |
+
profile_id=profile_id,
|
| 436 |
+
profile=profile,
|
| 437 |
+
runtime_egress_profile=runtime_profile,
|
| 438 |
+
)
|
| 439 |
+
if tracker is not None:
|
| 440 |
+
snapshot = tracker.snapshot(key)
|
| 441 |
else:
|
| 442 |
+
provider = str(profile.get("provider") or "").strip().lower()
|
| 443 |
+
raw_health = legacy_health.get(key.as_string(), legacy_health.get(provider))
|
| 444 |
+
snapshot = snapshot_from_legacy_health(
|
| 445 |
+
raw_health,
|
| 446 |
+
latency_degraded_threshold_ms=max_latency_ms,
|
| 447 |
+
)
|
| 448 |
+
|
| 449 |
+
if not snapshot.eligible:
|
| 450 |
continue
|
| 451 |
+
if max_latency_ms > 0 and snapshot.latency_ms > max_latency_ms:
|
| 452 |
continue
|
| 453 |
+
if max_error_rate > 0 and snapshot.error_rate > max_error_rate:
|
| 454 |
continue
|
| 455 |
return profile_id
|
| 456 |
+
|
| 457 |
+
raise RuntimeError(f"no_eligible_provider_for_policy:{policy_id}")
|
| 458 |
|
| 459 |
|
| 460 |
def run_benchmark(
|
benchmarks/fixtures/a3_benchmark_cases.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
{
|
| 2 |
-
"benchmark_version": "a3-provider-benchmark-
|
| 3 |
-
"notes": "Synthetic B1/B2 benchmark cases only; no live user prompts, credentials, payment, quota, or manuscript data. Provider/model/endpoints and
|
| 4 |
"profiles": {
|
| 5 |
"gemini_free_mechanical": {
|
| 6 |
"provider": "gemini",
|
|
@@ -529,27 +529,30 @@
|
|
| 529 |
"provider_adapters": {
|
| 530 |
"deepseek": {
|
| 531 |
"adapter_type": "pipeline_deepseek",
|
| 532 |
-
"api_key_env": "DEEPSEEK_API_KEY"
|
|
|
|
| 533 |
},
|
| 534 |
"gemini": {
|
| 535 |
"adapter_type": "gemini_rest",
|
| 536 |
"api_key_env": "GEMINI_API_KEY",
|
| 537 |
"base_url_env": "GEMINI_API_BASE_URL",
|
| 538 |
"base_url": "https://generativelanguage.googleapis.com/v1beta",
|
| 539 |
-
"timeout_seconds": 120
|
|
|
|
| 540 |
},
|
| 541 |
"zhipu": {
|
| 542 |
"adapter_type": "zhipu_glm_rest",
|
| 543 |
"api_key_env": "ZHIPU_API_KEY",
|
| 544 |
"base_url_env": "ZHIPU_API_BASE_URL",
|
| 545 |
"base_url": "https://open.bigmodel.cn/api/paas/v4",
|
| 546 |
-
"timeout_seconds": 120
|
|
|
|
| 547 |
}
|
| 548 |
},
|
| 549 |
"routing_policies": {
|
| 550 |
"free_auto_v1": {
|
| 551 |
"status": "benchmark_design_only",
|
| 552 |
-
"selection_strategy": "
|
| 553 |
"candidate_profiles": [
|
| 554 |
"zhipu_glm47_free_no_thinking",
|
| 555 |
"gemini_free_mechanical"
|
|
@@ -562,11 +565,17 @@
|
|
| 562 |
"skip_rate_limited": true,
|
| 563 |
"decision_inputs": [
|
| 564 |
"server_reachability",
|
|
|
|
| 565 |
"recent_provider_health",
|
| 566 |
"rate_limit_state",
|
|
|
|
| 567 |
"latency"
|
| 568 |
],
|
| 569 |
-
"client_region_authoritative": false
|
|
|
|
|
|
|
|
|
|
|
|
|
| 570 |
},
|
| 571 |
"standard_resilience_v1": {
|
| 572 |
"status": "benchmark_design_only",
|
|
@@ -613,5 +622,40 @@
|
|
| 613 |
],
|
| 614 |
"client_region_authoritative": false
|
| 615 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 616 |
}
|
| 617 |
}
|
|
|
|
| 1 |
{
|
| 2 |
+
"benchmark_version": "a3-provider-benchmark-v3",
|
| 3 |
+
"notes": "Synthetic B1/B2 benchmark cases only; no live user prompts, credentials, payment, quota, or manuscript data. A3-R5G Free Pool is limited to Gemini free + Zhipu GLM free. Provider/model/endpoints and all availability thresholds are versioned configuration. Runtime geography is not hard-coded; selection uses server-observed health under a frozen cost policy, and paid fallback is forbidden for the Free pool.",
|
| 4 |
"profiles": {
|
| 5 |
"gemini_free_mechanical": {
|
| 6 |
"provider": "gemini",
|
|
|
|
| 529 |
"provider_adapters": {
|
| 530 |
"deepseek": {
|
| 531 |
"adapter_type": "pipeline_deepseek",
|
| 532 |
+
"api_key_env": "DEEPSEEK_API_KEY",
|
| 533 |
+
"endpoint_profile_id": "deepseek_api_default"
|
| 534 |
},
|
| 535 |
"gemini": {
|
| 536 |
"adapter_type": "gemini_rest",
|
| 537 |
"api_key_env": "GEMINI_API_KEY",
|
| 538 |
"base_url_env": "GEMINI_API_BASE_URL",
|
| 539 |
"base_url": "https://generativelanguage.googleapis.com/v1beta",
|
| 540 |
+
"timeout_seconds": 120,
|
| 541 |
+
"endpoint_profile_id": "google_gemini_developer_api_v1beta"
|
| 542 |
},
|
| 543 |
"zhipu": {
|
| 544 |
"adapter_type": "zhipu_glm_rest",
|
| 545 |
"api_key_env": "ZHIPU_API_KEY",
|
| 546 |
"base_url_env": "ZHIPU_API_BASE_URL",
|
| 547 |
"base_url": "https://open.bigmodel.cn/api/paas/v4",
|
| 548 |
+
"timeout_seconds": 120,
|
| 549 |
+
"endpoint_profile_id": "zhipu_bigmodel_api_v4"
|
| 550 |
}
|
| 551 |
},
|
| 552 |
"routing_policies": {
|
| 553 |
"free_auto_v1": {
|
| 554 |
"status": "benchmark_design_only",
|
| 555 |
+
"selection_strategy": "cost_gate_then_availability_then_config_priority",
|
| 556 |
"candidate_profiles": [
|
| 557 |
"zhipu_glm47_free_no_thinking",
|
| 558 |
"gemini_free_mechanical"
|
|
|
|
| 565 |
"skip_rate_limited": true,
|
| 566 |
"decision_inputs": [
|
| 567 |
"server_reachability",
|
| 568 |
+
"provider_business_code",
|
| 569 |
"recent_provider_health",
|
| 570 |
"rate_limit_state",
|
| 571 |
+
"circuit_state",
|
| 572 |
"latency"
|
| 573 |
],
|
| 574 |
+
"client_region_authoritative": false,
|
| 575 |
+
"availability_policy_id": "benchmark_free_pool_availability_v1",
|
| 576 |
+
"runtime_egress_profile": "benchmark_default",
|
| 577 |
+
"price_contract": "input_and_output_zero_required",
|
| 578 |
+
"paid_fallback_allowed": false
|
| 579 |
},
|
| 580 |
"standard_resilience_v1": {
|
| 581 |
"status": "benchmark_design_only",
|
|
|
|
| 622 |
],
|
| 623 |
"client_region_authoritative": false
|
| 624 |
}
|
| 625 |
+
},
|
| 626 |
+
"availability_policies": {
|
| 627 |
+
"benchmark_free_pool_availability_v1": {
|
| 628 |
+
"status": "synthetic_benchmark_only",
|
| 629 |
+
"failure_window_seconds": 300,
|
| 630 |
+
"failure_threshold": 3,
|
| 631 |
+
"immediate_open_error_kinds": [
|
| 632 |
+
"OVERLOAD"
|
| 633 |
+
],
|
| 634 |
+
"overload_cooldown_schedule": [
|
| 635 |
+
30,
|
| 636 |
+
120,
|
| 637 |
+
300
|
| 638 |
+
],
|
| 639 |
+
"rate_limit_backoff_policy": {
|
| 640 |
+
"base_seconds": 15,
|
| 641 |
+
"max_seconds": 120
|
| 642 |
+
},
|
| 643 |
+
"half_open_probe_interval": 30,
|
| 644 |
+
"max_provider_attempts_per_node": 2,
|
| 645 |
+
"max_cross_provider_failovers_per_node": 1,
|
| 646 |
+
"latency_degraded_threshold_ms": 60000,
|
| 647 |
+
"production_tuning_authorized": false
|
| 648 |
+
}
|
| 649 |
+
},
|
| 650 |
+
"free_provider_pool": {
|
| 651 |
+
"status": "benchmark_only",
|
| 652 |
+
"providers": [
|
| 653 |
+
"zhipu",
|
| 654 |
+
"gemini"
|
| 655 |
+
],
|
| 656 |
+
"policy_id": "free_auto_v1",
|
| 657 |
+
"price_contract": "input_and_output_zero_required",
|
| 658 |
+
"paid_fallback_allowed": false,
|
| 659 |
+
"production_activation": false
|
| 660 |
}
|
| 661 |
}
|
core/provider_adapters/gemini_rest_adapter.py
CHANGED
|
@@ -15,6 +15,7 @@ from typing import Any, Iterable, Mapping
|
|
| 15 |
|
| 16 |
import requests
|
| 17 |
|
|
|
|
| 18 |
from core.model_usage import (
|
| 19 |
ModelUsageEvent,
|
| 20 |
emit_usage_event,
|
|
@@ -186,16 +187,20 @@ class GeminiRestAdapter:
|
|
| 186 |
def _raise_for_status(self, response: Any) -> None:
|
| 187 |
status = int(getattr(response, "status_code", 0) or 0)
|
| 188 |
detail = self._safe_error_detail(response)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
if status in (401, 403):
|
| 190 |
-
|
| 191 |
if status == 429:
|
| 192 |
-
|
| 193 |
if 400 <= status < 500:
|
| 194 |
-
|
| 195 |
if status >= 500:
|
| 196 |
-
|
| 197 |
if status != 200:
|
| 198 |
-
|
| 199 |
|
| 200 |
def _event(
|
| 201 |
self,
|
|
|
|
| 15 |
|
| 16 |
import requests
|
| 17 |
|
| 18 |
+
from core.provider_availability import attach_safe_error_metadata
|
| 19 |
from core.model_usage import (
|
| 20 |
ModelUsageEvent,
|
| 21 |
emit_usage_event,
|
|
|
|
| 187 |
def _raise_for_status(self, response: Any) -> None:
|
| 188 |
status = int(getattr(response, "status_code", 0) or 0)
|
| 189 |
detail = self._safe_error_detail(response)
|
| 190 |
+
|
| 191 |
+
def _raise(error: GeminiAPIError) -> None:
|
| 192 |
+
raise attach_safe_error_metadata(error, response, provider="gemini")
|
| 193 |
+
|
| 194 |
if status in (401, 403):
|
| 195 |
+
_raise(GeminiAuthenticationError(f"Gemini authentication failed ({status}): {detail}"))
|
| 196 |
if status == 429:
|
| 197 |
+
_raise(GeminiRateLimitError(f"Gemini rate limit reached (429): {detail}"))
|
| 198 |
if 400 <= status < 500:
|
| 199 |
+
_raise(GeminiRequestError(f"Gemini request rejected ({status}): {detail}"))
|
| 200 |
if status >= 500:
|
| 201 |
+
_raise(GeminiAPIError(f"Gemini provider error ({status}): {detail}"))
|
| 202 |
if status != 200:
|
| 203 |
+
_raise(GeminiAPIError(f"Gemini unexpected HTTP status ({status}): {detail}"))
|
| 204 |
|
| 205 |
def _event(
|
| 206 |
self,
|
core/provider_adapters/glm_rest_adapter.py
CHANGED
|
@@ -16,6 +16,7 @@ from typing import Any, Iterable, Mapping
|
|
| 16 |
|
| 17 |
import requests
|
| 18 |
|
|
|
|
| 19 |
from core.model_usage import (
|
| 20 |
ModelUsageEvent,
|
| 21 |
emit_usage_event,
|
|
@@ -181,16 +182,23 @@ class ZhipuGLMRestAdapter:
|
|
| 181 |
def _raise_for_status(self, response: Any) -> None:
|
| 182 |
status = int(getattr(response, "status_code", 0) or 0)
|
| 183 |
detail = self._safe_error_detail(response)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
if status in (401, 403):
|
| 185 |
-
|
| 186 |
if status == 429:
|
| 187 |
-
|
|
|
|
|
|
|
|
|
|
| 188 |
if 400 <= status < 500:
|
| 189 |
-
|
| 190 |
if status >= 500:
|
| 191 |
-
|
| 192 |
if status != 200:
|
| 193 |
-
|
| 194 |
|
| 195 |
def _event(
|
| 196 |
self,
|
|
|
|
| 16 |
|
| 17 |
import requests
|
| 18 |
|
| 19 |
+
from core.provider_availability import attach_safe_error_metadata
|
| 20 |
from core.model_usage import (
|
| 21 |
ModelUsageEvent,
|
| 22 |
emit_usage_event,
|
|
|
|
| 182 |
def _raise_for_status(self, response: Any) -> None:
|
| 183 |
status = int(getattr(response, "status_code", 0) or 0)
|
| 184 |
detail = self._safe_error_detail(response)
|
| 185 |
+
|
| 186 |
+
def _raise(error: ZhipuAPIError) -> None:
|
| 187 |
+
raise attach_safe_error_metadata(error, response, provider="zhipu")
|
| 188 |
+
|
| 189 |
if status in (401, 403):
|
| 190 |
+
_raise(ZhipuAuthenticationError(f"Zhipu authentication failed ({status}): {detail}"))
|
| 191 |
if status == 429:
|
| 192 |
+
# Preserve the existing public exception type for compatibility.
|
| 193 |
+
# A3-R5G classifies safe provider business codes (e.g. 1302 vs
|
| 194 |
+
# 1305) before the generic HTTP 429 meaning.
|
| 195 |
+
_raise(ZhipuRateLimitError(f"Zhipu rate limit reached (429): {detail}"))
|
| 196 |
if 400 <= status < 500:
|
| 197 |
+
_raise(ZhipuRequestError(f"Zhipu request rejected ({status}): {detail}"))
|
| 198 |
if status >= 500:
|
| 199 |
+
_raise(ZhipuAPIError(f"Zhipu provider error ({status}): {detail}"))
|
| 200 |
if status != 200:
|
| 201 |
+
_raise(ZhipuAPIError(f"Zhipu unexpected HTTP status ({status}): {detail}"))
|
| 202 |
|
| 203 |
def _event(
|
| 204 |
self,
|
core/provider_availability.py
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# -*- coding: utf-8 -*-
|
| 2 |
+
"""Provider-neutral availability taxonomy and benchmark-only health tracker.
|
| 3 |
+
|
| 4 |
+
A3-R5G scope is deliberately narrow: this module has no network calls, no
|
| 5 |
+
production route activation, no Worker/D1 access, and no product entitlement
|
| 6 |
+
logic. Provider/model/endpoints and all retry/circuit thresholds remain
|
| 7 |
+
versioned policy data supplied by callers.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from dataclasses import asdict, dataclass, field
|
| 12 |
+
from datetime import datetime, timezone
|
| 13 |
+
from email.utils import parsedate_to_datetime
|
| 14 |
+
import re
|
| 15 |
+
import threading
|
| 16 |
+
import time
|
| 17 |
+
from typing import Any, Callable, Mapping
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
ERROR_KINDS = {
|
| 21 |
+
"AUTHENTICATION",
|
| 22 |
+
"PERMISSION",
|
| 23 |
+
"BALANCE",
|
| 24 |
+
"RATE_LIMIT",
|
| 25 |
+
"OVERLOAD",
|
| 26 |
+
"QUOTA_EXHAUSTED",
|
| 27 |
+
"TIMEOUT",
|
| 28 |
+
"CONNECTION",
|
| 29 |
+
"SERVER_ERROR",
|
| 30 |
+
"REQUEST_INVALID",
|
| 31 |
+
"CANCELLED",
|
| 32 |
+
"UNKNOWN",
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
AVAILABILITY_STATES = {
|
| 36 |
+
"UNKNOWN",
|
| 37 |
+
"HEALTHY",
|
| 38 |
+
"DEGRADED",
|
| 39 |
+
"RATE_LIMITED",
|
| 40 |
+
"OVERLOADED",
|
| 41 |
+
"UNREACHABLE",
|
| 42 |
+
"AUTH_BLOCKED",
|
| 43 |
+
"BALANCE_BLOCKED",
|
| 44 |
+
"CIRCUIT_OPEN",
|
| 45 |
+
"DISABLED",
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
CIRCUIT_STATES = {"CLOSED", "OPEN", "HALF_OPEN"}
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@dataclass(frozen=True)
|
| 52 |
+
class HealthKey:
|
| 53 |
+
provider_profile_id: str
|
| 54 |
+
endpoint_profile_id: str
|
| 55 |
+
runtime_egress_profile: str
|
| 56 |
+
|
| 57 |
+
def as_string(self) -> str:
|
| 58 |
+
return "::".join(
|
| 59 |
+
(
|
| 60 |
+
str(self.provider_profile_id or "").strip(),
|
| 61 |
+
str(self.endpoint_profile_id or "").strip(),
|
| 62 |
+
str(self.runtime_egress_profile or "").strip(),
|
| 63 |
+
)
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@dataclass(frozen=True)
|
| 68 |
+
class ProviderErrorSignal:
|
| 69 |
+
provider: str
|
| 70 |
+
error_kind: str
|
| 71 |
+
availability_state: str
|
| 72 |
+
retryable: bool
|
| 73 |
+
http_status: int | None = None
|
| 74 |
+
provider_code: str = ""
|
| 75 |
+
retry_after_seconds: float | None = None
|
| 76 |
+
safe_reason_code: str = ""
|
| 77 |
+
penalizes_health: bool = False
|
| 78 |
+
|
| 79 |
+
def to_dict(self) -> dict[str, Any]:
|
| 80 |
+
return asdict(self)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
@dataclass(frozen=True)
|
| 84 |
+
class HealthSnapshot:
|
| 85 |
+
availability_state: str = "UNKNOWN"
|
| 86 |
+
circuit_state: str = "CLOSED"
|
| 87 |
+
eligible: bool = True
|
| 88 |
+
recent_successes: int = 0
|
| 89 |
+
recent_failures: int = 0
|
| 90 |
+
success_rate: float = 0.0
|
| 91 |
+
error_rate: float = 0.0
|
| 92 |
+
latency_ms: float = 0.0
|
| 93 |
+
blocked_until: float | None = None
|
| 94 |
+
last_error_kind: str = ""
|
| 95 |
+
last_provider_code: str = ""
|
| 96 |
+
cause_state: str = "UNKNOWN"
|
| 97 |
+
|
| 98 |
+
def to_dict(self) -> dict[str, Any]:
|
| 99 |
+
return asdict(self)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@dataclass
|
| 103 |
+
class _HealthRecord:
|
| 104 |
+
availability_state: str = "UNKNOWN"
|
| 105 |
+
circuit_state: str = "CLOSED"
|
| 106 |
+
blocked_until: float | None = None
|
| 107 |
+
last_error_kind: str = ""
|
| 108 |
+
last_provider_code: str = ""
|
| 109 |
+
consecutive_overloads: int = 0
|
| 110 |
+
events: list[tuple[float, bool, bool, float]] = field(default_factory=list)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _coerce_status(value: Any) -> int | None:
|
| 114 |
+
try:
|
| 115 |
+
number = int(value)
|
| 116 |
+
except (TypeError, ValueError):
|
| 117 |
+
return None
|
| 118 |
+
return number if number > 0 else None
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def _coerce_positive_float(value: Any) -> float | None:
|
| 122 |
+
try:
|
| 123 |
+
number = float(value)
|
| 124 |
+
except (TypeError, ValueError):
|
| 125 |
+
return None
|
| 126 |
+
return number if number >= 0 else None
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _header_value(headers: Any, name: str) -> str:
|
| 130 |
+
if not isinstance(headers, Mapping):
|
| 131 |
+
return ""
|
| 132 |
+
target = name.lower()
|
| 133 |
+
for key, value in headers.items():
|
| 134 |
+
if str(key).lower() == target:
|
| 135 |
+
return str(value or "").strip()
|
| 136 |
+
return ""
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _parse_retry_after(value: Any, *, now_epoch: float | None = None) -> float | None:
|
| 140 |
+
text = str(value or "").strip()
|
| 141 |
+
if not text:
|
| 142 |
+
return None
|
| 143 |
+
numeric = _coerce_positive_float(text)
|
| 144 |
+
if numeric is not None:
|
| 145 |
+
return numeric
|
| 146 |
+
try:
|
| 147 |
+
when = parsedate_to_datetime(text)
|
| 148 |
+
if when.tzinfo is None:
|
| 149 |
+
when = when.replace(tzinfo=timezone.utc)
|
| 150 |
+
now = datetime.fromtimestamp(now_epoch if now_epoch is not None else time.time(), tz=timezone.utc)
|
| 151 |
+
return max(0.0, (when.astimezone(timezone.utc) - now).total_seconds())
|
| 152 |
+
except Exception:
|
| 153 |
+
return None
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def _parse_duration_seconds(value: Any) -> float | None:
|
| 157 |
+
text = str(value or "").strip().lower()
|
| 158 |
+
match = re.fullmatch(r"([0-9]+(?:\.[0-9]+)?)s", text)
|
| 159 |
+
if match:
|
| 160 |
+
return float(match.group(1))
|
| 161 |
+
return _coerce_positive_float(text)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def safe_response_metadata(response: Any, *, provider: str) -> dict[str, Any]:
|
| 165 |
+
"""Extract only non-sensitive operational metadata from a provider response."""
|
| 166 |
+
status = _coerce_status(getattr(response, "status_code", None))
|
| 167 |
+
retry_after = _parse_retry_after(
|
| 168 |
+
_header_value(getattr(response, "headers", None), "Retry-After")
|
| 169 |
+
)
|
| 170 |
+
provider_code = ""
|
| 171 |
+
provider_reason = ""
|
| 172 |
+
try:
|
| 173 |
+
payload = response.json()
|
| 174 |
+
except Exception:
|
| 175 |
+
payload = None
|
| 176 |
+
|
| 177 |
+
if isinstance(payload, Mapping):
|
| 178 |
+
error = payload.get("error")
|
| 179 |
+
error_map = error if isinstance(error, Mapping) else payload
|
| 180 |
+
raw_code = error_map.get("code") if isinstance(error_map, Mapping) else None
|
| 181 |
+
if raw_code is None:
|
| 182 |
+
raw_code = payload.get("code")
|
| 183 |
+
if raw_code is not None:
|
| 184 |
+
provider_code = str(raw_code).strip()[:80]
|
| 185 |
+
if isinstance(error_map, Mapping):
|
| 186 |
+
raw_reason = error_map.get("status") or error_map.get("type") or error_map.get("reason")
|
| 187 |
+
if raw_reason:
|
| 188 |
+
provider_reason = str(raw_reason).strip()[:120]
|
| 189 |
+
if retry_after is None:
|
| 190 |
+
details = error_map.get("details")
|
| 191 |
+
if isinstance(details, list):
|
| 192 |
+
for detail in details:
|
| 193 |
+
if not isinstance(detail, Mapping):
|
| 194 |
+
continue
|
| 195 |
+
retry_after = _parse_duration_seconds(
|
| 196 |
+
detail.get("retryDelay") or detail.get("retry_delay")
|
| 197 |
+
)
|
| 198 |
+
if retry_after is not None:
|
| 199 |
+
break
|
| 200 |
+
|
| 201 |
+
return {
|
| 202 |
+
"provider": str(provider or "").strip().lower(),
|
| 203 |
+
"http_status": status,
|
| 204 |
+
"provider_code": provider_code,
|
| 205 |
+
"provider_reason": provider_reason,
|
| 206 |
+
"retry_after_seconds": retry_after,
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def attach_safe_error_metadata(error: BaseException, response: Any, *, provider: str) -> BaseException:
|
| 211 |
+
metadata = safe_response_metadata(response, provider=provider)
|
| 212 |
+
for key, value in metadata.items():
|
| 213 |
+
try:
|
| 214 |
+
setattr(error, key, value)
|
| 215 |
+
except Exception:
|
| 216 |
+
pass
|
| 217 |
+
return error
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def _signal(
|
| 221 |
+
*,
|
| 222 |
+
provider: str,
|
| 223 |
+
error_kind: str,
|
| 224 |
+
availability_state: str,
|
| 225 |
+
retryable: bool,
|
| 226 |
+
http_status: int | None,
|
| 227 |
+
provider_code: str,
|
| 228 |
+
retry_after_seconds: float | None,
|
| 229 |
+
safe_reason_code: str,
|
| 230 |
+
penalizes_health: bool,
|
| 231 |
+
) -> ProviderErrorSignal:
|
| 232 |
+
kind = str(error_kind or "UNKNOWN").upper()
|
| 233 |
+
state = str(availability_state or "UNKNOWN").upper()
|
| 234 |
+
if kind not in ERROR_KINDS:
|
| 235 |
+
kind = "UNKNOWN"
|
| 236 |
+
if state not in AVAILABILITY_STATES:
|
| 237 |
+
state = "UNKNOWN"
|
| 238 |
+
return ProviderErrorSignal(
|
| 239 |
+
provider=str(provider or "").strip().lower(),
|
| 240 |
+
error_kind=kind,
|
| 241 |
+
availability_state=state,
|
| 242 |
+
retryable=bool(retryable),
|
| 243 |
+
http_status=_coerce_status(http_status),
|
| 244 |
+
provider_code=str(provider_code or "").strip()[:80],
|
| 245 |
+
retry_after_seconds=_coerce_positive_float(retry_after_seconds),
|
| 246 |
+
safe_reason_code=str(safe_reason_code or "").strip()[:120],
|
| 247 |
+
penalizes_health=bool(penalizes_health),
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def classify_provider_error(
|
| 252 |
+
error: BaseException | None = None,
|
| 253 |
+
*,
|
| 254 |
+
provider: str = "",
|
| 255 |
+
http_status: int | None = None,
|
| 256 |
+
provider_code: Any = None,
|
| 257 |
+
provider_reason: Any = None,
|
| 258 |
+
retry_after_seconds: float | None = None,
|
| 259 |
+
) -> ProviderErrorSignal:
|
| 260 |
+
"""Normalize provider/network failures without retaining response bodies."""
|
| 261 |
+
provider_name = str(provider or getattr(error, "provider", "") or "").strip().lower()
|
| 262 |
+
status = _coerce_status(http_status if http_status is not None else getattr(error, "http_status", None))
|
| 263 |
+
code = str(
|
| 264 |
+
provider_code if provider_code is not None else getattr(error, "provider_code", "") or ""
|
| 265 |
+
).strip()
|
| 266 |
+
reason = str(
|
| 267 |
+
provider_reason if provider_reason is not None else getattr(error, "provider_reason", "") or ""
|
| 268 |
+
).strip()
|
| 269 |
+
retry_after = _coerce_positive_float(
|
| 270 |
+
retry_after_seconds if retry_after_seconds is not None else getattr(error, "retry_after_seconds", None)
|
| 271 |
+
)
|
| 272 |
+
class_name = error.__class__.__name__.lower() if error is not None else ""
|
| 273 |
+
safe_reason = (
|
| 274 |
+
reason if provider_name == "gemini" and reason
|
| 275 |
+
else code or reason or (f"http_{status}" if status else class_name or "unknown")
|
| 276 |
+
)
|
| 277 |
+
|
| 278 |
+
# Provider-specific business codes take precedence over outer HTTP status.
|
| 279 |
+
if provider_name == "zhipu":
|
| 280 |
+
if code in {"1000", "1001", "1003"}:
|
| 281 |
+
return _signal(provider=provider_name, error_kind="AUTHENTICATION", availability_state="AUTH_BLOCKED", retryable=False, http_status=status, provider_code=code, retry_after_seconds=retry_after, safe_reason_code=safe_reason, penalizes_health=False)
|
| 282 |
+
if code == "1113":
|
| 283 |
+
return _signal(provider=provider_name, error_kind="BALANCE", availability_state="BALANCE_BLOCKED", retryable=False, http_status=status, provider_code=code, retry_after_seconds=retry_after, safe_reason_code=safe_reason, penalizes_health=False)
|
| 284 |
+
if code == "1302":
|
| 285 |
+
return _signal(provider=provider_name, error_kind="RATE_LIMIT", availability_state="RATE_LIMITED", retryable=True, http_status=status, provider_code=code, retry_after_seconds=retry_after, safe_reason_code=safe_reason, penalizes_health=True)
|
| 286 |
+
if code == "1305":
|
| 287 |
+
return _signal(provider=provider_name, error_kind="OVERLOAD", availability_state="OVERLOADED", retryable=True, http_status=status, provider_code=code, retry_after_seconds=retry_after, safe_reason_code=safe_reason, penalizes_health=True)
|
| 288 |
+
if code == "1308":
|
| 289 |
+
return _signal(provider=provider_name, error_kind="QUOTA_EXHAUSTED", availability_state="RATE_LIMITED", retryable=False, http_status=status, provider_code=code, retry_after_seconds=retry_after, safe_reason_code=safe_reason, penalizes_health=False)
|
| 290 |
+
|
| 291 |
+
if "cancel" in class_name:
|
| 292 |
+
return _signal(provider=provider_name, error_kind="CANCELLED", availability_state="UNKNOWN", retryable=False, http_status=status, provider_code=code, retry_after_seconds=retry_after, safe_reason_code=safe_reason, penalizes_health=False)
|
| 293 |
+
if "timeout" in class_name or isinstance(error, TimeoutError):
|
| 294 |
+
return _signal(provider=provider_name, error_kind="TIMEOUT", availability_state="DEGRADED", retryable=True, http_status=status, provider_code=code, retry_after_seconds=retry_after, safe_reason_code=safe_reason, penalizes_health=True)
|
| 295 |
+
if "connection" in class_name or "chunked" in class_name:
|
| 296 |
+
return _signal(provider=provider_name, error_kind="CONNECTION", availability_state="DEGRADED", retryable=True, http_status=status, provider_code=code, retry_after_seconds=retry_after, safe_reason_code=safe_reason, penalizes_health=True)
|
| 297 |
+
|
| 298 |
+
if status == 401:
|
| 299 |
+
kind, state, retryable, penalty = "AUTHENTICATION", "AUTH_BLOCKED", False, False
|
| 300 |
+
elif status == 403:
|
| 301 |
+
kind, state, retryable, penalty = "PERMISSION", "AUTH_BLOCKED", False, False
|
| 302 |
+
elif status == 402:
|
| 303 |
+
kind, state, retryable, penalty = "BALANCE", "BALANCE_BLOCKED", False, False
|
| 304 |
+
elif status == 429:
|
| 305 |
+
kind, state, retryable, penalty = "RATE_LIMIT", "RATE_LIMITED", True, True
|
| 306 |
+
elif status == 503:
|
| 307 |
+
kind, state, retryable, penalty = "OVERLOAD", "OVERLOADED", True, True
|
| 308 |
+
elif status == 504:
|
| 309 |
+
kind, state, retryable, penalty = "TIMEOUT", "DEGRADED", True, True
|
| 310 |
+
elif status is not None and status >= 500:
|
| 311 |
+
kind, state, retryable, penalty = "SERVER_ERROR", "DEGRADED", True, True
|
| 312 |
+
elif status is not None and 400 <= status < 500:
|
| 313 |
+
kind, state, retryable, penalty = "REQUEST_INVALID", "UNKNOWN", False, False
|
| 314 |
+
elif "authentication" in class_name or "permission" in class_name:
|
| 315 |
+
kind, state, retryable, penalty = "AUTHENTICATION", "AUTH_BLOCKED", False, False
|
| 316 |
+
elif "ratelimit" in class_name or "rate_limit" in class_name:
|
| 317 |
+
kind, state, retryable, penalty = "RATE_LIMIT", "RATE_LIMITED", True, True
|
| 318 |
+
elif "request" in class_name:
|
| 319 |
+
kind, state, retryable, penalty = "REQUEST_INVALID", "UNKNOWN", False, False
|
| 320 |
+
else:
|
| 321 |
+
kind, state, retryable, penalty = "UNKNOWN", "UNKNOWN", False, False
|
| 322 |
+
|
| 323 |
+
return _signal(
|
| 324 |
+
provider=provider_name,
|
| 325 |
+
error_kind=kind,
|
| 326 |
+
availability_state=state,
|
| 327 |
+
retryable=retryable,
|
| 328 |
+
http_status=status,
|
| 329 |
+
provider_code=code,
|
| 330 |
+
retry_after_seconds=retry_after,
|
| 331 |
+
safe_reason_code=safe_reason,
|
| 332 |
+
penalizes_health=penalty,
|
| 333 |
+
)
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
def snapshot_from_legacy_health(
|
| 337 |
+
value: Any,
|
| 338 |
+
*,
|
| 339 |
+
latency_degraded_threshold_ms: float = 0.0,
|
| 340 |
+
) -> HealthSnapshot:
|
| 341 |
+
"""Compatibility bridge for the pre-R5G benchmark health shape."""
|
| 342 |
+
if isinstance(value, Mapping):
|
| 343 |
+
available = bool(value.get("available", False))
|
| 344 |
+
rate_limited = bool(value.get("rate_limited", False))
|
| 345 |
+
latency_ms = float(value.get("latency_ms") or 0.0)
|
| 346 |
+
error_rate = max(0.0, min(float(value.get("error_rate") or 0.0), 1.0))
|
| 347 |
+
else:
|
| 348 |
+
available = bool(value)
|
| 349 |
+
rate_limited = False
|
| 350 |
+
latency_ms = 0.0
|
| 351 |
+
error_rate = 0.0
|
| 352 |
+
|
| 353 |
+
if not available:
|
| 354 |
+
state, eligible = "UNREACHABLE", False
|
| 355 |
+
elif rate_limited:
|
| 356 |
+
state, eligible = "RATE_LIMITED", False
|
| 357 |
+
elif latency_degraded_threshold_ms > 0 and latency_ms > latency_degraded_threshold_ms:
|
| 358 |
+
state, eligible = "DEGRADED", True
|
| 359 |
+
else:
|
| 360 |
+
state, eligible = "HEALTHY", True
|
| 361 |
+
return HealthSnapshot(
|
| 362 |
+
availability_state=state,
|
| 363 |
+
circuit_state="CLOSED",
|
| 364 |
+
eligible=eligible,
|
| 365 |
+
recent_successes=1 if available else 0,
|
| 366 |
+
recent_failures=0 if available else 1,
|
| 367 |
+
success_rate=1.0 if available else 0.0,
|
| 368 |
+
error_rate=error_rate,
|
| 369 |
+
latency_ms=latency_ms,
|
| 370 |
+
cause_state=state,
|
| 371 |
+
)
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
class ProviderAvailabilityTracker:
|
| 375 |
+
"""Thread-safe, in-memory availability tracker for benchmark/testing only."""
|
| 376 |
+
|
| 377 |
+
def __init__(self, policy: Mapping[str, Any], *, clock: Callable[[], float] | None = None):
|
| 378 |
+
self.policy = dict(policy or {})
|
| 379 |
+
self.clock = clock or time.monotonic
|
| 380 |
+
self._lock = threading.RLock()
|
| 381 |
+
self._records: dict[HealthKey, _HealthRecord] = {}
|
| 382 |
+
|
| 383 |
+
def _now(self) -> float:
|
| 384 |
+
return float(self.clock())
|
| 385 |
+
|
| 386 |
+
def _record(self, key: HealthKey) -> _HealthRecord:
|
| 387 |
+
return self._records.setdefault(key, _HealthRecord())
|
| 388 |
+
|
| 389 |
+
def _window_seconds(self) -> float:
|
| 390 |
+
return max(1.0, float(self.policy.get("failure_window_seconds") or 1.0))
|
| 391 |
+
|
| 392 |
+
def _failure_threshold(self) -> int:
|
| 393 |
+
return max(1, int(self.policy.get("failure_threshold") or 1))
|
| 394 |
+
|
| 395 |
+
def _latency_threshold(self) -> float:
|
| 396 |
+
return max(0.0, float(self.policy.get("latency_degraded_threshold_ms") or 0.0))
|
| 397 |
+
|
| 398 |
+
def _prune(self, record: _HealthRecord, now: float) -> None:
|
| 399 |
+
cutoff = now - self._window_seconds()
|
| 400 |
+
record.events[:] = [row for row in record.events if row[0] >= cutoff]
|
| 401 |
+
|
| 402 |
+
def _penalizing_failures(self, record: _HealthRecord) -> int:
|
| 403 |
+
return sum(1 for _, success, penalty, _ in record.events if not success and penalty)
|
| 404 |
+
|
| 405 |
+
def _cooldown(self, record: _HealthRecord, signal: ProviderErrorSignal) -> float | None:
|
| 406 |
+
if signal.retry_after_seconds is not None:
|
| 407 |
+
return max(0.0, float(signal.retry_after_seconds))
|
| 408 |
+
if signal.error_kind == "OVERLOAD":
|
| 409 |
+
schedule = [max(0.0, float(v)) for v in self.policy.get("overload_cooldown_schedule") or []]
|
| 410 |
+
if schedule:
|
| 411 |
+
index = min(max(record.consecutive_overloads - 1, 0), len(schedule) - 1)
|
| 412 |
+
return schedule[index]
|
| 413 |
+
if signal.error_kind in {"RATE_LIMIT", "QUOTA_EXHAUSTED"}:
|
| 414 |
+
backoff = self.policy.get("rate_limit_backoff_policy")
|
| 415 |
+
if isinstance(backoff, Mapping):
|
| 416 |
+
base = max(0.0, float(backoff.get("base_seconds") or 0.0))
|
| 417 |
+
cap = max(base, float(backoff.get("max_seconds") or base))
|
| 418 |
+
attempts = max(1, self._penalizing_failures(record))
|
| 419 |
+
return min(cap, base * (2 ** max(0, attempts - 1)))
|
| 420 |
+
return None
|
| 421 |
+
|
| 422 |
+
def _refresh(self, record: _HealthRecord, now: float) -> None:
|
| 423 |
+
self._prune(record, now)
|
| 424 |
+
if record.circuit_state == "OPEN" and record.blocked_until is not None and now >= record.blocked_until:
|
| 425 |
+
record.circuit_state = "HALF_OPEN"
|
| 426 |
+
record.availability_state = "DEGRADED"
|
| 427 |
+
record.blocked_until = None
|
| 428 |
+
elif (
|
| 429 |
+
record.circuit_state == "CLOSED"
|
| 430 |
+
and record.availability_state in {"RATE_LIMITED", "OVERLOADED"}
|
| 431 |
+
and record.blocked_until is not None
|
| 432 |
+
and now >= record.blocked_until
|
| 433 |
+
):
|
| 434 |
+
record.availability_state = "DEGRADED"
|
| 435 |
+
record.blocked_until = None
|
| 436 |
+
|
| 437 |
+
def record_success(self, key: HealthKey, *, latency_ms: float = 0.0) -> HealthSnapshot:
|
| 438 |
+
with self._lock:
|
| 439 |
+
now = self._now()
|
| 440 |
+
record = self._record(key)
|
| 441 |
+
self._refresh(record, now)
|
| 442 |
+
latency = max(0.0, float(latency_ms or 0.0))
|
| 443 |
+
record.events.append((now, True, False, latency))
|
| 444 |
+
record.circuit_state = "CLOSED"
|
| 445 |
+
record.blocked_until = None
|
| 446 |
+
record.last_error_kind = ""
|
| 447 |
+
record.last_provider_code = ""
|
| 448 |
+
record.consecutive_overloads = 0
|
| 449 |
+
threshold = self._latency_threshold()
|
| 450 |
+
record.availability_state = "DEGRADED" if threshold > 0 and latency > threshold else "HEALTHY"
|
| 451 |
+
return self._snapshot_locked(record, now)
|
| 452 |
+
|
| 453 |
+
def record_failure(
|
| 454 |
+
self,
|
| 455 |
+
key: HealthKey,
|
| 456 |
+
signal: ProviderErrorSignal,
|
| 457 |
+
*,
|
| 458 |
+
latency_ms: float = 0.0,
|
| 459 |
+
) -> HealthSnapshot:
|
| 460 |
+
with self._lock:
|
| 461 |
+
now = self._now()
|
| 462 |
+
record = self._record(key)
|
| 463 |
+
self._refresh(record, now)
|
| 464 |
+
latency = max(0.0, float(latency_ms or 0.0))
|
| 465 |
+
record.events.append((now, False, bool(signal.penalizes_health), latency))
|
| 466 |
+
record.last_error_kind = signal.error_kind
|
| 467 |
+
record.last_provider_code = signal.provider_code
|
| 468 |
+
record.availability_state = signal.availability_state
|
| 469 |
+
if signal.error_kind == "OVERLOAD":
|
| 470 |
+
record.consecutive_overloads += 1
|
| 471 |
+
else:
|
| 472 |
+
record.consecutive_overloads = 0
|
| 473 |
+
|
| 474 |
+
cooldown = self._cooldown(record, signal)
|
| 475 |
+
if signal.availability_state in {"AUTH_BLOCKED", "BALANCE_BLOCKED"}:
|
| 476 |
+
record.circuit_state = "CLOSED"
|
| 477 |
+
record.blocked_until = None
|
| 478 |
+
elif signal.error_kind in {"RATE_LIMIT", "QUOTA_EXHAUSTED"}:
|
| 479 |
+
record.circuit_state = "CLOSED"
|
| 480 |
+
record.blocked_until = now + cooldown if cooldown is not None else None
|
| 481 |
+
else:
|
| 482 |
+
immediate = {
|
| 483 |
+
str(v or "").strip().upper()
|
| 484 |
+
for v in self.policy.get("immediate_open_error_kinds") or []
|
| 485 |
+
}
|
| 486 |
+
should_open = signal.error_kind in immediate
|
| 487 |
+
if signal.penalizes_health and self._penalizing_failures(record) >= self._failure_threshold():
|
| 488 |
+
should_open = True
|
| 489 |
+
if should_open:
|
| 490 |
+
record.circuit_state = "OPEN"
|
| 491 |
+
if cooldown is None:
|
| 492 |
+
cooldown = max(0.0, float(self.policy.get("half_open_probe_interval") or 0.0))
|
| 493 |
+
record.blocked_until = now + cooldown if cooldown is not None else None
|
| 494 |
+
return self._snapshot_locked(record, now)
|
| 495 |
+
|
| 496 |
+
def reset(self, key: HealthKey) -> None:
|
| 497 |
+
with self._lock:
|
| 498 |
+
self._records.pop(key, None)
|
| 499 |
+
|
| 500 |
+
def snapshot(self, key: HealthKey) -> HealthSnapshot:
|
| 501 |
+
with self._lock:
|
| 502 |
+
now = self._now()
|
| 503 |
+
record = self._record(key)
|
| 504 |
+
self._refresh(record, now)
|
| 505 |
+
return self._snapshot_locked(record, now)
|
| 506 |
+
|
| 507 |
+
def _snapshot_locked(self, record: _HealthRecord, now: float) -> HealthSnapshot:
|
| 508 |
+
self._prune(record, now)
|
| 509 |
+
successes = sum(1 for _, success, _, _ in record.events if success)
|
| 510 |
+
failures = sum(1 for _, success, penalty, _ in record.events if not success and penalty)
|
| 511 |
+
denominator = successes + failures
|
| 512 |
+
success_rate = successes / denominator if denominator else 0.0
|
| 513 |
+
error_rate = failures / denominator if denominator else 0.0
|
| 514 |
+
latencies = [latency for _, _, _, latency in record.events if latency > 0]
|
| 515 |
+
latency_ms = sum(latencies) / len(latencies) if latencies else 0.0
|
| 516 |
+
cause_state = record.availability_state
|
| 517 |
+
state = "CIRCUIT_OPEN" if record.circuit_state == "OPEN" else cause_state
|
| 518 |
+
eligible = (
|
| 519 |
+
record.circuit_state != "OPEN"
|
| 520 |
+
and cause_state not in {
|
| 521 |
+
"AUTH_BLOCKED",
|
| 522 |
+
"BALANCE_BLOCKED",
|
| 523 |
+
"RATE_LIMITED",
|
| 524 |
+
"OVERLOADED",
|
| 525 |
+
"UNREACHABLE",
|
| 526 |
+
"DISABLED",
|
| 527 |
+
}
|
| 528 |
+
)
|
| 529 |
+
return HealthSnapshot(
|
| 530 |
+
availability_state=state,
|
| 531 |
+
circuit_state=record.circuit_state,
|
| 532 |
+
eligible=eligible,
|
| 533 |
+
recent_successes=successes,
|
| 534 |
+
recent_failures=failures,
|
| 535 |
+
success_rate=success_rate,
|
| 536 |
+
error_rate=error_rate,
|
| 537 |
+
latency_ms=latency_ms,
|
| 538 |
+
blocked_until=record.blocked_until,
|
| 539 |
+
last_error_kind=record.last_error_kind,
|
| 540 |
+
last_provider_code=record.last_provider_code,
|
| 541 |
+
cause_state=cause_state,
|
| 542 |
+
)
|