singit 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.
- singit-0.1.0/.gitignore +75 -0
- singit-0.1.0/LICENSE +21 -0
- singit-0.1.0/PKG-INFO +145 -0
- singit-0.1.0/README.md +118 -0
- singit-0.1.0/pyproject.toml +47 -0
- singit-0.1.0/src/singit/__init__.py +5 -0
- singit-0.1.0/src/singit/client.py +256 -0
- singit-0.1.0/src/singit/guards.py +144 -0
- singit-0.1.0/src/singit/pins.py +56 -0
singit-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# macOS
|
|
2
|
+
.DS_Store
|
|
3
|
+
|
|
4
|
+
# Python
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.py[cod]
|
|
7
|
+
.pytest_cache/
|
|
8
|
+
.mypy_cache/
|
|
9
|
+
.ruff_cache/
|
|
10
|
+
*.egg-info/
|
|
11
|
+
|
|
12
|
+
# Node
|
|
13
|
+
node_modules/
|
|
14
|
+
|
|
15
|
+
# Local virtual environments
|
|
16
|
+
.venv/
|
|
17
|
+
venv/
|
|
18
|
+
env/
|
|
19
|
+
payment-executor/.venv/
|
|
20
|
+
|
|
21
|
+
# Secrets and local runtime config
|
|
22
|
+
# Ledger key ring ciphertext. The encrypted master key is still the master
|
|
23
|
+
# key: anyone who has it and WALLET_PASS has every managed wallet.
|
|
24
|
+
*.enc
|
|
25
|
+
.env
|
|
26
|
+
*.env
|
|
27
|
+
!*.env.example
|
|
28
|
+
payment-executor/.env
|
|
29
|
+
sign402-gateway/.bitrefill-api-key
|
|
30
|
+
.env.wallet-*
|
|
31
|
+
!.env.wallet-*.example
|
|
32
|
+
|
|
33
|
+
# Demo runtime artifacts
|
|
34
|
+
.demo-logs/
|
|
35
|
+
.ledger-live/
|
|
36
|
+
.graph-live/
|
|
37
|
+
# Runtime data retained at its historical path; includes SQLite sidecar files.
|
|
38
|
+
/demo-dashboard/
|
|
39
|
+
|
|
40
|
+
# Generated presentation scratch workspace
|
|
41
|
+
outputs/
|
|
42
|
+
output/
|
|
43
|
+
tmp/
|
|
44
|
+
SingIt-backgrounds.zip
|
|
45
|
+
SingIt-presentation-assets/
|
|
46
|
+
|
|
47
|
+
# Generated x402 survey exports; keep handwritten notes and patches visible.
|
|
48
|
+
/out/*.csv
|
|
49
|
+
/out/x402-services-live.md
|
|
50
|
+
|
|
51
|
+
# Local brainstorming sessions and preview server state
|
|
52
|
+
/.superpowers/
|
|
53
|
+
|
|
54
|
+
# Local tooling and duplicated/scratch workspaces
|
|
55
|
+
.tools/
|
|
56
|
+
tools/*
|
|
57
|
+
# …except the Ledger approval signer, which is part of the product rather than
|
|
58
|
+
# scratch: the local client signs a gateway operation on the owner's device.
|
|
59
|
+
!tools/ledger-approve/
|
|
60
|
+
x402HackBerlin/
|
|
61
|
+
.worktrees/
|
|
62
|
+
|
|
63
|
+
# Draft writing artifacts
|
|
64
|
+
Sign402 X Longform Post.md
|
|
65
|
+
|
|
66
|
+
# Local editor files
|
|
67
|
+
.vscode/
|
|
68
|
+
.idea/
|
|
69
|
+
/.codex/
|
|
70
|
+
|
|
71
|
+
# …but not its installed dependencies.
|
|
72
|
+
tools/ledger-approve/node_modules/
|
|
73
|
+
|
|
74
|
+
# Solana runtime state (wallets and payment journals)
|
|
75
|
+
.local/
|
singit-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SingIt
|
|
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.
|
singit-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: singit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: SingIt SDK: x402 payment and client-side spending allowance for SingIt Ask.
|
|
5
|
+
Project-URL: Homepage, https://singitai.app/developers/
|
|
6
|
+
Project-URL: Repository, https://github.com/bubon-ik/SingItAI
|
|
7
|
+
Project-URL: Source, https://github.com/bubon-ik/SingItAI/tree/main/sdk/python
|
|
8
|
+
Project-URL: Issues, https://github.com/bubon-ik/SingItAI/issues
|
|
9
|
+
Author-email: SingIt <contact@singitai.app>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: ai-agents,base,singit,solana,usdc,x402
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: x402[evm]<3,>=2.25.0
|
|
21
|
+
Provides-Extra: svm
|
|
22
|
+
Requires-Dist: x402[svm]<3,>=2.25.0; extra == 'svm'
|
|
23
|
+
Provides-Extra: test
|
|
24
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
25
|
+
Requires-Dist: tomli>=2; (python_version < '3.11') and extra == 'test'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# singit
|
|
29
|
+
|
|
30
|
+
x402 payment and client-side spending allowance for [SingIt Ask](../../singit-ask), in a few
|
|
31
|
+
lines. Limits are checked in your process before signing. The separate AgentAllowance
|
|
32
|
+
contract covers only the SingIt app's own agent; it does not protect other agents.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install singit # Base / Base Sepolia (EVM): upto + exact
|
|
38
|
+
pip install singit[svm] # adds Solana: exact only (see "What isn't supported yet")
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Base
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
import os
|
|
45
|
+
from singit import SingIt
|
|
46
|
+
|
|
47
|
+
singit = SingIt(
|
|
48
|
+
evm_private_key=os.environ["AGENT_PRIVATE_KEY"], # or evm_account=an eth_account LocalAccount
|
|
49
|
+
network="base",
|
|
50
|
+
max_per_call_usd=0.01,
|
|
51
|
+
max_per_day_usd=1,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
answer = singit.chat(messages=[{"role": "user", "content": "Hello"}])
|
|
55
|
+
print(answer["choices"][0]["message"]["content"], answer["payment"])
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Solana (exact only)
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
import os
|
|
62
|
+
from singit import SingIt
|
|
63
|
+
|
|
64
|
+
singit = SingIt(
|
|
65
|
+
solana_signer=solana_signer, # an x402[svm] ClientSvmSigner; requires `pip install singit[svm]`
|
|
66
|
+
network="solana",
|
|
67
|
+
max_per_call_usd=0.01,
|
|
68
|
+
max_per_day_usd=1,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
answer = singit.chat(messages=[{"role": "user", "content": "Hello"}], scheme="exact")
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Base Sepolia (testnet)
|
|
75
|
+
|
|
76
|
+
SingIt has no testnet deployment, so there is no pinned `pay_to` to default to: pass one
|
|
77
|
+
explicitly (e.g. your own test merchant, or a facilitator's own test receiver).
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
singit = SingIt(
|
|
81
|
+
evm_private_key=os.environ["AGENT_PRIVATE_KEY"], network="base-sepolia",
|
|
82
|
+
pay_to=os.environ["TEST_MERCHANT_PAY_TO"], # required: no SingIt deployment to pin a default against
|
|
83
|
+
max_per_call_usd=0.01, max_per_day_usd=1,
|
|
84
|
+
)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Guards
|
|
88
|
+
|
|
89
|
+
Every call pins the merchant's offer before signing anything, the same way
|
|
90
|
+
`singit-ask/src/metered-buyer.mjs` and `solana-buyer.mjs` do for the service's own buyers:
|
|
91
|
+
|
|
92
|
+
- **Network/asset/payTo.** The offer's `network`, `asset` and `payTo` must exactly match this
|
|
93
|
+
`SingIt` instance's configured network (the built-in pin for `base`/`solana`, or the `pay_to`
|
|
94
|
+
you pass for `base-sepolia`). A merchant offering a different asset or recipient is refused
|
|
95
|
+
before anything is signed.
|
|
96
|
+
- **Per-call ceiling (`max_per_call_usd`).** The offer's atomic amount must be at or below
|
|
97
|
+
`max_per_call_usd` converted to micro-USDC. A costlier offer is refused.
|
|
98
|
+
- **Per-day ceiling (`max_per_day_usd`).** An in-memory counter per client instance and per UTC day.
|
|
99
|
+
Reserved at the offer's worst-case ceiling before signing, settled down to the actual billed
|
|
100
|
+
amount for `upto` only. `exact` always counts the full offer amount. **Not durable**: it resets on restart and is not shared across processes —
|
|
101
|
+
it is a client-side allowance guard, not a ledger. For the SingIt app agent’s separate on-chain limit, see
|
|
102
|
+
`allowance()` below.
|
|
103
|
+
- **Settlement receipt.** The `payment-response` header's network and payer are checked against
|
|
104
|
+
the request; a mismatch is treated as unresolved rather than returned as a success.
|
|
105
|
+
|
|
106
|
+
This SDK does **not** wait for on-chain confirmation of the settlement transaction;
|
|
107
|
+
`payment["txHash"]` is returned as reported by the merchant's `payment-response` header for the
|
|
108
|
+
caller to check independently if needed.
|
|
109
|
+
|
|
110
|
+
## API
|
|
111
|
+
|
|
112
|
+
- `SingIt(...)` — `evm_private_key | evm_account` (EVM; `evm_account` is any `eth_account`
|
|
113
|
+
`LocalAccount`), `solana_signer` (an `x402[svm]` signer, for `network="solana"`), `network`,
|
|
114
|
+
`base_url` (default `https://ask.singitai.app`), `app_base_url` (default
|
|
115
|
+
`https://app.singitai.app`), `pay_to`, `rpc_url`, `max_per_call_usd`, `max_per_day_usd` (both
|
|
116
|
+
required).
|
|
117
|
+
- `chat(model=None, messages=..., max_tokens=None, scheme="upto", route="completions")` —
|
|
118
|
+
OpenAI-shaped chat completion. `scheme="upto"` (default) pays only the actual answer cost, up
|
|
119
|
+
to the offered ceiling, on EVM networks; `"exact"` pays that ceiling flat and is the only
|
|
120
|
+
scheme supported on Solana (see below). `route="completions"` (default) calls
|
|
121
|
+
`POST /v1/chat/completions`, live today. `route="per-model"` calls `POST /v1/chat/{model}`
|
|
122
|
+
(SI-62), live; `model` is then required.
|
|
123
|
+
- `wallet_report(address, days=None, api_key=None)` — `GET {app_base_url}/v1/wallet/{address}/reconciliation`
|
|
124
|
+
(SI-67). The report is private: `api_key` must be a SingIt API key of the account that signs in
|
|
125
|
+
with `address`; without one the server answers 401, and the SDK says so.
|
|
126
|
+
- `pricing()` — `GET {base_url}/pricing.json` (SI-68), live.
|
|
127
|
+
- `allowance(address, **kwargs)` — `wallet_report(address, **kwargs)["allowance"]`: the on-chain
|
|
128
|
+
spending limit set in the SingIt app, with what has been spent/remains today.
|
|
129
|
+
|
|
130
|
+
## What isn't supported yet
|
|
131
|
+
|
|
132
|
+
- **Solana `upto` is not supported.** The official `x402` Python package (2.25.0) ships an EVM
|
|
133
|
+
`upto` client (`x402.mechanisms.evm.upto.client.UptoEvmScheme`) but has no Solana equivalent —
|
|
134
|
+
only `x402.mechanisms.svm.exact.client.ExactSvmScheme`. This corrects an earlier assumption
|
|
135
|
+
that Python might lack EVM `upto` too: it does not, Base/Base Sepolia support both schemes.
|
|
136
|
+
Solana support here is `exact` only, and calling `chat(scheme="upto", network="solana")` raises
|
|
137
|
+
clearly rather than silently falling back.
|
|
138
|
+
- No on-chain confirmation of the settlement transaction.
|
|
139
|
+
- The Solana `exact` client in the official package resolves a blockhash over a live RPC call
|
|
140
|
+
during payload construction, so it cannot be exercised fully offline; this SDK's own test suite
|
|
141
|
+
therefore only covers Solana at the offer-selection/guard level (see `tests/test_solana_offer.py`).
|
|
142
|
+
- `base-sepolia` is useful for integration-testing against a merchant you control; SingIt itself
|
|
143
|
+
has no testnet deployment to pay.
|
|
144
|
+
|
|
145
|
+
Framework fetch adapters are currently available only in the TypeScript SDK.
|
singit-0.1.0/README.md
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# singit
|
|
2
|
+
|
|
3
|
+
x402 payment and client-side spending allowance for [SingIt Ask](../../singit-ask), in a few
|
|
4
|
+
lines. Limits are checked in your process before signing. The separate AgentAllowance
|
|
5
|
+
contract covers only the SingIt app's own agent; it does not protect other agents.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install singit # Base / Base Sepolia (EVM): upto + exact
|
|
11
|
+
pip install singit[svm] # adds Solana: exact only (see "What isn't supported yet")
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Base
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
import os
|
|
18
|
+
from singit import SingIt
|
|
19
|
+
|
|
20
|
+
singit = SingIt(
|
|
21
|
+
evm_private_key=os.environ["AGENT_PRIVATE_KEY"], # or evm_account=an eth_account LocalAccount
|
|
22
|
+
network="base",
|
|
23
|
+
max_per_call_usd=0.01,
|
|
24
|
+
max_per_day_usd=1,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
answer = singit.chat(messages=[{"role": "user", "content": "Hello"}])
|
|
28
|
+
print(answer["choices"][0]["message"]["content"], answer["payment"])
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Solana (exact only)
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
import os
|
|
35
|
+
from singit import SingIt
|
|
36
|
+
|
|
37
|
+
singit = SingIt(
|
|
38
|
+
solana_signer=solana_signer, # an x402[svm] ClientSvmSigner; requires `pip install singit[svm]`
|
|
39
|
+
network="solana",
|
|
40
|
+
max_per_call_usd=0.01,
|
|
41
|
+
max_per_day_usd=1,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
answer = singit.chat(messages=[{"role": "user", "content": "Hello"}], scheme="exact")
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Base Sepolia (testnet)
|
|
48
|
+
|
|
49
|
+
SingIt has no testnet deployment, so there is no pinned `pay_to` to default to: pass one
|
|
50
|
+
explicitly (e.g. your own test merchant, or a facilitator's own test receiver).
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
singit = SingIt(
|
|
54
|
+
evm_private_key=os.environ["AGENT_PRIVATE_KEY"], network="base-sepolia",
|
|
55
|
+
pay_to=os.environ["TEST_MERCHANT_PAY_TO"], # required: no SingIt deployment to pin a default against
|
|
56
|
+
max_per_call_usd=0.01, max_per_day_usd=1,
|
|
57
|
+
)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Guards
|
|
61
|
+
|
|
62
|
+
Every call pins the merchant's offer before signing anything, the same way
|
|
63
|
+
`singit-ask/src/metered-buyer.mjs` and `solana-buyer.mjs` do for the service's own buyers:
|
|
64
|
+
|
|
65
|
+
- **Network/asset/payTo.** The offer's `network`, `asset` and `payTo` must exactly match this
|
|
66
|
+
`SingIt` instance's configured network (the built-in pin for `base`/`solana`, or the `pay_to`
|
|
67
|
+
you pass for `base-sepolia`). A merchant offering a different asset or recipient is refused
|
|
68
|
+
before anything is signed.
|
|
69
|
+
- **Per-call ceiling (`max_per_call_usd`).** The offer's atomic amount must be at or below
|
|
70
|
+
`max_per_call_usd` converted to micro-USDC. A costlier offer is refused.
|
|
71
|
+
- **Per-day ceiling (`max_per_day_usd`).** An in-memory counter per client instance and per UTC day.
|
|
72
|
+
Reserved at the offer's worst-case ceiling before signing, settled down to the actual billed
|
|
73
|
+
amount for `upto` only. `exact` always counts the full offer amount. **Not durable**: it resets on restart and is not shared across processes —
|
|
74
|
+
it is a client-side allowance guard, not a ledger. For the SingIt app agent’s separate on-chain limit, see
|
|
75
|
+
`allowance()` below.
|
|
76
|
+
- **Settlement receipt.** The `payment-response` header's network and payer are checked against
|
|
77
|
+
the request; a mismatch is treated as unresolved rather than returned as a success.
|
|
78
|
+
|
|
79
|
+
This SDK does **not** wait for on-chain confirmation of the settlement transaction;
|
|
80
|
+
`payment["txHash"]` is returned as reported by the merchant's `payment-response` header for the
|
|
81
|
+
caller to check independently if needed.
|
|
82
|
+
|
|
83
|
+
## API
|
|
84
|
+
|
|
85
|
+
- `SingIt(...)` — `evm_private_key | evm_account` (EVM; `evm_account` is any `eth_account`
|
|
86
|
+
`LocalAccount`), `solana_signer` (an `x402[svm]` signer, for `network="solana"`), `network`,
|
|
87
|
+
`base_url` (default `https://ask.singitai.app`), `app_base_url` (default
|
|
88
|
+
`https://app.singitai.app`), `pay_to`, `rpc_url`, `max_per_call_usd`, `max_per_day_usd` (both
|
|
89
|
+
required).
|
|
90
|
+
- `chat(model=None, messages=..., max_tokens=None, scheme="upto", route="completions")` —
|
|
91
|
+
OpenAI-shaped chat completion. `scheme="upto"` (default) pays only the actual answer cost, up
|
|
92
|
+
to the offered ceiling, on EVM networks; `"exact"` pays that ceiling flat and is the only
|
|
93
|
+
scheme supported on Solana (see below). `route="completions"` (default) calls
|
|
94
|
+
`POST /v1/chat/completions`, live today. `route="per-model"` calls `POST /v1/chat/{model}`
|
|
95
|
+
(SI-62), live; `model` is then required.
|
|
96
|
+
- `wallet_report(address, days=None, api_key=None)` — `GET {app_base_url}/v1/wallet/{address}/reconciliation`
|
|
97
|
+
(SI-67). The report is private: `api_key` must be a SingIt API key of the account that signs in
|
|
98
|
+
with `address`; without one the server answers 401, and the SDK says so.
|
|
99
|
+
- `pricing()` — `GET {base_url}/pricing.json` (SI-68), live.
|
|
100
|
+
- `allowance(address, **kwargs)` — `wallet_report(address, **kwargs)["allowance"]`: the on-chain
|
|
101
|
+
spending limit set in the SingIt app, with what has been spent/remains today.
|
|
102
|
+
|
|
103
|
+
## What isn't supported yet
|
|
104
|
+
|
|
105
|
+
- **Solana `upto` is not supported.** The official `x402` Python package (2.25.0) ships an EVM
|
|
106
|
+
`upto` client (`x402.mechanisms.evm.upto.client.UptoEvmScheme`) but has no Solana equivalent —
|
|
107
|
+
only `x402.mechanisms.svm.exact.client.ExactSvmScheme`. This corrects an earlier assumption
|
|
108
|
+
that Python might lack EVM `upto` too: it does not, Base/Base Sepolia support both schemes.
|
|
109
|
+
Solana support here is `exact` only, and calling `chat(scheme="upto", network="solana")` raises
|
|
110
|
+
clearly rather than silently falling back.
|
|
111
|
+
- No on-chain confirmation of the settlement transaction.
|
|
112
|
+
- The Solana `exact` client in the official package resolves a blockhash over a live RPC call
|
|
113
|
+
during payload construction, so it cannot be exercised fully offline; this SDK's own test suite
|
|
114
|
+
therefore only covers Solana at the offer-selection/guard level (see `tests/test_solana_offer.py`).
|
|
115
|
+
- `base-sepolia` is useful for integration-testing against a merchant you control; SingIt itself
|
|
116
|
+
has no testnet deployment to pay.
|
|
117
|
+
|
|
118
|
+
Framework fetch adapters are currently available only in the TypeScript SDK.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "singit"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "SingIt SDK: x402 payment and client-side spending allowance for SingIt Ask."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{name = "SingIt", email = "contact@singitai.app"}]
|
|
14
|
+
keywords = ["singit", "x402", "usdc", "base", "solana", "ai-agents"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"x402[evm]>=2.25.0,<3",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
# Solana exact-scheme payments. The official x402 package has no Solana upto client yet (see README).
|
|
29
|
+
svm = [
|
|
30
|
+
"x402[svm]>=2.25.0,<3",
|
|
31
|
+
]
|
|
32
|
+
test = [
|
|
33
|
+
"pytest>=8",
|
|
34
|
+
"tomli>=2; python_version < '3.11'",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["src/singit"]
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
Homepage = "https://singitai.app/developers/"
|
|
42
|
+
Repository = "https://github.com/bubon-ik/SingItAI"
|
|
43
|
+
Source = "https://github.com/bubon-ik/SingItAI/tree/main/sdk/python"
|
|
44
|
+
Issues = "https://github.com/bubon-ik/SingItAI/issues"
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.sdist]
|
|
47
|
+
include = ["src/singit", "README.md", "LICENSE", "pyproject.toml"]
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""SingIt: x402 payment and client-side spending allowance for SingIt Ask, in a few lines::
|
|
2
|
+
|
|
3
|
+
singit = SingIt(evm_private_key=..., network="base", max_per_call_usd=0.01, max_per_day_usd=1)
|
|
4
|
+
answer = singit.chat(messages=[{"role": "user", "content": "Hello"}])
|
|
5
|
+
|
|
6
|
+
Every payment is checked against a pinned network/asset/payTo and a per-call and per-day USDC
|
|
7
|
+
ceiling before anything is signed; see guards.py. Nothing here ever raises a configured ceiling.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.parse
|
|
15
|
+
import urllib.request
|
|
16
|
+
|
|
17
|
+
from .guards import DailyLimiter, SingItPaymentError, decode_header, encode_header, is_atomic_amount, select_offer, usd_to_atomic
|
|
18
|
+
from .pins import DEFAULT_APP_BASE_URL, DEFAULT_BASE_URL, NETWORKS, ROUTE, per_model_route
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _post_json(url: str, body: bytes, headers: dict, timeout: float = 90.0):
|
|
22
|
+
request = urllib.request.Request(url, data=body, headers={"Content-Type": "application/json", **headers}, method="POST")
|
|
23
|
+
try:
|
|
24
|
+
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310 (merchant URL is caller-configured)
|
|
25
|
+
return response.status, response.headers, response.read()
|
|
26
|
+
except urllib.error.HTTPError as error:
|
|
27
|
+
return error.code, error.headers, error.read()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _get_json(url: str, timeout: float = 30.0, headers: dict | None = None):
|
|
31
|
+
request = urllib.request.Request(url, method="GET", headers=headers or {})
|
|
32
|
+
try:
|
|
33
|
+
with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
|
|
34
|
+
return response.status, response.read()
|
|
35
|
+
except urllib.error.HTTPError as error:
|
|
36
|
+
return error.code, error.read()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _required_model(model: str | None) -> str:
|
|
40
|
+
if not model:
|
|
41
|
+
raise SingItPaymentError('model is required when route is "per-model".')
|
|
42
|
+
return model
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _payer_from_payload(payload: dict) -> str | None:
|
|
46
|
+
inner = payload.get("payload") or {}
|
|
47
|
+
auth = inner.get("permit2Authorization") or inner.get("authorization")
|
|
48
|
+
if auth:
|
|
49
|
+
return auth.get("from")
|
|
50
|
+
return inner.get("from")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _same_payer(family: str, a: object, b: object) -> bool:
|
|
54
|
+
if not isinstance(a, str) or not isinstance(b, str):
|
|
55
|
+
return False
|
|
56
|
+
return a.lower() == b.lower() if family == "evm" else a == b
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SingIt:
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
*,
|
|
63
|
+
evm_private_key: str | None = None,
|
|
64
|
+
evm_account: object | None = None,
|
|
65
|
+
solana_signer: object | None = None,
|
|
66
|
+
network: str,
|
|
67
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
68
|
+
app_base_url: str = DEFAULT_APP_BASE_URL,
|
|
69
|
+
pay_to: str | None = None,
|
|
70
|
+
rpc_url: str | None = None,
|
|
71
|
+
max_per_call_usd: float | None = None,
|
|
72
|
+
max_per_day_usd: float | None = None,
|
|
73
|
+
):
|
|
74
|
+
pin = NETWORKS.get(network)
|
|
75
|
+
if pin is None:
|
|
76
|
+
raise SingItPaymentError(f'Unknown network "{network}". Use "base", "base-sepolia" or "solana".')
|
|
77
|
+
if pin.pay_to is None and not pay_to:
|
|
78
|
+
raise SingItPaymentError(f"SingIt has no deployment on {network}; pass pay_to explicitly (see README).")
|
|
79
|
+
if max_per_call_usd is None or max_per_day_usd is None:
|
|
80
|
+
raise SingItPaymentError("max_per_call_usd and max_per_day_usd are required: this SDK never pays without a ceiling.")
|
|
81
|
+
self.network = network
|
|
82
|
+
self.pin = pin
|
|
83
|
+
self.base_url = base_url.rstrip("/")
|
|
84
|
+
self.app_base_url = app_base_url.rstrip("/")
|
|
85
|
+
self.pay_to = pay_to or pin.pay_to
|
|
86
|
+
self.rpc_url = rpc_url or pin.default_rpc_url
|
|
87
|
+
self.max_per_call_atomic = usd_to_atomic(max_per_call_usd)
|
|
88
|
+
self.limiter = DailyLimiter(usd_to_atomic(max_per_day_usd))
|
|
89
|
+
self._evm_account = None
|
|
90
|
+
if pin.family == "evm":
|
|
91
|
+
if evm_account is not None:
|
|
92
|
+
self._evm_account = evm_account
|
|
93
|
+
elif evm_private_key is not None:
|
|
94
|
+
from eth_account import Account
|
|
95
|
+
|
|
96
|
+
self._evm_account = Account.from_key(evm_private_key)
|
|
97
|
+
else:
|
|
98
|
+
raise SingItPaymentError("Pass evm_private_key or evm_account to pay on an EVM network.")
|
|
99
|
+
self._solana_signer = solana_signer
|
|
100
|
+
|
|
101
|
+
def _sign(self, scheme: str, offer: dict, extensions: dict | None) -> dict:
|
|
102
|
+
from x402.schemas import PaymentRequirements
|
|
103
|
+
|
|
104
|
+
requirements = PaymentRequirements.model_validate(offer)
|
|
105
|
+
if self.pin.family == "evm":
|
|
106
|
+
if scheme == "upto":
|
|
107
|
+
from x402.mechanisms.evm.upto.client import UptoEvmScheme as Scheme
|
|
108
|
+
else:
|
|
109
|
+
from x402.mechanisms.evm.exact.client import ExactEvmScheme as Scheme
|
|
110
|
+
inner = Scheme(self._evm_account).create_payment_payload(requirements, extensions=extensions)
|
|
111
|
+
payload_extensions = inner.pop("__extensions", None)
|
|
112
|
+
payload = {"x402Version": 2, "accepted": offer, "payload": inner}
|
|
113
|
+
if payload_extensions:
|
|
114
|
+
payload["extensions"] = payload_extensions
|
|
115
|
+
return payload
|
|
116
|
+
# Solana: the official x402 Python package has no `upto` SVM client yet (see README).
|
|
117
|
+
if scheme != "exact":
|
|
118
|
+
raise SingItPaymentError("The official x402 Python package has no Solana `upto` client yet; use scheme='exact'.")
|
|
119
|
+
if not self._solana_signer:
|
|
120
|
+
raise SingItPaymentError("Pass solana_signer (and install singit[svm]) to pay on Solana.")
|
|
121
|
+
from x402.mechanisms.svm.exact.client import ExactSvmScheme
|
|
122
|
+
|
|
123
|
+
inner = ExactSvmScheme(self._solana_signer, rpc_url=self.rpc_url).create_payment_payload(requirements)
|
|
124
|
+
return {"x402Version": 2, "accepted": offer, "payload": inner}
|
|
125
|
+
|
|
126
|
+
def chat(
|
|
127
|
+
self,
|
|
128
|
+
*,
|
|
129
|
+
model: str | None = None,
|
|
130
|
+
messages: list[dict],
|
|
131
|
+
max_tokens: int | None = None,
|
|
132
|
+
scheme: str = "upto",
|
|
133
|
+
route: str = "completions",
|
|
134
|
+
) -> dict:
|
|
135
|
+
"""One OpenAI-shaped chat completion, paid over x402.
|
|
136
|
+
|
|
137
|
+
`route="per-model"` calls POST /v1/chat/{model} (SI-62), which is live;
|
|
138
|
+
the default calls POST /v1/chat/completions, which is live today.
|
|
139
|
+
"""
|
|
140
|
+
if not messages:
|
|
141
|
+
raise SingItPaymentError("messages must be a non-empty list.")
|
|
142
|
+
if scheme not in ("upto", "exact"):
|
|
143
|
+
raise SingItPaymentError('scheme must be "upto" or "exact".')
|
|
144
|
+
if route not in ("completions", "per-model"):
|
|
145
|
+
raise SingItPaymentError('route must be "completions" or "per-model".')
|
|
146
|
+
path = per_model_route(_required_model(model)) if route == "per-model" else ROUTE
|
|
147
|
+
url = f"{self.base_url}{path}"
|
|
148
|
+
body = {"messages": messages, "stream": False}
|
|
149
|
+
if max_tokens is not None:
|
|
150
|
+
body["max_tokens"] = max_tokens
|
|
151
|
+
if model and route == "completions":
|
|
152
|
+
body["model"] = model
|
|
153
|
+
encoded = json.dumps(body).encode("utf-8")
|
|
154
|
+
|
|
155
|
+
status, headers, raw = _post_json(url, encoded, {})
|
|
156
|
+
if status != 402:
|
|
157
|
+
if status >= 400:
|
|
158
|
+
raise SingItPaymentError(f"Request failed with HTTP {status}: {raw[:300]!r}")
|
|
159
|
+
return {**json.loads(raw), "payment": None}
|
|
160
|
+
|
|
161
|
+
header = headers.get("payment-required")
|
|
162
|
+
if not header:
|
|
163
|
+
raise SingItPaymentError("Got HTTP 402 with no PAYMENT-REQUIRED header.")
|
|
164
|
+
challenge = decode_header(header)
|
|
165
|
+
offer = select_offer(
|
|
166
|
+
challenge,
|
|
167
|
+
scheme=scheme,
|
|
168
|
+
network_id=self.pin.caip,
|
|
169
|
+
family=self.pin.family,
|
|
170
|
+
asset=self.pin.asset,
|
|
171
|
+
pay_to=self.pay_to,
|
|
172
|
+
max_cap_atomic=self.max_per_call_atomic,
|
|
173
|
+
resource_url=url,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
release = self.limiter.reserve(offer["amount"])
|
|
177
|
+
try:
|
|
178
|
+
payload = self._sign(scheme, offer, challenge.get("extensions"))
|
|
179
|
+
except Exception:
|
|
180
|
+
release()
|
|
181
|
+
raise
|
|
182
|
+
|
|
183
|
+
# Once a signed payment has left, only a 402 (payment refused) proves nothing settled. A dropped connection,
|
|
184
|
+
# another status or an unreadable invoice may still have settled, so the worst-case reservation stays
|
|
185
|
+
# counted against today's cap (Codex review).
|
|
186
|
+
try:
|
|
187
|
+
status, headers, raw = _post_json(url, encoded, {"PAYMENT-SIGNATURE": encode_header(payload)})
|
|
188
|
+
except Exception as error:
|
|
189
|
+
raise SingItPaymentError(
|
|
190
|
+
f"The paid request failed after the payment was sent ({error}); it was not repeated and its "
|
|
191
|
+
"ceiling stays counted against the daily cap.") from error
|
|
192
|
+
if status == 402:
|
|
193
|
+
release()
|
|
194
|
+
raise SingItPaymentError("The merchant refused the payment (HTTP 402); nothing was paid and it was not repeated.")
|
|
195
|
+
if status != 200:
|
|
196
|
+
raise SingItPaymentError(
|
|
197
|
+
f"Paid request returned HTTP {status}; it was not repeated, and its ceiling stays counted against "
|
|
198
|
+
"the daily cap in case it settled.")
|
|
199
|
+
try:
|
|
200
|
+
result = json.loads(raw)
|
|
201
|
+
except ValueError:
|
|
202
|
+
result = {}
|
|
203
|
+
total_atomic = ((result or {}).get("billing") or {}).get("totalAtomic")
|
|
204
|
+
if (not is_atomic_amount(total_atomic) or int(total_atomic) > int(offer["amount"])
|
|
205
|
+
or (scheme == "exact" and int(total_atomic) != int(offer["amount"]))):
|
|
206
|
+
raise SingItPaymentError("The invoice does not match the offered ceiling; treating the payment as unresolved "
|
|
207
|
+
"(its ceiling stays counted against the daily cap).")
|
|
208
|
+
|
|
209
|
+
receipt_header = headers.get("payment-response")
|
|
210
|
+
receipt = decode_header(receipt_header) if receipt_header else None
|
|
211
|
+
payer = _payer_from_payload(payload)
|
|
212
|
+
if receipt and (
|
|
213
|
+
receipt.get("network") != self.pin.caip
|
|
214
|
+
or (payer and receipt.get("payer") and not _same_payer(self.pin.family, receipt["payer"], payer))
|
|
215
|
+
):
|
|
216
|
+
raise SingItPaymentError("The settlement receipt does not match this request; treating the payment as unresolved.")
|
|
217
|
+
|
|
218
|
+
if scheme == "upto":
|
|
219
|
+
self.limiter.settle(release, total_atomic, offer["amount"])
|
|
220
|
+
|
|
221
|
+
payment_info = {"scheme": scheme, "network": self.network, "amountAtomic": total_atomic}
|
|
222
|
+
if receipt and receipt.get("transaction"):
|
|
223
|
+
payment_info["txHash"] = receipt["transaction"]
|
|
224
|
+
return {**result, "payment": payment_info}
|
|
225
|
+
|
|
226
|
+
def wallet_report(self, address: str, *, days: int | None = None, api_key: str | None = None) -> dict:
|
|
227
|
+
"""GET /v1/wallet/{address}/reconciliation on app_base_url (SI-67). The report is private: pass an
|
|
228
|
+
API key (sk_singit_...) of the SingIt account that signs in with `address`."""
|
|
229
|
+
qs = f"?days={days}" if days is not None else ""
|
|
230
|
+
url = f"{self.app_base_url}/v1/wallet/{urllib.parse.quote(address)}/reconciliation{qs}"
|
|
231
|
+
status, raw = _get_json(url, headers={"Authorization": f"Bearer {api_key}"} if api_key else None)
|
|
232
|
+
if status in (401, 403):
|
|
233
|
+
raise SingItPaymentError(
|
|
234
|
+
f"The wallet report is private (HTTP {status}): pass api_key, a SingIt API key of the account "
|
|
235
|
+
"that signs in with this wallet."
|
|
236
|
+
)
|
|
237
|
+
if status >= 400:
|
|
238
|
+
raise SingItPaymentError(
|
|
239
|
+
f"Wallet reconciliation is not available (HTTP {status})."
|
|
240
|
+
)
|
|
241
|
+
return json.loads(raw)
|
|
242
|
+
|
|
243
|
+
def pricing(self) -> dict:
|
|
244
|
+
"""GET /pricing.json on base_url (SI-68; live)."""
|
|
245
|
+
url = f"{self.base_url}/pricing.json"
|
|
246
|
+
status, raw = _get_json(url)
|
|
247
|
+
if status >= 400:
|
|
248
|
+
raise SingItPaymentError(
|
|
249
|
+
f"pricing.json is not available (HTTP {status})."
|
|
250
|
+
)
|
|
251
|
+
return json.loads(raw)
|
|
252
|
+
|
|
253
|
+
def allowance(self, address: str, **kwargs) -> object:
|
|
254
|
+
"""The on-chain spending limit set in the SingIt app for `address`, with today's spent/remaining."""
|
|
255
|
+
report = self.wallet_report(address, **kwargs)
|
|
256
|
+
return report.get("allowance") if isinstance(report, dict) else None
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Client-side allowance guards: pin the merchant's offer (network, asset, payTo) before signing,
|
|
2
|
+
cap a single call, and cap a rolling day of calls. Modeled on singit-ask's own buyers
|
|
3
|
+
(src/metered-buyer.mjs, src/solana-buyer.mjs): same idea, generalized to any x402 merchant.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
|
|
13
|
+
_ATOMIC_RE = re.compile(r"^[1-9][0-9]{0,27}$")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SingItPaymentError(Exception):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def is_atomic_amount(value: object) -> bool:
|
|
21
|
+
return isinstance(value, str) and bool(_ATOMIC_RE.match(value))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _same_evm_address(a: object, b: object) -> bool:
|
|
25
|
+
return isinstance(a, str) and isinstance(b, str) and a.lower() == b.lower()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _same_svm_address(a: object, b: object) -> bool:
|
|
29
|
+
return isinstance(a, str) and isinstance(b, str) and a == b
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def decode_header(value: str) -> dict:
|
|
33
|
+
try:
|
|
34
|
+
return json.loads(base64.b64decode(value).decode("utf-8"))
|
|
35
|
+
except Exception as error:
|
|
36
|
+
raise SingItPaymentError("The payment header could not be decoded.") from error
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def encode_header(value: dict) -> str:
|
|
40
|
+
return base64.b64encode(json.dumps(value).encode("utf-8")).decode("ascii")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def select_offer(
|
|
44
|
+
challenge: dict,
|
|
45
|
+
*,
|
|
46
|
+
scheme: str,
|
|
47
|
+
network_id: str,
|
|
48
|
+
family: str,
|
|
49
|
+
asset: str,
|
|
50
|
+
pay_to: str,
|
|
51
|
+
max_cap_atomic: str,
|
|
52
|
+
resource_url: str | None = None,
|
|
53
|
+
) -> dict:
|
|
54
|
+
"""Picks the single offer matching scheme+network, then refuses to sign unless its asset
|
|
55
|
+
and payTo are exactly the ones this SingIt instance was configured to pay, and its amount is
|
|
56
|
+
within the per-call ceiling. Nothing here ever raises the ceiling: a merchant asking for more
|
|
57
|
+
than `max_cap_atomic` is refused, never renegotiated.
|
|
58
|
+
"""
|
|
59
|
+
if not challenge or challenge.get("x402Version") != 2:
|
|
60
|
+
raise SingItPaymentError("Unexpected x402 version in the payment challenge.")
|
|
61
|
+
if resource_url and (challenge.get("resource") or {}).get("url") != resource_url:
|
|
62
|
+
raise SingItPaymentError("The challenge names a different resource than the one called.")
|
|
63
|
+
offers = [a for a in (challenge.get("accepts") or []) if a.get("scheme") == scheme and a.get("network") == network_id]
|
|
64
|
+
if len(offers) != 1:
|
|
65
|
+
raise SingItPaymentError(f"Expected exactly one {scheme} offer on {network_id}; got {len(offers)}.")
|
|
66
|
+
offer = offers[0]
|
|
67
|
+
same_address = _same_evm_address if family == "evm" else _same_svm_address
|
|
68
|
+
if not same_address(offer.get("asset"), asset):
|
|
69
|
+
raise SingItPaymentError(f"Unexpected payment asset ({offer.get('asset')}); refusing to sign.")
|
|
70
|
+
if not same_address(offer.get("payTo"), pay_to):
|
|
71
|
+
raise SingItPaymentError(f"Unexpected payee ({offer.get('payTo')}); refusing to sign.")
|
|
72
|
+
amount = offer.get("amount")
|
|
73
|
+
if not is_atomic_amount(amount) or not is_atomic_amount(max_cap_atomic) or int(amount) > int(max_cap_atomic):
|
|
74
|
+
raise SingItPaymentError(f"Offer amount {amount} exceeds the configured per-call ceiling ({max_cap_atomic}).")
|
|
75
|
+
timeout = offer.get("maxTimeoutSeconds")
|
|
76
|
+
if not isinstance(timeout, int) or isinstance(timeout, bool) or timeout < 1 or timeout > 600:
|
|
77
|
+
raise SingItPaymentError("Unexpected payment timeout window in the offer.")
|
|
78
|
+
if family == "evm" and offer.get("scheme") == "upto":
|
|
79
|
+
facilitator = (offer.get("extra") or {}).get("facilitatorAddress", "")
|
|
80
|
+
if not re.match(r"^0x[0-9a-fA-F]{40}$", facilitator or "") or re.match(r"^0x0{40}$", facilitator, re.IGNORECASE):
|
|
81
|
+
raise SingItPaymentError("The offer has no usable facilitator address for the upto scheme.")
|
|
82
|
+
return offer
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class DailyLimiter:
|
|
86
|
+
"""A simple, in-memory, per-UTC-day spend counter. Not durable (lost on restart) and not
|
|
87
|
+
shared across processes: a client-side allowance guard, not a ledger. Checked against the
|
|
88
|
+
offer's worst-case amount before signing, then adjusted down to the amount actually billed
|
|
89
|
+
afterwards, so an `upto` request that settles for less than its ceiling frees the unused
|
|
90
|
+
headroom immediately.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
def __init__(self, cap_atomic: str):
|
|
94
|
+
self.cap_atomic = int(cap_atomic)
|
|
95
|
+
self._day: str | None = None
|
|
96
|
+
self._spent_atomic = 0
|
|
97
|
+
|
|
98
|
+
def _roll_if_needed(self) -> None:
|
|
99
|
+
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
100
|
+
if today != self._day:
|
|
101
|
+
self._day = today
|
|
102
|
+
self._spent_atomic = 0
|
|
103
|
+
|
|
104
|
+
def remaining_atomic(self) -> int:
|
|
105
|
+
self._roll_if_needed()
|
|
106
|
+
return max(self.cap_atomic - self._spent_atomic, 0)
|
|
107
|
+
|
|
108
|
+
def reserve(self, worst_case_atomic: str):
|
|
109
|
+
self._roll_if_needed()
|
|
110
|
+
amount = int(worst_case_atomic)
|
|
111
|
+
if self._spent_atomic + amount > self.cap_atomic:
|
|
112
|
+
raise SingItPaymentError(
|
|
113
|
+
f"Daily spend cap reached: {self._spent_atomic} already spent today, "
|
|
114
|
+
f"{amount} more would exceed {self.cap_atomic} micro-USDC."
|
|
115
|
+
)
|
|
116
|
+
self._spent_atomic += amount
|
|
117
|
+
# A release only ever touches the day it was reserved on: a request spanning UTC midnight must not take
|
|
118
|
+
# yesterday's reservation off today's counter (Codex review).
|
|
119
|
+
day = self._day
|
|
120
|
+
state = {"done": False}
|
|
121
|
+
|
|
122
|
+
def release():
|
|
123
|
+
if state["done"]:
|
|
124
|
+
return
|
|
125
|
+
state["done"] = True
|
|
126
|
+
self._roll_if_needed()
|
|
127
|
+
if self._day == day:
|
|
128
|
+
self._spent_atomic -= amount
|
|
129
|
+
|
|
130
|
+
release.day = day
|
|
131
|
+
return release
|
|
132
|
+
|
|
133
|
+
def settle(self, release, actual_atomic: str | None, worst_case_atomic: str) -> None:
|
|
134
|
+
self._roll_if_needed()
|
|
135
|
+
if getattr(release, "day", self._day) != self._day:
|
|
136
|
+
return # yesterday's spend; today's counter starts clean
|
|
137
|
+
release()
|
|
138
|
+
self._spent_atomic += int(actual_atomic if actual_atomic is not None else worst_case_atomic)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def usd_to_atomic(usd: float) -> str:
|
|
142
|
+
if not isinstance(usd, (int, float)) or isinstance(usd, bool) or usd < 0:
|
|
143
|
+
raise SingItPaymentError("max_per_call_usd/max_per_day_usd must be a non-negative number.")
|
|
144
|
+
return str(round(usd * 1_000_000))
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Pinned, per-network expectations an x402 offer must match before the SDK signs anything.
|
|
2
|
+
|
|
3
|
+
Base and Solana values are the live SingIt Ask addresses (singit-ask/src/pricing.mjs).
|
|
4
|
+
Base Sepolia has no SingIt deployment: its USDC address is the well-known testnet asset, and
|
|
5
|
+
`pay_to` is never pinned here -- the caller must pass one explicitly (SingIt(pay_to=...)).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class NetworkPin:
|
|
13
|
+
caip: str
|
|
14
|
+
family: str # "evm" | "svm"
|
|
15
|
+
asset: str
|
|
16
|
+
pay_to: str | None
|
|
17
|
+
asset_name: str = "USDC"
|
|
18
|
+
asset_version: str = "2"
|
|
19
|
+
default_rpc_url: str | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
NETWORKS: dict[str, NetworkPin] = {
|
|
23
|
+
"base": NetworkPin(
|
|
24
|
+
caip="eip155:8453",
|
|
25
|
+
family="evm",
|
|
26
|
+
asset="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
27
|
+
pay_to="0xC23d1Dc0f5fCe1abfFB051e06cB93f0329968B4e",
|
|
28
|
+
asset_name="USD Coin",
|
|
29
|
+
asset_version="2",
|
|
30
|
+
default_rpc_url="https://mainnet.base.org",
|
|
31
|
+
),
|
|
32
|
+
"base-sepolia": NetworkPin(
|
|
33
|
+
caip="eip155:84532",
|
|
34
|
+
family="evm",
|
|
35
|
+
asset="0x036CbD53842c5426634e7929541eC2318f3dCF7e",
|
|
36
|
+
pay_to=None,
|
|
37
|
+
asset_name="USDC",
|
|
38
|
+
asset_version="2",
|
|
39
|
+
default_rpc_url="https://sepolia.base.org",
|
|
40
|
+
),
|
|
41
|
+
"solana": NetworkPin(
|
|
42
|
+
caip="solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
|
|
43
|
+
family="svm",
|
|
44
|
+
asset="EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
|
|
45
|
+
pay_to="4an2sqamWWhny9mjLsMtGXCDXakeNtg6vSLq4QvhdmQu",
|
|
46
|
+
default_rpc_url="https://api.mainnet-beta.solana.com",
|
|
47
|
+
),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
DEFAULT_BASE_URL = "https://ask.singitai.app"
|
|
51
|
+
DEFAULT_APP_BASE_URL = "https://app.singitai.app"
|
|
52
|
+
ROUTE = "/v1/chat/completions"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def per_model_route(model: str) -> str:
|
|
56
|
+
return f"/v1/chat/{model}"
|