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.
Files changed (84) hide show
  1. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/PKG-INFO +1 -1
  2. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/__init__.py +25 -0
  3. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/config.py +4 -0
  4. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/core/types.py +55 -0
  5. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/interceptor/anthropic.py +25 -69
  6. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/interceptor/base.py +133 -0
  7. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/interceptor/openai.py +18 -12
  8. flightdeck_sensor-0.7.0/flightdeck_sensor/interceptor/platform.py +126 -0
  9. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/providers/anthropic.py +106 -6
  10. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/providers/litellm.py +72 -1
  11. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/providers/openai.py +103 -4
  12. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/providers/protocol.py +36 -1
  13. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/pyproject.toml +1 -1
  14. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_anthropic_resource_patch.py +9 -5
  15. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_openai_parse_responses.py +20 -6
  16. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_providers.py +21 -26
  17. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_streaming_usage.py +6 -1
  18. flightdeck_sensor-0.7.0/tests/unit/test_token_cost_fields.py +557 -0
  19. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/uv.lock +101 -101
  20. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/.gitignore +0 -0
  21. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/CLAUDE.md +0 -0
  22. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/Makefile +0 -0
  23. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/README.md +0 -0
  24. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/compat/__init__.py +0 -0
  25. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/compat/crewai_mcp.py +0 -0
  26. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/core/__init__.py +0 -0
  27. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/core/agent_id.py +0 -0
  28. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/core/context.py +0 -0
  29. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/core/errors.py +0 -0
  30. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/core/exceptions.py +0 -0
  31. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/core/mcp_policy.py +0 -0
  32. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/core/policy.py +0 -0
  33. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/core/schemas.py +0 -0
  34. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/core/session.py +0 -0
  35. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/interceptor/__init__.py +0 -0
  36. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/interceptor/crewai.py +0 -0
  37. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/interceptor/langgraph.py +0 -0
  38. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/interceptor/litellm.py +0 -0
  39. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/interceptor/mcp.py +0 -0
  40. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/interceptor/mcp_identity.py +0 -0
  41. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/provider.py +0 -0
  42. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/providers/__init__.py +0 -0
  43. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/py.typed +0 -0
  44. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/transport/__init__.py +0 -0
  45. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/transport/client.py +0 -0
  46. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/flightdeck_sensor/transport/retry.py +0 -0
  47. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/__init__.py +0 -0
  48. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/conftest.py +0 -0
  49. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/__init__.py +0 -0
  50. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_agent_id.py +0 -0
  51. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_agent_type_validation.py +0 -0
  52. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_compat_crewai_mcp.py +0 -0
  53. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_context.py +0 -0
  54. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_cross_agent_messages.py +0 -0
  55. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_custom_directives.py +0 -0
  56. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_egress_url_validation.py +0 -0
  57. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_errors.py +0 -0
  58. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_framework_attribution.py +0 -0
  59. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_interceptor.py +0 -0
  60. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_interceptor_crewai.py +0 -0
  61. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_interceptor_langgraph.py +0 -0
  62. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_interceptor_litellm.py +0 -0
  63. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_llm_event_enrichment.py +0 -0
  64. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_mcp_2x_paths.py +0 -0
  65. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_mcp_availability.py +0 -0
  66. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_mcp_identity.py +0 -0
  67. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_mcp_interceptor.py +0 -0
  68. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_mcp_policy_emission_enriched.py +0 -0
  69. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_mcp_policy_enforcement.py +0 -0
  70. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_mcp_policy_enforcement_all_paths.py +0 -0
  71. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_originating_event_id.py +0 -0
  72. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_patch.py +0 -0
  73. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_policy.py +0 -0
  74. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_policy_decision_summary.py +0 -0
  75. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_policy_directive_events.py +0 -0
  76. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_policy_emission_enriched.py +0 -0
  77. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_policy_events.py +0 -0
  78. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_prompt_capture.py +0 -0
  79. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_provider_enum.py +0 -0
  80. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_session.py +0 -0
  81. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_session_lifecycle_enrichment.py +0 -0
  82. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_stop_reason_errors.py +0 -0
  83. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_tool_capture_destination.py +0 -0
  84. {flightdeck_sensor-0.6.0 → flightdeck_sensor-0.7.0}/tests/unit/test_transport.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: flightdeck-sensor
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: In-process agent observability sensor for Flightdeck
5
5
  Project-URL: Repository, https://github.com/flightdeckhq/flightdeck
6
6
  Author-email: Omri Arnon <omri.arnon@gmail.com>
@@ -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.
@@ -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: the platform of
307
- ``client.base_url.host`` (see :func:`platform_for_host`), else
308
- :data:`PLATFORM_ANTHROPIC` when the client exposes no usable host.
309
- The host is the only source, so a first-party client pointed at a
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=platform_of(client),
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 OpenAIProvider(
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 = OpenAIProvider(
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 = OpenAIProvider(
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 = OpenAIProvider(
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)