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 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
+ }
@@ -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.green(`
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: ' + ARBITER_URL + '/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('$' + data.total_actual_cost.toFixed(4))}`)
123
- console.log(` Would have cost: ${colors.dim('$' + data.total_baseline_cost.toFixed(4))}`)
124
- console.log(` ${colors.green('Saved: $' + data.total_savings.toFixed(4) + ' (' + data.savings_pct + '%)')}`)
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.1.3",
4
- "description": "Set up Arbiter in any project with one command",
3
+ "version": "0.2.0",
4
+ "description": "AI inference optimization smart routing saves 69% on LLM costs",
5
5
  "bin": {
6
- "arbiter-cli": "./index.js"
6
+ "arbiter": "./index.mjs",
7
+ "arbiter-cli": "./index.mjs"
7
8
  },
8
9
  "type": "module",
9
- "files": ["index.js"],
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
  }