plugin-sagg-x402 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +195 -0
- package/dist/index.d.ts +211 -0
- package/dist/index.js +349 -0
- package/dist/index.js.map +1 -0
- package/package.json +68 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SAGG
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# plugin-sagg-x402
|
|
2
|
+
|
|
3
|
+
An [ElizaOS](https://elizaos.ai/) plugin that pays for SAGG chat-completion requests
|
|
4
|
+
**per-request, via the [x402 protocol](https://x402.org/)** (a signed USDC micropayment)
|
|
5
|
+
instead of an API key. Registers as a `TEXT_LARGE`/`TEXT_SMALL` model handler, so an
|
|
6
|
+
ElizaOS agent can use SAGG as its LLM the same way it would use `@elizaos/plugin-openai`
|
|
7
|
+
or `@elizaos/plugin-anthropic` - except every call is paid for by the agent's own
|
|
8
|
+
funded wallet, with no human provisioning an API key first.
|
|
9
|
+
|
|
10
|
+
**Status: testnet only (Base Sepolia). Not published to npm or the ElizaOS plugin
|
|
11
|
+
registry - prepared for review, not distributed.**
|
|
12
|
+
|
|
13
|
+
## Why this exists
|
|
14
|
+
|
|
15
|
+
ElizaOS agents are unusually well-suited for this: they already carry EVM wallets for
|
|
16
|
+
DeFi/on-chain actions (`@elizaos/plugin-evm` and others), so paying for an LLM call the
|
|
17
|
+
same way is a natural fit rather than a bolted-on feature. ElizaOS has no native x402 /
|
|
18
|
+
decentralized-inference plugin today - the same kind of gap
|
|
19
|
+
[langchain-ai/langchain#36306](https://github.com/langchain-ai/langchain/issues/36306)
|
|
20
|
+
documents for LangChain. This plugin is the ElizaOS counterpart to the sibling
|
|
21
|
+
`langchain-x402-sagg`, `crewai-x402-sagg`, and `sagg-mcp-server` adapters already built
|
|
22
|
+
for the same SAGG x402 endpoint - same protocol, same gateway, this ecosystem's own
|
|
23
|
+
idiom (a `Plugin` object with a `models` map) instead of a `Tool`/MCP server.
|
|
24
|
+
|
|
25
|
+
## Why pure TypeScript (viem), not a bridge to the existing Go module
|
|
26
|
+
|
|
27
|
+
SAGG's own x402 client/server code (`x402module/`, now its own repo) is written in Go.
|
|
28
|
+
As with the earlier Python adapters, two approaches were considered: wrapping the Go
|
|
29
|
+
module via a subprocess/bridge, or reimplementing the EIP-712/EIP-3009 signing logic
|
|
30
|
+
natively. EIP-712 and EIP-3009 are open cryptographic standards, not Go-specific -
|
|
31
|
+
[viem](https://viem.sh) is the standard, actively maintained TypeScript implementation,
|
|
32
|
+
and it's already what `@elizaos/plugin-evm` itself is built on. Shipping or requiring a
|
|
33
|
+
platform-specific Go binary alongside an npm package would be real distribution
|
|
34
|
+
friction for no real benefit here - the same reasoning the LangChain adapter's own
|
|
35
|
+
README gives for choosing `eth_account` over a Go bridge in Python. Independently
|
|
36
|
+
verified, not just "no exception thrown": `src/__tests__/signer.test.ts` recovers the
|
|
37
|
+
signer's address from a produced signature via a **separate** call path
|
|
38
|
+
(`viem`'s `recoverTypedDataAddress`, not the package's own signing code), and includes
|
|
39
|
+
a double control that a corrupted signature genuinely fails that recovery.
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npm install
|
|
45
|
+
npm run build
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Not published - install from this local checkout, and reference it by local path from
|
|
49
|
+
an ElizaOS project's `character.ts`:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
plugins: [
|
|
53
|
+
'@elizaos/plugin-sql',
|
|
54
|
+
'../plugin-sagg-x402',
|
|
55
|
+
]
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Configuration
|
|
59
|
+
|
|
60
|
+
All read via `runtime.getSetting()` (character `settings` or environment variables):
|
|
61
|
+
|
|
62
|
+
| Setting | Default | Meaning |
|
|
63
|
+
|---|---|---|
|
|
64
|
+
| `SAGG_X402_URL` | required | Base URL of the SAGG gateway (no trailing `/v1/...`). |
|
|
65
|
+
| `SAGG_X402_PRIVATE_KEY` | none | Dedicated hex EVM private key for x402 payments. **Recommended over `EVM_PRIVATE_KEY`** - see Security below. |
|
|
66
|
+
| `EVM_PRIVATE_KEY` | none | Fallback: the same key `@elizaos/plugin-evm` already reads. Used only if `SAGG_X402_PRIVATE_KEY` is unset. |
|
|
67
|
+
| `SAGG_X402_MODEL` | `deepseek-ai/DeepSeek-V4-Flash-0731` | Model id to request. |
|
|
68
|
+
| `SAGG_X402_MAX_AMOUNT_ATOMIC` | `1000000` (1.00 USDC) | Hard per-call payment ceiling, in USDC's smallest unit (6 decimals). A 402 challenge asking for more is refused **before any signature is produced**. |
|
|
69
|
+
| `SAGG_X402_NETWORK` | `base-sepolia` | Only network this plugin has been tested against. `base` (mainnet) is accepted by the code but untested and out of scope - see Security. |
|
|
70
|
+
|
|
71
|
+
One of `SAGG_X402_PRIVATE_KEY` or `EVM_PRIVATE_KEY` is required; a missing/misconfigured
|
|
72
|
+
key surfaces as a clear error on the first `useModel` call routed here (not at plugin
|
|
73
|
+
load time - see `model.ts`'s own comment on why).
|
|
74
|
+
|
|
75
|
+
## How it works
|
|
76
|
+
|
|
77
|
+
Registers the same handler function for both `ModelType.TEXT_LARGE` and
|
|
78
|
+
`ModelType.TEXT_SMALL` (SAGG's gateway already does provider tiering/failover
|
|
79
|
+
server-side - this plugin doesn't reimplement a large/small distinction of its own; see
|
|
80
|
+
"What this plugin does NOT do"). On each `useModel` call:
|
|
81
|
+
|
|
82
|
+
1. POST the composed prompt/messages to `{SAGG_X402_URL}/v1/chat/completions`.
|
|
83
|
+
2. If the response is `402 Payment Required`, parse the price from the challenge
|
|
84
|
+
(`accepts[0]`).
|
|
85
|
+
3. Refuse if the price exceeds `SAGG_X402_MAX_AMOUNT_ATOMIC`.
|
|
86
|
+
4. Sign a fresh EIP-3009 `TransferWithAuthorization` for the exact amount, to the exact
|
|
87
|
+
recipient, with a fresh random nonce (never reused - see `signer.ts`'s own comment on
|
|
88
|
+
why that's the actual replay-protection mechanism).
|
|
89
|
+
5. Repost with an `X-PAYMENT` header carrying the signed payload.
|
|
90
|
+
6. Return the model's response text.
|
|
91
|
+
|
|
92
|
+
## A real finding from live-testing, not assumed from the spec
|
|
93
|
+
|
|
94
|
+
The generic x402 spec's `X-PAYMENT-RESPONSE` header (what `x402-go`'s own stock
|
|
95
|
+
middleware sets, and what the sibling Go/Python clients' `GetSettlement`/
|
|
96
|
+
`get_settlement` look for) is **never sent** by SAGG's actual `/v1/chat/completions`
|
|
97
|
+
x402 path - confirmed by inspecting real response headers from a live gateway
|
|
98
|
+
instance, not by reading the handler source alone. SAGG's `x402ChatCompletionsHandler`
|
|
99
|
+
is built on `DynamicServer` (needed for per-request dynamic pricing - x402-go's stock
|
|
100
|
+
middleware only supports one fixed price set at startup) and sets its own plain
|
|
101
|
+
headers instead: `X-SAGG-X402-Ceiling-Tx` (always present on a served response) and
|
|
102
|
+
`X-SAGG-X402-Refund-Tx` (present only when a refund was actually sent). This plugin's
|
|
103
|
+
`SaggX402Client.getSettlement()` reads the real headers; worth fixing in the sibling
|
|
104
|
+
Python adapters too, but out of this plugin's own scope.
|
|
105
|
+
|
|
106
|
+
Also corrected here: the sibling LangChain/CrewAI adapters default to the model id
|
|
107
|
+
`deepseek-v4-flash-0731`. The real, documented full-form id is
|
|
108
|
+
`deepseek-ai/DeepSeek-V4-Flash-0731` (PROJECT_STATE.md sec 3.164) - the short form is
|
|
109
|
+
exactly the class of bug that section found and fixed elsewhere (it breaks model-name
|
|
110
|
+
translation for the `eterial-deepseek`/`gonka24-deepseek` fallback tiers specifically).
|
|
111
|
+
This plugin defaults to the correct full form; worth fixing upstream in the sibling
|
|
112
|
+
adapters too.
|
|
113
|
+
|
|
114
|
+
## Testing
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
npm test # fast, no network - signing/recovery logic + a mocked-fetch payment flow
|
|
118
|
+
npm run test:live # opt-in, moves real (testnet) funds - needs a reachable SAGG endpoint
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
SAGG_X402_TEST_PRIVATE_KEY=0x... \
|
|
123
|
+
SAGG_X402_TEST_URL=http://localhost:8080 \
|
|
124
|
+
RUN_LIVE_X402_TEST=1 \
|
|
125
|
+
npm run test:live
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
**This was run for real** against a locally-running SAGG gateway built from
|
|
129
|
+
`feat/x402-billing-integration` (Base Sepolia, real `x402.org/facilitator`, real
|
|
130
|
+
on-chain settlement) during this plugin's own development - 5 consecutive real payment
|
|
131
|
+
cycles across both the raw client and the actual registered `handleGenerateText` model
|
|
132
|
+
handler, each ceiling transaction independently confirmed via a raw
|
|
133
|
+
`eth_getTransactionReceipt` RPC call (not trusted from the gateway's own response
|
|
134
|
+
alone) - e.g. `0xcd8df9355f0a86758c1b228607856700e8e47d39cbccab61063c2a0b46e59f27`,
|
|
135
|
+
`status: 0x1`, a real USDC `Transfer` event for exactly 326 atomic units
|
|
136
|
+
(0.000326 USDC) from the test wallet to SAGG's configured `payTo` address. Every real
|
|
137
|
+
completion came back as genuine model output (e.g. a literal `"pong"` for a
|
|
138
|
+
`"reply with exactly one word: pong"` prompt), not a canned/mocked value.
|
|
139
|
+
|
|
140
|
+
**One real limitation, disclosed rather than worked around**: this development session's
|
|
141
|
+
server-side test wallet held testnet USDC but no testnet ETH, so every refund attempt
|
|
142
|
+
(the ceiling always includes a safety margin above the real cost, so a small refund is
|
|
143
|
+
normal) failed with `insufficient funds for gas` and was logged - but the request was
|
|
144
|
+
still served successfully to the client either way, since SAGG's own handler treats a
|
|
145
|
+
refund failure as a bookkeeping gap, never a reason to fail an already-paid request (see
|
|
146
|
+
`cmd/gateway/x402billing.go`). The **payment-and-serve path this plugin actually
|
|
147
|
+
depends on was fully live-verified**; the refund leg specifically was not, for lack of
|
|
148
|
+
testnet ETH on the server wallet used this session - a future run with a
|
|
149
|
+
gas-funded server wallet would additionally confirm `X-SAGG-X402-Refund-Tx` end to end.
|
|
150
|
+
Separately: this run surfaced a real, pre-existing minor bug in SAGG's own gateway - a
|
|
151
|
+
refund failure for ANY reason (including plain insufficient gas, as hit here) is logged
|
|
152
|
+
as `"actual cost exceeded ceiling - refund refused per metering's own invariant"`,
|
|
153
|
+
which is only actually true for one specific failure cause, not this one. Flagged for
|
|
154
|
+
SAGG's own maintainers; not fixed here, out of this plugin's scope.
|
|
155
|
+
|
|
156
|
+
## Security
|
|
157
|
+
|
|
158
|
+
- **Testnet only.** Tested exclusively against Base Sepolia. Do not put a
|
|
159
|
+
mainnet-funded private key in `SAGG_X402_PRIVATE_KEY`/`EVM_PRIVATE_KEY` - nothing in
|
|
160
|
+
this plugin's code prevents it, but no part of it has been reviewed or tested for
|
|
161
|
+
mainnet use.
|
|
162
|
+
- **Use a dedicated key, not the agent's main wallet, if you can.** `EVM_PRIVATE_KEY`
|
|
163
|
+
(the same key `plugin-evm` uses for DeFi actions) works as a fallback for
|
|
164
|
+
convenience, but a compromised or over-eager LLM-payment path then has access to
|
|
165
|
+
every other fund that wallet holds. `SAGG_X402_PRIVATE_KEY` lets you isolate this
|
|
166
|
+
plugin's spending to a wallet that holds only what you're willing to lose - the same
|
|
167
|
+
principle the sibling Python adapters' own README states.
|
|
168
|
+
- **The private key is held in plain process memory** for the agent's lifetime - the
|
|
169
|
+
same custody model SAGG's own Go server wallet uses today (see the main
|
|
170
|
+
`sagg-project` repo's Stage 3 security audit, `PROJECT_STATE.md` sec 3.147).
|
|
171
|
+
- `SAGG_X402_MAX_AMOUNT_ATOMIC` is a real, enforced ceiling checked before any
|
|
172
|
+
signature is produced, but it's a courtesy limit on THIS client's own behavior - it
|
|
173
|
+
does not protect against a malicious or compromised server issuing a valid-looking
|
|
174
|
+
but overpriced challenge below that ceiling. Set it to the smallest value your actual
|
|
175
|
+
use case needs.
|
|
176
|
+
- **Signing refuses on a network mismatch, before any signature is produced.** Added
|
|
177
|
+
after a real failure in the sibling `langchain-x402-sagg` package's first mainnet
|
|
178
|
+
attempt: a client configured for one network but pointed at a server actually
|
|
179
|
+
issuing a challenge for a different one would otherwise sign with the wrong chain id
|
|
180
|
+
- a well-formed but cryptographically wrong signature the facilitator correctly
|
|
181
|
+
rejects, with no clue in the error as to why. `postWithPayment` now checks the
|
|
182
|
+
challenge's own `network` field against `SAGG_X402_NETWORK` and throws
|
|
183
|
+
`SaggX402PaymentError` immediately on any mismatch instead.
|
|
184
|
+
|
|
185
|
+
## What this plugin does NOT do
|
|
186
|
+
|
|
187
|
+
- Publish itself anywhere (no npm, no ElizaOS plugin registry) - prepared for review
|
|
188
|
+
only, per its own scope.
|
|
189
|
+
- Distinguish `TEXT_LARGE` from `TEXT_SMALL` - both call the same configured SAGG model;
|
|
190
|
+
SAGG's own gateway already handles provider tiering server-side.
|
|
191
|
+
- Support streaming responses, `TEXT_EMBEDDING`, or any ModelType beyond text
|
|
192
|
+
generation.
|
|
193
|
+
- Implement provider failover/retries beyond what SAGG's own gateway already does -
|
|
194
|
+
this is a minimal, first-version client.
|
|
195
|
+
- Touch mainnet, create a real wallet, or move real money in any way.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { IAgentRuntime, Plugin } from '@elizaos/core';
|
|
2
|
+
import { Hex } from 'viem';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The actual SAGG-backed TEXT_LARGE/TEXT_SMALL model handler. SAGG's own
|
|
6
|
+
* gateway already does provider tiering/failover server-side (Standard
|
|
7
|
+
* vs Super Deal lines - see the main sagg-project repo) - this plugin
|
|
8
|
+
* deliberately does not reimplement a large/small model distinction of
|
|
9
|
+
* its own; both ModelType.TEXT_LARGE and ModelType.TEXT_SMALL are wired
|
|
10
|
+
* to the SAME handler and the same configured model id (see README's
|
|
11
|
+
* "What this plugin does NOT do" for this and other scope calls).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
interface GenerateTextParamsLike {
|
|
15
|
+
prompt?: string;
|
|
16
|
+
system?: string;
|
|
17
|
+
messages?: Array<{
|
|
18
|
+
role: string;
|
|
19
|
+
content: string;
|
|
20
|
+
}>;
|
|
21
|
+
maxTokens?: number;
|
|
22
|
+
temperature?: number;
|
|
23
|
+
stopSequences?: string[];
|
|
24
|
+
}
|
|
25
|
+
/** The handler wired to ModelType.TEXT_LARGE/TEXT_SMALL in index.ts.
|
|
26
|
+
* Pays via x402 on every call - SAGG's x402 path is a genuinely separate
|
|
27
|
+
* accounting mechanism from its API-key/Stripe path (no quota, no
|
|
28
|
+
* subscription), so there is no "free" request to fall back to if a
|
|
29
|
+
* payment fails; a payment/HTTP error here is a real, user-visible
|
|
30
|
+
* failure, not something to swallow. */
|
|
31
|
+
declare function handleGenerateText(runtime: IAgentRuntime, params: GenerateTextParamsLike): Promise<string>;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* HTTP client implementing the client side of the x402 "exact" scheme:
|
|
35
|
+
* probe -> 402 challenge -> sign -> pay -> response. Mirrors
|
|
36
|
+
* x402module/poc/pkg/x402protocol/dynamicclient.go's own two-request
|
|
37
|
+
* design (a fresh, unpaid probe carrying the real body, then a SEPARATE
|
|
38
|
+
* paid retry with its own fresh body) and the sibling Python adapters'
|
|
39
|
+
* client.py (langchain-x402-sagg, crewai-x402-sagg) field-for-field.
|
|
40
|
+
* Node's global `fetch` builds a brand-new request body from the object
|
|
41
|
+
* passed to it on each call, so - like the Python adapters using
|
|
42
|
+
* `requests` - there is no equivalent of the mark3labs/x402-go
|
|
43
|
+
* single-consumed-body bug this project found and worked around in Go
|
|
44
|
+
* (PROJECT_STATE.md sec 3.145); noted for comparison, not because a
|
|
45
|
+
* workaround was needed here.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
declare class SaggX402PaymentError extends Error {
|
|
49
|
+
}
|
|
50
|
+
/** Shaped around what SAGG's own x402 handler actually reports, not the
|
|
51
|
+
* generic x402 spec's success/errorReason/transaction Settlement shape
|
|
52
|
+
* (see getSettlement's own doc comment for why those two differ). */
|
|
53
|
+
interface Settlement {
|
|
54
|
+
ceilingTx: string;
|
|
55
|
+
refundTx: string | null;
|
|
56
|
+
}
|
|
57
|
+
interface SaggX402ClientOptions {
|
|
58
|
+
privateKey: Hex;
|
|
59
|
+
maxAmountAtomic: bigint;
|
|
60
|
+
network?: string;
|
|
61
|
+
timeoutMs?: number;
|
|
62
|
+
}
|
|
63
|
+
/** Pays for x402-gated SAGG requests using an EVM private key, enforcing
|
|
64
|
+
* a hard per-call maximum (maxAmountAtomic) independent of whatever a
|
|
65
|
+
* server's own requirement asks for - the same "never auto-pay more
|
|
66
|
+
* than this" discipline as the Go module's WalletSigner and both Python
|
|
67
|
+
* adapters. */
|
|
68
|
+
declare class SaggX402Client {
|
|
69
|
+
private readonly privateKey;
|
|
70
|
+
private readonly maxAmountAtomic;
|
|
71
|
+
private readonly network;
|
|
72
|
+
private readonly chainId;
|
|
73
|
+
private readonly timeoutMs;
|
|
74
|
+
constructor(options: SaggX402ClientOptions);
|
|
75
|
+
/** POSTs jsonBody to url. If the first response is a real x402 402
|
|
76
|
+
* challenge, signs the first accepted requirement (refusing if it
|
|
77
|
+
* exceeds maxAmountAtomic) and reposts a FRESH request with the
|
|
78
|
+
* resulting X-PAYMENT header. Any other first response (2xx, or a
|
|
79
|
+
* non-402 error) is returned as-is, unpaid. */
|
|
80
|
+
postWithPayment(url: string, jsonBody: Record<string, unknown>): Promise<Response>;
|
|
81
|
+
/** Extracts settlement info from SAGG's OWN response headers.
|
|
82
|
+
*
|
|
83
|
+
* A real, live-testing-only finding, not something assumed from the
|
|
84
|
+
* spec or copied from the sibling adapters: the generic x402 spec's
|
|
85
|
+
* `X-PAYMENT-RESPONSE` header (base64 JSON, what x402-go's OWN stock
|
|
86
|
+
* middleware sets, and what x402protocol.GetSettlement (client.go)
|
|
87
|
+
* and the sibling LangChain/CrewAI adapters' get_settlement look for)
|
|
88
|
+
* is NEVER set by SAGG's actual `/v1/chat/completions` x402 path -
|
|
89
|
+
* confirmed by inspecting real response headers from a live local
|
|
90
|
+
* gateway instance, not by reading the handler source alone. SAGG's
|
|
91
|
+
* x402ChatCompletionsHandler (x402billing.go) is built directly on
|
|
92
|
+
* DynamicServer (needed for per-request dynamic pricing - x402-go's
|
|
93
|
+
* own stock middleware only supports one fixed price set at startup),
|
|
94
|
+
* and sets its OWN plain headers instead:
|
|
95
|
+
* `X-SAGG-X402-Ceiling-Tx` (always present on a served response) and
|
|
96
|
+
* `X-SAGG-X402-Refund-Tx` (present only when a refund was actually
|
|
97
|
+
* sent - absent, not empty, when the refund amount was zero OR when
|
|
98
|
+
* sending it failed, e.g. the server wallet lacking gas - see this
|
|
99
|
+
* plugin's README for what that means for a real refund's
|
|
100
|
+
* observability from the client side). No `success`/`errorReason`
|
|
101
|
+
* fields exist in this scheme at all - a served 200 response with a
|
|
102
|
+
* ceiling-tx header IS the success signal. */
|
|
103
|
+
static getSettlement(response: Response): Settlement | null;
|
|
104
|
+
private postJson;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* EIP-3009 (transferWithAuthorization) signing for the x402 "exact"
|
|
109
|
+
* payment scheme, via viem's own EIP-712 typed-data support.
|
|
110
|
+
*
|
|
111
|
+
* Mirrors the EXACT typed-data shape SAGG's Go gateway builds
|
|
112
|
+
* (x402module/poc/pkg/x402protocol/signer_adapter.go, WalletSigner.Sign)
|
|
113
|
+
* and this project's own Python adapters (langchain-x402-sagg/signer.py,
|
|
114
|
+
* crewai-x402-sagg) - same field names, same types, same domain
|
|
115
|
+
* structure - so a signature produced here is verified identically by
|
|
116
|
+
* the same facilitator/on-chain contract, regardless of which language
|
|
117
|
+
* signed it. EIP-712/EIP-3009 are open cryptographic standards, not
|
|
118
|
+
* anything Go- or Python-specific; viem is the standard, widely-used
|
|
119
|
+
* TypeScript implementation (the same library @elizaos/plugin-evm
|
|
120
|
+
* itself is built on) - this mirrors the earlier adapters' own choice
|
|
121
|
+
* of the host ecosystem's standard web3 library over a cross-language
|
|
122
|
+
* bridge to the Go module.
|
|
123
|
+
*
|
|
124
|
+
* One real difference from the Go/Python versions, not a bug: viem's
|
|
125
|
+
* signTypedData derives the "EIP712Domain" type entry itself from the
|
|
126
|
+
* `domain` object - it must NOT be listed again in `types` (unlike
|
|
127
|
+
* go-ethereum's apitypes.TypedData and eth_account's
|
|
128
|
+
* encode_typed_data/sign_typed_data, which both require it spelled out
|
|
129
|
+
* explicitly). Verified against viem's own docs before writing this.
|
|
130
|
+
*/
|
|
131
|
+
|
|
132
|
+
interface SignedAuthorization {
|
|
133
|
+
signature: Hex;
|
|
134
|
+
fromAddress: Hex;
|
|
135
|
+
toAddress: Hex;
|
|
136
|
+
value: string;
|
|
137
|
+
validAfter: string;
|
|
138
|
+
validBefore: string;
|
|
139
|
+
nonce: Hex;
|
|
140
|
+
}
|
|
141
|
+
interface TransferAuthorizationTypedData {
|
|
142
|
+
domain: {
|
|
143
|
+
name: string;
|
|
144
|
+
version: string;
|
|
145
|
+
chainId: number;
|
|
146
|
+
verifyingContract: Hex;
|
|
147
|
+
};
|
|
148
|
+
types: {
|
|
149
|
+
TransferWithAuthorization: [
|
|
150
|
+
{
|
|
151
|
+
name: "from";
|
|
152
|
+
type: "address";
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: "to";
|
|
156
|
+
type: "address";
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
name: "value";
|
|
160
|
+
type: "uint256";
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
name: "validAfter";
|
|
164
|
+
type: "uint256";
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
name: "validBefore";
|
|
168
|
+
type: "uint256";
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
name: "nonce";
|
|
172
|
+
type: "bytes32";
|
|
173
|
+
}
|
|
174
|
+
];
|
|
175
|
+
};
|
|
176
|
+
primaryType: "TransferWithAuthorization";
|
|
177
|
+
message: {
|
|
178
|
+
from: Hex;
|
|
179
|
+
to: Hex;
|
|
180
|
+
value: bigint;
|
|
181
|
+
validAfter: bigint;
|
|
182
|
+
validBefore: bigint;
|
|
183
|
+
nonce: Hex;
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
/** Builds the EIP-712 typed-data object for a TransferWithAuthorization -
|
|
187
|
+
* field-for-field identical to what SAGG's own Go signer and the sibling
|
|
188
|
+
* Python adapters build, minus the EIP712Domain entry viem derives on
|
|
189
|
+
* its own (see this file's own top comment). Exported separately from
|
|
190
|
+
* signTransferAuthorization so tests can independently re-derive the
|
|
191
|
+
* same digest viem signs, the same "recover the signer independently"
|
|
192
|
+
* discipline this whole project's own test suites already use. */
|
|
193
|
+
declare function buildTypedData(domainName: string, domainVersion: string, chainId: number, tokenAddress: Hex, fromAddress: Hex, toAddress: Hex, value: bigint, validAfter: bigint, validBefore: bigint, nonce: Hex): TransferAuthorizationTypedData;
|
|
194
|
+
/** Signs a fresh EIP-3009 authorization for exactly `value` atomic units
|
|
195
|
+
* of tokenAddress, payable to toAddress, valid from ~now until
|
|
196
|
+
* validTimeoutSeconds from now (mirrors the Go/Python signers' own
|
|
197
|
+
* 10-second clock-drift tolerance on validAfter). */
|
|
198
|
+
declare function signTransferAuthorization(privateKey: Hex, domainName: string, domainVersion: string, chainId: number, tokenAddress: Hex, toAddress: Hex, value: bigint, validTimeoutSeconds: number): Promise<SignedAuthorization>;
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* plugin-sagg-x402 - pays for SAGG (https://api.privatedeskai.com) LLM
|
|
202
|
+
* chat-completion requests per-request via the x402 protocol (a signed
|
|
203
|
+
* USDC micropayment) instead of an API key, registered as an ElizaOS
|
|
204
|
+
* model handler for ModelType.TEXT_LARGE/TEXT_SMALL. See README.md for
|
|
205
|
+
* why this exists and how it fits alongside the sibling LangChain/
|
|
206
|
+
* CrewAI/MCP adapters built for the same SAGG x402 endpoint.
|
|
207
|
+
*/
|
|
208
|
+
|
|
209
|
+
declare const saggX402Plugin: Plugin;
|
|
210
|
+
|
|
211
|
+
export { SaggX402Client, SaggX402PaymentError, buildTypedData, saggX402Plugin as default, handleGenerateText, saggX402Plugin, signTransferAuthorization };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { logger, ModelType } from "@elizaos/core";
|
|
3
|
+
|
|
4
|
+
// src/signer.ts
|
|
5
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
6
|
+
function randomNonceHex() {
|
|
7
|
+
const bytes = new Uint8Array(32);
|
|
8
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
9
|
+
return "0x" + Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(
|
|
10
|
+
""
|
|
11
|
+
);
|
|
12
|
+
}
|
|
13
|
+
function buildTypedData(domainName, domainVersion, chainId, tokenAddress, fromAddress, toAddress, value, validAfter, validBefore, nonce) {
|
|
14
|
+
return {
|
|
15
|
+
domain: {
|
|
16
|
+
name: domainName,
|
|
17
|
+
version: domainVersion,
|
|
18
|
+
chainId,
|
|
19
|
+
verifyingContract: tokenAddress
|
|
20
|
+
},
|
|
21
|
+
types: {
|
|
22
|
+
TransferWithAuthorization: [
|
|
23
|
+
{ name: "from", type: "address" },
|
|
24
|
+
{ name: "to", type: "address" },
|
|
25
|
+
{ name: "value", type: "uint256" },
|
|
26
|
+
{ name: "validAfter", type: "uint256" },
|
|
27
|
+
{ name: "validBefore", type: "uint256" },
|
|
28
|
+
{ name: "nonce", type: "bytes32" }
|
|
29
|
+
]
|
|
30
|
+
},
|
|
31
|
+
primaryType: "TransferWithAuthorization",
|
|
32
|
+
message: {
|
|
33
|
+
from: fromAddress,
|
|
34
|
+
to: toAddress,
|
|
35
|
+
value,
|
|
36
|
+
validAfter,
|
|
37
|
+
validBefore,
|
|
38
|
+
nonce
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
async function signTransferAuthorization(privateKey, domainName, domainVersion, chainId, tokenAddress, toAddress, value, validTimeoutSeconds) {
|
|
43
|
+
const account = privateKeyToAccount(privateKey);
|
|
44
|
+
const now = BigInt(Math.floor(Date.now() / 1e3));
|
|
45
|
+
const validAfter = now - 10n;
|
|
46
|
+
const validBefore = now + BigInt(validTimeoutSeconds);
|
|
47
|
+
const nonce = randomNonceHex();
|
|
48
|
+
const typedData = buildTypedData(
|
|
49
|
+
domainName,
|
|
50
|
+
domainVersion,
|
|
51
|
+
chainId,
|
|
52
|
+
tokenAddress,
|
|
53
|
+
account.address,
|
|
54
|
+
toAddress,
|
|
55
|
+
value,
|
|
56
|
+
validAfter,
|
|
57
|
+
validBefore,
|
|
58
|
+
nonce
|
|
59
|
+
);
|
|
60
|
+
const signature = await account.signTypedData(typedData);
|
|
61
|
+
return {
|
|
62
|
+
signature,
|
|
63
|
+
fromAddress: account.address,
|
|
64
|
+
toAddress,
|
|
65
|
+
value: value.toString(),
|
|
66
|
+
validAfter: validAfter.toString(),
|
|
67
|
+
validBefore: validBefore.toString(),
|
|
68
|
+
nonce
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// src/client.ts
|
|
73
|
+
var CHAIN_IDS = {
|
|
74
|
+
base: 8453,
|
|
75
|
+
"base-sepolia": 84532
|
|
76
|
+
};
|
|
77
|
+
var SaggX402PaymentError = class extends Error {
|
|
78
|
+
};
|
|
79
|
+
var SaggX402Client = class {
|
|
80
|
+
privateKey;
|
|
81
|
+
maxAmountAtomic;
|
|
82
|
+
network;
|
|
83
|
+
chainId;
|
|
84
|
+
timeoutMs;
|
|
85
|
+
constructor(options) {
|
|
86
|
+
const network = options.network ?? "base-sepolia";
|
|
87
|
+
const chainId = CHAIN_IDS[network];
|
|
88
|
+
if (chainId === void 0) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`unsupported network ${JSON.stringify(network)}, want one of ${Object.keys(CHAIN_IDS).join(", ")}`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
this.privateKey = options.privateKey;
|
|
94
|
+
this.maxAmountAtomic = options.maxAmountAtomic;
|
|
95
|
+
this.network = network;
|
|
96
|
+
this.chainId = chainId;
|
|
97
|
+
this.timeoutMs = options.timeoutMs ?? 6e4;
|
|
98
|
+
}
|
|
99
|
+
/** POSTs jsonBody to url. If the first response is a real x402 402
|
|
100
|
+
* challenge, signs the first accepted requirement (refusing if it
|
|
101
|
+
* exceeds maxAmountAtomic) and reposts a FRESH request with the
|
|
102
|
+
* resulting X-PAYMENT header. Any other first response (2xx, or a
|
|
103
|
+
* non-402 error) is returned as-is, unpaid. */
|
|
104
|
+
async postWithPayment(url, jsonBody) {
|
|
105
|
+
const probe = await this.postJson(url, jsonBody);
|
|
106
|
+
if (probe.status !== 402) {
|
|
107
|
+
return probe;
|
|
108
|
+
}
|
|
109
|
+
const challenge = await probe.json();
|
|
110
|
+
const accepts = challenge.accepts ?? [];
|
|
111
|
+
if (accepts.length === 0) {
|
|
112
|
+
throw new SaggX402PaymentError(
|
|
113
|
+
"402 challenge carried no accepted payment methods"
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
const requirement = accepts[0];
|
|
117
|
+
const amount = BigInt(requirement.maxAmountRequired);
|
|
118
|
+
if (amount > this.maxAmountAtomic) {
|
|
119
|
+
throw new SaggX402PaymentError(
|
|
120
|
+
`requirement asks for ${amount} atomic units, exceeds this client's configured max ${this.maxAmountAtomic}`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
const extra = requirement.extra ?? {};
|
|
124
|
+
if (!extra.name || !extra.version) {
|
|
125
|
+
throw new SaggX402PaymentError(
|
|
126
|
+
"requirement missing EIP-3009 domain name/version in its 'extra' field"
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
if (requirement.network !== this.network) {
|
|
130
|
+
throw new SaggX402PaymentError(
|
|
131
|
+
`requirement's network ${JSON.stringify(requirement.network)} does not match this client's configured network ${JSON.stringify(this.network)} - refusing to sign. This client was likely constructed with the wrong \`network\` option for the URL it's actually talking to.`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
const signed = await signTransferAuthorization(
|
|
135
|
+
this.privateKey,
|
|
136
|
+
extra.name,
|
|
137
|
+
extra.version,
|
|
138
|
+
this.chainId,
|
|
139
|
+
requirement.asset,
|
|
140
|
+
requirement.payTo,
|
|
141
|
+
amount,
|
|
142
|
+
requirement.maxTimeoutSeconds ?? 60
|
|
143
|
+
);
|
|
144
|
+
const paymentPayload = {
|
|
145
|
+
x402Version: 1,
|
|
146
|
+
scheme: "exact",
|
|
147
|
+
network: requirement.network,
|
|
148
|
+
payload: {
|
|
149
|
+
signature: signed.signature,
|
|
150
|
+
authorization: {
|
|
151
|
+
from: signed.fromAddress,
|
|
152
|
+
to: signed.toAddress,
|
|
153
|
+
value: signed.value,
|
|
154
|
+
validAfter: signed.validAfter,
|
|
155
|
+
validBefore: signed.validBefore,
|
|
156
|
+
nonce: signed.nonce
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
const headerValue = Buffer.from(JSON.stringify(paymentPayload)).toString(
|
|
161
|
+
"base64"
|
|
162
|
+
);
|
|
163
|
+
return this.postJson(url, jsonBody, { "X-PAYMENT": headerValue });
|
|
164
|
+
}
|
|
165
|
+
/** Extracts settlement info from SAGG's OWN response headers.
|
|
166
|
+
*
|
|
167
|
+
* A real, live-testing-only finding, not something assumed from the
|
|
168
|
+
* spec or copied from the sibling adapters: the generic x402 spec's
|
|
169
|
+
* `X-PAYMENT-RESPONSE` header (base64 JSON, what x402-go's OWN stock
|
|
170
|
+
* middleware sets, and what x402protocol.GetSettlement (client.go)
|
|
171
|
+
* and the sibling LangChain/CrewAI adapters' get_settlement look for)
|
|
172
|
+
* is NEVER set by SAGG's actual `/v1/chat/completions` x402 path -
|
|
173
|
+
* confirmed by inspecting real response headers from a live local
|
|
174
|
+
* gateway instance, not by reading the handler source alone. SAGG's
|
|
175
|
+
* x402ChatCompletionsHandler (x402billing.go) is built directly on
|
|
176
|
+
* DynamicServer (needed for per-request dynamic pricing - x402-go's
|
|
177
|
+
* own stock middleware only supports one fixed price set at startup),
|
|
178
|
+
* and sets its OWN plain headers instead:
|
|
179
|
+
* `X-SAGG-X402-Ceiling-Tx` (always present on a served response) and
|
|
180
|
+
* `X-SAGG-X402-Refund-Tx` (present only when a refund was actually
|
|
181
|
+
* sent - absent, not empty, when the refund amount was zero OR when
|
|
182
|
+
* sending it failed, e.g. the server wallet lacking gas - see this
|
|
183
|
+
* plugin's README for what that means for a real refund's
|
|
184
|
+
* observability from the client side). No `success`/`errorReason`
|
|
185
|
+
* fields exist in this scheme at all - a served 200 response with a
|
|
186
|
+
* ceiling-tx header IS the success signal. */
|
|
187
|
+
static getSettlement(response) {
|
|
188
|
+
const ceilingTx = response.headers.get("X-SAGG-X402-Ceiling-Tx");
|
|
189
|
+
if (!ceilingTx) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
ceilingTx,
|
|
194
|
+
refundTx: response.headers.get("X-SAGG-X402-Refund-Tx")
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
async postJson(url, jsonBody, extraHeaders) {
|
|
198
|
+
const controller = new AbortController();
|
|
199
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
200
|
+
try {
|
|
201
|
+
return await fetch(url, {
|
|
202
|
+
method: "POST",
|
|
203
|
+
headers: { "Content-Type": "application/json", ...extraHeaders },
|
|
204
|
+
body: JSON.stringify(jsonBody),
|
|
205
|
+
signal: controller.signal
|
|
206
|
+
});
|
|
207
|
+
} finally {
|
|
208
|
+
clearTimeout(timer);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// src/model.ts
|
|
214
|
+
var DEFAULT_MODEL = "deepseek-ai/DeepSeek-V4-Flash-0731";
|
|
215
|
+
var DEFAULT_MAX_AMOUNT_ATOMIC = 1000000n;
|
|
216
|
+
var DEFAULT_NETWORK = "base-sepolia";
|
|
217
|
+
var cachedClient = null;
|
|
218
|
+
var cachedClientKey = null;
|
|
219
|
+
function requireSetting(runtime, key) {
|
|
220
|
+
const value = runtime.getSetting(key);
|
|
221
|
+
if (value === null || value === void 0 || value === "") {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`plugin-sagg-x402: required setting ${key} is not configured`
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
return String(value);
|
|
227
|
+
}
|
|
228
|
+
function optionalSetting(runtime, key) {
|
|
229
|
+
const value = runtime.getSetting(key);
|
|
230
|
+
if (value === null || value === void 0 || value === "") {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
return String(value);
|
|
234
|
+
}
|
|
235
|
+
function resolvePrivateKey(runtime) {
|
|
236
|
+
const dedicated = optionalSetting(runtime, "SAGG_X402_PRIVATE_KEY");
|
|
237
|
+
const fallback = dedicated ?? optionalSetting(runtime, "EVM_PRIVATE_KEY");
|
|
238
|
+
if (!fallback) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
"plugin-sagg-x402: neither SAGG_X402_PRIVATE_KEY nor EVM_PRIVATE_KEY is configured"
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
return fallback.startsWith("0x") ? fallback : `0x${fallback}`;
|
|
244
|
+
}
|
|
245
|
+
function getClient(runtime) {
|
|
246
|
+
const privateKey = resolvePrivateKey(runtime);
|
|
247
|
+
const network = optionalSetting(runtime, "SAGG_X402_NETWORK") ?? DEFAULT_NETWORK;
|
|
248
|
+
const maxAmountSetting = optionalSetting(
|
|
249
|
+
runtime,
|
|
250
|
+
"SAGG_X402_MAX_AMOUNT_ATOMIC"
|
|
251
|
+
);
|
|
252
|
+
const maxAmountAtomic = maxAmountSetting ? BigInt(maxAmountSetting) : DEFAULT_MAX_AMOUNT_ATOMIC;
|
|
253
|
+
const cacheKey = `${privateKey}:${network}:${maxAmountAtomic}`;
|
|
254
|
+
if (cachedClient && cachedClientKey === cacheKey) {
|
|
255
|
+
return cachedClient;
|
|
256
|
+
}
|
|
257
|
+
cachedClient = new SaggX402Client({ privateKey, network, maxAmountAtomic });
|
|
258
|
+
cachedClientKey = cacheKey;
|
|
259
|
+
return cachedClient;
|
|
260
|
+
}
|
|
261
|
+
function buildMessages(params) {
|
|
262
|
+
const messages = [];
|
|
263
|
+
if (params.system) {
|
|
264
|
+
messages.push({ role: "system", content: params.system });
|
|
265
|
+
}
|
|
266
|
+
if (params.messages && params.messages.length > 0) {
|
|
267
|
+
for (const m of params.messages) {
|
|
268
|
+
messages.push({ role: m.role, content: m.content });
|
|
269
|
+
}
|
|
270
|
+
return messages;
|
|
271
|
+
}
|
|
272
|
+
if (params.prompt) {
|
|
273
|
+
messages.push({ role: "user", content: params.prompt });
|
|
274
|
+
return messages;
|
|
275
|
+
}
|
|
276
|
+
throw new Error(
|
|
277
|
+
"plugin-sagg-x402: GenerateTextParams carried neither `messages` nor `prompt`"
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
async function handleGenerateText(runtime, params) {
|
|
281
|
+
const saggUrl = requireSetting(runtime, "SAGG_X402_URL").replace(/\/+$/, "");
|
|
282
|
+
const model = optionalSetting(runtime, "SAGG_X402_MODEL") ?? DEFAULT_MODEL;
|
|
283
|
+
const client = getClient(runtime);
|
|
284
|
+
const body = {
|
|
285
|
+
model,
|
|
286
|
+
messages: buildMessages(params)
|
|
287
|
+
};
|
|
288
|
+
if (params.maxTokens !== void 0) body.max_tokens = params.maxTokens;
|
|
289
|
+
if (params.temperature !== void 0) body.temperature = params.temperature;
|
|
290
|
+
if (params.stopSequences && params.stopSequences.length > 0) {
|
|
291
|
+
body.stop = params.stopSequences;
|
|
292
|
+
}
|
|
293
|
+
let response;
|
|
294
|
+
try {
|
|
295
|
+
response = await client.postWithPayment(
|
|
296
|
+
`${saggUrl}/v1/chat/completions`,
|
|
297
|
+
body
|
|
298
|
+
);
|
|
299
|
+
} catch (err) {
|
|
300
|
+
if (err instanceof SaggX402PaymentError) {
|
|
301
|
+
throw new Error(`plugin-sagg-x402: payment refused: ${err.message}`);
|
|
302
|
+
}
|
|
303
|
+
throw err;
|
|
304
|
+
}
|
|
305
|
+
if (!response.ok) {
|
|
306
|
+
const text = await response.text().catch(() => "");
|
|
307
|
+
throw new Error(
|
|
308
|
+
`plugin-sagg-x402: SAGG request failed (${response.status}): ${text}`
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
const data = await response.json();
|
|
312
|
+
const content = data.choices?.[0]?.message?.content;
|
|
313
|
+
if (typeof content !== "string") {
|
|
314
|
+
throw new Error(
|
|
315
|
+
"plugin-sagg-x402: SAGG response had no choices[0].message.content"
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
return content;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// src/index.ts
|
|
322
|
+
var saggX402Plugin = {
|
|
323
|
+
name: "sagg-x402",
|
|
324
|
+
description: "Pays for SAGG LLM completions per-request via the x402 protocol (signed USDC micropayment, no API key). Testnet (Base Sepolia) only as shipped.",
|
|
325
|
+
async init(_config, runtime) {
|
|
326
|
+
const hasUrl = Boolean(runtime.getSetting("SAGG_X402_URL"));
|
|
327
|
+
const hasKey = Boolean(runtime.getSetting("SAGG_X402_PRIVATE_KEY")) || Boolean(runtime.getSetting("EVM_PRIVATE_KEY"));
|
|
328
|
+
if (!hasUrl || !hasKey) {
|
|
329
|
+
logger.warn(
|
|
330
|
+
"plugin-sagg-x402: SAGG_X402_URL and/or a private key (SAGG_X402_PRIVATE_KEY or EVM_PRIVATE_KEY) are not configured - TEXT_LARGE/TEXT_SMALL calls routed to this plugin will fail."
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
models: {
|
|
335
|
+
[ModelType.TEXT_LARGE]: handleGenerateText,
|
|
336
|
+
[ModelType.TEXT_SMALL]: handleGenerateText
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
var index_default = saggX402Plugin;
|
|
340
|
+
export {
|
|
341
|
+
SaggX402Client,
|
|
342
|
+
SaggX402PaymentError,
|
|
343
|
+
buildTypedData,
|
|
344
|
+
index_default as default,
|
|
345
|
+
handleGenerateText,
|
|
346
|
+
saggX402Plugin,
|
|
347
|
+
signTransferAuthorization
|
|
348
|
+
};
|
|
349
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/signer.ts","../src/client.ts","../src/model.ts"],"sourcesContent":["/**\n * plugin-sagg-x402 - pays for SAGG (https://api.privatedeskai.com) LLM\n * chat-completion requests per-request via the x402 protocol (a signed\n * USDC micropayment) instead of an API key, registered as an ElizaOS\n * model handler for ModelType.TEXT_LARGE/TEXT_SMALL. See README.md for\n * why this exists and how it fits alongside the sibling LangChain/\n * CrewAI/MCP adapters built for the same SAGG x402 endpoint.\n */\n\nimport type { IAgentRuntime, Plugin } from \"@elizaos/core\";\nimport { logger, ModelType } from \"@elizaos/core\";\nimport { handleGenerateText } from \"./model.js\";\n\nexport const saggX402Plugin: Plugin = {\n name: \"sagg-x402\",\n description:\n \"Pays for SAGG LLM completions per-request via the x402 protocol (signed USDC micropayment, no API key). Testnet (Base Sepolia) only as shipped.\",\n\n async init(_config: Record<string, string>, runtime: IAgentRuntime) {\n // Deliberately does NOT validate/throw here - see model.ts's own\n // comment on why a missing/bad private key surfaces at first real\n // call instead of at plugin load time. This only logs a clear,\n // early warning so a misconfiguration is visible in the agent's\n // own startup logs rather than a silent surprise on first use.\n const hasUrl = Boolean(runtime.getSetting(\"SAGG_X402_URL\"));\n const hasKey =\n Boolean(runtime.getSetting(\"SAGG_X402_PRIVATE_KEY\")) ||\n Boolean(runtime.getSetting(\"EVM_PRIVATE_KEY\"));\n if (!hasUrl || !hasKey) {\n logger.warn(\n \"plugin-sagg-x402: SAGG_X402_URL and/or a private key \" +\n \"(SAGG_X402_PRIVATE_KEY or EVM_PRIVATE_KEY) are not configured - \" +\n \"TEXT_LARGE/TEXT_SMALL calls routed to this plugin will fail.\",\n );\n }\n },\n\n models: {\n [ModelType.TEXT_LARGE]: handleGenerateText,\n [ModelType.TEXT_SMALL]: handleGenerateText,\n },\n};\n\nexport default saggX402Plugin;\nexport { handleGenerateText } from \"./model.js\";\nexport { SaggX402Client, SaggX402PaymentError } from \"./client.js\";\nexport { signTransferAuthorization, buildTypedData } from \"./signer.js\";\n","/**\n * EIP-3009 (transferWithAuthorization) signing for the x402 \"exact\"\n * payment scheme, via viem's own EIP-712 typed-data support.\n *\n * Mirrors the EXACT typed-data shape SAGG's Go gateway builds\n * (x402module/poc/pkg/x402protocol/signer_adapter.go, WalletSigner.Sign)\n * and this project's own Python adapters (langchain-x402-sagg/signer.py,\n * crewai-x402-sagg) - same field names, same types, same domain\n * structure - so a signature produced here is verified identically by\n * the same facilitator/on-chain contract, regardless of which language\n * signed it. EIP-712/EIP-3009 are open cryptographic standards, not\n * anything Go- or Python-specific; viem is the standard, widely-used\n * TypeScript implementation (the same library @elizaos/plugin-evm\n * itself is built on) - this mirrors the earlier adapters' own choice\n * of the host ecosystem's standard web3 library over a cross-language\n * bridge to the Go module.\n *\n * One real difference from the Go/Python versions, not a bug: viem's\n * signTypedData derives the \"EIP712Domain\" type entry itself from the\n * `domain` object - it must NOT be listed again in `types` (unlike\n * go-ethereum's apitypes.TypedData and eth_account's\n * encode_typed_data/sign_typed_data, which both require it spelled out\n * explicitly). Verified against viem's own docs before writing this.\n */\n\nimport { privateKeyToAccount, type PrivateKeyAccount } from \"viem/accounts\";\nimport type { Hex } from \"viem\";\n\nexport interface SignedAuthorization {\n signature: Hex;\n fromAddress: Hex;\n toAddress: Hex;\n value: string;\n validAfter: string;\n validBefore: string;\n nonce: Hex;\n}\n\n/** A fresh, cryptographically random 32-byte EIP-3009 nonce - a new one\n * MUST be generated per authorization; reusing one with the same signer\n * produces a signature the on-chain contract rejects as already-used on\n * the second attempt (PROJECT_STATE.md's x402 Stage 3 audit, sec 3.147,\n * item 1 - EIP-3009's own nonce tracking is the real replay protection\n * here, not anything this client does). Uses the Web Crypto API\n * (globalThis.crypto), available in both Node 19+ and browsers - no\n * separate crypto import needed. */\nexport function randomNonceHex(): Hex {\n const bytes = new Uint8Array(32);\n globalThis.crypto.getRandomValues(bytes);\n return (\"0x\" +\n Array.from(bytes, (b) => b.toString(16).padStart(2, \"0\")).join(\n \"\",\n )) as Hex;\n}\n\ninterface TransferAuthorizationTypedData {\n domain: {\n name: string;\n version: string;\n chainId: number;\n verifyingContract: Hex;\n };\n types: {\n TransferWithAuthorization: [\n { name: \"from\"; type: \"address\" },\n { name: \"to\"; type: \"address\" },\n { name: \"value\"; type: \"uint256\" },\n { name: \"validAfter\"; type: \"uint256\" },\n { name: \"validBefore\"; type: \"uint256\" },\n { name: \"nonce\"; type: \"bytes32\" },\n ];\n };\n primaryType: \"TransferWithAuthorization\";\n message: {\n from: Hex;\n to: Hex;\n value: bigint;\n validAfter: bigint;\n validBefore: bigint;\n nonce: Hex;\n };\n}\n\n/** Builds the EIP-712 typed-data object for a TransferWithAuthorization -\n * field-for-field identical to what SAGG's own Go signer and the sibling\n * Python adapters build, minus the EIP712Domain entry viem derives on\n * its own (see this file's own top comment). Exported separately from\n * signTransferAuthorization so tests can independently re-derive the\n * same digest viem signs, the same \"recover the signer independently\"\n * discipline this whole project's own test suites already use. */\nexport function buildTypedData(\n domainName: string,\n domainVersion: string,\n chainId: number,\n tokenAddress: Hex,\n fromAddress: Hex,\n toAddress: Hex,\n value: bigint,\n validAfter: bigint,\n validBefore: bigint,\n nonce: Hex,\n): TransferAuthorizationTypedData {\n return {\n domain: {\n name: domainName,\n version: domainVersion,\n chainId,\n verifyingContract: tokenAddress,\n },\n types: {\n TransferWithAuthorization: [\n { name: \"from\", type: \"address\" },\n { name: \"to\", type: \"address\" },\n { name: \"value\", type: \"uint256\" },\n { name: \"validAfter\", type: \"uint256\" },\n { name: \"validBefore\", type: \"uint256\" },\n { name: \"nonce\", type: \"bytes32\" },\n ],\n },\n primaryType: \"TransferWithAuthorization\",\n message: {\n from: fromAddress,\n to: toAddress,\n value,\n validAfter,\n validBefore,\n nonce,\n },\n };\n}\n\n/** Signs a fresh EIP-3009 authorization for exactly `value` atomic units\n * of tokenAddress, payable to toAddress, valid from ~now until\n * validTimeoutSeconds from now (mirrors the Go/Python signers' own\n * 10-second clock-drift tolerance on validAfter). */\nexport async function signTransferAuthorization(\n privateKey: Hex,\n domainName: string,\n domainVersion: string,\n chainId: number,\n tokenAddress: Hex,\n toAddress: Hex,\n value: bigint,\n validTimeoutSeconds: number,\n): Promise<SignedAuthorization> {\n const account: PrivateKeyAccount = privateKeyToAccount(privateKey);\n const now = BigInt(Math.floor(Date.now() / 1000));\n const validAfter = now - 10n;\n const validBefore = now + BigInt(validTimeoutSeconds);\n const nonce = randomNonceHex();\n\n const typedData = buildTypedData(\n domainName,\n domainVersion,\n chainId,\n tokenAddress,\n account.address,\n toAddress,\n value,\n validAfter,\n validBefore,\n nonce,\n );\n\n const signature = await account.signTypedData(typedData);\n\n return {\n signature,\n fromAddress: account.address,\n toAddress,\n value: value.toString(),\n validAfter: validAfter.toString(),\n validBefore: validBefore.toString(),\n nonce,\n };\n}\n","/**\n * HTTP client implementing the client side of the x402 \"exact\" scheme:\n * probe -> 402 challenge -> sign -> pay -> response. Mirrors\n * x402module/poc/pkg/x402protocol/dynamicclient.go's own two-request\n * design (a fresh, unpaid probe carrying the real body, then a SEPARATE\n * paid retry with its own fresh body) and the sibling Python adapters'\n * client.py (langchain-x402-sagg, crewai-x402-sagg) field-for-field.\n * Node's global `fetch` builds a brand-new request body from the object\n * passed to it on each call, so - like the Python adapters using\n * `requests` - there is no equivalent of the mark3labs/x402-go\n * single-consumed-body bug this project found and worked around in Go\n * (PROJECT_STATE.md sec 3.145); noted for comparison, not because a\n * workaround was needed here.\n */\n\nimport { signTransferAuthorization } from \"./signer.js\";\nimport type { Hex } from \"viem\";\n\nconst CHAIN_IDS: Record<string, number> = {\n base: 8453,\n \"base-sepolia\": 84532,\n};\n\nexport class SaggX402PaymentError extends Error {}\n\n/** Shaped around what SAGG's own x402 handler actually reports, not the\n * generic x402 spec's success/errorReason/transaction Settlement shape\n * (see getSettlement's own doc comment for why those two differ). */\nexport interface Settlement {\n ceilingTx: string;\n refundTx: string | null;\n}\n\ninterface PaymentRequirement {\n maxAmountRequired: string;\n asset: Hex;\n payTo: Hex;\n network: string;\n maxTimeoutSeconds?: number;\n extra?: { name?: string; version?: string };\n}\n\ninterface X402Challenge {\n x402Version: number;\n error?: string;\n accepts?: PaymentRequirement[];\n}\n\nexport interface SaggX402ClientOptions {\n privateKey: Hex;\n maxAmountAtomic: bigint;\n network?: string;\n timeoutMs?: number;\n}\n\n/** Pays for x402-gated SAGG requests using an EVM private key, enforcing\n * a hard per-call maximum (maxAmountAtomic) independent of whatever a\n * server's own requirement asks for - the same \"never auto-pay more\n * than this\" discipline as the Go module's WalletSigner and both Python\n * adapters. */\nexport class SaggX402Client {\n private readonly privateKey: Hex;\n private readonly maxAmountAtomic: bigint;\n private readonly network: string;\n private readonly chainId: number;\n private readonly timeoutMs: number;\n\n constructor(options: SaggX402ClientOptions) {\n const network = options.network ?? \"base-sepolia\";\n const chainId = CHAIN_IDS[network];\n if (chainId === undefined) {\n throw new Error(\n `unsupported network ${JSON.stringify(network)}, want one of ${Object.keys(CHAIN_IDS).join(\", \")}`,\n );\n }\n this.privateKey = options.privateKey;\n this.maxAmountAtomic = options.maxAmountAtomic;\n this.network = network;\n this.chainId = chainId;\n this.timeoutMs = options.timeoutMs ?? 60_000;\n }\n\n /** POSTs jsonBody to url. If the first response is a real x402 402\n * challenge, signs the first accepted requirement (refusing if it\n * exceeds maxAmountAtomic) and reposts a FRESH request with the\n * resulting X-PAYMENT header. Any other first response (2xx, or a\n * non-402 error) is returned as-is, unpaid. */\n async postWithPayment(\n url: string,\n jsonBody: Record<string, unknown>,\n ): Promise<Response> {\n const probe = await this.postJson(url, jsonBody);\n if (probe.status !== 402) {\n return probe;\n }\n\n const challenge = (await probe.json()) as X402Challenge;\n const accepts = challenge.accepts ?? [];\n if (accepts.length === 0) {\n throw new SaggX402PaymentError(\n \"402 challenge carried no accepted payment methods\",\n );\n }\n const requirement = accepts[0];\n\n const amount = BigInt(requirement.maxAmountRequired);\n if (amount > this.maxAmountAtomic) {\n throw new SaggX402PaymentError(\n `requirement asks for ${amount} atomic units, exceeds this client's configured max ${this.maxAmountAtomic}`,\n );\n }\n\n const extra = requirement.extra ?? {};\n if (!extra.name || !extra.version) {\n throw new SaggX402PaymentError(\n \"requirement missing EIP-3009 domain name/version in its 'extra' field\",\n );\n }\n\n // this.chainId was fixed once at construction from this.network -\n // correct ONLY as long as the requirement the server actually\n // issued is for that same network. Real bug found live in the\n // sibling langchain-x402-sagg package on the first real mainnet\n // attempt (PROJECT_STATE.md sec 3.172, then confirmed to be the\n // same shape here too): a client built with the default\n // network=\"base-sepolia\" but pointed at a real mainnet SAGG_X402_URL\n // signs with chainId 84532 while the server's requirement is for\n // chainId 8453 - the facilitator correctly rejects the resulting\n // signature as invalid (\"invalid_exact_evm_signature\"), a confusing\n // failure with no clue as to the real cause. Refusing outright on a\n // mismatch (rather than silently trusting whatever the server\n // claims and re-deriving chainId from it) is the safer failure mode\n // too: a testnet-configured client must never be tricked into\n // producing a valid signature for a real mainnet transfer just\n // because it was pointed at the wrong URL.\n if (requirement.network !== this.network) {\n throw new SaggX402PaymentError(\n `requirement's network ${JSON.stringify(requirement.network)} does not match this client's configured network ${JSON.stringify(this.network)} - refusing to sign. This client was likely constructed with the wrong \\`network\\` option for the URL it's actually talking to.`,\n );\n }\n\n const signed = await signTransferAuthorization(\n this.privateKey,\n extra.name,\n extra.version,\n this.chainId,\n requirement.asset,\n requirement.payTo,\n amount,\n requirement.maxTimeoutSeconds ?? 60,\n );\n\n const paymentPayload = {\n x402Version: 1,\n scheme: \"exact\",\n network: requirement.network,\n payload: {\n signature: signed.signature,\n authorization: {\n from: signed.fromAddress,\n to: signed.toAddress,\n value: signed.value,\n validAfter: signed.validAfter,\n validBefore: signed.validBefore,\n nonce: signed.nonce,\n },\n },\n };\n const headerValue = Buffer.from(JSON.stringify(paymentPayload)).toString(\n \"base64\",\n );\n\n return this.postJson(url, jsonBody, { \"X-PAYMENT\": headerValue });\n }\n\n /** Extracts settlement info from SAGG's OWN response headers.\n *\n * A real, live-testing-only finding, not something assumed from the\n * spec or copied from the sibling adapters: the generic x402 spec's\n * `X-PAYMENT-RESPONSE` header (base64 JSON, what x402-go's OWN stock\n * middleware sets, and what x402protocol.GetSettlement (client.go)\n * and the sibling LangChain/CrewAI adapters' get_settlement look for)\n * is NEVER set by SAGG's actual `/v1/chat/completions` x402 path -\n * confirmed by inspecting real response headers from a live local\n * gateway instance, not by reading the handler source alone. SAGG's\n * x402ChatCompletionsHandler (x402billing.go) is built directly on\n * DynamicServer (needed for per-request dynamic pricing - x402-go's\n * own stock middleware only supports one fixed price set at startup),\n * and sets its OWN plain headers instead:\n * `X-SAGG-X402-Ceiling-Tx` (always present on a served response) and\n * `X-SAGG-X402-Refund-Tx` (present only when a refund was actually\n * sent - absent, not empty, when the refund amount was zero OR when\n * sending it failed, e.g. the server wallet lacking gas - see this\n * plugin's README for what that means for a real refund's\n * observability from the client side). No `success`/`errorReason`\n * fields exist in this scheme at all - a served 200 response with a\n * ceiling-tx header IS the success signal. */\n static getSettlement(response: Response): Settlement | null {\n const ceilingTx = response.headers.get(\"X-SAGG-X402-Ceiling-Tx\");\n if (!ceilingTx) {\n return null;\n }\n return {\n ceilingTx,\n refundTx: response.headers.get(\"X-SAGG-X402-Refund-Tx\"),\n };\n }\n\n private async postJson(\n url: string,\n jsonBody: Record<string, unknown>,\n extraHeaders?: Record<string, string>,\n ): Promise<Response> {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), this.timeoutMs);\n try {\n return await fetch(url, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\", ...extraHeaders },\n body: JSON.stringify(jsonBody),\n signal: controller.signal,\n });\n } finally {\n clearTimeout(timer);\n }\n }\n}\n","/**\n * The actual SAGG-backed TEXT_LARGE/TEXT_SMALL model handler. SAGG's own\n * gateway already does provider tiering/failover server-side (Standard\n * vs Super Deal lines - see the main sagg-project repo) - this plugin\n * deliberately does not reimplement a large/small model distinction of\n * its own; both ModelType.TEXT_LARGE and ModelType.TEXT_SMALL are wired\n * to the SAME handler and the same configured model id (see README's\n * \"What this plugin does NOT do\" for this and other scope calls).\n */\n\nimport type { IAgentRuntime } from \"@elizaos/core\";\nimport type { Hex } from \"viem\";\nimport { SaggX402Client, SaggX402PaymentError } from \"./client.js\";\n\n// The real, documented full-form model id (PROJECT_STATE.md sec 3.164) -\n// NOT the short \"deepseek-v4-flash-0731\" form the sibling LangChain/\n// CrewAI adapters default to. That short form is exactly the class of\n// bug sec 3.164 found and fixed in bench/superdeal_vs_direct.go and\n// cmd/warmup/main.go: it breaks model-name translation for the\n// eterial-deepseek/gonka24-deepseek fallback tiers specifically. Worth\n// fixing in the sibling adapters too, but out of this plugin's own\n// scope - flagged in this plugin's README instead.\nexport const DEFAULT_MODEL = \"deepseek-ai/DeepSeek-V4-Flash-0731\";\nexport const DEFAULT_MAX_AMOUNT_ATOMIC = 1_000_000n; // 1.00 USDC (6 decimals)\nexport const DEFAULT_NETWORK = \"base-sepolia\";\n\ninterface GenerateTextParamsLike {\n prompt?: string;\n system?: string;\n messages?: Array<{ role: string; content: string }>;\n maxTokens?: number;\n temperature?: number;\n stopSequences?: string[];\n}\n\ninterface ChatMessage {\n role: string;\n content: string;\n}\n\n/** One client per plugin instance, built lazily from runtime settings on\n * first use rather than in `init()` - a private key that's missing or\n * wrong should surface as a clear error on the first real request, not\n * silently disable the plugin at startup (ElizaOS loads plugins whose\n * `init` throws inconsistently across versions; failing at call time is\n * the more predictable contract). */\nlet cachedClient: SaggX402Client | null = null;\nlet cachedClientKey: string | null = null;\n\nfunction requireSetting(runtime: IAgentRuntime, key: string): string {\n const value = runtime.getSetting(key);\n if (value === null || value === undefined || value === \"\") {\n throw new Error(\n `plugin-sagg-x402: required setting ${key} is not configured`,\n );\n }\n return String(value);\n}\n\nfunction optionalSetting(runtime: IAgentRuntime, key: string): string | null {\n const value = runtime.getSetting(key);\n if (value === null || value === undefined || value === \"\") {\n return null;\n }\n return String(value);\n}\n\nfunction resolvePrivateKey(runtime: IAgentRuntime): Hex {\n const dedicated = optionalSetting(runtime, \"SAGG_X402_PRIVATE_KEY\");\n const fallback = dedicated ?? optionalSetting(runtime, \"EVM_PRIVATE_KEY\");\n if (!fallback) {\n throw new Error(\n \"plugin-sagg-x402: neither SAGG_X402_PRIVATE_KEY nor EVM_PRIVATE_KEY is configured\",\n );\n }\n return (fallback.startsWith(\"0x\") ? fallback : `0x${fallback}`) as Hex;\n}\n\nfunction getClient(runtime: IAgentRuntime): SaggX402Client {\n const privateKey = resolvePrivateKey(runtime);\n const network = optionalSetting(runtime, \"SAGG_X402_NETWORK\") ?? DEFAULT_NETWORK;\n const maxAmountSetting = optionalSetting(\n runtime,\n \"SAGG_X402_MAX_AMOUNT_ATOMIC\",\n );\n const maxAmountAtomic = maxAmountSetting\n ? BigInt(maxAmountSetting)\n : DEFAULT_MAX_AMOUNT_ATOMIC;\n\n // Cache key covers every input that changes what the client does -\n // a settings change (e.g. rotating the key) must produce a fresh\n // client, not silently keep signing with a stale one.\n const cacheKey = `${privateKey}:${network}:${maxAmountAtomic}`;\n if (cachedClient && cachedClientKey === cacheKey) {\n return cachedClient;\n }\n cachedClient = new SaggX402Client({ privateKey, network, maxAmountAtomic });\n cachedClientKey = cacheKey;\n return cachedClient;\n}\n\nfunction buildMessages(params: GenerateTextParamsLike): ChatMessage[] {\n const messages: ChatMessage[] = [];\n if (params.system) {\n messages.push({ role: \"system\", content: params.system });\n }\n if (params.messages && params.messages.length > 0) {\n for (const m of params.messages) {\n messages.push({ role: m.role, content: m.content });\n }\n return messages;\n }\n if (params.prompt) {\n messages.push({ role: \"user\", content: params.prompt });\n return messages;\n }\n throw new Error(\n \"plugin-sagg-x402: GenerateTextParams carried neither `messages` nor `prompt`\",\n );\n}\n\n/** The handler wired to ModelType.TEXT_LARGE/TEXT_SMALL in index.ts.\n * Pays via x402 on every call - SAGG's x402 path is a genuinely separate\n * accounting mechanism from its API-key/Stripe path (no quota, no\n * subscription), so there is no \"free\" request to fall back to if a\n * payment fails; a payment/HTTP error here is a real, user-visible\n * failure, not something to swallow. */\nexport async function handleGenerateText(\n runtime: IAgentRuntime,\n params: GenerateTextParamsLike,\n): Promise<string> {\n const saggUrl = requireSetting(runtime, \"SAGG_X402_URL\").replace(/\\/+$/, \"\");\n const model = optionalSetting(runtime, \"SAGG_X402_MODEL\") ?? DEFAULT_MODEL;\n const client = getClient(runtime);\n\n const body: Record<string, unknown> = {\n model,\n messages: buildMessages(params),\n };\n if (params.maxTokens !== undefined) body.max_tokens = params.maxTokens;\n if (params.temperature !== undefined) body.temperature = params.temperature;\n if (params.stopSequences && params.stopSequences.length > 0) {\n body.stop = params.stopSequences;\n }\n\n let response: Response;\n try {\n response = await client.postWithPayment(\n `${saggUrl}/v1/chat/completions`,\n body,\n );\n } catch (err) {\n if (err instanceof SaggX402PaymentError) {\n throw new Error(`plugin-sagg-x402: payment refused: ${err.message}`);\n }\n throw err;\n }\n\n if (!response.ok) {\n const text = await response.text().catch(() => \"\");\n throw new Error(\n `plugin-sagg-x402: SAGG request failed (${response.status}): ${text}`,\n );\n }\n\n const data = (await response.json()) as {\n choices?: Array<{ message?: { content?: string } }>;\n };\n const content = data.choices?.[0]?.message?.content;\n if (typeof content !== \"string\") {\n throw new Error(\n \"plugin-sagg-x402: SAGG response had no choices[0].message.content\",\n );\n }\n return content;\n}\n\n/** Exported for tests only - lets a test reset the module-level cache\n * between cases instead of caching a client across unrelated tests. */\nexport function _resetClientCacheForTests(): void {\n cachedClient = null;\n cachedClientKey = null;\n}\n"],"mappings":"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","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "plugin-sagg-x402",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "ElizaOS plugin: pay for SAGG LLM completions per-request via the x402 protocol (signed USDC micropayment) instead of an API key.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"module": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
"./package.json": "./package.json",
|
|
11
|
+
".": {
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"types": "./dist/index.d.ts"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsup",
|
|
21
|
+
"dev": "tsup --watch",
|
|
22
|
+
"test": "vitest run src/__tests__/signer.test.ts src/__tests__/client.test.ts",
|
|
23
|
+
"test:live": "vitest run src/__tests__/live.test.ts"
|
|
24
|
+
},
|
|
25
|
+
"agentConfig": {
|
|
26
|
+
"pluginType": "elizaos:plugin:1.0.0",
|
|
27
|
+
"pluginParameters": {
|
|
28
|
+
"SAGG_X402_URL": {
|
|
29
|
+
"type": "string",
|
|
30
|
+
"description": "Base URL of the SAGG gateway, e.g. https://your-sagg-gateway.example.com (no trailing /v1/...)."
|
|
31
|
+
},
|
|
32
|
+
"SAGG_X402_MODEL": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"description": "Model id to request from SAGG. Defaults to deepseek-ai/DeepSeek-V4-Flash-0731."
|
|
35
|
+
},
|
|
36
|
+
"SAGG_X402_PRIVATE_KEY": {
|
|
37
|
+
"type": "string",
|
|
38
|
+
"description": "Dedicated hex EVM private key for x402 payments (TESTNET ONLY). Recommended over reusing EVM_PRIVATE_KEY so a compromised/overspending LLM-payment key can't touch the agent's other funds."
|
|
39
|
+
},
|
|
40
|
+
"EVM_PRIVATE_KEY": {
|
|
41
|
+
"type": "string",
|
|
42
|
+
"description": "Fallback: the same private key plugin-evm already reads, used only if SAGG_X402_PRIVATE_KEY is not set."
|
|
43
|
+
},
|
|
44
|
+
"SAGG_X402_MAX_AMOUNT_ATOMIC": {
|
|
45
|
+
"type": "string",
|
|
46
|
+
"description": "Hard per-call payment ceiling in USDC's smallest unit (6 decimals). Defaults to 1000000 (1.00 USDC). A 402 challenge asking for more is refused before any signature is produced."
|
|
47
|
+
},
|
|
48
|
+
"SAGG_X402_NETWORK": {
|
|
49
|
+
"type": "string",
|
|
50
|
+
"description": "\"base-sepolia\" (default, the only network this plugin has been tested against) or \"base\" (untested, out of scope as shipped)."
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"viem": "^2.21.0"
|
|
56
|
+
},
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"@elizaos/core": "^1.0.0"
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@elizaos/core": "^1.0.0",
|
|
62
|
+
"@types/node": "^22.0.0",
|
|
63
|
+
"tsup": "^8.3.5",
|
|
64
|
+
"typescript": "^5.6.0",
|
|
65
|
+
"vitest": "^2.1.0"
|
|
66
|
+
},
|
|
67
|
+
"license": "MIT"
|
|
68
|
+
}
|