A3-R5G Gemini + GLM two-provider free pool benchmark availability

#106
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
- """Config-driven route selection for benchmark simulations.
372
 
373
- The function deliberately consumes provider health observations rather than
374
- country codes or browser-supplied location. Production activation is out
375
- of scope for A3-R5C; this helper only proves that future CN/non-CN switching
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
- skip_rate_limited = bool(policy.get("skip_rate_limited", True))
389
- for profile_id in policy.get("candidate_profiles") or []:
390
- profile_id = str(profile_id or "").strip()
 
 
 
 
 
 
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
- provider = str(profile.get("provider") or "").strip().lower()
398
- provider_health = health.get(provider)
399
- if isinstance(provider_health, Mapping):
400
- available = bool(provider_health.get("available", False))
401
- latency_ms = float(provider_health.get("latency_ms") or 0)
402
- error_rate = float(provider_health.get("error_rate") or 0)
403
- rate_limited = bool(provider_health.get("rate_limited", False))
 
 
404
  else:
405
- available = bool(provider_health)
406
- latency_ms = 0.0
407
- error_rate = 0.0
408
- rate_limited = False
409
- if not available:
410
- continue
411
- if skip_rate_limited and rate_limited:
 
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
- raise RuntimeError(f"no_healthy_provider_for_policy:{policy_id}")
 
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-v2",
3
- "notes": "Synthetic B1/B2 benchmark cases only; no live user prompts, credentials, payment, quota, or manuscript data. Provider/model/endpoints and environment-routing preferences are versioned configuration. Runtime geography is not hard-coded; any future production switching must use server-authoritative provider health/reachability and frozen task policy.",
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": "server_health_then_config_priority",
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
- raise GeminiAuthenticationError(f"Gemini authentication failed ({status}): {detail}")
191
  if status == 429:
192
- raise GeminiRateLimitError(f"Gemini rate limit reached (429): {detail}")
193
  if 400 <= status < 500:
194
- raise GeminiRequestError(f"Gemini request rejected ({status}): {detail}")
195
  if status >= 500:
196
- raise GeminiAPIError(f"Gemini provider error ({status}): {detail}")
197
  if status != 200:
198
- raise GeminiAPIError(f"Gemini unexpected HTTP status ({status}): {detail}")
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
- raise ZhipuAuthenticationError(f"Zhipu authentication failed ({status}): {detail}")
186
  if status == 429:
187
- raise ZhipuRateLimitError(f"Zhipu rate limit reached (429): {detail}")
 
 
 
188
  if 400 <= status < 500:
189
- raise ZhipuRequestError(f"Zhipu request rejected ({status}): {detail}")
190
  if status >= 500:
191
- raise ZhipuAPIError(f"Zhipu provider error ({status}): {detail}")
192
  if status != 200:
193
- raise ZhipuAPIError(f"Zhipu unexpected HTTP status ({status}): {detail}")
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
+ )