nansen-cli 1.36.0 → 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 CHANGED
@@ -1,5 +1,17 @@
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
+
9
+ ## 1.36.1
10
+
11
+ ### Patch Changes
12
+
13
+ - [#476](https://github.com/nansen-ai/nansen-cli/pull/476) [`f480a49`](https://github.com/nansen-ai/nansen-cli/commit/f480a492a2ea142894a49f7a6e551d38e8aa4312) Thanks [@kome12](https://github.com/kome12)! - Add troubleshooting guidance for stale global installs.
14
+
3
15
  ## 1.36.0
4
16
 
5
17
  ### Minor 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.
@@ -237,6 +237,7 @@ Any field may be absent or `null`, meaning unknown — never assume zero. A low-
237
237
  | Symptom | Fix |
238
238
  |---------|-----|
239
239
  | `command not found` | `npm install -g nansen-cli` |
240
+ | Global install reports an older version | `npm i -g nansen-cli@latest --registry=https://registry.npmjs.org/ --prefer-online`, then check `which -a nansen` for stale binaries |
240
241
  | `UNAUTHORIZED` after login | `cat ~/.nansen/config.json` or set `NANSEN_API_KEY` |
241
242
  | Empty perp _research_ results | Use `--symbol BTC`, not `--token`. Perps are Hyperliquid-only. |
242
243
  | `perp` _trading_ prints the usage banner | Trading needs `--coin BTC` (`--symbol` also works); see the Perpetuals section. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nansen-cli",
3
- "version": "1.36.0",
3
+ "version": "1.36.2",
4
4
  "description": "AI-agent CLI for Nansen API analytics, DEX swaps, and cross-chain trading",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/api.js CHANGED
@@ -116,12 +116,43 @@ export class NansenError extends Error {
116
116
  }
117
117
 
118
118
  /**
119
- * Map HTTP status codes to error codes
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 });
@@ -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
- { detail: serverDetail || message, attempt: 1, retryAfterMs: null },
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
- const errData = await response.json();
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.
@@ -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.parseInt(raw, 10);
38
- return Number.isInteger(value) && value >= 0 ? value : null;
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
- meta.credits = { used, remaining };
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
- if (used !== null && used > 0 && remaining < used) {
120
- return `⚠️ ${remaining} API credit${remaining === 1 ? '' : 's'} left — less than this call cost (${used}). Top up at https://app.nansen.ai/api`;
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
  }