@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.
- package/LICENSE +190 -0
- package/README.md +256 -0
- package/dist/agent/commands.d.ts +27 -0
- package/dist/agent/commands.js +659 -0
- package/dist/agent/compact.d.ts +31 -0
- package/dist/agent/compact.js +366 -0
- package/dist/agent/context.d.ts +11 -0
- package/dist/agent/context.js +184 -0
- package/dist/agent/error-classifier.d.ts +10 -0
- package/dist/agent/error-classifier.js +61 -0
- package/dist/agent/llm.d.ts +63 -0
- package/dist/agent/llm.js +448 -0
- package/dist/agent/loop.d.ts +12 -0
- package/dist/agent/loop.js +346 -0
- package/dist/agent/optimize.d.ts +53 -0
- package/dist/agent/optimize.js +262 -0
- package/dist/agent/permissions.d.ts +39 -0
- package/dist/agent/permissions.js +226 -0
- package/dist/agent/reduce.d.ts +49 -0
- package/dist/agent/reduce.js +317 -0
- package/dist/agent/streaming-executor.d.ts +36 -0
- package/dist/agent/streaming-executor.js +149 -0
- package/dist/agent/tokens.d.ts +53 -0
- package/dist/agent/tokens.js +185 -0
- package/dist/agent/types.d.ts +125 -0
- package/dist/agent/types.js +5 -0
- package/dist/banner.d.ts +1 -0
- package/dist/banner.js +27 -0
- package/dist/commands/balance.d.ts +1 -0
- package/dist/commands/balance.js +40 -0
- package/dist/commands/config.d.ts +14 -0
- package/dist/commands/config.js +107 -0
- package/dist/commands/daemon.d.ts +3 -0
- package/dist/commands/daemon.js +117 -0
- package/dist/commands/history.d.ts +5 -0
- package/dist/commands/history.js +31 -0
- package/dist/commands/init.d.ts +3 -0
- package/dist/commands/init.js +92 -0
- package/dist/commands/logs.d.ts +5 -0
- package/dist/commands/logs.js +89 -0
- package/dist/commands/models.d.ts +1 -0
- package/dist/commands/models.js +56 -0
- package/dist/commands/plugin.d.ts +14 -0
- package/dist/commands/plugin.js +176 -0
- package/dist/commands/proxy.d.ts +13 -0
- package/dist/commands/proxy.js +106 -0
- package/dist/commands/setup.d.ts +1 -0
- package/dist/commands/setup.js +49 -0
- package/dist/commands/start.d.ts +8 -0
- package/dist/commands/start.js +292 -0
- package/dist/commands/stats.d.ts +10 -0
- package/dist/commands/stats.js +94 -0
- package/dist/commands/uninit.d.ts +1 -0
- package/dist/commands/uninit.js +63 -0
- package/dist/config.d.ts +9 -0
- package/dist/config.js +41 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +179 -0
- package/dist/mcp/client.d.ts +44 -0
- package/dist/mcp/client.js +147 -0
- package/dist/mcp/config.d.ts +20 -0
- package/dist/mcp/config.js +138 -0
- package/dist/plugin-sdk/channel.d.ts +100 -0
- package/dist/plugin-sdk/channel.js +10 -0
- package/dist/plugin-sdk/index.d.ts +14 -0
- package/dist/plugin-sdk/index.js +9 -0
- package/dist/plugin-sdk/plugin.d.ts +87 -0
- package/dist/plugin-sdk/plugin.js +7 -0
- package/dist/plugin-sdk/search.d.ts +13 -0
- package/dist/plugin-sdk/search.js +4 -0
- package/dist/plugin-sdk/tracker.d.ts +27 -0
- package/dist/plugin-sdk/tracker.js +5 -0
- package/dist/plugin-sdk/workflow.d.ts +126 -0
- package/dist/plugin-sdk/workflow.js +11 -0
- package/dist/plugins/registry.d.ts +33 -0
- package/dist/plugins/registry.js +155 -0
- package/dist/plugins/runner.d.ts +21 -0
- package/dist/plugins/runner.js +453 -0
- package/dist/plugins-bundled/social/index.d.ts +10 -0
- package/dist/plugins-bundled/social/index.js +363 -0
- package/dist/plugins-bundled/social/plugin.json +14 -0
- package/dist/plugins-bundled/social/prompts.d.ts +19 -0
- package/dist/plugins-bundled/social/prompts.js +67 -0
- package/dist/plugins-bundled/social/types.d.ts +58 -0
- package/dist/plugins-bundled/social/types.js +16 -0
- package/dist/pricing.d.ts +21 -0
- package/dist/pricing.js +91 -0
- package/dist/proxy/fallback.d.ts +38 -0
- package/dist/proxy/fallback.js +144 -0
- package/dist/proxy/server.d.ts +18 -0
- package/dist/proxy/server.js +576 -0
- package/dist/proxy/sse-translator.d.ts +29 -0
- package/dist/proxy/sse-translator.js +270 -0
- package/dist/router/index.d.ts +22 -0
- package/dist/router/index.js +269 -0
- package/dist/session/search.d.ts +33 -0
- package/dist/session/search.js +229 -0
- package/dist/session/storage.d.ts +48 -0
- package/dist/session/storage.js +173 -0
- package/dist/stats/insights.d.ts +55 -0
- package/dist/stats/insights.js +195 -0
- package/dist/stats/tracker.d.ts +54 -0
- package/dist/stats/tracker.js +165 -0
- package/dist/tools/askuser.d.ts +6 -0
- package/dist/tools/askuser.js +76 -0
- package/dist/tools/bash.d.ts +5 -0
- package/dist/tools/bash.js +336 -0
- package/dist/tools/edit.d.ts +5 -0
- package/dist/tools/edit.js +148 -0
- package/dist/tools/glob.d.ts +5 -0
- package/dist/tools/glob.js +158 -0
- package/dist/tools/grep.d.ts +5 -0
- package/dist/tools/grep.js +194 -0
- package/dist/tools/imagegen.d.ts +6 -0
- package/dist/tools/imagegen.js +172 -0
- package/dist/tools/index.d.ts +17 -0
- package/dist/tools/index.js +30 -0
- package/dist/tools/read.d.ts +11 -0
- package/dist/tools/read.js +90 -0
- package/dist/tools/subagent.d.ts +5 -0
- package/dist/tools/subagent.js +116 -0
- package/dist/tools/task.d.ts +5 -0
- package/dist/tools/task.js +91 -0
- package/dist/tools/webfetch.d.ts +5 -0
- package/dist/tools/webfetch.js +166 -0
- package/dist/tools/websearch.d.ts +5 -0
- package/dist/tools/websearch.js +103 -0
- package/dist/tools/write.d.ts +5 -0
- package/dist/tools/write.js +114 -0
- package/dist/ui/app.d.ts +26 -0
- package/dist/ui/app.js +545 -0
- package/dist/ui/model-picker.d.ts +14 -0
- package/dist/ui/model-picker.js +161 -0
- package/dist/ui/terminal.d.ts +35 -0
- package/dist/ui/terminal.js +337 -0
- package/dist/wallet/manager.d.ts +10 -0
- package/dist/wallet/manager.js +23 -0
- 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
|
+
}
|