flightdeck-sensor 0.6.0__tar.gz → 0.7.0__tar.gz
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.
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/PKG-INFO +1 -1
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/__init__.py +25 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/config.py +4 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/core/types.py +55 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/interceptor/anthropic.py +25 -69
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/interceptor/base.py +133 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/interceptor/openai.py +18 -12
- flightdeck_sensor-0.7.0/flightdeck_sensor/interceptor/platform.py +126 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/providers/anthropic.py +106 -6
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/providers/litellm.py +72 -1
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/providers/openai.py +103 -4
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/providers/protocol.py +36 -1
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/pyproject.toml +1 -1
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_anthropic_resource_patch.py +9 -5
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_openai_parse_responses.py +20 -6
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_providers.py +21 -26
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_streaming_usage.py +6 -1
- flightdeck_sensor-0.7.0/tests/unit/test_token_cost_fields.py +557 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/uv.lock +101 -101
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/.gitignore +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/CLAUDE.md +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/Makefile +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/README.md +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/compat/__init__.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/compat/crewai_mcp.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/core/__init__.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/core/agent_id.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/core/context.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/core/errors.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/core/exceptions.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/core/mcp_policy.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/core/policy.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/core/schemas.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/core/session.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/interceptor/__init__.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/interceptor/crewai.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/interceptor/langgraph.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/interceptor/litellm.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/interceptor/mcp.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/interceptor/mcp_identity.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/provider.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/providers/__init__.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/py.typed +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/transport/__init__.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/transport/client.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/transport/retry.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/__init__.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/conftest.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/__init__.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_agent_id.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_agent_type_validation.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_compat_crewai_mcp.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_context.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_cross_agent_messages.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_custom_directives.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_egress_url_validation.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_errors.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_framework_attribution.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_interceptor.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_interceptor_crewai.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_interceptor_langgraph.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_interceptor_litellm.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_llm_event_enrichment.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_mcp_2x_paths.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_mcp_availability.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_mcp_identity.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_mcp_interceptor.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_mcp_policy_emission_enriched.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_mcp_policy_enforcement.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_mcp_policy_enforcement_all_paths.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_originating_event_id.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_patch.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_policy.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_policy_decision_summary.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_policy_directive_events.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_policy_emission_enriched.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_policy_events.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_prompt_capture.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_provider_enum.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_session.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_session_lifecycle_enrichment.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_stop_reason_errors.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_tool_capture_destination.py +0 -0
- {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_transport.py +0 -0
|
@@ -94,6 +94,10 @@ __all__ = [
|
|
|
94
94
|
|
|
95
95
|
_log = logging.getLogger("flightdeck_sensor")
|
|
96
96
|
|
|
97
|
+
# Billing modes init(billing_mode=) / FLIGHTDECK_BILLING_MODE accept; the
|
|
98
|
+
# ingestion API rejects any other value on the wire.
|
|
99
|
+
_BILLING_MODES = frozenset({"metered", "subscription", "local"})
|
|
100
|
+
|
|
97
101
|
# Agent-type vocabulary lock. Any other value raises ConfigurationError
|
|
98
102
|
# at init time. See CHANGELOG.md for the v0.4.0 breaking change that
|
|
99
103
|
# narrowed this from {autonomous, supervised, batch, developer}.
|
|
@@ -241,6 +245,7 @@ def init(
|
|
|
241
245
|
langgraph_agent_node_pattern: str | None = None,
|
|
242
246
|
mcp_block_on_uncertainty: bool = False,
|
|
243
247
|
allow_insecure_transport: bool = False,
|
|
248
|
+
billing_mode: str | None = None,
|
|
244
249
|
) -> None:
|
|
245
250
|
"""Initialize the sensor and start the run.
|
|
246
251
|
|
|
@@ -307,6 +312,17 @@ def init(
|
|
|
307
312
|
- ``FLIGHTDECK_ALLOW_INSECURE_TRANSPORT`` -- ``"true"`` to allow plain
|
|
308
313
|
``http://`` egress to a non-local control plane (overrides
|
|
309
314
|
*allow_insecure_transport*)
|
|
315
|
+
- ``FLIGHTDECK_BILLING_MODE`` -- ``metered``, ``subscription`` or
|
|
316
|
+
``local`` (overrides *billing_mode*)
|
|
317
|
+
|
|
318
|
+
``billing_mode`` says how this agent's LLM calls are paid for, which
|
|
319
|
+
decides whether the dashboard counts them as spend. Left unset, every
|
|
320
|
+
call is ``metered`` except calls to a model server on this machine
|
|
321
|
+
(a loopback base URL), which are ``local``. Set it when the sensor
|
|
322
|
+
cannot tell: ``local`` for a self-hosted server elsewhere on your
|
|
323
|
+
network, ``subscription`` for a flat-rate plan. Case and surrounding
|
|
324
|
+
whitespace are ignored; any other value raises
|
|
325
|
+
:class:`ConfigurationError`.
|
|
310
326
|
|
|
311
327
|
``allow_insecure_transport`` opts in to plaintext ``http://`` egress
|
|
312
328
|
for a **non-local** control-plane host. Off by default: the resolved
|
|
@@ -389,6 +405,14 @@ def init(
|
|
|
389
405
|
resolved_api_url = resolved_server.rstrip("/").replace("/ingest", "/api")
|
|
390
406
|
|
|
391
407
|
capture = _env_bool(_env.ENV_CAPTURE_PROMPTS, capture_prompts)
|
|
408
|
+
resolved_billing_mode = (
|
|
409
|
+
os.environ.get(_env.ENV_BILLING_MODE) or billing_mode or ""
|
|
410
|
+
).strip().lower() or None
|
|
411
|
+
if resolved_billing_mode is not None and resolved_billing_mode not in _BILLING_MODES:
|
|
412
|
+
raise ConfigurationError(
|
|
413
|
+
f"billing_mode must be one of {sorted(_BILLING_MODES)}, "
|
|
414
|
+
f"got {resolved_billing_mode!r}"
|
|
415
|
+
)
|
|
392
416
|
|
|
393
417
|
# session_id resolution follows the same env-wins pattern as
|
|
394
418
|
# FLIGHTDECK_SERVER / AGENT_FLAVOR: env var overrides kwarg,
|
|
@@ -469,6 +493,7 @@ def init(
|
|
|
469
493
|
"token": resolved_token,
|
|
470
494
|
"api_url": resolved_api_url,
|
|
471
495
|
"capture_prompts": capture,
|
|
496
|
+
"billing_mode": resolved_billing_mode,
|
|
472
497
|
"allow_insecure_transport": resolved_allow_insecure,
|
|
473
498
|
"unavailable_policy": os.environ.get(_env.ENV_UNAVAILABLE_POLICY, "continue"),
|
|
474
499
|
# Legacy wire-level ``flavor`` field stays populated for
|
|
@@ -44,6 +44,9 @@ ENV_UNAVAILABLE_POLICY = "FLIGHTDECK_UNAVAILABLE_POLICY"
|
|
|
44
44
|
# Opt-in to plaintext http:// egress for a non-local control
|
|
45
45
|
# plane. Bool-coerced; default False forces https:// for public hosts.
|
|
46
46
|
ENV_ALLOW_INSECURE_TRANSPORT = "FLIGHTDECK_ALLOW_INSECURE_TRANSPORT"
|
|
47
|
+
# How LLM calls are paid for: "metered", "subscription" or "local".
|
|
48
|
+
# Overrides the per-call detection (loopback servers are local).
|
|
49
|
+
ENV_BILLING_MODE = "FLIGHTDECK_BILLING_MODE"
|
|
47
50
|
|
|
48
51
|
|
|
49
52
|
# Stable list of every FLIGHTDECK_* env var the sensor consults, in
|
|
@@ -60,4 +63,5 @@ ALL_ENV_VARS: tuple[str, ...] = (
|
|
|
60
63
|
ENV_CAPTURE_PROMPTS,
|
|
61
64
|
ENV_UNAVAILABLE_POLICY,
|
|
62
65
|
ENV_ALLOW_INSECURE_TRANSPORT,
|
|
66
|
+
ENV_BILLING_MODE,
|
|
63
67
|
)
|
|
@@ -262,18 +262,68 @@ class TokenUsage:
|
|
|
262
262
|
read as ``cached_tokens``, which lands in ``cache_read_tokens``, and
|
|
263
263
|
its cache write as ``cache_write_tokens``, which lands in
|
|
264
264
|
``cache_creation_tokens`` (0 when the response carries none).
|
|
265
|
+
|
|
266
|
+
``reasoning_tokens`` is the reasoning or thinking share of
|
|
267
|
+
``output_tokens`` and ``cache_creation_1h_tokens`` the one-hour share
|
|
268
|
+
of ``cache_creation_tokens`` (Anthropic's extended cache TTL). Both
|
|
269
|
+
are subsets already counted in their totals, and ``None`` when the
|
|
270
|
+
provider reported no such split.
|
|
265
271
|
"""
|
|
266
272
|
|
|
267
273
|
input_tokens: int = 0
|
|
268
274
|
output_tokens: int = 0
|
|
269
275
|
cache_read_tokens: int = 0
|
|
270
276
|
cache_creation_tokens: int = 0
|
|
277
|
+
reasoning_tokens: int | None = None
|
|
278
|
+
cache_creation_1h_tokens: int | None = None
|
|
271
279
|
|
|
272
280
|
@property
|
|
273
281
|
def total(self) -> int:
|
|
274
282
|
return self.input_tokens + self.output_tokens
|
|
275
283
|
|
|
276
284
|
|
|
285
|
+
@dataclass(frozen=True)
|
|
286
|
+
class CallDetails:
|
|
287
|
+
"""What a provider reports about one LLM call beyond its token counts
|
|
288
|
+
(ARCHITECTURE.md, POST /v1/events, token, model and cost fields).
|
|
289
|
+
|
|
290
|
+
Every field is ``None`` when the response did not carry it.
|
|
291
|
+
``model_served`` is the model id the response named, verbatim;
|
|
292
|
+
``cost_reported`` and ``cost_source`` are a cost the client library
|
|
293
|
+
or a gateway reported for the call (``cost_source`` ``"agent"`` or
|
|
294
|
+
``"provider"``).
|
|
295
|
+
"""
|
|
296
|
+
|
|
297
|
+
model_served: str | None = None
|
|
298
|
+
service_tier: str | None = None
|
|
299
|
+
speed: str | None = None
|
|
300
|
+
inference_geo: str | None = None
|
|
301
|
+
server_tool_use: dict[str, int] | None = None
|
|
302
|
+
cost_reported: float | None = None
|
|
303
|
+
cost_source: str | None = None
|
|
304
|
+
|
|
305
|
+
def merged(self, newer: CallDetails) -> CallDetails:
|
|
306
|
+
"""Fold details read from a later stream event over these: a
|
|
307
|
+
field the later event carries wins, and server-tool counters,
|
|
308
|
+
which streams report cumulatively, keep the larger count."""
|
|
309
|
+
tools: dict[str, int] | None = None
|
|
310
|
+
if self.server_tool_use or newer.server_tool_use:
|
|
311
|
+
tools = dict(self.server_tool_use or {})
|
|
312
|
+
for name, count in (newer.server_tool_use or {}).items():
|
|
313
|
+
tools[name] = max(count, tools.get(name, 0))
|
|
314
|
+
return CallDetails(
|
|
315
|
+
model_served=newer.model_served or self.model_served,
|
|
316
|
+
service_tier=newer.service_tier or self.service_tier,
|
|
317
|
+
speed=newer.speed or self.speed,
|
|
318
|
+
inference_geo=newer.inference_geo or self.inference_geo,
|
|
319
|
+
server_tool_use=tools,
|
|
320
|
+
cost_reported=(
|
|
321
|
+
newer.cost_reported if newer.cost_reported is not None else self.cost_reported
|
|
322
|
+
),
|
|
323
|
+
cost_source=newer.cost_source or self.cost_source,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
|
|
277
327
|
@dataclass
|
|
278
328
|
class SensorConfig:
|
|
279
329
|
"""Configuration for a sensor session.
|
|
@@ -299,6 +349,11 @@ class SensorConfig:
|
|
|
299
349
|
client_type: str = "flightdeck_sensor"
|
|
300
350
|
api_url: str = ""
|
|
301
351
|
capture_prompts: bool = False
|
|
352
|
+
# Billing mode stamped on every LLM call ("metered", "subscription",
|
|
353
|
+
# "local"); None lets each call's endpoint decide (loopback servers
|
|
354
|
+
# are local, everything else metered). Set from init(billing_mode=)
|
|
355
|
+
# or FLIGHTDECK_BILLING_MODE.
|
|
356
|
+
billing_mode: str | None = None
|
|
302
357
|
# Opt-in to plaintext http:// egress for a non-local control
|
|
303
358
|
# plane. Default False forces https:// for any public host; local /
|
|
304
359
|
# loopback / private targets are always allowed over http regardless.
|
{flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/interceptor/anthropic.py
RENAMED
|
@@ -103,6 +103,11 @@ from typing import TYPE_CHECKING, Any
|
|
|
103
103
|
|
|
104
104
|
from flightdeck_sensor.core.exceptions import BudgetExceededError, DirectiveError
|
|
105
105
|
from flightdeck_sensor.interceptor import base
|
|
106
|
+
from flightdeck_sensor.interceptor.platform import (
|
|
107
|
+
PLATFORM_ANTHROPIC,
|
|
108
|
+
PLATFORM_VERTEX,
|
|
109
|
+
endpoint_of,
|
|
110
|
+
)
|
|
106
111
|
from flightdeck_sensor.providers.anthropic import AnthropicProvider
|
|
107
112
|
|
|
108
113
|
if TYPE_CHECKING:
|
|
@@ -245,76 +250,12 @@ _PATCHED_SENTINEL = "_flightdeck_patched"
|
|
|
245
250
|
# through create / stream, which are already intercepted.
|
|
246
251
|
_PATCHED_METHODS = ("create", "stream", "parse")
|
|
247
252
|
|
|
248
|
-
# Platform reported when the client is Anthropic's own API; also the
|
|
249
|
-
# fallback when a client exposes no usable base_url at all (a test
|
|
250
|
-
# double), since the SDK is Anthropic's.
|
|
251
|
-
PLATFORM_ANTHROPIC = "anthropic"
|
|
252
|
-
|
|
253
|
-
# Hosts the SDK's own client classes default to, mapped to the platform
|
|
254
|
-
# name reported on the event: (host prefix, host suffix, platform). The
|
|
255
|
-
# first matching row wins. A host that matches no row -- a gateway or a
|
|
256
|
-
# proxy the caller pointed the SDK at -- is reported verbatim, so the
|
|
257
|
-
# deployment topology stays visible instead of being mislabelled as
|
|
258
|
-
# Anthropic's own API.
|
|
259
|
-
_PLATFORM_HOSTS: tuple[tuple[str, str, str], ...] = (
|
|
260
|
-
# Every suffix starts with a separator so a look-alike such as
|
|
261
|
-
# evilanthropic.com or anthropic.com.evil.example stays bare, and
|
|
262
|
-
# Vertex matches only the Vertex AI API hosts (aiplatform.<...>
|
|
263
|
-
# .googleapis.com and the regional <region>-aiplatform.googleapis.com)
|
|
264
|
-
# and google-cloud only Claude Platform on Google Cloud's gateway
|
|
265
|
-
# (claude.googleapis.com), not every Google API host.
|
|
266
|
-
("", ".anthropic.com", PLATFORM_ANTHROPIC),
|
|
267
|
-
("bedrock-runtime.", ".amazonaws.com", "bedrock"),
|
|
268
|
-
("bedrock-mantle.", ".api.aws", "bedrock-mantle"),
|
|
269
|
-
("aws-external-anthropic.", ".api.aws", "aws"),
|
|
270
|
-
("claude.", ".googleapis.com", "google-cloud"),
|
|
271
|
-
("aiplatform.", ".googleapis.com", "vertex"),
|
|
272
|
-
("", "-aiplatform.googleapis.com", "vertex"),
|
|
273
|
-
("", ".services.ai.azure.com", "foundry"),
|
|
274
|
-
)
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
# A platform value is one of the names above or a hostname. Hostnames are
|
|
278
|
-
# bounded by the DNS limit on a fully qualified name, and only letters,
|
|
279
|
-
# digits, dots and hyphens are accepted once lowercased; anything longer
|
|
280
|
-
# or containing other characters is reported as PLATFORM_INVALID_HOST,
|
|
281
|
-
# so a malformed or hostile base_url cannot put arbitrary bytes into
|
|
282
|
-
# every post_call of a session.
|
|
283
|
-
_MAX_PLATFORM_HOST_LENGTH = 253
|
|
284
|
-
_PLATFORM_HOST_CHARS = frozenset("abcdefghijklmnopqrstuvwxyz0123456789.-")
|
|
285
|
-
PLATFORM_INVALID_HOST = "invalid-host"
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
def platform_for_host(host: str) -> str:
|
|
289
|
-
"""The platform name for an API host: a known platform (see
|
|
290
|
-
``_PLATFORM_HOSTS``), else the host itself when it is a well-formed
|
|
291
|
-
hostname, else :data:`PLATFORM_INVALID_HOST`."""
|
|
292
|
-
lowered = host.lower()
|
|
293
|
-
if (
|
|
294
|
-
not lowered
|
|
295
|
-
or len(lowered) > _MAX_PLATFORM_HOST_LENGTH
|
|
296
|
-
or not set(lowered) <= _PLATFORM_HOST_CHARS
|
|
297
|
-
):
|
|
298
|
-
return PLATFORM_INVALID_HOST
|
|
299
|
-
for prefix, suffix, platform in _PLATFORM_HOSTS:
|
|
300
|
-
if lowered.startswith(prefix) and lowered.endswith(suffix):
|
|
301
|
-
return platform
|
|
302
|
-
return lowered
|
|
303
|
-
|
|
304
253
|
|
|
305
254
|
def platform_of(client: Any) -> str:
|
|
306
|
-
"""The platform a client talks to
|
|
307
|
-
|
|
308
|
-
:data:`PLATFORM_ANTHROPIC` when the client exposes no usable host.
|
|
309
|
-
|
|
310
|
-
gateway reports the gateway rather than a label its class carries."""
|
|
311
|
-
try:
|
|
312
|
-
host = getattr(getattr(client, "base_url", None), "host", None)
|
|
313
|
-
if isinstance(host, str) and host:
|
|
314
|
-
return platform_for_host(host)
|
|
315
|
-
except Exception:
|
|
316
|
-
_log.debug("platform attribution failed", exc_info=True)
|
|
317
|
-
return PLATFORM_ANTHROPIC
|
|
255
|
+
"""The platform a client talks to (see
|
|
256
|
+
:func:`flightdeck_sensor.interceptor.platform.endpoint_of`), else
|
|
257
|
+
:data:`PLATFORM_ANTHROPIC` when the client exposes no usable host."""
|
|
258
|
+
return endpoint_of(client, PLATFORM_ANTHROPIC).platform
|
|
318
259
|
|
|
319
260
|
|
|
320
261
|
def _is_async_client(client: Any) -> bool:
|
|
@@ -344,12 +285,27 @@ def _raw_response_mode(kwargs: dict[str, Any]) -> str | None:
|
|
|
344
285
|
|
|
345
286
|
|
|
346
287
|
def _provider_for(client: Any, session: Session) -> AnthropicProvider:
|
|
288
|
+
endpoint = endpoint_of(client, PLATFORM_ANTHROPIC)
|
|
347
289
|
return AnthropicProvider(
|
|
348
290
|
capture_prompts=session.config.capture_prompts,
|
|
349
|
-
platform=
|
|
291
|
+
platform=endpoint.platform,
|
|
292
|
+
local=endpoint.local,
|
|
293
|
+
inference_scope=_vertex_scope(client, endpoint.platform),
|
|
350
294
|
)
|
|
351
295
|
|
|
352
296
|
|
|
297
|
+
def _vertex_scope(client: Any, platform: str) -> str | None:
|
|
298
|
+
"""``global`` or ``regional`` for a Vertex AI client, from the region
|
|
299
|
+
it was configured with (``AnthropicVertex(region=...)``, defaulting
|
|
300
|
+
to ``CLOUD_ML_REGION``); ``None`` for every other platform."""
|
|
301
|
+
if platform != PLATFORM_VERTEX:
|
|
302
|
+
return None
|
|
303
|
+
region = getattr(client, "region", None)
|
|
304
|
+
if not isinstance(region, str) or not region:
|
|
305
|
+
return None
|
|
306
|
+
return "global" if region == "global" else "regional"
|
|
307
|
+
|
|
308
|
+
|
|
353
309
|
# ----------------------------------------------------------------------
|
|
354
310
|
# Deferred metering for the streaming view
|
|
355
311
|
# ----------------------------------------------------------------------
|
|
@@ -40,6 +40,7 @@ from flightdeck_sensor.core.exceptions import (
|
|
|
40
40
|
MCPPolicyBlocked,
|
|
41
41
|
)
|
|
42
42
|
from flightdeck_sensor.core.types import (
|
|
43
|
+
CallDetails,
|
|
43
44
|
EventType,
|
|
44
45
|
PolicyDecision,
|
|
45
46
|
PolicyDecisionSummary,
|
|
@@ -371,9 +372,36 @@ def _merge_usage(current: TokenUsage | None, new: TokenUsage) -> TokenUsage:
|
|
|
371
372
|
output_tokens=max(new.output_tokens, current.output_tokens),
|
|
372
373
|
cache_read_tokens=max(new.cache_read_tokens, current.cache_read_tokens),
|
|
373
374
|
cache_creation_tokens=max(new.cache_creation_tokens, current.cache_creation_tokens),
|
|
375
|
+
reasoning_tokens=_max_reported(new.reasoning_tokens, current.reasoning_tokens),
|
|
376
|
+
cache_creation_1h_tokens=_max_reported(
|
|
377
|
+
new.cache_creation_1h_tokens, current.cache_creation_1h_tokens
|
|
378
|
+
),
|
|
374
379
|
)
|
|
375
380
|
|
|
376
381
|
|
|
382
|
+
def _max_reported(a: int | None, b: int | None) -> int | None:
|
|
383
|
+
"""The larger of two optional cumulative counters; ``None`` only when
|
|
384
|
+
neither was reported."""
|
|
385
|
+
if a is None:
|
|
386
|
+
return b
|
|
387
|
+
if b is None:
|
|
388
|
+
return a
|
|
389
|
+
return max(a, b)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# Billing modes the sensor stamps (ARCHITECTURE.md, Cost estimation): a
|
|
393
|
+
# call to a model server on this machine is local, every other call is
|
|
394
|
+
# paid per token unless init(billing_mode=) / FLIGHTDECK_BILLING_MODE
|
|
395
|
+
# says otherwise.
|
|
396
|
+
BILLING_METERED = "metered"
|
|
397
|
+
BILLING_LOCAL = "local"
|
|
398
|
+
|
|
399
|
+
# The ingestion API's cap on the free-form strings of the token and cost
|
|
400
|
+
# fields (model ids, tier, speed, residency, stop reason), in UTF-8 bytes;
|
|
401
|
+
# a longer value would get the whole event rejected, so it is cut to fit
|
|
402
|
+
# instead.
|
|
403
|
+
_MAX_DETAIL_BYTES = 256
|
|
404
|
+
|
|
377
405
|
# Set on an exception once an intercept has reported it, so a stream's
|
|
378
406
|
# with-exit does not report the same failure of a nested call again.
|
|
379
407
|
_REPORTED_ATTR = "_flightdeck_reported"
|
|
@@ -509,6 +537,9 @@ class _GuardCore:
|
|
|
509
537
|
# The stop reason a streamed event announced (a raw
|
|
510
538
|
# stream has no final message to read it from at settle time).
|
|
511
539
|
self._stop_reason: str | None = None
|
|
540
|
+
# Call details the streamed events reported (served model, tier,
|
|
541
|
+
# server-tool counters), folded event by event.
|
|
542
|
+
self._details: CallDetails | None = None
|
|
512
543
|
self._hooks_failed: set[str] = set()
|
|
513
544
|
self._open_error: BaseException | None = None
|
|
514
545
|
drain_deferred_settles(session)
|
|
@@ -654,6 +685,15 @@ class _GuardCore:
|
|
|
654
685
|
reason = None
|
|
655
686
|
if isinstance(reason, str):
|
|
656
687
|
self._stop_reason = reason
|
|
688
|
+
try:
|
|
689
|
+
details = self._provider.call_details_from_stream_chunk(chunk)
|
|
690
|
+
except Exception:
|
|
691
|
+
self._hook_failed("call_details_from_stream_chunk")
|
|
692
|
+
details = None
|
|
693
|
+
# Chat chunks repeat the same model and tier on every chunk; fold
|
|
694
|
+
# only a chunk that reports something new.
|
|
695
|
+
if details is not None and details != self._details:
|
|
696
|
+
self._details = details if self._details is None else self._details.merged(details)
|
|
657
697
|
|
|
658
698
|
def _merge_settled_usage(self) -> None:
|
|
659
699
|
"""Fold in the usage the SDK stream object holds at settle time
|
|
@@ -765,6 +805,7 @@ class _GuardCore:
|
|
|
765
805
|
estimated_via=self._estimated_via,
|
|
766
806
|
usage_override=self._usage,
|
|
767
807
|
stop_reason=self._stop_reason,
|
|
808
|
+
details_override=self._details,
|
|
768
809
|
)
|
|
769
810
|
except Exception as emit_exc:
|
|
770
811
|
# No exc_info: the provider exception is the active context and
|
|
@@ -1392,6 +1433,7 @@ def _post_call(
|
|
|
1392
1433
|
estimated_via: str = "none",
|
|
1393
1434
|
usage_override: TokenUsage | None = None,
|
|
1394
1435
|
stop_reason: str | None = None,
|
|
1436
|
+
details_override: CallDetails | None = None,
|
|
1395
1437
|
) -> None:
|
|
1396
1438
|
"""Extract actual usage, reconcile with estimate, post event.
|
|
1397
1439
|
|
|
@@ -1408,6 +1450,10 @@ def _post_call(
|
|
|
1408
1450
|
the streamed events; when ``None`` the provider is asked to read it
|
|
1409
1451
|
from the response. A reason that means the call did not succeed files
|
|
1410
1452
|
an llm_error beside the post_call.
|
|
1453
|
+
|
|
1454
|
+
``details_override`` carries the call details a stream guard folded
|
|
1455
|
+
from the streamed events; the response's own details (a helper
|
|
1456
|
+
stream's final message) are laid over them.
|
|
1411
1457
|
"""
|
|
1412
1458
|
# ``usage_override`` carries the counts a stream guard read from the
|
|
1413
1459
|
# provider's in-stream usage events; stream objects themselves expose
|
|
@@ -1507,6 +1553,16 @@ def _post_call(
|
|
|
1507
1553
|
payload["content"] = content_dict
|
|
1508
1554
|
if streaming is not None:
|
|
1509
1555
|
payload["streaming"] = streaming
|
|
1556
|
+
_add_token_cost_fields(
|
|
1557
|
+
payload,
|
|
1558
|
+
session,
|
|
1559
|
+
provider,
|
|
1560
|
+
response,
|
|
1561
|
+
call_kwargs,
|
|
1562
|
+
usage,
|
|
1563
|
+
details_override,
|
|
1564
|
+
stop_reason,
|
|
1565
|
+
)
|
|
1510
1566
|
|
|
1511
1567
|
# provider_metadata: rate-limit headers, request id, processing
|
|
1512
1568
|
# time. Always-included when the provider exposes any of them so
|
|
@@ -1749,6 +1805,83 @@ def _response_request_id(response: Any, prov_meta: dict[str, Any] | None) -> str
|
|
|
1749
1805
|
return None
|
|
1750
1806
|
|
|
1751
1807
|
|
|
1808
|
+
def _add_token_cost_fields(
|
|
1809
|
+
payload: dict[str, Any],
|
|
1810
|
+
session: Session,
|
|
1811
|
+
provider: Provider,
|
|
1812
|
+
response: Any,
|
|
1813
|
+
call_kwargs: dict[str, Any] | None,
|
|
1814
|
+
usage: TokenUsage,
|
|
1815
|
+
details_override: CallDetails | None,
|
|
1816
|
+
stop_reason: str | None,
|
|
1817
|
+
) -> None:
|
|
1818
|
+
"""Add the token, model and cost fields of the event contract
|
|
1819
|
+
(ARCHITECTURE.md, POST /v1/events) to an LLM call's payload. A field
|
|
1820
|
+
is sent only when known, so the wire stays as before for whatever a
|
|
1821
|
+
provider does not report. Never raises."""
|
|
1822
|
+
try:
|
|
1823
|
+
details = details_override
|
|
1824
|
+
try:
|
|
1825
|
+
own = provider.call_details(response)
|
|
1826
|
+
except Exception:
|
|
1827
|
+
own = None
|
|
1828
|
+
if own is not None:
|
|
1829
|
+
details = own if details is None else details.merged(own)
|
|
1830
|
+
|
|
1831
|
+
fields: dict[str, Any] = {
|
|
1832
|
+
"model_requested": provider.get_model(call_kwargs or {}) or None,
|
|
1833
|
+
"provider": provider.vendor,
|
|
1834
|
+
"billing_mode": session.config.billing_mode
|
|
1835
|
+
or (BILLING_LOCAL if provider.local else BILLING_METERED),
|
|
1836
|
+
"inference_scope": provider.inference_scope,
|
|
1837
|
+
# Each share must fit inside its total, or ingestion rejects
|
|
1838
|
+
# the whole event; a share that does not is left out.
|
|
1839
|
+
"tokens_reasoning": _share(usage.reasoning_tokens, usage.output_tokens),
|
|
1840
|
+
"tokens_cache_creation_1h": _share(
|
|
1841
|
+
usage.cache_creation_1h_tokens, usage.cache_creation_tokens
|
|
1842
|
+
),
|
|
1843
|
+
}
|
|
1844
|
+
reason = stop_reason
|
|
1845
|
+
if reason is None:
|
|
1846
|
+
with contextlib.suppress(Exception):
|
|
1847
|
+
reason = provider.response_stop_reason(response)
|
|
1848
|
+
if isinstance(reason, str) and reason:
|
|
1849
|
+
fields["stop_reason"] = reason
|
|
1850
|
+
if details is not None:
|
|
1851
|
+
fields.update(
|
|
1852
|
+
model_served=details.model_served,
|
|
1853
|
+
service_tier=details.service_tier,
|
|
1854
|
+
speed=details.speed,
|
|
1855
|
+
inference_geo=details.inference_geo,
|
|
1856
|
+
server_tool_use=details.server_tool_use,
|
|
1857
|
+
cost_reported=details.cost_reported,
|
|
1858
|
+
cost_source=details.cost_source,
|
|
1859
|
+
)
|
|
1860
|
+
for key, value in fields.items():
|
|
1861
|
+
if value is None:
|
|
1862
|
+
continue
|
|
1863
|
+
if isinstance(value, str):
|
|
1864
|
+
value = _clip_utf8(value, _MAX_DETAIL_BYTES)
|
|
1865
|
+
payload[key] = value
|
|
1866
|
+
except Exception:
|
|
1867
|
+
_log.debug("token and cost fields failed", exc_info=True)
|
|
1868
|
+
|
|
1869
|
+
|
|
1870
|
+
def _clip_utf8(value: str, max_bytes: int) -> str:
|
|
1871
|
+
"""``value`` cut to at most ``max_bytes`` of UTF-8, never splitting a
|
|
1872
|
+
character."""
|
|
1873
|
+
encoded = value.encode("utf-8")
|
|
1874
|
+
if len(encoded) <= max_bytes:
|
|
1875
|
+
return value
|
|
1876
|
+
return encoded[:max_bytes].decode("utf-8", errors="ignore")
|
|
1877
|
+
|
|
1878
|
+
|
|
1879
|
+
def _share(part: int | None, whole: int) -> int | None:
|
|
1880
|
+
"""A reported share of a token total, or ``None`` when it was not
|
|
1881
|
+
reported or exceeds the total."""
|
|
1882
|
+
return part if part is not None and 0 <= part <= whole else None
|
|
1883
|
+
|
|
1884
|
+
|
|
1752
1885
|
def _emit_stop_reason_error(
|
|
1753
1886
|
session: Session,
|
|
1754
1887
|
provider: Provider,
|
|
@@ -74,6 +74,7 @@ from typing import TYPE_CHECKING, Any
|
|
|
74
74
|
|
|
75
75
|
from flightdeck_sensor.core.errors import classify_stop_reason
|
|
76
76
|
from flightdeck_sensor.interceptor import base
|
|
77
|
+
from flightdeck_sensor.interceptor.platform import PLATFORM_OPENAI, endpoint_of
|
|
77
78
|
from flightdeck_sensor.providers.openai import OpenAIProvider
|
|
78
79
|
|
|
79
80
|
if TYPE_CHECKING:
|
|
@@ -103,6 +104,19 @@ except ImportError:
|
|
|
103
104
|
_OPENAI_AVAILABLE = False
|
|
104
105
|
|
|
105
106
|
|
|
107
|
+
def _provider_for(client: Any, session: Session) -> OpenAIProvider:
|
|
108
|
+
"""An OpenAI provider for a client, attributed to the endpoint the
|
|
109
|
+
client talks to (``openai``, ``azure``, or the host of an
|
|
110
|
+
OpenAI-compatible server; see
|
|
111
|
+
:func:`flightdeck_sensor.interceptor.platform.endpoint_of`)."""
|
|
112
|
+
endpoint = endpoint_of(client, PLATFORM_OPENAI)
|
|
113
|
+
return OpenAIProvider(
|
|
114
|
+
capture_prompts=session.config.capture_prompts,
|
|
115
|
+
platform=endpoint.platform,
|
|
116
|
+
local=endpoint.local,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
106
120
|
def _is_async_client(client: Any) -> bool:
|
|
107
121
|
"""Detect async OpenAI client using the captured class reference."""
|
|
108
122
|
if _OrigAsyncOpenAI is None:
|
|
@@ -727,9 +741,7 @@ class SensorOpenAI:
|
|
|
727
741
|
self._client = client
|
|
728
742
|
self._session = session
|
|
729
743
|
self._is_async = _is_async_client(client)
|
|
730
|
-
self._provider = provider or
|
|
731
|
-
capture_prompts=session.config.capture_prompts,
|
|
732
|
-
)
|
|
744
|
+
self._provider = provider or _provider_for(client, session)
|
|
733
745
|
|
|
734
746
|
@property
|
|
735
747
|
def chat(self) -> SensorChat:
|
|
@@ -867,9 +879,7 @@ class _OpenAIChatDescriptor:
|
|
|
867
879
|
if session is None:
|
|
868
880
|
return raw
|
|
869
881
|
|
|
870
|
-
provider =
|
|
871
|
-
capture_prompts=session.config.capture_prompts,
|
|
872
|
-
)
|
|
882
|
+
provider = _provider_for(instance, session)
|
|
873
883
|
wrapped = SensorChat(
|
|
874
884
|
raw,
|
|
875
885
|
session,
|
|
@@ -913,9 +923,7 @@ class _OpenAIResponsesDescriptor:
|
|
|
913
923
|
if session is None:
|
|
914
924
|
return raw
|
|
915
925
|
|
|
916
|
-
provider =
|
|
917
|
-
capture_prompts=session.config.capture_prompts,
|
|
918
|
-
)
|
|
926
|
+
provider = _provider_for(instance, session)
|
|
919
927
|
wrapped = SensorResponses(
|
|
920
928
|
raw,
|
|
921
929
|
session,
|
|
@@ -959,9 +967,7 @@ class _OpenAIEmbeddingsDescriptor:
|
|
|
959
967
|
if session is None:
|
|
960
968
|
return raw
|
|
961
969
|
|
|
962
|
-
provider =
|
|
963
|
-
capture_prompts=session.config.capture_prompts,
|
|
964
|
-
)
|
|
970
|
+
provider = _provider_for(instance, session)
|
|
965
971
|
wrapped = SensorEmbeddings(
|
|
966
972
|
raw,
|
|
967
973
|
session,
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Endpoint attribution shared by the SDK interceptors.
|
|
2
|
+
|
|
3
|
+
Every ``post_call`` names the endpoint the client talked to under
|
|
4
|
+
``provider_metadata.platform``: a fixed label for the hosts the SDKs'
|
|
5
|
+
own client classes default to, else the bare host of whatever the client
|
|
6
|
+
was pointed at (a gateway, a proxy, a local model server). The worker
|
|
7
|
+
copies it into ``events.platform`` and the cost model picks the price
|
|
8
|
+
list from it. The host is the only source, so a first-party client
|
|
9
|
+
pointed at a gateway reports the gateway rather than a label its class
|
|
10
|
+
carries.
|
|
11
|
+
|
|
12
|
+
A client pointed at a loopback host (``localhost``, ``127.0.0.0/8``,
|
|
13
|
+
``::1``) is a self-hosted model server, whose calls carry the ``local``
|
|
14
|
+
billing mode.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import contextlib
|
|
20
|
+
import ipaddress
|
|
21
|
+
import logging
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
_log = logging.getLogger("flightdeck_sensor.interceptor.platform")
|
|
26
|
+
|
|
27
|
+
PLATFORM_ANTHROPIC = "anthropic"
|
|
28
|
+
PLATFORM_OPENAI = "openai"
|
|
29
|
+
PLATFORM_AZURE = "azure"
|
|
30
|
+
PLATFORM_VERTEX = "vertex"
|
|
31
|
+
# The platform of a client pointed at this machine, whatever spelling of
|
|
32
|
+
# the loopback host it uses (an IPv6 literal would otherwise fail the
|
|
33
|
+
# hostname check below and read as invalid-host).
|
|
34
|
+
PLATFORM_LOCALHOST = "localhost"
|
|
35
|
+
|
|
36
|
+
# Hosts the SDKs' own client classes default to, mapped to the platform
|
|
37
|
+
# name reported on the event: (host prefix, host suffix, platform). The
|
|
38
|
+
# first matching row wins. A host that matches no row -- a gateway or a
|
|
39
|
+
# proxy the caller pointed the SDK at -- is reported verbatim, so the
|
|
40
|
+
# deployment topology stays visible instead of being mislabelled as the
|
|
41
|
+
# provider's own API.
|
|
42
|
+
_PLATFORM_HOSTS: tuple[tuple[str, str, str], ...] = (
|
|
43
|
+
# Every suffix starts with a separator so a look-alike such as
|
|
44
|
+
# evilanthropic.com or anthropic.com.evil.example stays bare, and
|
|
45
|
+
# Vertex matches only the Vertex AI API hosts (aiplatform.<...>
|
|
46
|
+
# .googleapis.com and the regional <region>-aiplatform.googleapis.com)
|
|
47
|
+
# and google-cloud only Claude Platform on Google Cloud's gateway
|
|
48
|
+
# (claude.googleapis.com), not every Google API host.
|
|
49
|
+
("", ".anthropic.com", PLATFORM_ANTHROPIC),
|
|
50
|
+
("bedrock-runtime.", ".amazonaws.com", "bedrock"),
|
|
51
|
+
("bedrock-mantle.", ".api.aws", "bedrock-mantle"),
|
|
52
|
+
("aws-external-anthropic.", ".api.aws", "aws"),
|
|
53
|
+
("claude.", ".googleapis.com", "google-cloud"),
|
|
54
|
+
("aiplatform.", ".googleapis.com", PLATFORM_VERTEX),
|
|
55
|
+
("", "-aiplatform.googleapis.com", PLATFORM_VERTEX),
|
|
56
|
+
("", ".services.ai.azure.com", "foundry"),
|
|
57
|
+
("", ".openai.com", PLATFORM_OPENAI),
|
|
58
|
+
("", ".openai.azure.com", PLATFORM_AZURE),
|
|
59
|
+
("", ".cognitiveservices.azure.com", PLATFORM_AZURE),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# A platform value is one of the names above or a hostname. Hostnames are
|
|
63
|
+
# bounded by the DNS limit on a fully qualified name, and only letters,
|
|
64
|
+
# digits, dots and hyphens are accepted once lowercased; anything longer
|
|
65
|
+
# or containing other characters is reported as PLATFORM_INVALID_HOST,
|
|
66
|
+
# so a malformed or hostile base_url cannot put arbitrary bytes into
|
|
67
|
+
# every post_call of a session.
|
|
68
|
+
_MAX_PLATFORM_HOST_LENGTH = 253
|
|
69
|
+
_PLATFORM_HOST_CHARS = frozenset("abcdefghijklmnopqrstuvwxyz0123456789.-")
|
|
70
|
+
PLATFORM_INVALID_HOST = "invalid-host"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def platform_for_host(host: str) -> str:
|
|
74
|
+
"""The platform name for an API host: a known platform (see
|
|
75
|
+
``_PLATFORM_HOSTS``), else the host itself when it is a well-formed
|
|
76
|
+
hostname, else :data:`PLATFORM_INVALID_HOST`."""
|
|
77
|
+
lowered = host.lower()
|
|
78
|
+
if (
|
|
79
|
+
not lowered
|
|
80
|
+
or len(lowered) > _MAX_PLATFORM_HOST_LENGTH
|
|
81
|
+
or not set(lowered) <= _PLATFORM_HOST_CHARS
|
|
82
|
+
):
|
|
83
|
+
return PLATFORM_INVALID_HOST
|
|
84
|
+
for prefix, suffix, platform in _PLATFORM_HOSTS:
|
|
85
|
+
if lowered.startswith(prefix) and lowered.endswith(suffix):
|
|
86
|
+
return platform
|
|
87
|
+
return lowered
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def is_loopback_host(host: str) -> bool:
|
|
91
|
+
"""Whether an API host is this machine: ``localhost`` (or a name under
|
|
92
|
+
it) or a loopback address. A server elsewhere on a private network is
|
|
93
|
+
not treated as local, since gateways and metered proxies often sit on
|
|
94
|
+
such addresses."""
|
|
95
|
+
lowered = host.lower().strip("[]").rstrip(".")
|
|
96
|
+
if lowered == "localhost" or lowered.endswith(".localhost"):
|
|
97
|
+
return True
|
|
98
|
+
with contextlib.suppress(ValueError):
|
|
99
|
+
address = ipaddress.ip_address(lowered)
|
|
100
|
+
mapped = getattr(address, "ipv4_mapped", None)
|
|
101
|
+
return (mapped or address).is_loopback
|
|
102
|
+
return False
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class Endpoint:
|
|
107
|
+
"""Where a client sends its requests: the platform label, and whether
|
|
108
|
+
that is a model server on this machine."""
|
|
109
|
+
|
|
110
|
+
platform: str
|
|
111
|
+
local: bool = False
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def endpoint_of(client: Any, default_platform: str) -> Endpoint:
|
|
115
|
+
"""The endpoint of a client, read from ``client.base_url.host``;
|
|
116
|
+
``default_platform`` when the client exposes no usable host (a test
|
|
117
|
+
double)."""
|
|
118
|
+
try:
|
|
119
|
+
host = getattr(getattr(client, "base_url", None), "host", None)
|
|
120
|
+
if isinstance(host, str) and host:
|
|
121
|
+
if is_loopback_host(host):
|
|
122
|
+
return Endpoint(platform=PLATFORM_LOCALHOST, local=True)
|
|
123
|
+
return Endpoint(platform=platform_for_host(host))
|
|
124
|
+
except Exception:
|
|
125
|
+
_log.debug("platform attribution failed", exc_info=True)
|
|
126
|
+
return Endpoint(platform=default_platform)
|