metergraph 0.3.2__tar.gz → 0.5.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 (34) hide show
  1. {metergraph-0.3.2 → metergraph-0.5.0}/PKG-INFO +40 -3
  2. {metergraph-0.3.2 → metergraph-0.5.0}/README.md +39 -2
  3. {metergraph-0.3.2 → metergraph-0.5.0}/pyproject.toml +1 -1
  4. {metergraph-0.3.2 → metergraph-0.5.0}/src/metergraph/__init__.py +37 -3
  5. metergraph-0.5.0/src/metergraph/_batch_first.py +326 -0
  6. {metergraph-0.3.2 → metergraph-0.5.0}/src/metergraph/_capture.py +17 -2
  7. metergraph-0.5.0/src/metergraph/_provider_batch.py +390 -0
  8. metergraph-0.5.0/src/metergraph/_repo_config.py +165 -0
  9. metergraph-0.5.0/src/metergraph/_session.py +156 -0
  10. {metergraph-0.3.2 → metergraph-0.5.0}/src/metergraph/_transport.py +11 -1
  11. {metergraph-0.3.2 → metergraph-0.5.0}/src/metergraph/_version.py +1 -1
  12. {metergraph-0.3.2 → metergraph-0.5.0}/src/metergraph.egg-info/PKG-INFO +40 -3
  13. {metergraph-0.3.2 → metergraph-0.5.0}/src/metergraph.egg-info/SOURCES.txt +13 -1
  14. metergraph-0.5.0/tests/test_batch_first.py +595 -0
  15. metergraph-0.5.0/tests/test_capture_repo_root.py +101 -0
  16. metergraph-0.5.0/tests/test_init_repo_aware.py +110 -0
  17. metergraph-0.5.0/tests/test_provider_batch.py +604 -0
  18. metergraph-0.5.0/tests/test_public_api_surface.py +50 -0
  19. metergraph-0.5.0/tests/test_repository_aware_ingest.py +221 -0
  20. metergraph-0.5.0/tests/test_session_manager.py +319 -0
  21. metergraph-0.5.0/tests/test_writer_session.py +142 -0
  22. {metergraph-0.3.2 → metergraph-0.5.0}/setup.cfg +0 -0
  23. {metergraph-0.3.2 → metergraph-0.5.0}/src/metergraph/_config.py +0 -0
  24. {metergraph-0.3.2 → metergraph-0.5.0}/src/metergraph/_context.py +0 -0
  25. {metergraph-0.3.2 → metergraph-0.5.0}/src/metergraph/_failure_log.py +0 -0
  26. {metergraph-0.3.2 → metergraph-0.5.0}/src/metergraph/_template.py +0 -0
  27. {metergraph-0.3.2 → metergraph-0.5.0}/src/metergraph/_track.py +0 -0
  28. {metergraph-0.3.2 → metergraph-0.5.0}/src/metergraph.egg-info/dependency_links.txt +0 -0
  29. {metergraph-0.3.2 → metergraph-0.5.0}/src/metergraph.egg-info/requires.txt +0 -0
  30. {metergraph-0.3.2 → metergraph-0.5.0}/src/metergraph.egg-info/top_level.txt +0 -0
  31. {metergraph-0.3.2 → metergraph-0.5.0}/tests/test_edge_cases.py +0 -0
  32. {metergraph-0.3.2 → metergraph-0.5.0}/tests/test_real_client_integration.py +0 -0
  33. {metergraph-0.3.2 → metergraph-0.5.0}/tests/test_sdk.py +0 -0
  34. {metergraph-0.3.2 → metergraph-0.5.0}/tests/test_seam_reality.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metergraph
3
- Version: 0.3.2
3
+ Version: 0.5.0
4
4
  Summary: Fire-and-forget LLM spend capture for Metergraph
5
5
  Author: Pioneer Square Labs
6
6
  License-Expression: Apache-2.0
@@ -85,10 +85,18 @@ Configuration:
85
85
  - `METERGRAPH_DISABLED=1` — process kill switch
86
86
  - `METERGRAPH_QUEUE_SIZE`, `METERGRAPH_BATCH_SIZE`, `METERGRAPH_FLUSH_SECONDS`
87
87
 
88
+ SDK 0.4 associates traces with their GitHub repository automatically. On the
89
+ first `init()` in a Git checkout, it reads the `origin` remote and creates
90
+ `.metergraph/config.json` at the repository root if that file is absent.
91
+ Commit this non-secret file so production can use repository-aware ingest
92
+ without Git metadata. An existing file is authoritative and is never changed
93
+ by the SDK. If discovery or creation is unavailable, ingest remains compatible
94
+ with protocol v1.
95
+
88
96
  Delivery is bounded and off the request path. Queue overflow or a collector
89
97
  outage drops capture and increments internal counters; it never changes the
90
98
  provider call. Each wire batch is bounded to 512 KiB after optional gzip.
91
- SDK 0.3 captures the scrubbed provider request and a normalized response
99
+ SDK 0.4 captures the scrubbed provider request and a normalized response
92
100
  envelope, including assistant content and tool calls, by default. Provider
93
101
  credentials and transport headers are removed. Request and response are each
94
102
  limited to 100 KiB of UTF-8 with an explicit truncation marker.
@@ -117,6 +125,35 @@ message batches are captured per inference while iterating a wrapped
117
125
  rows carry real per-result usage and the batch pricing flag; job-management
118
126
  polls themselves are not miscounted as model calls.
119
127
 
128
+ ## Batch-first execution (opt-in)
129
+
130
+ `metergraph.batch_first()` is a separate, explicitly opt-in code path from `wrap()`/capture: submit one request through a provider's Batch API, wait up to a caller-chosen deadline, and fall back to exactly one direct call if the batch hasn't finished in time. It is synchronous/blocking, matching this SDK's own background-work model — a daemon thread, not asyncio.
131
+
132
+ ```python
133
+ import metergraph
134
+ from openai import OpenAI
135
+
136
+ client = OpenAI() # unwrapped — batch_first() drives it directly, not through wrap()
137
+
138
+ outcome = metergraph.batch_first(
139
+ client, "openai",
140
+ {"model": "gpt-5-mini", "input": "Summarize this document."},
141
+ deadline_seconds=60,
142
+ accept_duplicate_provider_execution=True, # required: a missed deadline can execute the request twice
143
+ on_late_batch_settled=lambda info: None, # fires later, in the background, only for a losing batch
144
+ )
145
+
146
+ outcome.source # "batch" | "direct"
147
+ outcome.result # the provider response
148
+ outcome.metadata.batch_outcome # "completed" | "failed" | "expired" | "pending_at_deadline"
149
+ ```
150
+
151
+ `provider` is `"openai" | "anthropic" | "google"`, matching `wrap()`'s own explicit-provider option — never inferred from the client instance. A request with `stream=True` is rejected before any provider call. A request carrying `tools` is rejected unless `allow_duplicate_tool_call_plans=True` is also set, acknowledging that the batch result and the direct fallback are independent provider executions that may each choose a different tool-call plan. `accept_duplicate_provider_execution` must be exactly `True` — there is no default and no environment-variable override, and a missed deadline can execute (and bill) the same request twice. Neither the batch nor the direct path executes a tool call automatically; the caller receives the tool-call plan in `outcome.result` and is responsible for executing it, exactly as with a normal (non-batch-first) provider response.
152
+
153
+ `batch_first()` is not integrated with `wrap()`'s capture/telemetry pipeline; its result and metadata are returned directly to the caller, never enqueued for delivery. The background poll that watches a losing batch for late telemetry runs on a daemon thread, which does not keep the process alive — a short-lived script may exit before `on_late_batch_settled` ever fires, silently dropping that signal. This milestone's adapters call their provider client's methods synchronously; `AsyncOpenAI`, `AsyncAnthropic`, and google-genai's `.aio` namespace are not supported.
154
+
155
+ OpenAI, Anthropic, and Google Gemini all have adapters (`create_openai_batch_adapter`, `create_anthropic_batch_adapter`, `create_google_batch_adapter`), built against each provider's real SDK method signatures — verified by inspecting `openai`, `anthropic`, and `google-genai` as installed from this package's own dev extras, not by a live call — but **none has been exercised against a live provider Batch API from this SDK**. Treat this as a beta-quality, code-reviewed-but-not-live-verified surface.
156
+
120
157
  ## Set up with an AI coding agent
121
158
 
122
159
  Paste this into Claude Code, Codex, Cursor, or any coding agent inside the
@@ -133,7 +170,7 @@ and AI_GATEWAY_API_KEY / VERCEL_OIDC_TOKEN configuration unchanged.
133
170
  wrap() returns the same client and initializes itself from the environment:
134
171
  METERGRAPH_APP_TOKEN is required (capture is silently off without it) and
135
172
  METERGRAPH_INGEST_URL is only for self-hosted servers. Add both to
136
- .env.example, and never commit a real token. SDK 0.3 captures scrubbed provider
173
+ .env.example, and never commit a real token. SDK 0.4 captures scrubbed provider
137
174
  requests and normalized responses by default for the hosted dashboard; use
138
175
  METERGRAPH_CAPTURE_TEXT=0 or capture_text=False around sensitive operations.
139
176
  Provider credentials and transport headers must never be captured. Capture is
@@ -64,10 +64,18 @@ Configuration:
64
64
  - `METERGRAPH_DISABLED=1` — process kill switch
65
65
  - `METERGRAPH_QUEUE_SIZE`, `METERGRAPH_BATCH_SIZE`, `METERGRAPH_FLUSH_SECONDS`
66
66
 
67
+ SDK 0.4 associates traces with their GitHub repository automatically. On the
68
+ first `init()` in a Git checkout, it reads the `origin` remote and creates
69
+ `.metergraph/config.json` at the repository root if that file is absent.
70
+ Commit this non-secret file so production can use repository-aware ingest
71
+ without Git metadata. An existing file is authoritative and is never changed
72
+ by the SDK. If discovery or creation is unavailable, ingest remains compatible
73
+ with protocol v1.
74
+
67
75
  Delivery is bounded and off the request path. Queue overflow or a collector
68
76
  outage drops capture and increments internal counters; it never changes the
69
77
  provider call. Each wire batch is bounded to 512 KiB after optional gzip.
70
- SDK 0.3 captures the scrubbed provider request and a normalized response
78
+ SDK 0.4 captures the scrubbed provider request and a normalized response
71
79
  envelope, including assistant content and tool calls, by default. Provider
72
80
  credentials and transport headers are removed. Request and response are each
73
81
  limited to 100 KiB of UTF-8 with an explicit truncation marker.
@@ -96,6 +104,35 @@ message batches are captured per inference while iterating a wrapped
96
104
  rows carry real per-result usage and the batch pricing flag; job-management
97
105
  polls themselves are not miscounted as model calls.
98
106
 
107
+ ## Batch-first execution (opt-in)
108
+
109
+ `metergraph.batch_first()` is a separate, explicitly opt-in code path from `wrap()`/capture: submit one request through a provider's Batch API, wait up to a caller-chosen deadline, and fall back to exactly one direct call if the batch hasn't finished in time. It is synchronous/blocking, matching this SDK's own background-work model — a daemon thread, not asyncio.
110
+
111
+ ```python
112
+ import metergraph
113
+ from openai import OpenAI
114
+
115
+ client = OpenAI() # unwrapped — batch_first() drives it directly, not through wrap()
116
+
117
+ outcome = metergraph.batch_first(
118
+ client, "openai",
119
+ {"model": "gpt-5-mini", "input": "Summarize this document."},
120
+ deadline_seconds=60,
121
+ accept_duplicate_provider_execution=True, # required: a missed deadline can execute the request twice
122
+ on_late_batch_settled=lambda info: None, # fires later, in the background, only for a losing batch
123
+ )
124
+
125
+ outcome.source # "batch" | "direct"
126
+ outcome.result # the provider response
127
+ outcome.metadata.batch_outcome # "completed" | "failed" | "expired" | "pending_at_deadline"
128
+ ```
129
+
130
+ `provider` is `"openai" | "anthropic" | "google"`, matching `wrap()`'s own explicit-provider option — never inferred from the client instance. A request with `stream=True` is rejected before any provider call. A request carrying `tools` is rejected unless `allow_duplicate_tool_call_plans=True` is also set, acknowledging that the batch result and the direct fallback are independent provider executions that may each choose a different tool-call plan. `accept_duplicate_provider_execution` must be exactly `True` — there is no default and no environment-variable override, and a missed deadline can execute (and bill) the same request twice. Neither the batch nor the direct path executes a tool call automatically; the caller receives the tool-call plan in `outcome.result` and is responsible for executing it, exactly as with a normal (non-batch-first) provider response.
131
+
132
+ `batch_first()` is not integrated with `wrap()`'s capture/telemetry pipeline; its result and metadata are returned directly to the caller, never enqueued for delivery. The background poll that watches a losing batch for late telemetry runs on a daemon thread, which does not keep the process alive — a short-lived script may exit before `on_late_batch_settled` ever fires, silently dropping that signal. This milestone's adapters call their provider client's methods synchronously; `AsyncOpenAI`, `AsyncAnthropic`, and google-genai's `.aio` namespace are not supported.
133
+
134
+ OpenAI, Anthropic, and Google Gemini all have adapters (`create_openai_batch_adapter`, `create_anthropic_batch_adapter`, `create_google_batch_adapter`), built against each provider's real SDK method signatures — verified by inspecting `openai`, `anthropic`, and `google-genai` as installed from this package's own dev extras, not by a live call — but **none has been exercised against a live provider Batch API from this SDK**. Treat this as a beta-quality, code-reviewed-but-not-live-verified surface.
135
+
99
136
  ## Set up with an AI coding agent
100
137
 
101
138
  Paste this into Claude Code, Codex, Cursor, or any coding agent inside the
@@ -112,7 +149,7 @@ and AI_GATEWAY_API_KEY / VERCEL_OIDC_TOKEN configuration unchanged.
112
149
  wrap() returns the same client and initializes itself from the environment:
113
150
  METERGRAPH_APP_TOKEN is required (capture is silently off without it) and
114
151
  METERGRAPH_INGEST_URL is only for self-hosted servers. Add both to
115
- .env.example, and never commit a real token. SDK 0.3 captures scrubbed provider
152
+ .env.example, and never commit a real token. SDK 0.4 captures scrubbed provider
116
153
  requests and normalized responses by default for the hosted dashboard; use
117
154
  METERGRAPH_CAPTURE_TEXT=0 or capture_text=False around sensitive operations.
118
155
  Provider credentials and transport headers must never be captured. Capture is
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "metergraph"
3
- version = "0.3.2"
3
+ version = "0.5.0"
4
4
  description = "Fire-and-forget LLM spend capture for Metergraph"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -14,6 +14,15 @@ from ._capture import Options, Runtime, set_runtime
14
14
  from ._capture import wrap as _wrap
15
15
  from ._config import ConfigPoller
16
16
  from ._context import route, set_session, set_tags, snapshot, trace, wrap_executor
17
+ from ._batch_first import (
18
+ BatchFirstIneligibleError,
19
+ BatchFirstMetadata,
20
+ BatchFirstResult,
21
+ LateBatchInfo,
22
+ batch_first,
23
+ )
24
+ from ._repo_config import ensure_repo_config
25
+ from ._session import SessionManager
17
26
  from ._track import track
18
27
  from ._transport import Writer
19
28
  from ._version import SDK_VERSION
@@ -24,6 +33,7 @@ DEFAULT_INGEST_URL = "https://d2xus7mp8zdv6t.cloudfront.net"
24
33
  log = logging.getLogger("metergraph")
25
34
  _writer: Writer | None = None
26
35
  _config: ConfigPoller | None = None
36
+ _session_manager: SessionManager | None = None
27
37
  _initialized = False
28
38
  _warned_no_token = False
29
39
 
@@ -47,7 +57,7 @@ def init(
47
57
  disabled: bool | None = None,
48
58
  ) -> None:
49
59
  """Initialize capture. This function is idempotent and never raises."""
50
- global _initialized, _warned_no_token, _writer, _config
60
+ global _initialized, _warned_no_token, _writer, _config, _session_manager
51
61
  if _initialized:
52
62
  return
53
63
  if os.getenv("METERGRAPH_DISABLED") == "1" or disabled:
@@ -65,9 +75,23 @@ def init(
65
75
  return
66
76
  _initialized = True
67
77
  try:
78
+ app_root_resolved = os.path.realpath(app_root or os.getcwd())
79
+ repo_config = ensure_repo_config(app_root_resolved)
80
+ session = (
81
+ SessionManager(
82
+ token,
83
+ ingest_url,
84
+ repository=repo_config.repository,
85
+ sdk_version=SDK_VERSION,
86
+ )
87
+ if repo_config is not None
88
+ else None
89
+ )
90
+ _session_manager = session
68
91
  _writer = Writer(
69
92
  token,
70
93
  ingest_url,
94
+ session=session,
71
95
  queue_size=int(os.getenv("METERGRAPH_QUEUE_SIZE", "2000")),
72
96
  batch_size=int(os.getenv("METERGRAPH_BATCH_SIZE", "100")),
73
97
  flush_seconds=float(os.getenv("METERGRAPH_FLUSH_SECONDS", "5")),
@@ -79,7 +103,8 @@ def init(
79
103
  else capture_text
80
104
  ),
81
105
  redact=redact,
82
- app_root=os.path.realpath(app_root or os.getcwd()),
106
+ app_root=app_root_resolved,
107
+ repo_root=repo_config.repo_root if repo_config is not None else None,
83
108
  skip_frames=tuple(skip_frames or ()),
84
109
  environment=environment or os.getenv("METERGRAPH_ENV"),
85
110
  text_max_bytes=min(
@@ -110,6 +135,7 @@ def init(
110
135
  _writer.shutdown()
111
136
  _writer = None
112
137
  _config = None
138
+ _session_manager = None
113
139
  log.warning(
114
140
  "Metergraph initialization failed; application is running uninstrumented"
115
141
  )
@@ -214,18 +240,26 @@ def flush(timeout: float = 3.0) -> bool:
214
240
 
215
241
 
216
242
  def shutdown() -> None:
217
- global _writer, _config
243
+ global _writer, _config, _session_manager
218
244
  if _config:
219
245
  _config.stop()
220
246
  _config = None
221
247
  if _writer:
222
248
  _writer.shutdown()
223
249
  _writer = None
250
+ if _session_manager:
251
+ _session_manager.stop()
252
+ _session_manager = None
224
253
  set_runtime(None)
225
254
 
226
255
 
227
256
  __all__ = [
228
257
  "DEFAULT_INGEST_URL",
258
+ "BatchFirstIneligibleError",
259
+ "BatchFirstMetadata",
260
+ "BatchFirstResult",
261
+ "LateBatchInfo",
262
+ "batch_first",
229
263
  "flush",
230
264
  "init",
231
265
  "model_for",
@@ -0,0 +1,326 @@
1
+ """Explicit, opt-in batch-first execution: submit one request through a
2
+ provider's Batch API, wait a caller-selected deadline, and fall back to a
3
+ single direct call if the batch hasn't finished in time. Never enabled by
4
+ wrap()/capture defaults or an environment variable — a caller reaches this
5
+ only by importing and calling batch_first() directly, and only after
6
+ explicitly acknowledging the duplicate-execution semantic below.
7
+
8
+ On a missed deadline, the request may execute twice against the provider
9
+ (once via batch, once via direct) — an accepted, deliberate semantic,
10
+ never silently avoided. What this module guarantees instead: exactly one
11
+ direct fallback is ever issued, and a batch result that arrives after the
12
+ fallback already won is never returned, never executed, and never mutates
13
+ an already-returned result.
14
+
15
+ This module's own concurrency (background late-batch polling after a
16
+ deadline-triggered fallback) uses a daemon threading.Thread/Event, matching
17
+ the rest of this SDK's background work (see _transport.Writer,
18
+ _config.ConfigPoller) rather than asyncio — batch_first()/run_batch_first()
19
+ are synchronous, blocking calls, and the adapters in _provider_batch call
20
+ their client's methods directly (synchronously). Async provider clients
21
+ (AsyncOpenAI, AsyncAnthropic, google-genai's `.aio` namespace) are not
22
+ supported by this milestone's adapters — see the package's batch-adapter
23
+ notes for what needs live/gated verification before this is extended to
24
+ them.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import threading
30
+ import time
31
+ from dataclasses import dataclass
32
+ from typing import Any, Callable, Mapping
33
+
34
+ from ._provider_batch import (
35
+ ProviderBatchAdapter,
36
+ create_anthropic_batch_adapter,
37
+ create_google_batch_adapter,
38
+ create_openai_batch_adapter,
39
+ )
40
+
41
+
42
+ class BatchFirstIneligibleError(Exception):
43
+ """Raised before any provider call when a request/policy combination is
44
+ not eligible for batch-first execution — streaming, tools without
45
+ acknowledgement, a missing/false accept_duplicate_provider_execution,
46
+ a non-positive deadline, or an adapter-specific ineligibility."""
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class LateBatchInfo:
51
+ """Reported asynchronously, after run_batch_first()/batch_first() have
52
+ already returned via the direct fallback, if the losing batch
53
+ eventually reaches a terminal state. Its actual result content is
54
+ never included here and never returned — only whether it happened to
55
+ contain a tool-call plan, which may differ from the one the direct
56
+ fallback produced (see allow_duplicate_tool_call_plans)."""
57
+
58
+ outcome: str # "completed" | "failed" | "expired"
59
+ contained_tool_call_plan: bool
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class BatchFirstMetadata:
64
+ execution_mode: str
65
+ deadline_seconds: float
66
+ # Wall-clock time from submission to the canonical result settling.
67
+ batch_wait_seconds: float
68
+ # The batch's own status as of when run_batch_first()/batch_first()
69
+ # returned — not its eventual status if that differs (see
70
+ # LateBatchInfo).
71
+ batch_outcome: str # "completed" | "failed" | "expired" | "pending_at_deadline"
72
+ canonical_result: str # "batch" | "direct"
73
+ duplicate_provider_execution: bool
74
+ # Always False when canonical_result is "batch" (no lateness is
75
+ # possible — the batch IS the canonical result). When canonical_result
76
+ # is "direct", this reflects what was known AT THE MOMENT the call
77
+ # returned, which is always False: a completion confirmed later only
78
+ # reaches the caller through on_late_batch_settled, never by mutating
79
+ # this object.
80
+ late_batch_completed: bool
81
+ late_batch_contained_tool_call_plan: bool
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class BatchFirstResult:
86
+ source: str # "batch" | "direct"
87
+ result: Any
88
+ metadata: BatchFirstMetadata
89
+
90
+
91
+ class BatchFirstClock:
92
+ """Injectable wait primitive. Real time.monotonic()/threading.Event.wait
93
+ by default; tests can substitute a fake for deterministic control, with
94
+ no real waiting — the same purpose as the TypeScript SDK's injectable
95
+ BatchFirstClock, shaped around this SDK's own concurrency primitive
96
+ (threading.Event) rather than setTimeout/clearTimeout."""
97
+
98
+ def monotonic(self) -> float:
99
+ return time.monotonic()
100
+
101
+ def wait(self, event: threading.Event, timeout: float) -> bool:
102
+ return event.wait(timeout)
103
+
104
+
105
+ def _has_tools(request: Mapping[str, Any]) -> bool:
106
+ tools = request.get("tools")
107
+ return isinstance(tools, list) and len(tools) > 0
108
+
109
+
110
+ def _validate(
111
+ request: Mapping[str, Any],
112
+ *,
113
+ accept_duplicate_provider_execution: bool,
114
+ allow_duplicate_tool_call_plans: bool,
115
+ deadline_seconds: float,
116
+ ) -> None:
117
+ if accept_duplicate_provider_execution is not True:
118
+ raise BatchFirstIneligibleError(
119
+ "batch_first() requires accept_duplicate_provider_execution=True — a missed "
120
+ "deadline can execute the request twice against the provider"
121
+ )
122
+ if not isinstance(deadline_seconds, (int, float)) or isinstance(deadline_seconds, bool) or deadline_seconds <= 0:
123
+ raise BatchFirstIneligibleError("batch_first() requires a positive deadline_seconds")
124
+ if request.get("stream") is True:
125
+ raise BatchFirstIneligibleError(
126
+ "batch_first() does not support streaming requests — streaming is direct-only"
127
+ )
128
+ if _has_tools(request) and allow_duplicate_tool_call_plans is not True:
129
+ raise BatchFirstIneligibleError(
130
+ "batch_first() requires allow_duplicate_tool_call_plans=True for requests with "
131
+ "tools — the batch result and the direct fallback are independent provider "
132
+ "executions and may each choose a different tool call plan"
133
+ )
134
+
135
+
136
+ _DEFAULT_POLL_INTERVAL_SECONDS = 2.0
137
+ _TERMINAL_STATUSES = ("completed", "failed", "expired")
138
+
139
+
140
+ def run_batch_first(
141
+ adapter: ProviderBatchAdapter,
142
+ request: Mapping[str, Any],
143
+ *,
144
+ deadline_seconds: float,
145
+ accept_duplicate_provider_execution: bool,
146
+ allow_duplicate_tool_call_plans: bool = False,
147
+ poll_interval_seconds: float = _DEFAULT_POLL_INTERVAL_SECONDS,
148
+ clock: BatchFirstClock | None = None,
149
+ on_late_batch_settled: Callable[[LateBatchInfo], None] | None = None,
150
+ ) -> BatchFirstResult:
151
+ """The adapter-injected core state machine — importable directly so
152
+ fake-adapter, fake-clock tests can drive it, and used internally by the
153
+ public, provider-explicit batch_first() below."""
154
+ _validate(
155
+ request,
156
+ accept_duplicate_provider_execution=accept_duplicate_provider_execution,
157
+ allow_duplicate_tool_call_plans=allow_duplicate_tool_call_plans,
158
+ deadline_seconds=deadline_seconds,
159
+ )
160
+ eligibility = adapter.eligibility(request)
161
+ if not eligibility.eligible:
162
+ raise BatchFirstIneligibleError(
163
+ "request is not eligible for batch-first execution: "
164
+ f"{eligibility.reason or 'unsupported by this adapter'}"
165
+ )
166
+
167
+ clock = clock or BatchFirstClock()
168
+ started_at = clock.monotonic()
169
+
170
+ # Not wrapped: a submission failure means no batch was ever created,
171
+ # and is raised directly rather than treated as a fallback trigger.
172
+ handle = adapter.submit_one(request)
173
+
174
+ terminal_event = threading.Event()
175
+ stop_polling = threading.Event()
176
+ terminal_status: list[str | None] = [None]
177
+
178
+ def poll_loop() -> None:
179
+ try:
180
+ while not stop_polling.is_set():
181
+ outcome = adapter.poll(handle)
182
+ if outcome.status != "pending":
183
+ terminal_status[0] = outcome.status
184
+ terminal_event.set()
185
+ return
186
+ if clock.wait(stop_polling, poll_interval_seconds):
187
+ return
188
+ except Exception:
189
+ # An unexpected poll() failure never surfaces here — the
190
+ # deadline simply wins on its own, exactly as if the batch
191
+ # were still pending.
192
+ return
193
+
194
+ poll_thread = threading.Thread(target=poll_loop, daemon=True)
195
+ poll_thread.start()
196
+
197
+ batch_won = clock.wait(terminal_event, deadline_seconds)
198
+
199
+ # A batch reported "completed" but whose result cannot be read (a
200
+ # missing output file, an item-level provider error, a malformed or
201
+ # missing matching line, a transient read failure) is neither a valid
202
+ # canonical batch result nor grounds to raise out of run_batch_first()
203
+ # instead of the promised fallback — it is treated exactly like a
204
+ # batch that reported "failed": exactly one direct fallback, never a
205
+ # second read attempt, never surfaced as an exception.
206
+ unreadable_completed_batch = False
207
+
208
+ if batch_won and terminal_status[0] == "completed":
209
+ stop_polling.set()
210
+ try:
211
+ batch_result = adapter.read_result(handle)
212
+ return BatchFirstResult(
213
+ source="batch",
214
+ result=batch_result.result,
215
+ metadata=BatchFirstMetadata(
216
+ execution_mode="batch_first",
217
+ deadline_seconds=deadline_seconds,
218
+ batch_wait_seconds=clock.monotonic() - started_at,
219
+ batch_outcome="completed",
220
+ canonical_result="batch",
221
+ duplicate_provider_execution=False,
222
+ late_batch_completed=False,
223
+ late_batch_contained_tool_call_plan=False,
224
+ ),
225
+ )
226
+ except Exception:
227
+ unreadable_completed_batch = True
228
+
229
+ # Either the deadline fired first, the batch reached a non-completed
230
+ # terminal status before the deadline, or the batch completed but its
231
+ # result could not be read — either way, issue exactly one direct
232
+ # fallback now, and never wait further (or retry a read) on the batch
233
+ # for the canonical result. Only stop the poll loop when the batch
234
+ # itself already produced a terminal status (it has nothing left to
235
+ # do, so this is a no-op) — when the DEADLINE won, deliberately leave
236
+ # polling running in the background: "keep polling only to write
237
+ # terminal telemetry" requires the loop to keep going, not stop here.
238
+ batch_outcome_at_fallback = (
239
+ "failed"
240
+ if unreadable_completed_batch
241
+ else terminal_status[0]
242
+ if batch_won
243
+ else "pending_at_deadline"
244
+ )
245
+ if batch_won:
246
+ stop_polling.set()
247
+
248
+ if not batch_won:
249
+ def late_watcher() -> None:
250
+ # Observe the batch purely for telemetry — never read for its
251
+ # content to be returned, executed, or used to mutate the
252
+ # already-in-flight direct result.
253
+ poll_thread.join()
254
+ status = terminal_status[0]
255
+ if status not in _TERMINAL_STATUSES:
256
+ return
257
+ contained = False
258
+ if status == "completed":
259
+ try:
260
+ contained = adapter.read_result(handle).contained_tool_call_plan
261
+ except Exception:
262
+ pass # telemetry only — never raised, never surfaced
263
+ if on_late_batch_settled is not None:
264
+ try:
265
+ on_late_batch_settled(
266
+ LateBatchInfo(outcome=status, contained_tool_call_plan=contained)
267
+ )
268
+ except Exception:
269
+ pass # telemetry only
270
+
271
+ threading.Thread(target=late_watcher, daemon=True).start()
272
+
273
+ direct_result = adapter.direct(request)
274
+ return BatchFirstResult(
275
+ source="direct",
276
+ result=direct_result.result,
277
+ metadata=BatchFirstMetadata(
278
+ execution_mode="batch_first",
279
+ deadline_seconds=deadline_seconds,
280
+ batch_wait_seconds=clock.monotonic() - started_at,
281
+ batch_outcome=batch_outcome_at_fallback,
282
+ canonical_result="direct",
283
+ duplicate_provider_execution=True,
284
+ late_batch_completed=False,
285
+ late_batch_contained_tool_call_plan=False,
286
+ ),
287
+ )
288
+
289
+
290
+ _PROVIDER_FACTORIES: dict[str, Callable[[Any], ProviderBatchAdapter]] = {
291
+ "openai": create_openai_batch_adapter,
292
+ "anthropic": create_anthropic_batch_adapter,
293
+ "google": create_google_batch_adapter,
294
+ }
295
+
296
+
297
+ def _resolve_adapter(client: Any, provider: str) -> ProviderBatchAdapter:
298
+ factory = _PROVIDER_FACTORIES.get(provider)
299
+ if factory is None:
300
+ raise BatchFirstIneligibleError(f'batch_first() has no adapter for provider "{provider}" yet')
301
+ return factory(client)
302
+
303
+
304
+ def batch_first(
305
+ client: Any,
306
+ provider: str,
307
+ request: Mapping[str, Any],
308
+ *,
309
+ deadline_seconds: float,
310
+ accept_duplicate_provider_execution: bool,
311
+ allow_duplicate_tool_call_plans: bool = False,
312
+ on_late_batch_settled: Callable[[LateBatchInfo], None] | None = None,
313
+ ) -> BatchFirstResult:
314
+ """Explicit, provider-specific batch-first execution. Never inferred
315
+ from the client instance — the caller states the provider, matching
316
+ wrap()'s own explicit-provider option. Polling interval and time
317
+ source are test-only controls on run_batch_first(), not public here."""
318
+ adapter = _resolve_adapter(client, provider)
319
+ return run_batch_first(
320
+ adapter,
321
+ request,
322
+ deadline_seconds=deadline_seconds,
323
+ accept_duplicate_provider_execution=accept_duplicate_provider_execution,
324
+ allow_duplicate_tool_call_plans=allow_duplicate_tool_call_plans,
325
+ on_late_batch_settled=on_late_batch_settled,
326
+ )
@@ -556,10 +556,11 @@ def _tool_events(
556
556
 
557
557
 
558
558
  def _capture_frames(
559
- app_root: str, skip_frames: tuple[str, ...]
559
+ app_root: str, skip_frames: tuple[str, ...], repo_root: str | None = None
560
560
  ) -> tuple[str | None, str | None, list[dict]]:
561
561
  frames: list[dict] = []
562
562
  root = os.path.realpath(app_root)
563
+ repo_root_real = os.path.realpath(repo_root) if repo_root else None
563
564
  frame = sys._getframe(2)
564
565
  while frame is not None and len(frames) < 5:
565
566
  filename = os.path.realpath(frame.f_code.co_filename)
@@ -569,7 +570,19 @@ def _capture_frames(
569
570
  relative = os.path.relpath(filename, root)
570
571
  module = str(Path(relative).with_suffix("")).replace(os.sep, ".")
571
572
  qualname = getattr(frame.f_code, "co_qualname", frame.f_code.co_name)
572
- frames.append({"m": module, "f": qualname, "l": frame.f_lineno})
573
+ entry = {"m": module, "f": qualname, "l": frame.f_lineno}
574
+ try:
575
+ inside_repo = bool(
576
+ repo_root_real
577
+ and os.path.commonpath((filename, repo_root_real)) == repo_root_real
578
+ )
579
+ except ValueError:
580
+ inside_repo = False
581
+ if inside_repo:
582
+ entry["p"] = os.path.relpath(filename, repo_root_real).replace(
583
+ os.sep, "/"
584
+ )
585
+ frames.append(entry)
573
586
  frame = frame.f_back
574
587
  if not frames:
575
588
  return None, None, []
@@ -581,6 +594,7 @@ class Options:
581
594
  capture_text: bool = True
582
595
  redact: Callable[[str, str], str] | None = None
583
596
  app_root: str = os.getcwd()
597
+ repo_root: str | None = None
584
598
  skip_frames: tuple[str, ...] = ()
585
599
  environment: str | None = None
586
600
  text_max_bytes: int = 100 * 1024
@@ -609,6 +623,7 @@ class Runtime:
609
623
  "threading.py",
610
624
  *self.options.skip_frames,
611
625
  ),
626
+ self.options.repo_root,
612
627
  )
613
628
  return CallState(
614
629
  runtime=self,