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.
@@ -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,6 @@
1
+ """Time helpers."""
2
+ from datetime import datetime, timezone
3
+
4
+
5
+ def now_iso() -> str:
6
+ return datetime.now(timezone.utc).isoformat()
@@ -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")
@@ -0,0 +1,5 @@
1
+ """Workload observability adapters and analysis."""
2
+ from inference_control_plane.workload.analysis import analyze_events
3
+ from inference_control_plane.workload.errors import WorkloadOptimizerError
4
+
5
+ __all__ = ["analyze_events", "WorkloadOptimizerError"]