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.
- {metergraph-0.4.0 → metergraph-0.5.0}/PKG-INFO +30 -1
- {metergraph-0.4.0 → metergraph-0.5.0}/README.md +29 -0
- {metergraph-0.4.0 → metergraph-0.5.0}/pyproject.toml +1 -1
- {metergraph-0.4.0 → metergraph-0.5.0}/src/metergraph/__init__.py +12 -0
- metergraph-0.5.0/src/metergraph/_batch_first.py +326 -0
- metergraph-0.5.0/src/metergraph/_provider_batch.py +390 -0
- {metergraph-0.4.0 → metergraph-0.5.0}/src/metergraph/_version.py +1 -1
- {metergraph-0.4.0 → metergraph-0.5.0}/src/metergraph.egg-info/PKG-INFO +30 -1
- {metergraph-0.4.0 → metergraph-0.5.0}/src/metergraph.egg-info/SOURCES.txt +5 -0
- metergraph-0.5.0/tests/test_batch_first.py +595 -0
- metergraph-0.5.0/tests/test_provider_batch.py +604 -0
- metergraph-0.5.0/tests/test_public_api_surface.py +50 -0
- {metergraph-0.4.0 → metergraph-0.5.0}/setup.cfg +0 -0
- {metergraph-0.4.0 → metergraph-0.5.0}/src/metergraph/_capture.py +0 -0
- {metergraph-0.4.0 → metergraph-0.5.0}/src/metergraph/_config.py +0 -0
- {metergraph-0.4.0 → metergraph-0.5.0}/src/metergraph/_context.py +0 -0
- {metergraph-0.4.0 → metergraph-0.5.0}/src/metergraph/_failure_log.py +0 -0
- {metergraph-0.4.0 → metergraph-0.5.0}/src/metergraph/_repo_config.py +0 -0
- {metergraph-0.4.0 → metergraph-0.5.0}/src/metergraph/_session.py +0 -0
- {metergraph-0.4.0 → metergraph-0.5.0}/src/metergraph/_template.py +0 -0
- {metergraph-0.4.0 → metergraph-0.5.0}/src/metergraph/_track.py +0 -0
- {metergraph-0.4.0 → metergraph-0.5.0}/src/metergraph/_transport.py +0 -0
- {metergraph-0.4.0 → metergraph-0.5.0}/src/metergraph.egg-info/dependency_links.txt +0 -0
- {metergraph-0.4.0 → metergraph-0.5.0}/src/metergraph.egg-info/requires.txt +0 -0
- {metergraph-0.4.0 → metergraph-0.5.0}/src/metergraph.egg-info/top_level.txt +0 -0
- {metergraph-0.4.0 → metergraph-0.5.0}/tests/test_capture_repo_root.py +0 -0
- {metergraph-0.4.0 → metergraph-0.5.0}/tests/test_edge_cases.py +0 -0
- {metergraph-0.4.0 → metergraph-0.5.0}/tests/test_init_repo_aware.py +0 -0
- {metergraph-0.4.0 → metergraph-0.5.0}/tests/test_real_client_integration.py +0 -0
- {metergraph-0.4.0 → metergraph-0.5.0}/tests/test_repository_aware_ingest.py +0 -0
- {metergraph-0.4.0 → metergraph-0.5.0}/tests/test_sdk.py +0 -0
- {metergraph-0.4.0 → metergraph-0.5.0}/tests/test_seam_reality.py +0 -0
- {metergraph-0.4.0 → metergraph-0.5.0}/tests/test_session_manager.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
+
)
|