nansen-cli 1.36.1 → 1.36.2
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/CHANGELOG.md +6 -0
- package/README.md +3 -3
- package/package.json +1 -1
- package/src/api.js +36 -2
- package/src/cli.js +14 -1
- package/src/commands/agent.js +14 -4
- package/src/cost-cache.js +15 -0
- package/src/response-meta.js +13 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.36.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#479](https://github.com/nansen-ai/nansen-cli/pull/479) [`e2590ed`](https://github.com/nansen-ai/nansen-cli/commit/e2590ed5caf0461b43f6e726ec62d87f70d391fd) Thanks [@gulshngill](https://github.com/gulshngill)! - Surface richer API response metadata: the `X-Nansen-Credits-Cost` header now drives credit reporting (a concise `Credits: N (this call)` stderr line after each data command, falling back to the cached spec estimate when the header is absent), `requestId` is hoisted to the top level of the JSON error envelope (including `nansen agent` failures, which previously dropped it), and error codes now come from the API's stable `code` field when present — known codes map onto the existing error code enum, unknown ones pass through verbatim instead of being flattened. stdout JSON is unchanged; all new reporting goes to stderr.
|
|
8
|
+
|
|
3
9
|
## 1.36.1
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -209,7 +209,7 @@ nansen research smart-money netflow --chain solana --fields token_symbol,net_flo
|
|
|
209
209
|
|
|
210
210
|
```json
|
|
211
211
|
{ "success": true, "data": <api_response> }
|
|
212
|
-
{ "success": false, "error": "message", "code": "ERROR_CODE", "status": 401, "details": { ... } }
|
|
212
|
+
{ "success": false, "error": "message", "code": "ERROR_CODE", "status": 401, "requestId": "...", "details": { ... } }
|
|
213
213
|
```
|
|
214
214
|
|
|
215
215
|
**Critical error codes:**
|
|
@@ -226,8 +226,8 @@ nansen research smart-money netflow --chain solana --fields token_symbol,net_flo
|
|
|
226
226
|
|
|
227
227
|
| Field | Meaning |
|
|
228
228
|
|-------|---------|
|
|
229
|
-
| `requestId` | Identifies this call end to end. Quote it in any support report. Opaque — do not parse it. |
|
|
230
|
-
| `credits` | `used`, `remaining` |
|
|
229
|
+
| `requestId` | Identifies this call end to end. Quote it in any support report. Opaque — do not parse it. Also hoisted to the top level of the error envelope. |
|
|
230
|
+
| `credits` | `used`, `remaining`, `cost` (the authoritative charge for this call) |
|
|
231
231
|
| `rateLimit` | `limit`, `remaining`, `resetSeconds` |
|
|
232
232
|
|
|
233
233
|
Any field may be absent or `null`, meaning unknown — never assume zero. A low-balance warning goes to **stderr**, so stdout stays pure JSON.
|
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -116,12 +116,43 @@ export class NansenError extends Error {
|
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
/**
|
|
119
|
-
*
|
|
119
|
+
* Stable snake_case codes the server sends in error bodies, mapped to the
|
|
120
|
+
* ErrorCode values downstream consumers already key on.
|
|
121
|
+
*/
|
|
122
|
+
const SERVER_CODE_MAP = {
|
|
123
|
+
rate_limit_exceeded: ErrorCode.RATE_LIMITED,
|
|
124
|
+
insufficient_credits: ErrorCode.CREDITS_EXHAUSTED,
|
|
125
|
+
payment_required: ErrorCode.PAYMENT_REQUIRED,
|
|
126
|
+
unauthorized: ErrorCode.UNAUTHORIZED,
|
|
127
|
+
forbidden: ErrorCode.FORBIDDEN,
|
|
128
|
+
not_found: ErrorCode.NOT_FOUND,
|
|
129
|
+
unsupported_filter: ErrorCode.UNSUPPORTED_FILTER,
|
|
130
|
+
validation_error: ErrorCode.INVALID_PARAMS,
|
|
131
|
+
invalid_params: ErrorCode.INVALID_PARAMS,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Map an error response to an error code.
|
|
136
|
+
*
|
|
137
|
+
* Order matters: a 402 is always PAYMENT_REQUIRED (the x402 auto-payment flow
|
|
138
|
+
* keys on it, whatever the body says); then a stable `code` field from the
|
|
139
|
+
* server body wins over prose matching — known codes map onto the ErrorCode
|
|
140
|
+
* enum, unknown ones pass through verbatim so new server codes are tolerated,
|
|
141
|
+
* never flattened; bodies without a code fall back to status + prose.
|
|
120
142
|
*/
|
|
121
143
|
export function statusToErrorCode(status, data = {}) {
|
|
144
|
+
if (status === 402) return ErrorCode.PAYMENT_REQUIRED;
|
|
145
|
+
|
|
146
|
+
const rawCode = [data?.code, data?.detail?.code]
|
|
147
|
+
.find(value => typeof value === 'string' && value.trim() !== '');
|
|
148
|
+
if (rawCode !== undefined) {
|
|
149
|
+
const serverCode = rawCode.trim();
|
|
150
|
+
return SERVER_CODE_MAP[serverCode] ?? serverCode;
|
|
151
|
+
}
|
|
152
|
+
|
|
122
153
|
const message = data?.message || data?.error || '';
|
|
123
154
|
const messageLower = message.toLowerCase();
|
|
124
|
-
|
|
155
|
+
|
|
125
156
|
switch (status) {
|
|
126
157
|
case 400:
|
|
127
158
|
case 422:
|
|
@@ -484,6 +515,8 @@ export class NansenAPI {
|
|
|
484
515
|
* low-credit warning wants.
|
|
485
516
|
*/
|
|
486
517
|
this.lastResponseMeta = null;
|
|
518
|
+
/** API path of the most recent request(), for pairing lastResponseMeta with a cost estimate. */
|
|
519
|
+
this.lastEndpoint = null;
|
|
487
520
|
}
|
|
488
521
|
|
|
489
522
|
static cleanBody(body) {
|
|
@@ -547,6 +580,7 @@ export class NansenAPI {
|
|
|
547
580
|
}
|
|
548
581
|
|
|
549
582
|
async request(endpoint, body = {}, options = {}) {
|
|
583
|
+
this.lastEndpoint = endpoint;
|
|
550
584
|
const url = `${this.baseUrl}${endpoint}`;
|
|
551
585
|
const { maxRetries, baseDelayMs, maxDelayMs, retryOnStatus } = this.retryOptions;
|
|
552
586
|
const shouldRetry = options.retry !== false; // Allow disabling retry per-request
|
package/src/cli.js
CHANGED
|
@@ -15,7 +15,7 @@ import { buildResearchCommands, RESEARCH_HISTORICAL_SUBCOMMANDS } from './comman
|
|
|
15
15
|
import { resolveAddress, isEnsName } from './ens.js';
|
|
16
16
|
import fs from 'fs';
|
|
17
17
|
import { getUpdateNotification, getUpgradeNotice, scheduleUpdateCheck } from './update-check.js';
|
|
18
|
-
import { refreshCostMapIfStale, getCostForEndpoint } from './cost-cache.js';
|
|
18
|
+
import { refreshCostMapIfStale, getCostForEndpoint, creditsCharged } from './cost-cache.js';
|
|
19
19
|
import { creditWarning, noticeWarnings } from './response-meta.js';
|
|
20
20
|
import { trackCommandSucceeded, trackCommandFailed } from './telemetry.js';
|
|
21
21
|
import { createRequire } from 'module';
|
|
@@ -390,6 +390,10 @@ export function formatError(error) {
|
|
|
390
390
|
code: error.code || 'UNKNOWN',
|
|
391
391
|
status: error.status || null,
|
|
392
392
|
};
|
|
393
|
+
// Hoisted so the id survives even if details is omitted or later pruned.
|
|
394
|
+
if (details?.requestId) {
|
|
395
|
+
result.requestId = details.requestId;
|
|
396
|
+
}
|
|
393
397
|
if (details != null && !(typeof details === 'object' && !Array.isArray(details) && Object.keys(details).length === 0)) {
|
|
394
398
|
result.details = details;
|
|
395
399
|
}
|
|
@@ -1983,6 +1987,15 @@ export async function runCLI(rawArgs, deps = {}) {
|
|
|
1983
1987
|
if (lowCredits) errorOutput(lowCredits);
|
|
1984
1988
|
for (const notice of noticeWarnings(api.lastResponseMeta)) errorOutput(notice);
|
|
1985
1989
|
|
|
1990
|
+
// What this call cost — authoritative header when the API sent one, else
|
|
1991
|
+
// the cached spec estimate. stderr only, so stdout JSON stays pure.
|
|
1992
|
+
const charged = creditsCharged(api.lastResponseMeta, api.lastEndpoint);
|
|
1993
|
+
if (charged?.source === 'header') {
|
|
1994
|
+
errorOutput(`Credits: ${charged.cost} (this call)`);
|
|
1995
|
+
} else if (charged?.source === 'estimate') {
|
|
1996
|
+
errorOutput(`Credits: ~${charged.estimate.free} free / ${charged.estimate.pro} pro (estimated)`);
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1986
1999
|
// Commands that handle their own output return undefined
|
|
1987
2000
|
if (result === undefined) {
|
|
1988
2001
|
await trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, flags: usedFlags, chain });
|
package/src/commands/agent.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import crypto from 'crypto';
|
|
7
7
|
import { NansenError, ErrorCode, statusToErrorCode, telemetryHeaders, packageVersion } from '../api.js';
|
|
8
8
|
import { getCostForEndpoint } from '../cost-cache.js';
|
|
9
|
+
import { readResponseMeta } from '../response-meta.js';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Build standard request headers, matching apiInstance.request() conventions.
|
|
@@ -25,7 +26,7 @@ function buildHeaders(apiInstance) {
|
|
|
25
26
|
* Throw a NansenError with the same structure as apiInstance.request() errors.
|
|
26
27
|
* Includes `details` field for consistency with other commands.
|
|
27
28
|
*/
|
|
28
|
-
function throwApiError(message, status, serverDetail) {
|
|
29
|
+
function throwApiError(message, status, serverDetail, errData = null, requestId = null) {
|
|
29
30
|
// Match the friendly wrapper messages from apiInstance.request()
|
|
30
31
|
let friendlyMessage = message;
|
|
31
32
|
if (status === 401) {
|
|
@@ -36,9 +37,14 @@ function throwApiError(message, status, serverDetail) {
|
|
|
36
37
|
|
|
37
38
|
throw new NansenError(
|
|
38
39
|
friendlyMessage,
|
|
39
|
-
statusToErrorCode(status),
|
|
40
|
+
statusToErrorCode(status, errData || {}),
|
|
40
41
|
status,
|
|
41
|
-
{
|
|
42
|
+
{
|
|
43
|
+
detail: serverDetail || message,
|
|
44
|
+
attempt: 1,
|
|
45
|
+
retryAfterMs: null,
|
|
46
|
+
...(requestId && { requestId }),
|
|
47
|
+
},
|
|
42
48
|
);
|
|
43
49
|
}
|
|
44
50
|
|
|
@@ -273,16 +279,20 @@ EXAMPLES:
|
|
|
273
279
|
if (!response.ok) {
|
|
274
280
|
clearTimeout(timer);
|
|
275
281
|
let serverDetail;
|
|
282
|
+
let errData = null;
|
|
276
283
|
if (response.headers.get('content-type')?.includes('application/json')) {
|
|
277
284
|
try {
|
|
278
|
-
|
|
285
|
+
errData = await response.json();
|
|
279
286
|
serverDetail = errData.detail || errData.message;
|
|
280
287
|
} catch { /* ignore parse failure */ }
|
|
281
288
|
}
|
|
289
|
+
const meta = readResponseMeta(response);
|
|
282
290
|
throwApiError(
|
|
283
291
|
serverDetail || `Agent returned ${response.status}`,
|
|
284
292
|
response.status,
|
|
285
293
|
serverDetail,
|
|
294
|
+
errData,
|
|
295
|
+
meta?.requestId,
|
|
286
296
|
);
|
|
287
297
|
}
|
|
288
298
|
|
package/src/cost-cache.js
CHANGED
|
@@ -27,6 +27,21 @@ export function getCostForEndpoint(endpoint) {
|
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* What did (or would) this call cost?
|
|
32
|
+
*
|
|
33
|
+
* Prefers the authoritative cost response header, then the spec-derived
|
|
34
|
+
* estimate for the endpoint, else null.
|
|
35
|
+
* Returns { cost, source: 'header' } or { estimate: { free, pro }, source: 'estimate' }.
|
|
36
|
+
*/
|
|
37
|
+
export function creditsCharged(meta, endpoint) {
|
|
38
|
+
const charged = meta?.credits?.cost;
|
|
39
|
+
if (charged != null) return { cost: charged, source: 'header' };
|
|
40
|
+
const estimate = endpoint ? getCostForEndpoint(endpoint) : null;
|
|
41
|
+
if (estimate != null) return { estimate, source: 'estimate' };
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
30
45
|
/**
|
|
31
46
|
* Fetches the OpenAPI spec and writes the cost map to disk if the cache is
|
|
32
47
|
* missing or older than 24h. Awaited inline — only blocks on cold/stale cache.
|
package/src/response-meta.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
/** Header names, as documented in the API reference. */
|
|
20
20
|
const CREDITS_USED = 'x-nansen-credits-used';
|
|
21
21
|
const CREDITS_REMAINING = 'x-nansen-credits-remaining';
|
|
22
|
+
const CREDITS_COST = 'x-nansen-credits-cost';
|
|
22
23
|
const RATE_LIMIT = 'x-ratelimit-limit';
|
|
23
24
|
const RATE_REMAINING = 'x-ratelimit-remaining';
|
|
24
25
|
const RATE_RESET = 'x-ratelimit-reset';
|
|
@@ -34,8 +35,8 @@ const REQUEST_ID = 'x-request-id';
|
|
|
34
35
|
function intHeader(response, name) {
|
|
35
36
|
const raw = stringHeader(response, name);
|
|
36
37
|
if (raw === null) return null;
|
|
37
|
-
const value = Number
|
|
38
|
-
return Number.
|
|
38
|
+
const value = Number(raw);
|
|
39
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
/**
|
|
@@ -57,6 +58,7 @@ function stringHeader(response, name) {
|
|
|
57
58
|
export function readResponseMeta(response) {
|
|
58
59
|
const used = intHeader(response, CREDITS_USED);
|
|
59
60
|
const remaining = intHeader(response, CREDITS_REMAINING);
|
|
61
|
+
const cost = intHeader(response, CREDITS_COST);
|
|
60
62
|
const limit = intHeader(response, RATE_LIMIT);
|
|
61
63
|
const rateRemaining = intHeader(response, RATE_REMAINING);
|
|
62
64
|
const resetSeconds = intHeader(response, RATE_RESET);
|
|
@@ -71,8 +73,10 @@ export function readResponseMeta(response) {
|
|
|
71
73
|
// never parse it or assume a format.
|
|
72
74
|
meta.requestId = requestId;
|
|
73
75
|
}
|
|
74
|
-
if (used !== null || remaining !== null) {
|
|
75
|
-
|
|
76
|
+
if (used !== null || remaining !== null || cost !== null) {
|
|
77
|
+
// cost is the server's authoritative pre-flight price for this call;
|
|
78
|
+
// used is what was actually deducted. They can disagree (e.g. free rails).
|
|
79
|
+
meta.credits = { used, remaining, cost };
|
|
76
80
|
}
|
|
77
81
|
if (limit !== null || rateRemaining !== null || resetSeconds !== null) {
|
|
78
82
|
// resetSeconds is a delta in seconds — how long the tripped window needs to
|
|
@@ -111,13 +115,15 @@ export function noticeWarnings(meta) {
|
|
|
111
115
|
export function creditWarning(meta) {
|
|
112
116
|
const credits = meta?.credits;
|
|
113
117
|
if (!credits) return null;
|
|
114
|
-
const { used, remaining } = credits;
|
|
118
|
+
const { used, remaining, cost } = credits;
|
|
115
119
|
if (remaining === null) return null;
|
|
116
120
|
if (remaining === 0) {
|
|
117
121
|
return '⚠️ Out of API credits. Top up at https://app.nansen.ai/api';
|
|
118
122
|
}
|
|
119
|
-
|
|
120
|
-
|
|
123
|
+
// The cost header is the authoritative charge; used is the fallback.
|
|
124
|
+
const charged = cost ?? used;
|
|
125
|
+
if (charged !== null && charged > 0 && remaining < charged) {
|
|
126
|
+
return `⚠️ ${remaining} API credit${remaining === 1 ? '' : 's'} left — less than this call cost (${charged}). Top up at https://app.nansen.ai/api`;
|
|
121
127
|
}
|
|
122
128
|
return null;
|
|
123
129
|
}
|