weckr-sdk 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,11 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ .pytest_cache/
5
+ .venv/
6
+ venv/
7
+ dist/
8
+ build/
9
+ *.egg-info/
10
+ .env
11
+ .env.local
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Weckr
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.
@@ -0,0 +1,138 @@
1
+ Metadata-Version: 2.4
2
+ Name: weckr-sdk
3
+ Version: 0.1.0
4
+ Summary: AI cost and margin intelligence for SaaS founders
5
+ Project-URL: Homepage, https://useweckr.com
6
+ Project-URL: Documentation, https://useweckr.com/docs
7
+ Project-URL: Repository, https://github.com/Ghiles3232/weckr
8
+ Project-URL: Issues, https://github.com/Ghiles3232/weckr/issues
9
+ Author: Weckr
10
+ License: MIT License
11
+
12
+ Copyright (c) 2026 Weckr
13
+
14
+ Permission is hereby granted, free of charge, to any person obtaining a copy
15
+ of this software and associated documentation files (the "Software"), to deal
16
+ in the Software without restriction, including without limitation the rights
17
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
18
+ copies of the Software, and to permit persons to whom the Software is
19
+ furnished to do so, subject to the following conditions:
20
+
21
+ The above copyright notice and this permission notice shall be included in all
22
+ copies or substantial portions of the Software.
23
+
24
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
25
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
26
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
27
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
28
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
29
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
30
+ SOFTWARE.
31
+ License-File: LICENSE
32
+ Keywords: ai,anthropic,cost,finops,gemini,llm,monitoring,openai,saas
33
+ Classifier: Development Status :: 4 - Beta
34
+ Classifier: Intended Audience :: Developers
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Programming Language :: Python :: 3
37
+ Classifier: Programming Language :: Python :: 3.9
38
+ Classifier: Programming Language :: Python :: 3.10
39
+ Classifier: Programming Language :: Python :: 3.11
40
+ Classifier: Programming Language :: Python :: 3.12
41
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
42
+ Requires-Python: >=3.9
43
+ Provides-Extra: all
44
+ Requires-Dist: anthropic>=0.20.0; extra == 'all'
45
+ Requires-Dist: google-generativeai>=0.5.0; extra == 'all'
46
+ Requires-Dist: openai>=1.0.0; extra == 'all'
47
+ Provides-Extra: anthropic
48
+ Requires-Dist: anthropic>=0.20.0; extra == 'anthropic'
49
+ Provides-Extra: dev
50
+ Requires-Dist: pytest>=7.0; extra == 'dev'
51
+ Provides-Extra: gemini
52
+ Requires-Dist: google-generativeai>=0.5.0; extra == 'gemini'
53
+ Provides-Extra: openai
54
+ Requires-Dist: openai>=1.0.0; extra == 'openai'
55
+ Description-Content-Type: text/markdown
56
+
57
+ # weckr
58
+
59
+ Token-budget enforcement and observability for LLM apps. One line to wrap any OpenAI or Anthropic client.
60
+
61
+ ## Install
62
+
63
+ ```bash
64
+ pip install weckr
65
+ ```
66
+
67
+ ## Quick start
68
+
69
+ ```python
70
+ import os
71
+ from openai import OpenAI
72
+ from weckr import Weckr
73
+
74
+ wk = Weckr(api_key=os.environ["WK_API_KEY"])
75
+ client = wk.wrap(OpenAI())
76
+
77
+ resp = client.chat.completions.create(
78
+ model="gpt-4o-mini",
79
+ messages=[{"role": "user", "content": "Hello!"}],
80
+ )
81
+ print(resp.choices[0].message.content)
82
+ ```
83
+
84
+ Every call is logged to your Weckr dashboard with token counts, latency, and cost. If you exceed your daily token cap, the next call raises `WeckrCapError` instead of silently burning money.
85
+
86
+ ## How it works
87
+
88
+ `wk.wrap(client)` returns a proxy that:
89
+
90
+ 1. Checks your remaining budget before each call (cached, ~5ms overhead).
91
+ 2. Forwards the call to the underlying client unchanged.
92
+ 3. Fires a non-blocking log to `api.weckr.dev` with usage data.
93
+
94
+ Works with sync and async clients. Streaming is passed through transparently — usage is logged when the stream closes.
95
+
96
+ ## Anthropic
97
+
98
+ ```python
99
+ from anthropic import Anthropic
100
+ from weckr import Weckr
101
+
102
+ wk = Weckr(api_key=os.environ["WK_API_KEY"])
103
+ client = wk.wrap(Anthropic())
104
+
105
+ msg = client.messages.create(
106
+ model="claude-3-5-sonnet-latest",
107
+ max_tokens=1024,
108
+ messages=[{"role": "user", "content": "Hello!"}],
109
+ )
110
+ ```
111
+
112
+ ## Direct chat
113
+
114
+ If you don't want to wrap a client, use `wk.chat` directly:
115
+
116
+ ```python
117
+ text = wk.chat(
118
+ provider="openai",
119
+ model="gpt-4o-mini",
120
+ messages=[{"role": "user", "content": "Hi"}],
121
+ )
122
+ ```
123
+
124
+ `wk.chat` raises `WeckrCapError` if you're over budget.
125
+
126
+ ## Configuration
127
+
128
+ | Env var | Purpose |
129
+ | ---------------- | ---------------------------------------------- |
130
+ | `WK_API_KEY` | Your Weckr API key (starts with `wk_`) |
131
+ | `OPENAI_API_KEY` | Forwarded to the OpenAI client |
132
+ | `ANTHROPIC_API_KEY` | Forwarded to the Anthropic client |
133
+
134
+ Get your key at [weckr.dev](https://weckr.dev).
135
+
136
+ ## License
137
+
138
+ MIT
@@ -0,0 +1,82 @@
1
+ # weckr
2
+
3
+ Token-budget enforcement and observability for LLM apps. One line to wrap any OpenAI or Anthropic client.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install weckr
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ ```python
14
+ import os
15
+ from openai import OpenAI
16
+ from weckr import Weckr
17
+
18
+ wk = Weckr(api_key=os.environ["WK_API_KEY"])
19
+ client = wk.wrap(OpenAI())
20
+
21
+ resp = client.chat.completions.create(
22
+ model="gpt-4o-mini",
23
+ messages=[{"role": "user", "content": "Hello!"}],
24
+ )
25
+ print(resp.choices[0].message.content)
26
+ ```
27
+
28
+ Every call is logged to your Weckr dashboard with token counts, latency, and cost. If you exceed your daily token cap, the next call raises `WeckrCapError` instead of silently burning money.
29
+
30
+ ## How it works
31
+
32
+ `wk.wrap(client)` returns a proxy that:
33
+
34
+ 1. Checks your remaining budget before each call (cached, ~5ms overhead).
35
+ 2. Forwards the call to the underlying client unchanged.
36
+ 3. Fires a non-blocking log to `api.weckr.dev` with usage data.
37
+
38
+ Works with sync and async clients. Streaming is passed through transparently — usage is logged when the stream closes.
39
+
40
+ ## Anthropic
41
+
42
+ ```python
43
+ from anthropic import Anthropic
44
+ from weckr import Weckr
45
+
46
+ wk = Weckr(api_key=os.environ["WK_API_KEY"])
47
+ client = wk.wrap(Anthropic())
48
+
49
+ msg = client.messages.create(
50
+ model="claude-3-5-sonnet-latest",
51
+ max_tokens=1024,
52
+ messages=[{"role": "user", "content": "Hello!"}],
53
+ )
54
+ ```
55
+
56
+ ## Direct chat
57
+
58
+ If you don't want to wrap a client, use `wk.chat` directly:
59
+
60
+ ```python
61
+ text = wk.chat(
62
+ provider="openai",
63
+ model="gpt-4o-mini",
64
+ messages=[{"role": "user", "content": "Hi"}],
65
+ )
66
+ ```
67
+
68
+ `wk.chat` raises `WeckrCapError` if you're over budget.
69
+
70
+ ## Configuration
71
+
72
+ | Env var | Purpose |
73
+ | ---------------- | ---------------------------------------------- |
74
+ | `WK_API_KEY` | Your Weckr API key (starts with `wk_`) |
75
+ | `OPENAI_API_KEY` | Forwarded to the OpenAI client |
76
+ | `ANTHROPIC_API_KEY` | Forwarded to the Anthropic client |
77
+
78
+ Get your key at [weckr.dev](https://weckr.dev).
79
+
80
+ ## License
81
+
82
+ MIT
@@ -0,0 +1,51 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "weckr-sdk"
7
+ version = "0.1.0"
8
+ description = "AI cost and margin intelligence for SaaS founders"
9
+ readme = "README.md"
10
+ license = { file = "LICENSE" }
11
+ requires-python = ">=3.9"
12
+ authors = [
13
+ { name = "Weckr" }
14
+ ]
15
+ keywords = ["ai", "llm", "openai", "anthropic", "gemini", "cost", "monitoring", "saas", "finops"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.9",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Topic :: Software Development :: Libraries :: Python Modules",
26
+ ]
27
+ dependencies = []
28
+
29
+ [project.optional-dependencies]
30
+ openai = ["openai>=1.0.0"]
31
+ anthropic = ["anthropic>=0.20.0"]
32
+ gemini = ["google-generativeai>=0.5.0"]
33
+ all = ["openai>=1.0.0", "anthropic>=0.20.0", "google-generativeai>=0.5.0"]
34
+ dev = ["pytest>=7.0"]
35
+
36
+ [project.urls]
37
+ Homepage = "https://useweckr.com"
38
+ Documentation = "https://useweckr.com/docs"
39
+ Repository = "https://github.com/Ghiles3232/weckr"
40
+ Issues = "https://github.com/Ghiles3232/weckr/issues"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["weckr"]
44
+
45
+ [tool.hatch.build.targets.sdist]
46
+ include = [
47
+ "/weckr",
48
+ "/README.md",
49
+ "/LICENSE",
50
+ "/pyproject.toml",
51
+ ]
@@ -0,0 +1,13 @@
1
+ from __future__ import annotations
2
+
3
+ from .client import Weckr
4
+ from .errors import WeckrCapError, is_weckr_cap_error
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ __all__ = [
9
+ "Weckr",
10
+ "WeckrCapError",
11
+ "is_weckr_cap_error",
12
+ "__version__",
13
+ ]
@@ -0,0 +1,107 @@
1
+ from __future__ import annotations
2
+
3
+ """Spend-cap check against /api/v1/check with a 60-second in-memory cache.
4
+
5
+ The cache is keyed on (user_id, plan_name) per the spec — one entry per
6
+ (user, plan) pair. Failures fail open: if the cap service is unreachable,
7
+ returns CapCheckResult(allowed=True) so a broken service can never block
8
+ legitimate LLM traffic.
9
+
10
+ The real endpoint is GET /api/v1/check?userId=&planName=&model= with the
11
+ x-api-key header. We send GET with query params, not POST with a body.
12
+ """
13
+
14
+ import json
15
+ import time
16
+ import urllib.parse
17
+ import urllib.request
18
+ from dataclasses import dataclass
19
+ from typing import Dict, Optional, Tuple
20
+
21
+ DEFAULT_CHECK_ENDPOINT = "https://app.useweckr.com/api/v1/check"
22
+ _CACHE_TTL_SECONDS = 60.0
23
+ _CHECK_TIMEOUT_SECONDS = 3.0
24
+
25
+
26
+ @dataclass
27
+ class CapCheckResult:
28
+ """Decoded response from /api/v1/check.
29
+
30
+ `allowed=True` means the LLM call should proceed.
31
+ `allowed=False` plus `action='block'` means raise WeckrCapError.
32
+ `action='downgrade'` plus `alternative_model` means silently swap model.
33
+ """
34
+
35
+ allowed: bool = True
36
+ action: Optional[str] = None
37
+ alternative_model: Optional[str] = None
38
+ remaining_budget: Optional[float] = None
39
+ current_spend: Optional[float] = None
40
+ cap: Optional[float] = None
41
+
42
+
43
+ # Module-level cache: (user_id, plan_name) -> (result, expires_at_ms)
44
+ # Per-process. A new venv / serverless cold start resets it.
45
+ _CACHE: Dict[Tuple[str, str], Tuple[CapCheckResult, float]] = {}
46
+
47
+
48
+ def _cache_key(user_id: str, plan_name: str) -> Tuple[str, str]:
49
+ return (user_id, plan_name)
50
+
51
+
52
+ def check_cap(
53
+ check_endpoint: str,
54
+ api_key: str,
55
+ user_id: str,
56
+ plan_name: str,
57
+ model: Optional[str] = None,
58
+ disable_cap_check: bool = False,
59
+ ) -> CapCheckResult:
60
+ """Return the cap status for this (user_id, plan_name).
61
+
62
+ Cached for 60 seconds per pair — at most one extra request per (user, plan)
63
+ per minute. Fails open on any error.
64
+ """
65
+ if disable_cap_check or not user_id or not plan_name:
66
+ return CapCheckResult(allowed=True)
67
+
68
+ key = _cache_key(user_id, plan_name)
69
+ now = time.time()
70
+ cached = _CACHE.get(key)
71
+ if cached is not None:
72
+ result, expires_at = cached
73
+ if now < expires_at:
74
+ return result
75
+
76
+ # GET with query params + x-api-key header.
77
+ query: Dict[str, str] = {"userId": user_id, "planName": plan_name}
78
+ if model:
79
+ query["model"] = model
80
+ url = f"{check_endpoint}?{urllib.parse.urlencode(query)}"
81
+
82
+ try:
83
+ req = urllib.request.Request(
84
+ url,
85
+ method="GET",
86
+ headers={"x-api-key": api_key},
87
+ )
88
+ with urllib.request.urlopen(req, timeout=_CHECK_TIMEOUT_SECONDS) as resp:
89
+ body = resp.read()
90
+ data = json.loads(body.decode("utf-8"))
91
+ result = CapCheckResult(
92
+ allowed=bool(data.get("allowed", True)),
93
+ action=data.get("action"),
94
+ alternative_model=data.get("alternativeModel"),
95
+ remaining_budget=data.get("remainingBudget"),
96
+ current_spend=data.get("currentSpend"),
97
+ cap=data.get("cap"),
98
+ )
99
+ except Exception:
100
+ # Fail open — never block the user's app on our error.
101
+ result = CapCheckResult(allowed=True)
102
+
103
+ _CACHE[key] = (result, now + _CACHE_TTL_SECONDS)
104
+ return result
105
+
106
+
107
+ __all__ = ["CapCheckResult", "check_cap", "DEFAULT_CHECK_ENDPOINT", "_CACHE"]
@@ -0,0 +1,149 @@
1
+ from __future__ import annotations
2
+
3
+ """Main Weckr class. Mirrors the @weckr/sdk public interface for TypeScript:
4
+
5
+ wk = Weckr(api_key="wk_...", plans={"free": 0, "pro": 29})
6
+ result = wk.chat(openai, {
7
+ "model": "gpt-4o-mini",
8
+ "messages": [{"role": "user", "content": "Summarize this."}],
9
+ "user_id": user.id,
10
+ "feature": "ai-summary",
11
+ "plan": user.plan,
12
+ })
13
+
14
+ user_id, feature, plan, and model live INSIDE the params dict — same shape
15
+ as the TS SDK.
16
+ """
17
+
18
+ import time
19
+ import warnings
20
+ from datetime import datetime, timezone
21
+ from typing import Any, Callable, Dict, Optional
22
+
23
+ from .cap import check_cap
24
+ from .errors import WeckrCapError
25
+ from .logger import fire_and_forget_log
26
+ from .normalize import detect_provider, normalize_usage
27
+ from .pricing import calculate_cost
28
+
29
+ DEFAULT_LOG_ENDPOINT = "https://app.useweckr.com/api/v1/log"
30
+ DEFAULT_CHECK_ENDPOINT = "https://app.useweckr.com/api/v1/check"
31
+
32
+
33
+ class Weckr:
34
+ def __init__(
35
+ self,
36
+ api_key: str,
37
+ plans: Optional[Dict[str, float]] = None,
38
+ endpoint: str = DEFAULT_LOG_ENDPOINT,
39
+ check_endpoint: str = DEFAULT_CHECK_ENDPOINT,
40
+ disable_cap_check: bool = False,
41
+ on_error: Optional[Callable[[Exception], None]] = None,
42
+ ) -> None:
43
+ if not api_key:
44
+ raise ValueError("Weckr: api_key is required.")
45
+ self.api_key = api_key
46
+ self.plans: Dict[str, float] = plans or {}
47
+ self.endpoint = endpoint
48
+ self.check_endpoint = check_endpoint
49
+ self.disable_cap_check = disable_cap_check
50
+ self.on_error = on_error
51
+
52
+ def chat(self, client: Any, params: Dict[str, Any]) -> Any:
53
+ """Wrap any LLM client call. Returns the original result unchanged."""
54
+ # Shallow-copy so we never mutate the caller's dict.
55
+ params = dict(params)
56
+
57
+ user_id = params.pop("user_id", None)
58
+ feature = params.pop("feature", "unknown")
59
+ plan_name = params.pop("plan", None)
60
+ model = params.get("model", "unknown")
61
+ plan_revenue = float(self.plans.get(plan_name or "", 0))
62
+
63
+ # 1) Cap check before the LLM call (best-effort; fails open).
64
+ check = check_cap(
65
+ check_endpoint=self.check_endpoint,
66
+ api_key=self.api_key,
67
+ user_id=user_id or "",
68
+ plan_name=plan_name or "",
69
+ model=model,
70
+ disable_cap_check=self.disable_cap_check,
71
+ )
72
+ if not check.allowed:
73
+ if check.action == "block":
74
+ raise WeckrCapError(
75
+ f"Weckr: spending cap reached for user {user_id}",
76
+ user_id=user_id or "",
77
+ plan_name=plan_name or "",
78
+ current_spend=check.current_spend,
79
+ cap=check.cap,
80
+ )
81
+ if check.action == "downgrade" and check.alternative_model:
82
+ warnings.warn(
83
+ f"Weckr: downgraded {user_id} from {model} "
84
+ f"to {check.alternative_model}",
85
+ stacklevel=2,
86
+ )
87
+ params["model"] = check.alternative_model
88
+ model = check.alternative_model
89
+
90
+ # 2) Detect provider + call.
91
+ provider = detect_provider(client)
92
+ start = time.time()
93
+ result = self._call_provider(client, provider, params)
94
+ latency_ms = int((time.time() - start) * 1000)
95
+
96
+ # 3) Best-effort log. Never raises.
97
+ try:
98
+ input_tokens, output_tokens = normalize_usage(provider, result)
99
+ cost_usd = calculate_cost(model, input_tokens, output_tokens)
100
+ margin_usd = round(plan_revenue - cost_usd, 6)
101
+
102
+ payload: Dict[str, Any] = {
103
+ "userId": user_id,
104
+ "feature": feature,
105
+ "model": model,
106
+ "provider": provider,
107
+ "inputTokens": input_tokens,
108
+ "outputTokens": output_tokens,
109
+ "costUsd": cost_usd,
110
+ "latencyMs": latency_ms,
111
+ "planName": plan_name,
112
+ "planRevenueUsd": plan_revenue,
113
+ "marginUsd": margin_usd,
114
+ "timestamp": datetime.now(timezone.utc).isoformat(),
115
+ }
116
+ fire_and_forget_log(
117
+ endpoint=self.endpoint,
118
+ api_key=self.api_key,
119
+ payload=payload,
120
+ on_error=self.on_error,
121
+ )
122
+ except Exception as err: # never bubble logging failures
123
+ if self.on_error is not None:
124
+ try:
125
+ self.on_error(err)
126
+ except Exception:
127
+ pass
128
+
129
+ return result
130
+
131
+ def _call_provider(self, client: Any, provider: str, params: Dict[str, Any]) -> Any:
132
+ if provider == "openai":
133
+ return client.chat.completions.create(**params)
134
+ if provider == "anthropic":
135
+ return client.messages.create(**params)
136
+ if provider == "gemini":
137
+ model_name = params.get("model", "gemini-2.5-flash")
138
+ messages = params.get("messages", []) or []
139
+ prompt = " ".join(
140
+ m.get("content", "") if isinstance(m, dict) else str(m)
141
+ for m in messages
142
+ if isinstance(m, (dict, str))
143
+ )
144
+ gemini_model = client.GenerativeModel(model_name)
145
+ return gemini_model.generate_content(prompt)
146
+ raise ValueError(f"Unsupported provider: {provider}")
147
+
148
+
149
+ __all__ = ["Weckr", "DEFAULT_LOG_ENDPOINT", "DEFAULT_CHECK_ENDPOINT"]
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional
4
+
5
+
6
+ class WeckrCapError(Exception):
7
+ """Raised by `wk.chat(...)` when the configured spending cap has been hit
8
+ and the cap's action is `"block"`. The LLM call is never made.
9
+ """
10
+
11
+ name: str = "WeckrCapError"
12
+
13
+ def __init__(
14
+ self,
15
+ message: Optional[str] = None,
16
+ *,
17
+ user_id: Optional[str] = None,
18
+ plan_name: Optional[str] = None,
19
+ current_spend: Optional[float] = None,
20
+ cap: Optional[float] = None,
21
+ ) -> None:
22
+ msg = message or (
23
+ f"Weckr: spending cap reached for user {user_id} on plan {plan_name}"
24
+ )
25
+ super().__init__(msg)
26
+ self.user_id = user_id
27
+ self.plan_name = plan_name
28
+ self.current_spend = current_spend
29
+ self.cap = cap
30
+
31
+
32
+ def is_weckr_cap_error(e: object) -> bool:
33
+ """True when `e` is a `WeckrCapError` (matched by class or name)."""
34
+ if isinstance(e, WeckrCapError):
35
+ return True
36
+ return isinstance(e, BaseException) and getattr(e, "name", None) == "WeckrCapError"
37
+
38
+
39
+ __all__ = ["WeckrCapError", "is_weckr_cap_error"]
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ """Fire-and-forget logging to the Weckr ingest endpoint.
4
+
5
+ Uses urllib (stdlib) so the SDK has zero runtime dependencies. The POST runs
6
+ on a daemon thread so it never blocks the caller's LLM hot path. All errors
7
+ are swallowed silently unless an on_error callback is provided.
8
+ """
9
+
10
+ import json
11
+ import threading
12
+ import urllib.request
13
+ from typing import Any, Callable, Dict, Optional
14
+
15
+
16
+ DEFAULT_LOG_ENDPOINT = "https://app.useweckr.com/api/v1/log"
17
+
18
+
19
+ def fire_and_forget_log(
20
+ *,
21
+ endpoint: str,
22
+ api_key: str,
23
+ payload: Dict[str, Any],
24
+ timeout: float = 5.0,
25
+ on_error: Optional[Callable[[Exception], None]] = None,
26
+ ) -> None:
27
+ """POST `payload` to the Weckr ingest endpoint on a daemon thread.
28
+
29
+ Never raises — logging must never break the host application. If an
30
+ on_error callback is provided, it receives any exception (including a
31
+ synthesized Exception for non-2xx HTTP responses).
32
+ """
33
+
34
+ def _send() -> None:
35
+ try:
36
+ body = json.dumps(payload).encode("utf-8")
37
+ req = urllib.request.Request(
38
+ endpoint,
39
+ data=body,
40
+ method="POST",
41
+ headers={
42
+ "Content-Type": "application/json",
43
+ "x-api-key": api_key,
44
+ },
45
+ )
46
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
47
+ resp.read()
48
+ if resp.status >= 400 and on_error is not None:
49
+ try:
50
+ on_error(Exception(f"Weckr log failed: HTTP {resp.status}"))
51
+ except Exception:
52
+ pass
53
+ except Exception as err:
54
+ if on_error is not None:
55
+ try:
56
+ on_error(err)
57
+ except Exception:
58
+ pass
59
+
60
+ threading.Thread(target=_send, daemon=True).start()
61
+
62
+
63
+ __all__ = ["fire_and_forget_log", "DEFAULT_LOG_ENDPOINT"]
@@ -0,0 +1,92 @@
1
+ from __future__ import annotations
2
+
3
+ """Provider detection and usage normalization.
4
+
5
+ `detect_provider(client)` returns one of: "openai", "anthropic", "gemini",
6
+ "unknown" — based on the client's module path with a shape-based fallback.
7
+
8
+ `normalize_usage(provider, result)` returns a `(input_tokens, output_tokens)`
9
+ tuple. Missing or malformed fields collapse to 0.
10
+ """
11
+
12
+ import math
13
+ from typing import Any, Tuple
14
+
15
+
16
+ def _to_int(v: Any) -> int:
17
+ try:
18
+ if v is None:
19
+ return 0
20
+ n = float(v)
21
+ except (TypeError, ValueError):
22
+ return 0
23
+ if not math.isfinite(n):
24
+ return 0
25
+ return max(0, int(n))
26
+
27
+
28
+ def _get(obj: Any, key: str) -> Any:
29
+ if obj is None:
30
+ return None
31
+ if isinstance(obj, dict):
32
+ return obj.get(key)
33
+ return getattr(obj, key, None)
34
+
35
+
36
+ def detect_provider(client: Any) -> str:
37
+ if client is None:
38
+ return "unknown"
39
+
40
+ module_name = ""
41
+ try:
42
+ module_name = (type(client).__module__ or "").lower()
43
+ except Exception:
44
+ module_name = ""
45
+
46
+ if "openai" in module_name:
47
+ return "openai"
48
+ if "anthropic" in module_name or "claude" in module_name:
49
+ return "anthropic"
50
+ if (
51
+ "google.genai" in module_name
52
+ or "google.generativeai" in module_name
53
+ or "genai" in module_name
54
+ or "gemini" in module_name
55
+ ):
56
+ return "gemini"
57
+
58
+ return "unknown"
59
+
60
+
61
+ def normalize_usage(provider: str, result: Any) -> Tuple[int, int]:
62
+ """Return `(input_tokens, output_tokens)` from a provider response."""
63
+ if provider == "openai":
64
+ usage = _get(result, "usage")
65
+ prompt = _get(usage, "prompt_tokens")
66
+ if prompt is None:
67
+ prompt = _get(usage, "input_tokens")
68
+ completion = _get(usage, "completion_tokens")
69
+ if completion is None:
70
+ completion = _get(usage, "output_tokens")
71
+ return _to_int(prompt), _to_int(completion)
72
+
73
+ if provider == "anthropic":
74
+ usage = _get(result, "usage")
75
+ return _to_int(_get(usage, "input_tokens")), _to_int(_get(usage, "output_tokens"))
76
+
77
+ if provider == "gemini":
78
+ meta = _get(result, "usage_metadata")
79
+ if meta is None:
80
+ meta = _get(result, "usageMetadata")
81
+ prompt = _get(meta, "prompt_token_count")
82
+ if prompt is None:
83
+ prompt = _get(meta, "promptTokenCount")
84
+ completion = _get(meta, "candidates_token_count")
85
+ if completion is None:
86
+ completion = _get(meta, "candidatesTokenCount")
87
+ return _to_int(prompt), _to_int(completion)
88
+
89
+ return 0, 0
90
+
91
+
92
+ __all__ = ["detect_provider", "normalize_usage"]
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ """Per-million-token pricing + cheaper-alternative mapping.
4
+
5
+ `calculate_cost(model, input_tokens, output_tokens)` returns USD cost as a
6
+ float. Unknown models return 0.0 — server-side recalculation in
7
+ /api/v1/log catches the difference if it matters, and the SDK never crashes
8
+ the host app over a missing pricing row.
9
+ """
10
+
11
+ from typing import Dict, TypedDict
12
+
13
+
14
+ class ModelPricing(TypedDict):
15
+ input: float
16
+ output: float
17
+
18
+
19
+ # Per-million-token pricing for supported models. Same numbers as the
20
+ # TypeScript SDK + the backend's weckr-api/lib/caps.ts PRICING.
21
+ PRICING: Dict[str, ModelPricing] = {
22
+ # OpenAI
23
+ "gpt-4o": {"input": 2.50, "output": 10.00},
24
+ "gpt-4o-mini": {"input": 0.15, "output": 0.60},
25
+ "gpt-4-turbo": {"input": 2.50, "output": 10.00},
26
+ "gpt-3.5-turbo": {"input": 0.50, "output": 1.50},
27
+ # Anthropic
28
+ "claude-opus-4": {"input": 15.00, "output": 75.00},
29
+ "claude-sonnet-4": {"input": 3.00, "output": 15.00},
30
+ "claude-haiku-4-5": {"input": 0.80, "output": 4.00},
31
+ # Gemini
32
+ "gemini-2.5-pro": {"input": 1.25, "output": 10.00},
33
+ "gemini-2.5-flash": {"input": 0.15, "output": 0.60},
34
+ }
35
+
36
+
37
+ # Cheaper alternative per model — used when a cap's action is "downgrade".
38
+ # Same-provider only; never silently switch a customer to a different vendor.
39
+ CHEAPER_ALTERNATIVE: Dict[str, str] = {
40
+ # OpenAI
41
+ "gpt-4o": "gpt-4o-mini",
42
+ "gpt-4-turbo": "gpt-4o-mini",
43
+ "gpt-4": "gpt-4o-mini",
44
+ # Anthropic
45
+ "claude-opus-4": "claude-sonnet-4",
46
+ "claude-sonnet-4": "claude-haiku-4-5",
47
+ # Gemini
48
+ "gemini-2.5-pro": "gemini-2.5-flash",
49
+ "gemini-1.5-pro": "gemini-2.5-flash",
50
+ }
51
+
52
+
53
+ def calculate_cost(model: str, input_tokens: int, output_tokens: int) -> float:
54
+ """Return USD cost for `model` given input/output token counts.
55
+
56
+ Unknown models return 0.0.
57
+ """
58
+ pricing = PRICING.get(model)
59
+ if pricing is None:
60
+ return 0.0
61
+ return (
62
+ input_tokens * pricing["input"] + output_tokens * pricing["output"]
63
+ ) / 1_000_000.0
64
+
65
+
66
+ __all__ = ["ModelPricing", "PRICING", "CHEAPER_ALTERNATIVE", "calculate_cost"]
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Dict, List, Literal, Optional, TypedDict, Union
5
+
6
+ # Providers supported by the SDK
7
+ Provider = Literal["openai", "anthropic", "gemini"]
8
+ ProviderOrUnknown = Literal["openai", "anthropic", "gemini", "unknown"]
9
+
10
+ # Cap action surfaced by /api/v1/check
11
+ CapAction = Literal["block", "downgrade"]
12
+
13
+
14
+ class Message(TypedDict, total=False):
15
+ role: str
16
+ content: str
17
+
18
+
19
+ class NormalizedUsage(TypedDict):
20
+ inputTokens: int
21
+ outputTokens: int
22
+
23
+
24
+ class CapCheckResult(TypedDict, total=False):
25
+ allowed: bool
26
+ action: CapAction
27
+ alternativeModel: str
28
+ remainingBudget: float
29
+ currentSpend: float
30
+ cap: float
31
+
32
+
33
+ class LogPayload(TypedDict, total=False):
34
+ userId: Optional[str]
35
+ feature: Optional[str]
36
+ model: str
37
+ provider: str
38
+ inputTokens: int
39
+ outputTokens: int
40
+ costUsd: float
41
+ latencyMs: int
42
+ planName: Optional[str]
43
+ planRevenueUsd: Optional[float]
44
+ marginUsd: Optional[float]
45
+ timestamp: str
46
+
47
+
48
+ @dataclass
49
+ class WeckrConfig:
50
+ """Runtime configuration for a Weckr client."""
51
+
52
+ api_key: str
53
+ plans: Dict[str, float] = field(default_factory=dict)
54
+ endpoint: str = "https://www.weckr.dev/api/v1/log"
55
+ check_endpoint: Optional[str] = None
56
+ disable_cap_check: bool = False
57
+ on_error: Optional[Any] = None # Callable[[BaseException], None]
58
+
59
+
60
+ @dataclass
61
+ class ChatOptions:
62
+ """Normalized chat-call options. Provider-specific kwargs land in `extra`."""
63
+
64
+ model: str
65
+ messages: List[Dict[str, Any]] = field(default_factory=list)
66
+ user_id: Optional[str] = None
67
+ feature: Optional[str] = None
68
+ plan: Optional[str] = None
69
+ extra: Dict[str, Any] = field(default_factory=dict)
70
+
71
+
72
+ __all__ = [
73
+ "Provider",
74
+ "ProviderOrUnknown",
75
+ "CapAction",
76
+ "Message",
77
+ "NormalizedUsage",
78
+ "CapCheckResult",
79
+ "LogPayload",
80
+ "WeckrConfig",
81
+ "ChatOptions",
82
+ ]