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.
- weckr_sdk-0.1.0/.gitignore +11 -0
- weckr_sdk-0.1.0/LICENSE +21 -0
- weckr_sdk-0.1.0/PKG-INFO +138 -0
- weckr_sdk-0.1.0/README.md +82 -0
- weckr_sdk-0.1.0/pyproject.toml +51 -0
- weckr_sdk-0.1.0/weckr/__init__.py +13 -0
- weckr_sdk-0.1.0/weckr/cap.py +107 -0
- weckr_sdk-0.1.0/weckr/client.py +149 -0
- weckr_sdk-0.1.0/weckr/errors.py +39 -0
- weckr_sdk-0.1.0/weckr/logger.py +63 -0
- weckr_sdk-0.1.0/weckr/normalize.py +92 -0
- weckr_sdk-0.1.0/weckr/pricing.py +66 -0
- weckr_sdk-0.1.0/weckr/types.py +82 -0
weckr_sdk-0.1.0/LICENSE
ADDED
|
@@ -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.
|
weckr_sdk-0.1.0/PKG-INFO
ADDED
|
@@ -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,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
|
+
]
|