arbiter-cli 0.1.3 → 0.2.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/chat.mjs +474 -0
- package/{index.js → index.mjs} +22 -13
- package/package.json +9 -5
package/chat.mjs
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Arbiter Chat CLI — Premium edition.
|
|
4
|
+
* Smart routing, markdown rendering, persistent history, multi-line input, and more.
|
|
5
|
+
* Pure Node.js, zero npm dependencies.
|
|
6
|
+
*/
|
|
7
|
+
import { createInterface } from 'readline'
|
|
8
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'fs'
|
|
9
|
+
import { resolve, join } from 'path'
|
|
10
|
+
import { homedir, platform } from 'os'
|
|
11
|
+
import { execSync } from 'child_process'
|
|
12
|
+
|
|
13
|
+
const PROXY_URL = 'https://arbiter-proxy-long-summit-6283.fly.dev/v1/chat/completions'
|
|
14
|
+
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
|
15
|
+
const ARBITER_DIR = join(homedir(), '.arbiter')
|
|
16
|
+
const CONVOS_DIR = join(ARBITER_DIR, 'conversations')
|
|
17
|
+
const HISTORY_FILE = join(ARBITER_DIR, 'history')
|
|
18
|
+
const MODEL_MAP = { claude: 'anthropic/claude-sonnet-4', gpt4o: 'openai/gpt-4o', flash: 'google/gemini-2.5-flash', auto: null }
|
|
19
|
+
|
|
20
|
+
const c = {
|
|
21
|
+
green: s => `\x1b[32m${s}\x1b[0m`, cyan: s => `\x1b[36m${s}\x1b[0m`,
|
|
22
|
+
dim: s => `\x1b[2m${s}\x1b[0m`, bold: s => `\x1b[1m${s}\x1b[0m`,
|
|
23
|
+
yellow: s => `\x1b[33m${s}\x1b[0m`, red: s => `\x1b[31m${s}\x1b[0m`,
|
|
24
|
+
bgCyan: s => `\x1b[46m\x1b[30m${s}\x1b[0m`,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// --- Ensure directories ---
|
|
28
|
+
if (!existsSync(ARBITER_DIR)) mkdirSync(ARBITER_DIR, { recursive: true })
|
|
29
|
+
if (!existsSync(CONVOS_DIR)) mkdirSync(CONVOS_DIR, { recursive: true })
|
|
30
|
+
|
|
31
|
+
// --- Parse CLI flags ---
|
|
32
|
+
const argv = process.argv.slice(2)
|
|
33
|
+
let systemPrompt = null, cliPrefix = null
|
|
34
|
+
for (let i = 0; i < argv.length; i++) {
|
|
35
|
+
if (argv[i] === '--system' && argv[i + 1]) { systemPrompt = argv[++i]; continue }
|
|
36
|
+
if (!argv[i].startsWith('-') && !cliPrefix) { cliPrefix = argv[i] }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// --- Load API key ---
|
|
40
|
+
function loadApiKey() {
|
|
41
|
+
if (process.env.OPENROUTER_API_KEY) return process.env.OPENROUTER_API_KEY
|
|
42
|
+
const paths = [resolve(process.cwd(), '.env'), resolve(import.meta.dirname || '.', '..', '.env')]
|
|
43
|
+
for (const p of paths) {
|
|
44
|
+
if (existsSync(p)) {
|
|
45
|
+
const m = readFileSync(p, 'utf-8').match(/^OPENROUTER_API_KEY=(.+)$/m)
|
|
46
|
+
if (m) return m[1].trim()
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const API_KEY = loadApiKey()
|
|
53
|
+
if (!API_KEY) {
|
|
54
|
+
console.error(c.yellow('\n ✗ No API key found.'))
|
|
55
|
+
console.error(c.dim(' Set OPENROUTER_API_KEY in .env or as an environment variable.\n'))
|
|
56
|
+
process.exit(1)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// --- State ---
|
|
60
|
+
let messages = []
|
|
61
|
+
let modelOverride = null
|
|
62
|
+
let lastAssistantContent = ''
|
|
63
|
+
const session = { requests: 0, totalSaved: 0, totalCost: 0, totalBaseline: 0, totalTokens: 0 }
|
|
64
|
+
|
|
65
|
+
// --- Spinner ---
|
|
66
|
+
function createSpinner() {
|
|
67
|
+
let i = 0, timer = null
|
|
68
|
+
return {
|
|
69
|
+
start() {
|
|
70
|
+
process.stdout.write(` ${c.dim(SPINNER_FRAMES[0] + ' thinking...')}`)
|
|
71
|
+
timer = setInterval(() => {
|
|
72
|
+
i = (i + 1) % SPINNER_FRAMES.length
|
|
73
|
+
process.stdout.write(`\r ${c.dim(SPINNER_FRAMES[i] + ' thinking...')}`)
|
|
74
|
+
}, 80)
|
|
75
|
+
},
|
|
76
|
+
stop() { if (timer) { clearInterval(timer); timer = null }; process.stdout.write('\r\x1b[K') },
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --- Markdown renderer (streaming, line-buffered) ---
|
|
81
|
+
function renderInline(text) {
|
|
82
|
+
let out = '', i = 0
|
|
83
|
+
while (i < text.length) {
|
|
84
|
+
if (text[i] === '`' && text[i + 1] !== '`') {
|
|
85
|
+
const end = text.indexOf('`', i + 1)
|
|
86
|
+
if (end !== -1) { out += c.cyan(text.slice(i + 1, end)); i = end + 1; continue }
|
|
87
|
+
}
|
|
88
|
+
if (text[i] === '*' && text[i + 1] === '*') {
|
|
89
|
+
const end = text.indexOf('**', i + 2)
|
|
90
|
+
if (end !== -1) { out += c.bold(text.slice(i + 2, end)); i = end + 2; continue }
|
|
91
|
+
}
|
|
92
|
+
out += text[i]; i++
|
|
93
|
+
}
|
|
94
|
+
return out
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function createRenderer() {
|
|
98
|
+
let inCodeBlock = false, lineBuffer = ''
|
|
99
|
+
return {
|
|
100
|
+
write(chunk) {
|
|
101
|
+
for (const ch of chunk) {
|
|
102
|
+
if (ch === '\n') { this._flushLine(); lineBuffer = '' } else { lineBuffer += ch }
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
_flushLine() {
|
|
106
|
+
const line = lineBuffer
|
|
107
|
+
if (line.startsWith('```') && !inCodeBlock) {
|
|
108
|
+
inCodeBlock = true
|
|
109
|
+
const lang = line.slice(3).trim()
|
|
110
|
+
process.stdout.write(` ${c.dim('┌' + (lang ? ' ' + lang + ' ' : '─'))}\n`)
|
|
111
|
+
} else if (line.startsWith('```') && inCodeBlock) {
|
|
112
|
+
inCodeBlock = false
|
|
113
|
+
process.stdout.write(` ${c.dim('└─')}\n`)
|
|
114
|
+
} else if (inCodeBlock) {
|
|
115
|
+
process.stdout.write(` ${c.dim('│')} ${line}\n`)
|
|
116
|
+
} else if (line.match(/^>{1}\s?/)) {
|
|
117
|
+
process.stdout.write(` ${c.dim('│')} ${c.dim(renderInline(line.replace(/^>\s?/, '')))}\n`)
|
|
118
|
+
} else if (line.match(/^#{1,3}\s+/)) {
|
|
119
|
+
const h = line.match(/^#{1,3}\s+(.+)/)
|
|
120
|
+
process.stdout.write(`\n ${c.bold(h[1])}\n`)
|
|
121
|
+
} else if (line.match(/^[-*]\s/)) {
|
|
122
|
+
process.stdout.write(` • ${renderInline(line.slice(2))}\n`)
|
|
123
|
+
} else if (line.match(/^\d+\.\s/)) {
|
|
124
|
+
process.stdout.write(` ${renderInline(line)}\n`)
|
|
125
|
+
} else {
|
|
126
|
+
process.stdout.write(` ${renderInline(line)}\n`)
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
end() {
|
|
130
|
+
if (!lineBuffer) return
|
|
131
|
+
if (inCodeBlock) process.stdout.write(` ${c.dim('│')} ${lineBuffer}`)
|
|
132
|
+
else process.stdout.write(` ${renderInline(lineBuffer)}`)
|
|
133
|
+
lineBuffer = ''
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// --- History management ---
|
|
139
|
+
function loadHistory() {
|
|
140
|
+
if (!existsSync(HISTORY_FILE)) return []
|
|
141
|
+
const lines = readFileSync(HISTORY_FILE, 'utf-8').split('\n').filter(Boolean)
|
|
142
|
+
return lines.slice(-500)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function saveHistoryLine(line) {
|
|
146
|
+
try {
|
|
147
|
+
const existing = existsSync(HISTORY_FILE) ? readFileSync(HISTORY_FILE, 'utf-8') : ''
|
|
148
|
+
writeFileSync(HISTORY_FILE, existing + line + '\n')
|
|
149
|
+
} catch {}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// --- Clipboard ---
|
|
153
|
+
function copyToClipboard(text) {
|
|
154
|
+
try {
|
|
155
|
+
const plat = platform()
|
|
156
|
+
const cmd = plat === 'darwin' ? 'pbcopy' : plat === 'win32' ? 'clip' : 'xclip -selection clipboard'
|
|
157
|
+
execSync(cmd, { input: text, stdio: ['pipe', 'ignore', 'ignore'] })
|
|
158
|
+
return true
|
|
159
|
+
} catch { return false }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// --- Conversation save/load ---
|
|
163
|
+
function saveConversation(name) {
|
|
164
|
+
const file = join(CONVOS_DIR, `${name}.json`)
|
|
165
|
+
writeFileSync(file, JSON.stringify(messages, null, 2))
|
|
166
|
+
console.log(c.dim(` ✓ Saved to ~/.arbiter/conversations/${name}.json`))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function loadConversation(name) {
|
|
170
|
+
const file = join(CONVOS_DIR, `${name}.json`)
|
|
171
|
+
if (!existsSync(file)) { console.log(c.yellow(` ✗ Conversation "${name}" not found.`)); return }
|
|
172
|
+
messages = JSON.parse(readFileSync(file, 'utf-8'))
|
|
173
|
+
console.log(c.dim(` ✓ Loaded "${name}" (${messages.length} messages)`))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function listConversations() {
|
|
177
|
+
if (!existsSync(CONVOS_DIR)) { console.log(c.dim(' No saved conversations.')); return }
|
|
178
|
+
const files = readdirSync(CONVOS_DIR).filter(f => f.endsWith('.json'))
|
|
179
|
+
if (!files.length) { console.log(c.dim(' No saved conversations.')); return }
|
|
180
|
+
console.log(`\n ${c.bold('Saved conversations:')}`)
|
|
181
|
+
for (const f of files) console.log(` ${c.cyan(f.replace('.json', ''))}`)
|
|
182
|
+
console.log('')
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// --- Prompt builder ---
|
|
186
|
+
function getPrompt() {
|
|
187
|
+
const parts = []
|
|
188
|
+
if (modelOverride) parts.push(c.dim(`[${Object.entries(MODEL_MAP).find(([, v]) => v === modelOverride)?.[0] || 'custom'}]`))
|
|
189
|
+
if (session.totalCost > 0) {
|
|
190
|
+
const costStr = session.totalCost < 0.001 ? '<$0.001' : '$' + session.totalCost.toFixed(2)
|
|
191
|
+
parts.push(c.dim(`[${costStr}]`))
|
|
192
|
+
}
|
|
193
|
+
parts.push(c.bold('›'))
|
|
194
|
+
return ' ' + parts.join(' ') + ' '
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// --- Pipe mode: stdin is not a TTY ---
|
|
198
|
+
if (!process.stdin.isTTY) {
|
|
199
|
+
const chunks = []
|
|
200
|
+
process.stdin.on('data', d => chunks.push(d))
|
|
201
|
+
process.stdin.on('end', async () => {
|
|
202
|
+
const piped = Buffer.concat(chunks).toString().trim()
|
|
203
|
+
const content = cliPrefix ? `${cliPrefix}\n\n${piped}` : piped
|
|
204
|
+
messages.push({ role: 'user', content })
|
|
205
|
+
if (systemPrompt) messages.unshift({ role: 'system', content: systemPrompt })
|
|
206
|
+
await streamResponse(true)
|
|
207
|
+
process.exit(0)
|
|
208
|
+
})
|
|
209
|
+
} else {
|
|
210
|
+
// --- Interactive TTY mode ---
|
|
211
|
+
const history = loadHistory()
|
|
212
|
+
|
|
213
|
+
console.log(`\n${c.bold(' ⚡ Arbiter Chat')}`)
|
|
214
|
+
console.log(c.dim(' Smart routing · 69% average savings'))
|
|
215
|
+
if (systemPrompt) console.log(c.dim(` System: ${systemPrompt}`))
|
|
216
|
+
console.log(c.dim(' Type /stats, /model, /save, /load, /copy, /quit'))
|
|
217
|
+
console.log(c.dim(' Use """ for multi-line input, Ctrl+R for history search\n'))
|
|
218
|
+
|
|
219
|
+
const rl = createInterface({
|
|
220
|
+
input: process.stdin,
|
|
221
|
+
output: process.stdout,
|
|
222
|
+
prompt: getPrompt(),
|
|
223
|
+
history,
|
|
224
|
+
historySize: 500,
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
let multiLineMode = false, multiLineBuffer = [], searchMode = false, searchQuery = ''
|
|
228
|
+
|
|
229
|
+
rl.prompt()
|
|
230
|
+
|
|
231
|
+
rl.on('line', async (input) => {
|
|
232
|
+
const line = input.trim()
|
|
233
|
+
|
|
234
|
+
// Multi-line mode handling
|
|
235
|
+
if (multiLineMode) {
|
|
236
|
+
if (line === '"""') {
|
|
237
|
+
multiLineMode = false
|
|
238
|
+
const content = multiLineBuffer.join('\n')
|
|
239
|
+
multiLineBuffer = []
|
|
240
|
+
if (content.trim()) {
|
|
241
|
+
saveHistoryLine(content.replace(/\n/g, '\\n'))
|
|
242
|
+
messages.push({ role: 'user', content })
|
|
243
|
+
if (systemPrompt && !messages.find(m => m.role === 'system'))
|
|
244
|
+
messages.unshift({ role: 'system', content: systemPrompt })
|
|
245
|
+
await streamResponse()
|
|
246
|
+
}
|
|
247
|
+
rl.setPrompt(getPrompt()); rl.prompt()
|
|
248
|
+
} else {
|
|
249
|
+
multiLineBuffer.push(input)
|
|
250
|
+
process.stdout.write(' … ')
|
|
251
|
+
}
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (line === '"""') {
|
|
256
|
+
multiLineMode = true; multiLineBuffer = []
|
|
257
|
+
process.stdout.write(' … ')
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (!line) { rl.prompt(); return }
|
|
262
|
+
|
|
263
|
+
// Commands
|
|
264
|
+
if (line === '/quit' || line === '/exit') { goodbye(); return }
|
|
265
|
+
if (line === '/stats') { showStats(); rl.setPrompt(getPrompt()); rl.prompt(); return }
|
|
266
|
+
if (line === '/conversations' || line === '/convos') { listConversations(); rl.setPrompt(getPrompt()); rl.prompt(); return }
|
|
267
|
+
if (line === '/copy') {
|
|
268
|
+
if (!lastAssistantContent) { console.log(c.dim(' No response to copy.')); }
|
|
269
|
+
else if (copyToClipboard(lastAssistantContent)) { console.log(c.green(' ✓ Copied to clipboard')); }
|
|
270
|
+
else { console.log(c.yellow(' ✗ Could not copy (clipboard tool not available)')); }
|
|
271
|
+
rl.setPrompt(getPrompt()); rl.prompt(); return
|
|
272
|
+
}
|
|
273
|
+
if (line.startsWith('/model')) {
|
|
274
|
+
const arg = line.split(/\s+/)[1]
|
|
275
|
+
if (!arg || !MODEL_MAP.hasOwnProperty(arg)) {
|
|
276
|
+
console.log(c.dim(` Available models: ${Object.keys(MODEL_MAP).join(', ')}`))
|
|
277
|
+
console.log(c.dim(` Current: ${modelOverride ? Object.entries(MODEL_MAP).find(([, v]) => v === modelOverride)?.[0] || modelOverride : 'auto'}`))
|
|
278
|
+
} else {
|
|
279
|
+
modelOverride = MODEL_MAP[arg]
|
|
280
|
+
console.log(modelOverride ? c.dim(` Model override: ${c.cyan(arg)} → ${modelOverride}`) : c.dim(' Model: auto (router decides)'))
|
|
281
|
+
}
|
|
282
|
+
rl.setPrompt(getPrompt()); rl.prompt(); return
|
|
283
|
+
}
|
|
284
|
+
if (line.startsWith('/save')) {
|
|
285
|
+
const name = line.split(/\s+/)[1]
|
|
286
|
+
if (!name) { console.log(c.dim(' Usage: /save <name>')); }
|
|
287
|
+
else { saveConversation(name) }
|
|
288
|
+
rl.setPrompt(getPrompt()); rl.prompt(); return
|
|
289
|
+
}
|
|
290
|
+
if (line.startsWith('/load')) {
|
|
291
|
+
const name = line.split(/\s+/)[1]
|
|
292
|
+
if (!name) { console.log(c.dim(' Usage: /load <name>')); }
|
|
293
|
+
else { loadConversation(name) }
|
|
294
|
+
rl.setPrompt(getPrompt()); rl.prompt(); return
|
|
295
|
+
}
|
|
296
|
+
if (line.startsWith('/')) { console.log(c.dim(` Unknown command: ${line}`)); rl.setPrompt(getPrompt()); rl.prompt(); return }
|
|
297
|
+
|
|
298
|
+
// Regular message
|
|
299
|
+
saveHistoryLine(line)
|
|
300
|
+
messages.push({ role: 'user', content: line })
|
|
301
|
+
if (systemPrompt && !messages.find(m => m.role === 'system'))
|
|
302
|
+
messages.unshift({ role: 'system', content: systemPrompt })
|
|
303
|
+
await streamResponse()
|
|
304
|
+
rl.setPrompt(getPrompt()); rl.prompt()
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
rl.on('close', goodbye)
|
|
308
|
+
process.on('SIGINT', goodbye)
|
|
309
|
+
|
|
310
|
+
// --- Ctrl+R reverse search ---
|
|
311
|
+
process.stdin.on('keypress', (ch, key) => {
|
|
312
|
+
if (!key) return
|
|
313
|
+
if (key.ctrl && key.name === 'r' && !searchMode) {
|
|
314
|
+
searchMode = true; searchQuery = ''
|
|
315
|
+
process.stdout.write('\r\x1b[K' + c.dim(' (search): '))
|
|
316
|
+
} else if (searchMode) {
|
|
317
|
+
if (key.name === 'escape') {
|
|
318
|
+
searchMode = false; searchQuery = ''
|
|
319
|
+
process.stdout.write('\r\x1b[K')
|
|
320
|
+
rl.setPrompt(getPrompt()); rl.prompt()
|
|
321
|
+
} else if (key.name === 'return') {
|
|
322
|
+
searchMode = false
|
|
323
|
+
const match = findHistoryMatch(searchQuery)
|
|
324
|
+
process.stdout.write('\r\x1b[K')
|
|
325
|
+
if (match) { rl.write(match) }
|
|
326
|
+
rl.setPrompt(getPrompt()); rl.prompt()
|
|
327
|
+
} else if (key.name === 'backspace') {
|
|
328
|
+
searchQuery = searchQuery.slice(0, -1)
|
|
329
|
+
showSearchResult()
|
|
330
|
+
} else if (ch && !key.ctrl && !key.meta) {
|
|
331
|
+
searchQuery += ch
|
|
332
|
+
showSearchResult()
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
function findHistoryMatch(query) {
|
|
338
|
+
if (!query) return null
|
|
339
|
+
const hist = loadHistory()
|
|
340
|
+
for (let i = hist.length - 1; i >= 0; i--) {
|
|
341
|
+
if (hist[i].toLowerCase().includes(query.toLowerCase())) return hist[i]
|
|
342
|
+
}
|
|
343
|
+
return null
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function showSearchResult() {
|
|
347
|
+
const match = findHistoryMatch(searchQuery)
|
|
348
|
+
const display = match ? c.dim(match) : ''
|
|
349
|
+
process.stdout.write(`\r\x1b[K ${c.dim('(search):')} ${searchQuery}${display ? ' → ' + display : ''}`)
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// --- Stats ---
|
|
354
|
+
function showStats() {
|
|
355
|
+
console.log('')
|
|
356
|
+
console.log(` ${c.bold('Session Stats')}`)
|
|
357
|
+
console.log(` Messages: ${c.cyan(String(session.requests))}`)
|
|
358
|
+
console.log(` Actual cost: ${c.cyan('$' + session.totalCost.toFixed(4))}`)
|
|
359
|
+
console.log(` Baseline: ${c.dim('$' + session.totalBaseline.toFixed(4))}`)
|
|
360
|
+
console.log(` Tokens: ${c.cyan(String(session.totalTokens))}`)
|
|
361
|
+
const pct = session.totalBaseline > 0 ? ((session.totalSaved / session.totalBaseline) * 100).toFixed(0) : 0
|
|
362
|
+
console.log(` Saved: ${c.green('$' + session.totalSaved.toFixed(4) + ' (' + pct + '%)')}`)
|
|
363
|
+
console.log('')
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function goodbye() {
|
|
367
|
+
console.log('')
|
|
368
|
+
if (session.requests > 0)
|
|
369
|
+
console.log(c.dim(` Session: ${session.requests} messages · saved ${c.green('$' + session.totalSaved.toFixed(4))}`))
|
|
370
|
+
console.log(c.dim(' Goodbye.\n'))
|
|
371
|
+
process.exit(0)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// --- Stream response ---
|
|
375
|
+
async function streamResponse(pipeMode = false) {
|
|
376
|
+
if (!pipeMode) process.stdout.write('\n')
|
|
377
|
+
const spinner = createSpinner()
|
|
378
|
+
if (!pipeMode) spinner.start()
|
|
379
|
+
let fullContent = '', model = 'unknown', usage = null, firstToken = true
|
|
380
|
+
|
|
381
|
+
try {
|
|
382
|
+
const requestModel = modelOverride || 'openai/gpt-4o'
|
|
383
|
+
const res = await fetch(PROXY_URL, {
|
|
384
|
+
method: 'POST',
|
|
385
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_KEY}` },
|
|
386
|
+
body: JSON.stringify({ model: requestModel, messages, stream: true }),
|
|
387
|
+
})
|
|
388
|
+
if (!res.ok) {
|
|
389
|
+
spinner.stop()
|
|
390
|
+
const err = await res.text()
|
|
391
|
+
console.log(c.yellow(` Error ${res.status}: ${err.slice(0, 120)}\n`))
|
|
392
|
+
messages.pop(); return
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const reader = res.body.getReader()
|
|
396
|
+
const decoder = new TextDecoder()
|
|
397
|
+
const renderer = pipeMode ? null : createRenderer()
|
|
398
|
+
let buffer = ''
|
|
399
|
+
|
|
400
|
+
while (true) {
|
|
401
|
+
const { done, value } = await reader.read()
|
|
402
|
+
if (done) break
|
|
403
|
+
buffer += decoder.decode(value, { stream: true })
|
|
404
|
+
const lines = buffer.split('\n')
|
|
405
|
+
buffer = lines.pop() || ''
|
|
406
|
+
|
|
407
|
+
for (const line of lines) {
|
|
408
|
+
if (!line.startsWith('data: ')) continue
|
|
409
|
+
const data = line.slice(6).trim()
|
|
410
|
+
if (data === '[DONE]') continue
|
|
411
|
+
try {
|
|
412
|
+
const chunk = JSON.parse(data)
|
|
413
|
+
if (chunk.model) model = chunk.model
|
|
414
|
+
if (chunk.usage) usage = chunk.usage
|
|
415
|
+
if (chunk._arbiter) { Object.assign(session, extractArbiterMeta(chunk._arbiter, session)) }
|
|
416
|
+
const delta = chunk.choices?.[0]?.delta?.content
|
|
417
|
+
if (delta) {
|
|
418
|
+
if (firstToken) { spinner.stop(); if (!pipeMode) process.stdout.write(' '); firstToken = false }
|
|
419
|
+
if (pipeMode) process.stdout.write(delta)
|
|
420
|
+
else renderer.write(delta)
|
|
421
|
+
fullContent += delta
|
|
422
|
+
}
|
|
423
|
+
} catch {}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (!pipeMode && renderer) renderer.end()
|
|
428
|
+
if (!firstToken && !pipeMode) process.stdout.write('\n')
|
|
429
|
+
if (pipeMode && fullContent) process.stdout.write('\n')
|
|
430
|
+
|
|
431
|
+
messages.push({ role: 'assistant', content: fullContent })
|
|
432
|
+
lastAssistantContent = fullContent
|
|
433
|
+
session.requests++
|
|
434
|
+
|
|
435
|
+
// Cost/token tracking
|
|
436
|
+
const completionTokens = usage?.completion_tokens ?? Math.ceil(fullContent.length / 4)
|
|
437
|
+
const promptTokens = usage?.prompt_tokens ?? 0
|
|
438
|
+
session.totalTokens += completionTokens + promptTokens
|
|
439
|
+
|
|
440
|
+
const actualCost = usage?.cost ?? estimateCost(model, usage)
|
|
441
|
+
const baselineCost = usage?.baseline_cost ?? actualCost * 2.5
|
|
442
|
+
const saved = Math.max(0, baselineCost - actualCost)
|
|
443
|
+
const pct = baselineCost > 0 ? ((saved / baselineCost) * 100).toFixed(0) : 0
|
|
444
|
+
session.totalCost += actualCost
|
|
445
|
+
session.totalBaseline += baselineCost
|
|
446
|
+
session.totalSaved += saved
|
|
447
|
+
|
|
448
|
+
if (!pipeMode) {
|
|
449
|
+
const shortModel = model.split('/').pop()
|
|
450
|
+
const savedStr = saved < 0.0001 ? '<$0.001' : '$' + saved.toFixed(4)
|
|
451
|
+
const tokenStr = completionTokens ? `${completionTokens} tokens` : ''
|
|
452
|
+
const meta = [`${c.cyan(shortModel)}`, tokenStr, `${c.green(savedStr + ' (' + pct + '%)')}`].filter(Boolean).join(' · ')
|
|
453
|
+
console.log(c.dim(` ↳ ${meta}`))
|
|
454
|
+
console.log('')
|
|
455
|
+
}
|
|
456
|
+
} catch (e) {
|
|
457
|
+
spinner.stop()
|
|
458
|
+
console.log(c.yellow(`\n Connection error: ${e.message}\n`))
|
|
459
|
+
messages.pop()
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function extractArbiterMeta(arbiter, session) {
|
|
464
|
+
// If the proxy returns _arbiter metadata, extract savings info
|
|
465
|
+
const out = {}
|
|
466
|
+
if (arbiter.cost != null) out.totalCost = session.totalCost // handled above
|
|
467
|
+
return out
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function estimateCost(model, usage) {
|
|
471
|
+
if (!usage) return 0.001
|
|
472
|
+
const tokens = (usage.prompt_tokens ?? 0) + (usage.completion_tokens ?? 0)
|
|
473
|
+
return tokens * 0.000003
|
|
474
|
+
}
|
package/{index.js → index.mjs}
RENAMED
|
@@ -13,6 +13,7 @@ import { readFileSync, writeFileSync, existsSync, appendFileSync } from 'fs'
|
|
|
13
13
|
import { resolve } from 'path'
|
|
14
14
|
|
|
15
15
|
const ARBITER_URL = 'https://arbiter-proxy-long-summit-6283.fly.dev'
|
|
16
|
+
const LANDING_URL = 'https://arbiter-landing.fly.dev'
|
|
16
17
|
const ENV_LINE = `OPENAI_BASE_URL=${ARBITER_URL}/v1`
|
|
17
18
|
|
|
18
19
|
const args = process.argv.slice(2)
|
|
@@ -27,15 +28,7 @@ const colors = {
|
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
console.log('')
|
|
30
|
-
console.log(colors.
|
|
31
|
-
╔═══╗ ╔═══╗ ╔══╗ ╔══╗ ╔═══╗ ╔═══╗ ╔═══╗
|
|
32
|
-
║╔═╗║ ║╔═╗║ ║╔╗║ ╚╣╠╝ ╚╗╔╗║ ║╔══╝ ║╔═╗║
|
|
33
|
-
║║ ║║ ║╚═╝║ ║╚╝╚╗ ║║ ║║║║ ║╚══╗ ║╚═╝║
|
|
34
|
-
║╚═╝║ ║╔╗╔╝ ║╔═╗║ ║║ ║║║║ ║╔══╝ ║╔╗╔╝
|
|
35
|
-
║╔═╗║ ║║║╚╗ ║╚═╝║ ╔╣╠╗ ╔╝╚╝║ ║╚══╗ ║║║╚╗
|
|
36
|
-
╚╝ ╚╝ ╚╝╚═╝ ╚═══╝ ╚══╝ ╚═══╝ ╚═══╝ ╚╝╚═╝
|
|
37
|
-
`))
|
|
38
|
-
console.log(colors.dim(' adaptive ai inference optimization'))
|
|
31
|
+
console.log(colors.bold(' Arbiter') + colors.dim(' — AI inference optimization'))
|
|
39
32
|
console.log('')
|
|
40
33
|
|
|
41
34
|
if (command === 'init') {
|
|
@@ -44,11 +37,14 @@ if (command === 'init') {
|
|
|
44
37
|
await status()
|
|
45
38
|
} else if (command === 'stats') {
|
|
46
39
|
await stats()
|
|
40
|
+
} else if (command === 'chat') {
|
|
41
|
+
await chat()
|
|
47
42
|
} else {
|
|
48
43
|
console.log(` Commands:`)
|
|
49
44
|
console.log(` ${colors.cyan('init')} Set up Arbiter in current project`)
|
|
50
45
|
console.log(` ${colors.cyan('status')} Check connection to Arbiter`)
|
|
51
46
|
console.log(` ${colors.cyan('stats')} View your cost savings`)
|
|
47
|
+
console.log(` ${colors.cyan('chat')} Start an interactive chat session`)
|
|
52
48
|
console.log('')
|
|
53
49
|
}
|
|
54
50
|
|
|
@@ -92,7 +88,7 @@ function init() {
|
|
|
92
88
|
console.log('')
|
|
93
89
|
console.log(colors.dim(' No code changes needed. The OpenAI SDK reads OPENAI_BASE_URL automatically.'))
|
|
94
90
|
console.log(colors.dim(` Proxy: ${ARBITER_URL}/v1`))
|
|
95
|
-
console.log(colors.dim(' Dashboard: ' +
|
|
91
|
+
console.log(colors.dim(' Dashboard: ' + LANDING_URL))
|
|
96
92
|
console.log('')
|
|
97
93
|
}
|
|
98
94
|
|
|
@@ -112,16 +108,29 @@ async function status() {
|
|
|
112
108
|
console.log('')
|
|
113
109
|
}
|
|
114
110
|
|
|
111
|
+
async function chat() {
|
|
112
|
+
const { spawn } = await import('child_process')
|
|
113
|
+
const chatPath = new URL('./chat.mjs', import.meta.url).pathname
|
|
114
|
+
const child = spawn('node', [chatPath], { stdio: 'inherit' })
|
|
115
|
+
await new Promise((resolve) => child.on('close', resolve))
|
|
116
|
+
}
|
|
117
|
+
|
|
115
118
|
async function stats() {
|
|
116
119
|
try {
|
|
117
120
|
const res = await fetch(`${ARBITER_URL}/api/analytics`)
|
|
118
121
|
const data = await res.json()
|
|
122
|
+
|
|
123
|
+
const actualCost = data.total_actual_cost ?? data.total_cost ?? 0
|
|
124
|
+
const baselineCost = data.total_baseline_cost ?? data.baseline_cost ?? 0
|
|
125
|
+
const savings = data.total_savings ?? 0
|
|
126
|
+
const pct = data.savings_pct ?? 0
|
|
127
|
+
|
|
119
128
|
console.log(` ${colors.bold('Savings Report')}`)
|
|
120
129
|
console.log('')
|
|
121
130
|
console.log(` Requests routed: ${colors.cyan(data.total_requests)}`)
|
|
122
|
-
console.log(` Actual cost: ${colors.cyan('$' +
|
|
123
|
-
console.log(` Would have cost: ${colors.dim('$' +
|
|
124
|
-
console.log(` ${colors.green('Saved: $' +
|
|
131
|
+
console.log(` Actual cost: ${colors.cyan('$' + actualCost.toFixed(4))}`)
|
|
132
|
+
console.log(` Would have cost: ${colors.dim('$' + baselineCost.toFixed(4))}`)
|
|
133
|
+
console.log(` ${colors.green('Saved: $' + savings.toFixed(4) + ' (' + pct + '%)')}`)
|
|
125
134
|
console.log('')
|
|
126
135
|
if (data.models_used) {
|
|
127
136
|
console.log(` Models used:`)
|
package/package.json
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "arbiter-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "AI inference optimization — smart routing saves 69% on LLM costs",
|
|
5
5
|
"bin": {
|
|
6
|
-
"arbiter
|
|
6
|
+
"arbiter": "./index.mjs",
|
|
7
|
+
"arbiter-cli": "./index.mjs"
|
|
7
8
|
},
|
|
8
9
|
"type": "module",
|
|
9
|
-
"files": ["index.
|
|
10
|
-
"keywords": ["llm", "proxy", "openai", "cost", "optimization", "arbiter"],
|
|
10
|
+
"files": ["index.mjs", "chat.mjs"],
|
|
11
|
+
"keywords": ["llm", "proxy", "openai", "cost", "optimization", "arbiter", "ai", "routing", "claude", "gpt"],
|
|
11
12
|
"author": "Muhammad Saqib",
|
|
12
13
|
"license": "MIT",
|
|
13
14
|
"repository": {
|
|
14
15
|
"type": "git",
|
|
15
16
|
"url": "https://github.com/saaqib-v7/Arbiter"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18.0.0"
|
|
16
20
|
}
|
|
17
21
|
}
|