meterspw-sdk 0.2.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.
@@ -0,0 +1,7 @@
1
+ dist/
2
+ .venv/
3
+ .pytest_cache/
4
+ __pycache__/
5
+ *.egg-info/
6
+ .mypy_cache/
7
+ .ruff_cache/
@@ -0,0 +1,23 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 S&T Integrated Solutions, LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ https://scitechsolutions.ai
@@ -0,0 +1,201 @@
1
+ Metadata-Version: 2.5
2
+ Name: meterspw-sdk
3
+ Version: 0.2.0
4
+ Summary: Meter-SPW-v1.0 — deterministic LLM routing with metered savings (BYOK)
5
+ Project-URL: Homepage, https://spw.scitechsolutions.ai
6
+ Author-email: "S&T Integrated Solutions, LLC" <support@scitechsolutions.ai>
7
+ License: MIT License
8
+
9
+ Copyright (c) 2026 S&T Integrated Solutions, LLC
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
28
+
29
+ https://scitechsolutions.ai
30
+ License-File: LICENSE
31
+ Keywords: anthropic,byok,cost,llm,openai,router
32
+ Classifier: Development Status :: 4 - Beta
33
+ Classifier: Intended Audience :: Developers
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Programming Language :: Python :: 3.10
36
+ Classifier: Programming Language :: Python :: 3.11
37
+ Classifier: Programming Language :: Python :: 3.12
38
+ Classifier: Typing :: Typed
39
+ Requires-Python: >=3.10
40
+ Provides-Extra: async
41
+ Requires-Dist: httpx>=0.24; extra == 'async'
42
+ Provides-Extra: dev
43
+ Requires-Dist: httpx>=0.24; extra == 'dev'
44
+ Requires-Dist: mypy>=1.8; extra == 'dev'
45
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
46
+ Requires-Dist: pytest>=7; extra == 'dev'
47
+ Requires-Dist: ruff>=0.5; extra == 'dev'
48
+ Description-Content-Type: text/markdown
49
+
50
+ # meterspw-sdk
51
+
52
+ Python client for **Meter-SPW** — deterministic LLM routing with metered savings.
53
+
54
+ You bring your own provider keys. Meter-SPW routes each prompt to the cheapest
55
+ model that can answer it, caches what repeats, and meters what it saved.
56
+
57
+ ```bash
58
+ pip install meterspw-sdk
59
+ ```
60
+
61
+ Zero runtime dependencies — the default transport is `urllib` from the standard
62
+ library.
63
+
64
+ ## Use
65
+
66
+ ```python
67
+ from meterspw import MeterSpwClient
68
+
69
+ with MeterSpwClient() as spw: # key from ROUTER_API_KEY
70
+ out = spw.relay(
71
+ "Summarise this contract in three bullets.",
72
+ model="claude-sonnet-5",
73
+ max_tokens=2000,
74
+ )
75
+ print(out.content)
76
+ print(out.model_used) # what ACTUALLY answered
77
+ print(out.savings.usd) # what that saved you
78
+ ```
79
+
80
+ `model` is what you want; `model_used` is what answered. The difference is the
81
+ product.
82
+
83
+ ### Attachments
84
+
85
+ ```python
86
+ out = spw.relay_multipart(
87
+ "Summarise the attached report.",
88
+ model="claude-sonnet-5",
89
+ max_tokens=2000,
90
+ files=["report.pdf"],
91
+ )
92
+ ```
93
+
94
+ ### Know where you stand
95
+
96
+ ```python
97
+ b = spw.balance()
98
+ print(b.usd, b.accrued_micro_usd, b.remaining_usd)
99
+ if b.passthrough:
100
+ print("credit exhausted — calls still work, but nothing is being routed")
101
+ ```
102
+
103
+ **Check `passthrough`.** At zero credit the router degrades to a transparent
104
+ proxy: your calls keep succeeding, with no routing, no cache and no savings.
105
+ Nothing fails, which is exactly why it is easy to miss.
106
+
107
+ **Three numbers, because two books.** `usd` is the ledger. `accrued` is this
108
+ period's fees, which have not been taken yet — they settle at the monthly
109
+ close. `remaining` is what you should plan against.
110
+
111
+ ### Your receipts
112
+
113
+ ```python
114
+ rows = spw.verdicts(window="7d", q="haiku") # the last 7 days, searched
115
+ ```
116
+
117
+ `window` is one of `30m 1h 6h 12h 24h 7d 4w 12m` (a period ending now); `q`
118
+ matches the decision, the requested model and the served model. The router
119
+ applies both, so the same two values count and export exactly these rows:
120
+
121
+ ```bash
122
+ curl -H "Authorization: Bearer $ROUTER_API_KEY" \
123
+ "https://spw.scitechsolutions.ai/v1/verdicts/count?window=7d&q=haiku"
124
+ curl -H "Authorization: Bearer $ROUTER_API_KEY" -o verdicts-7d.csv \
125
+ "https://spw.scitechsolutions.ai/v1/verdicts.csv?window=7d&q=haiku"
126
+ ```
127
+
128
+ The export is refused, with the count, when more than 200 000 rows match —
129
+ narrow the window or the search.
130
+
131
+ ### Long requests
132
+
133
+ A proxy between you and the router closes a connection that stays silent for
134
+ about 100 seconds, and the router is silent until the verified answer exists.
135
+ So `relay()` asks for keep-alive delivery: the router answers within a second,
136
+ keeps the line warm every 15 seconds while the model works, and delivers the
137
+ whole answer at the end — the same result, nothing to change on your side.
138
+
139
+ `timeout` bounds **silence** on the line, not the whole call. A long answer is
140
+ bounded by the router's own request limit (the operator's
141
+ `ROUTER_REQUEST_TIMEOUT_SECS`), not by this number.
142
+
143
+ Over raw HTTP, send the header yourself and read the last `data:` line:
144
+
145
+ ```bash
146
+ curl -N -X POST https://spw.scitechsolutions.ai/v1/relay \
147
+ -H "Authorization: Bearer $ROUTER_API_KEY" \
148
+ -H "Accept: text/event-stream" \
149
+ -H "Content-Type: application/json" \
150
+ -d '{"prompt": "Summarise this contract in three bullets.", "model": "claude-sonnet-5", "max_tokens": 2000}'
151
+ # -N is optional: curl prints each line as it arrives instead of all at the end.
152
+ ```
153
+
154
+ ```
155
+ : keep-alive ← every 15 s while the model works
156
+ event: result
157
+ data: {"decision": "relayed", "model_used": "…", "content": "…", "verdict_id": "…", "latency_ms": 1840, …}
158
+ ```
159
+
160
+ An `event: error` line with `{"status": 502, "error": "…"}` takes the place
161
+ of `result` if the router fails late. `/v1/relay-multipart` takes the same
162
+ header. Without it the reply is one JSON body — and a call longer than about
163
+ 100 seconds dies at the proxy.
164
+
165
+ ## Errors
166
+
167
+ | Exception | When | Retry? |
168
+ |---|---|---|
169
+ | `AuthError` | 401/403 — key wrong or revoked | no |
170
+ | `RateLimited` | 429 | yes, honours `Retry-After` |
171
+ | `UploadCapacityFull` | 503 — upload slots full | yes, honours `Retry-After` |
172
+ | `Exhausted` | 502 — the router spent every lever | **no** |
173
+ | `TransportError` | never reached the server | yes |
174
+
175
+ `Exhausted` is not retryable on purpose. The router already climbed the model
176
+ ladder, repaired and re-called before answering — each of those attempts billed
177
+ **your** provider key. Asking again spends the same levers for the same failure.
178
+ The message names what it tried.
179
+
180
+ Retries use exponential backoff with deterministic jitter and a wall-clock
181
+ budget, and never extend past your own deadline. Configure with `RetryPolicy`.
182
+
183
+ ## Configuration
184
+
185
+ | | |
186
+ |---|---|
187
+ | `ROUTER_API_KEY` | your Meter-SPW key (or pass `api_key=`) |
188
+ | `ROUTER_BASE_URL` | defaults to `https://spw.scitechsolutions.ai` |
189
+
190
+ The SDK never sees a provider key. Those are stored once in the dashboard and
191
+ used by the router on your behalf — the only credential here spends credit, not
192
+ inference.
193
+
194
+ ## Support
195
+
196
+ support@scitechsolutions.ai — quote `RelayResult.verdict_id` or the
197
+ `request_id` from an error and we can find the exact call.
198
+
199
+ ---
200
+
201
+ © S&T Integrated Solutions, LLC — MIT licensed.
@@ -0,0 +1,152 @@
1
+ # meterspw-sdk
2
+
3
+ Python client for **Meter-SPW** — deterministic LLM routing with metered savings.
4
+
5
+ You bring your own provider keys. Meter-SPW routes each prompt to the cheapest
6
+ model that can answer it, caches what repeats, and meters what it saved.
7
+
8
+ ```bash
9
+ pip install meterspw-sdk
10
+ ```
11
+
12
+ Zero runtime dependencies — the default transport is `urllib` from the standard
13
+ library.
14
+
15
+ ## Use
16
+
17
+ ```python
18
+ from meterspw import MeterSpwClient
19
+
20
+ with MeterSpwClient() as spw: # key from ROUTER_API_KEY
21
+ out = spw.relay(
22
+ "Summarise this contract in three bullets.",
23
+ model="claude-sonnet-5",
24
+ max_tokens=2000,
25
+ )
26
+ print(out.content)
27
+ print(out.model_used) # what ACTUALLY answered
28
+ print(out.savings.usd) # what that saved you
29
+ ```
30
+
31
+ `model` is what you want; `model_used` is what answered. The difference is the
32
+ product.
33
+
34
+ ### Attachments
35
+
36
+ ```python
37
+ out = spw.relay_multipart(
38
+ "Summarise the attached report.",
39
+ model="claude-sonnet-5",
40
+ max_tokens=2000,
41
+ files=["report.pdf"],
42
+ )
43
+ ```
44
+
45
+ ### Know where you stand
46
+
47
+ ```python
48
+ b = spw.balance()
49
+ print(b.usd, b.accrued_micro_usd, b.remaining_usd)
50
+ if b.passthrough:
51
+ print("credit exhausted — calls still work, but nothing is being routed")
52
+ ```
53
+
54
+ **Check `passthrough`.** At zero credit the router degrades to a transparent
55
+ proxy: your calls keep succeeding, with no routing, no cache and no savings.
56
+ Nothing fails, which is exactly why it is easy to miss.
57
+
58
+ **Three numbers, because two books.** `usd` is the ledger. `accrued` is this
59
+ period's fees, which have not been taken yet — they settle at the monthly
60
+ close. `remaining` is what you should plan against.
61
+
62
+ ### Your receipts
63
+
64
+ ```python
65
+ rows = spw.verdicts(window="7d", q="haiku") # the last 7 days, searched
66
+ ```
67
+
68
+ `window` is one of `30m 1h 6h 12h 24h 7d 4w 12m` (a period ending now); `q`
69
+ matches the decision, the requested model and the served model. The router
70
+ applies both, so the same two values count and export exactly these rows:
71
+
72
+ ```bash
73
+ curl -H "Authorization: Bearer $ROUTER_API_KEY" \
74
+ "https://spw.scitechsolutions.ai/v1/verdicts/count?window=7d&q=haiku"
75
+ curl -H "Authorization: Bearer $ROUTER_API_KEY" -o verdicts-7d.csv \
76
+ "https://spw.scitechsolutions.ai/v1/verdicts.csv?window=7d&q=haiku"
77
+ ```
78
+
79
+ The export is refused, with the count, when more than 200 000 rows match —
80
+ narrow the window or the search.
81
+
82
+ ### Long requests
83
+
84
+ A proxy between you and the router closes a connection that stays silent for
85
+ about 100 seconds, and the router is silent until the verified answer exists.
86
+ So `relay()` asks for keep-alive delivery: the router answers within a second,
87
+ keeps the line warm every 15 seconds while the model works, and delivers the
88
+ whole answer at the end — the same result, nothing to change on your side.
89
+
90
+ `timeout` bounds **silence** on the line, not the whole call. A long answer is
91
+ bounded by the router's own request limit (the operator's
92
+ `ROUTER_REQUEST_TIMEOUT_SECS`), not by this number.
93
+
94
+ Over raw HTTP, send the header yourself and read the last `data:` line:
95
+
96
+ ```bash
97
+ curl -N -X POST https://spw.scitechsolutions.ai/v1/relay \
98
+ -H "Authorization: Bearer $ROUTER_API_KEY" \
99
+ -H "Accept: text/event-stream" \
100
+ -H "Content-Type: application/json" \
101
+ -d '{"prompt": "Summarise this contract in three bullets.", "model": "claude-sonnet-5", "max_tokens": 2000}'
102
+ # -N is optional: curl prints each line as it arrives instead of all at the end.
103
+ ```
104
+
105
+ ```
106
+ : keep-alive ← every 15 s while the model works
107
+ event: result
108
+ data: {"decision": "relayed", "model_used": "…", "content": "…", "verdict_id": "…", "latency_ms": 1840, …}
109
+ ```
110
+
111
+ An `event: error` line with `{"status": 502, "error": "…"}` takes the place
112
+ of `result` if the router fails late. `/v1/relay-multipart` takes the same
113
+ header. Without it the reply is one JSON body — and a call longer than about
114
+ 100 seconds dies at the proxy.
115
+
116
+ ## Errors
117
+
118
+ | Exception | When | Retry? |
119
+ |---|---|---|
120
+ | `AuthError` | 401/403 — key wrong or revoked | no |
121
+ | `RateLimited` | 429 | yes, honours `Retry-After` |
122
+ | `UploadCapacityFull` | 503 — upload slots full | yes, honours `Retry-After` |
123
+ | `Exhausted` | 502 — the router spent every lever | **no** |
124
+ | `TransportError` | never reached the server | yes |
125
+
126
+ `Exhausted` is not retryable on purpose. The router already climbed the model
127
+ ladder, repaired and re-called before answering — each of those attempts billed
128
+ **your** provider key. Asking again spends the same levers for the same failure.
129
+ The message names what it tried.
130
+
131
+ Retries use exponential backoff with deterministic jitter and a wall-clock
132
+ budget, and never extend past your own deadline. Configure with `RetryPolicy`.
133
+
134
+ ## Configuration
135
+
136
+ | | |
137
+ |---|---|
138
+ | `ROUTER_API_KEY` | your Meter-SPW key (or pass `api_key=`) |
139
+ | `ROUTER_BASE_URL` | defaults to `https://spw.scitechsolutions.ai` |
140
+
141
+ The SDK never sees a provider key. Those are stored once in the dashboard and
142
+ used by the router on your behalf — the only credential here spends credit, not
143
+ inference.
144
+
145
+ ## Support
146
+
147
+ support@scitechsolutions.ai — quote `RelayResult.verdict_id` or the
148
+ `request_id` from an error and we can find the exact call.
149
+
150
+ ---
151
+
152
+ © S&T Integrated Solutions, LLC — MIT licensed.
@@ -0,0 +1,58 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ # ONE version literal, read from the source. See src/meterspw/_version.py.
6
+ [tool.hatch.version]
7
+ path = "src/meterspw/_version.py"
8
+
9
+ [project]
10
+ name = "meterspw-sdk"
11
+ dynamic = ["version"]
12
+ description = "Meter-SPW-v1.0 — deterministic LLM routing with metered savings (BYOK)"
13
+ readme = "README.md"
14
+ requires-python = ">=3.10"
15
+ license = { file = "LICENSE" }
16
+ authors = [{ name = "S&T Integrated Solutions, LLC", email = "support@scitechsolutions.ai" }]
17
+ keywords = ["llm", "router", "byok", "cost", "openai", "anthropic"]
18
+ classifiers = [
19
+ "Development Status :: 4 - Beta",
20
+ "Intended Audience :: Developers",
21
+ "License :: OSI Approved :: MIT License",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Typing :: Typed",
26
+ ]
27
+ # ZERO runtime dependencies, deliberately. This package carries a CREDENTIAL,
28
+ # and every transitive dependency is code that could read it — an SDK ends up
29
+ # installed in environments its authors never see. The synchronous client's
30
+ # transport is `urllib` from the standard library, so installing this package
31
+ # pulls nothing into an environment its author never chose.
32
+ dependencies = []
33
+
34
+ [project.optional-dependencies]
35
+ # The ASYNC client only. `MeterSpwClient` never needs this, so a caller who
36
+ # does not use asyncio never installs it — which is the whole point of the
37
+ # extra rather than a plain dependency.
38
+ async = ["httpx>=0.24"]
39
+ dev = ["pytest>=7", "pytest-asyncio>=0.23", "httpx>=0.24", "ruff>=0.5", "mypy>=1.8"]
40
+
41
+ [project.urls]
42
+ Homepage = "https://spw.scitechsolutions.ai"
43
+
44
+ [tool.hatch.build.targets.wheel]
45
+ packages = ["src/meterspw"]
46
+
47
+ [tool.ruff]
48
+ line-length = 100
49
+ target-version = "py310"
50
+
51
+ [tool.mypy]
52
+ python_version = "3.10"
53
+ strict = true
54
+
55
+ [tool.pytest.ini_options]
56
+ # The async tests are plain coroutines; without this every one of them is
57
+ # collected, skipped with a warning, and reports as a pass.
58
+ asyncio_mode = "auto"
@@ -0,0 +1,56 @@
1
+ """Meter-SPW-v1.0 — deterministic LLM routing with metered savings.
2
+
3
+ from meterspw import MeterSpwClient
4
+
5
+ with MeterSpwClient() as spw:
6
+ out = spw.relay("Hello", model="claude-sonnet-5", max_tokens=64)
7
+ print(out.content, "saved", out.savings.usd)
8
+ """
9
+
10
+ from ._version import __version__
11
+ from .client import DEFAULT_BASE_URL, MeterSpwClient
12
+ from .errors import (
13
+ ApiError,
14
+ AuthError,
15
+ ConfigError,
16
+ Exhausted,
17
+ MeterSpwError,
18
+ RateLimited,
19
+ TransportError,
20
+ UploadCapacityFull,
21
+ )
22
+ from .models import Balance, RelayResult, Savings
23
+ from .retry import RetryPolicy
24
+
25
+ __all__ = [
26
+ "DEFAULT_BASE_URL",
27
+ "ApiError",
28
+ "AuthError",
29
+ "Balance",
30
+ "ConfigError",
31
+ "Exhausted",
32
+ "MeterSpwClient",
33
+ "MeterSpwError",
34
+ "RateLimited",
35
+ "RelayResult",
36
+ "RetryPolicy",
37
+ "Savings",
38
+ "TransportError",
39
+ "UploadCapacityFull",
40
+ "__version__",
41
+ ]
42
+
43
+
44
+ def __getattr__(name: str) -> object: # pragma: no cover - thin lazy shim
45
+ """Expose `AsyncMeterSpwClient` without importing httpx eagerly.
46
+
47
+ The synchronous client has no dependencies — this package carries a
48
+ credential, and every transitive dependency is code that could read it.
49
+ Importing `meterspw` must therefore never pull `httpx`, so the async
50
+ client is resolved only when someone actually asks for it.
51
+ """
52
+ if name == "AsyncMeterSpwClient":
53
+ from .aio import AsyncMeterSpwClient
54
+
55
+ return AsyncMeterSpwClient
56
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,10 @@
1
+ """The ONE version literal in this package.
2
+
3
+ `pyproject.toml` reads it via `[tool.hatch.version]`, the User-Agent is built
4
+ from it, and the release workflow asserts the git tag matches it. AGaaS's
5
+ release once compared the tag against two of four copies and the two it
6
+ skipped were the two that were wrong — so there is exactly one, and a test
7
+ keeps it that way.
8
+ """
9
+
10
+ __version__ = "0.2.0"