nous-token 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/PROTOCOL.md +176 -0
- package/README.md +137 -0
- package/cli/setup.ts +306 -0
- package/index.ts +130 -0
- package/openclaw.plugin.json +44 -0
- package/package.json +30 -0
- package/sentinel.ts +256 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nousworld
|
|
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/PROTOCOL.md
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# nous-token Protocol v2
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
nous-token is an open-source AI usage tracking protocol. A transparent gateway proxies LLM API calls, extracts real token consumption from provider responses, and signs a **receipt** for every call. Users own their receipts and decide what to store on-chain.
|
|
6
|
+
|
|
7
|
+
The gateway guarantees data authenticity. It does not dictate storage format.
|
|
8
|
+
|
|
9
|
+
## How It Works
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
User → Plugin/CLI/SDK → Gateway → LLM Provider (any)
|
|
13
|
+
↓ ↓
|
|
14
|
+
hash(API key) extract .usage
|
|
15
|
+
X-Nous-User auto-detect format
|
|
16
|
+
↓
|
|
17
|
+
sign receipt (ECDSA) + store in D1 + update Merkle tree
|
|
18
|
+
↓
|
|
19
|
+
User fetches receipts → decides what to store on-chain
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### Provider Routing
|
|
23
|
+
|
|
24
|
+
Two ways to route through the gateway:
|
|
25
|
+
|
|
26
|
+
1. **Shortcut prefix**: `/{provider}/v1/...` for known providers (openai, anthropic, deepseek, gemini, groq, together, mistral, openrouter, fireworks, perplexity, cohere)
|
|
27
|
+
2. **Custom upstream**: Set `X-Nous-Upstream: https://api.example.com` header to proxy any OpenAI-compatible API
|
|
28
|
+
|
|
29
|
+
Usage extraction auto-detects response format (OpenAI, Anthropic, or Gemini style). No provider registration needed.
|
|
30
|
+
|
|
31
|
+
## The Receipt
|
|
32
|
+
|
|
33
|
+
Every API call through the gateway produces a signed receipt:
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"p": "nous",
|
|
38
|
+
"v": 1,
|
|
39
|
+
"type": "receipt",
|
|
40
|
+
"id": 42,
|
|
41
|
+
"ts": "2026-04-02T12:00:00.000Z",
|
|
42
|
+
"user": "a1b2c3d4...",
|
|
43
|
+
"provider": "anthropic",
|
|
44
|
+
"model": "claude-opus-4-6",
|
|
45
|
+
"input": 5000,
|
|
46
|
+
"output": 2000,
|
|
47
|
+
"cache_read": 0,
|
|
48
|
+
"cache_write": 0,
|
|
49
|
+
"total": 7000,
|
|
50
|
+
"leaf": "sha256...",
|
|
51
|
+
"sig": "ecdsa_signature..."
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Verification**: `leaf` = SHA-256(`ts|user|provider|model|input|output|cache_read|cache_write|total`). `sig` = ECDSA-P256-SHA256 signature over `leaf`. Verify with the gateway's public key at `/api/pubkey`.
|
|
56
|
+
|
|
57
|
+
The gateway signs the leaf hash, not the JSON. This means:
|
|
58
|
+
- Anyone can independently recompute `leaf` from the fields and verify it matches
|
|
59
|
+
- The signature proves the gateway attested to this exact data
|
|
60
|
+
- No ambiguity from JSON serialization differences
|
|
61
|
+
|
|
62
|
+
## What the Gateway Guarantees
|
|
63
|
+
|
|
64
|
+
1. **Token counts are real** — extracted from the actual provider response, not estimated
|
|
65
|
+
2. **Receipts are signed** — ECDSA P-256 signature on each record's leaf hash
|
|
66
|
+
3. **History is append-only** — Merkle Mountain Range makes deletion/modification detectable
|
|
67
|
+
4. **Data is public** — anyone can pull all records and verify independently
|
|
68
|
+
|
|
69
|
+
## What the Gateway Does NOT Dictate
|
|
70
|
+
|
|
71
|
+
- What the user stores on-chain (individual receipts, summaries, or nothing)
|
|
72
|
+
- How the user calculates cost (different plans have different rates)
|
|
73
|
+
- What fields the user displays publicly
|
|
74
|
+
- When the user stores proof
|
|
75
|
+
|
|
76
|
+
## Wallet Identity
|
|
77
|
+
|
|
78
|
+
Users identify themselves with an Ethereum wallet address. The wallet is the permanent identity — API keys rotate, wallets don't.
|
|
79
|
+
|
|
80
|
+
### How it works
|
|
81
|
+
|
|
82
|
+
1. User runs `npx nous-token setup`, enters wallet address + API key
|
|
83
|
+
2. CLI computes `user_hash = SHA-256(api_key)[0:16]` locally, sends `{api_key, wallet}` to `/api/link`
|
|
84
|
+
3. Gateway computes the same hash, verifies it exists in records, links all records to the wallet
|
|
85
|
+
4. Future API calls include `?wallet=0x...` in the base URL (set by CLI) or `X-Nous-Wallet` header (set by plugin)
|
|
86
|
+
5. Gateway stores wallet with each new record and backfills old records for the same hash
|
|
87
|
+
|
|
88
|
+
### Binding rules
|
|
89
|
+
|
|
90
|
+
- **First bind wins**: Once a hash is linked to a wallet, it cannot be relinked to a different wallet. This prevents rebinding with expired or leaked API keys.
|
|
91
|
+
- **Multiple hashes per wallet**: A user can link multiple API keys (hashes) to the same wallet. The leaderboard aggregates all hashes under one wallet.
|
|
92
|
+
- **Wallet validation**: Must be a valid Ethereum address (`0x` + 40 hex chars). Invalid addresses are silently ignored.
|
|
93
|
+
|
|
94
|
+
### Leaderboard aggregation
|
|
95
|
+
|
|
96
|
+
The leaderboard uses `CASE WHEN wallet != '' THEN wallet ELSE user_hash END` as the identity key. Users with wallets are aggregated by wallet; users without wallets fall back to hash-based identity.
|
|
97
|
+
|
|
98
|
+
## Privacy by Structure
|
|
99
|
+
|
|
100
|
+
Not by promise — by code. Audit the source:
|
|
101
|
+
|
|
102
|
+
- **API Key**: Plugin computes SHA-256 hash locally, sends only the hash via `X-Nous-User` header. If no `X-Nous-User` is present (e.g., Claude Code via base URL), the gateway reads the API key from `Authorization` header **solely** to compute the same hash. The key is never stored, logged, or retained — it exists in Worker memory only for the duration of one SHA-256 call, then is discarded by GC. The `X-Nous-Upstream` header (if used) is stripped before forwarding.
|
|
103
|
+
- **Prompts**: `request.body` is piped directly to the provider. No `.text()`, `.json()`, or `.getReader()` is called on it.
|
|
104
|
+
- **Responses (streaming)**: Tee'd. One branch to user unchanged, other buffers last 4KB to extract `.usage` only.
|
|
105
|
+
- **Responses (non-streaming)**: Full body in Worker memory (V8 isolate, GC'd after request) to extract `.usage`. Never reads `.choices`, `.content`.
|
|
106
|
+
- **Storage**: D1 stores: timestamp, user_hash, provider, model, token counts, leaf_hash, receipt_sig. No prompts, no responses, no keys.
|
|
107
|
+
|
|
108
|
+
## Tamper Detection: Merkle Mountain Range
|
|
109
|
+
|
|
110
|
+
Each receipt's `leaf` hash is appended to a Merkle Mountain Range (MMR). The MMR root changes if any single record is modified.
|
|
111
|
+
|
|
112
|
+
### Verification Levels
|
|
113
|
+
|
|
114
|
+
| Level | How | Cost |
|
|
115
|
+
|---|---|---|
|
|
116
|
+
| **Single receipt** | Recompute `leaf` from fields, verify `sig` with pubkey | O(1) |
|
|
117
|
+
| **Full tree** | Pull all records, rebuild MMR, compare root | O(n) |
|
|
118
|
+
| **External anchor** | Compare root with published root (GitHub, on-chain) | O(1) |
|
|
119
|
+
|
|
120
|
+
### Sentinel
|
|
121
|
+
|
|
122
|
+
Anyone can run an independent verifier:
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
npx tsx sentinel.ts --watch
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The sentinel verifies leaf hashes, receipt signatures, and Merkle root integrity. No API key needed.
|
|
129
|
+
|
|
130
|
+
## On-Chain Storage
|
|
131
|
+
|
|
132
|
+
Users choose how to store proof on BASE (or any EVM chain):
|
|
133
|
+
|
|
134
|
+
### Option 1: Individual Receipts
|
|
135
|
+
|
|
136
|
+
Fetch signed receipts via `/api/user/:hash/receipts`, store as calldata. Each receipt is independently verifiable.
|
|
137
|
+
|
|
138
|
+
### Option 2: Summary
|
|
139
|
+
|
|
140
|
+
Request a signed summary via `POST /api/sign`, store as calldata. The summary aggregates multiple receipts into a single signed attestation.
|
|
141
|
+
|
|
142
|
+
Both options use self-to-self transactions with data as calldata. No smart contract needed — the ECDSA signature is the proof.
|
|
143
|
+
|
|
144
|
+
## Trust Model
|
|
145
|
+
|
|
146
|
+
1. **Code is open-source** — anyone can audit
|
|
147
|
+
2. **Per-call signatures** — every receipt is signed by the gateway
|
|
148
|
+
3. **Merkle tree** — tampering with any record breaks the tree
|
|
149
|
+
4. **Sentinels** — independent verifiers monitor continuously
|
|
150
|
+
5. **On-chain anchoring** — published roots prevent history rewrites
|
|
151
|
+
|
|
152
|
+
## API Endpoints
|
|
153
|
+
|
|
154
|
+
| Endpoint | Method | Description |
|
|
155
|
+
|---|---|---|
|
|
156
|
+
| `/{provider}/v1/...` | * | Proxy to LLM provider (append `?wallet=0x...` for identity) |
|
|
157
|
+
| `/api/leaderboard` | GET | Top users by tokens (`?days=30`, `?days=0` for all time) |
|
|
158
|
+
| `/api/leaderboard/model` | GET | Per-model user ranking (`?model=...&days=30`) |
|
|
159
|
+
| `/api/models` | GET | Usage breakdown by model (aggregated across providers) |
|
|
160
|
+
| `/api/stats` | GET | Global totals |
|
|
161
|
+
| `/api/wallet/{address}` | GET | Usage for a wallet (all linked hashes aggregated) |
|
|
162
|
+
| `/api/user/{hash}` | GET | Single user aggregated stats |
|
|
163
|
+
| `/api/user/{hash}/receipts` | GET | Signed receipts (paginated) |
|
|
164
|
+
| `/api/link` | POST | Link hash to wallet (`{api_key, wallet}`) — first bind wins |
|
|
165
|
+
| `/api/records` | GET | Raw records for sentinel verification |
|
|
166
|
+
| `/api/chain` | GET | Merkle root and MMR state |
|
|
167
|
+
| `/api/sign` | POST | Signed summary (optional aggregation) |
|
|
168
|
+
| `/api/pubkey` | GET | Gateway's ECDSA public key |
|
|
169
|
+
|
|
170
|
+
## Auditing the Gateway
|
|
171
|
+
|
|
172
|
+
Search the source to verify privacy claims:
|
|
173
|
+
- `authorization`, `x-api-key` → read only to compute user hash when `X-Nous-User` is absent; value is not stored or logged
|
|
174
|
+
- `request.body` → only as argument to `fetch()` (piped, not consumed)
|
|
175
|
+
- `.content`, `.choices`, `.message` → absent from data-reading code
|
|
176
|
+
- `headers.get()` → only for `x-nous-user`, `x-nous-upstream`, `x-nous-wallet`, and `content-type`
|
package/README.md
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# nous-token
|
|
2
|
+
|
|
3
|
+
Open-source AI usage tracker. Every token, verified.
|
|
4
|
+
|
|
5
|
+
One command to track all your AI spending across every provider. Your data is signed, verifiable, and yours.
|
|
6
|
+
|
|
7
|
+
**Live leaderboard**: [token.nousai.cc](https://token.nousai.cc)
|
|
8
|
+
|
|
9
|
+
## Quick Start
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx nous-token setup
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The CLI asks two things:
|
|
16
|
+
1. **Wallet address** (0x...) — your permanent identity across API keys
|
|
17
|
+
2. **API key** — used locally to compute your hash, never sent anywhere
|
|
18
|
+
|
|
19
|
+
Then it auto-detects your AI tools (Claude Code, Cursor, Python SDKs, etc.) and routes them through the gateway.
|
|
20
|
+
|
|
21
|
+
## How It Works
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
You → AI Tool → Gateway → LLM Provider
|
|
25
|
+
↓
|
|
26
|
+
extract .usage from response
|
|
27
|
+
sign receipt (ECDSA)
|
|
28
|
+
record in Merkle tree
|
|
29
|
+
↓
|
|
30
|
+
token.nousai.cc
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The gateway is a transparent proxy. It forwards your request untouched, reads only the `.usage` field from the response, signs a receipt, and records it. Your prompts and completions are never read or stored.
|
|
34
|
+
|
|
35
|
+
## Supported Providers
|
|
36
|
+
|
|
37
|
+
Works with any OpenAI-compatible API out of the box:
|
|
38
|
+
|
|
39
|
+
| Provider | Route |
|
|
40
|
+
|----------|-------|
|
|
41
|
+
| OpenAI | `/openai/v1/...` |
|
|
42
|
+
| Anthropic | `/anthropic/...` |
|
|
43
|
+
| DeepSeek | `/deepseek/v1/...` |
|
|
44
|
+
| Google Gemini | `/gemini/...` |
|
|
45
|
+
| Groq | `/groq/v1/...` |
|
|
46
|
+
| Together | `/together/v1/...` |
|
|
47
|
+
| Mistral | `/mistral/v1/...` |
|
|
48
|
+
| OpenRouter | `/openrouter/api/v1/...` |
|
|
49
|
+
| Fireworks | `/fireworks/v1/...` |
|
|
50
|
+
| Perplexity | `/perplexity/v1/...` |
|
|
51
|
+
| Cohere | `/cohere/v1/...` |
|
|
52
|
+
| **Any custom** | Set `X-Nous-Upstream` header |
|
|
53
|
+
|
|
54
|
+
## Wallet Identity
|
|
55
|
+
|
|
56
|
+
Your wallet address is your permanent identity. API keys expire and rotate — your wallet doesn't.
|
|
57
|
+
|
|
58
|
+
- Run `npx nous-token setup` with your wallet address
|
|
59
|
+
- All usage across any API key links to your wallet
|
|
60
|
+
- Switch keys, switch machines — your history follows you
|
|
61
|
+
- First bind is permanent (prevents rebind with leaked keys)
|
|
62
|
+
|
|
63
|
+
## Privacy by Structure
|
|
64
|
+
|
|
65
|
+
Not by promise — by code. [Audit the source](gateway/src/index.ts).
|
|
66
|
+
|
|
67
|
+
| Data | What happens |
|
|
68
|
+
|------|-------------|
|
|
69
|
+
| API Key | Hashed (SHA-256) for identity. Never stored. |
|
|
70
|
+
| Prompts | `request.body` piped directly to provider. Never read. |
|
|
71
|
+
| Responses (streaming) | Tee'd. Only last 4KB buffered to extract `.usage`. |
|
|
72
|
+
| Responses (non-streaming) | In V8 isolate memory. Only `.usage` accessed. GC'd after request. |
|
|
73
|
+
| Storage | D1 stores: timestamp, user_hash, wallet, provider, model, token counts. Nothing else. |
|
|
74
|
+
|
|
75
|
+
## Verification
|
|
76
|
+
|
|
77
|
+
Every API call produces a signed receipt. Anyone can verify independently.
|
|
78
|
+
|
|
79
|
+
### Run a sentinel
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
npx tsx sentinel.ts # one-shot verify
|
|
83
|
+
npx tsx sentinel.ts --watch # continuous monitoring
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The sentinel pulls all records, recomputes leaf hashes, rebuilds the Merkle tree, and verifies receipt signatures. No API key needed.
|
|
87
|
+
|
|
88
|
+
### Trust model
|
|
89
|
+
|
|
90
|
+
1. **Code is open-source** — audit everything
|
|
91
|
+
2. **Per-call ECDSA signatures** — every receipt is signed
|
|
92
|
+
3. **Merkle Mountain Range** — tampering breaks the tree
|
|
93
|
+
4. **Sentinels** — independent verifiers anyone can run
|
|
94
|
+
5. **On-chain anchoring** — store receipts on BASE or any EVM chain
|
|
95
|
+
|
|
96
|
+
## API
|
|
97
|
+
|
|
98
|
+
Gateway: `https://gateway.nousai.cc`
|
|
99
|
+
|
|
100
|
+
| Endpoint | Method | Description |
|
|
101
|
+
|----------|--------|-------------|
|
|
102
|
+
| `/{provider}/v1/...` | * | Proxy to LLM provider |
|
|
103
|
+
| `/api/leaderboard` | GET | Top users by tokens (`?days=30`, `?days=0` for all time) |
|
|
104
|
+
| `/api/leaderboard/model` | GET | Per-model user ranking (`?model=...`) |
|
|
105
|
+
| `/api/models` | GET | Usage breakdown by model |
|
|
106
|
+
| `/api/stats` | GET | Global totals |
|
|
107
|
+
| `/api/wallet/{address}` | GET | Usage for a wallet (all linked hashes) |
|
|
108
|
+
| `/api/user/{hash}` | GET | Single user stats |
|
|
109
|
+
| `/api/user/{hash}/receipts` | GET | Signed receipts (paginated) |
|
|
110
|
+
| `/api/link` | POST | Link hash to wallet (`{api_key, wallet}`) |
|
|
111
|
+
| `/api/records` | GET | Raw records for sentinel verification |
|
|
112
|
+
| `/api/chain` | GET | Merkle root and MMR state |
|
|
113
|
+
| `/api/sign` | POST | Signed summary for on-chain storage |
|
|
114
|
+
| `/api/pubkey` | GET | Gateway's ECDSA public key |
|
|
115
|
+
|
|
116
|
+
## Architecture
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
nous-token/
|
|
120
|
+
├── gateway/ Cloudflare Worker — proxy + usage extraction + signing
|
|
121
|
+
│ └── src/
|
|
122
|
+
│ ├── index.ts API routes + proxy logic
|
|
123
|
+
│ ├── db.ts D1 storage + Merkle Mountain Range
|
|
124
|
+
│ ├── providers.ts Usage format auto-detection (OpenAI/Anthropic/Gemini)
|
|
125
|
+
│ └── stream.ts Streaming response usage extraction
|
|
126
|
+
├── web/ Cloudflare Pages — leaderboard frontend
|
|
127
|
+
│ └── index.html
|
|
128
|
+
├── cli/ CLI setup tool
|
|
129
|
+
│ └── setup.ts
|
|
130
|
+
├── index.ts OpenClaw plugin
|
|
131
|
+
├── sentinel.ts Independent Merkle tree verifier
|
|
132
|
+
└── PROTOCOL.md Full protocol specification
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
MIT
|
package/cli/setup.ts
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
#!/usr/bin/env npx tsx
|
|
2
|
+
//
|
|
3
|
+
// nous-token setup — one command to configure all AI tools
|
|
4
|
+
//
|
|
5
|
+
// Usage: npx nous-token setup
|
|
6
|
+
//
|
|
7
|
+
// Scans for installed AI tools, configures them to route through
|
|
8
|
+
// the nous-token gateway for usage tracking.
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync } from "fs";
|
|
11
|
+
import { homedir } from "os";
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
import { execSync } from "child_process";
|
|
14
|
+
import { createHash } from "crypto";
|
|
15
|
+
|
|
16
|
+
const GATEWAY = "https://gateway.nousai.cc";
|
|
17
|
+
const HOME = homedir();
|
|
18
|
+
const NOUS_CONFIG = join(HOME, ".nous-token");
|
|
19
|
+
|
|
20
|
+
function loadWallet(): string {
|
|
21
|
+
try { return readFileSync(NOUS_CONFIG, "utf-8").trim(); } catch { return ""; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function saveWallet(wallet: string): void {
|
|
25
|
+
writeFileSync(NOUS_CONFIG, wallet);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function gatewayUrl(path: string, wallet: string): string {
|
|
29
|
+
return wallet ? `${GATEWAY}${path}?wallet=${wallet}` : `${GATEWAY}${path}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface Tool {
|
|
33
|
+
name: string;
|
|
34
|
+
detect: () => boolean;
|
|
35
|
+
configure: (wallet: string) => ConfigResult;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ConfigResult {
|
|
39
|
+
success: boolean;
|
|
40
|
+
message: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── Tool detectors and configurators ──
|
|
44
|
+
|
|
45
|
+
const tools: Tool[] = [
|
|
46
|
+
// OpenClaw
|
|
47
|
+
{
|
|
48
|
+
name: "OpenClaw",
|
|
49
|
+
detect: () => {
|
|
50
|
+
try { execSync("which openclaw", { stdio: "ignore" }); return true; } catch { return false; }
|
|
51
|
+
},
|
|
52
|
+
configure: (_wallet: string) => {
|
|
53
|
+
try {
|
|
54
|
+
execSync("openclaw plugins install nous-token", { stdio: "inherit" });
|
|
55
|
+
return { success: true, message: "installed nous-token plugin" };
|
|
56
|
+
} catch {
|
|
57
|
+
return { success: false, message: "plugin install failed" };
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
// Claude Code
|
|
63
|
+
{
|
|
64
|
+
name: "Claude Code",
|
|
65
|
+
detect: () => {
|
|
66
|
+
const claudeDir = join(HOME, ".claude");
|
|
67
|
+
if (existsSync(claudeDir)) return true;
|
|
68
|
+
try { execSync("which claude", { stdio: "ignore" }); return true; } catch { return false; }
|
|
69
|
+
},
|
|
70
|
+
configure: (wallet: string) => {
|
|
71
|
+
return setShellEnv("ANTHROPIC_BASE_URL", gatewayUrl("/anthropic", wallet), "Claude Code");
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
// Cursor
|
|
76
|
+
{
|
|
77
|
+
name: "Cursor",
|
|
78
|
+
detect: () => {
|
|
79
|
+
const paths = [
|
|
80
|
+
join(HOME, ".cursor"),
|
|
81
|
+
join(HOME, "Library", "Application Support", "Cursor"),
|
|
82
|
+
join(HOME, ".config", "Cursor"),
|
|
83
|
+
];
|
|
84
|
+
return paths.some(p => existsSync(p));
|
|
85
|
+
},
|
|
86
|
+
configure: (wallet: string) => {
|
|
87
|
+
const settingsPaths = [
|
|
88
|
+
join(HOME, "Library", "Application Support", "Cursor", "User", "settings.json"),
|
|
89
|
+
join(HOME, ".config", "Cursor", "User", "settings.json"),
|
|
90
|
+
];
|
|
91
|
+
for (const sp of settingsPaths) {
|
|
92
|
+
if (existsSync(sp)) {
|
|
93
|
+
try {
|
|
94
|
+
const content = JSON.parse(readFileSync(sp, "utf-8"));
|
|
95
|
+
if (content["openai.baseUrl"]?.includes("nousai")) {
|
|
96
|
+
return { success: true, message: "already configured" };
|
|
97
|
+
}
|
|
98
|
+
content["openai.baseUrl"] = gatewayUrl("/openai/v1", wallet);
|
|
99
|
+
writeFileSync(sp, JSON.stringify(content, null, 2));
|
|
100
|
+
return { success: true, message: `set baseUrl in Cursor settings` };
|
|
101
|
+
} catch {
|
|
102
|
+
return { success: false, message: "failed to update Cursor settings" };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { success: false, message: "Cursor settings file not found" };
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
// Codex CLI
|
|
111
|
+
{
|
|
112
|
+
name: "Codex",
|
|
113
|
+
detect: () => {
|
|
114
|
+
return existsSync(join(HOME, ".codex")) ||
|
|
115
|
+
(() => { try { execSync("which codex", { stdio: "ignore" }); return true; } catch { return false; } })();
|
|
116
|
+
},
|
|
117
|
+
configure: (wallet: string) => {
|
|
118
|
+
return setShellEnv("OPENAI_BASE_URL", gatewayUrl("/openai/v1", wallet), "Codex");
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
// Gemini CLI
|
|
123
|
+
{
|
|
124
|
+
name: "Gemini CLI",
|
|
125
|
+
detect: () => {
|
|
126
|
+
return existsSync(join(HOME, ".gemini")) ||
|
|
127
|
+
(() => { try { execSync("which gemini", { stdio: "ignore" }); return true; } catch { return false; } })();
|
|
128
|
+
},
|
|
129
|
+
configure: (wallet: string) => {
|
|
130
|
+
return setShellEnv("GOOGLE_GEMINI_BASE_URL", gatewayUrl("/gemini", wallet), "Gemini CLI");
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
// Python openai SDK
|
|
135
|
+
{
|
|
136
|
+
name: "Python (openai)",
|
|
137
|
+
detect: () => {
|
|
138
|
+
try { execSync("python3 -c 'import openai'", { stdio: "ignore" }); return true; } catch { return false; }
|
|
139
|
+
},
|
|
140
|
+
configure: (wallet: string) => {
|
|
141
|
+
return setShellEnv("OPENAI_BASE_URL", gatewayUrl("/openai/v1", wallet), "Python openai SDK");
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
// Python anthropic SDK
|
|
146
|
+
{
|
|
147
|
+
name: "Python (anthropic)",
|
|
148
|
+
detect: () => {
|
|
149
|
+
try { execSync("python3 -c 'import anthropic'", { stdio: "ignore" }); return true; } catch { return false; }
|
|
150
|
+
},
|
|
151
|
+
configure: (wallet: string) => {
|
|
152
|
+
return setShellEnv("ANTHROPIC_BASE_URL", gatewayUrl("/anthropic", wallet), "Python anthropic SDK");
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
// ── Shell env helper ──
|
|
158
|
+
|
|
159
|
+
function setShellEnv(key: string, value: string, toolName: string): ConfigResult {
|
|
160
|
+
const shell = process.env.SHELL || "/bin/bash";
|
|
161
|
+
const rcFile = shell.includes("zsh") ? join(HOME, ".zshrc") : join(HOME, ".bashrc");
|
|
162
|
+
|
|
163
|
+
if (existsSync(rcFile)) {
|
|
164
|
+
const content = readFileSync(rcFile, "utf-8");
|
|
165
|
+
// Check if already configured with the exact same value
|
|
166
|
+
if (content.includes(`${key}="${value}"`)) {
|
|
167
|
+
return { success: true, message: "already configured" };
|
|
168
|
+
}
|
|
169
|
+
// Remove old nous-token setting if exists, then write new one
|
|
170
|
+
const lines = content.split("\n").filter(l => !(l.includes(`${key}=`) && l.includes("nousai")));
|
|
171
|
+
lines.push(`export ${key}="${value}" # nous-token gateway`);
|
|
172
|
+
writeFileSync(rcFile, lines.join("\n"));
|
|
173
|
+
} else {
|
|
174
|
+
writeFileSync(rcFile, `export ${key}="${value}" # nous-token gateway\n`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Also set in current process
|
|
178
|
+
process.env[key] = value;
|
|
179
|
+
|
|
180
|
+
const rcName = rcFile.includes("zsh") ? "~/.zshrc" : "~/.bashrc";
|
|
181
|
+
return { success: true, message: `set ${key} in ${rcName}` };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── Find first available API key ──
|
|
185
|
+
|
|
186
|
+
function findFirstApiKey(): string | null {
|
|
187
|
+
const keys = [
|
|
188
|
+
process.env.OPENAI_API_KEY,
|
|
189
|
+
process.env.ANTHROPIC_API_KEY,
|
|
190
|
+
process.env.DEEPSEEK_API_KEY,
|
|
191
|
+
process.env.GEMINI_API_KEY,
|
|
192
|
+
process.env.GROQ_API_KEY,
|
|
193
|
+
];
|
|
194
|
+
for (const key of keys) {
|
|
195
|
+
if (key) return key.replace(/^Bearer\s+/i, "");
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function computeUserHash(): string | null {
|
|
201
|
+
const key = findFirstApiKey();
|
|
202
|
+
if (!key) return null;
|
|
203
|
+
return createHash("sha256").update(key).digest("hex").slice(0, 32);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── Prompt helper ──
|
|
207
|
+
|
|
208
|
+
async function ask(prompt: string): Promise<string> {
|
|
209
|
+
process.stdout.write(prompt);
|
|
210
|
+
return new Promise<string>((resolve) => {
|
|
211
|
+
process.stdin.setEncoding("utf-8");
|
|
212
|
+
process.stdin.once("data", (chunk) => resolve(chunk.toString().trim()));
|
|
213
|
+
process.stdin.resume();
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── Main ──
|
|
218
|
+
|
|
219
|
+
console.log("");
|
|
220
|
+
console.log(" \x1b[1mnous-token setup\x1b[0m\n");
|
|
221
|
+
|
|
222
|
+
// Step 1: Wallet address
|
|
223
|
+
let wallet = loadWallet();
|
|
224
|
+
const walletArg = process.argv.find(a => /^0x[a-fA-F0-9]{40}$/.test(a));
|
|
225
|
+
if (walletArg) {
|
|
226
|
+
wallet = walletArg.toLowerCase();
|
|
227
|
+
} else if (!wallet) {
|
|
228
|
+
const input = await ask(" Wallet address (0x...): ");
|
|
229
|
+
if (/^0x[a-fA-F0-9]{40}$/.test(input)) {
|
|
230
|
+
wallet = input.toLowerCase();
|
|
231
|
+
} else if (input) {
|
|
232
|
+
console.log(" \x1b[33mInvalid address format.\x1b[0m\n");
|
|
233
|
+
process.exit(1);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (wallet) {
|
|
238
|
+
saveWallet(wallet);
|
|
239
|
+
console.log(` \x1b[36mWallet: ${wallet}\x1b[0m\n`);
|
|
240
|
+
} else {
|
|
241
|
+
console.log(" \x1b[90mNo wallet. Run again with: npx nous-token setup 0x...\x1b[0m\n");
|
|
242
|
+
process.exit(0);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Step 2: API key — read from env or ask
|
|
246
|
+
let apiKey = findFirstApiKey();
|
|
247
|
+
if (!apiKey) {
|
|
248
|
+
const input = await ask(" API key (sk-...): ");
|
|
249
|
+
if (input) {
|
|
250
|
+
apiKey = input.replace(/^Bearer\s+/i, "");
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Step 3: Link hash → wallet via gateway
|
|
255
|
+
if (apiKey) {
|
|
256
|
+
const hash = createHash("sha256").update(apiKey).digest("hex").slice(0, 32);
|
|
257
|
+
console.log(` \x1b[90mUser hash: ${hash}\x1b[0m`);
|
|
258
|
+
try {
|
|
259
|
+
const res = await fetch(`${GATEWAY}/api/link`, {
|
|
260
|
+
method: "POST",
|
|
261
|
+
headers: { "Content-Type": "application/json" },
|
|
262
|
+
body: JSON.stringify({ api_key: apiKey, wallet }),
|
|
263
|
+
});
|
|
264
|
+
const data = await res.json() as { ok?: boolean; error?: string };
|
|
265
|
+
if (data.ok) {
|
|
266
|
+
console.log(` \x1b[32m✓ Linked to wallet\x1b[0m\n`);
|
|
267
|
+
} else {
|
|
268
|
+
console.log(` \x1b[90m${data.error || "No existing records to link — they'll link automatically on first use."}\x1b[0m\n`);
|
|
269
|
+
}
|
|
270
|
+
} catch {
|
|
271
|
+
console.log(" \x1b[90mCouldn't reach gateway — linking will happen on first use.\x1b[0m\n");
|
|
272
|
+
}
|
|
273
|
+
} else {
|
|
274
|
+
console.log(" \x1b[90mNo API key found. Wallet will link on first use through the gateway.\x1b[0m\n");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Step 4: Configure tools
|
|
278
|
+
console.log(" Scanning for AI tools...\n");
|
|
279
|
+
|
|
280
|
+
let configured = 0;
|
|
281
|
+
|
|
282
|
+
for (const tool of tools) {
|
|
283
|
+
if (tool.detect()) {
|
|
284
|
+
const result = tool.configure(wallet);
|
|
285
|
+
if (result.success) {
|
|
286
|
+
console.log(` \x1b[32m✓\x1b[0m ${tool.name.padEnd(20)} → ${result.message}`);
|
|
287
|
+
configured++;
|
|
288
|
+
} else {
|
|
289
|
+
console.log(` \x1b[31m✗\x1b[0m ${tool.name.padEnd(20)} → ${result.message}`);
|
|
290
|
+
}
|
|
291
|
+
} else {
|
|
292
|
+
console.log(` \x1b[90m-\x1b[0m \x1b[90m${tool.name.padEnd(20)} → not found\x1b[0m`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
console.log("");
|
|
297
|
+
|
|
298
|
+
if (configured > 0) {
|
|
299
|
+
console.log(` \x1b[32m${configured} tool(s) configured.\x1b[0m`);
|
|
300
|
+
console.log(` See your usage at \x1b[4mhttps://token.nousai.cc\x1b[0m`);
|
|
301
|
+
console.log(` Run \x1b[1msource ~/.zshrc\x1b[0m to apply changes in this terminal.`);
|
|
302
|
+
} else {
|
|
303
|
+
console.log(" No AI tools found. Install Claude Code, Cursor, or any OpenAI-compatible tool.");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
console.log("");
|
package/index.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
// nous-token plugin: routes LLM calls through the nous-token gateway
|
|
4
|
+
// for real usage tracking on the global leaderboard.
|
|
5
|
+
//
|
|
6
|
+
// Supports 11 built-in providers + unlimited custom providers via config.
|
|
7
|
+
// Any OpenAI-compatible API can be routed through the gateway.
|
|
8
|
+
//
|
|
9
|
+
// PRIVACY: API Key never leaves the machine in readable form.
|
|
10
|
+
// Plugin computes SHA-256(apiKey) locally and sends only the hash
|
|
11
|
+
// to the gateway via X-Nous-User header.
|
|
12
|
+
|
|
13
|
+
const GATEWAY = "https://gateway.nousai.cc";
|
|
14
|
+
|
|
15
|
+
// Built-in providers: prefix maps to gateway shortcut route
|
|
16
|
+
const BUILTIN_PROVIDERS: Record<string, { prefix: string; api: string; envVars: string[] }> = {
|
|
17
|
+
openai: { prefix: "openai", api: "openai-completions", envVars: ["OPENAI_API_KEY"] },
|
|
18
|
+
anthropic: { prefix: "anthropic", api: "anthropic-messages", envVars: ["ANTHROPIC_API_KEY"] },
|
|
19
|
+
deepseek: { prefix: "deepseek", api: "openai-completions", envVars: ["DEEPSEEK_API_KEY"] },
|
|
20
|
+
google: { prefix: "gemini", api: "openai-completions", envVars: ["GEMINI_API_KEY", "GOOGLE_AI_API_KEY"] },
|
|
21
|
+
groq: { prefix: "groq", api: "openai-completions", envVars: ["GROQ_API_KEY"] },
|
|
22
|
+
together: { prefix: "together", api: "openai-completions", envVars: ["TOGETHER_API_KEY"] },
|
|
23
|
+
mistral: { prefix: "mistral", api: "openai-completions", envVars: ["MISTRAL_API_KEY"] },
|
|
24
|
+
openrouter: { prefix: "openrouter", api: "openai-completions", envVars: ["OPENROUTER_API_KEY"] },
|
|
25
|
+
fireworks: { prefix: "fireworks", api: "openai-completions", envVars: ["FIREWORKS_API_KEY"] },
|
|
26
|
+
perplexity: { prefix: "perplexity", api: "openai-completions", envVars: ["PERPLEXITY_API_KEY"] },
|
|
27
|
+
cohere: { prefix: "cohere", api: "openai-completions", envVars: ["COHERE_API_KEY"] },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
interface PluginConfig {
|
|
31
|
+
// Custom providers: { "myapi": { "upstream": "https://api.myapi.com", "envVar": "MYAPI_KEY" } }
|
|
32
|
+
customProviders?: Record<string, { upstream: string; envVar: string; api?: string }>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default function plugin(api: OpenClawPluginApi): void {
|
|
36
|
+
const cfg = (api.pluginConfig ?? {}) as PluginConfig;
|
|
37
|
+
|
|
38
|
+
// ── Register built-in providers ──
|
|
39
|
+
for (const [providerKey, { prefix, api: apiType, envVars }] of Object.entries(BUILTIN_PROVIDERS)) {
|
|
40
|
+
registerProvider(api, providerKey, {
|
|
41
|
+
baseUrl: `${GATEWAY}/${prefix}/v1`,
|
|
42
|
+
apiType,
|
|
43
|
+
envVars,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ── Register custom providers from config ──
|
|
48
|
+
if (cfg.customProviders) {
|
|
49
|
+
for (const [name, { upstream, envVar, api: apiType }] of Object.entries(cfg.customProviders)) {
|
|
50
|
+
registerProvider(api, name, {
|
|
51
|
+
// Custom providers use X-Nous-Upstream header instead of shortcut prefix
|
|
52
|
+
baseUrl: `${GATEWAY}/v1`,
|
|
53
|
+
apiType: apiType || "openai-completions",
|
|
54
|
+
envVars: [envVar],
|
|
55
|
+
upstreamHeader: upstream,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const total = Object.keys(BUILTIN_PROVIDERS).length + Object.keys(cfg.customProviders || {}).length;
|
|
61
|
+
api.logger.info(`[nous-token] ready — ${total} providers, usage tracked via gateway`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Provider registration ──
|
|
65
|
+
|
|
66
|
+
function registerProvider(
|
|
67
|
+
api: OpenClawPluginApi,
|
|
68
|
+
providerKey: string,
|
|
69
|
+
opts: { baseUrl: string; apiType: string; envVars: string[]; upstreamHeader?: string }
|
|
70
|
+
): void {
|
|
71
|
+
const nousId = `nous-${providerKey}`;
|
|
72
|
+
|
|
73
|
+
api.registerProvider({
|
|
74
|
+
id: nousId,
|
|
75
|
+
label: `${providerKey} (nous-token)`,
|
|
76
|
+
|
|
77
|
+
auth: {
|
|
78
|
+
envVars: opts.envVars,
|
|
79
|
+
choices: [{
|
|
80
|
+
method: "api-key" as const,
|
|
81
|
+
choiceId: `${nousId}-key`,
|
|
82
|
+
choiceLabel: `${providerKey} API key`,
|
|
83
|
+
groupId: nousId,
|
|
84
|
+
groupLabel: `${providerKey} via nous-token`,
|
|
85
|
+
}],
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
catalog: { run: async () => null },
|
|
89
|
+
|
|
90
|
+
resolveDynamicModel: (ctx) => ({
|
|
91
|
+
id: ctx.modelId,
|
|
92
|
+
name: ctx.modelId,
|
|
93
|
+
provider: nousId,
|
|
94
|
+
api: opts.apiType,
|
|
95
|
+
baseUrl: opts.baseUrl,
|
|
96
|
+
reasoning: false,
|
|
97
|
+
input: ["text"] as const,
|
|
98
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
99
|
+
contextWindow: 200000,
|
|
100
|
+
maxTokens: 32768,
|
|
101
|
+
}),
|
|
102
|
+
|
|
103
|
+
wrapStreamFn: (ctx) => {
|
|
104
|
+
if (!ctx.streamFn) return undefined;
|
|
105
|
+
const inner = ctx.streamFn;
|
|
106
|
+
return async (params) => {
|
|
107
|
+
let rawKey = params.headers?.["authorization"] || params.headers?.["x-api-key"] || "";
|
|
108
|
+
rawKey = rawKey.replace(/^Bearer\s+/i, "");
|
|
109
|
+
if (!rawKey) {
|
|
110
|
+
return inner(params);
|
|
111
|
+
}
|
|
112
|
+
const keyHash = await sha256(rawKey);
|
|
113
|
+
params.headers = {
|
|
114
|
+
...params.headers,
|
|
115
|
+
"x-nous-user": keyHash,
|
|
116
|
+
...(opts.upstreamHeader ? { "x-nous-upstream": opts.upstreamHeader } : {}),
|
|
117
|
+
};
|
|
118
|
+
return inner(params);
|
|
119
|
+
};
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function sha256(input: string): Promise<string> {
|
|
125
|
+
const data = new TextEncoder().encode(input);
|
|
126
|
+
const hash = await crypto.subtle.digest("SHA-256", data);
|
|
127
|
+
return Array.from(new Uint8Array(hash).slice(0, 16))
|
|
128
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
129
|
+
.join("");
|
|
130
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "nous-token",
|
|
3
|
+
"name": "nous-token",
|
|
4
|
+
"description": "Track your AI usage on the global leaderboard. Routes LLM calls through the nous-token gateway to record real token consumption. Supports 11 providers out of the box + unlimited custom providers. No prompt or response content is stored.",
|
|
5
|
+
"version": "0.2.0",
|
|
6
|
+
"kind": "provider",
|
|
7
|
+
"providers": [
|
|
8
|
+
"nous-openai", "nous-anthropic", "nous-deepseek", "nous-google",
|
|
9
|
+
"nous-groq", "nous-together", "nous-mistral", "nous-openrouter",
|
|
10
|
+
"nous-fireworks", "nous-perplexity", "nous-cohere"
|
|
11
|
+
],
|
|
12
|
+
"providerAuthEnvVars": {
|
|
13
|
+
"nous-openai": ["OPENAI_API_KEY"],
|
|
14
|
+
"nous-anthropic": ["ANTHROPIC_API_KEY"],
|
|
15
|
+
"nous-deepseek": ["DEEPSEEK_API_KEY"],
|
|
16
|
+
"nous-google": ["GEMINI_API_KEY", "GOOGLE_AI_API_KEY"],
|
|
17
|
+
"nous-groq": ["GROQ_API_KEY"],
|
|
18
|
+
"nous-together": ["TOGETHER_API_KEY"],
|
|
19
|
+
"nous-mistral": ["MISTRAL_API_KEY"],
|
|
20
|
+
"nous-openrouter": ["OPENROUTER_API_KEY"],
|
|
21
|
+
"nous-fireworks": ["FIREWORKS_API_KEY"],
|
|
22
|
+
"nous-perplexity": ["PERPLEXITY_API_KEY"],
|
|
23
|
+
"nous-cohere": ["COHERE_API_KEY"]
|
|
24
|
+
},
|
|
25
|
+
"configSchema": {
|
|
26
|
+
"type": "object",
|
|
27
|
+
"additionalProperties": false,
|
|
28
|
+
"properties": {
|
|
29
|
+
"customProviders": {
|
|
30
|
+
"type": "object",
|
|
31
|
+
"description": "Add custom providers. Key is provider name, value has upstream URL and env var for API key.",
|
|
32
|
+
"additionalProperties": {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"properties": {
|
|
35
|
+
"upstream": { "type": "string", "description": "Provider API base URL (e.g. https://api.myapi.com)" },
|
|
36
|
+
"envVar": { "type": "string", "description": "Environment variable name for the API key" },
|
|
37
|
+
"api": { "type": "string", "description": "API type (default: openai-completions)", "enum": ["openai-completions", "anthropic-messages"] }
|
|
38
|
+
},
|
|
39
|
+
"required": ["upstream", "envVar"]
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nous-token",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Open-source AI usage tracker. One command to track all your AI spending.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.ts",
|
|
7
|
+
"bin": {
|
|
8
|
+
"nous-token": "./cli/setup.ts"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"cli/",
|
|
12
|
+
"index.ts",
|
|
13
|
+
"sentinel.ts",
|
|
14
|
+
"openclaw.plugin.json",
|
|
15
|
+
"README.md",
|
|
16
|
+
"PROTOCOL.md"
|
|
17
|
+
],
|
|
18
|
+
"openclaw": {
|
|
19
|
+
"extensions": ["./index.ts"]
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"tsx": "^4"
|
|
23
|
+
},
|
|
24
|
+
"keywords": ["ai", "llm", "usage", "tracker", "openclaw", "openai", "anthropic", "leaderboard", "wallet", "crypto"],
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "https://github.com/nousworld/nous-token"
|
|
29
|
+
}
|
|
30
|
+
}
|
package/sentinel.ts
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
#!/usr/bin/env npx tsx
|
|
2
|
+
//
|
|
3
|
+
// nous-token sentinel — independent Merkle tree verifier
|
|
4
|
+
//
|
|
5
|
+
// Pulls records from the gateway, recomputes leaf hashes, rebuilds the
|
|
6
|
+
// Merkle Mountain Range locally, and compares the root with the gateway's.
|
|
7
|
+
//
|
|
8
|
+
// Usage:
|
|
9
|
+
// npx tsx sentinel.ts # one-shot verify
|
|
10
|
+
// npx tsx sentinel.ts --watch # verify every 5 minutes
|
|
11
|
+
// npx tsx sentinel.ts --gateway https://... # verify a different gateway
|
|
12
|
+
//
|
|
13
|
+
// Anyone can run this. No API key needed. All data is public.
|
|
14
|
+
|
|
15
|
+
const DEFAULT_GATEWAY = "https://gateway.nousai.cc";
|
|
16
|
+
const POLL_INTERVAL_MS = 5 * 60 * 1000;
|
|
17
|
+
|
|
18
|
+
interface Record {
|
|
19
|
+
id: number;
|
|
20
|
+
timestamp: string;
|
|
21
|
+
user_hash: string;
|
|
22
|
+
provider: string;
|
|
23
|
+
model: string;
|
|
24
|
+
input_tokens: number;
|
|
25
|
+
output_tokens: number;
|
|
26
|
+
cache_read_tokens: number;
|
|
27
|
+
cache_write_tokens: number;
|
|
28
|
+
total_tokens: number;
|
|
29
|
+
leaf_hash: string;
|
|
30
|
+
receipt_sig?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface ChainState {
|
|
34
|
+
merkle_root: string;
|
|
35
|
+
leaf_count: number;
|
|
36
|
+
peak_count: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface Peak {
|
|
40
|
+
height: number;
|
|
41
|
+
hash: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Crypto ──
|
|
45
|
+
|
|
46
|
+
async function sha256(input: string): Promise<string> {
|
|
47
|
+
const data = new TextEncoder().encode(input);
|
|
48
|
+
const hash = await crypto.subtle.digest("SHA-256", data);
|
|
49
|
+
return Array.from(new Uint8Array(hash))
|
|
50
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
51
|
+
.join("");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── MMR ──
|
|
55
|
+
|
|
56
|
+
function computeLeafHash(r: Record): Promise<string> {
|
|
57
|
+
// Model name is normalized at write time (provider/ prefix stripped)
|
|
58
|
+
// Use the stored model name as-is — it matches what was hashed at write time
|
|
59
|
+
return sha256([
|
|
60
|
+
r.timestamp,
|
|
61
|
+
r.user_hash,
|
|
62
|
+
r.provider,
|
|
63
|
+
r.model,
|
|
64
|
+
r.input_tokens,
|
|
65
|
+
r.output_tokens,
|
|
66
|
+
r.cache_read_tokens,
|
|
67
|
+
r.cache_write_tokens,
|
|
68
|
+
r.total_tokens,
|
|
69
|
+
].join("|"));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function appendToMMR(peaks: Peak[], leafHash: string): Promise<Peak[]> {
|
|
73
|
+
let current: Peak = { height: 0, hash: leafHash };
|
|
74
|
+
|
|
75
|
+
while (peaks.length > 0 && peaks[peaks.length - 1].height === current.height) {
|
|
76
|
+
const left = peaks.pop()!;
|
|
77
|
+
const mergedHash = await sha256(left.hash + "|" + current.hash);
|
|
78
|
+
current = { height: current.height + 1, hash: mergedHash };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
peaks.push(current);
|
|
82
|
+
return peaks;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function computeRoot(peaks: Peak[]): Promise<string> {
|
|
86
|
+
if (peaks.length === 0) return "empty";
|
|
87
|
+
if (peaks.length === 1) return peaks[0].hash;
|
|
88
|
+
return sha256(peaks.map((p) => p.hash).join("|"));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── HTTP ──
|
|
92
|
+
|
|
93
|
+
async function fetchJSON<T>(url: string): Promise<T> {
|
|
94
|
+
const res = await fetch(url);
|
|
95
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
|
96
|
+
return res.json() as Promise<T>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Receipt Signature Verification ──
|
|
100
|
+
|
|
101
|
+
async function getGatewayPubkey(gateway: string): Promise<CryptoKey | null> {
|
|
102
|
+
try {
|
|
103
|
+
const res = await fetchJSON<{ ok: boolean; algorithm: string; pubkey: JsonWebKey }>(
|
|
104
|
+
`${gateway}/api/pubkey`
|
|
105
|
+
);
|
|
106
|
+
if (!res.ok || !res.pubkey) return null;
|
|
107
|
+
return crypto.subtle.importKey(
|
|
108
|
+
"jwk",
|
|
109
|
+
res.pubkey,
|
|
110
|
+
{ name: "ECDSA", namedCurve: "P-256" },
|
|
111
|
+
false,
|
|
112
|
+
["verify"]
|
|
113
|
+
);
|
|
114
|
+
} catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function verifyReceiptSig(pubkey: CryptoKey, leafHash: string, sig: string): Promise<boolean> {
|
|
120
|
+
const sigBytes = new Uint8Array(sig.match(/.{2}/g)!.map((b) => parseInt(b, 16)));
|
|
121
|
+
return crypto.subtle.verify(
|
|
122
|
+
{ name: "ECDSA", hash: "SHA-256" },
|
|
123
|
+
pubkey,
|
|
124
|
+
sigBytes,
|
|
125
|
+
new TextEncoder().encode(leafHash)
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Verification ──
|
|
130
|
+
|
|
131
|
+
async function verify(gateway: string): Promise<boolean> {
|
|
132
|
+
// 1. Get Merkle state from gateway
|
|
133
|
+
const chainRes = await fetchJSON<{ ok: boolean; data: ChainState }>(
|
|
134
|
+
`${gateway}/api/chain`
|
|
135
|
+
);
|
|
136
|
+
const state = chainRes.data;
|
|
137
|
+
|
|
138
|
+
if (state.leaf_count === 0) {
|
|
139
|
+
console.log("[sentinel] Tree is empty (no records yet). OK.");
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
console.log(`[sentinel] Gateway reports: ${state.leaf_count} records, ${state.peak_count} peaks`);
|
|
144
|
+
console.log(`[sentinel] Merkle root: ${state.merkle_root}`);
|
|
145
|
+
|
|
146
|
+
// 2. Fetch gateway public key for receipt signature verification
|
|
147
|
+
const pubkey = await getGatewayPubkey(gateway);
|
|
148
|
+
if (pubkey) {
|
|
149
|
+
console.log(`[sentinel] Gateway public key loaded — will verify receipt signatures`);
|
|
150
|
+
} else {
|
|
151
|
+
console.log(`[sentinel] No public key available — skipping receipt signature verification`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 3. Pull all records and rebuild MMR
|
|
155
|
+
let afterId = 0;
|
|
156
|
+
let verified = 0;
|
|
157
|
+
let sigVerified = 0;
|
|
158
|
+
let sigMissing = 0;
|
|
159
|
+
let peaks: Peak[] = [];
|
|
160
|
+
|
|
161
|
+
while (true) {
|
|
162
|
+
const recordsRes = await fetchJSON<{ ok: boolean; data: Record[]; has_more: boolean }>(
|
|
163
|
+
`${gateway}/api/records?after=${afterId}&limit=10000`
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
if (recordsRes.data.length === 0) break;
|
|
167
|
+
|
|
168
|
+
for (const r of recordsRes.data) {
|
|
169
|
+
// Recompute leaf hash from record fields
|
|
170
|
+
const computed = await computeLeafHash(r);
|
|
171
|
+
if (computed !== r.leaf_hash) {
|
|
172
|
+
console.error(`[sentinel] LEAF HASH MISMATCH at record #${r.id}`);
|
|
173
|
+
console.error(` Computed: ${computed}`);
|
|
174
|
+
console.error(` Stored: ${r.leaf_hash}`);
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Verify receipt signature if available
|
|
179
|
+
if (pubkey && r.receipt_sig) {
|
|
180
|
+
const sigValid = await verifyReceiptSig(pubkey, r.leaf_hash, r.receipt_sig);
|
|
181
|
+
if (!sigValid) {
|
|
182
|
+
console.error(`[sentinel] RECEIPT SIGNATURE INVALID at record #${r.id}`);
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
sigVerified++;
|
|
186
|
+
} else if (!r.receipt_sig) {
|
|
187
|
+
sigMissing++;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Append to local MMR
|
|
191
|
+
peaks = await appendToMMR(peaks, computed);
|
|
192
|
+
|
|
193
|
+
afterId = r.id;
|
|
194
|
+
verified++;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
console.log(`[sentinel] Verified ${verified} leaf hashes...`);
|
|
198
|
+
|
|
199
|
+
if (!recordsRes.has_more) break;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// 3. Compare roots
|
|
203
|
+
if (verified !== state.leaf_count) {
|
|
204
|
+
console.error(`[sentinel] RECORD COUNT MISMATCH`);
|
|
205
|
+
console.error(` Gateway claims: ${state.leaf_count}`);
|
|
206
|
+
console.error(` Records found: ${verified}`);
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const computedRoot = await computeRoot(peaks);
|
|
211
|
+
if (computedRoot !== state.merkle_root) {
|
|
212
|
+
console.error(`[sentinel] MERKLE ROOT MISMATCH`);
|
|
213
|
+
console.error(` Computed: ${computedRoot}`);
|
|
214
|
+
console.error(` Gateway: ${state.merkle_root}`);
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
console.log(`[sentinel] ALL ${verified} RECORDS VERIFIED. Merkle tree intact.`);
|
|
219
|
+
console.log(`[sentinel] Root: ${computedRoot}`);
|
|
220
|
+
console.log(`[sentinel] Peaks: ${peaks.length} (heights: ${peaks.map((p) => p.height).join(", ")})`);
|
|
221
|
+
if (sigVerified > 0) {
|
|
222
|
+
console.log(`[sentinel] Receipt signatures verified: ${sigVerified}/${verified}` +
|
|
223
|
+
(sigMissing > 0 ? ` (${sigMissing} unsigned — pre-receipt records)` : ""));
|
|
224
|
+
}
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── Main ──
|
|
229
|
+
|
|
230
|
+
const args = process.argv.slice(2);
|
|
231
|
+
const watch = args.includes("--watch");
|
|
232
|
+
const gatewayIdx = args.indexOf("--gateway");
|
|
233
|
+
const gateway = (gatewayIdx >= 0 ? args[gatewayIdx + 1] : DEFAULT_GATEWAY).replace(/\/$/, "");
|
|
234
|
+
|
|
235
|
+
console.log(`[sentinel] Gateway: ${gateway}`);
|
|
236
|
+
console.log(`[sentinel] Mode: ${watch ? "watch (every 5m)" : "one-shot"}`);
|
|
237
|
+
console.log("");
|
|
238
|
+
|
|
239
|
+
async function run() {
|
|
240
|
+
try {
|
|
241
|
+
const ok = await verify(gateway);
|
|
242
|
+
if (!ok) {
|
|
243
|
+
console.error("\n[sentinel] TAMPERING DETECTED. Data integrity compromised.");
|
|
244
|
+
if (!watch) process.exit(1);
|
|
245
|
+
}
|
|
246
|
+
} catch (err) {
|
|
247
|
+
console.error(`[sentinel] Error: ${err}`);
|
|
248
|
+
if (!watch) process.exit(1);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
await run();
|
|
253
|
+
|
|
254
|
+
if (watch) {
|
|
255
|
+
setInterval(run, POLL_INTERVAL_MS);
|
|
256
|
+
}
|