@blockrun/franklin 3.15.53 → 3.15.55

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.
@@ -288,6 +288,19 @@ function getBackoffDelay(attempt, maxDelayMs = 32_000) {
288
288
  const jitter = base * 0.25 * (Math.random() * 2 - 1); // ±25%
289
289
  return Math.max(500, Math.round(base + jitter));
290
290
  }
291
+ /**
292
+ * Format the user-facing "switching model" line. Includes the resolved
293
+ * concrete model in parentheses when the user-facing alias (e.g.
294
+ * `blockrun/auto`) differs from what was actually being called (e.g.
295
+ * `anthropic/claude-sonnet-4.6`). Verified 2026-05-04 in a live session:
296
+ * a payment fail surfaced as `*blockrun/auto failed — switching to
297
+ * nvidia/qwen3-coder-480b*` with no hint of which concrete model
298
+ * actually failed, and no hint of why. The reason label closes that gap.
299
+ */
300
+ function formatModelSwitch(alias, resolved, reason, newModel) {
301
+ const oldDisplay = alias === resolved ? alias : `${alias} (${resolved})`;
302
+ return `${oldDisplay} ${reason} — switching to ${newModel}`;
303
+ }
291
304
  /**
292
305
  * Identify models known to hallucinate tool calls (invented names, literal
293
306
  * `[TOOLCALL]` / `<tool_call>` text in answers) — they need the explicit
@@ -1013,8 +1026,9 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
1013
1026
  const oldModel = config.model;
1014
1027
  config.model = nextModel;
1015
1028
  config.onModelChange?.(nextModel, 'system');
1016
- logger.warn(`[franklin] ${oldModel} returned empty — switching to ${nextModel}`);
1017
- onEvent({ kind: 'text_delta', text: `\n*${oldModel} returned empty — switching to ${nextModel}*\n` });
1029
+ const switchLine = formatModelSwitch(oldModel, resolvedModel, 'returned empty', nextModel);
1030
+ logger.warn(`[franklin] ${switchLine}`);
1031
+ onEvent({ kind: 'text_delta', text: `\n*${switchLine}*\n` });
1018
1032
  continue;
1019
1033
  }
1020
1034
  // No fallback available OR already tried 2 models — give up, tell the user.
@@ -1177,7 +1191,11 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
1177
1191
  const oldModel = config.model;
1178
1192
  config.model = nextFree;
1179
1193
  config.onModelChange?.(nextFree, 'system');
1180
- onEvent({ kind: 'text_delta', text: `\n*${oldModel} failed — switching to ${nextFree}*\n` });
1194
+ const reason = `failed [${classified.label}]`;
1195
+ onEvent({
1196
+ kind: 'text_delta',
1197
+ text: `\n*${formatModelSwitch(oldModel, resolvedModel, reason, nextFree)}*\n`,
1198
+ });
1181
1199
  continue; // Retry with next model
1182
1200
  }
1183
1201
  }
@@ -1202,7 +1220,7 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
1202
1220
  recoveryAttempts = 0;
1203
1221
  onEvent({
1204
1222
  kind: 'text_delta',
1205
- text: `\n*${oldModel} rate-limited — switching to ${nextFree}*\n`,
1223
+ text: `\n*${formatModelSwitch(oldModel, resolvedModel, 'rate-limited', nextFree)}*\n`,
1206
1224
  });
1207
1225
  continue;
1208
1226
  }
@@ -7,7 +7,26 @@
7
7
  */
8
8
  import type { ProviderError } from '../standard-models.js';
9
9
  export declare const TICKER_TO_ID: Record<string, string>;
10
+ /** For tests + cache invalidation. */
11
+ export declare function clearIdResolutionCache(): void;
12
+ /**
13
+ * Resolve a ticker to its CoinGecko id. Synchronous — checks the static
14
+ * map and the dynamic cache. Falls through to lowercase as a final guess.
15
+ *
16
+ * Use this from `transformData` (which is sync). Use `resolveProviderIdAsync`
17
+ * from `fetchData` to populate the cache before the sync read happens.
18
+ */
10
19
  export declare function resolveProviderId(ticker: string): string;
20
+ /**
21
+ * Like `resolveProviderId`, but on a static-map miss, hits CoinGecko's
22
+ * `/search?query=` to find the canonical id. Caches the result for 7 days
23
+ * so `resolveProviderId` (sync) can read it back during `transformData`.
24
+ *
25
+ * Why not always async: `transformData` is part of the Fetcher contract
26
+ * and is intentionally sync. `fetchData` is async, runs first, and is the
27
+ * right place to do network resolution. The two share state via the cache.
28
+ */
29
+ export declare function resolveProviderIdAsync(ticker: string): Promise<string>;
11
30
  export declare function cached<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T>;
12
31
  /** For tests: wipe every cached entry. */
13
32
  export declare function clearCache(): void;
@@ -11,7 +11,15 @@ const BASE = 'https://api.coingecko.com/api/v3';
11
11
  const UA = `franklin/${VERSION} (trading)`;
12
12
  const TIMEOUT_MS = 10_000;
13
13
  // Ticker → CoinGecko slug. Not exhaustive; unknown tickers fall through to
14
- // lowercase and let CoinGecko either accept the slug or 404.
14
+ // the dynamic /search resolver below, which caches results.
15
+ //
16
+ // Verified 2026-05-04 in a live session: user asked Franklin for TON price,
17
+ // TradingMarket returned "No CoinGecko data for TON" because TON wasn't in
18
+ // this map and the lowercase fallback ("ton") doesn't match CoinGecko's
19
+ // actual id ("the-open-network"). Same hole exists for any token whose
20
+ // symbol differs from its id slug. Expanded the static map to cover the
21
+ // top ~30 currently-missing tokens, and added a /search-based resolver
22
+ // for everything else.
15
23
  export const TICKER_TO_ID = {
16
24
  BTC: 'bitcoin', ETH: 'ethereum', SOL: 'solana', BNB: 'binancecoin', XRP: 'ripple',
17
25
  ADA: 'cardano', DOGE: 'dogecoin', AVAX: 'avalanche-2', DOT: 'polkadot', MATIC: 'matic-network',
@@ -20,12 +28,87 @@ export const TICKER_TO_ID = {
20
28
  FIL: 'filecoin', AAVE: 'aave', MKR: 'maker', SNX: 'synthetix-network-token',
21
29
  COMP: 'compound-governance-token', INJ: 'injective-protocol', TIA: 'celestia',
22
30
  PEPE: 'pepe', WIF: 'dogwifcoin', RENDER: 'render-token',
31
+ // ── Added 2026-05-04 after live "No CoinGecko data for TON" report ──
32
+ TON: 'the-open-network', HYPE: 'hyperliquid', TRX: 'tron', TAO: 'bittensor',
33
+ WLD: 'worldcoin-wld', ENA: 'ethena', BERA: 'berachain-bera', JUP: 'jupiter-exchange-solana',
34
+ FET: 'fetch-ai', ONDO: 'ondo-finance', RNDR: 'render-token',
35
+ USDT: 'tether', USDC: 'usd-coin', DAI: 'dai', BCH: 'bitcoin-cash', ETC: 'ethereum-classic',
36
+ XLM: 'stellar', XMR: 'monero', IMX: 'immutable-x', GRT: 'the-graph', SAND: 'the-sandbox',
37
+ MANA: 'decentraland', AXS: 'axie-infinity', KAS: 'kaspa', ICP: 'internet-computer',
38
+ HBAR: 'hedera-hashgraph', VET: 'vechain', ALGO: 'algorand', FTM: 'fantom',
39
+ EGLD: 'elrond-erd-2', CRV: 'curve-dao-token', LDO: 'lido-dao', SHIB: 'shiba-inu',
40
+ BONK: 'bonk', POPCAT: 'popcat', FLOKI: 'floki', PNUT: 'peanut-the-squirrel',
23
41
  };
42
+ const ID_RESOLUTION_CACHE = new Map();
43
+ const ID_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
44
+ function normalizeTicker(ticker) {
45
+ return ticker.toUpperCase().replace(/-USD$/, '').replace(/USDT?$/, '');
46
+ }
47
+ /** For tests + cache invalidation. */
48
+ export function clearIdResolutionCache() {
49
+ ID_RESOLUTION_CACHE.clear();
50
+ }
51
+ /**
52
+ * Resolve a ticker to its CoinGecko id. Synchronous — checks the static
53
+ * map and the dynamic cache. Falls through to lowercase as a final guess.
54
+ *
55
+ * Use this from `transformData` (which is sync). Use `resolveProviderIdAsync`
56
+ * from `fetchData` to populate the cache before the sync read happens.
57
+ */
24
58
  export function resolveProviderId(ticker) {
25
- // Accept both "BTC" and "BTC-USD" — Pyth-style callers may pass the pair
26
- // form even when the registry routes them to CoinGecko.
27
- const normalized = ticker.toUpperCase().replace(/-USD$/, '').replace(/USDT?$/, '');
28
- return TICKER_TO_ID[normalized] ?? TICKER_TO_ID[ticker.toUpperCase()] ?? normalized.toLowerCase();
59
+ const normalized = normalizeTicker(ticker);
60
+ if (TICKER_TO_ID[normalized])
61
+ return TICKER_TO_ID[normalized];
62
+ if (TICKER_TO_ID[ticker.toUpperCase()])
63
+ return TICKER_TO_ID[ticker.toUpperCase()];
64
+ const cached = ID_RESOLUTION_CACHE.get(normalized);
65
+ if (cached && cached.expiresAt > Date.now())
66
+ return cached.id;
67
+ return normalized.toLowerCase();
68
+ }
69
+ /**
70
+ * Like `resolveProviderId`, but on a static-map miss, hits CoinGecko's
71
+ * `/search?query=` to find the canonical id. Caches the result for 7 days
72
+ * so `resolveProviderId` (sync) can read it back during `transformData`.
73
+ *
74
+ * Why not always async: `transformData` is part of the Fetcher contract
75
+ * and is intentionally sync. `fetchData` is async, runs first, and is the
76
+ * right place to do network resolution. The two share state via the cache.
77
+ */
78
+ export async function resolveProviderIdAsync(ticker) {
79
+ const normalized = normalizeTicker(ticker);
80
+ // Static map and dynamic cache — fast path.
81
+ if (TICKER_TO_ID[normalized])
82
+ return TICKER_TO_ID[normalized];
83
+ if (TICKER_TO_ID[ticker.toUpperCase()])
84
+ return TICKER_TO_ID[ticker.toUpperCase()];
85
+ const cached = ID_RESOLUTION_CACHE.get(normalized);
86
+ if (cached && cached.expiresAt > Date.now())
87
+ return cached.id;
88
+ // Network: ask CoinGecko's search endpoint.
89
+ try {
90
+ const result = await coingeckoGet(`/search?query=${encodeURIComponent(normalized)}`);
91
+ if (result && typeof result === 'object' && !('kind' in result) && 'coins' in result) {
92
+ const coins = result.coins;
93
+ if (Array.isArray(coins) && coins.length > 0) {
94
+ // Prefer an exact symbol match; fall back to the highest-ranked
95
+ // coin (lowest market_cap_rank value, ignoring null/undefined).
96
+ const exact = coins.find(c => c.symbol?.toUpperCase() === normalized && typeof c.id === 'string');
97
+ const fallback = [...coins]
98
+ .filter((c) => typeof c.id === 'string')
99
+ .sort((a, b) => (a.market_cap_rank ?? Infinity) - (b.market_cap_rank ?? Infinity))[0];
100
+ const resolved = exact?.id ?? fallback?.id;
101
+ if (resolved) {
102
+ ID_RESOLUTION_CACHE.set(normalized, { id: resolved, expiresAt: Date.now() + ID_TTL_MS });
103
+ return resolved;
104
+ }
105
+ }
106
+ }
107
+ }
108
+ catch {
109
+ // /search itself failed — fall through to the lowercase guess.
110
+ }
111
+ return normalized.toLowerCase();
29
112
  }
30
113
  const cache = new Map();
31
114
  export async function cached(key, ttlMs, fn) {
@@ -1,4 +1,4 @@
1
- import { cached, coingeckoGet, resolveProviderId, TTL } from './client.js';
1
+ import { cached, coingeckoGet, resolveProviderIdAsync, TTL } from './client.js';
2
2
  export const coingeckoOHLCVFetcher = {
3
3
  providerName: 'coingecko',
4
4
  transformQuery(input) {
@@ -9,7 +9,7 @@ export const coingeckoOHLCVFetcher = {
9
9
  return { ticker, days };
10
10
  },
11
11
  async fetchData(query) {
12
- const id = resolveProviderId(query.ticker);
12
+ const id = await resolveProviderIdAsync(query.ticker);
13
13
  return cached(`ohlcv:${id}:${query.days}`, TTL.ohlcv, async () => {
14
14
  return coingeckoGet(`/coins/${id}/market_chart?vs_currency=usd&days=${query.days}&interval=daily`);
15
15
  });
@@ -6,7 +6,7 @@
6
6
  * Coerces: the nested `{ [id]: { usd: ..., usd_24h_change: ... } }`
7
7
  * response into the standard PriceData shape.
8
8
  */
9
- import { cached, coingeckoGet, resolveProviderId, TTL } from './client.js';
9
+ import { cached, coingeckoGet, resolveProviderId, resolveProviderIdAsync, TTL } from './client.js';
10
10
  export const coingeckoPriceFetcher = {
11
11
  providerName: 'coingecko',
12
12
  transformQuery(input) {
@@ -17,7 +17,10 @@ export const coingeckoPriceFetcher = {
17
17
  return { ticker };
18
18
  },
19
19
  async fetchData(query) {
20
- const id = resolveProviderId(query.ticker);
20
+ // resolveProviderIdAsync warms the dynamic id cache via /search when the
21
+ // ticker isn't in the static map (e.g. TON → the-open-network).
22
+ // transformData below reads back from the same cache synchronously.
23
+ const id = await resolveProviderIdAsync(query.ticker);
21
24
  return cached(`price:${id}`, TTL.price, async () => {
22
25
  return coingeckoGet(`/simple/price?ids=${id}` +
23
26
  `&vs_currencies=usd&include_24hr_change=true` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blockrun/franklin",
3
- "version": "3.15.53",
3
+ "version": "3.15.55",
4
4
  "description": "Franklin — The AI agent with a wallet. Spends USDC autonomously to get real work done. Pay per action, no subscriptions.",
5
5
  "type": "module",
6
6
  "exports": {