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.
- {metergraph-0.3.2 → metergraph-0.5.0}/PKG-INFO +40 -3
- {metergraph-0.3.2 → metergraph-0.5.0}/README.md +39 -2
- {metergraph-0.3.2 → metergraph-0.5.0}/pyproject.toml +1 -1
- {metergraph-0.3.2 → metergraph-0.5.0}/src/metergraph/__init__.py +37 -3
- metergraph-0.5.0/src/metergraph/_batch_first.py +326 -0
- {metergraph-0.3.2 → metergraph-0.5.0}/src/metergraph/_capture.py +17 -2
- metergraph-0.5.0/src/metergraph/_provider_batch.py +390 -0
- metergraph-0.5.0/src/metergraph/_repo_config.py +165 -0
- metergraph-0.5.0/src/metergraph/_session.py +156 -0
- {metergraph-0.3.2 → metergraph-0.5.0}/src/metergraph/_transport.py +11 -1
- {metergraph-0.3.2 → metergraph-0.5.0}/src/metergraph/_version.py +1 -1
- {metergraph-0.3.2 → metergraph-0.5.0}/src/metergraph.egg-info/PKG-INFO +40 -3
- {metergraph-0.3.2 → metergraph-0.5.0}/src/metergraph.egg-info/SOURCES.txt +13 -1
- metergraph-0.5.0/tests/test_batch_first.py +595 -0
- metergraph-0.5.0/tests/test_capture_repo_root.py +101 -0
- metergraph-0.5.0/tests/test_init_repo_aware.py +110 -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.5.0/tests/test_repository_aware_ingest.py +221 -0
- metergraph-0.5.0/tests/test_session_manager.py +319 -0
- metergraph-0.5.0/tests/test_writer_session.py +142 -0
- {metergraph-0.3.2 → metergraph-0.5.0}/setup.cfg +0 -0
- {metergraph-0.3.2 → metergraph-0.5.0}/src/metergraph/_config.py +0 -0
- {metergraph-0.3.2 → metergraph-0.5.0}/src/metergraph/_context.py +0 -0
- {metergraph-0.3.2 → metergraph-0.5.0}/src/metergraph/_failure_log.py +0 -0
- {metergraph-0.3.2 → metergraph-0.5.0}/src/metergraph/_template.py +0 -0
- {metergraph-0.3.2 → metergraph-0.5.0}/src/metergraph/_track.py +0 -0
- {metergraph-0.3.2 → metergraph-0.5.0}/src/metergraph.egg-info/dependency_links.txt +0 -0
- {metergraph-0.3.2 → metergraph-0.5.0}/src/metergraph.egg-info/requires.txt +0 -0
- {metergraph-0.3.2 → metergraph-0.5.0}/src/metergraph.egg-info/top_level.txt +0 -0
- {metergraph-0.3.2 → metergraph-0.5.0}/tests/test_edge_cases.py +0 -0
- {metergraph-0.3.2 → metergraph-0.5.0}/tests/test_real_client_integration.py +0 -0
- {metergraph-0.3.2 → metergraph-0.5.0}/tests/test_sdk.py +0 -0
- {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
|
+
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
@@ -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=
|
|
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
|
-
|
|
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,
|