metergraph 0.4.0__tar.gz → 0.6.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 (32) hide show
  1. metergraph-0.6.0/MANIFEST.in +1 -0
  2. {metergraph-0.4.0 → metergraph-0.6.0}/PKG-INFO +64 -18
  3. {metergraph-0.4.0 → metergraph-0.6.0}/README.md +62 -17
  4. {metergraph-0.4.0 → metergraph-0.6.0}/pyproject.toml +2 -2
  5. {metergraph-0.4.0 → metergraph-0.6.0}/src/metergraph/__init__.py +47 -5
  6. metergraph-0.6.0/src/metergraph/_batch_first.py +326 -0
  7. metergraph-0.6.0/src/metergraph/_provider_batch.py +390 -0
  8. {metergraph-0.4.0 → metergraph-0.6.0}/src/metergraph/_repo_config.py +4 -4
  9. {metergraph-0.4.0 → metergraph-0.6.0}/src/metergraph/_session.py +1 -1
  10. {metergraph-0.4.0 → metergraph-0.6.0}/src/metergraph/_version.py +1 -1
  11. {metergraph-0.4.0 → metergraph-0.6.0}/src/metergraph.egg-info/PKG-INFO +64 -18
  12. {metergraph-0.4.0 → metergraph-0.6.0}/src/metergraph.egg-info/SOURCES.txt +4 -10
  13. {metergraph-0.4.0 → metergraph-0.6.0}/src/metergraph.egg-info/requires.txt +1 -0
  14. metergraph-0.4.0/tests/test_capture_repo_root.py +0 -101
  15. metergraph-0.4.0/tests/test_edge_cases.py +0 -556
  16. metergraph-0.4.0/tests/test_init_repo_aware.py +0 -110
  17. metergraph-0.4.0/tests/test_real_client_integration.py +0 -327
  18. metergraph-0.4.0/tests/test_repository_aware_ingest.py +0 -221
  19. metergraph-0.4.0/tests/test_sdk.py +0 -1465
  20. metergraph-0.4.0/tests/test_seam_reality.py +0 -35
  21. metergraph-0.4.0/tests/test_session_manager.py +0 -319
  22. metergraph-0.4.0/tests/test_writer_session.py +0 -142
  23. {metergraph-0.4.0 → metergraph-0.6.0}/setup.cfg +0 -0
  24. {metergraph-0.4.0 → metergraph-0.6.0}/src/metergraph/_capture.py +0 -0
  25. {metergraph-0.4.0 → metergraph-0.6.0}/src/metergraph/_config.py +0 -0
  26. {metergraph-0.4.0 → metergraph-0.6.0}/src/metergraph/_context.py +0 -0
  27. {metergraph-0.4.0 → metergraph-0.6.0}/src/metergraph/_failure_log.py +0 -0
  28. {metergraph-0.4.0 → metergraph-0.6.0}/src/metergraph/_template.py +0 -0
  29. {metergraph-0.4.0 → metergraph-0.6.0}/src/metergraph/_track.py +0 -0
  30. {metergraph-0.4.0 → metergraph-0.6.0}/src/metergraph/_transport.py +0 -0
  31. {metergraph-0.4.0 → metergraph-0.6.0}/src/metergraph.egg-info/dependency_links.txt +0 -0
  32. {metergraph-0.4.0 → metergraph-0.6.0}/src/metergraph.egg-info/top_level.txt +0 -0
@@ -0,0 +1 @@
1
+ prune tests
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metergraph
3
- Version: 0.4.0
3
+ Version: 0.6.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
@@ -14,6 +14,7 @@ Classifier: Typing :: Typed
14
14
  Requires-Python: >=3.10
15
15
  Description-Content-Type: text/markdown
16
16
  Provides-Extra: dev
17
+ Requires-Dist: build>=1; extra == "dev"
17
18
  Requires-Dist: pytest>=8; extra == "dev"
18
19
  Requires-Dist: openai>=2.50.0; extra == "dev"
19
20
  Requires-Dist: anthropic>=0.40; extra == "dev"
@@ -23,14 +24,18 @@ Requires-Dist: google-genai>=1; extra == "dev"
23
24
 
24
25
  Zero-runtime-dependency capture for OpenAI, Anthropic, Gemini, and Python
25
26
  Vercel AI Gateway clients.
26
- `wrap()` initializes capture from the environment, so setup is one line per
27
- client; call `metergraph.init(...)` before the first `wrap()` only to pass
28
- options in code.
27
+ Initialize Metergraph once, then wrap each provider client. `init()` reads the
28
+ token and other omitted options from the environment.
29
+
30
+ Initialization is process-wide. The first `init()` configuration remains
31
+ active; later explicit calls are ignored and produce one generic warning
32
+ without option names, token values, or other secrets.
29
33
 
30
34
  ```python
31
35
  import metergraph
32
36
  from openai import OpenAI
33
37
 
38
+ metergraph.init(repository="owner/repository")
34
39
  # Anthropic() and google-genai's genai.Client() wrap the same way.
35
40
  client = metergraph.wrap(OpenAI())
36
41
  metergraph.set_session("ticket-123")
@@ -61,6 +66,7 @@ import os
61
66
  import metergraph
62
67
  from openai import OpenAI
63
68
 
69
+ metergraph.init(repository="owner/repository")
64
70
  gateway = metergraph.wrap(OpenAI(
65
71
  api_key=os.getenv("AI_GATEWAY_API_KEY") or os.getenv("VERCEL_OIDC_TOKEN"),
66
72
  base_url="https://ai-gateway.vercel.sh/v1",
@@ -81,23 +87,32 @@ Configuration:
81
87
 
82
88
  - `METERGRAPH_APP_TOKEN` — required bearer token
83
89
  - `METERGRAPH_INGEST_URL` — optional override; defaults to the hosted HTTPS endpoint
90
+ - `METERGRAPH_REPOSITORY` — optional `owner/repository` identity; used by [MeterGraph Bot](https://github.com/apps/metergraph)
84
91
  - `METERGRAPH_CAPTURE_TEXT=0` — opt out of content capture globally
85
92
  - `METERGRAPH_DISABLED=1` — process kill switch
86
93
  - `METERGRAPH_QUEUE_SIZE`, `METERGRAPH_BATCH_SIZE`, `METERGRAPH_FLUSH_SECONDS`
87
94
 
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
+ Repository identity enables repository-level attribution and
96
+ [MeterGraph Bot](https://github.com/apps/metergraph). Choose any one of these
97
+ options; each is sufficient on its own:
98
+
99
+ - Pass `repository="owner/repository"` to `metergraph.init()`.
100
+ - Set `METERGRAPH_REPOSITORY=owner/repository` in the environment.
101
+ - Store the identity with the source code in `.metergraph/config.json`:
102
+
103
+ ```json
104
+ {"repository":"owner/repository"}
105
+ ```
106
+
107
+ Resolution order is the explicit option, environment variable, then
108
+ configuration file. The SDK treats the file as read-only. Without repository
109
+ identity, it warns once and continues capture without repository attribution.
95
110
 
96
111
  Delivery is bounded and off the request path. Queue overflow or a collector
97
112
  outage drops capture and increments internal counters; it never changes the
98
113
  provider call. Each wire batch is bounded to 512 KiB after optional gzip.
99
- SDK 0.4 captures the scrubbed provider request and a normalized response
100
- envelope, including assistant content and tool calls, by default. Provider
114
+ By default, Metergraph captures the scrubbed provider request and a normalized
115
+ response envelope, including assistant content and tool calls. Provider
101
116
  credentials and transport headers are removed. Request and response are each
102
117
  limited to 100 KiB of UTF-8 with an explicit truncation marker.
103
118
  `capture_text=False` on `route()` or `trace()` overrides the global content
@@ -125,6 +140,35 @@ message batches are captured per inference while iterating a wrapped
125
140
  rows carry real per-result usage and the batch pricing flag; job-management
126
141
  polls themselves are not miscounted as model calls.
127
142
 
143
+ ## Batch-first execution (opt-in)
144
+
145
+ `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.
146
+
147
+ ```python
148
+ import metergraph
149
+ from openai import OpenAI
150
+
151
+ client = OpenAI() # unwrapped — batch_first() drives it directly, not through wrap()
152
+
153
+ outcome = metergraph.batch_first(
154
+ client, "openai",
155
+ {"model": "gpt-5-mini", "input": "Summarize this document."},
156
+ deadline_seconds=60,
157
+ accept_duplicate_provider_execution=True, # required: a missed deadline can execute the request twice
158
+ on_late_batch_settled=lambda info: None, # fires later, in the background, only for a losing batch
159
+ )
160
+
161
+ outcome.source # "batch" | "direct"
162
+ outcome.result # the provider response
163
+ outcome.metadata.batch_outcome # "completed" | "failed" | "expired" | "pending_at_deadline"
164
+ ```
165
+
166
+ `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.
167
+
168
+ `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.
169
+
170
+ 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.
171
+
128
172
  ## Set up with an AI coding agent
129
173
 
130
174
  Paste this into Claude Code, Codex, Cursor, or any coding agent inside the
@@ -138,11 +182,13 @@ genai.Client() construction in place, e.g. client = metergraph.wrap(OpenAI()).
138
182
  OpenAI or Anthropic clients pointed at https://ai-gateway.vercel.sh are Vercel
139
183
  AI Gateway clients and are detected automatically; keep their creator/model ID
140
184
  and AI_GATEWAY_API_KEY / VERCEL_OIDC_TOKEN configuration unchanged.
141
- wrap() returns the same client and initializes itself from the environment:
142
- METERGRAPH_APP_TOKEN is required (capture is silently off without it) and
143
- METERGRAPH_INGEST_URL is only for self-hosted servers. Add both to
144
- .env.example, and never commit a real token. SDK 0.4 captures scrubbed provider
145
- requests and normalized responses by default for the hosted dashboard; use
185
+ Before wrapping, call metergraph.init(repository="owner/repository") using the
186
+ actual GitHub owner and repository name. METERGRAPH_APP_TOKEN is required; the
187
+ SDK warns and disables capture when it is missing. METERGRAPH_INGEST_URL is
188
+ only for self-hosted servers. Document variable names with placeholders in
189
+ .env.example, and put real values only in deployment configuration. Metergraph
190
+ captures scrubbed provider requests and normalized responses by default for
191
+ the hosted dashboard; use
146
192
  METERGRAPH_CAPTURE_TEXT=0 or capture_text=False around sensitive operations.
147
193
  Provider credentials and transport headers must never be captured. Capture is
148
194
  fail-open, so do not change call sites, arguments, or error handling; sync,
@@ -2,14 +2,18 @@
2
2
 
3
3
  Zero-runtime-dependency capture for OpenAI, Anthropic, Gemini, and Python
4
4
  Vercel AI Gateway clients.
5
- `wrap()` initializes capture from the environment, so setup is one line per
6
- client; call `metergraph.init(...)` before the first `wrap()` only to pass
7
- options in code.
5
+ Initialize Metergraph once, then wrap each provider client. `init()` reads the
6
+ token and other omitted options from the environment.
7
+
8
+ Initialization is process-wide. The first `init()` configuration remains
9
+ active; later explicit calls are ignored and produce one generic warning
10
+ without option names, token values, or other secrets.
8
11
 
9
12
  ```python
10
13
  import metergraph
11
14
  from openai import OpenAI
12
15
 
16
+ metergraph.init(repository="owner/repository")
13
17
  # Anthropic() and google-genai's genai.Client() wrap the same way.
14
18
  client = metergraph.wrap(OpenAI())
15
19
  metergraph.set_session("ticket-123")
@@ -40,6 +44,7 @@ import os
40
44
  import metergraph
41
45
  from openai import OpenAI
42
46
 
47
+ metergraph.init(repository="owner/repository")
43
48
  gateway = metergraph.wrap(OpenAI(
44
49
  api_key=os.getenv("AI_GATEWAY_API_KEY") or os.getenv("VERCEL_OIDC_TOKEN"),
45
50
  base_url="https://ai-gateway.vercel.sh/v1",
@@ -60,23 +65,32 @@ Configuration:
60
65
 
61
66
  - `METERGRAPH_APP_TOKEN` — required bearer token
62
67
  - `METERGRAPH_INGEST_URL` — optional override; defaults to the hosted HTTPS endpoint
68
+ - `METERGRAPH_REPOSITORY` — optional `owner/repository` identity; used by [MeterGraph Bot](https://github.com/apps/metergraph)
63
69
  - `METERGRAPH_CAPTURE_TEXT=0` — opt out of content capture globally
64
70
  - `METERGRAPH_DISABLED=1` — process kill switch
65
71
  - `METERGRAPH_QUEUE_SIZE`, `METERGRAPH_BATCH_SIZE`, `METERGRAPH_FLUSH_SECONDS`
66
72
 
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.
73
+ Repository identity enables repository-level attribution and
74
+ [MeterGraph Bot](https://github.com/apps/metergraph). Choose any one of these
75
+ options; each is sufficient on its own:
76
+
77
+ - Pass `repository="owner/repository"` to `metergraph.init()`.
78
+ - Set `METERGRAPH_REPOSITORY=owner/repository` in the environment.
79
+ - Store the identity with the source code in `.metergraph/config.json`:
80
+
81
+ ```json
82
+ {"repository":"owner/repository"}
83
+ ```
84
+
85
+ Resolution order is the explicit option, environment variable, then
86
+ configuration file. The SDK treats the file as read-only. Without repository
87
+ identity, it warns once and continues capture without repository attribution.
74
88
 
75
89
  Delivery is bounded and off the request path. Queue overflow or a collector
76
90
  outage drops capture and increments internal counters; it never changes the
77
91
  provider call. Each wire batch is bounded to 512 KiB after optional gzip.
78
- SDK 0.4 captures the scrubbed provider request and a normalized response
79
- envelope, including assistant content and tool calls, by default. Provider
92
+ By default, Metergraph captures the scrubbed provider request and a normalized
93
+ response envelope, including assistant content and tool calls. Provider
80
94
  credentials and transport headers are removed. Request and response are each
81
95
  limited to 100 KiB of UTF-8 with an explicit truncation marker.
82
96
  `capture_text=False` on `route()` or `trace()` overrides the global content
@@ -104,6 +118,35 @@ message batches are captured per inference while iterating a wrapped
104
118
  rows carry real per-result usage and the batch pricing flag; job-management
105
119
  polls themselves are not miscounted as model calls.
106
120
 
121
+ ## Batch-first execution (opt-in)
122
+
123
+ `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.
124
+
125
+ ```python
126
+ import metergraph
127
+ from openai import OpenAI
128
+
129
+ client = OpenAI() # unwrapped — batch_first() drives it directly, not through wrap()
130
+
131
+ outcome = metergraph.batch_first(
132
+ client, "openai",
133
+ {"model": "gpt-5-mini", "input": "Summarize this document."},
134
+ deadline_seconds=60,
135
+ accept_duplicate_provider_execution=True, # required: a missed deadline can execute the request twice
136
+ on_late_batch_settled=lambda info: None, # fires later, in the background, only for a losing batch
137
+ )
138
+
139
+ outcome.source # "batch" | "direct"
140
+ outcome.result # the provider response
141
+ outcome.metadata.batch_outcome # "completed" | "failed" | "expired" | "pending_at_deadline"
142
+ ```
143
+
144
+ `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.
145
+
146
+ `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.
147
+
148
+ 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.
149
+
107
150
  ## Set up with an AI coding agent
108
151
 
109
152
  Paste this into Claude Code, Codex, Cursor, or any coding agent inside the
@@ -117,11 +160,13 @@ genai.Client() construction in place, e.g. client = metergraph.wrap(OpenAI()).
117
160
  OpenAI or Anthropic clients pointed at https://ai-gateway.vercel.sh are Vercel
118
161
  AI Gateway clients and are detected automatically; keep their creator/model ID
119
162
  and AI_GATEWAY_API_KEY / VERCEL_OIDC_TOKEN configuration unchanged.
120
- wrap() returns the same client and initializes itself from the environment:
121
- METERGRAPH_APP_TOKEN is required (capture is silently off without it) and
122
- METERGRAPH_INGEST_URL is only for self-hosted servers. Add both to
123
- .env.example, and never commit a real token. SDK 0.4 captures scrubbed provider
124
- requests and normalized responses by default for the hosted dashboard; use
163
+ Before wrapping, call metergraph.init(repository="owner/repository") using the
164
+ actual GitHub owner and repository name. METERGRAPH_APP_TOKEN is required; the
165
+ SDK warns and disables capture when it is missing. METERGRAPH_INGEST_URL is
166
+ only for self-hosted servers. Document variable names with placeholders in
167
+ .env.example, and put real values only in deployment configuration. Metergraph
168
+ captures scrubbed provider requests and normalized responses by default for
169
+ the hosted dashboard; use
125
170
  METERGRAPH_CAPTURE_TEXT=0 or capture_text=False around sensitive operations.
126
171
  Provider credentials and transport headers must never be captured. Capture is
127
172
  fail-open, so do not change call sites, arguments, or error handling; sync,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "metergraph"
3
- version = "0.4.0"
3
+ version = "0.6.0"
4
4
  description = "Fire-and-forget LLM spend capture for Metergraph"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -20,7 +20,7 @@ Repository = "https://github.com/PioneerSquareLabs/metergraphsdk"
20
20
  Issues = "https://github.com/PioneerSquareLabs/metergraphsdk/issues"
21
21
 
22
22
  [project.optional-dependencies]
23
- dev = ["pytest>=8", "openai>=2.50.0", "anthropic>=0.40", "google-genai>=1"]
23
+ dev = ["build>=1", "pytest>=8", "openai>=2.50.0", "anthropic>=0.40", "google-genai>=1"]
24
24
 
25
25
  [build-system]
26
26
  requires = ["setuptools>=68"]
@@ -14,7 +14,14 @@ 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 ._repo_config import ensure_repo_config
17
+ from ._batch_first import (
18
+ BatchFirstIneligibleError,
19
+ BatchFirstMetadata,
20
+ BatchFirstResult,
21
+ LateBatchInfo,
22
+ batch_first,
23
+ )
24
+ from ._repo_config import RepoConfig, discover_repo_config
18
25
  from ._session import SessionManager
19
26
  from ._track import track
20
27
  from ._transport import Writer
@@ -29,6 +36,8 @@ _config: ConfigPoller | None = None
29
36
  _session_manager: SessionManager | None = None
30
37
  _initialized = False
31
38
  _warned_no_token = False
39
+ _warned_no_repository = False
40
+ _warned_repeated_init = False
32
41
 
33
42
 
34
43
  def _env_bool(name: str, default: bool) -> bool:
@@ -38,6 +47,17 @@ def _env_bool(name: str, default: bool) -> bool:
38
47
  return value.lower() not in {"0", "false", "no", "off"}
39
48
 
40
49
 
50
+ def _warn_repeated_init() -> None:
51
+ global _warned_repeated_init
52
+ if _warned_repeated_init:
53
+ return
54
+ _warned_repeated_init = True
55
+ log.warning(
56
+ "Metergraph init() was called more than once; "
57
+ "the first configuration remains active."
58
+ )
59
+
60
+
41
61
  def init(
42
62
  *,
43
63
  token: str | None = None,
@@ -45,13 +65,16 @@ def init(
45
65
  capture_text: bool | None = None,
46
66
  redact: Callable[[str, str], str] | None = None,
47
67
  app_root: str | None = None,
68
+ repository: str | None = None,
48
69
  skip_frames: list[str] | None = None,
49
70
  environment: str | None = None,
50
71
  disabled: bool | None = None,
51
72
  ) -> None:
52
73
  """Initialize capture. This function is idempotent and never raises."""
53
- global _initialized, _warned_no_token, _writer, _config, _session_manager
74
+ global _initialized, _warned_no_token, _warned_no_repository
75
+ global _writer, _config, _session_manager
54
76
  if _initialized:
77
+ _warn_repeated_init()
55
78
  return
56
79
  if os.getenv("METERGRAPH_DISABLED") == "1" or disabled:
57
80
  _initialized = True
@@ -66,10 +89,23 @@ def init(
66
89
  "Metergraph capture disabled: token and ingest URL are required"
67
90
  )
68
91
  return
69
- _initialized = True
70
92
  try:
71
93
  app_root_resolved = os.path.realpath(app_root or os.getcwd())
72
- repo_config = ensure_repo_config(app_root_resolved)
94
+ repository_value = repository or os.getenv("METERGRAPH_REPOSITORY")
95
+ repo_config = (
96
+ RepoConfig(repository_value.strip(), app_root_resolved)
97
+ if isinstance(repository_value, str)
98
+ and "/" in repository_value.strip()
99
+ else discover_repo_config(app_root_resolved)
100
+ )
101
+ _initialized = True
102
+ if repo_config is None and not _warned_no_repository:
103
+ _warned_no_repository = True
104
+ log.warning(
105
+ "Metergraph repository identity is not configured; set "
106
+ "init(repository='owner/repository'), METERGRAPH_REPOSITORY, "
107
+ "or provide .metergraph/config.json. Continuing with legacy ingestion."
108
+ )
73
109
  session = (
74
110
  SessionManager(
75
111
  token,
@@ -143,7 +179,8 @@ def wrap(client: Any, *, provider: str | None = None) -> Any:
143
179
  force gateway handling for a compatible client with a custom URL. Call
144
180
  init(...) first to pass Metergraph options in code.
145
181
  """
146
- init()
182
+ if not _initialized:
183
+ init()
147
184
  return _wrap(client, provider=provider)
148
185
 
149
186
 
@@ -248,6 +285,11 @@ def shutdown() -> None:
248
285
 
249
286
  __all__ = [
250
287
  "DEFAULT_INGEST_URL",
288
+ "BatchFirstIneligibleError",
289
+ "BatchFirstMetadata",
290
+ "BatchFirstResult",
291
+ "LateBatchInfo",
292
+ "batch_first",
251
293
  "flush",
252
294
  "init",
253
295
  "model_for",