inference-control-plane 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- inference_control_plane/__init__.py +49 -0
- inference_control_plane/models.py +83 -0
- inference_control_plane/pool_status.py +41 -0
- inference_control_plane/recommendations.py +112 -0
- inference_control_plane/timeutil.py +6 -0
- inference_control_plane/vllm.py +387 -0
- inference_control_plane/workload/__init__.py +5 -0
- inference_control_plane/workload/adapter_base.py +145 -0
- inference_control_plane/workload/adapters.py +40 -0
- inference_control_plane/workload/analysis.py +162 -0
- inference_control_plane/workload/errors.py +10 -0
- inference_control_plane/workload/event_utils.py +43 -0
- inference_control_plane/workload/helicone.py +141 -0
- inference_control_plane/workload/langfuse.py +111 -0
- inference_control_plane/workload/otlp.py +137 -0
- inference_control_plane/workload/sample_data.py +150 -0
- inference_control_plane-0.1.0.dist-info/METADATA +140 -0
- inference_control_plane-0.1.0.dist-info/RECORD +21 -0
- inference_control_plane-0.1.0.dist-info/WHEEL +5 -0
- inference_control_plane-0.1.0.dist-info/licenses/LICENSE +207 -0
- inference_control_plane-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Inference Control Plane — reusable inference observability."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from inference_control_plane.models import (
|
|
6
|
+
ComparisonRow,
|
|
7
|
+
DerivedRates,
|
|
8
|
+
EffectiveCost,
|
|
9
|
+
EndpointCapabilities,
|
|
10
|
+
EndpointConfig,
|
|
11
|
+
MetricSnapshot,
|
|
12
|
+
Recommendation,
|
|
13
|
+
)
|
|
14
|
+
from inference_control_plane.pool_status import snapshot_from_pool_status
|
|
15
|
+
from inference_control_plane.recommendations import (
|
|
16
|
+
compare_snapshots,
|
|
17
|
+
recommend_from_snapshot,
|
|
18
|
+
)
|
|
19
|
+
from inference_control_plane.vllm import (
|
|
20
|
+
VllmClient,
|
|
21
|
+
derive_rates,
|
|
22
|
+
estimate_effective_cost,
|
|
23
|
+
parse_prometheus,
|
|
24
|
+
snapshot_from_metrics,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
__version__ = version("inference-control-plane")
|
|
29
|
+
except PackageNotFoundError: # source checkout without install
|
|
30
|
+
__version__ = "0.0.0+unknown"
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"ComparisonRow",
|
|
34
|
+
"DerivedRates",
|
|
35
|
+
"EffectiveCost",
|
|
36
|
+
"EndpointCapabilities",
|
|
37
|
+
"EndpointConfig",
|
|
38
|
+
"MetricSnapshot",
|
|
39
|
+
"Recommendation",
|
|
40
|
+
"VllmClient",
|
|
41
|
+
"__version__",
|
|
42
|
+
"compare_snapshots",
|
|
43
|
+
"derive_rates",
|
|
44
|
+
"estimate_effective_cost",
|
|
45
|
+
"parse_prometheus",
|
|
46
|
+
"recommend_from_snapshot",
|
|
47
|
+
"snapshot_from_metrics",
|
|
48
|
+
"snapshot_from_pool_status",
|
|
49
|
+
]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Public domain models. Missing metrics are None, never fabricated zero."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def as_ratio(value: float | None) -> float | None:
|
|
8
|
+
"""Normalize a percent-form value (>1) to a 0..1 ratio."""
|
|
9
|
+
return value / 100.0 if value is not None and value > 1.0 else value
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EndpointConfig(BaseModel):
|
|
13
|
+
provider: str = "vllm"
|
|
14
|
+
base_url: str
|
|
15
|
+
metrics_url: str | None = None
|
|
16
|
+
model: str | None = None
|
|
17
|
+
gpu_hourly_usd: float | None = Field(default=None, ge=0)
|
|
18
|
+
|
|
19
|
+
class MetricSnapshot(BaseModel):
|
|
20
|
+
provider: str
|
|
21
|
+
status: str # ok | degraded | unreachable
|
|
22
|
+
collected_at: str
|
|
23
|
+
source: str
|
|
24
|
+
errors: list[str] = Field(default_factory=list)
|
|
25
|
+
requests_running: float | None = None
|
|
26
|
+
requests_waiting: float | None = None
|
|
27
|
+
kv_cache_usage_ratio: float | None = None # 0..1 when known
|
|
28
|
+
gpu_utilization_ratio: float | None = None
|
|
29
|
+
prompt_tokens_total: float | None = None
|
|
30
|
+
generation_tokens_total: float | None = None
|
|
31
|
+
request_success_total: float | None = None
|
|
32
|
+
request_failure_total: float | None = None
|
|
33
|
+
error_rate: float | None = None
|
|
34
|
+
ttft_p95_seconds: float | None = None
|
|
35
|
+
e2e_latency_p95_seconds: float | None = None
|
|
36
|
+
tokens_per_second: float | None = None # only if caller supplies a window rate
|
|
37
|
+
|
|
38
|
+
class EndpointCapabilities(BaseModel):
|
|
39
|
+
"""Best-effort, GET-only capability probe. Failures are notes, never exceptions."""
|
|
40
|
+
|
|
41
|
+
health_ok: bool = False
|
|
42
|
+
models: list[str] = Field(default_factory=list)
|
|
43
|
+
metrics_ok: bool = False
|
|
44
|
+
errors: list[str] = Field(default_factory=list)
|
|
45
|
+
checked_at: str = ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class DerivedRates(BaseModel):
|
|
49
|
+
"""Rates derived from two counter snapshots; unknown rates stay None."""
|
|
50
|
+
|
|
51
|
+
elapsed_seconds: float | None = None
|
|
52
|
+
tokens_per_second: float | None = None
|
|
53
|
+
generation_tokens_per_second: float | None = None
|
|
54
|
+
prompt_tokens_per_second: float | None = None
|
|
55
|
+
requests_per_second: float | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class EffectiveCost(BaseModel):
|
|
59
|
+
"""Cost estimate from a user-supplied GPU price; kind is always user_assumption."""
|
|
60
|
+
|
|
61
|
+
kind: str = "user_assumption"
|
|
62
|
+
gpu_hourly_usd: float
|
|
63
|
+
hourly_cost_usd: float
|
|
64
|
+
utilization_ratio: float | None = None
|
|
65
|
+
effective_cost_per_utilized_hour_usd: float | None = None
|
|
66
|
+
cost_per_million_generation_tokens_usd: float | None = None
|
|
67
|
+
notes: str = ""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Recommendation(BaseModel):
|
|
71
|
+
code: str
|
|
72
|
+
title: str
|
|
73
|
+
evidence: str
|
|
74
|
+
assumptions: list[str] = Field(default_factory=list)
|
|
75
|
+
confidence: str = "low"
|
|
76
|
+
|
|
77
|
+
class ComparisonRow(BaseModel):
|
|
78
|
+
metric: str
|
|
79
|
+
current: float | None = None
|
|
80
|
+
managed: float | None = None
|
|
81
|
+
unit: str
|
|
82
|
+
kind: str # measured | estimated | user_assumption
|
|
83
|
+
notes: str = ""
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Adapt a serving-pool status payload to a MetricSnapshot; never invent values."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from inference_control_plane.models import MetricSnapshot, as_ratio
|
|
8
|
+
from inference_control_plane.timeutil import now_iso
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _peak(records: list[dict[str, Any]], field: str) -> float | None:
|
|
12
|
+
values = [
|
|
13
|
+
float(record["resources"][field])
|
|
14
|
+
for record in records
|
|
15
|
+
if isinstance((record.get("resources") or {}).get(field), (int, float))
|
|
16
|
+
]
|
|
17
|
+
return max(values) if values else None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def snapshot_from_pool_status(pool: dict[str, Any], *, source: str = "managed-serving-pool") -> MetricSnapshot:
|
|
21
|
+
replicas = [
|
|
22
|
+
record
|
|
23
|
+
for record in pool.get("replicas") or []
|
|
24
|
+
if isinstance(record, dict) and record.get("status") == "ready"
|
|
25
|
+
]
|
|
26
|
+
unfinished = [
|
|
27
|
+
float(record["engine_unfinished_requests"])
|
|
28
|
+
for record in replicas
|
|
29
|
+
if isinstance(record.get("engine_unfinished_requests"), (int, float))
|
|
30
|
+
]
|
|
31
|
+
kv = as_ratio(_peak(replicas, "kv_cache_utilization"))
|
|
32
|
+
return MetricSnapshot(
|
|
33
|
+
provider="managed",
|
|
34
|
+
status="ok" if replicas else "degraded",
|
|
35
|
+
collected_at=now_iso(),
|
|
36
|
+
source=source,
|
|
37
|
+
errors=[] if replicas else ["No ready replicas reported by the serving pool"],
|
|
38
|
+
requests_running=sum(unfinished) if unfinished else None,
|
|
39
|
+
kv_cache_usage_ratio=kv,
|
|
40
|
+
gpu_utilization_ratio=_peak(replicas, "gpu_sm_utilization"),
|
|
41
|
+
)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Evidence-based optimization recommendations (review-only, no auto-apply)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from inference_control_plane.models import ComparisonRow, MetricSnapshot, Recommendation
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def recommend_from_snapshot(snapshot: MetricSnapshot) -> list[Recommendation]:
|
|
9
|
+
recs: list[Recommendation] = []
|
|
10
|
+
if snapshot.status == "unreachable":
|
|
11
|
+
return [
|
|
12
|
+
Recommendation(
|
|
13
|
+
code="endpoint_unreachable",
|
|
14
|
+
title="Restore endpoint observability before optimizing",
|
|
15
|
+
evidence=f"Collection failed: {'; '.join(snapshot.errors) or 'unreachable'}",
|
|
16
|
+
assumptions=["No performance conclusion is drawn from a failed collection."],
|
|
17
|
+
confidence="high",
|
|
18
|
+
)
|
|
19
|
+
]
|
|
20
|
+
if snapshot.kv_cache_usage_ratio is not None:
|
|
21
|
+
kv = snapshot.kv_cache_usage_ratio
|
|
22
|
+
if kv >= 0.95:
|
|
23
|
+
recs.append(Recommendation(
|
|
24
|
+
code="kv_cache_saturated",
|
|
25
|
+
title="KV cache is saturated; review concurrency / max_num_seqs",
|
|
26
|
+
evidence=f"KV cache usage is {kv:.0%} of capacity.",
|
|
27
|
+
assumptions=["Sustained saturation, not a single spike, should be confirmed."],
|
|
28
|
+
confidence="medium",
|
|
29
|
+
))
|
|
30
|
+
elif kv <= 0.25 and (snapshot.requests_running or 0) <= 1:
|
|
31
|
+
recs.append(Recommendation(
|
|
32
|
+
code="gpu_underutilized",
|
|
33
|
+
title="Capacity appears underutilized; consider a smaller GPU or consolidation",
|
|
34
|
+
evidence=f"KV cache usage is {kv:.0%} with {snapshot.requests_running} running request(s).",
|
|
35
|
+
assumptions=["Based on one snapshot; confirm over a representative window."],
|
|
36
|
+
confidence="low",
|
|
37
|
+
))
|
|
38
|
+
if snapshot.requests_waiting is not None and snapshot.requests_waiting >= 5:
|
|
39
|
+
recs.append(Recommendation(
|
|
40
|
+
code="queue_backlog",
|
|
41
|
+
title="Requests are queueing; review batching/concurrency or add capacity",
|
|
42
|
+
evidence=f"{snapshot.requests_waiting:.0f} requests are waiting.",
|
|
43
|
+
confidence="medium",
|
|
44
|
+
))
|
|
45
|
+
if snapshot.error_rate is not None and snapshot.error_rate >= 0.05:
|
|
46
|
+
recs.append(Recommendation(
|
|
47
|
+
code="elevated_error_rate",
|
|
48
|
+
title="Elevated error rate; inspect endpoint errors before tuning performance",
|
|
49
|
+
evidence=f"Error rate is {snapshot.error_rate:.1%} of completed requests.",
|
|
50
|
+
confidence="medium",
|
|
51
|
+
))
|
|
52
|
+
if not recs:
|
|
53
|
+
recs.append(Recommendation(
|
|
54
|
+
code="insufficient_evidence",
|
|
55
|
+
title="No specific change recommended from the available measurements",
|
|
56
|
+
evidence="Available metrics do not cross any documented threshold.",
|
|
57
|
+
assumptions=["Collect a longer representative window before deciding."],
|
|
58
|
+
confidence="low",
|
|
59
|
+
))
|
|
60
|
+
return recs
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def compare_snapshots(
|
|
64
|
+
current: MetricSnapshot,
|
|
65
|
+
*,
|
|
66
|
+
managed_ttft_p95_seconds: float | None = None,
|
|
67
|
+
managed_e2e_p95_seconds: float | None = None,
|
|
68
|
+
managed_kind: str = "estimated",
|
|
69
|
+
managed_snapshot: MetricSnapshot | None = None,
|
|
70
|
+
) -> list[ComparisonRow]:
|
|
71
|
+
"""Compare snapshots; managed side is estimated unless a measured snapshot is given."""
|
|
72
|
+
if managed_snapshot is not None:
|
|
73
|
+
if managed_ttft_p95_seconds is None:
|
|
74
|
+
managed_ttft_p95_seconds = managed_snapshot.ttft_p95_seconds
|
|
75
|
+
if managed_e2e_p95_seconds is None:
|
|
76
|
+
managed_e2e_p95_seconds = managed_snapshot.e2e_latency_p95_seconds
|
|
77
|
+
rows = [
|
|
78
|
+
ComparisonRow(
|
|
79
|
+
metric="ttft_p95_seconds", current=current.ttft_p95_seconds,
|
|
80
|
+
managed=managed_ttft_p95_seconds, unit="s", kind=managed_kind,
|
|
81
|
+
notes="Current side is measured from vLLM histograms when present.",
|
|
82
|
+
),
|
|
83
|
+
ComparisonRow(
|
|
84
|
+
metric="e2e_latency_p95_seconds", current=current.e2e_latency_p95_seconds,
|
|
85
|
+
managed=managed_e2e_p95_seconds, unit="s", kind=managed_kind,
|
|
86
|
+
notes="Do not claim improvement unless both sides are measured on the same workload.",
|
|
87
|
+
),
|
|
88
|
+
ComparisonRow(
|
|
89
|
+
metric="error_rate", current=current.error_rate, managed=None,
|
|
90
|
+
unit="ratio", kind="measured" if current.error_rate is not None else "estimated",
|
|
91
|
+
),
|
|
92
|
+
]
|
|
93
|
+
if managed_snapshot is not None:
|
|
94
|
+
shared_kind = "measured" if managed_snapshot.status == "ok" else managed_kind
|
|
95
|
+
rows.extend([
|
|
96
|
+
ComparisonRow(
|
|
97
|
+
metric="requests_running", current=current.requests_running,
|
|
98
|
+
managed=managed_snapshot.requests_running, unit="requests", kind=shared_kind,
|
|
99
|
+
notes="Managed side counts unfinished requests on ready serving-pool replicas.",
|
|
100
|
+
),
|
|
101
|
+
ComparisonRow(
|
|
102
|
+
metric="kv_cache_usage_ratio", current=current.kv_cache_usage_ratio,
|
|
103
|
+
managed=managed_snapshot.kv_cache_usage_ratio, unit="ratio", kind=shared_kind,
|
|
104
|
+
notes="Managed side is the peak KV cache utilization across ready replicas.",
|
|
105
|
+
),
|
|
106
|
+
ComparisonRow(
|
|
107
|
+
metric="gpu_utilization_ratio", current=current.gpu_utilization_ratio,
|
|
108
|
+
managed=managed_snapshot.gpu_utilization_ratio, unit="ratio", kind=shared_kind,
|
|
109
|
+
notes="Managed side is the peak GPU SM utilization across ready replicas.",
|
|
110
|
+
),
|
|
111
|
+
])
|
|
112
|
+
return rows
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"""Read-only vLLM monitoring via Prometheus /metrics and /health (GET only)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from inference_control_plane.models import (
|
|
11
|
+
DerivedRates,
|
|
12
|
+
EffectiveCost,
|
|
13
|
+
EndpointCapabilities,
|
|
14
|
+
EndpointConfig,
|
|
15
|
+
MetricSnapshot,
|
|
16
|
+
as_ratio,
|
|
17
|
+
)
|
|
18
|
+
from inference_control_plane.timeutil import now_iso
|
|
19
|
+
from inference_control_plane.workload.adapter_base import (
|
|
20
|
+
ProviderAdapterError,
|
|
21
|
+
_first,
|
|
22
|
+
validate_provider_url,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
_LINE = re.compile(r"^([a-zA-Z_:][a-zA-Z0-9_:]*)(?:\{[^}]*\})?\s+([-+eE0-9.]+|NaN|\+Inf|-Inf)$")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_prometheus(text: str) -> dict[str, float]:
|
|
29
|
+
"""Sum samples per metric name; histogram buckets keep their ``_bucket`` name."""
|
|
30
|
+
totals: dict[str, float] = {}
|
|
31
|
+
buckets: dict[str, list[tuple[float, float]]] = {}
|
|
32
|
+
for raw in text.splitlines():
|
|
33
|
+
line = raw.strip()
|
|
34
|
+
if not line or line.startswith("#"):
|
|
35
|
+
# Bucket bounds live in the label set, handled below.
|
|
36
|
+
if line.startswith("#") or not line:
|
|
37
|
+
continue
|
|
38
|
+
match = _LINE.match(line)
|
|
39
|
+
if not match:
|
|
40
|
+
continue
|
|
41
|
+
name, value = match.group(1), match.group(2)
|
|
42
|
+
try:
|
|
43
|
+
number = float(value)
|
|
44
|
+
except ValueError:
|
|
45
|
+
continue
|
|
46
|
+
if number != number: # NaN
|
|
47
|
+
continue
|
|
48
|
+
totals[name] = totals.get(name, 0.0) + number
|
|
49
|
+
if name.endswith("_bucket"):
|
|
50
|
+
bound_match = re.search(r'le="([^"]+)"', line)
|
|
51
|
+
if bound_match:
|
|
52
|
+
try:
|
|
53
|
+
bound = float(bound_match.group(1))
|
|
54
|
+
except ValueError:
|
|
55
|
+
bound = float("inf")
|
|
56
|
+
buckets.setdefault(name, []).append((bound, number))
|
|
57
|
+
# Store bucket-derived p95 under a synthetic key for the collector.
|
|
58
|
+
for name, pairs in buckets.items():
|
|
59
|
+
p95 = _histogram_p95(pairs)
|
|
60
|
+
if p95 is not None:
|
|
61
|
+
totals[f"{name}::p95"] = p95
|
|
62
|
+
return totals
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _histogram_p95(pairs: list[tuple[float, float]]) -> float | None:
|
|
66
|
+
finite = sorted((b, c) for b, c in pairs if b != float("inf"))
|
|
67
|
+
if not finite:
|
|
68
|
+
return None
|
|
69
|
+
total = max(c for _, c in pairs)
|
|
70
|
+
if total <= 0:
|
|
71
|
+
return None
|
|
72
|
+
target = 0.95 * total
|
|
73
|
+
previous_bound, previous_count = 0.0, 0.0
|
|
74
|
+
for bound, count in finite:
|
|
75
|
+
if count >= target:
|
|
76
|
+
if count == previous_count:
|
|
77
|
+
return bound
|
|
78
|
+
fraction = (target - previous_count) / (count - previous_count)
|
|
79
|
+
return previous_bound + fraction * (bound - previous_bound)
|
|
80
|
+
previous_bound, previous_count = bound, count
|
|
81
|
+
return finite[-1][0]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _first_metric(metrics: dict[str, float], *names: str) -> float | None:
|
|
85
|
+
return _first(metrics, *names)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def snapshot_from_metrics(text: str, *, source: str) -> MetricSnapshot:
|
|
89
|
+
metrics = parse_prometheus(text)
|
|
90
|
+
success = _first_metric(
|
|
91
|
+
metrics, "vllm:request_success_total", "vllm_request_success_total"
|
|
92
|
+
)
|
|
93
|
+
failure = _first_metric(
|
|
94
|
+
metrics,
|
|
95
|
+
"vllm:request_failure_total",
|
|
96
|
+
"vllm_request_failure_total",
|
|
97
|
+
"vllm:request_error_total",
|
|
98
|
+
)
|
|
99
|
+
error_rate = None
|
|
100
|
+
if success is not None or failure is not None:
|
|
101
|
+
total = (success or 0.0) + (failure or 0.0)
|
|
102
|
+
error_rate = ((failure or 0.0) / total) if total > 0 else None
|
|
103
|
+
kv = as_ratio(_first_metric(
|
|
104
|
+
metrics, "vllm:gpu_cache_usage_perc", "vllm:kv_cache_usage_perc",
|
|
105
|
+
"vllm_gpu_cache_usage_perc", "vllm_kv_cache_usage_perc",
|
|
106
|
+
))
|
|
107
|
+
return MetricSnapshot(
|
|
108
|
+
provider="vllm",
|
|
109
|
+
status="ok",
|
|
110
|
+
collected_at=now_iso(),
|
|
111
|
+
source=source,
|
|
112
|
+
requests_running=_first_metric(
|
|
113
|
+
metrics, "vllm:num_requests_running", "vllm_num_requests_running"
|
|
114
|
+
),
|
|
115
|
+
requests_waiting=_first_metric(
|
|
116
|
+
metrics, "vllm:num_requests_waiting", "vllm_num_requests_waiting"
|
|
117
|
+
),
|
|
118
|
+
kv_cache_usage_ratio=kv,
|
|
119
|
+
gpu_utilization_ratio=_first_metric(
|
|
120
|
+
metrics, "DCGM_FI_DEV_GPU_UTIL", "vllm:gpu_utilization"
|
|
121
|
+
),
|
|
122
|
+
prompt_tokens_total=_first_metric(
|
|
123
|
+
metrics, "vllm:prompt_tokens_total", "vllm_prompt_tokens_total"
|
|
124
|
+
),
|
|
125
|
+
generation_tokens_total=_first_metric(
|
|
126
|
+
metrics, "vllm:generation_tokens_total", "vllm_generation_tokens_total"
|
|
127
|
+
),
|
|
128
|
+
request_success_total=success,
|
|
129
|
+
request_failure_total=failure,
|
|
130
|
+
error_rate=error_rate,
|
|
131
|
+
ttft_p95_seconds=_first_metric(
|
|
132
|
+
metrics,
|
|
133
|
+
"vllm:time_to_first_token_seconds_bucket::p95",
|
|
134
|
+
"vllm_time_to_first_token_seconds_bucket::p95",
|
|
135
|
+
),
|
|
136
|
+
e2e_latency_p95_seconds=_first_metric(
|
|
137
|
+
metrics,
|
|
138
|
+
"vllm:e2e_request_latency_seconds_bucket::p95",
|
|
139
|
+
"vllm_e2e_request_latency_seconds_bucket::p95",
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _parse_iso(value: str) -> datetime | None:
|
|
145
|
+
try:
|
|
146
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
147
|
+
except (ValueError, AttributeError):
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _counter_rate(prev: float | None, curr: float | None, elapsed: float) -> float | None:
|
|
152
|
+
if prev is None or curr is None:
|
|
153
|
+
return None
|
|
154
|
+
delta = curr - prev
|
|
155
|
+
if delta < 0: # counter reset / rewound (endpoint restarted)
|
|
156
|
+
return None
|
|
157
|
+
return delta / elapsed
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def derive_rates(prev: MetricSnapshot, curr: MetricSnapshot) -> DerivedRates:
|
|
161
|
+
"""Derive per-second rates from two snapshots; unknown rates stay None."""
|
|
162
|
+
prev_at, curr_at = _parse_iso(prev.collected_at), _parse_iso(curr.collected_at)
|
|
163
|
+
if prev_at is None or curr_at is None:
|
|
164
|
+
return DerivedRates()
|
|
165
|
+
try:
|
|
166
|
+
elapsed = (curr_at - prev_at).total_seconds()
|
|
167
|
+
except TypeError: # naive vs aware mix
|
|
168
|
+
return DerivedRates()
|
|
169
|
+
if elapsed <= 0:
|
|
170
|
+
return DerivedRates(elapsed_seconds=elapsed if elapsed == 0 else None)
|
|
171
|
+
generation = _counter_rate(prev.generation_tokens_total, curr.generation_tokens_total, elapsed)
|
|
172
|
+
# Requests: prefer success+failure totals when both snapshots report them.
|
|
173
|
+
def _req_total(s: MetricSnapshot) -> float | None:
|
|
174
|
+
if s.request_success_total is None and s.request_failure_total is None:
|
|
175
|
+
return None
|
|
176
|
+
return (s.request_success_total or 0.0) + (s.request_failure_total or 0.0)
|
|
177
|
+
|
|
178
|
+
return DerivedRates(
|
|
179
|
+
elapsed_seconds=elapsed,
|
|
180
|
+
tokens_per_second=generation,
|
|
181
|
+
generation_tokens_per_second=generation,
|
|
182
|
+
prompt_tokens_per_second=_counter_rate(
|
|
183
|
+
prev.prompt_tokens_total, curr.prompt_tokens_total, elapsed
|
|
184
|
+
),
|
|
185
|
+
requests_per_second=_counter_rate(_req_total(prev), _req_total(curr), elapsed),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def estimate_effective_cost(
|
|
190
|
+
snapshot: MetricSnapshot,
|
|
191
|
+
gpu_hourly_usd: float | None = None,
|
|
192
|
+
*,
|
|
193
|
+
tokens_per_second: float | None = None,
|
|
194
|
+
) -> EffectiveCost | None:
|
|
195
|
+
"""Estimate cost from a user GPU price; None when no price is given."""
|
|
196
|
+
if gpu_hourly_usd is None or gpu_hourly_usd < 0:
|
|
197
|
+
return None
|
|
198
|
+
utilization = as_ratio(snapshot.gpu_utilization_ratio)
|
|
199
|
+
effective = (
|
|
200
|
+
gpu_hourly_usd / utilization
|
|
201
|
+
if utilization is not None and utilization > 0
|
|
202
|
+
else None
|
|
203
|
+
)
|
|
204
|
+
tps = tokens_per_second if tokens_per_second is not None else snapshot.tokens_per_second
|
|
205
|
+
cost_per_million = None
|
|
206
|
+
if tps is not None and tps > 0:
|
|
207
|
+
cost_per_million = gpu_hourly_usd / (tps * 3600.0) * 1_000_000.0
|
|
208
|
+
notes = (
|
|
209
|
+
"Estimate only: based on the user-supplied GPU hourly price "
|
|
210
|
+
f"(${gpu_hourly_usd:.4g}/h), not a measured bill."
|
|
211
|
+
)
|
|
212
|
+
if utilization is None:
|
|
213
|
+
notes += " GPU utilization was not reported, so no effective (utilization-adjusted) cost is given."
|
|
214
|
+
elif effective is None:
|
|
215
|
+
notes += " GPU utilization is zero, so no effective (utilization-adjusted) cost is given."
|
|
216
|
+
return EffectiveCost(
|
|
217
|
+
gpu_hourly_usd=gpu_hourly_usd,
|
|
218
|
+
hourly_cost_usd=gpu_hourly_usd,
|
|
219
|
+
utilization_ratio=utilization,
|
|
220
|
+
effective_cost_per_utilized_hour_usd=effective,
|
|
221
|
+
cost_per_million_generation_tokens_usd=cost_per_million,
|
|
222
|
+
notes=notes,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class VllmClient:
|
|
227
|
+
"""Bounded, read-only collector. Never raises for endpoint failures."""
|
|
228
|
+
|
|
229
|
+
def __init__(
|
|
230
|
+
self,
|
|
231
|
+
config: EndpointConfig,
|
|
232
|
+
*,
|
|
233
|
+
api_key: str | None = None,
|
|
234
|
+
allow_private_endpoints: bool = False,
|
|
235
|
+
timeout_seconds: float = 5.0,
|
|
236
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
237
|
+
):
|
|
238
|
+
self.config = config
|
|
239
|
+
self._api_key = api_key
|
|
240
|
+
self.allow_private_endpoints = allow_private_endpoints
|
|
241
|
+
self.timeout_seconds = timeout_seconds
|
|
242
|
+
self._transport = transport
|
|
243
|
+
self._validated: tuple[str, str] | None = None
|
|
244
|
+
|
|
245
|
+
def __repr__(self) -> str: # never leak the credential
|
|
246
|
+
return f"VllmClient(base_url={self.config.base_url!r})"
|
|
247
|
+
|
|
248
|
+
async def _validated_urls(self) -> tuple[str, str]:
|
|
249
|
+
"""SSRF-check base and metrics URLs before any request is sent."""
|
|
250
|
+
if self._validated is not None:
|
|
251
|
+
return self._validated
|
|
252
|
+
# Metrics URL first: a public base must not launder a private metrics URL.
|
|
253
|
+
allow = self.allow_private_endpoints
|
|
254
|
+
# An injected transport is a trusted test seam: hostnames are not
|
|
255
|
+
# DNS-resolved then, but IP-literal/scheme/credential checks remain.
|
|
256
|
+
resolve_dns = self._transport is None
|
|
257
|
+
metrics_url = self.config.metrics_url
|
|
258
|
+
if metrics_url:
|
|
259
|
+
metrics_url = await validate_provider_url(
|
|
260
|
+
metrics_url, allow_private_endpoints=allow, resolve_dns=resolve_dns
|
|
261
|
+
)
|
|
262
|
+
base_url = await validate_provider_url(
|
|
263
|
+
self.config.base_url, allow_private_endpoints=allow, resolve_dns=resolve_dns
|
|
264
|
+
)
|
|
265
|
+
self._validated = (base_url, metrics_url or f"{base_url}/metrics")
|
|
266
|
+
return self._validated
|
|
267
|
+
|
|
268
|
+
async def validate(self) -> str:
|
|
269
|
+
base_url, _ = await self._validated_urls()
|
|
270
|
+
return base_url
|
|
271
|
+
|
|
272
|
+
async def collect(self) -> MetricSnapshot:
|
|
273
|
+
try:
|
|
274
|
+
_, metrics_url = await self._validated_urls()
|
|
275
|
+
except ProviderAdapterError as exc:
|
|
276
|
+
return MetricSnapshot(
|
|
277
|
+
provider="vllm",
|
|
278
|
+
status="unreachable",
|
|
279
|
+
collected_at=now_iso(),
|
|
280
|
+
source=self.config.metrics_url or self.config.base_url,
|
|
281
|
+
errors=[str(exc)],
|
|
282
|
+
)
|
|
283
|
+
headers = {"Authorization": f"Bearer {self._api_key}"} if self._api_key else {}
|
|
284
|
+
try:
|
|
285
|
+
async with httpx.AsyncClient(
|
|
286
|
+
timeout=httpx.Timeout(self.timeout_seconds, connect=3.0),
|
|
287
|
+
follow_redirects=False,
|
|
288
|
+
transport=self._transport,
|
|
289
|
+
) as client:
|
|
290
|
+
response = await client.get(metrics_url, headers=headers)
|
|
291
|
+
response.raise_for_status()
|
|
292
|
+
text = response.text
|
|
293
|
+
except Exception as exc: # unreachable / auth / timeout / malformed
|
|
294
|
+
return MetricSnapshot(
|
|
295
|
+
provider="vllm",
|
|
296
|
+
status="unreachable",
|
|
297
|
+
collected_at=now_iso(),
|
|
298
|
+
source=metrics_url,
|
|
299
|
+
errors=[f"{type(exc).__name__}: collection failed"][:1],
|
|
300
|
+
)
|
|
301
|
+
if "vllm" not in text and "# HELP" not in text:
|
|
302
|
+
return MetricSnapshot(
|
|
303
|
+
provider="vllm",
|
|
304
|
+
status="degraded",
|
|
305
|
+
collected_at=now_iso(),
|
|
306
|
+
source=metrics_url,
|
|
307
|
+
errors=["Metrics response did not look like Prometheus exposition"],
|
|
308
|
+
)
|
|
309
|
+
return snapshot_from_metrics(text, source=metrics_url)
|
|
310
|
+
|
|
311
|
+
async def capabilities(self) -> EndpointCapabilities:
|
|
312
|
+
"""Probe /health, /v1/models and /metrics with GET only. Never raises."""
|
|
313
|
+
try:
|
|
314
|
+
base, metrics_url = await self._validated_urls()
|
|
315
|
+
except ProviderAdapterError as exc:
|
|
316
|
+
return EndpointCapabilities(
|
|
317
|
+
health_ok=False,
|
|
318
|
+
models=[],
|
|
319
|
+
metrics_ok=False,
|
|
320
|
+
errors=[str(exc)],
|
|
321
|
+
checked_at=now_iso(),
|
|
322
|
+
)
|
|
323
|
+
headers = {"Authorization": f"Bearer {self._api_key}"} if self._api_key else {}
|
|
324
|
+
errors: list[str] = []
|
|
325
|
+
health_ok = False
|
|
326
|
+
models: list[str] = []
|
|
327
|
+
metrics_ok = False
|
|
328
|
+
try:
|
|
329
|
+
async with httpx.AsyncClient(
|
|
330
|
+
timeout=httpx.Timeout(self.timeout_seconds, connect=3.0),
|
|
331
|
+
follow_redirects=False,
|
|
332
|
+
transport=self._transport,
|
|
333
|
+
) as client:
|
|
334
|
+
try:
|
|
335
|
+
resp = await client.get(f"{base}/health", headers=headers)
|
|
336
|
+
health_ok = 200 <= resp.status_code < 300
|
|
337
|
+
if not health_ok:
|
|
338
|
+
errors.append(f"health: HTTP {resp.status_code}")
|
|
339
|
+
except Exception as exc:
|
|
340
|
+
errors.append(f"health: {type(exc).__name__}: health check failed")
|
|
341
|
+
try:
|
|
342
|
+
resp = await client.get(f"{base}/v1/models", headers=headers)
|
|
343
|
+
if 200 <= resp.status_code < 300:
|
|
344
|
+
try:
|
|
345
|
+
payload = resp.json()
|
|
346
|
+
except Exception:
|
|
347
|
+
payload = None
|
|
348
|
+
errors.append("models: response was not valid JSON")
|
|
349
|
+
if isinstance(payload, dict):
|
|
350
|
+
data = payload.get("data")
|
|
351
|
+
if isinstance(data, list):
|
|
352
|
+
for item in data:
|
|
353
|
+
if isinstance(item, dict) and item.get("id"):
|
|
354
|
+
models.append(str(item["id"]))
|
|
355
|
+
elif isinstance(item, str) and item:
|
|
356
|
+
models.append(item)
|
|
357
|
+
else:
|
|
358
|
+
errors.append("models: response had no model list")
|
|
359
|
+
else:
|
|
360
|
+
errors.append(f"models: HTTP {resp.status_code}")
|
|
361
|
+
except Exception as exc:
|
|
362
|
+
errors.append(f"models: {type(exc).__name__}: model list failed")
|
|
363
|
+
try:
|
|
364
|
+
resp = await client.get(metrics_url, headers=headers)
|
|
365
|
+
if 200 <= resp.status_code < 300:
|
|
366
|
+
text = resp.text
|
|
367
|
+
metrics_ok = "vllm" in text or "# HELP" in text
|
|
368
|
+
if not metrics_ok:
|
|
369
|
+
errors.append("metrics: response did not look like Prometheus exposition")
|
|
370
|
+
else:
|
|
371
|
+
errors.append(f"metrics: HTTP {resp.status_code}")
|
|
372
|
+
except Exception as exc:
|
|
373
|
+
errors.append(f"metrics: {type(exc).__name__}: metrics check failed")
|
|
374
|
+
except Exception as exc: # client construction etc. — still never raise
|
|
375
|
+
errors.append(f"capabilities: {type(exc).__name__}: probe failed")
|
|
376
|
+
return EndpointCapabilities(
|
|
377
|
+
health_ok=health_ok,
|
|
378
|
+
models=models,
|
|
379
|
+
metrics_ok=metrics_ok,
|
|
380
|
+
errors=errors,
|
|
381
|
+
checked_at=now_iso(),
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
async def test(self) -> None:
|
|
385
|
+
snapshot = await self.collect()
|
|
386
|
+
if snapshot.status == "unreachable":
|
|
387
|
+
raise ProviderAdapterError(snapshot.errors[0] if snapshot.errors else "unreachable")
|