flopsindex 0.1.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,158 @@
1
+ Metadata-Version: 2.4
2
+ Name: flopsindex
3
+ Version: 0.1.0
4
+ Summary: Python SDK for FLOPS Index — live GPU compute and inference token pricing reference rates
5
+ Author-email: FLOPS Index <support@flopsindex.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://app.flopsindex.com
8
+ Project-URL: Documentation, https://app.flopsindex.com/llms.txt
9
+ Project-URL: Source, https://github.com/zeroatflops/flopsindex
10
+ Project-URL: Methodology, https://app.flopsindex.com/v1/methodology
11
+ Keywords: flops,gpu,pricing,compute,inference,benchmark,fintech
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Financial and Insurance Industry
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Office/Business :: Financial
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Requires-Python: >=3.9
25
+ Description-Content-Type: text/markdown
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=7.0; extra == "dev"
28
+
29
+ # flopsindex — Python SDK
30
+
31
+ [![PyPI version](https://img.shields.io/pypi/v/flopsindex.svg)](https://pypi.org/project/flopsindex/)
32
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/flopsindex.svg)](https://pypi.org/project/flopsindex/)
33
+ [![PyPI Downloads](https://img.shields.io/pypi/dm/flopsindex.svg)](https://pypi.org/project/flopsindex/)
34
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
35
+
36
+ ```bash
37
+ pip install flopsindex
38
+ ```
39
+
40
+ Live GPU compute pricing + LLM inference token pricing reference rates
41
+ from the FLOPS Index. Single dependency-free SDK over the FLOPS HTTP
42
+ API.
43
+
44
+ ## 30-second example
45
+
46
+ ```python
47
+ from flopsindex import Client
48
+
49
+ c = Client() # picks up FLOPSINDEX_API_KEY if set; works auth-free for public methods
50
+
51
+ # Public, auth-free — works with no API key
52
+ print(c.price("FLCI-H100"))
53
+ # {'index_id': 'FLCI-H100', 'value': 2.45, 'unit': 'USD/GPU-hr',
54
+ # 'ts': '2026-05-17T14:00:00+00:00', 'tier': 'LIVE',
55
+ # 'confidence': 'HIGH', 'verify_url': '...', 'citation_url': '...'}
56
+
57
+ print(c.timeseries("FLCI-H100", "7d")) # ≤200 decimated points
58
+ print(c.search("h100 spot")) # NL → canonical slugs
59
+ print(c.methodology("flci-h100", "v0.9")) # versioned methodology + frontmatter
60
+ ```
61
+
62
+ ## Authentication
63
+
64
+ The SDK reads an API key from `FLOPSINDEX_API_KEY`:
65
+
66
+ ```bash
67
+ export FLOPSINDEX_API_KEY="flops_xxxxxxxxx"
68
+ ```
69
+
70
+ Or pass directly:
71
+
72
+ ```python
73
+ c = Client(api_key="flops_xxxxxxxxx")
74
+ ```
75
+
76
+ **Auth-free methods** (work without a key): `price`, `timeseries`,
77
+ `search`, `methodology`, `methodologies`, `verify`.
78
+
79
+ **Partner-tier methods** (require a key): `catalog`, `index_current`,
80
+ `index_history`, `compute_margin`, `recompute_audit`.
81
+
82
+ If you call a partner-tier method without a key, you'll get
83
+ `FlopsAuthError`. Public methods always work — the API key just
84
+ unlocks higher rate-limit tiers when you have one.
85
+
86
+ ## Citation in code
87
+
88
+ When citing a FLOPS price in code, contracts, or research, ALWAYS
89
+ include the methodology version (every response carries it under
90
+ `methodology_version`):
91
+
92
+ ```python
93
+ tick = c.index_current("FLCI-H100")
94
+ print(f"Settled at ${tick['current_value']:.2f}/GPU-hr per "
95
+ f"{tick['methodology_version']}")
96
+ # Settled at $2.45/GPU-hr per flci-h100@v0.9
97
+ ```
98
+
99
+ The version is the contract anchor — partner replays + recompute
100
+ audits pin against it.
101
+
102
+ ## Methods
103
+
104
+ | Method | Auth | Returns |
105
+ |-----------------------|-----------|------------------------------------|
106
+ | `price(index_id)` | none | latest tick |
107
+ | `timeseries(id, range)` | none | decimated points (≤200) |
108
+ | `search(q)` | none | NL → slug results |
109
+ | `methodologies()` | none | list of all methodologies |
110
+ | `methodology(slug, version)` | none | one methodology doc (markdown + JSON) |
111
+ | `verify(id, value)` | none | does the value match our tick? |
112
+ | `catalog()` | partner | full envelope w/ methodology_version |
113
+ | `index_current(id)` | partner | partner-tier per-index lookup |
114
+ | `index_history(id)` | partner | up to 720 historical ticks |
115
+ | `compute_margin(...)` | partner | derived: price − power − rack |
116
+ | `recompute_audit(...)`| partner | audit receipts (IOSCO substrate) |
117
+
118
+ ## Naming conventions
119
+
120
+ - `FLCI-{model}` — composite spot+OD+DePIN
121
+ - `FLOPS-{model}-{OD|SPOT|DEPIN}` — single-tier specific
122
+ - `ITPI-{model_id}-{INPUT|OUTPUT}` — inference token pricing
123
+ - `CLRI-{model}-{tenor}` — forward / term rates (partner-tier only)
124
+
125
+ Use `c.search()` if you don't know the exact slug.
126
+
127
+ ## Errors
128
+
129
+ ```python
130
+ from flopsindex import Client, FlopsNotFoundError, FlopsAuthError
131
+
132
+ c = Client()
133
+ try:
134
+ tick = c.price("FLCI-UNKNOWN")
135
+ except FlopsNotFoundError as e:
136
+ print(f"index not found: {e.detail}")
137
+ ```
138
+
139
+ Hierarchy: `FlopsError` → `FlopsAuthError` / `FlopsNotFoundError` /
140
+ `FlopsRateLimitError` / `FlopsServerError`. All errors carry
141
+ `.status_code` and `.detail`.
142
+
143
+ ## Not the MCP server
144
+
145
+ `flopsindex` (this package) is a HTTP client SDK. The MCP server for
146
+ AI agents is a separate package: `pip install flopsindex-mcp`. See
147
+ https://github.com/zeroatflops/flopsindex-mcp for that one.
148
+
149
+ ## Methodology
150
+
151
+ Every published price is backed by a versioned methodology document.
152
+ Citation hooks live at:
153
+
154
+ ```
155
+ https://app.flopsindex.com/v1/methodology/{slug}/{version}
156
+ ```
157
+
158
+ For example, `https://app.flopsindex.com/v1/methodology/flci-h100/v0.9`.
@@ -0,0 +1,130 @@
1
+ # flopsindex — Python SDK
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/flopsindex.svg)](https://pypi.org/project/flopsindex/)
4
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/flopsindex.svg)](https://pypi.org/project/flopsindex/)
5
+ [![PyPI Downloads](https://img.shields.io/pypi/dm/flopsindex.svg)](https://pypi.org/project/flopsindex/)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+
8
+ ```bash
9
+ pip install flopsindex
10
+ ```
11
+
12
+ Live GPU compute pricing + LLM inference token pricing reference rates
13
+ from the FLOPS Index. Single dependency-free SDK over the FLOPS HTTP
14
+ API.
15
+
16
+ ## 30-second example
17
+
18
+ ```python
19
+ from flopsindex import Client
20
+
21
+ c = Client() # picks up FLOPSINDEX_API_KEY if set; works auth-free for public methods
22
+
23
+ # Public, auth-free — works with no API key
24
+ print(c.price("FLCI-H100"))
25
+ # {'index_id': 'FLCI-H100', 'value': 2.45, 'unit': 'USD/GPU-hr',
26
+ # 'ts': '2026-05-17T14:00:00+00:00', 'tier': 'LIVE',
27
+ # 'confidence': 'HIGH', 'verify_url': '...', 'citation_url': '...'}
28
+
29
+ print(c.timeseries("FLCI-H100", "7d")) # ≤200 decimated points
30
+ print(c.search("h100 spot")) # NL → canonical slugs
31
+ print(c.methodology("flci-h100", "v0.9")) # versioned methodology + frontmatter
32
+ ```
33
+
34
+ ## Authentication
35
+
36
+ The SDK reads an API key from `FLOPSINDEX_API_KEY`:
37
+
38
+ ```bash
39
+ export FLOPSINDEX_API_KEY="flops_xxxxxxxxx"
40
+ ```
41
+
42
+ Or pass directly:
43
+
44
+ ```python
45
+ c = Client(api_key="flops_xxxxxxxxx")
46
+ ```
47
+
48
+ **Auth-free methods** (work without a key): `price`, `timeseries`,
49
+ `search`, `methodology`, `methodologies`, `verify`.
50
+
51
+ **Partner-tier methods** (require a key): `catalog`, `index_current`,
52
+ `index_history`, `compute_margin`, `recompute_audit`.
53
+
54
+ If you call a partner-tier method without a key, you'll get
55
+ `FlopsAuthError`. Public methods always work — the API key just
56
+ unlocks higher rate-limit tiers when you have one.
57
+
58
+ ## Citation in code
59
+
60
+ When citing a FLOPS price in code, contracts, or research, ALWAYS
61
+ include the methodology version (every response carries it under
62
+ `methodology_version`):
63
+
64
+ ```python
65
+ tick = c.index_current("FLCI-H100")
66
+ print(f"Settled at ${tick['current_value']:.2f}/GPU-hr per "
67
+ f"{tick['methodology_version']}")
68
+ # Settled at $2.45/GPU-hr per flci-h100@v0.9
69
+ ```
70
+
71
+ The version is the contract anchor — partner replays + recompute
72
+ audits pin against it.
73
+
74
+ ## Methods
75
+
76
+ | Method | Auth | Returns |
77
+ |-----------------------|-----------|------------------------------------|
78
+ | `price(index_id)` | none | latest tick |
79
+ | `timeseries(id, range)` | none | decimated points (≤200) |
80
+ | `search(q)` | none | NL → slug results |
81
+ | `methodologies()` | none | list of all methodologies |
82
+ | `methodology(slug, version)` | none | one methodology doc (markdown + JSON) |
83
+ | `verify(id, value)` | none | does the value match our tick? |
84
+ | `catalog()` | partner | full envelope w/ methodology_version |
85
+ | `index_current(id)` | partner | partner-tier per-index lookup |
86
+ | `index_history(id)` | partner | up to 720 historical ticks |
87
+ | `compute_margin(...)` | partner | derived: price − power − rack |
88
+ | `recompute_audit(...)`| partner | audit receipts (IOSCO substrate) |
89
+
90
+ ## Naming conventions
91
+
92
+ - `FLCI-{model}` — composite spot+OD+DePIN
93
+ - `FLOPS-{model}-{OD|SPOT|DEPIN}` — single-tier specific
94
+ - `ITPI-{model_id}-{INPUT|OUTPUT}` — inference token pricing
95
+ - `CLRI-{model}-{tenor}` — forward / term rates (partner-tier only)
96
+
97
+ Use `c.search()` if you don't know the exact slug.
98
+
99
+ ## Errors
100
+
101
+ ```python
102
+ from flopsindex import Client, FlopsNotFoundError, FlopsAuthError
103
+
104
+ c = Client()
105
+ try:
106
+ tick = c.price("FLCI-UNKNOWN")
107
+ except FlopsNotFoundError as e:
108
+ print(f"index not found: {e.detail}")
109
+ ```
110
+
111
+ Hierarchy: `FlopsError` → `FlopsAuthError` / `FlopsNotFoundError` /
112
+ `FlopsRateLimitError` / `FlopsServerError`. All errors carry
113
+ `.status_code` and `.detail`.
114
+
115
+ ## Not the MCP server
116
+
117
+ `flopsindex` (this package) is a HTTP client SDK. The MCP server for
118
+ AI agents is a separate package: `pip install flopsindex-mcp`. See
119
+ https://github.com/zeroatflops/flopsindex-mcp for that one.
120
+
121
+ ## Methodology
122
+
123
+ Every published price is backed by a versioned methodology document.
124
+ Citation hooks live at:
125
+
126
+ ```
127
+ https://app.flopsindex.com/v1/methodology/{slug}/{version}
128
+ ```
129
+
130
+ For example, `https://app.flopsindex.com/v1/methodology/flci-h100/v0.9`.
@@ -0,0 +1,39 @@
1
+ """flopsindex — Python SDK for the FLOPS Compute Intelligence API.
2
+
3
+ ```python
4
+ from flopsindex import Client
5
+
6
+ c = Client() # auth from FLOPSINDEX_API_KEY env var
7
+ tick = c.price("FLCI-H100") # current price
8
+ hist = c.timeseries("FLCI-H100", "7d") # 7-day decimated timeseries
9
+ matches = c.search("h100 spot") # NL → index_id
10
+ catalog = c.catalog() # full catalog (partner-tier)
11
+ doc = c.methodology("flci-h100", "v0.9") # raw methodology markdown
12
+ margin = c.compute_margin(sku="h100_sxm5", region="us_west")
13
+ ```
14
+
15
+ The public surface is auth-free (price / timeseries / search /
16
+ methodology) — those work without an API key. catalog +
17
+ compute_margin + recompute_audit need a key.
18
+
19
+ Set the key via:
20
+ - `FLOPSINDEX_API_KEY` env var (recommended)
21
+ - `Client(api_key="flops_xxx")` constructor
22
+ """
23
+ from flopsindex.client import Client
24
+ from flopsindex.exceptions import (
25
+ FlopsError,
26
+ FlopsAuthError,
27
+ FlopsNotFoundError,
28
+ FlopsRateLimitError,
29
+ )
30
+
31
+ __version__ = "0.1.0"
32
+ __all__ = [
33
+ "Client",
34
+ "FlopsError",
35
+ "FlopsAuthError",
36
+ "FlopsNotFoundError",
37
+ "FlopsRateLimitError",
38
+ "__version__",
39
+ ]
@@ -0,0 +1,390 @@
1
+ """flopsindex consumer SDK — read-side client for the FLOPS API.
2
+
3
+ Architecture:
4
+ - All methods are synchronous (most consumers are scripts /
5
+ notebooks / finance pipelines, not async services). An async
6
+ wrapper can be added in v0.2 without breaking the sync surface.
7
+ - HTTP via stdlib `urllib` so the SDK has ZERO third-party
8
+ dependencies for the basic read path. `httpx` would be nicer
9
+ but locks consumers into a specific async runtime.
10
+ - API key resolved from constructor → FLOPSINDEX_API_KEY env var.
11
+ - Public-surface methods (price / timeseries / search / methodology)
12
+ work without an API key.
13
+ - Partner-tier methods (catalog / compute_margin / recompute_audit /
14
+ indices) require an API key; raise FlopsAuthError if missing.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import logging
20
+ import os
21
+ import time
22
+ import urllib.parse
23
+ import urllib.request
24
+ import urllib.error
25
+ from typing import Any, Dict, List, Optional, Union
26
+
27
+ from flopsindex.exceptions import (
28
+ FlopsAuthError,
29
+ FlopsError,
30
+ FlopsNotFoundError,
31
+ FlopsRateLimitError,
32
+ FlopsServerError,
33
+ )
34
+
35
+ logger = logging.getLogger("flopsindex")
36
+
37
+ _DEFAULT_BASE_URL = "https://app.flopsindex.com"
38
+ _DEFAULT_TIMEOUT = 30
39
+ _USER_AGENT_TEMPLATE = "flopsindex/{version} (+https://flopsindex.com)"
40
+
41
+
42
+ class Client:
43
+ """The FLOPS API consumer client.
44
+
45
+ Most users want the no-arg form::
46
+
47
+ from flopsindex import Client
48
+ c = Client() # FLOPSINDEX_API_KEY from env
49
+
50
+ Override base_url to hit a staging instance::
51
+
52
+ c = Client(base_url="https://staging.flopsindex.com")
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ api_key: Optional[str] = None,
58
+ base_url: str = _DEFAULT_BASE_URL,
59
+ timeout: int = _DEFAULT_TIMEOUT,
60
+ user_agent: Optional[str] = None,
61
+ ):
62
+ self._api_key = api_key or os.environ.get("FLOPSINDEX_API_KEY")
63
+ self._base_url = base_url.rstrip("/")
64
+ self._timeout = timeout
65
+ # Lazy import to avoid circular ref
66
+ from flopsindex import __version__ as _v
67
+ self._user_agent = user_agent or _USER_AGENT_TEMPLATE.format(version=_v)
68
+
69
+ # -----------------------------------------------------------------
70
+ # PUBLIC (auth-free) — works without an API key
71
+ # -----------------------------------------------------------------
72
+
73
+ def price(self, index_id: str) -> Dict[str, Any]:
74
+ """Latest published price for one SPOT index. Auth-free.
75
+
76
+ Returns ``{index_id, value, unit, ts, tier, confidence,
77
+ verify_url, citation_url}``.
78
+
79
+ Raises FlopsNotFoundError if the slug is unknown or is not on
80
+ the public spot surface (forwards / derived stay partner-tier).
81
+ """
82
+ return self._get(f"/v1/price/{urllib.parse.quote(index_id, safe='')}",
83
+ require_auth=False)
84
+
85
+ def timeseries(self, index_id: str, range: str = "7d") -> Dict[str, Any]:
86
+ """Decimated timeseries (≤200 points). Auth-free.
87
+
88
+ ``range`` ∈ {"24h", "7d", "30d", "90d", "1y"}.
89
+ """
90
+ return self._get(
91
+ f"/v1/ts/{urllib.parse.quote(index_id, safe='')}",
92
+ params={"range": range}, require_auth=False,
93
+ )
94
+
95
+ def search(self, q: str, limit: int = 10) -> Dict[str, Any]:
96
+ """Natural-language → canonical index_id. Auth-free.
97
+
98
+ Returns ``{q, count, results: [{index_id, family,
99
+ citation_url}, ...]}``.
100
+ """
101
+ return self._get("/v1/search",
102
+ params={"q": q, "limit": str(limit)},
103
+ require_auth=False)
104
+
105
+ def methodology(
106
+ self, slug: str, version: Optional[str] = None,
107
+ ) -> Dict[str, Any]:
108
+ """Versioned methodology document (with parsed frontmatter).
109
+
110
+ Auth-free. If ``version`` is None, returns the version history
111
+ list. Otherwise returns the doc with body_md + frontmatter.
112
+
113
+ ``slug`` is the lower-kebab methodology_id (e.g. "flci-h100",
114
+ "itpi-canonical", "flops-h100-od").
115
+ """
116
+ if version is None:
117
+ return self._get(
118
+ f"/v1/methodology/{urllib.parse.quote(slug)}/versions",
119
+ require_auth=False)
120
+ return self._get(
121
+ f"/v1/methodology/{urllib.parse.quote(slug)}/"
122
+ f"{urllib.parse.quote(version)}.json",
123
+ require_auth=False)
124
+
125
+ def methodologies(self) -> Dict[str, Any]:
126
+ """List every published methodology + active version. Auth-free."""
127
+ return self._get("/v1/methodology", require_auth=False)
128
+
129
+ def verify(self, index_id: str, value: float) -> Dict[str, Any]:
130
+ """Verify whether the given value matches our latest published
131
+ tick for the index_id (within tolerance). Auth-free citation
132
+ check. Raises on non-2xx upstream — use ``verify_handshake`` if
133
+ you want a defensive envelope instead."""
134
+ return self._get(
135
+ "/v1/verify",
136
+ params={"index_id": index_id, "value": str(value)},
137
+ require_auth=False)
138
+
139
+ def verify_handshake(
140
+ self,
141
+ index_id: str,
142
+ value: Optional[float] = None,
143
+ ) -> Dict[str, Any]:
144
+ """Defensive verify — returns either the canonical record OR a
145
+ structured ``{ok: false, reason, upstream_status}`` envelope on
146
+ any non-2xx response. Never raises for HTTP errors.
147
+
148
+ This is the citation handshake idiom Track D promotes::
149
+
150
+ tick = client.price("FLCI-H100")
151
+ check = client.verify_handshake("FLCI-H100", tick["value"])
152
+ if check.get("ok") is False:
153
+ # upstream broken / endpoint pending — caller decides
154
+ ...
155
+ elif check.get("verified"):
156
+ cite(tick, source_url=check["source_url"])
157
+
158
+ Mirrors the MCP server's ``_defensive_get`` shape so an agent
159
+ switching between MCP and direct SDK gets the same error envelope.
160
+
161
+ ``value`` is optional — omit to ask "what's the latest tick"
162
+ without committing a value to verify against.
163
+ """
164
+ params: Dict[str, str] = {"index_id": index_id}
165
+ if value is not None:
166
+ params["value"] = str(value)
167
+ url = self._base_url + "/v1/verify?" + urllib.parse.urlencode(params)
168
+ headers = {"User-Agent": self._user_agent, "Accept": "application/json"}
169
+ try:
170
+ req = urllib.request.Request(url, headers=headers)
171
+ with urllib.request.urlopen(req, timeout=self._timeout) as resp:
172
+ body = resp.read().decode("utf-8")
173
+ try:
174
+ return json.loads(body)
175
+ except ValueError:
176
+ return {"ok": False, "reason": "invalid_json",
177
+ "upstream_status": resp.status, "url": url}
178
+ except urllib.error.HTTPError as e:
179
+ code = e.code
180
+ if code in (401, 403):
181
+ return {"ok": False, "reason": "auth_required",
182
+ "upstream_status": code, "url": url}
183
+ if code == 404:
184
+ return {"ok": False, "reason": "endpoint_pending",
185
+ "upstream_status": code, "url": url}
186
+ if code >= 500:
187
+ return {"ok": False, "reason": "upstream_http_error",
188
+ "upstream_status": code, "url": url}
189
+ return {"ok": False, "reason": "client_error",
190
+ "upstream_status": code, "url": url}
191
+ except urllib.error.URLError as exc:
192
+ return {"ok": False, "reason": "network_error",
193
+ "url": url, "detail": str(exc.reason)[:300]}
194
+ except Exception as exc: # noqa: BLE001
195
+ return {"ok": False, "reason": "network_error",
196
+ "url": url, "detail": str(exc)[:300]}
197
+
198
+ # -----------------------------------------------------------------
199
+ # PARTNER-TIER (X-FLOPS-Api-Key required)
200
+ # -----------------------------------------------------------------
201
+
202
+ def catalog(self) -> Dict[str, Any]:
203
+ """Full index catalog with methodology_version stamps + tier
204
+ labels. Partner-tier — requires API key.
205
+
206
+ For an auth-free subset filtered to spot only, use the public
207
+ catalog mirror at GET /v2/catalog/public (no SDK method —
208
+ agents discover via /llms.txt).
209
+ """
210
+ return self._get("/v1/catalog", require_auth=True)
211
+
212
+ def index_current(self, index_id: str) -> Dict[str, Any]:
213
+ """Partner-tier per-index lookup with full envelope (value,
214
+ as_of, num_sources, data_tier, methodology_version,
215
+ verify_url). Returns 404 for unknown index_ids; differs from
216
+ ``price()`` in that the partner-tier surface knows about
217
+ non-spot families (forwards, derived, etc.) too."""
218
+ return self._get(
219
+ f"/v1/indices/{urllib.parse.quote(index_id, safe='')}/current",
220
+ require_auth=True)
221
+
222
+ def index_history(
223
+ self, index_id: str, limit: int = 24,
224
+ ) -> Dict[str, Any]:
225
+ """Partner-tier per-index history. ``limit`` capped at 720."""
226
+ return self._get(
227
+ f"/v1/indices/{urllib.parse.quote(index_id, safe='')}/history",
228
+ params={"limit": str(min(limit, 720))},
229
+ require_auth=True)
230
+
231
+ def compute_margin(
232
+ self, sku: str, region: str = "us_east",
233
+ pue: float = 1.3, kwh_source: str = "live_lmp",
234
+ kwh_override: Optional[float] = None,
235
+ rack_amortization: Optional[float] = None,
236
+ ) -> Dict[str, Any]:
237
+ """Compute-margin endpoint (Tier 2 #5). Partner-tier.
238
+
239
+ Returns the full margin decomposition:
240
+ {price, power_cost, rack_amortization, margin, margin_pct,
241
+ inputs: {chip_power_kw, pue, kwh_source, kwh_usd, ...}}
242
+ """
243
+ params = {"sku": sku, "region": region,
244
+ "pue": str(pue), "kwh_source": kwh_source}
245
+ if kwh_override is not None:
246
+ params["kwh_override"] = str(kwh_override)
247
+ if rack_amortization is not None:
248
+ params["rack_amortization"] = str(rack_amortization)
249
+ return self._get("/v1/derived/compute-margin",
250
+ params=params, require_auth=True)
251
+
252
+ def recompute_audit(
253
+ self,
254
+ methodology_id: Optional[str] = None,
255
+ index_id: Optional[str] = None,
256
+ status: Optional[str] = None,
257
+ since_hours: int = 168,
258
+ limit: int = 100,
259
+ ) -> Dict[str, Any]:
260
+ """Recompute audit receipts (Tier 2 #4). Partner-tier.
261
+
262
+ Returns ``{count, receipts: [...]}`` with each receipt
263
+ carrying methodology_version, window, shipped vs recomputed
264
+ values, variance_bps, inputs_hash + receipt_hash for
265
+ external citation.
266
+ """
267
+ params: Dict[str, str] = {"since_hours": str(since_hours),
268
+ "limit": str(limit)}
269
+ if methodology_id:
270
+ params["methodology_id"] = methodology_id
271
+ if index_id:
272
+ params["index_id"] = index_id
273
+ if status:
274
+ params["status"] = status
275
+ return self._get("/v1/audit/recompute",
276
+ params=params, require_auth=True)
277
+
278
+ def gpu_capex(self, sku: Optional[str] = None) -> Dict[str, Any]:
279
+ """Per-SKU GPU module reference price — 3-tier LIVE cascade.
280
+
281
+ Cascade per SKU:
282
+ T2 LIVE_USASPENDING — federal procurement median (real new),
283
+ preferred when >=3 awards in 180d
284
+ T1 LIVE_EBAY_X1.15 — eBay completed-listings trimmed-median × 1.15
285
+ T3 SEED — quarterly reference fallback
286
+
287
+ Returns the single-SKU envelope (price_usd, tier, source,
288
+ effective, secondary_market_range_usd, plus live_observations +
289
+ also_seed when LIVE). With ``sku=None`` returns the full seed
290
+ map with meta.live_api_status.
291
+
292
+ No auth required. Methodology:
293
+ https://app.flopsindex.com/methodology/GPU_CAPEX_LIVE_METHODOLOGY.md
294
+
295
+ NOTE: there is no public LIVE MSRP API for datacenter GPUs —
296
+ NVIDIA/AMD/Huawei are channel-priced through OEMs. The LIVE
297
+ tiers here are federal-procurement (T2) or secondary-market ×
298
+ markup (T1), not chip-new MSRP.
299
+ """
300
+ if sku:
301
+ return self._get(f"/v1/refdata/gpu-capex/{sku}", require_auth=False)
302
+ return self._get("/v1/refdata/gpu-capex", require_auth=False)
303
+
304
+ def gpu_capex_observations(
305
+ self,
306
+ sku: str,
307
+ limit: int = 50,
308
+ ) -> Dict[str, Any]:
309
+ """Audit-trail surface — raw gpu_capex_observations rows for a SKU.
310
+
311
+ Returns the rows that feed the LIVE-tier cascade — partners +
312
+ auditors can drill from a published LIVE value back to the
313
+ underlying federal-contract / eBay-listing records.
314
+
315
+ Returns ``{sku, count, limit, rows, by_source, methodology}``.
316
+ Limit clamped server-side to [1, 500]. No auth required;
317
+ 503 if migration 012 hasn't been applied to this Neon branch.
318
+ """
319
+ return self._get(
320
+ f"/v1/refdata/gpu-capex/{sku}/observations",
321
+ params={"limit": str(int(limit))},
322
+ require_auth=False,
323
+ )
324
+
325
+ # -----------------------------------------------------------------
326
+ # HTTP plumbing
327
+ # -----------------------------------------------------------------
328
+
329
+ def _get(
330
+ self,
331
+ path: str,
332
+ *,
333
+ params: Optional[Dict[str, str]] = None,
334
+ require_auth: bool = False,
335
+ ) -> Dict[str, Any]:
336
+ url = self._base_url + path
337
+ if params:
338
+ url += "?" + urllib.parse.urlencode(params)
339
+
340
+ headers = {"User-Agent": self._user_agent, "Accept": "application/json"}
341
+ if require_auth:
342
+ if not self._api_key:
343
+ raise FlopsAuthError(
344
+ f"{path} requires an API key. Set FLOPSINDEX_API_KEY "
345
+ f"or pass api_key= to Client()."
346
+ )
347
+ headers["X-FLOPS-Api-Key"] = self._api_key
348
+ elif self._api_key:
349
+ # Send the key on public paths too — gives the server
350
+ # higher rate-limit allowance for keyed callers.
351
+ headers["X-FLOPS-Api-Key"] = self._api_key
352
+
353
+ req = urllib.request.Request(url, headers=headers)
354
+ try:
355
+ with urllib.request.urlopen(req, timeout=self._timeout) as resp:
356
+ body = resp.read().decode("utf-8")
357
+ return json.loads(body)
358
+ except urllib.error.HTTPError as e:
359
+ self._raise_for_status(e.code, e.read().decode("utf-8", "replace"),
360
+ e.headers)
361
+ except urllib.error.URLError as e:
362
+ raise FlopsError(f"network error: {e.reason}")
363
+ # unreachable
364
+ raise FlopsError(f"unexpected: {path}")
365
+
366
+ def _raise_for_status(
367
+ self, status_code: int, body: str, headers,
368
+ ) -> None:
369
+ try:
370
+ detail = json.loads(body)
371
+ except Exception:
372
+ detail = body
373
+ msg = f"HTTP {status_code}"
374
+ if isinstance(detail, dict) and "detail" in detail:
375
+ msg += f": {detail['detail']}"
376
+ if status_code in (401, 403):
377
+ raise FlopsAuthError(msg, status_code=status_code, detail=detail)
378
+ if status_code == 404:
379
+ raise FlopsNotFoundError(msg, status_code=status_code, detail=detail)
380
+ if status_code == 429:
381
+ retry_after = 60
382
+ try:
383
+ retry_after = int(headers.get("Retry-After", "60"))
384
+ except (ValueError, TypeError, AttributeError):
385
+ pass
386
+ raise FlopsRateLimitError(msg, retry_after_seconds=retry_after,
387
+ status_code=status_code, detail=detail)
388
+ if 500 <= status_code < 600:
389
+ raise FlopsServerError(msg, status_code=status_code, detail=detail)
390
+ raise FlopsError(msg, status_code=status_code, detail=detail)
@@ -0,0 +1,39 @@
1
+ """flopsindex SDK exception hierarchy.
2
+
3
+ All SDK errors descend from `FlopsError` so callers can catch one
4
+ thing if they don't care about the distinction.
5
+ """
6
+
7
+
8
+ class FlopsError(Exception):
9
+ """Base SDK error. Carries (status_code, detail) when raised from
10
+ an HTTP response."""
11
+
12
+ def __init__(self, message: str, status_code: int = 0, detail=None):
13
+ self.status_code = status_code
14
+ self.detail = detail
15
+ super().__init__(message)
16
+
17
+
18
+ class FlopsAuthError(FlopsError):
19
+ """401 / 403 — bad or missing API key, or scope insufficient."""
20
+
21
+
22
+ class FlopsNotFoundError(FlopsError):
23
+ """404 — index_id / methodology slug doesn't exist OR is not on
24
+ the public surface (forwards/term-rates/derived) and you're
25
+ calling an unauthenticated method."""
26
+
27
+
28
+ class FlopsRateLimitError(FlopsError):
29
+ """429 — rate limit exceeded. `retry_after_seconds` populated
30
+ when the response carries the standard header."""
31
+
32
+ def __init__(self, message: str, retry_after_seconds: int = 60,
33
+ status_code: int = 429, detail=None):
34
+ self.retry_after_seconds = retry_after_seconds
35
+ super().__init__(message, status_code, detail)
36
+
37
+
38
+ class FlopsServerError(FlopsError):
39
+ """5xx — server-side fault. Usually transient; retry with backoff."""
@@ -0,0 +1,158 @@
1
+ Metadata-Version: 2.4
2
+ Name: flopsindex
3
+ Version: 0.1.0
4
+ Summary: Python SDK for FLOPS Index — live GPU compute and inference token pricing reference rates
5
+ Author-email: FLOPS Index <support@flopsindex.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://app.flopsindex.com
8
+ Project-URL: Documentation, https://app.flopsindex.com/llms.txt
9
+ Project-URL: Source, https://github.com/zeroatflops/flopsindex
10
+ Project-URL: Methodology, https://app.flopsindex.com/v1/methodology
11
+ Keywords: flops,gpu,pricing,compute,inference,benchmark,fintech
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Financial and Insurance Industry
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Office/Business :: Financial
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Requires-Python: >=3.9
25
+ Description-Content-Type: text/markdown
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=7.0; extra == "dev"
28
+
29
+ # flopsindex — Python SDK
30
+
31
+ [![PyPI version](https://img.shields.io/pypi/v/flopsindex.svg)](https://pypi.org/project/flopsindex/)
32
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/flopsindex.svg)](https://pypi.org/project/flopsindex/)
33
+ [![PyPI Downloads](https://img.shields.io/pypi/dm/flopsindex.svg)](https://pypi.org/project/flopsindex/)
34
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
35
+
36
+ ```bash
37
+ pip install flopsindex
38
+ ```
39
+
40
+ Live GPU compute pricing + LLM inference token pricing reference rates
41
+ from the FLOPS Index. Single dependency-free SDK over the FLOPS HTTP
42
+ API.
43
+
44
+ ## 30-second example
45
+
46
+ ```python
47
+ from flopsindex import Client
48
+
49
+ c = Client() # picks up FLOPSINDEX_API_KEY if set; works auth-free for public methods
50
+
51
+ # Public, auth-free — works with no API key
52
+ print(c.price("FLCI-H100"))
53
+ # {'index_id': 'FLCI-H100', 'value': 2.45, 'unit': 'USD/GPU-hr',
54
+ # 'ts': '2026-05-17T14:00:00+00:00', 'tier': 'LIVE',
55
+ # 'confidence': 'HIGH', 'verify_url': '...', 'citation_url': '...'}
56
+
57
+ print(c.timeseries("FLCI-H100", "7d")) # ≤200 decimated points
58
+ print(c.search("h100 spot")) # NL → canonical slugs
59
+ print(c.methodology("flci-h100", "v0.9")) # versioned methodology + frontmatter
60
+ ```
61
+
62
+ ## Authentication
63
+
64
+ The SDK reads an API key from `FLOPSINDEX_API_KEY`:
65
+
66
+ ```bash
67
+ export FLOPSINDEX_API_KEY="flops_xxxxxxxxx"
68
+ ```
69
+
70
+ Or pass directly:
71
+
72
+ ```python
73
+ c = Client(api_key="flops_xxxxxxxxx")
74
+ ```
75
+
76
+ **Auth-free methods** (work without a key): `price`, `timeseries`,
77
+ `search`, `methodology`, `methodologies`, `verify`.
78
+
79
+ **Partner-tier methods** (require a key): `catalog`, `index_current`,
80
+ `index_history`, `compute_margin`, `recompute_audit`.
81
+
82
+ If you call a partner-tier method without a key, you'll get
83
+ `FlopsAuthError`. Public methods always work — the API key just
84
+ unlocks higher rate-limit tiers when you have one.
85
+
86
+ ## Citation in code
87
+
88
+ When citing a FLOPS price in code, contracts, or research, ALWAYS
89
+ include the methodology version (every response carries it under
90
+ `methodology_version`):
91
+
92
+ ```python
93
+ tick = c.index_current("FLCI-H100")
94
+ print(f"Settled at ${tick['current_value']:.2f}/GPU-hr per "
95
+ f"{tick['methodology_version']}")
96
+ # Settled at $2.45/GPU-hr per flci-h100@v0.9
97
+ ```
98
+
99
+ The version is the contract anchor — partner replays + recompute
100
+ audits pin against it.
101
+
102
+ ## Methods
103
+
104
+ | Method | Auth | Returns |
105
+ |-----------------------|-----------|------------------------------------|
106
+ | `price(index_id)` | none | latest tick |
107
+ | `timeseries(id, range)` | none | decimated points (≤200) |
108
+ | `search(q)` | none | NL → slug results |
109
+ | `methodologies()` | none | list of all methodologies |
110
+ | `methodology(slug, version)` | none | one methodology doc (markdown + JSON) |
111
+ | `verify(id, value)` | none | does the value match our tick? |
112
+ | `catalog()` | partner | full envelope w/ methodology_version |
113
+ | `index_current(id)` | partner | partner-tier per-index lookup |
114
+ | `index_history(id)` | partner | up to 720 historical ticks |
115
+ | `compute_margin(...)` | partner | derived: price − power − rack |
116
+ | `recompute_audit(...)`| partner | audit receipts (IOSCO substrate) |
117
+
118
+ ## Naming conventions
119
+
120
+ - `FLCI-{model}` — composite spot+OD+DePIN
121
+ - `FLOPS-{model}-{OD|SPOT|DEPIN}` — single-tier specific
122
+ - `ITPI-{model_id}-{INPUT|OUTPUT}` — inference token pricing
123
+ - `CLRI-{model}-{tenor}` — forward / term rates (partner-tier only)
124
+
125
+ Use `c.search()` if you don't know the exact slug.
126
+
127
+ ## Errors
128
+
129
+ ```python
130
+ from flopsindex import Client, FlopsNotFoundError, FlopsAuthError
131
+
132
+ c = Client()
133
+ try:
134
+ tick = c.price("FLCI-UNKNOWN")
135
+ except FlopsNotFoundError as e:
136
+ print(f"index not found: {e.detail}")
137
+ ```
138
+
139
+ Hierarchy: `FlopsError` → `FlopsAuthError` / `FlopsNotFoundError` /
140
+ `FlopsRateLimitError` / `FlopsServerError`. All errors carry
141
+ `.status_code` and `.detail`.
142
+
143
+ ## Not the MCP server
144
+
145
+ `flopsindex` (this package) is a HTTP client SDK. The MCP server for
146
+ AI agents is a separate package: `pip install flopsindex-mcp`. See
147
+ https://github.com/zeroatflops/flopsindex-mcp for that one.
148
+
149
+ ## Methodology
150
+
151
+ Every published price is backed by a versioned methodology document.
152
+ Citation hooks live at:
153
+
154
+ ```
155
+ https://app.flopsindex.com/v1/methodology/{slug}/{version}
156
+ ```
157
+
158
+ For example, `https://app.flopsindex.com/v1/methodology/flci-h100/v0.9`.
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ flopsindex/__init__.py
4
+ flopsindex/client.py
5
+ flopsindex/exceptions.py
6
+ flopsindex.egg-info/PKG-INFO
7
+ flopsindex.egg-info/SOURCES.txt
8
+ flopsindex.egg-info/dependency_links.txt
9
+ flopsindex.egg-info/requires.txt
10
+ flopsindex.egg-info/top_level.txt
11
+ tests/test_client.py
@@ -0,0 +1,3 @@
1
+
2
+ [dev]
3
+ pytest>=7.0
@@ -0,0 +1 @@
1
+ flopsindex
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "flopsindex"
7
+ version = "0.1.0"
8
+ description = "Python SDK for FLOPS Index — live GPU compute and inference token pricing reference rates"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = {text = "MIT"}
12
+ authors = [{name = "FLOPS Index", email = "support@flopsindex.com"}]
13
+ keywords = ["flops", "gpu", "pricing", "compute", "inference", "benchmark", "fintech"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Intended Audience :: Financial and Insurance Industry",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.9",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Office/Business :: Financial",
26
+ "Topic :: Software Development :: Libraries :: Python Modules",
27
+ ]
28
+
29
+ # Zero external dependencies for the basic read path (urllib stdlib).
30
+ # httpx etc. are deliberately omitted so the SDK can drop into a
31
+ # finance-team Excel-add-in-VBA-host or a notebook without
32
+ # pulling a transitive dependency tree.
33
+ dependencies = []
34
+
35
+ [project.optional-dependencies]
36
+ dev = [
37
+ "pytest>=7.0",
38
+ ]
39
+
40
+ [project.urls]
41
+ Homepage = "https://app.flopsindex.com"
42
+ Documentation = "https://app.flopsindex.com/llms.txt"
43
+ Source = "https://github.com/zeroatflops/flopsindex"
44
+ Methodology = "https://app.flopsindex.com/v1/methodology"
45
+
46
+ [tool.setuptools.packages.find]
47
+ where = ["."]
48
+ include = ["flopsindex*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,362 @@
1
+ """Tests for the flopsindex Python SDK.
2
+
3
+ Network-free: every HTTP call is monkeypatched. Tests pin the
4
+ exception mapping + URL construction + auth gating.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import sys
10
+ from pathlib import Path
11
+ from unittest import mock
12
+
13
+ import pytest
14
+
15
+ # Add SDK to path (test runs from repo root)
16
+ SDK_DIR = Path(__file__).parent.parent
17
+ sys.path.insert(0, str(SDK_DIR))
18
+
19
+ from flopsindex import Client # noqa: E402
20
+ from flopsindex.exceptions import ( # noqa: E402
21
+ FlopsAuthError,
22
+ FlopsError,
23
+ FlopsNotFoundError,
24
+ FlopsRateLimitError,
25
+ FlopsServerError,
26
+ )
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Auth gating
31
+ # ---------------------------------------------------------------------------
32
+
33
+
34
+ def test_public_methods_work_without_api_key(monkeypatch):
35
+ """price / timeseries / search / methodology must work without
36
+ an API key — that's the whole point of the public surface."""
37
+ monkeypatch.delenv("FLOPSINDEX_API_KEY", raising=False)
38
+ c = Client()
39
+
40
+ # Stub _get so we don't hit the network
41
+ with mock.patch.object(c, "_get", return_value={"ok": True}) as m:
42
+ c.price("FLCI-H100")
43
+ m.assert_called_once()
44
+ # require_auth must be False on the call
45
+ assert m.call_args.kwargs.get("require_auth", True) is False
46
+
47
+
48
+ def test_partner_methods_raise_without_api_key(monkeypatch):
49
+ """catalog / compute_margin / index_current need a key. Without
50
+ one, the SDK must raise FlopsAuthError BEFORE making the HTTP
51
+ call — saves a wasted round-trip and gives a clearer error."""
52
+ monkeypatch.delenv("FLOPSINDEX_API_KEY", raising=False)
53
+ c = Client()
54
+
55
+ # _get does the auth check
56
+ with pytest.raises(FlopsAuthError):
57
+ c.catalog()
58
+
59
+ with pytest.raises(FlopsAuthError):
60
+ c.compute_margin(sku="h100_sxm5")
61
+
62
+ with pytest.raises(FlopsAuthError):
63
+ c.index_current("FLCI-H100")
64
+
65
+
66
+ def test_api_key_from_env(monkeypatch):
67
+ monkeypatch.setenv("FLOPSINDEX_API_KEY", "flops_test_key")
68
+ c = Client()
69
+ assert c._api_key == "flops_test_key"
70
+
71
+
72
+ def test_api_key_from_constructor_wins(monkeypatch):
73
+ monkeypatch.setenv("FLOPSINDEX_API_KEY", "from_env")
74
+ c = Client(api_key="from_arg")
75
+ assert c._api_key == "from_arg"
76
+
77
+
78
+ # ---------------------------------------------------------------------------
79
+ # URL construction
80
+ # ---------------------------------------------------------------------------
81
+
82
+
83
+ def test_price_url(monkeypatch):
84
+ monkeypatch.delenv("FLOPSINDEX_API_KEY", raising=False)
85
+ c = Client()
86
+ captured = {}
87
+
88
+ def fake_urlopen(req, timeout=None):
89
+ captured["url"] = req.full_url
90
+ captured["headers"] = dict(req.headers)
91
+ return _FakeResp(json.dumps({"ok": True}).encode("utf-8"))
92
+ monkeypatch.setattr("flopsindex.client.urllib.request.urlopen", fake_urlopen)
93
+ c.price("FLCI-H100")
94
+ assert captured["url"] == "https://app.flopsindex.com/v1/price/FLCI-H100"
95
+ # No API key header (we didn't set one)
96
+ assert "X-flops-api-key" not in captured["headers"]
97
+
98
+
99
+ def test_timeseries_url_includes_range(monkeypatch):
100
+ monkeypatch.delenv("FLOPSINDEX_API_KEY", raising=False)
101
+ c = Client()
102
+ captured = {}
103
+
104
+ def fake_urlopen(req, timeout=None):
105
+ captured["url"] = req.full_url
106
+ return _FakeResp(json.dumps({"ok": True}).encode("utf-8"))
107
+ monkeypatch.setattr("flopsindex.client.urllib.request.urlopen", fake_urlopen)
108
+ c.timeseries("FLCI-H100", range="30d")
109
+ assert "/v1/ts/FLCI-H100" in captured["url"]
110
+ assert "range=30d" in captured["url"]
111
+
112
+
113
+ def test_api_key_attached_when_present(monkeypatch):
114
+ monkeypatch.setenv("FLOPSINDEX_API_KEY", "flops_xxx")
115
+ c = Client()
116
+ captured = {}
117
+
118
+ def fake_urlopen(req, timeout=None):
119
+ captured["headers"] = dict(req.headers)
120
+ return _FakeResp(json.dumps({"ok": True}).encode("utf-8"))
121
+ monkeypatch.setattr("flopsindex.client.urllib.request.urlopen", fake_urlopen)
122
+ c.catalog()
123
+ # urllib lowercases header names in the .headers dict
124
+ assert "X-flops-api-key" in captured["headers"] or \
125
+ "X-Flops-Api-Key" in captured["headers"]
126
+
127
+
128
+ def test_methodology_versions_when_no_version(monkeypatch):
129
+ monkeypatch.delenv("FLOPSINDEX_API_KEY", raising=False)
130
+ c = Client()
131
+ captured = {}
132
+
133
+ def fake_urlopen(req, timeout=None):
134
+ captured["url"] = req.full_url
135
+ return _FakeResp(json.dumps({"ok": True}).encode("utf-8"))
136
+ monkeypatch.setattr("flopsindex.client.urllib.request.urlopen", fake_urlopen)
137
+ c.methodology("flci-h100")
138
+ assert "/v1/methodology/flci-h100/versions" in captured["url"]
139
+
140
+
141
+ def test_methodology_specific_version_uses_json_suffix(monkeypatch):
142
+ monkeypatch.delenv("FLOPSINDEX_API_KEY", raising=False)
143
+ c = Client()
144
+ captured = {}
145
+
146
+ def fake_urlopen(req, timeout=None):
147
+ captured["url"] = req.full_url
148
+ return _FakeResp(json.dumps({"ok": True}).encode("utf-8"))
149
+ monkeypatch.setattr("flopsindex.client.urllib.request.urlopen", fake_urlopen)
150
+ c.methodology("flci-h100", "v0.9")
151
+ assert "/v1/methodology/flci-h100/v0.9.json" in captured["url"]
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # Error mapping
156
+ # ---------------------------------------------------------------------------
157
+
158
+
159
+ @pytest.mark.parametrize("status,exc_class", [
160
+ (401, FlopsAuthError),
161
+ (403, FlopsAuthError),
162
+ (404, FlopsNotFoundError),
163
+ (429, FlopsRateLimitError),
164
+ (500, FlopsServerError),
165
+ (502, FlopsServerError),
166
+ (503, FlopsServerError),
167
+ ])
168
+ def test_http_error_maps_to_exception(monkeypatch, status, exc_class):
169
+ """Each HTTP status code maps to the right exception class."""
170
+ import urllib.error
171
+ monkeypatch.delenv("FLOPSINDEX_API_KEY", raising=False)
172
+ c = Client()
173
+
174
+ def fake_urlopen(req, timeout=None):
175
+ raise urllib.error.HTTPError(
176
+ req.full_url, status, "err",
177
+ {"Retry-After": "120"},
178
+ _FakeReader(json.dumps({"detail": f"status {status}"}).encode("utf-8")),
179
+ )
180
+ monkeypatch.setattr("flopsindex.client.urllib.request.urlopen", fake_urlopen)
181
+
182
+ with pytest.raises(exc_class) as exc_info:
183
+ c.price("FLCI-H100")
184
+ assert exc_info.value.status_code == status
185
+
186
+ if status == 429:
187
+ assert exc_info.value.retry_after_seconds == 120
188
+
189
+
190
+ def test_network_error_raises_flops_error(monkeypatch):
191
+ import urllib.error
192
+ monkeypatch.delenv("FLOPSINDEX_API_KEY", raising=False)
193
+ c = Client()
194
+
195
+ def fake_urlopen(req, timeout=None):
196
+ raise urllib.error.URLError("connection refused")
197
+ monkeypatch.setattr("flopsindex.client.urllib.request.urlopen", fake_urlopen)
198
+
199
+ with pytest.raises(FlopsError) as exc_info:
200
+ c.price("FLCI-H100")
201
+ assert "connection refused" in str(exc_info.value)
202
+
203
+
204
+ # ---------------------------------------------------------------------------
205
+ # Helpers
206
+ # ---------------------------------------------------------------------------
207
+
208
+
209
+ class _FakeReader:
210
+ """urllib.error.HTTPError needs a file-like object as `fp`."""
211
+ def __init__(self, body: bytes):
212
+ self._body = body
213
+
214
+ def read(self, *args):
215
+ return self._body
216
+
217
+
218
+ class _FakeResp:
219
+ """Minimal context-manager fake for urllib.request.urlopen()."""
220
+ def __init__(self, body: bytes, status: int = 200):
221
+ self._body = body
222
+ self.status = status
223
+
224
+ def __enter__(self):
225
+ return self
226
+
227
+ def __exit__(self, *exc):
228
+ return False
229
+
230
+ def read(self):
231
+ return self._body
232
+
233
+
234
+ # ---------------------------------------------------------------------------
235
+ # verify_handshake — defensive citation helper (Lane 5 R6 PB)
236
+ # ---------------------------------------------------------------------------
237
+
238
+
239
+ def test_verify_handshake_happy_path_returns_canonical_record(monkeypatch):
240
+ """2xx → return the JSON record as-is (the verify endpoint's success
241
+ shape). Caller checks .get('verified') / .get('actual_value')."""
242
+ monkeypatch.delenv("FLOPSINDEX_API_KEY", raising=False)
243
+ c = Client()
244
+ captured = {}
245
+
246
+ def fake_urlopen(req, timeout=None):
247
+ captured["url"] = req.full_url
248
+ return _FakeResp(json.dumps({
249
+ "verified": True, "index_id": "FLCI-H100",
250
+ "actual_value": 2.45, "delta_pct": 0.0,
251
+ "source_url": "https://app.flopsindex.com/i/FLCI-H100",
252
+ }).encode("utf-8"))
253
+ monkeypatch.setattr("flopsindex.client.urllib.request.urlopen", fake_urlopen)
254
+
255
+ out = c.verify_handshake("FLCI-H100", value=2.45)
256
+ assert out["verified"] is True
257
+ assert out["actual_value"] == 2.45
258
+ assert "value=2.45" in captured["url"]
259
+ assert "index_id=FLCI-H100" in captured["url"]
260
+
261
+
262
+ def test_verify_handshake_omits_value_when_none(monkeypatch):
263
+ """value=None → just ask 'what's the latest tick?' without committing."""
264
+ monkeypatch.delenv("FLOPSINDEX_API_KEY", raising=False)
265
+ c = Client()
266
+ captured = {}
267
+
268
+ def fake_urlopen(req, timeout=None):
269
+ captured["url"] = req.full_url
270
+ return _FakeResp(json.dumps({"actual_value": 2.45}).encode("utf-8"))
271
+ monkeypatch.setattr("flopsindex.client.urllib.request.urlopen", fake_urlopen)
272
+
273
+ c.verify_handshake("FLCI-H100")
274
+ assert "value=" not in captured["url"]
275
+ assert "index_id=FLCI-H100" in captured["url"]
276
+
277
+
278
+ def test_verify_handshake_500_returns_upstream_http_error(monkeypatch):
279
+ """Per 2026-05-19 grounding /v1/verify was 500'ing. The handshake
280
+ MUST NOT raise — it returns the defensive envelope so the
281
+ price→verify→cite idiom never explodes downstream while Lane 4
282
+ fixes upstream."""
283
+ import urllib.error
284
+ monkeypatch.delenv("FLOPSINDEX_API_KEY", raising=False)
285
+ c = Client()
286
+
287
+ def fake_urlopen(req, timeout=None):
288
+ raise urllib.error.HTTPError(
289
+ req.full_url, 500, "boom", {},
290
+ _FakeReader(json.dumps({"detail": "internal"}).encode("utf-8")),
291
+ )
292
+ monkeypatch.setattr("flopsindex.client.urllib.request.urlopen", fake_urlopen)
293
+
294
+ out = c.verify_handshake("FLCI-H100", value=2.45)
295
+ assert out["ok"] is False
296
+ assert out["reason"] == "upstream_http_error"
297
+ assert out["upstream_status"] == 500
298
+
299
+
300
+ @pytest.mark.parametrize("status,expected_reason", [
301
+ (401, "auth_required"),
302
+ (403, "auth_required"),
303
+ (404, "endpoint_pending"),
304
+ (400, "client_error"),
305
+ (502, "upstream_http_error"),
306
+ (503, "upstream_http_error"),
307
+ ])
308
+ def test_verify_handshake_maps_status_to_reason(monkeypatch, status, expected_reason):
309
+ """Each HTTP status code maps to the right defensive reason — the
310
+ same shape MCP's _defensive_get emits, so an agent switching between
311
+ MCP and direct SDK gets identical error envelopes."""
312
+ import urllib.error
313
+ monkeypatch.delenv("FLOPSINDEX_API_KEY", raising=False)
314
+ c = Client()
315
+
316
+ def fake_urlopen(req, timeout=None):
317
+ raise urllib.error.HTTPError(
318
+ req.full_url, status, "err", {},
319
+ _FakeReader(b'{"detail":"x"}'),
320
+ )
321
+ monkeypatch.setattr("flopsindex.client.urllib.request.urlopen", fake_urlopen)
322
+
323
+ out = c.verify_handshake("FLCI-H100", value=2.45)
324
+ assert out["ok"] is False
325
+ assert out["reason"] == expected_reason
326
+ assert out["upstream_status"] == status
327
+
328
+
329
+ def test_verify_handshake_network_error_returns_envelope(monkeypatch):
330
+ """URLError (DNS, connection refused, timeout) → defensive envelope,
331
+ not a raised exception. The handshake must survive a network blip."""
332
+ import urllib.error
333
+ monkeypatch.delenv("FLOPSINDEX_API_KEY", raising=False)
334
+ c = Client()
335
+
336
+ def fake_urlopen(req, timeout=None):
337
+ raise urllib.error.URLError("connection refused")
338
+ monkeypatch.setattr("flopsindex.client.urllib.request.urlopen", fake_urlopen)
339
+
340
+ out = c.verify_handshake("FLCI-H100", value=2.45)
341
+ assert out["ok"] is False
342
+ assert out["reason"] == "network_error"
343
+ assert "connection refused" in out["detail"]
344
+
345
+
346
+ def test_verify_legacy_method_still_raises(monkeypatch):
347
+ """Backward-compat: existing .verify(index_id, value) still raises on
348
+ non-2xx. Anyone who wants the defensive envelope opts in via
349
+ .verify_handshake() — no silent shape change for legacy callers."""
350
+ import urllib.error
351
+ monkeypatch.delenv("FLOPSINDEX_API_KEY", raising=False)
352
+ c = Client()
353
+
354
+ def fake_urlopen(req, timeout=None):
355
+ raise urllib.error.HTTPError(
356
+ req.full_url, 500, "boom", {},
357
+ _FakeReader(b'{"detail":"internal"}'),
358
+ )
359
+ monkeypatch.setattr("flopsindex.client.urllib.request.urlopen", fake_urlopen)
360
+
361
+ with pytest.raises(FlopsServerError):
362
+ c.verify("FLCI-H100", value=2.45)