metrify-sdk 0.1.0__py3-none-any.whl

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.
metrify/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ from metrify.client import Metrify
2
+ from metrify.exceptions import (
3
+ ConsumerSuspendedError,
4
+ GatewayError,
5
+ InsufficientBalanceError,
6
+ ProviderNotFoundError,
7
+ ProviderSuspendedError,
8
+ UpstreamError,
9
+ )
10
+
11
+ __all__ = [
12
+ "Metrify",
13
+ "UpstreamError",
14
+ "InsufficientBalanceError",
15
+ "ProviderSuspendedError",
16
+ "ConsumerSuspendedError",
17
+ "ProviderNotFoundError",
18
+ "GatewayError",
19
+ ]
20
+ __version__ = "0.1.0"
metrify/billing.py ADDED
@@ -0,0 +1,85 @@
1
+ import httpx
2
+
3
+ from metrify.exceptions import (
4
+ ConsumerSuspendedError,
5
+ GatewayError,
6
+ InsufficientBalanceError,
7
+ ProviderNotFoundError,
8
+ ProviderSuspendedError,
9
+ )
10
+ from metrify.models import ChargeRequest, ChargeResponse
11
+
12
+ _ERROR_MAP = {
13
+ "INSUFFICIENT_BALANCE": InsufficientBalanceError,
14
+ "PROVIDER_SUSPENDED": ProviderSuspendedError,
15
+ "CONSUMER_SUSPENDED": ConsumerSuspendedError,
16
+ "PROVIDER_NOT_FOUND": ProviderNotFoundError,
17
+ }
18
+
19
+ _TIMEOUT = 10.0
20
+
21
+
22
+ class BillingClient:
23
+ def __init__(self, gateway_url: str, provider_key: str) -> None:
24
+ self._gateway_url = gateway_url.rstrip("/")
25
+ self._provider_key = provider_key
26
+
27
+ def check_balance(self, consumer_api_key: str, required: float) -> None:
28
+ """Verify consumer has at least `required` balance without charging.
29
+
30
+ Raises InsufficientBalanceError or GatewayError on failure.
31
+ """
32
+ try:
33
+ with httpx.Client(timeout=_TIMEOUT) as client:
34
+ response = client.get(
35
+ f"{self._gateway_url}/consumers/me",
36
+ headers={"X-API-Key": consumer_api_key},
37
+ )
38
+ except (httpx.TimeoutException, httpx.NetworkError, httpx.TransportError) as exc:
39
+ raise GatewayError(f"Request to billing gateway failed: {exc}") from exc
40
+
41
+ if response.status_code >= 500:
42
+ raise GatewayError(f"Billing gateway returned {response.status_code}")
43
+
44
+ if not response.is_success:
45
+ self._raise_api_error(response)
46
+
47
+ balance = float(response.json().get("balance", 0.0))
48
+ if balance < required:
49
+ raise InsufficientBalanceError(
50
+ f"Consumer balance {balance:.4f} is insufficient for cost {required:.4f}"
51
+ )
52
+
53
+ def charge(self, consumer_api_key: str, tool_name: str, cost: float) -> ChargeResponse:
54
+ request = ChargeRequest(
55
+ provider_key=self._provider_key,
56
+ tool_name=tool_name,
57
+ cost=cost,
58
+ )
59
+ try:
60
+ with httpx.Client(timeout=_TIMEOUT) as client:
61
+ response = client.post(
62
+ f"{self._gateway_url}/billing/charge",
63
+ json=request.model_dump(),
64
+ headers={"X-API-Key": consumer_api_key},
65
+ )
66
+ except (httpx.TimeoutException, httpx.NetworkError, httpx.TransportError) as exc:
67
+ raise GatewayError(f"Request to billing gateway failed: {exc}") from exc
68
+
69
+ if response.status_code >= 500:
70
+ raise GatewayError(f"Billing gateway returned {response.status_code}")
71
+
72
+ if not response.is_success:
73
+ self._raise_api_error(response)
74
+
75
+ return ChargeResponse.model_validate(response.json())
76
+
77
+ def _raise_api_error(self, response: httpx.Response) -> None:
78
+ try:
79
+ body = response.json()
80
+ error_code = body.get("error") or body.get("code") or ""
81
+ except Exception:
82
+ error_code = ""
83
+
84
+ exc_class = _ERROR_MAP.get(error_code, GatewayError)
85
+ raise exc_class(f"Billing gateway error {response.status_code}: {error_code}")
metrify/client.py ADDED
@@ -0,0 +1,47 @@
1
+ import os
2
+ from typing import Callable, Optional
3
+
4
+ from dotenv import load_dotenv
5
+
6
+ from metrify.billing import BillingClient
7
+ from metrify.decorator import make_tool_decorator
8
+ from metrify.models import BillingUnit
9
+
10
+ load_dotenv()
11
+
12
+ _DEFAULT_GATEWAY_URL = "https://airy-wholeness-production-fcc4.up.railway.app"
13
+
14
+
15
+ class Metrify:
16
+ def __init__(
17
+ self,
18
+ provider_key: Optional[str] = None,
19
+ gateway_url: Optional[str] = None,
20
+ ) -> None:
21
+ resolved_key = provider_key or os.environ.get("METRIFY_PROVIDER_KEY")
22
+ if not resolved_key:
23
+ raise ValueError(
24
+ "provider_key is required. Pass it to Metrify(provider_key=...) "
25
+ "or set the METRIFY_PROVIDER_KEY environment variable."
26
+ )
27
+
28
+ resolved_url = (
29
+ gateway_url
30
+ or os.environ.get("METRIFY_GATEWAY_URL")
31
+ or _DEFAULT_GATEWAY_URL
32
+ )
33
+
34
+ self._provider_key = resolved_key
35
+ self._gateway_url = resolved_url
36
+ self._billing = BillingClient(gateway_url=resolved_url, provider_key=resolved_key)
37
+
38
+ def tool(self, price: float, unit: BillingUnit = "per_call") -> Callable:
39
+ def decorator(func: Callable) -> Callable:
40
+ return make_tool_decorator(
41
+ billing=self._billing,
42
+ price=price,
43
+ unit=unit,
44
+ tool_name=func.__name__,
45
+ )(func)
46
+
47
+ return decorator
metrify/decorator.py ADDED
@@ -0,0 +1,65 @@
1
+ import functools
2
+ import inspect
3
+ from typing import Any, Callable
4
+
5
+ from metrify.billing import BillingClient
6
+ from metrify.exceptions import GatewayError, InsufficientBalanceError, UpstreamError
7
+ from metrify.models import BillingUnit
8
+
9
+
10
+ def _extract_consumer_key(args: tuple, kwargs: dict, func: Callable) -> str:
11
+ if "consumer_api_key" in kwargs:
12
+ return kwargs["consumer_api_key"]
13
+
14
+ sig = inspect.signature(func)
15
+ params = list(sig.parameters.keys())
16
+ if params and params[0] == "consumer_api_key" and args:
17
+ return args[0]
18
+
19
+ raise TypeError(
20
+ f"{func.__name__}() missing required argument 'consumer_api_key'. "
21
+ "Pass it as the first positional argument or as a keyword argument."
22
+ )
23
+
24
+
25
+ def make_tool_decorator(
26
+ billing: BillingClient,
27
+ price: float,
28
+ unit: BillingUnit,
29
+ tool_name: str,
30
+ ) -> Callable:
31
+ def decorator(func: Callable) -> Callable:
32
+ if inspect.iscoroutinefunction(func):
33
+ @functools.wraps(func)
34
+ async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
35
+ consumer_api_key = _extract_consumer_key(args, kwargs, func)
36
+ billing.check_balance(consumer_api_key=consumer_api_key, required=price)
37
+ try:
38
+ result = await func(*args, **kwargs)
39
+ except UpstreamError as exc:
40
+ return str(exc)
41
+ except (InsufficientBalanceError, GatewayError):
42
+ raise
43
+ except Exception as exc:
44
+ return f"Tool error: {exc}"
45
+ billing.charge(consumer_api_key=consumer_api_key, tool_name=tool_name, cost=price)
46
+ return result
47
+ return async_wrapper
48
+ else:
49
+ @functools.wraps(func)
50
+ def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
51
+ consumer_api_key = _extract_consumer_key(args, kwargs, func)
52
+ billing.check_balance(consumer_api_key=consumer_api_key, required=price)
53
+ try:
54
+ result = func(*args, **kwargs)
55
+ except UpstreamError as exc:
56
+ return str(exc)
57
+ except (InsufficientBalanceError, GatewayError):
58
+ raise
59
+ except Exception as exc:
60
+ return f"Tool error: {exc}"
61
+ billing.charge(consumer_api_key=consumer_api_key, tool_name=tool_name, cost=price)
62
+ return result
63
+ return sync_wrapper
64
+
65
+ return decorator
metrify/exceptions.py ADDED
@@ -0,0 +1,37 @@
1
+ class MetrifyError(Exception):
2
+ """Base exception for all metrify SDK errors."""
3
+
4
+
5
+ class InsufficientBalanceError(MetrifyError):
6
+ """Consumer does not have enough balance to pay for the tool call."""
7
+
8
+
9
+ class ProviderSuspendedError(MetrifyError):
10
+ """The provider account has been suspended."""
11
+
12
+
13
+ class ConsumerSuspendedError(MetrifyError):
14
+ """The consumer account has been suspended."""
15
+
16
+
17
+ class ProviderNotFoundError(MetrifyError):
18
+ """The provider key is invalid or not registered."""
19
+
20
+
21
+ class GatewayError(MetrifyError):
22
+ """Network error, timeout, or backend returned a 5xx response."""
23
+
24
+
25
+ class UpstreamError(MetrifyError):
26
+ """Raised by a tool when an upstream/external API fails.
27
+
28
+ The decorator catches this, skips billing, and returns the message to the
29
+ consumer as a formatted error string.
30
+
31
+ Example::
32
+
33
+ try:
34
+ result = await anthropic_client.complete(...)
35
+ except anthropic.APIError as e:
36
+ raise UpstreamError(f"Anthropic API error: {e.message}")
37
+ """
metrify/models.py ADDED
@@ -0,0 +1,25 @@
1
+ from typing import Literal
2
+ from pydantic import BaseModel
3
+
4
+ BillingUnit = Literal["per_call", "per_token", "per_minute", "per_image", "per_page"]
5
+
6
+
7
+ class ChargeRequest(BaseModel):
8
+ provider_key: str
9
+ tool_name: str
10
+ cost: float
11
+
12
+
13
+ class ChargeResponse(BaseModel):
14
+ transaction_id: str
15
+ status: str
16
+ cost: float
17
+ provider_share: float
18
+ metrify_fee: float
19
+ consumer_balance_after: float
20
+
21
+
22
+ class ToolConfig(BaseModel):
23
+ name: str
24
+ price: float
25
+ unit: BillingUnit
@@ -0,0 +1,176 @@
1
+ Metadata-Version: 2.1
2
+ Name: metrify-sdk
3
+ Version: 0.1.0
4
+ Summary: Billing SDK for MCP servers
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/mangelico/metrify-sdk
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: httpx>=0.27.0
10
+ Requires-Dist: pydantic>=2.0
11
+ Requires-Dist: python-dotenv>=1.0.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=8.0; extra == "dev"
14
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
15
+ Requires-Dist: pytest-mock>=3.12; extra == "dev"
16
+ Requires-Dist: respx>=0.21; extra == "dev"
17
+
18
+ # metrify
19
+
20
+ **Billing SDK for MCP servers.** Add pay-per-call monetization to any MCP tool
21
+ in under 5 minutes — no payment infrastructure required.
22
+
23
+ ---
24
+
25
+ ## Quickstart
26
+
27
+ ### 1. Register as a provider
28
+
29
+ Sign up at the Metrify dashboard and get your `pk_live_...` provider key.
30
+
31
+ ### 2. Install the SDK
32
+
33
+ ```bash
34
+ pip install metrify
35
+ ```
36
+
37
+ ### 3. Configure your provider key
38
+
39
+ ```bash
40
+ export METRIFY_PROVIDER_KEY="pk_live_your_key_here"
41
+ ```
42
+
43
+ Or pass it directly in code (see step 4).
44
+
45
+ ### 4. Decorate your tools
46
+
47
+ ```python
48
+ from metrify import Metrify
49
+
50
+ m = Metrify() # reads METRIFY_PROVIDER_KEY from env
51
+
52
+ @m.tool(price=0.01, unit="per_call")
53
+ async def summarize(consumer_api_key: str, text: str) -> str:
54
+ # Balance is pre-checked before this runs; charge happens only on success
55
+ return f"Summary of: {text[:50]}..."
56
+ ```
57
+
58
+ ### 5. Deploy
59
+
60
+ Your tool now charges consumers on every call. The `consumer_api_key` is
61
+ extracted automatically by the decorator — consumers pass it as the first
62
+ argument or as a keyword argument.
63
+
64
+ ---
65
+
66
+ ## Full example with FastMCP
67
+
68
+ ```python
69
+ from metrify import Metrify
70
+ from mcp.server.fastmcp import FastMCP
71
+
72
+ m = Metrify(provider_key="pk_live_your_key_here")
73
+ server = FastMCP("my-mcp-server")
74
+
75
+ @server.tool()
76
+ @m.tool(price=0.05, unit="per_call")
77
+ async def analyze_document(consumer_api_key: str, content: str) -> str:
78
+ """Analyze a document and return insights."""
79
+ # billing.charge() runs before this line
80
+ analysis = await run_analysis(content)
81
+ return analysis
82
+
83
+ @server.tool()
84
+ @m.tool(price=0.001, unit="per_token")
85
+ async def generate_text(consumer_api_key: str, prompt: str) -> str:
86
+ """Generate text from a prompt."""
87
+ result = await llm.complete(prompt)
88
+ return result
89
+ ```
90
+
91
+ ---
92
+
93
+ ## Billing flow
94
+
95
+ The decorator runs a **two-phase billing** flow on every call:
96
+
97
+ 1. **Pre-check** — verify the consumer has enough balance (no charge yet).
98
+ 2. **Execute** — run your tool function.
99
+ 3. **Charge** — deduct only if the function completed without error.
100
+
101
+ If the function raises any exception the consumer is never charged.
102
+
103
+ ## Exception reference
104
+
105
+ | Exception | Raised by | Behavior |
106
+ |-----------|-----------|----------|
107
+ | `InsufficientBalanceError` | Pre-check | Propagated — consumer has no funds |
108
+ | `UpstreamError` | Your tool | Caught — returns message to consumer, no charge |
109
+ | `ProviderSuspendedError` | Pre-check / charge | Propagated — your account is suspended |
110
+ | `ConsumerSuspendedError` | Pre-check / charge | Propagated — consumer account is suspended |
111
+ | `ProviderNotFoundError` | Pre-check / charge | Propagated — your provider key is invalid |
112
+ | `GatewayError` | Pre-check / charge | Propagated — network error or backend 5xx |
113
+ | Any other `Exception` | Your tool | Caught — returns `"Tool error: ..."`, no charge |
114
+
115
+ ### Signalling upstream failures with `UpstreamError`
116
+
117
+ Use `UpstreamError` when an external API your tool depends on fails. The
118
+ decorator catches it, skips billing, and returns your message to the consumer:
119
+
120
+ ```python
121
+ from metrify import Metrify, UpstreamError
122
+
123
+ m = Metrify()
124
+
125
+ @m.tool(price=0.05, unit="per_call")
126
+ async def summarize(consumer_api_key: str, text: str) -> str:
127
+ try:
128
+ result = await anthropic_client.messages.create(...)
129
+ except anthropic.APIError as e:
130
+ raise UpstreamError(f"Anthropic API error: {e.message}")
131
+ return result.content[0].text
132
+ ```
133
+
134
+ The consumer receives `"Anthropic API error: ..."` and is not charged.
135
+
136
+ ```python
137
+ from metrify.exceptions import InsufficientBalanceError, GatewayError
138
+
139
+ # In your MCP server error handler:
140
+ try:
141
+ result = await my_tool(consumer_api_key=ck, query=q)
142
+ except InsufficientBalanceError:
143
+ return "Insufficient balance. Please top up at the Metrify dashboard."
144
+ except GatewayError:
145
+ return "Billing service temporarily unavailable. Please retry."
146
+ ```
147
+
148
+ ---
149
+
150
+ ## Environment variables
151
+
152
+ | Variable | Default | Description |
153
+ |----------|---------|-------------|
154
+ | `METRIFY_PROVIDER_KEY` | — | Your provider key (`pk_live_...`) |
155
+ | `METRIFY_GATEWAY_URL` | `https://airy-wholeness-production-fcc4.up.railway.app` | Override backend URL |
156
+
157
+ ---
158
+
159
+ ## Supported billing units (V1)
160
+
161
+ All units charge a flat `price` per call. The `unit` field is displayed in your
162
+ dashboard for reporting purposes.
163
+
164
+ | Unit | Use case |
165
+ |------|----------|
166
+ | `per_call` | Fixed price per tool invocation |
167
+ | `per_token` | LLM completions (flat in V1) |
168
+ | `per_minute` | Audio/video processing |
169
+ | `per_image` | Image generation or analysis |
170
+ | `per_page` | Document processing |
171
+
172
+ ---
173
+
174
+ ## License
175
+
176
+ MIT
@@ -0,0 +1,10 @@
1
+ metrify/__init__.py,sha256=LkFYq9U0TIJbdOm_HJ7wYd4azvvM51kuX4spIbQR06M,434
2
+ metrify/billing.py,sha256=iS-5MQYd2Htsb3Y4WcGUjsIgqml6q-D8QlcW4S9i5J4,3129
3
+ metrify/client.py,sha256=X_F6pL0srSIi75Zy93LVAGaAv2fi83l9KfLcsQStOVM,1439
4
+ metrify/decorator.py,sha256=WMTrlD6bio5hhzawPdzlQ6wVAz6kdyUIXDnPPi2YznU,2522
5
+ metrify/exceptions.py,sha256=XgNYGpMkUjCAPNT8jz66XDC9JDZwjYCpG8iImTvkLek,1031
6
+ metrify/models.py,sha256=_UYhZ_ge32j4oW_f5-YTE8SnCFchL0LTGCnAwny-ZGo,494
7
+ metrify_sdk-0.1.0.dist-info/METADATA,sha256=f46sjpS6jX9ff2-mRTO6Nw044tEaOeOy4Zwshs0RtCk,5268
8
+ metrify_sdk-0.1.0.dist-info/WHEEL,sha256=BNRMDyzLkkcmlv0J8ppDQkk2VED33SesJDynr9ED1gc,91
9
+ metrify_sdk-0.1.0.dist-info/top_level.txt,sha256=aMBu6UysoY7pXDQfpfXLpTWXGV0tOgnX95Ha8t0tOFY,8
10
+ metrify_sdk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (75.3.4)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ metrify