ocb-cli 1.0.0 → 1.0.2
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/dist/proxy.js +236 -232
- package/package.json +1 -1
- package/src/proxy.ts +238 -240
package/dist/proxy.js
CHANGED
|
@@ -115,6 +115,9 @@ app.use((req, res, next) => {
|
|
|
115
115
|
next();
|
|
116
116
|
});
|
|
117
117
|
app.get("/", (req, res) => res.send(generateHTML()));
|
|
118
|
+
app.get("/health", (req, res) => {
|
|
119
|
+
res.json({ status: "ok", timestamp: Date.now(), models: availableModels.length, providers: Object.keys(providers).length });
|
|
120
|
+
});
|
|
118
121
|
app.get("/api/status", (req, res) => {
|
|
119
122
|
res.json({
|
|
120
123
|
proxyPort: PROXY_PORT,
|
|
@@ -132,11 +135,7 @@ app.get("/api/models", (req, res) => {
|
|
|
132
135
|
grouped[model.provider] = [];
|
|
133
136
|
grouped[model.provider].push(model);
|
|
134
137
|
}
|
|
135
|
-
res.json({
|
|
136
|
-
models: availableModels,
|
|
137
|
-
grouped,
|
|
138
|
-
providers: Object.entries(providers).map(([id, p]) => ({ id, ...p }))
|
|
139
|
-
});
|
|
138
|
+
res.json({ models: availableModels, grouped, providers: Object.entries(providers).map(([id, p]) => ({ id, ...p })) });
|
|
140
139
|
});
|
|
141
140
|
app.post("/api/model", (req, res) => {
|
|
142
141
|
const { modelId } = req.body;
|
|
@@ -165,332 +164,337 @@ app.post("/api/reset-session", async (req, res) => {
|
|
|
165
164
|
});
|
|
166
165
|
app.get("/v1/authenticate", (req, res) => res.json({ type: "authentication", authenticated: true }));
|
|
167
166
|
app.get("/v1/whoami", (req, res) => res.json({ type: "user", id: "opencode-user", email: "opencode@local" }));
|
|
168
|
-
app.get("/v1/models", (req, res) => {
|
|
169
|
-
|
|
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
|
-
});
|
|
167
|
+
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 })) }));
|
|
168
|
+
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
169
|
app.post("/v1/messages", async (req, res) => {
|
|
181
170
|
try {
|
|
182
171
|
if (req.body?.max_tokens === undefined && req.body?.messages) {
|
|
183
|
-
const messages = req.body.messages;
|
|
184
172
|
let totalTokens = 0;
|
|
185
|
-
for (const msg of messages) {
|
|
186
|
-
|
|
187
|
-
totalTokens += Math.ceil(content.length / 4);
|
|
173
|
+
for (const msg of req.body.messages) {
|
|
174
|
+
totalTokens += Math.ceil(extractTextFromContent(msg.content).length / 4);
|
|
188
175
|
}
|
|
189
176
|
return res.json({ tokens: totalTokens });
|
|
190
177
|
}
|
|
191
|
-
|
|
192
|
-
if (!currentSessionId) {
|
|
178
|
+
if (!currentSessionId)
|
|
193
179
|
currentSessionId = await createSession(process.cwd());
|
|
194
|
-
}
|
|
195
|
-
const { text, tokens } = await sendMessage(currentSessionId, messages);
|
|
180
|
+
const { text, tokens } = await sendMessage(currentSessionId, req.body.messages);
|
|
196
181
|
totalRequests++;
|
|
197
182
|
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
|
-
});
|
|
183
|
+
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
184
|
}
|
|
211
185
|
catch (error) {
|
|
212
|
-
console.error("Error:", error);
|
|
213
186
|
res.status(500).json({ error: { type: "api_error", message: error instanceof Error ? error.message : String(error) } });
|
|
214
187
|
}
|
|
215
188
|
});
|
|
216
189
|
app.post("/v1/messages/count_tokens", (req, res) => {
|
|
217
|
-
const { messages } = req.body;
|
|
218
190
|
let totalTokens = 0;
|
|
219
|
-
for (const msg of messages || [])
|
|
220
|
-
|
|
221
|
-
totalTokens += Math.ceil(content.length / 4);
|
|
222
|
-
}
|
|
191
|
+
for (const msg of req.body.messages || [])
|
|
192
|
+
totalTokens += Math.ceil(extractTextFromContent(msg.content).length / 4);
|
|
223
193
|
res.json({ tokens: totalTokens });
|
|
224
194
|
});
|
|
195
|
+
const PORT = PROXY_PORT;
|
|
225
196
|
function generateHTML() {
|
|
226
197
|
return `<!DOCTYPE html>
|
|
227
198
|
<html lang="en">
|
|
228
199
|
<head>
|
|
229
200
|
<meta charset="UTF-8">
|
|
230
201
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
231
|
-
<title>OpenCode Bridge</title>
|
|
202
|
+
<title>OCB - OpenCode Bridge</title>
|
|
232
203
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
233
204
|
<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">
|
|
234
205
|
<style>
|
|
235
206
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
236
|
-
body { font-family: 'Inter', -apple-system, sans-serif; background: #
|
|
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 */
|
|
207
|
+
body { font-family: 'Inter', -apple-system, sans-serif; background: #0f0f11; color: #fafafa; min-height: 100vh; }
|
|
288
208
|
::-webkit-scrollbar { width: 6px; }
|
|
289
209
|
::-webkit-scrollbar-track { background: transparent; }
|
|
290
210
|
::-webkit-scrollbar-thumb { background: #3f3f46; border-radius: 3px; }
|
|
291
|
-
|
|
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; }
|
|
211
|
+
.tab-active { background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); color: white; }
|
|
212
|
+
.model-card:hover { transform: translateY(-2px); box-shadow: 0 8px 25px -5px rgba(99, 102, 241, 0.3); }
|
|
299
213
|
</style>
|
|
300
214
|
</head>
|
|
301
215
|
<body>
|
|
302
|
-
<div class="
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
<div
|
|
308
|
-
|
|
216
|
+
<div class="min-h-screen flex flex-col">
|
|
217
|
+
<header class="bg-[#18181b] border-b border-zinc-800 px-6 py-4">
|
|
218
|
+
<div class="max-w-7xl mx-auto flex items-center justify-between">
|
|
219
|
+
<div class="flex items-center gap-4">
|
|
220
|
+
<div class="w-12 h-12 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-2xl flex items-center justify-center text-2xl shadow-lg shadow-indigo-500/20">⚡</div>
|
|
221
|
+
<div>
|
|
222
|
+
<h1 class="text-2xl font-bold bg-gradient-to-r from-white to-zinc-400 bg-clip-text text-transparent">OCB</h1>
|
|
223
|
+
<p class="text-xs text-zinc-500 font-medium">OpenCode Bridge</p>
|
|
224
|
+
</div>
|
|
225
|
+
<div class="ml-8 flex items-center gap-2 px-4 py-2 bg-zinc-900 rounded-xl border border-zinc-800">
|
|
226
|
+
<div id="healthDot" class="w-2.5 h-2.5 bg-green-500 rounded-full animate-pulse"></div>
|
|
227
|
+
<span id="connectionStatus" class="text-sm text-zinc-300">Connected</span>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
<div class="flex items-center gap-3">
|
|
231
|
+
<div class="text-right mr-4">
|
|
232
|
+
<p class="text-xs text-zinc-500">Current Model</p>
|
|
233
|
+
<p id="currentModelDisplay" class="text-sm font-medium text-white">Loading...</p>
|
|
234
|
+
</div>
|
|
235
|
+
<button onclick="refreshModels()" class="p-2.5 bg-zinc-800 hover:bg-zinc-700 rounded-xl transition text-zinc-300 hover:text-white" title="Refresh Models">
|
|
236
|
+
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg>
|
|
237
|
+
</button>
|
|
309
238
|
</div>
|
|
310
239
|
</div>
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
240
|
+
</header>
|
|
241
|
+
|
|
242
|
+
<nav class="bg-[#18181b] border-b border-zinc-800 px-6">
|
|
243
|
+
<div class="max-w-7xl mx-auto flex gap-1">
|
|
244
|
+
<button onclick="switchTab('models')" id="tab-models" class="px-5 py-3 text-sm font-medium rounded-t-xl transition-all tab-active">Models</button>
|
|
245
|
+
<button onclick="switchTab('status')" id="tab-status" class="px-5 py-3 text-sm font-medium rounded-t-xl transition-all text-zinc-400 hover:bg-zinc-800">Status</button>
|
|
246
|
+
<button onclick="switchTab('settings')" id="tab-settings" class="px-5 py-3 text-sm font-medium rounded-t-xl transition-all text-zinc-400 hover:bg-zinc-800">Settings</button>
|
|
314
247
|
</div>
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
248
|
+
</nav>
|
|
249
|
+
|
|
250
|
+
<main class="flex-1 max-w-7xl mx-auto w-full p-6">
|
|
251
|
+
<div id="models-view" class="flex gap-6 h-[calc(100vh-220px)]">
|
|
252
|
+
<aside class="w-72 bg-[#18181b] rounded-2xl border border-zinc-800 flex flex-col overflow-hidden">
|
|
253
|
+
<div class="p-4 border-b border-zinc-800">
|
|
254
|
+
<div class="relative">
|
|
255
|
+
<input type="text" id="searchInput" placeholder="Search models..." oninput="filterData()" class="w-full px-4 py-2.5 bg-zinc-900 border border-zinc-700 rounded-xl text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20">
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
<div id="providerList" class="flex-1 overflow-y-auto p-2"></div>
|
|
259
|
+
</aside>
|
|
260
|
+
<section class="flex-1 overflow-y-auto">
|
|
261
|
+
<div class="flex items-center justify-between mb-4">
|
|
262
|
+
<h2 id="sectionTitle" class="text-lg font-semibold text-white">All Models</h2>
|
|
263
|
+
<span id="modelCount" class="text-sm text-zinc-500"></span>
|
|
264
|
+
</div>
|
|
265
|
+
<div id="modelGrid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"></div>
|
|
266
|
+
</section>
|
|
318
267
|
</div>
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
268
|
+
|
|
269
|
+
<div id="status-view" class="hidden space-y-6">
|
|
270
|
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
271
|
+
<div class="bg-[#18181b] rounded-2xl border border-zinc-800 p-6">
|
|
272
|
+
<div class="flex items-center gap-3 mb-4">
|
|
273
|
+
<div class="w-10 h-10 bg-indigo-500/20 rounded-xl flex items-center justify-center text-indigo-400">📊</div>
|
|
274
|
+
<div>
|
|
275
|
+
<p class="text-xs text-zinc-500">Total Requests</p>
|
|
276
|
+
<p id="totalRequests" class="text-2xl font-bold text-white">0</p>
|
|
277
|
+
</div>
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
<div class="bg-[#18181b] rounded-2xl border border-zinc-800 p-6">
|
|
281
|
+
<div class="flex items-center gap-3 mb-4">
|
|
282
|
+
<div class="w-10 h-10 bg-purple-500/20 rounded-xl flex items-center justify-center text-purple-400">🎯</div>
|
|
283
|
+
<div>
|
|
284
|
+
<p class="text-xs text-zinc-500">Total Tokens</p>
|
|
285
|
+
<p id="totalTokens" class="text-2xl font-bold text-white">0</p>
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
</div>
|
|
289
|
+
<div class="bg-[#18181b] rounded-2xl border border-zinc-800 p-6">
|
|
290
|
+
<div class="flex items-center gap-3 mb-4">
|
|
291
|
+
<div class="w-10 h-10 bg-green-500/20 rounded-xl flex items-center justify-center text-green-400">🔗</div>
|
|
292
|
+
<div>
|
|
293
|
+
<p class="text-xs text-zinc-500">Session</p>
|
|
294
|
+
<p id="sessionStatus" class="text-lg font-semibold text-green-400">Active</p>
|
|
295
|
+
</div>
|
|
296
|
+
</div>
|
|
328
297
|
</div>
|
|
329
298
|
</div>
|
|
330
|
-
<div
|
|
331
|
-
<
|
|
332
|
-
<
|
|
299
|
+
<div class="bg-[#18181b] rounded-2xl border border-zinc-800 p-6">
|
|
300
|
+
<h3 class="text-lg font-semibold text-white mb-4">Model Usage</h3>
|
|
301
|
+
<div class="space-y-3">
|
|
302
|
+
<div class="flex justify-between items-center py-2 border-b border-zinc-800">
|
|
303
|
+
<span class="text-zinc-400">Active Model</span>
|
|
304
|
+
<span id="activeModelName" class="text-white font-medium">-</span>
|
|
305
|
+
</div>
|
|
306
|
+
<div class="flex justify-between items-center py-2 border-b border-zinc-800">
|
|
307
|
+
<span class="text-zinc-400">Available Models</span>
|
|
308
|
+
<span id="totalModelsDisplay" class="text-white font-medium">-</span>
|
|
309
|
+
</div>
|
|
310
|
+
<div class="flex justify-between items-center py-2 border-b border-zinc-800">
|
|
311
|
+
<span class="text-zinc-400">Providers</span>
|
|
312
|
+
<span id="totalProviders" class="text-white font-medium">-</span>
|
|
313
|
+
</div>
|
|
314
|
+
<div class="flex justify-between items-center py-2">
|
|
315
|
+
<span class="text-zinc-400">OpenCode Server</span>
|
|
316
|
+
<span class="text-green-400 font-medium">localhost:4096</span>
|
|
317
|
+
</div>
|
|
318
|
+
</div>
|
|
333
319
|
</div>
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
<div class="model-list-header" id="modelListHeader">All Models</div>
|
|
338
|
-
<div id="modelList">
|
|
339
|
-
<!-- Models loaded here -->
|
|
320
|
+
<div class="flex gap-4">
|
|
321
|
+
<button onclick="resetSession()" class="px-6 py-3 bg-zinc-800 hover:bg-zinc-700 rounded-xl text-white font-medium transition">Reset Session</button>
|
|
322
|
+
<button onclick="resetStats()" class="px-6 py-3 bg-zinc-800 hover:bg-zinc-700 rounded-xl text-white font-medium transition">Reset Stats</button>
|
|
340
323
|
</div>
|
|
341
324
|
</div>
|
|
342
|
-
|
|
343
|
-
<div class="
|
|
344
|
-
<div class="
|
|
345
|
-
<
|
|
346
|
-
<
|
|
325
|
+
|
|
326
|
+
<div id="settings-view" class="hidden space-y-6">
|
|
327
|
+
<div class="bg-[#18181b] rounded-2xl border border-zinc-800 p-6">
|
|
328
|
+
<h3 class="text-lg font-semibold text-white mb-4">Claude Code Configuration</h3>
|
|
329
|
+
<p class="text-zinc-400 text-sm mb-4">Add this to your Claude Code settings to use OCB:</p>
|
|
330
|
+
<div class="bg-zinc-950 rounded-xl p-4 font-mono text-sm text-zinc-300 overflow-x-auto">
|
|
331
|
+
<pre id="configJson">{
|
|
332
|
+
"env": {
|
|
333
|
+
"ANTHROPIC_BASE_URL": "http://localhost:8300/v1",
|
|
334
|
+
"ANTHROPIC_AUTH_TOKEN": "test",
|
|
335
|
+
"ANTHROPIC_MODEL": "minimax-m2.5-free"
|
|
336
|
+
}
|
|
337
|
+
}</pre>
|
|
338
|
+
</div>
|
|
339
|
+
<button onclick="copyConfig()" class="mt-4 px-6 py-2.5 bg-indigo-600 hover:bg-indigo-500 rounded-xl text-white font-medium transition flex items-center gap-2">
|
|
340
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
|
|
341
|
+
Copy Config
|
|
342
|
+
</button>
|
|
347
343
|
</div>
|
|
348
|
-
<div class="
|
|
349
|
-
<
|
|
350
|
-
<
|
|
344
|
+
<div class="bg-[#18181b] rounded-2xl border border-zinc-800 p-6">
|
|
345
|
+
<h3 class="text-lg font-semibold text-white mb-4">API Endpoints</h3>
|
|
346
|
+
<div class="space-y-2 font-mono text-sm">
|
|
347
|
+
<div class="flex justify-between py-2 border-b border-zinc-800"><span class="text-zinc-400">Health</span><span class="text-indigo-400">http://localhost:8300/health</span></div>
|
|
348
|
+
<div class="flex justify-between py-2 border-b border-zinc-800"><span class="text-zinc-400">Status</span><span class="text-indigo-400">http://localhost:8300/api/status</span></div>
|
|
349
|
+
<div class="flex justify-between py-2 border-b border-zinc-800"><span class="text-zinc-400">Models</span><span class="text-indigo-400">http://localhost:8300/api/models</span></div>
|
|
350
|
+
<div class="flex justify-between py-2 border-b border-zinc-800"><span class="text-zinc-400">Set Model</span><span class="text-indigo-400">POST /api/model</span></div>
|
|
351
|
+
<div class="flex justify-between py-2"><span class="text-zinc-400">Messages</span><span class="text-indigo-400">http://localhost:8300/v1/messages</span></div>
|
|
352
|
+
</div>
|
|
351
353
|
</div>
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
354
|
+
</div>
|
|
355
|
+
</main>
|
|
356
|
+
|
|
357
|
+
<footer class="bg-[#18181b] border-t border-zinc-800 px-6 py-3">
|
|
358
|
+
<div class="max-w-7xl mx-auto flex items-center justify-between text-sm text-zinc-500">
|
|
359
|
+
<div class="flex items-center gap-6">
|
|
360
|
+
<span>OCB v1.0.1</span>
|
|
361
|
+
<span>Proxy Port: <span class="text-white">8300</span></span>
|
|
355
362
|
</div>
|
|
363
|
+
<span>OpenCode Bridge for Claude Code</span>
|
|
356
364
|
</div>
|
|
357
|
-
</
|
|
365
|
+
</footer>
|
|
358
366
|
</div>
|
|
359
|
-
|
|
360
367
|
<script>
|
|
361
|
-
const PROXY_PORT = ${PROXY_PORT};
|
|
362
368
|
let allModels = [];
|
|
363
369
|
let groupedModels = {};
|
|
364
370
|
let currentProvider = 'all';
|
|
365
371
|
let currentModelId = null;
|
|
366
|
-
|
|
372
|
+
|
|
373
|
+
function switchTab(tab) {
|
|
374
|
+
document.querySelectorAll('[id^="tab-"]').forEach(el => {
|
|
375
|
+
el.classList.remove('tab-active');
|
|
376
|
+
el.classList.add('text-zinc-400', 'hover:bg-zinc-800');
|
|
377
|
+
});
|
|
378
|
+
document.getElementById('tab-' + tab).classList.add('tab-active');
|
|
379
|
+
document.getElementById('tab-' + tab).classList.remove('text-zinc-400', 'hover:bg-zinc-800');
|
|
380
|
+
|
|
381
|
+
document.getElementById('models-view').classList.add('hidden');
|
|
382
|
+
document.getElementById('status-view').classList.add('hidden');
|
|
383
|
+
document.getElementById('settings-view').classList.add('hidden');
|
|
384
|
+
document.getElementById(tab + '-view').classList.remove('hidden');
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function copyConfig() {
|
|
388
|
+
const config = document.getElementById('configJson').textContent;
|
|
389
|
+
navigator.clipboard.writeText(config);
|
|
390
|
+
alert('Config copied to clipboard!');
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function resetStats() {
|
|
394
|
+
await fetch('/api/reset-stats', { method: 'POST' });
|
|
395
|
+
loadStatus();
|
|
396
|
+
}
|
|
397
|
+
|
|
367
398
|
async function loadModels() {
|
|
368
399
|
const res = await fetch('/api/models');
|
|
369
400
|
const data = await res.json();
|
|
370
|
-
|
|
371
401
|
allModels = data.models || [];
|
|
372
402
|
groupedModels = data.grouped || {};
|
|
373
|
-
|
|
374
403
|
renderProviders();
|
|
375
404
|
renderModels(allModels);
|
|
376
405
|
updateStats();
|
|
377
406
|
}
|
|
378
|
-
|
|
407
|
+
|
|
379
408
|
function renderProviders() {
|
|
380
409
|
const container = document.getElementById('providerList');
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
</div
|
|
387
|
-
|
|
388
|
-
|
|
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>\`;
|
|
410
|
+
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
|
|
411
|
+
let providersToShow = Object.entries(groupedModels);
|
|
412
|
+
if (searchTerm) {
|
|
413
|
+
providersToShow = providersToShow.filter(([name, models]) => name.toLowerCase().includes(searchTerm) || models.some(m => m.name.toLowerCase().includes(searchTerm)));
|
|
414
|
+
}
|
|
415
|
+
let html = '<div onclick="selectProvider('all')" class="px-3 py-2.5 rounded-xl 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 Models</span><span class="text-xs opacity-60 bg-zinc-800 px-2 py-0.5 rounded-full">' + allModels.length + '</span></div>';
|
|
416
|
+
for (const [provider, models] of providersToShow) {
|
|
417
|
+
html += '<div onclick="selectProvider(\\'' + provider.replace(/'/g, "\\\\'") + '\\')" class="px-3 py-2.5 rounded-xl 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 bg-zinc-800 px-2 py-0.5 rounded-full">' + models.length + '</span></div>';
|
|
393
418
|
}
|
|
394
|
-
|
|
395
419
|
container.innerHTML = html;
|
|
396
420
|
}
|
|
397
|
-
|
|
421
|
+
|
|
398
422
|
function selectProvider(provider) {
|
|
399
423
|
currentProvider = provider;
|
|
424
|
+
document.getElementById('sectionTitle').textContent = provider === 'all' ? 'All Models' : provider;
|
|
400
425
|
renderProviders();
|
|
401
|
-
|
|
402
|
-
const models = provider === 'all' ? allModels : groupedModels[provider] || [];
|
|
403
|
-
document.getElementById('modelListHeader').textContent = provider === 'all' ? 'All Models' : provider;
|
|
404
|
-
renderModels(models);
|
|
426
|
+
renderModels(provider === 'all' ? allModels : (groupedModels[provider] || []));
|
|
405
427
|
}
|
|
406
|
-
|
|
428
|
+
|
|
407
429
|
function renderModels(models) {
|
|
408
|
-
const container = document.getElementById('
|
|
430
|
+
const container = document.getElementById('modelGrid');
|
|
409
431
|
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
|
|
410
|
-
|
|
411
432
|
let filtered = models;
|
|
412
433
|
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
|
-
);
|
|
434
|
+
filtered = models.filter(m => m.name.toLowerCase().includes(searchTerm) || m.id.toLowerCase().includes(searchTerm));
|
|
418
435
|
}
|
|
419
|
-
|
|
436
|
+
document.getElementById('modelCount').textContent = filtered.length + ' models';
|
|
420
437
|
if (filtered.length === 0) {
|
|
421
|
-
container.innerHTML = '<div
|
|
438
|
+
container.innerHTML = '<div class="col-span-full text-center text-zinc-500 py-12">No models found</div>';
|
|
422
439
|
return;
|
|
423
440
|
}
|
|
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;
|
|
441
|
+
container.innerHTML = filtered.slice(0, 100).map(m => '<div onclick="selectModel(\\'' + m.id.replace(/'/g, "\\\\'") + '\\')" class="model-card p-5 rounded-2xl border cursor-pointer transition-all ' + (m.id === currentModelId ? 'bg-indigo-600/20 border-indigo-500 shadow-lg shadow-indigo-500/20' : 'bg-[#18181b] border-zinc-800 hover:border-zinc-700') + '"><div class="flex items-start justify-between mb-3"><h3 class="font-semibold text-white truncate text-base">' + m.name + '</h3>' + (m.id === currentModelId ? '<span class="text-xs bg-indigo-500 text-white px-3 py-1 rounded-full font-medium">Active</span>' : '') + '</div><p class="text-xs text-zinc-500 font-mono truncate mb-3">' + m.id + '</p>' + (m.cost ? '<div class="flex items-center gap-2 text-xs text-zinc-400"><span class="bg-zinc-800 px-2 py-1 rounded">$' + m.cost.input + '/M</span><span>→</span><span class="bg-zinc-800 px-2 py-1 rounded">$' + m.cost.output + '/M</span></div>' : '<div class="text-xs text-zinc-600">Free</div>') + '</div>').join('');
|
|
440
442
|
}
|
|
441
|
-
|
|
442
|
-
function
|
|
443
|
-
const
|
|
444
|
-
|
|
443
|
+
|
|
444
|
+
function filterData() {
|
|
445
|
+
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
|
|
446
|
+
if (searchTerm) {
|
|
447
|
+
const filtered = allModels.filter(m => m.name.toLowerCase().includes(searchTerm) || m.id.toLowerCase().includes(searchTerm));
|
|
448
|
+
document.getElementById('sectionTitle').textContent = 'Search Results (' + filtered.length + ')';
|
|
449
|
+
renderModels(filtered);
|
|
450
|
+
renderProviders();
|
|
451
|
+
} else {
|
|
452
|
+
selectProvider(currentProvider);
|
|
453
|
+
}
|
|
445
454
|
}
|
|
446
|
-
|
|
455
|
+
|
|
447
456
|
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
|
-
});
|
|
457
|
+
const res = await fetch('/api/model', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ modelId }) });
|
|
453
458
|
const data = await res.json();
|
|
454
459
|
if (data.success) {
|
|
455
460
|
currentModelId = modelId;
|
|
456
461
|
loadStatus();
|
|
457
|
-
|
|
458
|
-
|
|
462
|
+
renderModels(currentProvider === 'all' ? allModels : (groupedModels[currentProvider] || []));
|
|
463
|
+
document.getElementById('configJson').textContent = JSON.stringify({ env: { ANTHROPIC_BASE_URL: "http://localhost:8300/v1", ANTHROPIC_AUTH_TOKEN: "test", ANTHROPIC_MODEL: modelId } }, null, 2);
|
|
459
464
|
}
|
|
460
465
|
}
|
|
461
|
-
|
|
466
|
+
|
|
462
467
|
async function loadStatus() {
|
|
463
468
|
const res = await fetch('/api/status');
|
|
464
469
|
const data = await res.json();
|
|
465
|
-
|
|
466
470
|
const model = allModels.find(m => m.id === data.currentModel);
|
|
467
|
-
document.getElementById('
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
if (data.sessionId === 'active') {
|
|
471
|
-
statusDot.classList.remove('inactive');
|
|
472
|
-
} else {
|
|
473
|
-
statusDot.classList.add('inactive');
|
|
474
|
-
}
|
|
475
|
-
|
|
471
|
+
document.getElementById('currentModelDisplay').textContent = model?.name || data.currentModel;
|
|
472
|
+
document.getElementById('healthDot').className = 'w-2.5 h-2.5 rounded-full animate-pulse ' + (data.sessionId === 'active' ? 'bg-green-500' : 'bg-red-500');
|
|
473
|
+
document.getElementById('connectionStatus').textContent = data.sessionId === 'active' ? 'Connected' : 'Disconnected';
|
|
476
474
|
document.getElementById('totalRequests').textContent = data.totalRequests.toLocaleString();
|
|
477
475
|
document.getElementById('totalTokens').textContent = data.totalTokensUsed.toLocaleString();
|
|
478
|
-
document.getElementById('
|
|
479
|
-
|
|
476
|
+
document.getElementById('sessionStatus').textContent = data.sessionId === 'active' ? 'Active' : 'Inactive';
|
|
477
|
+
document.getElementById('sessionStatus').className = 'text-lg font-semibold ' + (data.sessionId === 'active' ? 'text-green-400' : 'text-red-400');
|
|
478
|
+
document.getElementById('activeModelName').textContent = model?.name || data.currentModel;
|
|
479
|
+
document.getElementById('totalModelsDisplay').textContent = allModels.length + ' models';
|
|
480
|
+
document.getElementById('totalProviders').textContent = Object.keys(groupedModels).length + ' providers';
|
|
480
481
|
currentModelId = data.currentModel;
|
|
481
482
|
}
|
|
482
|
-
|
|
483
|
+
|
|
483
484
|
async function resetSession() {
|
|
484
485
|
await fetch('/api/reset-session', { method: 'POST' });
|
|
485
486
|
loadStatus();
|
|
486
487
|
}
|
|
487
|
-
|
|
488
|
-
async function
|
|
489
|
-
await fetch('/api/
|
|
490
|
-
|
|
488
|
+
|
|
489
|
+
async function refreshModels() {
|
|
490
|
+
await fetch('/api/refresh-models', { method: 'POST' });
|
|
491
|
+
loadModels();
|
|
491
492
|
}
|
|
492
|
-
|
|
493
|
-
|
|
493
|
+
|
|
494
|
+
function updateStats() {
|
|
495
|
+
document.getElementById('totalModelsDisplay').textContent = allModels.length + ' models from ' + Object.keys(groupedModels).length + ' providers';
|
|
496
|
+
}
|
|
497
|
+
|
|
494
498
|
loadModels();
|
|
495
499
|
loadStatus();
|
|
496
500
|
setInterval(loadStatus, 3000);
|
|
@@ -498,7 +502,7 @@ function generateHTML() {
|
|
|
498
502
|
</body>
|
|
499
503
|
</html>`;
|
|
500
504
|
}
|
|
501
|
-
app.listen(
|
|
502
|
-
console.error(`OpenCode Bridge running on http://localhost:${
|
|
503
|
-
console.error(`Dashboard: http://localhost:${
|
|
505
|
+
app.listen(PORT, () => {
|
|
506
|
+
console.error(`OpenCode Bridge running on http://localhost:${PORT}`);
|
|
507
|
+
console.error(`Dashboard: http://localhost:${PORT}`);
|
|
504
508
|
});
|
package/package.json
CHANGED
package/src/proxy.ts
CHANGED
|
@@ -143,6 +143,10 @@ app.use((req, res, next) => {
|
|
|
143
143
|
|
|
144
144
|
app.get("/", (req, res) => res.send(generateHTML()));
|
|
145
145
|
|
|
146
|
+
app.get("/health", (req, res) => {
|
|
147
|
+
res.json({ status: "ok", timestamp: Date.now(), models: availableModels.length, providers: Object.keys(providers).length });
|
|
148
|
+
});
|
|
149
|
+
|
|
146
150
|
app.get("/api/status", (req, res) => {
|
|
147
151
|
res.json({
|
|
148
152
|
proxyPort: PROXY_PORT,
|
|
@@ -160,11 +164,7 @@ app.get("/api/models", (req, res) => {
|
|
|
160
164
|
if (!grouped[model.provider]) grouped[model.provider] = [];
|
|
161
165
|
grouped[model.provider].push(model);
|
|
162
166
|
}
|
|
163
|
-
res.json({
|
|
164
|
-
models: availableModels,
|
|
165
|
-
grouped,
|
|
166
|
-
providers: Object.entries(providers).map(([id, p]) => ({ id, ...p }))
|
|
167
|
-
});
|
|
167
|
+
res.json({ models: availableModels, grouped, providers: Object.entries(providers).map(([id, p]) => ({ id, ...p })) });
|
|
168
168
|
});
|
|
169
169
|
|
|
170
170
|
app.post("/api/model", (req, res) => {
|
|
@@ -198,340 +198,338 @@ app.post("/api/reset-session", async (req, res) => {
|
|
|
198
198
|
app.get("/v1/authenticate", (req, res) => res.json({ type: "authentication", authenticated: true }));
|
|
199
199
|
app.get("/v1/whoami", (req, res) => res.json({ type: "user", id: "opencode-user", email: "opencode@local" }));
|
|
200
200
|
|
|
201
|
-
app.get("/v1/models", (req, res) => {
|
|
202
|
-
|
|
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
|
-
});
|
|
201
|
+
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 })) }));
|
|
202
|
+
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
203
|
|
|
215
204
|
app.post("/v1/messages", async (req, res) => {
|
|
216
205
|
try {
|
|
217
206
|
if (req.body?.max_tokens === undefined && req.body?.messages) {
|
|
218
|
-
const messages = req.body.messages;
|
|
219
207
|
let totalTokens = 0;
|
|
220
|
-
for (const msg of messages) {
|
|
221
|
-
|
|
222
|
-
totalTokens += Math.ceil(content.length / 4);
|
|
208
|
+
for (const msg of req.body.messages) {
|
|
209
|
+
totalTokens += Math.ceil(extractTextFromContent(msg.content).length / 4);
|
|
223
210
|
}
|
|
224
211
|
return res.json({ tokens: totalTokens });
|
|
225
212
|
}
|
|
226
|
-
|
|
227
|
-
const {
|
|
228
|
-
|
|
229
|
-
if (!currentSessionId) {
|
|
230
|
-
currentSessionId = await createSession(process.cwd());
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
const { text, tokens } = await sendMessage(currentSessionId, messages);
|
|
234
|
-
|
|
213
|
+
if (!currentSessionId) currentSessionId = await createSession(process.cwd());
|
|
214
|
+
const { text, tokens } = await sendMessage(currentSessionId, req.body.messages);
|
|
235
215
|
totalRequests++;
|
|
236
216
|
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
|
-
});
|
|
217
|
+
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
218
|
} catch (error) {
|
|
251
|
-
console.error("Error:", error);
|
|
252
219
|
res.status(500).json({ error: { type: "api_error", message: error instanceof Error ? error.message : String(error) } });
|
|
253
220
|
}
|
|
254
221
|
});
|
|
255
222
|
|
|
256
223
|
app.post("/v1/messages/count_tokens", (req, res) => {
|
|
257
|
-
const { messages } = req.body;
|
|
258
224
|
let totalTokens = 0;
|
|
259
|
-
for (const msg of messages || [])
|
|
260
|
-
const content = extractTextFromContent(msg.content);
|
|
261
|
-
totalTokens += Math.ceil(content.length / 4);
|
|
262
|
-
}
|
|
225
|
+
for (const msg of req.body.messages || []) totalTokens += Math.ceil(extractTextFromContent(msg.content).length / 4);
|
|
263
226
|
res.json({ tokens: totalTokens });
|
|
264
227
|
});
|
|
265
228
|
|
|
266
|
-
|
|
229
|
+
const PORT = PROXY_PORT;
|
|
230
|
+
|
|
231
|
+
function generateHTML() {
|
|
267
232
|
return `<!DOCTYPE html>
|
|
268
233
|
<html lang="en">
|
|
269
234
|
<head>
|
|
270
235
|
<meta charset="UTF-8">
|
|
271
236
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
272
|
-
<title>OpenCode Bridge</title>
|
|
237
|
+
<title>OCB - OpenCode Bridge</title>
|
|
273
238
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
274
239
|
<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">
|
|
275
240
|
<style>
|
|
276
241
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
277
|
-
body { font-family: 'Inter', -apple-system, sans-serif; background: #
|
|
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 */
|
|
242
|
+
body { font-family: 'Inter', -apple-system, sans-serif; background: #0f0f11; color: #fafafa; min-height: 100vh; }
|
|
329
243
|
::-webkit-scrollbar { width: 6px; }
|
|
330
244
|
::-webkit-scrollbar-track { background: transparent; }
|
|
331
245
|
::-webkit-scrollbar-thumb { background: #3f3f46; border-radius: 3px; }
|
|
332
|
-
|
|
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; }
|
|
246
|
+
.tab-active { background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); color: white; }
|
|
247
|
+
.model-card:hover { transform: translateY(-2px); box-shadow: 0 8px 25px -5px rgba(99, 102, 241, 0.3); }
|
|
340
248
|
</style>
|
|
341
249
|
</head>
|
|
342
250
|
<body>
|
|
343
|
-
<div class="
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
<div
|
|
349
|
-
|
|
251
|
+
<div class="min-h-screen flex flex-col">
|
|
252
|
+
<header class="bg-[#18181b] border-b border-zinc-800 px-6 py-4">
|
|
253
|
+
<div class="max-w-7xl mx-auto flex items-center justify-between">
|
|
254
|
+
<div class="flex items-center gap-4">
|
|
255
|
+
<div class="w-12 h-12 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-2xl flex items-center justify-center text-2xl shadow-lg shadow-indigo-500/20">⚡</div>
|
|
256
|
+
<div>
|
|
257
|
+
<h1 class="text-2xl font-bold bg-gradient-to-r from-white to-zinc-400 bg-clip-text text-transparent">OCB</h1>
|
|
258
|
+
<p class="text-xs text-zinc-500 font-medium">OpenCode Bridge</p>
|
|
259
|
+
</div>
|
|
260
|
+
<div class="ml-8 flex items-center gap-2 px-4 py-2 bg-zinc-900 rounded-xl border border-zinc-800">
|
|
261
|
+
<div id="healthDot" class="w-2.5 h-2.5 bg-green-500 rounded-full animate-pulse"></div>
|
|
262
|
+
<span id="connectionStatus" class="text-sm text-zinc-300">Connected</span>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
<div class="flex items-center gap-3">
|
|
266
|
+
<div class="text-right mr-4">
|
|
267
|
+
<p class="text-xs text-zinc-500">Current Model</p>
|
|
268
|
+
<p id="currentModelDisplay" class="text-sm font-medium text-white">Loading...</p>
|
|
269
|
+
</div>
|
|
270
|
+
<button onclick="refreshModels()" class="p-2.5 bg-zinc-800 hover:bg-zinc-700 rounded-xl transition text-zinc-300 hover:text-white" title="Refresh Models">
|
|
271
|
+
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg>
|
|
272
|
+
</button>
|
|
350
273
|
</div>
|
|
351
274
|
</div>
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
275
|
+
</header>
|
|
276
|
+
|
|
277
|
+
<nav class="bg-[#18181b] border-b border-zinc-800 px-6">
|
|
278
|
+
<div class="max-w-7xl mx-auto flex gap-1">
|
|
279
|
+
<button onclick="switchTab('models')" id="tab-models" class="px-5 py-3 text-sm font-medium rounded-t-xl transition-all tab-active">Models</button>
|
|
280
|
+
<button onclick="switchTab('status')" id="tab-status" class="px-5 py-3 text-sm font-medium rounded-t-xl transition-all text-zinc-400 hover:bg-zinc-800">Status</button>
|
|
281
|
+
<button onclick="switchTab('settings')" id="tab-settings" class="px-5 py-3 text-sm font-medium rounded-t-xl transition-all text-zinc-400 hover:bg-zinc-800">Settings</button>
|
|
355
282
|
</div>
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
283
|
+
</nav>
|
|
284
|
+
|
|
285
|
+
<main class="flex-1 max-w-7xl mx-auto w-full p-6">
|
|
286
|
+
<div id="models-view" class="flex gap-6 h-[calc(100vh-220px)]">
|
|
287
|
+
<aside class="w-72 bg-[#18181b] rounded-2xl border border-zinc-800 flex flex-col overflow-hidden">
|
|
288
|
+
<div class="p-4 border-b border-zinc-800">
|
|
289
|
+
<div class="relative">
|
|
290
|
+
<input type="text" id="searchInput" placeholder="Search models..." oninput="filterData()" class="w-full px-4 py-2.5 bg-zinc-900 border border-zinc-700 rounded-xl text-sm text-white placeholder-zinc-500 focus:outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20">
|
|
291
|
+
</div>
|
|
292
|
+
</div>
|
|
293
|
+
<div id="providerList" class="flex-1 overflow-y-auto p-2"></div>
|
|
294
|
+
</aside>
|
|
295
|
+
<section class="flex-1 overflow-y-auto">
|
|
296
|
+
<div class="flex items-center justify-between mb-4">
|
|
297
|
+
<h2 id="sectionTitle" class="text-lg font-semibold text-white">All Models</h2>
|
|
298
|
+
<span id="modelCount" class="text-sm text-zinc-500"></span>
|
|
299
|
+
</div>
|
|
300
|
+
<div id="modelGrid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"></div>
|
|
301
|
+
</section>
|
|
359
302
|
</div>
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
303
|
+
|
|
304
|
+
<div id="status-view" class="hidden space-y-6">
|
|
305
|
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
306
|
+
<div class="bg-[#18181b] rounded-2xl border border-zinc-800 p-6">
|
|
307
|
+
<div class="flex items-center gap-3 mb-4">
|
|
308
|
+
<div class="w-10 h-10 bg-indigo-500/20 rounded-xl flex items-center justify-center text-indigo-400">📊</div>
|
|
309
|
+
<div>
|
|
310
|
+
<p class="text-xs text-zinc-500">Total Requests</p>
|
|
311
|
+
<p id="totalRequests" class="text-2xl font-bold text-white">0</p>
|
|
312
|
+
</div>
|
|
313
|
+
</div>
|
|
314
|
+
</div>
|
|
315
|
+
<div class="bg-[#18181b] rounded-2xl border border-zinc-800 p-6">
|
|
316
|
+
<div class="flex items-center gap-3 mb-4">
|
|
317
|
+
<div class="w-10 h-10 bg-purple-500/20 rounded-xl flex items-center justify-center text-purple-400">🎯</div>
|
|
318
|
+
<div>
|
|
319
|
+
<p class="text-xs text-zinc-500">Total Tokens</p>
|
|
320
|
+
<p id="totalTokens" class="text-2xl font-bold text-white">0</p>
|
|
321
|
+
</div>
|
|
322
|
+
</div>
|
|
323
|
+
</div>
|
|
324
|
+
<div class="bg-[#18181b] rounded-2xl border border-zinc-800 p-6">
|
|
325
|
+
<div class="flex items-center gap-3 mb-4">
|
|
326
|
+
<div class="w-10 h-10 bg-green-500/20 rounded-xl flex items-center justify-center text-green-400">🔗</div>
|
|
327
|
+
<div>
|
|
328
|
+
<p class="text-xs text-zinc-500">Session</p>
|
|
329
|
+
<p id="sessionStatus" class="text-lg font-semibold text-green-400">Active</p>
|
|
330
|
+
</div>
|
|
331
|
+
</div>
|
|
369
332
|
</div>
|
|
370
333
|
</div>
|
|
371
|
-
<div
|
|
372
|
-
<
|
|
373
|
-
<
|
|
334
|
+
<div class="bg-[#18181b] rounded-2xl border border-zinc-800 p-6">
|
|
335
|
+
<h3 class="text-lg font-semibold text-white mb-4">Model Usage</h3>
|
|
336
|
+
<div class="space-y-3">
|
|
337
|
+
<div class="flex justify-between items-center py-2 border-b border-zinc-800">
|
|
338
|
+
<span class="text-zinc-400">Active Model</span>
|
|
339
|
+
<span id="activeModelName" class="text-white font-medium">-</span>
|
|
340
|
+
</div>
|
|
341
|
+
<div class="flex justify-between items-center py-2 border-b border-zinc-800">
|
|
342
|
+
<span class="text-zinc-400">Available Models</span>
|
|
343
|
+
<span id="totalModelsDisplay" class="text-white font-medium">-</span>
|
|
344
|
+
</div>
|
|
345
|
+
<div class="flex justify-between items-center py-2 border-b border-zinc-800">
|
|
346
|
+
<span class="text-zinc-400">Providers</span>
|
|
347
|
+
<span id="totalProviders" class="text-white font-medium">-</span>
|
|
348
|
+
</div>
|
|
349
|
+
<div class="flex justify-between items-center py-2">
|
|
350
|
+
<span class="text-zinc-400">OpenCode Server</span>
|
|
351
|
+
<span class="text-green-400 font-medium">localhost:4096</span>
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
374
354
|
</div>
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
<div class="model-list-header" id="modelListHeader">All Models</div>
|
|
379
|
-
<div id="modelList">
|
|
380
|
-
<!-- Models loaded here -->
|
|
355
|
+
<div class="flex gap-4">
|
|
356
|
+
<button onclick="resetSession()" class="px-6 py-3 bg-zinc-800 hover:bg-zinc-700 rounded-xl text-white font-medium transition">Reset Session</button>
|
|
357
|
+
<button onclick="resetStats()" class="px-6 py-3 bg-zinc-800 hover:bg-zinc-700 rounded-xl text-white font-medium transition">Reset Stats</button>
|
|
381
358
|
</div>
|
|
382
359
|
</div>
|
|
383
|
-
|
|
384
|
-
<div class="
|
|
385
|
-
<div class="
|
|
386
|
-
<
|
|
387
|
-
<
|
|
360
|
+
|
|
361
|
+
<div id="settings-view" class="hidden space-y-6">
|
|
362
|
+
<div class="bg-[#18181b] rounded-2xl border border-zinc-800 p-6">
|
|
363
|
+
<h3 class="text-lg font-semibold text-white mb-4">Claude Code Configuration</h3>
|
|
364
|
+
<p class="text-zinc-400 text-sm mb-4">Add this to your Claude Code settings to use OCB:</p>
|
|
365
|
+
<div class="bg-zinc-950 rounded-xl p-4 font-mono text-sm text-zinc-300 overflow-x-auto">
|
|
366
|
+
<pre id="configJson">{
|
|
367
|
+
"env": {
|
|
368
|
+
"ANTHROPIC_BASE_URL": "http://localhost:8300/v1",
|
|
369
|
+
"ANTHROPIC_AUTH_TOKEN": "test",
|
|
370
|
+
"ANTHROPIC_MODEL": "minimax-m2.5-free"
|
|
371
|
+
}
|
|
372
|
+
}</pre>
|
|
373
|
+
</div>
|
|
374
|
+
<button onclick="copyConfig()" class="mt-4 px-6 py-2.5 bg-indigo-600 hover:bg-indigo-500 rounded-xl text-white font-medium transition flex items-center gap-2">
|
|
375
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
|
|
376
|
+
Copy Config
|
|
377
|
+
</button>
|
|
388
378
|
</div>
|
|
389
|
-
<div class="
|
|
390
|
-
<
|
|
391
|
-
<
|
|
379
|
+
<div class="bg-[#18181b] rounded-2xl border border-zinc-800 p-6">
|
|
380
|
+
<h3 class="text-lg font-semibold text-white mb-4">API Endpoints</h3>
|
|
381
|
+
<div class="space-y-2 font-mono text-sm">
|
|
382
|
+
<div class="flex justify-between py-2 border-b border-zinc-800"><span class="text-zinc-400">Health</span><span class="text-indigo-400">http://localhost:8300/health</span></div>
|
|
383
|
+
<div class="flex justify-between py-2 border-b border-zinc-800"><span class="text-zinc-400">Status</span><span class="text-indigo-400">http://localhost:8300/api/status</span></div>
|
|
384
|
+
<div class="flex justify-between py-2 border-b border-zinc-800"><span class="text-zinc-400">Models</span><span class="text-indigo-400">http://localhost:8300/api/models</span></div>
|
|
385
|
+
<div class="flex justify-between py-2 border-b border-zinc-800"><span class="text-zinc-400">Set Model</span><span class="text-indigo-400">POST /api/model</span></div>
|
|
386
|
+
<div class="flex justify-between py-2"><span class="text-zinc-400">Messages</span><span class="text-indigo-400">http://localhost:8300/v1/messages</span></div>
|
|
387
|
+
</div>
|
|
392
388
|
</div>
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
389
|
+
</div>
|
|
390
|
+
</main>
|
|
391
|
+
|
|
392
|
+
<footer class="bg-[#18181b] border-t border-zinc-800 px-6 py-3">
|
|
393
|
+
<div class="max-w-7xl mx-auto flex items-center justify-between text-sm text-zinc-500">
|
|
394
|
+
<div class="flex items-center gap-6">
|
|
395
|
+
<span>OCB v1.0.1</span>
|
|
396
|
+
<span>Proxy Port: <span class="text-white">8300</span></span>
|
|
396
397
|
</div>
|
|
398
|
+
<span>OpenCode Bridge for Claude Code</span>
|
|
397
399
|
</div>
|
|
398
|
-
</
|
|
400
|
+
</footer>
|
|
399
401
|
</div>
|
|
400
|
-
|
|
401
402
|
<script>
|
|
402
|
-
const PROXY_PORT = ${PROXY_PORT};
|
|
403
403
|
let allModels = [];
|
|
404
404
|
let groupedModels = {};
|
|
405
405
|
let currentProvider = 'all';
|
|
406
406
|
let currentModelId = null;
|
|
407
|
-
|
|
407
|
+
|
|
408
|
+
function switchTab(tab) {
|
|
409
|
+
document.querySelectorAll('[id^="tab-"]').forEach(el => {
|
|
410
|
+
el.classList.remove('tab-active');
|
|
411
|
+
el.classList.add('text-zinc-400', 'hover:bg-zinc-800');
|
|
412
|
+
});
|
|
413
|
+
document.getElementById('tab-' + tab).classList.add('tab-active');
|
|
414
|
+
document.getElementById('tab-' + tab).classList.remove('text-zinc-400', 'hover:bg-zinc-800');
|
|
415
|
+
|
|
416
|
+
document.getElementById('models-view').classList.add('hidden');
|
|
417
|
+
document.getElementById('status-view').classList.add('hidden');
|
|
418
|
+
document.getElementById('settings-view').classList.add('hidden');
|
|
419
|
+
document.getElementById(tab + '-view').classList.remove('hidden');
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function copyConfig() {
|
|
423
|
+
const config = document.getElementById('configJson').textContent;
|
|
424
|
+
navigator.clipboard.writeText(config);
|
|
425
|
+
alert('Config copied to clipboard!');
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function resetStats() {
|
|
429
|
+
await fetch('/api/reset-stats', { method: 'POST' });
|
|
430
|
+
loadStatus();
|
|
431
|
+
}
|
|
432
|
+
|
|
408
433
|
async function loadModels() {
|
|
409
434
|
const res = await fetch('/api/models');
|
|
410
435
|
const data = await res.json();
|
|
411
|
-
|
|
412
436
|
allModels = data.models || [];
|
|
413
437
|
groupedModels = data.grouped || {};
|
|
414
|
-
|
|
415
438
|
renderProviders();
|
|
416
439
|
renderModels(allModels);
|
|
417
440
|
updateStats();
|
|
418
441
|
}
|
|
419
|
-
|
|
442
|
+
|
|
420
443
|
function renderProviders() {
|
|
421
444
|
const container = document.getElementById('providerList');
|
|
422
|
-
const
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
</div
|
|
428
|
-
|
|
429
|
-
|
|
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>\`;
|
|
445
|
+
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
|
|
446
|
+
let providersToShow = Object.entries(groupedModels);
|
|
447
|
+
if (searchTerm) {
|
|
448
|
+
providersToShow = providersToShow.filter(([name, models]) => name.toLowerCase().includes(searchTerm) || models.some(m => m.name.toLowerCase().includes(searchTerm)));
|
|
449
|
+
}
|
|
450
|
+
let html = '<div onclick="selectProvider('all')" class="px-3 py-2.5 rounded-xl 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 Models</span><span class="text-xs opacity-60 bg-zinc-800 px-2 py-0.5 rounded-full">' + allModels.length + '</span></div>';
|
|
451
|
+
for (const [provider, models] of providersToShow) {
|
|
452
|
+
html += '<div onclick="selectProvider(\\'' + provider.replace(/'/g, "\\\\'") + '\\')" class="px-3 py-2.5 rounded-xl 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 bg-zinc-800 px-2 py-0.5 rounded-full">' + models.length + '</span></div>';
|
|
434
453
|
}
|
|
435
|
-
|
|
436
454
|
container.innerHTML = html;
|
|
437
455
|
}
|
|
438
|
-
|
|
456
|
+
|
|
439
457
|
function selectProvider(provider) {
|
|
440
458
|
currentProvider = provider;
|
|
459
|
+
document.getElementById('sectionTitle').textContent = provider === 'all' ? 'All Models' : provider;
|
|
441
460
|
renderProviders();
|
|
442
|
-
|
|
443
|
-
const models = provider === 'all' ? allModels : groupedModels[provider] || [];
|
|
444
|
-
document.getElementById('modelListHeader').textContent = provider === 'all' ? 'All Models' : provider;
|
|
445
|
-
renderModels(models);
|
|
461
|
+
renderModels(provider === 'all' ? allModels : (groupedModels[provider] || []));
|
|
446
462
|
}
|
|
447
|
-
|
|
463
|
+
|
|
448
464
|
function renderModels(models) {
|
|
449
|
-
const container = document.getElementById('
|
|
465
|
+
const container = document.getElementById('modelGrid');
|
|
450
466
|
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
|
|
451
|
-
|
|
452
467
|
let filtered = models;
|
|
453
468
|
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
|
-
);
|
|
469
|
+
filtered = models.filter(m => m.name.toLowerCase().includes(searchTerm) || m.id.toLowerCase().includes(searchTerm));
|
|
459
470
|
}
|
|
460
|
-
|
|
471
|
+
document.getElementById('modelCount').textContent = filtered.length + ' models';
|
|
461
472
|
if (filtered.length === 0) {
|
|
462
|
-
container.innerHTML = '<div
|
|
473
|
+
container.innerHTML = '<div class="col-span-full text-center text-zinc-500 py-12">No models found</div>';
|
|
463
474
|
return;
|
|
464
475
|
}
|
|
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;
|
|
476
|
+
container.innerHTML = filtered.slice(0, 100).map(m => '<div onclick="selectModel(\\'' + m.id.replace(/'/g, "\\\\'") + '\\')" class="model-card p-5 rounded-2xl border cursor-pointer transition-all ' + (m.id === currentModelId ? 'bg-indigo-600/20 border-indigo-500 shadow-lg shadow-indigo-500/20' : 'bg-[#18181b] border-zinc-800 hover:border-zinc-700') + '"><div class="flex items-start justify-between mb-3"><h3 class="font-semibold text-white truncate text-base">' + m.name + '</h3>' + (m.id === currentModelId ? '<span class="text-xs bg-indigo-500 text-white px-3 py-1 rounded-full font-medium">Active</span>' : '') + '</div><p class="text-xs text-zinc-500 font-mono truncate mb-3">' + m.id + '</p>' + (m.cost ? '<div class="flex items-center gap-2 text-xs text-zinc-400"><span class="bg-zinc-800 px-2 py-1 rounded">$' + m.cost.input + '/M</span><span>→</span><span class="bg-zinc-800 px-2 py-1 rounded">$' + m.cost.output + '/M</span></div>' : '<div class="text-xs text-zinc-600">Free</div>') + '</div>').join('');
|
|
481
477
|
}
|
|
482
|
-
|
|
483
|
-
function
|
|
484
|
-
const
|
|
485
|
-
|
|
478
|
+
|
|
479
|
+
function filterData() {
|
|
480
|
+
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
|
|
481
|
+
if (searchTerm) {
|
|
482
|
+
const filtered = allModels.filter(m => m.name.toLowerCase().includes(searchTerm) || m.id.toLowerCase().includes(searchTerm));
|
|
483
|
+
document.getElementById('sectionTitle').textContent = 'Search Results (' + filtered.length + ')';
|
|
484
|
+
renderModels(filtered);
|
|
485
|
+
renderProviders();
|
|
486
|
+
} else {
|
|
487
|
+
selectProvider(currentProvider);
|
|
488
|
+
}
|
|
486
489
|
}
|
|
487
|
-
|
|
490
|
+
|
|
488
491
|
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
|
-
});
|
|
492
|
+
const res = await fetch('/api/model', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ modelId }) });
|
|
494
493
|
const data = await res.json();
|
|
495
494
|
if (data.success) {
|
|
496
495
|
currentModelId = modelId;
|
|
497
496
|
loadStatus();
|
|
498
|
-
|
|
499
|
-
|
|
497
|
+
renderModels(currentProvider === 'all' ? allModels : (groupedModels[currentProvider] || []));
|
|
498
|
+
document.getElementById('configJson').textContent = JSON.stringify({ env: { ANTHROPIC_BASE_URL: "http://localhost:8300/v1", ANTHROPIC_AUTH_TOKEN: "test", ANTHROPIC_MODEL: modelId } }, null, 2);
|
|
500
499
|
}
|
|
501
500
|
}
|
|
502
|
-
|
|
501
|
+
|
|
503
502
|
async function loadStatus() {
|
|
504
503
|
const res = await fetch('/api/status');
|
|
505
504
|
const data = await res.json();
|
|
506
|
-
|
|
507
505
|
const model = allModels.find(m => m.id === data.currentModel);
|
|
508
|
-
document.getElementById('
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
if (data.sessionId === 'active') {
|
|
512
|
-
statusDot.classList.remove('inactive');
|
|
513
|
-
} else {
|
|
514
|
-
statusDot.classList.add('inactive');
|
|
515
|
-
}
|
|
516
|
-
|
|
506
|
+
document.getElementById('currentModelDisplay').textContent = model?.name || data.currentModel;
|
|
507
|
+
document.getElementById('healthDot').className = 'w-2.5 h-2.5 rounded-full animate-pulse ' + (data.sessionId === 'active' ? 'bg-green-500' : 'bg-red-500');
|
|
508
|
+
document.getElementById('connectionStatus').textContent = data.sessionId === 'active' ? 'Connected' : 'Disconnected';
|
|
517
509
|
document.getElementById('totalRequests').textContent = data.totalRequests.toLocaleString();
|
|
518
510
|
document.getElementById('totalTokens').textContent = data.totalTokensUsed.toLocaleString();
|
|
519
|
-
document.getElementById('
|
|
520
|
-
|
|
511
|
+
document.getElementById('sessionStatus').textContent = data.sessionId === 'active' ? 'Active' : 'Inactive';
|
|
512
|
+
document.getElementById('sessionStatus').className = 'text-lg font-semibold ' + (data.sessionId === 'active' ? 'text-green-400' : 'text-red-400');
|
|
513
|
+
document.getElementById('activeModelName').textContent = model?.name || data.currentModel;
|
|
514
|
+
document.getElementById('totalModelsDisplay').textContent = allModels.length + ' models';
|
|
515
|
+
document.getElementById('totalProviders').textContent = Object.keys(groupedModels).length + ' providers';
|
|
521
516
|
currentModelId = data.currentModel;
|
|
522
517
|
}
|
|
523
|
-
|
|
518
|
+
|
|
524
519
|
async function resetSession() {
|
|
525
520
|
await fetch('/api/reset-session', { method: 'POST' });
|
|
526
521
|
loadStatus();
|
|
527
522
|
}
|
|
528
|
-
|
|
529
|
-
async function
|
|
530
|
-
await fetch('/api/
|
|
531
|
-
|
|
523
|
+
|
|
524
|
+
async function refreshModels() {
|
|
525
|
+
await fetch('/api/refresh-models', { method: 'POST' });
|
|
526
|
+
loadModels();
|
|
532
527
|
}
|
|
533
|
-
|
|
534
|
-
|
|
528
|
+
|
|
529
|
+
function updateStats() {
|
|
530
|
+
document.getElementById('totalModelsDisplay').textContent = allModels.length + ' models from ' + Object.keys(groupedModels).length + ' providers';
|
|
531
|
+
}
|
|
532
|
+
|
|
535
533
|
loadModels();
|
|
536
534
|
loadStatus();
|
|
537
535
|
setInterval(loadStatus, 3000);
|
|
@@ -540,7 +538,7 @@ function generateHTML(): string {
|
|
|
540
538
|
</html>`;
|
|
541
539
|
}
|
|
542
540
|
|
|
543
|
-
app.listen(
|
|
544
|
-
console.error(`OpenCode Bridge running on http://localhost:${
|
|
545
|
-
console.error(`Dashboard: http://localhost:${
|
|
541
|
+
app.listen(PORT, () => {
|
|
542
|
+
console.error(`OpenCode Bridge running on http://localhost:${PORT}`);
|
|
543
|
+
console.error(`Dashboard: http://localhost:${PORT}`);
|
|
546
544
|
});
|