metergraph 0.4.0__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.4.0 → metergraph-0.5.0}/PKG-INFO +30 -1
  2. {metergraph-0.4.0 → metergraph-0.5.0}/README.md +29 -0
  3. {metergraph-0.4.0 → metergraph-0.5.0}/pyproject.toml +1 -1
  4. {metergraph-0.4.0 → metergraph-0.5.0}/src/metergraph/__init__.py +12 -0
  5. metergraph-0.5.0/src/metergraph/_batch_first.py +326 -0
  6. metergraph-0.5.0/src/metergraph/_provider_batch.py +390 -0
  7. {metergraph-0.4.0 → metergraph-0.5.0}/src/metergraph/_version.py +1 -1
  8. {metergraph-0.4.0 → metergraph-0.5.0}/src/metergraph.egg-info/PKG-INFO +30 -1
  9. {metergraph-0.4.0 → metergraph-0.5.0}/src/metergraph.egg-info/SOURCES.txt +5 -0
  10. metergraph-0.5.0/tests/test_batch_first.py +595 -0
  11. metergraph-0.5.0/tests/test_provider_batch.py +604 -0
  12. metergraph-0.5.0/tests/test_public_api_surface.py +50 -0
  13. {metergraph-0.4.0 → metergraph-0.5.0}/setup.cfg +0 -0
  14. {metergraph-0.4.0 → metergraph-0.5.0}/src/metergraph/_capture.py +0 -0
  15. {metergraph-0.4.0 → metergraph-0.5.0}/src/metergraph/_config.py +0 -0
  16. {metergraph-0.4.0 → metergraph-0.5.0}/src/metergraph/_context.py +0 -0
  17. {metergraph-0.4.0 → metergraph-0.5.0}/src/metergraph/_failure_log.py +0 -0
  18. {metergraph-0.4.0 → metergraph-0.5.0}/src/metergraph/_repo_config.py +0 -0
  19. {metergraph-0.4.0 → metergraph-0.5.0}/src/metergraph/_session.py +0 -0
  20. {metergraph-0.4.0 → metergraph-0.5.0}/src/metergraph/_template.py +0 -0
  21. {metergraph-0.4.0 → metergraph-0.5.0}/src/metergraph/_track.py +0 -0
  22. {metergraph-0.4.0 → metergraph-0.5.0}/src/metergraph/_transport.py +0 -0
  23. {metergraph-0.4.0 → metergraph-0.5.0}/src/metergraph.egg-info/dependency_links.txt +0 -0
  24. {metergraph-0.4.0 → metergraph-0.5.0}/src/metergraph.egg-info/requires.txt +0 -0
  25. {metergraph-0.4.0 → metergraph-0.5.0}/src/metergraph.egg-info/top_level.txt +0 -0
  26. {metergraph-0.4.0 → metergraph-0.5.0}/tests/test_capture_repo_root.py +0 -0
  27. {metergraph-0.4.0 → metergraph-0.5.0}/tests/test_edge_cases.py +0 -0
  28. {metergraph-0.4.0 → metergraph-0.5.0}/tests/test_init_repo_aware.py +0 -0
  29. {metergraph-0.4.0 → metergraph-0.5.0}/tests/test_real_client_integration.py +0 -0
  30. {metergraph-0.4.0 → metergraph-0.5.0}/tests/test_repository_aware_ingest.py +0 -0
  31. {metergraph-0.4.0 → metergraph-0.5.0}/tests/test_sdk.py +0 -0
  32. {metergraph-0.4.0 → metergraph-0.5.0}/tests/test_seam_reality.py +0 -0
  33. {metergraph-0.4.0 → metergraph-0.5.0}/tests/test_session_manager.py +0 -0
  34. {metergraph-0.4.0 → metergraph-0.5.0}/tests/test_writer_session.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metergraph
3
- Version: 0.4.0
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
@@ -125,6 +125,35 @@ message batches are captured per inference while iterating a wrapped
125
125
  rows carry real per-result usage and the batch pricing flag; job-management
126
126
  polls themselves are not miscounted as model calls.
127
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
+
128
157
  ## Set up with an AI coding agent
129
158
 
130
159
  Paste this into Claude Code, Codex, Cursor, or any coding agent inside the
@@ -104,6 +104,35 @@ message batches are captured per inference while iterating a wrapped
104
104
  rows carry real per-result usage and the batch pricing flag; job-management
105
105
  polls themselves are not miscounted as model calls.
106
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
+
107
136
  ## Set up with an AI coding agent
108
137
 
109
138
  Paste this into Claude Code, Codex, Cursor, or any coding agent inside the
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "metergraph"
3
- version = "0.4.0"
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,13 @@ 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
+ )
17
24
  from ._repo_config import ensure_repo_config
18
25
  from ._session import SessionManager
19
26
  from ._track import track
@@ -248,6 +255,11 @@ def shutdown() -> None:
248
255
 
249
256
  __all__ = [
250
257
  "DEFAULT_INGEST_URL",
258
+ "BatchFirstIneligibleError",
259
+ "BatchFirstMetadata",
260
+ "BatchFirstResult",
261
+ "LateBatchInfo",
262
+ "batch_first",
251
263
  "flush",
252
264
  "init",
253
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
+ )