ocb-cli 1.0.0 → 1.0.1

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 (3) hide show
  1. package/dist/proxy.js +97 -239
  2. package/package.json +1 -1
  3. package/src/proxy.ts +98 -247
package/dist/proxy.js CHANGED
@@ -132,11 +132,7 @@ app.get("/api/models", (req, res) => {
132
132
  grouped[model.provider] = [];
133
133
  grouped[model.provider].push(model);
134
134
  }
135
- res.json({
136
- models: availableModels,
137
- grouped,
138
- providers: Object.entries(providers).map(([id, p]) => ({ id, ...p }))
139
- });
135
+ res.json({ models: availableModels, grouped, providers: Object.entries(providers).map(([id, p]) => ({ id, ...p })) });
140
136
  });
141
137
  app.post("/api/model", (req, res) => {
142
138
  const { modelId } = req.body;
@@ -165,332 +161,194 @@ app.post("/api/reset-session", async (req, res) => {
165
161
  });
166
162
  app.get("/v1/authenticate", (req, res) => res.json({ type: "authentication", authenticated: true }));
167
163
  app.get("/v1/whoami", (req, res) => res.json({ type: "user", id: "opencode-user", email: "opencode@local" }));
168
- app.get("/v1/models", (req, res) => {
169
- res.json({ data: availableModels.map(m => ({
170
- id: m.id, type: "model", name: m.name,
171
- supports_cached_previews: true, supports_system_instructions: true
172
- })) });
173
- });
174
- app.get("/v1/models/list", (req, res) => {
175
- res.json({ data: availableModels.map(m => ({
176
- id: m.id, type: "model", name: m.name,
177
- supports_cached_previews: true, supports_system_instructions: true
178
- })) });
179
- });
164
+ app.get("/v1/models", (req, res) => res.json({ data: availableModels.map(m => ({ id: m.id, type: "model", name: m.name, supports_cached_previews: true, supports_system_instructions: true })) }));
165
+ app.get("/v1/models/list", (req, res) => res.json({ data: availableModels.map(m => ({ id: m.id, type: "model", name: m.name, supports_cached_previews: true, supports_system_instructions: true })) }));
180
166
  app.post("/v1/messages", async (req, res) => {
181
167
  try {
182
168
  if (req.body?.max_tokens === undefined && req.body?.messages) {
183
- const messages = req.body.messages;
184
169
  let totalTokens = 0;
185
- for (const msg of messages) {
186
- const content = extractTextFromContent(msg.content);
187
- totalTokens += Math.ceil(content.length / 4);
170
+ for (const msg of req.body.messages) {
171
+ totalTokens += Math.ceil(extractTextFromContent(msg.content).length / 4);
188
172
  }
189
173
  return res.json({ tokens: totalTokens });
190
174
  }
191
- const { messages, model } = req.body;
192
- if (!currentSessionId) {
175
+ if (!currentSessionId)
193
176
  currentSessionId = await createSession(process.cwd());
194
- }
195
- const { text, tokens } = await sendMessage(currentSessionId, messages);
177
+ const { text, tokens } = await sendMessage(currentSessionId, req.body.messages);
196
178
  totalRequests++;
197
179
  totalTokensUsed += tokens;
198
- res.json({
199
- id: `msg_${Date.now()}`,
200
- type: "message",
201
- role: "assistant",
202
- content: [{ type: "text", text }],
203
- model: currentModel,
204
- stop_reason: "end_turn",
205
- usage: {
206
- input_tokens: Math.ceil((JSON.stringify(messages).length) / 4),
207
- output_tokens: Math.ceil(text.length / 4)
208
- }
209
- });
180
+ res.json({ id: `msg_${Date.now()}`, type: "message", role: "assistant", content: [{ type: "text", text }], model: currentModel, stop_reason: "end_turn", usage: { input_tokens: Math.ceil(JSON.stringify(req.body.messages).length / 4), output_tokens: Math.ceil(text.length / 4) } });
210
181
  }
211
182
  catch (error) {
212
- console.error("Error:", error);
213
183
  res.status(500).json({ error: { type: "api_error", message: error instanceof Error ? error.message : String(error) } });
214
184
  }
215
185
  });
216
186
  app.post("/v1/messages/count_tokens", (req, res) => {
217
- const { messages } = req.body;
218
187
  let totalTokens = 0;
219
- for (const msg of messages || []) {
220
- const content = extractTextFromContent(msg.content);
221
- totalTokens += Math.ceil(content.length / 4);
222
- }
188
+ for (const msg of req.body.messages || [])
189
+ totalTokens += Math.ceil(extractTextFromContent(msg.content).length / 4);
223
190
  res.json({ tokens: totalTokens });
224
191
  });
192
+ const PORT = PROXY_PORT;
225
193
  function generateHTML() {
226
194
  return `<!DOCTYPE html>
227
195
  <html lang="en">
228
196
  <head>
229
197
  <meta charset="UTF-8">
230
198
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
231
- <title>OpenCode Bridge</title>
199
+ <title>OCB - OpenCode Bridge</title>
232
200
  <script src="https://cdn.tailwindcss.com"></script>
233
- <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
201
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
234
202
  <style>
235
- * { box-sizing: border-box; margin: 0; padding: 0; }
236
- body { font-family: 'Inter', -apple-system, sans-serif; background: #0a0a0f; color: #e4e4e7; min-height: 100vh; }
237
-
238
- .app-container { display: flex; height: 100vh; }
239
-
240
- /* Sidebar */
241
- .sidebar { width: 280px; background: #121218; border-right: 1px solid #27272a; display: flex; flex-direction: column; }
242
- .sidebar-header { padding: 20px; border-bottom: 1px solid #27272a; }
243
- .logo { display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 16px; }
244
- .logo-icon { width: 28px; height: 28px; background: linear-gradient(135deg, #6366f1, #8b5cf6); border-radius: 6px; display: flex; align-items: center; justify-content: center; }
245
-
246
- /* Search */
247
- .search-box { padding: 16px 20px; }
248
- .search-input { width: 100%; padding: 10px 12px; background: #1a1a20; border: 1px solid #27272a; border-radius: 8px; color: #e4e4e7; font-size: 13px; outline: none; transition: border-color 0.2s; }
249
- .search-input:focus { border-color: #6366f1; }
250
- .search-input::placeholder { color: #71717a; }
251
-
252
- /* Provider List */
253
- .provider-list { flex: 1; overflow-y: auto; padding: 8px; }
254
- .provider-item { padding: 10px 12px; border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: space-between; font-size: 13px; transition: background 0.15s; }
255
- .provider-item:hover { background: #1f1f26; }
256
- .provider-item.active { background: #6366f1/15; color: #a5b4fc; }
257
- .provider-name { display: flex; align-items: center; gap: 8px; }
258
- .provider-count { font-size: 11px; color: #71717a; background: #27272a; padding: 2px 6px; border-radius: 4px; }
259
-
260
- /* Main Content */
261
- .main-content { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
262
-
263
- /* Header */
264
- .main-header { padding: 16px 24px; border-bottom: 1px solid #27272a; display: flex; align-items: center; justify-content: space-between; background: #121218; }
265
- .header-left { display: flex; align-items: center; gap: 16px; }
266
- .current-model-badge { display: flex; align-items: center; gap: 8px; padding: 8px 14px; background: #6366f1/15; border: 1px solid #6366f1/30; border-radius: 8px; font-size: 13px; }
267
- .status-dot { width: 8px; height: 8px; background: #22c55e; border-radius: 50%; }
268
- .status-dot.inactive { background: #ef4444; }
269
-
270
- /* Model List */
271
- .model-list-container { flex: 1; overflow-y: auto; padding: 16px 24px; }
272
- .model-list-header { font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; color: #71717a; margin-bottom: 12px; padding: 0 8px; }
273
- .model-item { padding: 12px 16px; border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: space-between; transition: all 0.15s; border: 1px solid transparent; margin-bottom: 4px; }
274
- .model-item:hover { background: #1f1f26; }
275
- .model-item.selected { background: #6366f1/15; border-color: #6366f1/40; }
276
- .model-info { display: flex; flex-direction: column; gap: 2px; }
277
- .model-name { font-size: 14px; font-weight: 500; }
278
- .model-id { font-size: 11px; color: #71717a; font-family: 'JetBrains Mono', monospace; }
279
- .model-cost { font-size: 11px; color: #a1a1aa; text-align: right; }
280
- .model-cost span { display: block; }
281
-
282
- /* Footer Stats */
283
- .footer { padding: 12px 24px; border-top: 1px solid #27272a; display: flex; gap: 24px; background: #121218; }
284
- .stat-item { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #a1a1aa; }
285
- .stat-value { color: #e4e4e7; font-weight: 500; }
286
-
287
- /* Scrollbar */
203
+ * { margin: 0; padding: 0; box-sizing: border-box; }
204
+ body { font-family: 'Inter', -apple-system, sans-serif; background: #09090b; color: #fafafa; min-height: 100vh; }
288
205
  ::-webkit-scrollbar { width: 6px; }
289
206
  ::-webkit-scrollbar-track { background: transparent; }
290
207
  ::-webkit-scrollbar-thumb { background: #3f3f46; border-radius: 3px; }
291
- ::-webkit-scrollbar-thumb:hover { background: #52525b; }
292
-
293
- /* Buttons */
294
- .btn { padding: 8px 14px; border-radius: 6px; font-size: 12px; font-weight: 500; cursor: pointer; transition: all 0.15s; border: none; }
295
- .btn-primary { background: #6366f1; color: white; }
296
- .btn-primary:hover { background: #4f46e5; }
297
- .btn-secondary { background: #27272a; color: #a1a1aa; }
298
- .btn-secondary:hover { background: #3f3f46; color: #e4e4e7; }
299
208
  </style>
300
209
  </head>
301
210
  <body>
302
- <div class="app-container">
303
- <!-- Sidebar -->
304
- <div class="sidebar">
305
- <div class="sidebar-header">
306
- <div class="logo">
307
- <div class="logo-icon">⚡</div>
308
- <span>OpenCode Bridge</span>
309
- </div>
310
- </div>
311
-
312
- <div class="search-box">
313
- <input type="text" class="search-input" placeholder="Search models..." id="searchInput" oninput="filterModels()">
314
- </div>
315
-
316
- <div class="provider-list" id="providerList">
317
- <!-- Providers loaded here -->
318
- </div>
319
- </div>
320
-
321
- <!-- Main Content -->
322
- <div class="main-content">
323
- <div class="main-header">
324
- <div class="header-left">
325
- <div class="current-model-badge">
326
- <div class="status-dot" id="statusDot"></div>
327
- <span id="currentModelName">Loading...</span>
211
+ <div class="min-h-screen flex flex-col">
212
+ <header class="bg-zinc-900 border-b border-zinc-800 px-6 py-4">
213
+ <div class="max-w-7xl mx-auto flex items-center justify-between">
214
+ <div class="flex items-center gap-3">
215
+ <div class="w-10 h-10 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl flex items-center justify-center text-xl font-bold">⚡</div>
216
+ <div>
217
+ <h1 class="text-xl font-semibold text-white">OCB</h1>
218
+ <p class="text-xs text-zinc-500">OpenCode Bridge</p>
328
219
  </div>
329
220
  </div>
330
- <div style="display: flex; gap: 8px;">
331
- <button class="btn btn-secondary" onclick="resetSession()">↻ Session</button>
332
- <button class="btn btn-secondary" onclick="resetStats()">📊 Reset</button>
333
- </div>
334
- </div>
335
-
336
- <div class="model-list-container">
337
- <div class="model-list-header" id="modelListHeader">All Models</div>
338
- <div id="modelList">
339
- <!-- Models loaded here -->
221
+ <div class="flex items-center gap-3">
222
+ <div class="flex items-center gap-2 px-3 py-1.5 bg-zinc-800 rounded-lg">
223
+ <div id="statusDot" class="w-2 h-2 bg-green-500 rounded-full"></div>
224
+ <span id="currentModelDisplay" class="text-sm text-zinc-300">Loading...</span>
225
+ </div>
226
+ <button onclick="refreshModels()" class="px-3 py-1.5 text-sm bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg transition">↻</button>
227
+ <button onclick="resetSession()" class="px-3 py-1.5 text-sm bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg transition">Reset</button>
340
228
  </div>
341
229
  </div>
342
-
343
- <div class="footer">
344
- <div class="stat-item">
345
- <span>Requests:</span>
346
- <span class="stat-value" id="totalRequests">0</span>
230
+ </header>
231
+ <main class="flex-1 flex max-w-7xl mx-auto w-full">
232
+ <aside class="w-64 bg-zinc-900 border-r border-zinc-800 flex flex-col">
233
+ <div class="p-4">
234
+ <input type="text" id="searchInput" placeholder="Search models..." oninput="filterData()" class="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-indigo-500">
347
235
  </div>
348
- <div class="stat-item">
349
- <span>Tokens:</span>
350
- <span class="stat-value" id="totalTokens">0</span>
236
+ <div id="providerList" class="flex-1 overflow-y-auto px-2 pb-4"></div>
237
+ </aside>
238
+ <section class="flex-1 p-6 overflow-y-auto">
239
+ <div class="flex items-center justify-between mb-4">
240
+ <h2 id="sectionTitle" class="text-lg font-medium text-white">All Models</h2>
241
+ <span id="modelCount" class="text-sm text-zinc-500"></span>
351
242
  </div>
352
- <div class="stat-item">
353
- <span>Models:</span>
354
- <span class="stat-value" id="totalModels">0</span>
243
+ <div id="modelGrid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"></div>
244
+ </section>
245
+ </main>
246
+ <footer class="bg-zinc-900 border-t border-zinc-800 px-6 py-3">
247
+ <div class="max-w-7xl mx-auto flex items-center justify-between text-sm text-zinc-500">
248
+ <div class="flex items-center gap-6">
249
+ <span>Requests: <span id="totalRequests" class="text-white font-medium">0</span></span>
250
+ <span>Tokens: <span id="totalTokens" class="text-white font-medium">0</span></span>
355
251
  </div>
252
+ <span id="totalModelsDisplay">Loading...</span>
356
253
  </div>
357
- </div>
254
+ </footer>
358
255
  </div>
359
-
360
256
  <script>
361
- const PROXY_PORT = ${PROXY_PORT};
362
257
  let allModels = [];
363
258
  let groupedModels = {};
364
259
  let currentProvider = 'all';
365
260
  let currentModelId = null;
366
-
261
+
367
262
  async function loadModels() {
368
263
  const res = await fetch('/api/models');
369
264
  const data = await res.json();
370
-
371
265
  allModels = data.models || [];
372
266
  groupedModels = data.grouped || {};
373
-
374
267
  renderProviders();
375
268
  renderModels(allModels);
376
269
  updateStats();
377
270
  }
378
-
271
+
379
272
  function renderProviders() {
380
273
  const container = document.getElementById('providerList');
381
- const providers = Object.entries(groupedModels);
382
-
383
- let html = \`<div class="provider-item \${currentProvider === 'all' ? 'active' : ''}" onclick="selectProvider('all')">
384
- <span class="provider-name">🏠 All Models</span>
385
- <span class="provider-count">\${allModels.length}</span>
386
- </div>\`;
387
-
388
- for (const [provider, models] of providers) {
389
- html += \`<div class="provider-item \${currentProvider === provider ? 'active' : ''}" onclick="selectProvider('\${provider}')">
390
- <span class="provider-name">📦 \${provider}</span>
391
- <span class="provider-count">\${models.length}</span>
392
- </div>\`;
274
+ const searchTerm = document.getElementById('searchInput').value.toLowerCase();
275
+ let providersToShow = Object.entries(groupedModels);
276
+ if (searchTerm) {
277
+ providersToShow = providersToShow.filter(([name, models]) => name.toLowerCase().includes(searchTerm) || models.some(m => m.name.toLowerCase().includes(searchTerm)));
278
+ }
279
+ let html = '<div onclick="selectProvider('all')" class="px-3 py-2 rounded-lg cursor-pointer flex items-center justify-between text-sm mb-1 ' + (currentProvider === 'all' ? 'bg-indigo-600 text-white' : 'text-zinc-400 hover:bg-zinc-800') + '"><span>🏠 All</span><span class="text-xs opacity-60">' + allModels.length + '</span></div>';
280
+ for (const [provider, models] of providersToShow) {
281
+ html += '<div onclick="selectProvider(\\'' + provider + '\\')" class="px-3 py-2 rounded-lg cursor-pointer flex items-center justify-between text-sm mb-1 ' + (currentProvider === provider ? 'bg-indigo-600 text-white' : 'text-zinc-400 hover:bg-zinc-800') + '"><span class="truncate">' + provider + '</span><span class="text-xs opacity-60">' + models.length + '</span></div>';
393
282
  }
394
-
395
283
  container.innerHTML = html;
396
284
  }
397
-
285
+
398
286
  function selectProvider(provider) {
399
287
  currentProvider = provider;
288
+ document.getElementById('sectionTitle').textContent = provider === 'all' ? 'All Models' : provider;
400
289
  renderProviders();
401
-
402
- const models = provider === 'all' ? allModels : groupedModels[provider] || [];
403
- document.getElementById('modelListHeader').textContent = provider === 'all' ? 'All Models' : provider;
404
- renderModels(models);
290
+ renderModels(provider === 'all' ? allModels : (groupedModels[provider] || []));
405
291
  }
406
-
292
+
407
293
  function renderModels(models) {
408
- const container = document.getElementById('modelList');
294
+ const container = document.getElementById('modelGrid');
409
295
  const searchTerm = document.getElementById('searchInput').value.toLowerCase();
410
-
411
296
  let filtered = models;
412
297
  if (searchTerm) {
413
- filtered = models.filter(m =>
414
- m.name.toLowerCase().includes(searchTerm) ||
415
- m.id.toLowerCase().includes(searchTerm) ||
416
- m.provider.toLowerCase().includes(searchTerm)
417
- );
298
+ filtered = models.filter(m => m.name.toLowerCase().includes(searchTerm) || m.id.toLowerCase().includes(searchTerm));
418
299
  }
419
-
300
+ document.getElementById('modelCount').textContent = filtered.length + ' models';
420
301
  if (filtered.length === 0) {
421
- container.innerHTML = '<div style="text-align: center; color: #71717a; padding: 40px;">No models found</div>';
302
+ container.innerHTML = '<div class="col-span-full text-center text-zinc-500 py-12">No models found</div>';
422
303
  return;
423
304
  }
424
-
425
- let html = '';
426
- for (const model of filtered) {
427
- const isSelected = model.id === currentModelId;
428
- const costInfo = model.cost ? \`<span>$\${model.cost.input}/M in</span><span>$\${model.cost.output}/M out</span>\` : '';
429
-
430
- html += \`<div class="model-item \${isSelected ? 'selected' : ''}" onclick="selectModel('\${model.id}')">
431
- <div class="model-info">
432
- <div class="model-name">\${model.name}</div>
433
- <div class="model-id">\${model.id}</div>
434
- </div>
435
- <div class="model-cost">\${costInfo}</div>
436
- </div>\`;
437
- }
438
-
439
- container.innerHTML = html;
305
+ container.innerHTML = filtered.map(m => '<div onclick="selectModel(\\'' + m.id + '\\')" class="p-4 rounded-xl border cursor-pointer transition-all hover:scale-[1.02] ' + (m.id === currentModelId ? 'bg-indigo-600/20 border-indigo-500' : 'bg-zinc-900 border-zinc-800 hover:border-zinc-700') + '"><div class="flex items-start justify-between mb-2"><h3 class="font-medium text-white truncate">' + m.name + '</h3>' + (m.id === currentModelId ? '<span class="text-xs bg-indigo-500 text-white px-2 py-0.5 rounded">Active</span>' : '') + '</div><p class="text-xs text-zinc-500 font-mono truncate mb-2">' + m.id + '</p>' + (m.cost ? '<p class="text-xs text-zinc-400">$' + m.cost.input + '/M → $' + m.cost.output + '/M</p>' : '') + '</div>').join('');
440
306
  }
441
-
442
- function filterModels() {
443
- const models = currentProvider === 'all' ? allModels : groupedModels[currentProvider] || [];
444
- renderModels(models);
307
+
308
+ function filterData() {
309
+ const searchTerm = document.getElementById('searchInput').value.toLowerCase();
310
+ if (searchTerm) {
311
+ const filtered = allModels.filter(m => m.name.toLowerCase().includes(searchTerm) || m.id.toLowerCase().includes(searchTerm));
312
+ document.getElementById('sectionTitle').textContent = 'Search Results';
313
+ renderModels(filtered);
314
+ renderProviders();
315
+ } else {
316
+ selectProvider(currentProvider);
317
+ }
445
318
  }
446
-
319
+
447
320
  async function selectModel(modelId) {
448
- const res = await fetch('/api/model', {
449
- method: 'POST',
450
- headers: { 'Content-Type': 'application/json' },
451
- body: JSON.stringify({ modelId })
452
- });
321
+ const res = await fetch('/api/model', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ modelId }) });
453
322
  const data = await res.json();
454
323
  if (data.success) {
455
324
  currentModelId = modelId;
456
325
  loadStatus();
457
- const models = currentProvider === 'all' ? allModels : groupedModels[currentProvider] || [];
458
- renderModels(models);
326
+ renderModels(currentProvider === 'all' ? allModels : (groupedModels[currentProvider] || []));
459
327
  }
460
328
  }
461
-
329
+
462
330
  async function loadStatus() {
463
331
  const res = await fetch('/api/status');
464
332
  const data = await res.json();
465
-
466
333
  const model = allModels.find(m => m.id === data.currentModel);
467
- document.getElementById('currentModelName').textContent = model ? model.name : data.currentModel;
468
-
469
- const statusDot = document.getElementById('statusDot');
470
- if (data.sessionId === 'active') {
471
- statusDot.classList.remove('inactive');
472
- } else {
473
- statusDot.classList.add('inactive');
474
- }
475
-
334
+ document.getElementById('currentModelDisplay').textContent = model?.name || data.currentModel;
335
+ document.getElementById('statusDot').className = 'w-2 h-2 rounded-full ' + (data.sessionId === 'active' ? 'bg-green-500' : 'bg-red-500');
476
336
  document.getElementById('totalRequests').textContent = data.totalRequests.toLocaleString();
477
337
  document.getElementById('totalTokens').textContent = data.totalTokensUsed.toLocaleString();
478
- document.getElementById('totalModels').textContent = allModels.length.toLocaleString();
479
-
338
+ document.getElementById('totalModelsDisplay').textContent = allModels.length + ' models from ' + Object.keys(groupedModels).length + ' providers';
480
339
  currentModelId = data.currentModel;
481
340
  }
482
-
341
+
483
342
  async function resetSession() {
484
343
  await fetch('/api/reset-session', { method: 'POST' });
485
344
  loadStatus();
486
345
  }
487
-
488
- async function resetStats() {
489
- await fetch('/api/reset-stats', { method: 'POST' });
490
- loadStatus();
346
+
347
+ async function refreshModels() {
348
+ await fetch('/api/refresh-models', { method: 'POST' });
349
+ loadModels();
491
350
  }
492
-
493
- // Initialize
351
+
494
352
  loadModels();
495
353
  loadStatus();
496
354
  setInterval(loadStatus, 3000);
@@ -498,7 +356,7 @@ function generateHTML() {
498
356
  </body>
499
357
  </html>`;
500
358
  }
501
- app.listen(PROXY_PORT, () => {
502
- console.error(`OpenCode Bridge running on http://localhost:${PROXY_PORT}`);
503
- console.error(`Dashboard: http://localhost:${PROXY_PORT}`);
359
+ app.listen(PORT, () => {
360
+ console.error(`OpenCode Bridge running on http://localhost:${PORT}`);
361
+ console.error(`Dashboard: http://localhost:${PORT}`);
504
362
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ocb-cli",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "OpenCode Bridge - Use OpenCode AI models in Claude Code",
5
5
  "type": "module",
6
6
  "main": "dist/proxy.js",
package/src/proxy.ts CHANGED
@@ -160,11 +160,7 @@ app.get("/api/models", (req, res) => {
160
160
  if (!grouped[model.provider]) grouped[model.provider] = [];
161
161
  grouped[model.provider].push(model);
162
162
  }
163
- res.json({
164
- models: availableModels,
165
- grouped,
166
- providers: Object.entries(providers).map(([id, p]) => ({ id, ...p }))
167
- });
163
+ res.json({ models: availableModels, grouped, providers: Object.entries(providers).map(([id, p]) => ({ id, ...p })) });
168
164
  });
169
165
 
170
166
  app.post("/api/model", (req, res) => {
@@ -198,340 +194,195 @@ app.post("/api/reset-session", async (req, res) => {
198
194
  app.get("/v1/authenticate", (req, res) => res.json({ type: "authentication", authenticated: true }));
199
195
  app.get("/v1/whoami", (req, res) => res.json({ type: "user", id: "opencode-user", email: "opencode@local" }));
200
196
 
201
- app.get("/v1/models", (req, res) => {
202
- res.json({ data: availableModels.map(m => ({
203
- id: m.id, type: "model", name: m.name,
204
- supports_cached_previews: true, supports_system_instructions: true
205
- }))});
206
- });
207
-
208
- app.get("/v1/models/list", (req, res) => {
209
- res.json({ data: availableModels.map(m => ({
210
- id: m.id, type: "model", name: m.name,
211
- supports_cached_previews: true, supports_system_instructions: true
212
- }))});
213
- });
197
+ app.get("/v1/models", (req, res) => res.json({ data: availableModels.map(m => ({ id: m.id, type: "model", name: m.name, supports_cached_previews: true, supports_system_instructions: true })) }));
198
+ app.get("/v1/models/list", (req, res) => res.json({ data: availableModels.map(m => ({ id: m.id, type: "model", name: m.name, supports_cached_previews: true, supports_system_instructions: true })) }));
214
199
 
215
200
  app.post("/v1/messages", async (req, res) => {
216
201
  try {
217
202
  if (req.body?.max_tokens === undefined && req.body?.messages) {
218
- const messages = req.body.messages;
219
203
  let totalTokens = 0;
220
- for (const msg of messages) {
221
- const content = extractTextFromContent(msg.content);
222
- totalTokens += Math.ceil(content.length / 4);
204
+ for (const msg of req.body.messages) {
205
+ totalTokens += Math.ceil(extractTextFromContent(msg.content).length / 4);
223
206
  }
224
207
  return res.json({ tokens: totalTokens });
225
208
  }
226
-
227
- const { messages, model } = req.body;
228
-
229
- if (!currentSessionId) {
230
- currentSessionId = await createSession(process.cwd());
231
- }
232
-
233
- const { text, tokens } = await sendMessage(currentSessionId, messages);
234
-
209
+ if (!currentSessionId) currentSessionId = await createSession(process.cwd());
210
+ const { text, tokens } = await sendMessage(currentSessionId, req.body.messages);
235
211
  totalRequests++;
236
212
  totalTokensUsed += tokens;
237
-
238
- res.json({
239
- id: `msg_${Date.now()}`,
240
- type: "message",
241
- role: "assistant",
242
- content: [{ type: "text", text }],
243
- model: currentModel,
244
- stop_reason: "end_turn",
245
- usage: {
246
- input_tokens: Math.ceil((JSON.stringify(messages).length) / 4),
247
- output_tokens: Math.ceil(text.length / 4)
248
- }
249
- });
213
+ res.json({ id: `msg_${Date.now()}`, type: "message", role: "assistant", content: [{ type: "text", text }], model: currentModel, stop_reason: "end_turn", usage: { input_tokens: Math.ceil(JSON.stringify(req.body.messages).length / 4), output_tokens: Math.ceil(text.length / 4) } });
250
214
  } catch (error) {
251
- console.error("Error:", error);
252
215
  res.status(500).json({ error: { type: "api_error", message: error instanceof Error ? error.message : String(error) } });
253
216
  }
254
217
  });
255
218
 
256
219
  app.post("/v1/messages/count_tokens", (req, res) => {
257
- const { messages } = req.body;
258
220
  let totalTokens = 0;
259
- for (const msg of messages || []) {
260
- const content = extractTextFromContent(msg.content);
261
- totalTokens += Math.ceil(content.length / 4);
262
- }
221
+ for (const msg of req.body.messages || []) totalTokens += Math.ceil(extractTextFromContent(msg.content).length / 4);
263
222
  res.json({ tokens: totalTokens });
264
223
  });
265
224
 
266
- function generateHTML(): string {
225
+ const PORT = PROXY_PORT;
226
+
227
+ function generateHTML() {
267
228
  return `<!DOCTYPE html>
268
229
  <html lang="en">
269
230
  <head>
270
231
  <meta charset="UTF-8">
271
232
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
272
- <title>OpenCode Bridge</title>
233
+ <title>OCB - OpenCode Bridge</title>
273
234
  <script src="https://cdn.tailwindcss.com"></script>
274
- <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
235
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
275
236
  <style>
276
- * { box-sizing: border-box; margin: 0; padding: 0; }
277
- body { font-family: 'Inter', -apple-system, sans-serif; background: #0a0a0f; color: #e4e4e7; min-height: 100vh; }
278
-
279
- .app-container { display: flex; height: 100vh; }
280
-
281
- /* Sidebar */
282
- .sidebar { width: 280px; background: #121218; border-right: 1px solid #27272a; display: flex; flex-direction: column; }
283
- .sidebar-header { padding: 20px; border-bottom: 1px solid #27272a; }
284
- .logo { display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 16px; }
285
- .logo-icon { width: 28px; height: 28px; background: linear-gradient(135deg, #6366f1, #8b5cf6); border-radius: 6px; display: flex; align-items: center; justify-content: center; }
286
-
287
- /* Search */
288
- .search-box { padding: 16px 20px; }
289
- .search-input { width: 100%; padding: 10px 12px; background: #1a1a20; border: 1px solid #27272a; border-radius: 8px; color: #e4e4e7; font-size: 13px; outline: none; transition: border-color 0.2s; }
290
- .search-input:focus { border-color: #6366f1; }
291
- .search-input::placeholder { color: #71717a; }
292
-
293
- /* Provider List */
294
- .provider-list { flex: 1; overflow-y: auto; padding: 8px; }
295
- .provider-item { padding: 10px 12px; border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: space-between; font-size: 13px; transition: background 0.15s; }
296
- .provider-item:hover { background: #1f1f26; }
297
- .provider-item.active { background: #6366f1/15; color: #a5b4fc; }
298
- .provider-name { display: flex; align-items: center; gap: 8px; }
299
- .provider-count { font-size: 11px; color: #71717a; background: #27272a; padding: 2px 6px; border-radius: 4px; }
300
-
301
- /* Main Content */
302
- .main-content { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
303
-
304
- /* Header */
305
- .main-header { padding: 16px 24px; border-bottom: 1px solid #27272a; display: flex; align-items: center; justify-content: space-between; background: #121218; }
306
- .header-left { display: flex; align-items: center; gap: 16px; }
307
- .current-model-badge { display: flex; align-items: center; gap: 8px; padding: 8px 14px; background: #6366f1/15; border: 1px solid #6366f1/30; border-radius: 8px; font-size: 13px; }
308
- .status-dot { width: 8px; height: 8px; background: #22c55e; border-radius: 50%; }
309
- .status-dot.inactive { background: #ef4444; }
310
-
311
- /* Model List */
312
- .model-list-container { flex: 1; overflow-y: auto; padding: 16px 24px; }
313
- .model-list-header { font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; color: #71717a; margin-bottom: 12px; padding: 0 8px; }
314
- .model-item { padding: 12px 16px; border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: space-between; transition: all 0.15s; border: 1px solid transparent; margin-bottom: 4px; }
315
- .model-item:hover { background: #1f1f26; }
316
- .model-item.selected { background: #6366f1/15; border-color: #6366f1/40; }
317
- .model-info { display: flex; flex-direction: column; gap: 2px; }
318
- .model-name { font-size: 14px; font-weight: 500; }
319
- .model-id { font-size: 11px; color: #71717a; font-family: 'JetBrains Mono', monospace; }
320
- .model-cost { font-size: 11px; color: #a1a1aa; text-align: right; }
321
- .model-cost span { display: block; }
322
-
323
- /* Footer Stats */
324
- .footer { padding: 12px 24px; border-top: 1px solid #27272a; display: flex; gap: 24px; background: #121218; }
325
- .stat-item { display: flex; align-items: center; gap: 8px; font-size: 12px; color: #a1a1aa; }
326
- .stat-value { color: #e4e4e7; font-weight: 500; }
327
-
328
- /* Scrollbar */
237
+ * { margin: 0; padding: 0; box-sizing: border-box; }
238
+ body { font-family: 'Inter', -apple-system, sans-serif; background: #09090b; color: #fafafa; min-height: 100vh; }
329
239
  ::-webkit-scrollbar { width: 6px; }
330
240
  ::-webkit-scrollbar-track { background: transparent; }
331
241
  ::-webkit-scrollbar-thumb { background: #3f3f46; border-radius: 3px; }
332
- ::-webkit-scrollbar-thumb:hover { background: #52525b; }
333
-
334
- /* Buttons */
335
- .btn { padding: 8px 14px; border-radius: 6px; font-size: 12px; font-weight: 500; cursor: pointer; transition: all 0.15s; border: none; }
336
- .btn-primary { background: #6366f1; color: white; }
337
- .btn-primary:hover { background: #4f46e5; }
338
- .btn-secondary { background: #27272a; color: #a1a1aa; }
339
- .btn-secondary:hover { background: #3f3f46; color: #e4e4e7; }
340
242
  </style>
341
243
  </head>
342
244
  <body>
343
- <div class="app-container">
344
- <!-- Sidebar -->
345
- <div class="sidebar">
346
- <div class="sidebar-header">
347
- <div class="logo">
348
- <div class="logo-icon">⚡</div>
349
- <span>OpenCode Bridge</span>
350
- </div>
351
- </div>
352
-
353
- <div class="search-box">
354
- <input type="text" class="search-input" placeholder="Search models..." id="searchInput" oninput="filterModels()">
355
- </div>
356
-
357
- <div class="provider-list" id="providerList">
358
- <!-- Providers loaded here -->
359
- </div>
360
- </div>
361
-
362
- <!-- Main Content -->
363
- <div class="main-content">
364
- <div class="main-header">
365
- <div class="header-left">
366
- <div class="current-model-badge">
367
- <div class="status-dot" id="statusDot"></div>
368
- <span id="currentModelName">Loading...</span>
245
+ <div class="min-h-screen flex flex-col">
246
+ <header class="bg-zinc-900 border-b border-zinc-800 px-6 py-4">
247
+ <div class="max-w-7xl mx-auto flex items-center justify-between">
248
+ <div class="flex items-center gap-3">
249
+ <div class="w-10 h-10 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl flex items-center justify-center text-xl font-bold">⚡</div>
250
+ <div>
251
+ <h1 class="text-xl font-semibold text-white">OCB</h1>
252
+ <p class="text-xs text-zinc-500">OpenCode Bridge</p>
369
253
  </div>
370
254
  </div>
371
- <div style="display: flex; gap: 8px;">
372
- <button class="btn btn-secondary" onclick="resetSession()">↻ Session</button>
373
- <button class="btn btn-secondary" onclick="resetStats()">📊 Reset</button>
374
- </div>
375
- </div>
376
-
377
- <div class="model-list-container">
378
- <div class="model-list-header" id="modelListHeader">All Models</div>
379
- <div id="modelList">
380
- <!-- Models loaded here -->
255
+ <div class="flex items-center gap-3">
256
+ <div class="flex items-center gap-2 px-3 py-1.5 bg-zinc-800 rounded-lg">
257
+ <div id="statusDot" class="w-2 h-2 bg-green-500 rounded-full"></div>
258
+ <span id="currentModelDisplay" class="text-sm text-zinc-300">Loading...</span>
259
+ </div>
260
+ <button onclick="refreshModels()" class="px-3 py-1.5 text-sm bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg transition">↻</button>
261
+ <button onclick="resetSession()" class="px-3 py-1.5 text-sm bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg transition">Reset</button>
381
262
  </div>
382
263
  </div>
383
-
384
- <div class="footer">
385
- <div class="stat-item">
386
- <span>Requests:</span>
387
- <span class="stat-value" id="totalRequests">0</span>
264
+ </header>
265
+ <main class="flex-1 flex max-w-7xl mx-auto w-full">
266
+ <aside class="w-64 bg-zinc-900 border-r border-zinc-800 flex flex-col">
267
+ <div class="p-4">
268
+ <input type="text" id="searchInput" placeholder="Search models..." oninput="filterData()" class="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-indigo-500">
388
269
  </div>
389
- <div class="stat-item">
390
- <span>Tokens:</span>
391
- <span class="stat-value" id="totalTokens">0</span>
270
+ <div id="providerList" class="flex-1 overflow-y-auto px-2 pb-4"></div>
271
+ </aside>
272
+ <section class="flex-1 p-6 overflow-y-auto">
273
+ <div class="flex items-center justify-between mb-4">
274
+ <h2 id="sectionTitle" class="text-lg font-medium text-white">All Models</h2>
275
+ <span id="modelCount" class="text-sm text-zinc-500"></span>
392
276
  </div>
393
- <div class="stat-item">
394
- <span>Models:</span>
395
- <span class="stat-value" id="totalModels">0</span>
277
+ <div id="modelGrid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"></div>
278
+ </section>
279
+ </main>
280
+ <footer class="bg-zinc-900 border-t border-zinc-800 px-6 py-3">
281
+ <div class="max-w-7xl mx-auto flex items-center justify-between text-sm text-zinc-500">
282
+ <div class="flex items-center gap-6">
283
+ <span>Requests: <span id="totalRequests" class="text-white font-medium">0</span></span>
284
+ <span>Tokens: <span id="totalTokens" class="text-white font-medium">0</span></span>
396
285
  </div>
286
+ <span id="totalModelsDisplay">Loading...</span>
397
287
  </div>
398
- </div>
288
+ </footer>
399
289
  </div>
400
-
401
290
  <script>
402
- const PROXY_PORT = ${PROXY_PORT};
403
291
  let allModels = [];
404
292
  let groupedModels = {};
405
293
  let currentProvider = 'all';
406
294
  let currentModelId = null;
407
-
295
+
408
296
  async function loadModels() {
409
297
  const res = await fetch('/api/models');
410
298
  const data = await res.json();
411
-
412
299
  allModels = data.models || [];
413
300
  groupedModels = data.grouped || {};
414
-
415
301
  renderProviders();
416
302
  renderModels(allModels);
417
303
  updateStats();
418
304
  }
419
-
305
+
420
306
  function renderProviders() {
421
307
  const container = document.getElementById('providerList');
422
- const providers = Object.entries(groupedModels);
423
-
424
- let html = \`<div class="provider-item \${currentProvider === 'all' ? 'active' : ''}" onclick="selectProvider('all')">
425
- <span class="provider-name">🏠 All Models</span>
426
- <span class="provider-count">\${allModels.length}</span>
427
- </div>\`;
428
-
429
- for (const [provider, models] of providers) {
430
- html += \`<div class="provider-item \${currentProvider === provider ? 'active' : ''}" onclick="selectProvider('\${provider}')">
431
- <span class="provider-name">📦 \${provider}</span>
432
- <span class="provider-count">\${models.length}</span>
433
- </div>\`;
308
+ const searchTerm = document.getElementById('searchInput').value.toLowerCase();
309
+ let providersToShow = Object.entries(groupedModels);
310
+ if (searchTerm) {
311
+ providersToShow = providersToShow.filter(([name, models]) => name.toLowerCase().includes(searchTerm) || models.some(m => m.name.toLowerCase().includes(searchTerm)));
312
+ }
313
+ let html = '<div onclick="selectProvider('all')" class="px-3 py-2 rounded-lg cursor-pointer flex items-center justify-between text-sm mb-1 ' + (currentProvider === 'all' ? 'bg-indigo-600 text-white' : 'text-zinc-400 hover:bg-zinc-800') + '"><span>🏠 All</span><span class="text-xs opacity-60">' + allModels.length + '</span></div>';
314
+ for (const [provider, models] of providersToShow) {
315
+ html += '<div onclick="selectProvider(\\'' + provider + '\\')" class="px-3 py-2 rounded-lg cursor-pointer flex items-center justify-between text-sm mb-1 ' + (currentProvider === provider ? 'bg-indigo-600 text-white' : 'text-zinc-400 hover:bg-zinc-800') + '"><span class="truncate">' + provider + '</span><span class="text-xs opacity-60">' + models.length + '</span></div>';
434
316
  }
435
-
436
317
  container.innerHTML = html;
437
318
  }
438
-
319
+
439
320
  function selectProvider(provider) {
440
321
  currentProvider = provider;
322
+ document.getElementById('sectionTitle').textContent = provider === 'all' ? 'All Models' : provider;
441
323
  renderProviders();
442
-
443
- const models = provider === 'all' ? allModels : groupedModels[provider] || [];
444
- document.getElementById('modelListHeader').textContent = provider === 'all' ? 'All Models' : provider;
445
- renderModels(models);
324
+ renderModels(provider === 'all' ? allModels : (groupedModels[provider] || []));
446
325
  }
447
-
326
+
448
327
  function renderModels(models) {
449
- const container = document.getElementById('modelList');
328
+ const container = document.getElementById('modelGrid');
450
329
  const searchTerm = document.getElementById('searchInput').value.toLowerCase();
451
-
452
330
  let filtered = models;
453
331
  if (searchTerm) {
454
- filtered = models.filter(m =>
455
- m.name.toLowerCase().includes(searchTerm) ||
456
- m.id.toLowerCase().includes(searchTerm) ||
457
- m.provider.toLowerCase().includes(searchTerm)
458
- );
332
+ filtered = models.filter(m => m.name.toLowerCase().includes(searchTerm) || m.id.toLowerCase().includes(searchTerm));
459
333
  }
460
-
334
+ document.getElementById('modelCount').textContent = filtered.length + ' models';
461
335
  if (filtered.length === 0) {
462
- container.innerHTML = '<div style="text-align: center; color: #71717a; padding: 40px;">No models found</div>';
336
+ container.innerHTML = '<div class="col-span-full text-center text-zinc-500 py-12">No models found</div>';
463
337
  return;
464
338
  }
465
-
466
- let html = '';
467
- for (const model of filtered) {
468
- const isSelected = model.id === currentModelId;
469
- const costInfo = model.cost ? \`<span>$\${model.cost.input}/M in</span><span>$\${model.cost.output}/M out</span>\` : '';
470
-
471
- html += \`<div class="model-item \${isSelected ? 'selected' : ''}" onclick="selectModel('\${model.id}')">
472
- <div class="model-info">
473
- <div class="model-name">\${model.name}</div>
474
- <div class="model-id">\${model.id}</div>
475
- </div>
476
- <div class="model-cost">\${costInfo}</div>
477
- </div>\`;
478
- }
479
-
480
- container.innerHTML = html;
339
+ container.innerHTML = filtered.map(m => '<div onclick="selectModel(\\'' + m.id + '\\')" class="p-4 rounded-xl border cursor-pointer transition-all hover:scale-[1.02] ' + (m.id === currentModelId ? 'bg-indigo-600/20 border-indigo-500' : 'bg-zinc-900 border-zinc-800 hover:border-zinc-700') + '"><div class="flex items-start justify-between mb-2"><h3 class="font-medium text-white truncate">' + m.name + '</h3>' + (m.id === currentModelId ? '<span class="text-xs bg-indigo-500 text-white px-2 py-0.5 rounded">Active</span>' : '') + '</div><p class="text-xs text-zinc-500 font-mono truncate mb-2">' + m.id + '</p>' + (m.cost ? '<p class="text-xs text-zinc-400">$' + m.cost.input + '/M → $' + m.cost.output + '/M</p>' : '') + '</div>').join('');
481
340
  }
482
-
483
- function filterModels() {
484
- const models = currentProvider === 'all' ? allModels : groupedModels[currentProvider] || [];
485
- renderModels(models);
341
+
342
+ function filterData() {
343
+ const searchTerm = document.getElementById('searchInput').value.toLowerCase();
344
+ if (searchTerm) {
345
+ const filtered = allModels.filter(m => m.name.toLowerCase().includes(searchTerm) || m.id.toLowerCase().includes(searchTerm));
346
+ document.getElementById('sectionTitle').textContent = 'Search Results';
347
+ renderModels(filtered);
348
+ renderProviders();
349
+ } else {
350
+ selectProvider(currentProvider);
351
+ }
486
352
  }
487
-
353
+
488
354
  async function selectModel(modelId) {
489
- const res = await fetch('/api/model', {
490
- method: 'POST',
491
- headers: { 'Content-Type': 'application/json' },
492
- body: JSON.stringify({ modelId })
493
- });
355
+ const res = await fetch('/api/model', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ modelId }) });
494
356
  const data = await res.json();
495
357
  if (data.success) {
496
358
  currentModelId = modelId;
497
359
  loadStatus();
498
- const models = currentProvider === 'all' ? allModels : groupedModels[currentProvider] || [];
499
- renderModels(models);
360
+ renderModels(currentProvider === 'all' ? allModels : (groupedModels[currentProvider] || []));
500
361
  }
501
362
  }
502
-
363
+
503
364
  async function loadStatus() {
504
365
  const res = await fetch('/api/status');
505
366
  const data = await res.json();
506
-
507
367
  const model = allModels.find(m => m.id === data.currentModel);
508
- document.getElementById('currentModelName').textContent = model ? model.name : data.currentModel;
509
-
510
- const statusDot = document.getElementById('statusDot');
511
- if (data.sessionId === 'active') {
512
- statusDot.classList.remove('inactive');
513
- } else {
514
- statusDot.classList.add('inactive');
515
- }
516
-
368
+ document.getElementById('currentModelDisplay').textContent = model?.name || data.currentModel;
369
+ document.getElementById('statusDot').className = 'w-2 h-2 rounded-full ' + (data.sessionId === 'active' ? 'bg-green-500' : 'bg-red-500');
517
370
  document.getElementById('totalRequests').textContent = data.totalRequests.toLocaleString();
518
371
  document.getElementById('totalTokens').textContent = data.totalTokensUsed.toLocaleString();
519
- document.getElementById('totalModels').textContent = allModels.length.toLocaleString();
520
-
372
+ document.getElementById('totalModelsDisplay').textContent = allModels.length + ' models from ' + Object.keys(groupedModels).length + ' providers';
521
373
  currentModelId = data.currentModel;
522
374
  }
523
-
375
+
524
376
  async function resetSession() {
525
377
  await fetch('/api/reset-session', { method: 'POST' });
526
378
  loadStatus();
527
379
  }
528
-
529
- async function resetStats() {
530
- await fetch('/api/reset-stats', { method: 'POST' });
531
- loadStatus();
380
+
381
+ async function refreshModels() {
382
+ await fetch('/api/refresh-models', { method: 'POST' });
383
+ loadModels();
532
384
  }
533
-
534
- // Initialize
385
+
535
386
  loadModels();
536
387
  loadStatus();
537
388
  setInterval(loadStatus, 3000);
@@ -540,7 +391,7 @@ function generateHTML(): string {
540
391
  </html>`;
541
392
  }
542
393
 
543
- app.listen(PROXY_PORT, () => {
544
- console.error(`OpenCode Bridge running on http://localhost:${PROXY_PORT}`);
545
- console.error(`Dashboard: http://localhost:${PROXY_PORT}`);
394
+ app.listen(PORT, () => {
395
+ console.error(`OpenCode Bridge running on http://localhost:${PORT}`);
396
+ console.error(`Dashboard: http://localhost:${PORT}`);
546
397
  });