@blockrun/franklin 3.0.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.
Files changed (138) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +256 -0
  3. package/dist/agent/commands.d.ts +27 -0
  4. package/dist/agent/commands.js +659 -0
  5. package/dist/agent/compact.d.ts +31 -0
  6. package/dist/agent/compact.js +366 -0
  7. package/dist/agent/context.d.ts +11 -0
  8. package/dist/agent/context.js +184 -0
  9. package/dist/agent/error-classifier.d.ts +10 -0
  10. package/dist/agent/error-classifier.js +61 -0
  11. package/dist/agent/llm.d.ts +63 -0
  12. package/dist/agent/llm.js +448 -0
  13. package/dist/agent/loop.d.ts +12 -0
  14. package/dist/agent/loop.js +346 -0
  15. package/dist/agent/optimize.d.ts +53 -0
  16. package/dist/agent/optimize.js +262 -0
  17. package/dist/agent/permissions.d.ts +39 -0
  18. package/dist/agent/permissions.js +226 -0
  19. package/dist/agent/reduce.d.ts +49 -0
  20. package/dist/agent/reduce.js +317 -0
  21. package/dist/agent/streaming-executor.d.ts +36 -0
  22. package/dist/agent/streaming-executor.js +149 -0
  23. package/dist/agent/tokens.d.ts +53 -0
  24. package/dist/agent/tokens.js +185 -0
  25. package/dist/agent/types.d.ts +125 -0
  26. package/dist/agent/types.js +5 -0
  27. package/dist/banner.d.ts +1 -0
  28. package/dist/banner.js +27 -0
  29. package/dist/commands/balance.d.ts +1 -0
  30. package/dist/commands/balance.js +40 -0
  31. package/dist/commands/config.d.ts +14 -0
  32. package/dist/commands/config.js +107 -0
  33. package/dist/commands/daemon.d.ts +3 -0
  34. package/dist/commands/daemon.js +117 -0
  35. package/dist/commands/history.d.ts +5 -0
  36. package/dist/commands/history.js +31 -0
  37. package/dist/commands/init.d.ts +3 -0
  38. package/dist/commands/init.js +92 -0
  39. package/dist/commands/logs.d.ts +5 -0
  40. package/dist/commands/logs.js +89 -0
  41. package/dist/commands/models.d.ts +1 -0
  42. package/dist/commands/models.js +56 -0
  43. package/dist/commands/plugin.d.ts +14 -0
  44. package/dist/commands/plugin.js +176 -0
  45. package/dist/commands/proxy.d.ts +13 -0
  46. package/dist/commands/proxy.js +106 -0
  47. package/dist/commands/setup.d.ts +1 -0
  48. package/dist/commands/setup.js +49 -0
  49. package/dist/commands/start.d.ts +8 -0
  50. package/dist/commands/start.js +292 -0
  51. package/dist/commands/stats.d.ts +10 -0
  52. package/dist/commands/stats.js +94 -0
  53. package/dist/commands/uninit.d.ts +1 -0
  54. package/dist/commands/uninit.js +63 -0
  55. package/dist/config.d.ts +9 -0
  56. package/dist/config.js +41 -0
  57. package/dist/index.d.ts +2 -0
  58. package/dist/index.js +179 -0
  59. package/dist/mcp/client.d.ts +44 -0
  60. package/dist/mcp/client.js +147 -0
  61. package/dist/mcp/config.d.ts +20 -0
  62. package/dist/mcp/config.js +138 -0
  63. package/dist/plugin-sdk/channel.d.ts +100 -0
  64. package/dist/plugin-sdk/channel.js +10 -0
  65. package/dist/plugin-sdk/index.d.ts +14 -0
  66. package/dist/plugin-sdk/index.js +9 -0
  67. package/dist/plugin-sdk/plugin.d.ts +87 -0
  68. package/dist/plugin-sdk/plugin.js +7 -0
  69. package/dist/plugin-sdk/search.d.ts +13 -0
  70. package/dist/plugin-sdk/search.js +4 -0
  71. package/dist/plugin-sdk/tracker.d.ts +27 -0
  72. package/dist/plugin-sdk/tracker.js +5 -0
  73. package/dist/plugin-sdk/workflow.d.ts +126 -0
  74. package/dist/plugin-sdk/workflow.js +11 -0
  75. package/dist/plugins/registry.d.ts +33 -0
  76. package/dist/plugins/registry.js +155 -0
  77. package/dist/plugins/runner.d.ts +21 -0
  78. package/dist/plugins/runner.js +453 -0
  79. package/dist/plugins-bundled/social/index.d.ts +10 -0
  80. package/dist/plugins-bundled/social/index.js +363 -0
  81. package/dist/plugins-bundled/social/plugin.json +14 -0
  82. package/dist/plugins-bundled/social/prompts.d.ts +19 -0
  83. package/dist/plugins-bundled/social/prompts.js +67 -0
  84. package/dist/plugins-bundled/social/types.d.ts +58 -0
  85. package/dist/plugins-bundled/social/types.js +16 -0
  86. package/dist/pricing.d.ts +21 -0
  87. package/dist/pricing.js +91 -0
  88. package/dist/proxy/fallback.d.ts +38 -0
  89. package/dist/proxy/fallback.js +144 -0
  90. package/dist/proxy/server.d.ts +18 -0
  91. package/dist/proxy/server.js +576 -0
  92. package/dist/proxy/sse-translator.d.ts +29 -0
  93. package/dist/proxy/sse-translator.js +270 -0
  94. package/dist/router/index.d.ts +22 -0
  95. package/dist/router/index.js +269 -0
  96. package/dist/session/search.d.ts +33 -0
  97. package/dist/session/search.js +229 -0
  98. package/dist/session/storage.d.ts +48 -0
  99. package/dist/session/storage.js +173 -0
  100. package/dist/stats/insights.d.ts +55 -0
  101. package/dist/stats/insights.js +195 -0
  102. package/dist/stats/tracker.d.ts +54 -0
  103. package/dist/stats/tracker.js +165 -0
  104. package/dist/tools/askuser.d.ts +6 -0
  105. package/dist/tools/askuser.js +76 -0
  106. package/dist/tools/bash.d.ts +5 -0
  107. package/dist/tools/bash.js +336 -0
  108. package/dist/tools/edit.d.ts +5 -0
  109. package/dist/tools/edit.js +148 -0
  110. package/dist/tools/glob.d.ts +5 -0
  111. package/dist/tools/glob.js +158 -0
  112. package/dist/tools/grep.d.ts +5 -0
  113. package/dist/tools/grep.js +194 -0
  114. package/dist/tools/imagegen.d.ts +6 -0
  115. package/dist/tools/imagegen.js +172 -0
  116. package/dist/tools/index.d.ts +17 -0
  117. package/dist/tools/index.js +30 -0
  118. package/dist/tools/read.d.ts +11 -0
  119. package/dist/tools/read.js +90 -0
  120. package/dist/tools/subagent.d.ts +5 -0
  121. package/dist/tools/subagent.js +116 -0
  122. package/dist/tools/task.d.ts +5 -0
  123. package/dist/tools/task.js +91 -0
  124. package/dist/tools/webfetch.d.ts +5 -0
  125. package/dist/tools/webfetch.js +166 -0
  126. package/dist/tools/websearch.d.ts +5 -0
  127. package/dist/tools/websearch.js +103 -0
  128. package/dist/tools/write.d.ts +5 -0
  129. package/dist/tools/write.js +114 -0
  130. package/dist/ui/app.d.ts +26 -0
  131. package/dist/ui/app.js +545 -0
  132. package/dist/ui/model-picker.d.ts +14 -0
  133. package/dist/ui/model-picker.js +161 -0
  134. package/dist/ui/terminal.d.ts +35 -0
  135. package/dist/ui/terminal.js +337 -0
  136. package/dist/wallet/manager.d.ts +10 -0
  137. package/dist/wallet/manager.js +23 -0
  138. package/package.json +79 -0
@@ -0,0 +1,576 @@
1
+ import http from 'node:http';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, createSolanaPaymentPayload, parsePaymentRequired, extractPaymentDetails, solanaKeyToBytes, SOLANA_NETWORK, } from '@blockrun/llm';
6
+ import { recordUsage } from '../stats/tracker.js';
7
+ import { fetchWithFallback, buildFallbackChain, DEFAULT_FALLBACK_CONFIG, } from './fallback.js';
8
+ import { routeRequest, parseRoutingProfile, } from '../router/index.js';
9
+ import { estimateCost } from '../pricing.js';
10
+ import { VERSION } from '../config.js';
11
+ // User-Agent for backend requests
12
+ const USER_AGENT = `runcode/${VERSION}`;
13
+ const X_RUNCODE_VERSION = VERSION;
14
+ const LOG_FILE = path.join(os.homedir(), '.blockrun', 'runcode-debug.log');
15
+ // Strip ANSI escape codes so log file doesn't distort terminal on replay
16
+ function stripAnsi(str) {
17
+ // eslint-disable-next-line no-control-regex
18
+ return str.replace(/\x1B\[[0-9;]*[A-Za-z]|\x1B\][^\x07]*\x07|\x1B[()][A-B]|\r/g, '');
19
+ }
20
+ function debug(options, ...args) {
21
+ if (!options.debug)
22
+ return;
23
+ const msg = `[${new Date().toISOString()}] ${stripAnsi(args.map(String).join(' '))}\n`;
24
+ try {
25
+ fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true });
26
+ fs.appendFileSync(LOG_FILE, msg);
27
+ }
28
+ catch {
29
+ /* ignore */
30
+ }
31
+ }
32
+ function log(...args) {
33
+ const msg = `[runcode] ${args.map(String).join(' ')}`;
34
+ // Do NOT print to stdout — Claude Code owns the terminal (stdio: inherit).
35
+ // Use `runcode logs` to read runtime messages.
36
+ try {
37
+ fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true });
38
+ fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${stripAnsi(msg)}\n`);
39
+ }
40
+ catch { /* ignore */ }
41
+ }
42
+ const DEFAULT_MAX_TOKENS = 4096;
43
+ // Per-model last output tokens for adaptive max_tokens (avoids cross-request pollution)
44
+ const MAX_TRACKED_MODELS = 50;
45
+ const lastOutputByModel = new Map();
46
+ function trackOutputTokens(model, tokens) {
47
+ if (lastOutputByModel.size >= MAX_TRACKED_MODELS) {
48
+ const firstKey = lastOutputByModel.keys().next().value;
49
+ if (firstKey)
50
+ lastOutputByModel.delete(firstKey);
51
+ }
52
+ lastOutputByModel.set(model, tokens);
53
+ }
54
+ // Model shortcuts for quick switching
55
+ const MODEL_SHORTCUTS = {
56
+ // Routing profiles
57
+ auto: 'blockrun/auto',
58
+ smart: 'blockrun/auto',
59
+ eco: 'blockrun/eco',
60
+ premium: 'blockrun/premium',
61
+ // Anthropic
62
+ sonnet: 'anthropic/claude-sonnet-4.6',
63
+ claude: 'anthropic/claude-sonnet-4.6',
64
+ opus: 'anthropic/claude-opus-4.6',
65
+ haiku: 'anthropic/claude-haiku-4.5',
66
+ // OpenAI
67
+ gpt: 'openai/gpt-5.4',
68
+ gpt5: 'openai/gpt-5.4',
69
+ 'gpt-5': 'openai/gpt-5.4',
70
+ 'gpt-5.4': 'openai/gpt-5.4',
71
+ 'gpt-5.4-pro': 'openai/gpt-5.4-pro',
72
+ 'gpt-5.3': 'openai/gpt-5.3',
73
+ 'gpt-5.2': 'openai/gpt-5.2',
74
+ 'gpt-5.2-pro': 'openai/gpt-5.2-pro',
75
+ 'gpt-4.1': 'openai/gpt-4.1',
76
+ codex: 'openai/gpt-5.3-codex',
77
+ nano: 'openai/gpt-5-nano',
78
+ mini: 'openai/gpt-5-mini',
79
+ o3: 'openai/o3',
80
+ o4: 'openai/o4-mini',
81
+ 'o4-mini': 'openai/o4-mini',
82
+ o1: 'openai/o1',
83
+ // Google
84
+ gemini: 'google/gemini-2.5-pro',
85
+ flash: 'google/gemini-2.5-flash',
86
+ 'gemini-3': 'google/gemini-3.1-pro',
87
+ // xAI
88
+ grok: 'xai/grok-3',
89
+ 'grok-4': 'xai/grok-4-0709',
90
+ 'grok-fast': 'xai/grok-4-1-fast-reasoning',
91
+ // DeepSeek
92
+ deepseek: 'deepseek/deepseek-chat',
93
+ r1: 'deepseek/deepseek-reasoner',
94
+ // Free models
95
+ free: 'nvidia/nemotron-ultra-253b',
96
+ nemotron: 'nvidia/nemotron-ultra-253b',
97
+ 'deepseek-free': 'nvidia/deepseek-v3.2',
98
+ devstral: 'nvidia/devstral-2-123b',
99
+ 'qwen-coder': 'nvidia/qwen3-coder-480b',
100
+ maverick: 'nvidia/llama-4-maverick',
101
+ // Minimax
102
+ minimax: 'minimax/minimax-m2.7',
103
+ // Others
104
+ glm: 'zai/glm-5.1',
105
+ kimi: 'moonshot/kimi-k2.5',
106
+ };
107
+ // Model pricing now uses shared source from src/pricing.ts
108
+ function detectModelSwitch(parsed) {
109
+ if (!parsed.messages || parsed.messages.length === 0)
110
+ return null;
111
+ const last = parsed.messages[parsed.messages.length - 1];
112
+ if (last.role !== 'user')
113
+ return null;
114
+ let content = '';
115
+ if (typeof last.content === 'string') {
116
+ content = last.content;
117
+ }
118
+ else if (Array.isArray(last.content)) {
119
+ const textBlock = last.content.find((b) => b.type === 'text' && b.text);
120
+ if (textBlock && textBlock.text)
121
+ content = textBlock.text;
122
+ }
123
+ if (!content)
124
+ return null;
125
+ content = content.trim().toLowerCase();
126
+ const match = content.match(/^use\s+(.+)$/);
127
+ if (!match)
128
+ return null;
129
+ const modelInput = match[1].trim();
130
+ // Check shortcuts first
131
+ if (MODEL_SHORTCUTS[modelInput])
132
+ return MODEL_SHORTCUTS[modelInput];
133
+ // If it contains a slash, treat as full model ID
134
+ if (modelInput.includes('/'))
135
+ return modelInput;
136
+ return null;
137
+ }
138
+ // Default model - smart routing built-in
139
+ const DEFAULT_MODEL = 'blockrun/auto';
140
+ export function createProxy(options) {
141
+ const chain = options.chain || 'base';
142
+ let currentModel = options.modelOverride || DEFAULT_MODEL;
143
+ const fallbackEnabled = options.fallbackEnabled !== false; // Default true
144
+ let baseWallet = null;
145
+ let solanaWallet = null;
146
+ if (chain === 'base') {
147
+ const w = getOrCreateWallet();
148
+ baseWallet = { privateKey: w.privateKey, address: w.address };
149
+ }
150
+ let solanaInitPromise = null;
151
+ const initSolana = () => {
152
+ if (chain !== 'solana' || solanaWallet)
153
+ return Promise.resolve();
154
+ if (!solanaInitPromise) {
155
+ solanaInitPromise = getOrCreateSolanaWallet().then((w) => {
156
+ solanaWallet = { privateKey: w.privateKey, address: w.address };
157
+ }).catch((err) => {
158
+ solanaInitPromise = null; // Allow retry on failure
159
+ throw err;
160
+ });
161
+ }
162
+ return solanaInitPromise;
163
+ };
164
+ const server = http.createServer(async (req, res) => {
165
+ if (req.method === 'OPTIONS') {
166
+ res.writeHead(200);
167
+ res.end();
168
+ return;
169
+ }
170
+ await initSolana();
171
+ const requestPath = req.url?.replace(/^\/api/, '') || '';
172
+ const targetUrl = `${options.apiUrl}${requestPath}`;
173
+ let body = '';
174
+ const requestStartTime = Date.now();
175
+ req.on('data', (chunk) => {
176
+ body += chunk;
177
+ });
178
+ req.on('end', async () => {
179
+ let requestModel = currentModel || options.modelOverride || 'unknown';
180
+ let usedFallback = false;
181
+ try {
182
+ debug(options, `request: ${req.method} ${req.url} currentModel=${currentModel || 'none'}`);
183
+ if (body) {
184
+ try {
185
+ const parsed = JSON.parse(body);
186
+ // Intercept "use <model>" commands for in-session model switching
187
+ if (parsed.messages) {
188
+ const last = parsed.messages[parsed.messages.length - 1];
189
+ debug(options, `last msg role=${last?.role} content-type=${typeof last?.content} content=${JSON.stringify(last?.content).slice(0, 200)}`);
190
+ }
191
+ const switchCmd = detectModelSwitch(parsed);
192
+ if (switchCmd) {
193
+ currentModel = switchCmd;
194
+ debug(options, `model switched to: ${currentModel}`);
195
+ const fakeResponse = {
196
+ id: `msg_runcode_${Date.now()}`,
197
+ type: 'message',
198
+ role: 'assistant',
199
+ model: currentModel,
200
+ content: [
201
+ {
202
+ type: 'text',
203
+ text: `Switched to **${currentModel}**. All subsequent requests will use this model.`,
204
+ },
205
+ ],
206
+ stop_reason: 'end_turn',
207
+ stop_sequence: null,
208
+ usage: { input_tokens: 0, output_tokens: 10 },
209
+ };
210
+ res.writeHead(200, { 'Content-Type': 'application/json' });
211
+ res.end(JSON.stringify(fakeResponse));
212
+ return;
213
+ }
214
+ // Model override logic:
215
+ // - Claude Code sends native Anthropic IDs (e.g. "claude-sonnet-4-6-20250514")
216
+ // which don't contain "/" — these MUST be replaced with currentModel.
217
+ // - BlockRun model IDs always contain "/" (e.g. "blockrun/auto", "nvidia/nemotron-ultra-253b")
218
+ // — these should be passed through as-is.
219
+ // - If --model CLI flag is set, always override regardless.
220
+ if (options.modelOverride) {
221
+ parsed.model = currentModel;
222
+ }
223
+ else if (!parsed.model || !parsed.model.includes('/')) {
224
+ parsed.model = currentModel || DEFAULT_MODEL;
225
+ }
226
+ requestModel = parsed.model || DEFAULT_MODEL;
227
+ // Smart routing: if model is a routing profile, classify and route
228
+ const routingProfile = parseRoutingProfile(requestModel);
229
+ if (routingProfile) {
230
+ // Extract user prompt for classification
231
+ const userMessages = parsed.messages?.filter((m) => m.role === 'user') || [];
232
+ const lastUserMsg = userMessages[userMessages.length - 1];
233
+ let promptText = '';
234
+ if (lastUserMsg) {
235
+ if (typeof lastUserMsg.content === 'string') {
236
+ promptText = lastUserMsg.content;
237
+ }
238
+ else if (Array.isArray(lastUserMsg.content)) {
239
+ promptText = lastUserMsg.content
240
+ .filter((b) => b.type === 'text')
241
+ .map((b) => b.text)
242
+ .join('\n');
243
+ }
244
+ }
245
+ // Route the request
246
+ const routing = routeRequest(promptText, routingProfile);
247
+ parsed.model = routing.model;
248
+ requestModel = routing.model;
249
+ log(`🧠 Smart routing: ${routingProfile} → ${routing.tier} → ${routing.model} ` +
250
+ `(${(routing.savings * 100).toFixed(0)}% savings) [${routing.signals.join(', ')}]`);
251
+ }
252
+ {
253
+ const original = parsed.max_tokens;
254
+ const model = (parsed.model || '').toLowerCase();
255
+ const modelCap = model.includes('deepseek') ||
256
+ model.includes('haiku') ||
257
+ model.includes('gpt-oss')
258
+ ? 8192
259
+ : 16384;
260
+ // Use max of (last output × 2, default 4096) capped by model limit
261
+ // This ensures short replies don't starve the next request
262
+ const lastOut = lastOutputByModel.get(requestModel) ?? 0;
263
+ const adaptive = lastOut > 0
264
+ ? Math.max(lastOut * 2, DEFAULT_MAX_TOKENS)
265
+ : DEFAULT_MAX_TOKENS;
266
+ parsed.max_tokens = Math.min(adaptive, modelCap);
267
+ if (original !== parsed.max_tokens) {
268
+ debug(options, `max_tokens: ${original || 'unset'} → ${parsed.max_tokens} (last output: ${lastOut || 'none'})`);
269
+ }
270
+ }
271
+ body = JSON.stringify(parsed);
272
+ }
273
+ catch {
274
+ /* not JSON, pass through */
275
+ }
276
+ }
277
+ const headers = {
278
+ 'Content-Type': 'application/json',
279
+ 'User-Agent': USER_AGENT,
280
+ 'X-runcode-Version': X_RUNCODE_VERSION,
281
+ };
282
+ for (const [key, value] of Object.entries(req.headers)) {
283
+ if (key.toLowerCase() !== 'host' &&
284
+ key.toLowerCase() !== 'content-length' &&
285
+ key.toLowerCase() !== 'user-agent' && // Don't forward client's user-agent
286
+ value) {
287
+ headers[key] = Array.isArray(value) ? value[0] : value;
288
+ }
289
+ }
290
+ // Build request init
291
+ const requestInit = {
292
+ method: req.method || 'POST',
293
+ headers,
294
+ body: body || undefined,
295
+ };
296
+ let response;
297
+ let finalModel = requestModel;
298
+ // Use fallback chain if enabled
299
+ if (fallbackEnabled && body && requestPath.includes('messages')) {
300
+ const fallbackConfig = {
301
+ ...DEFAULT_FALLBACK_CONFIG,
302
+ chain: buildFallbackChain(requestModel),
303
+ };
304
+ const result = await fetchWithFallback(targetUrl, requestInit, body, fallbackConfig, (failedModel, status, nextModel) => {
305
+ log(`⚠️ ${failedModel} returned ${status}, falling back to ${nextModel}`);
306
+ });
307
+ response = result.response;
308
+ finalModel = result.modelUsed;
309
+ // Use the body with the correct fallback model for payment
310
+ body = result.bodyUsed;
311
+ usedFallback = result.fallbackUsed;
312
+ if (usedFallback) {
313
+ log(`↺ Fallback successful: using ${finalModel}`);
314
+ }
315
+ }
316
+ else {
317
+ // Direct fetch without fallback (with timeout)
318
+ const directCtrl = new AbortController();
319
+ const directTimeout = setTimeout(() => directCtrl.abort(), 120_000); // 2min
320
+ response = await fetch(targetUrl, { ...requestInit, signal: directCtrl.signal });
321
+ clearTimeout(directTimeout);
322
+ }
323
+ // Handle 402 payment — body now has the correct model after fallback
324
+ if (response.status === 402) {
325
+ if (chain === 'solana' && solanaWallet) {
326
+ response = await handleSolanaPayment(response, targetUrl, req.method || 'POST', headers, body, solanaWallet.privateKey, solanaWallet.address);
327
+ }
328
+ else if (baseWallet) {
329
+ response = await handleBasePayment(response, targetUrl, req.method || 'POST', headers, body, baseWallet.privateKey, baseWallet.address);
330
+ }
331
+ }
332
+ const responseHeaders = {};
333
+ response.headers.forEach((v, k) => {
334
+ responseHeaders[k] = v;
335
+ });
336
+ // Intercept error responses and ensure Anthropic-format errors
337
+ // so Claude Code doesn't fall back to showing a login page
338
+ if (response.status >= 400 && !responseHeaders['content-type']?.includes('text/event-stream')) {
339
+ let errorBody;
340
+ try {
341
+ const rawText = await response.text();
342
+ const parsed = JSON.parse(rawText);
343
+ // Already has Anthropic error shape? Pass through
344
+ if (parsed.type === 'error' && parsed.error) {
345
+ errorBody = rawText;
346
+ }
347
+ else {
348
+ // Wrap in Anthropic error format
349
+ const errorMsg = parsed.error?.message || parsed.message || rawText.slice(0, 500);
350
+ errorBody = JSON.stringify({
351
+ type: 'error',
352
+ error: {
353
+ type: response.status === 401 ? 'authentication_error'
354
+ : response.status === 402 ? 'invalid_request_error'
355
+ : response.status === 429 ? 'rate_limit_error'
356
+ : response.status === 400 ? 'invalid_request_error'
357
+ : 'api_error',
358
+ message: `[${finalModel}] ${errorMsg}`,
359
+ },
360
+ });
361
+ }
362
+ }
363
+ catch {
364
+ errorBody = JSON.stringify({
365
+ type: 'error',
366
+ error: { type: 'api_error', message: `Backend returned ${response.status}` },
367
+ });
368
+ }
369
+ res.writeHead(response.status, { 'Content-Type': 'application/json' });
370
+ res.end(errorBody);
371
+ log(`⚠️ ${response.status} from backend for ${finalModel}`);
372
+ return;
373
+ }
374
+ res.writeHead(response.status, responseHeaders);
375
+ const isStreaming = responseHeaders['content-type']?.includes('text/event-stream');
376
+ if (response.body) {
377
+ const reader = response.body.getReader();
378
+ const decoder = new TextDecoder();
379
+ let fullResponse = '';
380
+ const STREAM_CAP = 5_000_000; // 5MB cap on accumulated stream
381
+ const STREAM_TIMEOUT_MS = 5 * 60 * 1000; // 5 min timeout for entire stream
382
+ const streamDeadline = Date.now() + STREAM_TIMEOUT_MS;
383
+ const pump = async () => {
384
+ while (true) {
385
+ if (Date.now() > streamDeadline) {
386
+ log('⚠️ Stream timeout after 5 minutes');
387
+ try {
388
+ reader.cancel();
389
+ }
390
+ catch { /* ignore */ }
391
+ break;
392
+ }
393
+ const { done, value } = await reader.read();
394
+ if (done) {
395
+ // Record stats from streaming response
396
+ if (isStreaming && fullResponse) {
397
+ // Extract token usage from SSE stream by parsing message_delta events
398
+ let outputTokens = 0;
399
+ let inputTokens = 0;
400
+ // Find all data: lines and parse JSON to extract usage
401
+ for (const line of fullResponse.split('\n')) {
402
+ if (!line.startsWith('data: '))
403
+ continue;
404
+ const json = line.slice(6).trim();
405
+ if (json === '[DONE]')
406
+ continue;
407
+ try {
408
+ const parsed = JSON.parse(json);
409
+ if (parsed.usage?.output_tokens)
410
+ outputTokens = parsed.usage.output_tokens;
411
+ if (parsed.usage?.input_tokens)
412
+ inputTokens = parsed.usage.input_tokens;
413
+ }
414
+ catch { /* skip malformed */ }
415
+ }
416
+ if (outputTokens > 0) {
417
+ trackOutputTokens(finalModel, outputTokens);
418
+ const latencyMs = Date.now() - requestStartTime;
419
+ const cost = estimateCost(finalModel, inputTokens, outputTokens);
420
+ recordUsage(finalModel, inputTokens, outputTokens, cost, latencyMs, usedFallback);
421
+ debug(options, `recorded: model=${finalModel} in=${inputTokens} out=${outputTokens} cost=$${cost.toFixed(4)} fallback=${usedFallback}`);
422
+ }
423
+ }
424
+ res.end();
425
+ break;
426
+ }
427
+ if (isStreaming && fullResponse.length < STREAM_CAP) {
428
+ const chunk = decoder.decode(value, { stream: true });
429
+ fullResponse += chunk;
430
+ }
431
+ res.write(value);
432
+ }
433
+ };
434
+ pump().catch((err) => {
435
+ log(`❌ Stream error: ${err instanceof Error ? err.message : String(err)}`);
436
+ res.end();
437
+ });
438
+ }
439
+ else {
440
+ const text = await response.text();
441
+ try {
442
+ const parsed = JSON.parse(text);
443
+ if (parsed.usage?.output_tokens) {
444
+ const outputTokens = parsed.usage.output_tokens;
445
+ trackOutputTokens(finalModel, outputTokens);
446
+ const inputTokens = parsed.usage?.input_tokens || 0;
447
+ const latencyMs = Date.now() - requestStartTime;
448
+ const cost = estimateCost(finalModel, inputTokens, outputTokens);
449
+ recordUsage(finalModel, inputTokens, outputTokens, cost, latencyMs, usedFallback);
450
+ debug(options, `recorded: model=${finalModel} in=${inputTokens} out=${outputTokens} cost=$${cost.toFixed(4)} fallback=${usedFallback}`);
451
+ }
452
+ }
453
+ catch {
454
+ /* not JSON */
455
+ }
456
+ res.end(text);
457
+ }
458
+ }
459
+ catch (error) {
460
+ const msg = error instanceof Error ? error.message : 'Proxy error';
461
+ log(`❌ Error: ${msg}`);
462
+ res.writeHead(502, { 'Content-Type': 'application/json' });
463
+ res.end(JSON.stringify({
464
+ type: 'error',
465
+ error: { type: 'api_error', message: msg },
466
+ }));
467
+ }
468
+ });
469
+ });
470
+ return server;
471
+ }
472
+ // ======================================================================
473
+ // Base (EIP-712) payment handler
474
+ // ======================================================================
475
+ async function handleBasePayment(response, url, method, headers, body, privateKey, fromAddress) {
476
+ const paymentHeader = await extractPaymentHeader(response);
477
+ if (!paymentHeader) {
478
+ throw new Error('402 Payment Required — wallet may need funding. Run: runcode balance');
479
+ }
480
+ const paymentRequired = parsePaymentRequired(paymentHeader);
481
+ const details = extractPaymentDetails(paymentRequired);
482
+ const paymentPayload = await createPaymentPayload(privateKey, fromAddress, details.recipient, details.amount, details.network || 'eip155:8453', {
483
+ resourceUrl: details.resource?.url || url,
484
+ resourceDescription: details.resource?.description || 'BlockRun AI API call',
485
+ maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
486
+ extra: details.extra,
487
+ });
488
+ return fetch(url, {
489
+ method,
490
+ headers: {
491
+ ...headers,
492
+ 'PAYMENT-SIGNATURE': paymentPayload,
493
+ },
494
+ body: body || undefined,
495
+ });
496
+ }
497
+ // ======================================================================
498
+ // Solana payment handler
499
+ // ======================================================================
500
+ async function handleSolanaPayment(response, url, method, headers, body, privateKey, fromAddress) {
501
+ const paymentHeader = await extractPaymentHeader(response);
502
+ if (!paymentHeader) {
503
+ throw new Error('402 Payment Required — wallet may need funding. Run: runcode balance');
504
+ }
505
+ const paymentRequired = parsePaymentRequired(paymentHeader);
506
+ const details = extractPaymentDetails(paymentRequired, SOLANA_NETWORK);
507
+ const secretKey = await solanaKeyToBytes(privateKey);
508
+ const feePayer = details.extra?.feePayer || details.recipient;
509
+ const paymentPayload = await createSolanaPaymentPayload(secretKey, fromAddress, details.recipient, details.amount, feePayer, {
510
+ resourceUrl: details.resource?.url || url,
511
+ resourceDescription: details.resource?.description || 'BlockRun AI API call',
512
+ maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
513
+ extra: details.extra,
514
+ });
515
+ return fetch(url, {
516
+ method,
517
+ headers: {
518
+ ...headers,
519
+ 'PAYMENT-SIGNATURE': paymentPayload,
520
+ },
521
+ body: body || undefined,
522
+ });
523
+ }
524
+ export function classifyRequest(body) {
525
+ try {
526
+ const parsed = JSON.parse(body);
527
+ const messages = parsed.messages;
528
+ if (!Array.isArray(messages) || messages.length === 0) {
529
+ return { category: 'default' };
530
+ }
531
+ const lastMessage = messages[messages.length - 1];
532
+ let content = '';
533
+ if (typeof lastMessage.content === 'string') {
534
+ content = lastMessage.content;
535
+ }
536
+ else if (Array.isArray(lastMessage.content)) {
537
+ content = lastMessage.content
538
+ .filter((b) => b.type === 'text' && b.text)
539
+ .map((b) => b.text)
540
+ .join('\n');
541
+ }
542
+ if (content.includes('```') ||
543
+ content.includes('function ') ||
544
+ content.includes('class ') ||
545
+ content.includes('import ') ||
546
+ content.includes('def ') ||
547
+ content.includes('const ')) {
548
+ return { category: 'code' };
549
+ }
550
+ if (content.length < 100) {
551
+ return { category: 'simple' };
552
+ }
553
+ return { category: 'default' };
554
+ }
555
+ catch {
556
+ return { category: 'default' };
557
+ }
558
+ }
559
+ // ======================================================================
560
+ // Shared helpers
561
+ // ======================================================================
562
+ async function extractPaymentHeader(response) {
563
+ let paymentHeader = response.headers.get('payment-required');
564
+ if (!paymentHeader) {
565
+ try {
566
+ const respBody = (await response.json());
567
+ if (respBody.x402 || respBody.accepts) {
568
+ paymentHeader = btoa(JSON.stringify(respBody));
569
+ }
570
+ }
571
+ catch {
572
+ // ignore parse errors
573
+ }
574
+ }
575
+ return paymentHeader;
576
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * SSE Event Translator: OpenAI → Anthropic Messages API format
3
+ *
4
+ * Handles three critical gaps in the streaming pipeline:
5
+ * 1. Tool calls: choice.delta.tool_calls → content_block_start/content_block_delta (tool_use)
6
+ * 2. Reasoning: reasoning_content → content_block_start/content_block_delta (thinking)
7
+ * 3. Ensures proper content_block_stop and message_stop events
8
+ */
9
+ export declare class SSETranslator {
10
+ private state;
11
+ private buffer;
12
+ constructor(model?: string);
13
+ /**
14
+ * Detect whether an SSE chunk is in OpenAI format.
15
+ * Returns true if it contains OpenAI-style `choices[].delta` structure.
16
+ */
17
+ static isOpenAIFormat(chunk: string): boolean;
18
+ /**
19
+ * Process a raw SSE text chunk and return translated Anthropic-format SSE events.
20
+ * Returns null if no translation needed (already Anthropic format or not parseable).
21
+ */
22
+ processChunk(rawChunk: string): string | null;
23
+ private parseSSEEvents;
24
+ private formatSSE;
25
+ private closeThinkingBlock;
26
+ private closeTextBlock;
27
+ private closeToolCalls;
28
+ private closeActiveBlocks;
29
+ }