<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Admin</title>
<script src="https://unpkg.com/htmx.org@2.0.8"></script>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/franken-ui@2.1.1/dist/css/core.min.css"
/>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<style type="text/tailwindcss">
* {
font-family:
"Pretendard",
-apple-system,
BlinkMacSystemFont,
system-ui,
Roboto,
sans-serif;
}
body {
background: #f9fafb;
color: #191f28;
}
.tab-active {
color: #3182f6;
font-weight: 600;
}
.btn-primary {
background: #3182f6;
color: white;
padding: 16px 24px;
border-radius: 12px;
font-weight: 600;
font-size: 17px;
border: none;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary:hover {
background: #1b64da;
}
.btn-secondary {
background: #f2f4f6;
color: #4e5968;
padding: 16px 24px;
border-radius: 12px;
font-weight: 600;
font-size: 17px;
border: none;
cursor: pointer;
transition: all 0.2s;
}
.btn-secondary:hover {
background: #e5e8eb;
}
.card {
background: white;
border-radius: 16px;
padding: 32px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.input-field {
width: 100%;
padding: 16px 20px;
border: 1.5px solid #e5e8eb;
border-radius: 12px;
font-size: 16px;
transition: all 0.2s;
}
.input-field:focus {
outline: none;
border-color: #3182f6;
}
.label {
font-size: 15px;
font-weight: 600;
color: #333d4b;
margin-bottom: 8px;
display: block;
}
.helper-text {
font-size: 14px;
color: #6b7684;
margin-top: 6px;
}
/* Fade out animation for deletions */
@keyframes fadeOut {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(-20px);
}
}
.fade-out {
animation: fadeOut 0.3s ease-out forwards;
}
/* Fade in animation for new items */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-in {
animation: fadeIn 0.3s ease-out;
}
</style>
</head>
<body>
<div class="flex min-h-screen">
<aside
class="w-64 bg-white border-r border-gray-200 flex flex-col h-screen sticky top-0"
>
<div class="px-6 py-8">
<div class="mb-12">
<h1 class="text-2xl font-bold">Claude Code Mux</h1>
<p class="text-sm text-gray-500 mt-1">Admin</p>
</div>
<nav class="space-y-2">
<button
onclick="showTab('overview')"
id="tab-overview"
class="tab-active w-full text-left px-4 py-3 rounded-lg hover:bg-gray-50 transition-colors text-[15px]"
>
Overview
</button>
<button
onclick="showTab('models')"
id="tab-models"
class="w-full text-left px-4 py-3 rounded-lg hover:bg-gray-50 transition-colors text-[15px] text-gray-600"
>
Models
</button>
<button
onclick="showTab('providers')"
id="tab-providers"
class="w-full text-left px-4 py-3 rounded-lg hover:bg-gray-50 transition-colors text-[15px] text-gray-600"
>
Providers
</button>
<button
onclick="showTab('router')"
id="tab-router"
class="w-full text-left px-4 py-3 rounded-lg hover:bg-gray-50 transition-colors text-[15px] text-gray-600"
>
Router
</button>
<button
onclick="showTab('settings')"
id="tab-settings"
class="w-full text-left px-4 py-3 rounded-lg hover:bg-gray-50 transition-colors text-[15px] text-gray-600"
>
Settings
</button>
<button
onclick="showTab('test')"
id="tab-test"
class="w-full text-left px-4 py-3 rounded-lg hover:bg-gray-50 transition-colors text-[15px] text-gray-600"
>
Test
</button>
</nav>
</div>
<div class="mt-auto border-t border-gray-200 p-6 space-y-3">
<button
onclick="saveAllConfig()"
class="w-full btn-primary text-sm py-3"
>
💾 Save to Server
</button>
<button
onclick="saveAndRestart()"
class="w-full btn-secondary text-sm py-3 flex items-center justify-center gap-2"
>
<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="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"
/>
</svg>
<span>Save & Restart</span>
</button>
<div class="text-xs text-gray-500 text-center pt-2">
Last saved: <span id="last-saved">-</span>
</div>
</div>
</aside>
<main class="flex-1 p-12 max-w-5xl">
<div id="content-overview" class="tab-content">
<h1 class="text-4xl font-bold mb-3">Routing Status</h1>
<p class="text-gray-600 text-lg mb-12">
Monitor your AI routing configuration
</p>
<div class="grid grid-cols-3 gap-6 mb-12">
<div class="card">
<div class="text-gray-600 text-sm mb-2">
Providers
</div>
<div class="text-4xl font-bold" id="provider-count">
0
</div>
</div>
<div class="card">
<div class="text-gray-600 text-sm mb-2">Models</div>
<div
class="text-4xl font-bold"
id="model-count-overview"
>
0
</div>
</div>
<div class="card">
<div class="text-gray-600 text-sm mb-2">Status</div>
<div class="text-4xl font-bold text-blue-600">
Active
</div>
</div>
</div>
<div class="card mb-6">
<h2 class="text-2xl font-bold mb-6">
Router Configuration
</h2>
<div class="space-y-4" id="router-status">
<div
class="flex justify-between items-center py-3 border-b"
>
<span class="text-gray-600">Default Model</span>
<span class="font-semibold" id="current-default"
>-</span
>
</div>
<div
class="flex justify-between items-center py-3 border-b"
>
<span class="text-gray-600">Think Model</span>
<span class="font-semibold" id="current-think"
>-</span
>
</div>
<div
class="flex justify-between items-center py-3 border-b"
>
<span class="text-gray-600"
>Background Model</span
>
<span
class="font-semibold"
id="current-background"
>-</span
>
</div>
<div class="flex justify-between items-center py-3">
<span class="text-gray-600"
>WebSearch Model</span
>
<span
class="font-semibold"
id="current-websearch"
>-</span
>
</div>
</div>
</div>
<div class="card">
<h2 class="text-2xl font-bold mb-6">Server Info</h2>
<div class="space-y-4">
<div class="flex justify-between items-center py-3">
<span class="text-gray-600">Address</span>
<code
class="font-mono text-sm"
id="server-address"
>-</code
>
</div>
</div>
</div>
<div class="card mt-6">
<h2 class="text-2xl font-bold mb-6">API Endpoints</h2>
<div class="space-y-3">
<div
class="p-4 rounded-lg bg-gray-50 border border-gray-200"
>
<div class="flex items-center gap-2 mb-2">
<span
class="px-2 py-1 rounded bg-blue-100 text-blue-700 text-xs font-bold"
>POST</span
>
<code class="text-sm font-mono"
>/v1/messages</code
>
</div>
<p class="text-xs text-gray-600">
Send messages with automatic model routing
</p>
</div>
<div
class="p-4 rounded-lg bg-gray-50 border border-gray-200"
>
<div class="flex items-center gap-2 mb-2">
<span
class="px-2 py-1 rounded bg-blue-100 text-blue-700 text-xs font-bold"
>POST</span
>
<code class="text-sm font-mono"
>/v1/messages/count_tokens</code
>
</div>
<p class="text-xs text-gray-600">
Count tokens for messages
</p>
</div>
<div
class="p-4 rounded-lg bg-gray-50 border border-gray-200"
>
<div class="flex items-center gap-2 mb-2">
<span
class="px-2 py-1 rounded bg-green-100 text-green-700 text-xs font-bold"
>GET</span
>
<code class="text-sm font-mono"
>/api/models-config</code
>
</div>
<p class="text-xs text-gray-600">
Get models configuration (name, provider
mappings)
</p>
</div>
<div
class="p-4 rounded-lg bg-gray-50 border border-gray-200"
>
<div class="flex items-center gap-2 mb-2">
<span
class="px-2 py-1 rounded bg-green-100 text-green-700 text-xs font-bold"
>GET</span
>
<code class="text-sm font-mono"
>/api/providers</code
>
</div>
<p class="text-xs text-gray-600">
Get providers configuration (name, type, API
keys)
</p>
</div>
<div
class="p-4 rounded-lg bg-gray-50 border border-gray-200"
>
<div class="flex items-center gap-2 mb-2">
<span
class="px-2 py-1 rounded bg-green-100 text-green-700 text-xs font-bold"
>GET</span
>
<code class="text-sm font-mono"
>/api/config</code
>
</div>
<p class="text-xs text-gray-600">
Get router configuration (default, think,
background models)
</p>
</div>
<div
class="p-4 rounded-lg bg-gray-50 border border-gray-200"
>
<div class="flex items-center gap-2 mb-2">
<span
class="px-2 py-1 rounded bg-green-100 text-green-700 text-xs font-bold"
>GET</span
>
<code class="text-sm font-mono"
>/api/config/json</code
>
</div>
<p class="text-xs text-gray-600">
Get full configuration (JSON format)
</p>
</div>
<div
class="p-4 rounded-lg bg-gray-50 border border-gray-200"
>
<div class="flex items-center gap-2 mb-2">
<span
class="px-2 py-1 rounded bg-purple-100 text-purple-700 text-xs font-bold"
>POST</span
>
<code class="text-sm font-mono"
>/api/config/json</code
>
</div>
<p class="text-xs text-gray-600">
Update full configuration (JSON format)
</p>
</div>
<div
class="p-4 rounded-lg bg-gray-50 border border-gray-200"
>
<div class="flex items-center gap-2 mb-2">
<span
class="px-2 py-1 rounded bg-red-100 text-red-700 text-xs font-bold"
>POST</span
>
<code class="text-sm font-mono"
>/api/restart</code
>
</div>
<p class="text-xs text-gray-600">
Restart the server to apply configuration
changes
</p>
</div>
</div>
</div>
</div>
<div id="content-providers" class="tab-content hidden">
<div id="providers-list-view">
<h1 class="text-4xl font-bold mb-3">Providers</h1>
<p class="text-gray-600 text-lg mb-12">
Connect and manage AI providers
</p>
<button
onclick="showAddProvider()"
class="btn-primary mb-8"
>
Add Provider
</button>
<div id="providers-list" class="space-y-4">
<div class="card hidden" id="provider-card-example">
<div class="flex items-start justify-between">
<div class="flex-1">
<div
class="flex items-center gap-3 mb-2"
>
<h3 class="text-xl font-bold">
Anthropic
</h3>
<span
class="px-3 py-1 bg-blue-50 text-blue-600 rounded-full text-sm font-semibold"
>Active</span
>
</div>
<p class="text-gray-600 mb-4">
anthropic-native
</p>
<div class="text-sm text-gray-500">
3 models • API key configured
</div>
</div>
<div class="flex gap-2">
<button class="btn-secondary">
Edit
</button>
<button
class="btn-secondary text-red-600"
>
Delete
</button>
</div>
</div>
</div>
<div
class="card text-center py-16"
id="empty-providers"
>
<div class="text-6xl mb-4">🔌</div>
<h3 class="text-2xl font-bold mb-2">
No providers configured
</h3>
<p class="text-gray-600 mb-6">
Add a provider to use more AI models
</p>
<button
onclick="showAddProvider()"
class="btn-primary"
>
Add your first Provider
</button>
</div>
</div>
</div>
<div id="providers-add-view" class="hidden">
<button
onclick="showProvidersList()"
class="text-blue-600 font-semibold mb-8 hover:underline"
>
← Back to Providers
</button>
<h1 class="text-4xl font-bold mb-3">Provider Add</h1>
<p class="text-gray-600 text-lg mb-12">
Enter your API key to start using
</p>
<form id="add-provider-form" class="space-y-8">
<div class="card">
<h2 class="text-2xl font-bold mb-8">
Which provider do you want to add?
</h2>
<div class="grid grid-cols-3 gap-4">
<label class="cursor-pointer">
<input
type="radio"
name="provider_type"
value="anthropic"
class="peer sr-only"
required
/>
<div
class="p-6 border-2 border-gray-200 rounded-xl peer-checked:border-blue-600 peer-checked:bg-blue-50 hover:border-gray-300 transition-all"
>
<div class="text-xl font-bold mb-1">
Anthropic
</div>
<div class="text-sm text-gray-600">
Claude models
</div>
</div>
</label>
<label class="cursor-pointer">
<input
type="radio"
name="provider_type"
value="z.ai"
class="peer sr-only"
/>
<div
class="p-6 border-2 border-gray-200 rounded-xl peer-checked:border-blue-600 peer-checked:bg-blue-50 hover:border-gray-300 transition-all"
>
<div class="text-xl font-bold mb-1">
z.ai
</div>
<div class="text-sm text-gray-600">
GLM models
</div>
</div>
</label>
<label class="cursor-pointer">
<input
type="radio"
name="provider_type"
value="zenmux"
class="peer sr-only"
/>
<div
class="p-6 border-2 border-gray-200 rounded-xl peer-checked:border-blue-600 peer-checked:bg-blue-50 hover:border-gray-300 transition-all"
>
<div class="text-xl font-bold mb-1">
ZenMux
</div>
<div class="text-sm text-gray-600">
Unified API gateway
</div>
</div>
</label>
<label class="cursor-pointer">
<input
type="radio"
name="provider_type"
value="minimax"
class="peer sr-only"
/>
<div
class="p-6 border-2 border-gray-200 rounded-xl peer-checked:border-blue-600 peer-checked:bg-blue-50 hover:border-gray-300 transition-all"
>
<div class="text-xl font-bold mb-1">
Minimax
</div>
<div class="text-sm text-gray-600">
MiniMax models
</div>
</div>
</label>
<label class="cursor-pointer">
<input
type="radio"
name="provider_type"
value="kimi-coding"
class="peer sr-only"
/>
<div
class="p-6 border-2 border-gray-200 rounded-xl peer-checked:border-blue-600 peer-checked:bg-blue-50 hover:border-gray-300 transition-all"
>
<div class="text-xl font-bold mb-1">
Kimi For Coding
</div>
<div class="text-sm text-gray-600">
Premium membership for Kimi
</div>
</div>
</label>
<label class="cursor-pointer">
<input
type="radio"
name="provider_type"
value="openai"
class="peer sr-only"
/>
<div
class="p-6 border-2 border-gray-200 rounded-xl peer-checked:border-blue-600 peer-checked:bg-blue-50 hover:border-gray-300 transition-all"
>
<div class="text-xl font-bold mb-1">
OpenAI
</div>
<div class="text-sm text-gray-600">
GPT models
</div>
</div>
</label>
<label class="cursor-pointer">
<input
type="radio"
name="provider_type"
value="openrouter"
class="peer sr-only"
/>
<div
class="p-6 border-2 border-gray-200 rounded-xl peer-checked:border-blue-600 peer-checked:bg-blue-50 hover:border-gray-300 transition-all"
>
<div class="text-xl font-bold mb-1">
OpenRouter
</div>
<div class="text-sm text-gray-600">
500+ models
</div>
</div>
</label>
<label class="cursor-pointer">
<input
type="radio"
name="provider_type"
value="groq"
class="peer sr-only"
/>
<div
class="p-6 border-2 border-gray-200 rounded-xl peer-checked:border-blue-600 peer-checked:bg-blue-50 hover:border-gray-300 transition-all"
>
<div class="text-xl font-bold mb-1">
Groq
</div>
<div class="text-sm text-gray-600">
Ultra-fast ⚡
</div>
</div>
</label>
<label class="cursor-pointer">
<input
type="radio"
name="provider_type"
value="together"
class="peer sr-only"
/>
<div
class="p-6 border-2 border-gray-200 rounded-xl peer-checked:border-blue-600 peer-checked:bg-blue-50 hover:border-gray-300 transition-all"
>
<div class="text-xl font-bold mb-1">
Together AI
</div>
<div class="text-sm text-gray-600">
Open source
</div>
</div>
</label>
<label class="cursor-pointer">
<input
type="radio"
name="provider_type"
value="fireworks"
class="peer sr-only"
/>
<div
class="p-6 border-2 border-gray-200 rounded-xl peer-checked:border-blue-600 peer-checked:bg-blue-50 hover:border-gray-300 transition-all"
>
<div class="text-xl font-bold mb-1">
Fireworks AI
</div>
<div class="text-sm text-gray-600">
Fast inference
</div>
</div>
</label>
<label class="cursor-pointer">
<input
type="radio"
name="provider_type"
value="deepinfra"
class="peer sr-only"
/>
<div
class="p-6 border-2 border-gray-200 rounded-xl peer-checked:border-blue-600 peer-checked:bg-blue-50 hover:border-gray-300 transition-all"
>
<div class="text-xl font-bold mb-1">
Deepinfra
</div>
<div class="text-sm text-gray-600">
Cost-effective
</div>
</div>
</label>
<label class="cursor-pointer">
<input
type="radio"
name="provider_type"
value="cerebras"
class="peer sr-only"
/>
<div
class="p-6 border-2 border-gray-200 rounded-xl peer-checked:border-blue-600 peer-checked:bg-blue-50 hover:border-gray-300 transition-all"
>
<div class="text-xl font-bold mb-1">
Cerebras
</div>
<div class="text-sm text-gray-600">
Ultra-fast ⚡
</div>
</div>
</label>
<label class="cursor-pointer">
<input
type="radio"
name="provider_type"
value="nebius"
class="peer sr-only"
/>
<div
class="p-6 border-2 border-gray-200 rounded-xl peer-checked:border-blue-600 peer-checked:bg-blue-50 hover:border-gray-300 transition-all"
>
<div class="text-xl font-bold mb-1">
Nebius
</div>
<div class="text-sm text-gray-600">
AI platform
</div>
</div>
</label>
<label class="cursor-pointer">
<input
type="radio"
name="provider_type"
value="moonshot"
class="peer sr-only"
/>
<div
class="p-6 border-2 border-gray-200 rounded-xl peer-checked:border-blue-600 peer-checked:bg-blue-50 hover:border-gray-300 transition-all"
>
<div class="text-xl font-bold mb-1">
Moonshot AI
</div>
<div class="text-sm text-gray-600">
Kimi models
</div>
</div>
</label>
<label class="cursor-pointer">
<input
type="radio"
name="provider_type"
value="novita"
class="peer sr-only"
/>
<div
class="p-6 border-2 border-gray-200 rounded-xl peer-checked:border-blue-600 peer-checked:bg-blue-50 hover:border-gray-300 transition-all"
>
<div class="text-xl font-bold mb-1">
NovitaAI
</div>
<div class="text-sm text-gray-600">
GPU cloud
</div>
</div>
</label>
<label class="cursor-pointer">
<input
type="radio"
name="provider_type"
value="baseten"
class="peer sr-only"
/>
<div
class="p-6 border-2 border-gray-200 rounded-xl peer-checked:border-blue-600 peer-checked:bg-blue-50 hover:border-gray-300 transition-all"
>
<div class="text-xl font-bold mb-1">
Baseten
</div>
<div class="text-sm text-gray-600">
ML deployment
</div>
</div>
</label>
</div>
</div>
<div class="card">
<h2 class="text-2xl font-bold mb-8">
Choose a name for this provider
</h2>
<div>
<label class="label">Name</label>
<input
type="text"
name="provider_name"
class="input-field"
placeholder="e.g., anthropic-main"
required
/>
<div class="helper-text">
A name to identify this provider
</div>
</div>
</div>
<div class="card">
<h2 class="text-2xl font-bold mb-8">
Choose authentication method
</h2>
<div class="mb-8">
<div class="grid grid-cols-2 gap-4">
<label class="cursor-pointer">
<input
type="radio"
name="auth_type"
value="apikey"
class="peer sr-only"
checked
onchange="toggleAuthMethod()"
/>
<div
class="p-6 border-2 border-gray-200 rounded-xl peer-checked:border-blue-600 peer-checked:bg-blue-50 hover:border-gray-300 transition-all"
>
<div class="text-lg font-bold mb-1">
API Key
</div>
<div class="text-sm text-gray-600">
Use your API key for authentication
</div>
</div>
</label>
<label class="cursor-pointer">
<input
type="radio"
name="auth_type"
value="oauth"
class="peer sr-only"
onchange="toggleAuthMethod()"
/>
<div
class="p-6 border-2 border-gray-200 rounded-xl peer-checked:border-blue-600 peer-checked:bg-blue-50 hover:border-gray-300 transition-all"
>
<div id="oauth-label-title" class="text-lg font-bold mb-1">
OAuth (Claude Pro/Max)
</div>
<div id="oauth-label-description" class="text-sm text-gray-600">
Free for Claude Max subscribers
</div>
</div>
</label>
</div>
</div>
<div id="api-key-section">
<label class="label">API Key</label>
<input
type="password"
id="api-key-input"
name="api_key"
class="input-field font-mono"
placeholder="sk-ant-..."
/>
<div class="helper-text">
Your API key will be stored securely
</div>
</div>
<div id="oauth-section" class="hidden">
<div class="bg-purple-50 border-2 border-purple-200 rounded-xl p-6 mb-4">
<div class="flex items-start gap-4">
<div class="text-3xl">🔐</div>
<div class="flex-1">
<h3 class="font-bold text-lg mb-2">OAuth Authentication</h3>
<div id="oauth-step-1">
<p id="oauth-step1-instruction" class="text-sm text-gray-700 mb-4">
Click the button below to authenticate with your Claude Pro/Max account.
</p>
<button
type="button"
onclick="startOAuthFlow()"
class="btn-primary"
>
🔐 Start OAuth Login
</button>
</div>
<div id="oauth-step-2" class="hidden">
<div class="bg-blue-50 border-2 border-blue-200 rounded-lg p-4 mb-4">
<p class="text-sm text-blue-900 mb-2 font-semibold">
✓ Authorization window opened
</p>
<ol id="oauth-step2-instructions" class="text-sm text-blue-800 space-y-1 ml-4 list-decimal">
<li>Log in to your Claude Pro/Max account</li>
<li>Click "Allow" to authorize</li>
<li>Copy the authorization code</li>
<li>Paste it in the field below</li>
</ol>
</div>
<label class="label">Authorization Code</label>
<input
type="text"
id="oauth-code-input"
class="input-field font-mono"
placeholder="Paste the code here..."
/>
<div class="flex gap-2 mt-4">
<button
type="button"
onclick="completeOAuthFlow()"
class="btn-primary flex-1"
>
Complete Authentication
</button>
<button
type="button"
onclick="cancelOAuthFlow()"
class="btn-secondary"
>
Cancel
</button>
</div>
</div>
<div id="oauth-step-3" class="hidden">
<div class="flex items-center gap-2 text-green-600">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
<span class="font-semibold">OAuth token saved!</span>
</div>
</div>
</div>
</div>
</div>
<div class="helper-text">
For Claude Pro/Max subscribers only. Requires active subscription.
</div>
</div>
</div>
<div class="card">
<h2 class="text-2xl font-bold mb-4">
Add Settings
</h2>
<p class="text-gray-600 mb-8">
Optional - only if needed
</p>
<div>
<label class="label"
>Custom Endpoint (Optional)</label
>
<input
type="url"
name="base_url"
class="input-field font-mono"
placeholder="https://api.anthropic.com"
/>
<div class="helper-text">
Only enter if you want to use a
different URL than the default endpoint
</div>
</div>
</div>
<div class="flex gap-4">
<button
type="button"
onclick="showProvidersList()"
class="btn-secondary flex-1"
>
Cancel
</button>
<button
type="submit"
class="btn-primary flex-1"
>
Add Provider
</button>
</div>
</form>
</div>
</div>
<div id="content-models" class="tab-content hidden">
<div id="models-list-view">
<h1 class="text-4xl font-bold mb-3">Models</h1>
<p class="text-gray-600 text-lg mb-12">
Improve stability with multiple providers
</p>
<button
onclick="showAddModel()"
class="btn-primary mb-8"
>
Add Model
</button>
<div id="models-list" class="space-y-4">
<div class="card hidden" id="model-card-example">
<div class="flex items-start justify-between">
<div class="flex-1">
<h3 class="text-xl font-bold mb-4">
claude-sonnet-4-5
</h3>
<div class="space-y-2">
<div
class="flex items-center gap-2 text-sm"
>
<span
class="px-2 py-1 bg-blue-50 text-blue-600 rounded-lg font-semibold"
>Priority 1</span
>
<span class="text-gray-600"
>anthropic/claude-sonnet-4-5</span
>
</div>
<div
class="flex items-center gap-2 text-sm"
>
<span
class="px-2 py-1 bg-gray-100 text-gray-600 rounded-lg font-semibold"
>Priority 2</span
>
<span class="text-gray-500"
>openrouter/anthropic/claude-sonnet-4-5</span
>
</div>
</div>
</div>
<div class="flex gap-2">
<button class="btn-secondary">
Edit
</button>
<button
class="btn-secondary text-red-600"
>
Delete
</button>
</div>
</div>
</div>
<div
class="card text-center py-16"
id="empty-models"
>
<div class="text-6xl mb-4">🎯</div>
<h3 class="text-2xl font-bold mb-2">
No models configured
</h3>
<p class="text-gray-600 mb-6">
Add a model to make it available via API
</p>
<button
onclick="showAddModel()"
class="btn-primary"
>
Add First Model
</button>
</div>
</div>
</div>
<div id="models-add-view" class="hidden">
<button
onclick="showModelsList()"
class="text-blue-600 font-semibold mb-8 hover:underline"
>
← Back to Models
</button>
<h1 class="text-4xl font-bold mb-3">Add Model</h1>
<p class="text-gray-600 text-lg mb-12">
Configure multiple providers to increase reliability
</p>
<form id="add-model-form" class="space-y-8">
<div class="card">
<h2 class="text-2xl font-bold mb-8">
Choose a name for this model
</h2>
<div>
<label class="label">Model Name</label>
<input
type="text"
name="model_name"
class="input-field font-mono"
placeholder="e.g., claude-sonnet-4-5"
required
/>
<div class="helper-text">
This name will be used in API requests
</div>
</div>
</div>
<div class="card">
<h2 class="text-2xl font-bold mb-4">
Map providers to actual models
</h2>
<p class="text-gray-600 mb-8">
Tries providers in order, automatically
fails over to the next one Auto-switching
enabled
</p>
<div id="provider-mappings" class="space-y-4">
<div
class="provider-mapping border-2 border-blue-200 bg-blue-50 rounded-xl p-6"
>
<div
class="flex items-center justify-between mb-4"
>
<div
class="flex items-center gap-3"
>
<span
class="px-3 py-1 bg-blue-600 text-white rounded-lg font-bold text-sm"
>Priority 1</span
>
<span
class="text-blue-600 font-semibold"
>Primary</span
>
</div>
</div>
<div class="space-y-4">
<div>
<label class="label"
>Select Provider</label
>
<select
name="mappings[0][provider]"
class="input-field"
required
>
<option value="">
Choose a provider
</option>
<option value="anthropic">
Anthropic
</option>
<option value="openrouter">
OpenRouter
</option>
<option value="openai">
OpenAI
</option>
</select>
</div>
<div>
<label class="label"
>Model Name</label
>
<input
type="text"
name="mappings[0][actual_model]"
class="input-field font-mono"
placeholder="e.g., claude-sonnet-4-5 or anthropic/claude-sonnet-4-5"
required
/>
<div class="helper-text">
Actual model name used by
the provider Model ID
</div>
</div>
</div>
</div>
</div>
<button
type="button"
onclick="addProviderMapping()"
class="mt-4 w-full py-3 border-2 border-dashed border-gray-300 rounded-xl text-gray-600 font-semibold hover:border-blue-400 hover:text-blue-600 transition-colors"
>
+ Fallback Provider Add
</button>
</div>
<div class="flex gap-4">
<button
type="button"
onclick="showModelsList()"
class="btn-secondary flex-1"
>
Cancel
</button>
<button
type="submit"
class="btn-primary flex-1"
>
Add Model
</button>
</div>
</form>
</div>
</div>
<div id="content-router" class="tab-content hidden">
<h1 class="text-4xl font-bold mb-3">
Router Configuration
</h1>
<p class="text-gray-600 text-lg mb-12">
Configure different models for different scenarios
</p>
<form id="router-form" class="space-y-6">
<div class="card">
<h2 class="text-xl font-bold mb-6">
Default Model
</h2>
<p class="text-gray-600 mb-6">
Model used for most requests
</p>
<select
name="default_model"
class="input-field"
required
>
<option value="">Select a model</option>
</select>
</div>
<div class="card">
<h2 class="text-xl font-bold mb-6">Think Model</h2>
<p class="text-gray-600 mb-6">
Model used for complex reasoning tasks
</p>
<select name="think_model" class="input-field">
<option value="">Not configured</option>
</select>
</div>
<div class="card">
<h2 class="text-xl font-bold mb-6">
Background Model
</h2>
<p class="text-gray-600 mb-6">
Fast model used for simple tasks
</p>
<select name="background_model" class="input-field">
<option value="">Not configured</option>
</select>
</div>
<div class="card">
<h2 class="text-xl font-bold mb-6">
WebSearch Model
</h2>
<p class="text-gray-600 mb-6">
Model used when web search is needed
</p>
<select name="websearch_model" class="input-field">
<option value="">Not configured</option>
</select>
</div>
</form>
</div>
<div id="content-settings" class="tab-content hidden">
<h1 class="text-4xl font-bold mb-3">Settings</h1>
<p class="text-gray-600 text-lg mb-12">
Manage router regex patterns and server settings
</p>
<form id="settings-form" class="space-y-6">
<div class="card">
<h2 class="text-xl font-bold mb-4">
Auto-mapping Pattern
</h2>
<div class="space-y-4">
<div>
<label
class="block text-sm font-semibold text-gray-700 mb-3"
>
Auto-map Regex
<span class="text-gray-500 font-normal"
>(e.g., ^claude- to match all Claude
models)</span
>
</label>
<input
type="text"
id="auto-map-regex"
class="input-field font-mono"
placeholder="^claude-"
/>
<p class="text-sm text-gray-500 mt-2">
Models matching this regex will be
routed through
WebSearch/Think/Background logic. Leave
empty to use default (^claude-).
</p>
</div>
</div>
</div>
<div class="card">
<h2 class="text-xl font-bold mb-4">
Background Task Pattern
</h2>
<div class="space-y-4">
<div>
<label
class="block text-sm font-semibold text-gray-700 mb-3"
>
Background Regex
<span class="text-gray-500 font-normal"
>(e.g., (?i)claude.*haiku for Haiku
models)</span
>
</label>
<input
type="text"
id="background-regex"
class="input-field font-mono"
placeholder="(?i)claude.*haiku"
/>
<p class="text-sm text-gray-500 mt-2">
Models matching this regex will use the
background model. Leave empty to use
default ((?i)claude.*haiku).
</p>
</div>
</div>
</div>
<div class="card">
<h2 class="text-xl font-bold mb-4">
OAuth Tokens
</h2>
<p class="text-gray-600 mb-6">
Manage your Claude Pro/Max OAuth authentication tokens
</p>
<div id="oauth-tokens-container">
<div id="oauth-tokens-loading" class="text-center py-8">
<div class="text-gray-500">Loading OAuth tokens...</div>
</div>
<div id="oauth-tokens-list" class="space-y-4 hidden">
</div>
<div id="oauth-tokens-empty" class="text-center py-8 hidden">
<div class="text-gray-500 mb-4">No OAuth tokens found</div>
<p class="text-sm text-gray-600 mb-4">
Add a provider with OAuth authentication to see tokens here
</p>
</div>
</div>
</div>
<div class="card">
<h2 class="text-xl font-bold mb-4">
Server Actions
</h2>
<div class="flex gap-4">
<button
type="button"
onclick="restartServer()"
class="btn-primary flex-1"
>
Restart Server
</button>
</div>
</div>
</form>
</div>
<div id="content-test" class="tab-content hidden">
<h1 class="text-4xl font-bold mb-3">Test Models</h1>
<p class="text-gray-600 text-lg mb-12">
Test your registered models with custom messages
</p>
<div class="space-y-6">
<div class="card">
<div class="space-y-4">
<div>
<label
class="block text-sm font-semibold text-gray-700 mb-3"
>
Select Model
</label>
<select
id="test-model-select"
class="input-field"
onchange="updateTestProviders()"
>
<option value="">
Choose a model...
</option>
</select>
</div>
<div
id="test-provider-selection"
class="hidden"
>
<label
class="block text-sm font-semibold text-gray-700 mb-3"
>
Select Provider (순위 무시)
</label>
<select
id="test-provider-select"
class="input-field"
>
<option value="">
Use default routing...
</option>
</select>
<p class="text-xs text-gray-500 mt-2">
선택하면 라우팅 순위를 무시하고 해당
provider로 직접 요청합니다
</p>
</div>
</div>
</div>
<div class="card">
<label
class="block text-sm font-semibold text-gray-700 mb-3"
>
Message
</label>
<textarea
id="test-message-input"
class="input-field font-mono text-sm"
rows="8"
placeholder="Enter your message here..."
></textarea>
<details class="mt-4">
<summary
class="cursor-pointer text-sm font-semibold text-gray-700 select-none"
>
Advanced Options
</summary>
<div class="mt-4 space-y-4">
<div>
<label
class="block text-sm text-gray-700 mb-2"
>
Max Tokens
</label>
<input
type="number"
id="test-max-tokens"
class="input-field"
value="4096"
min="1"
max="200000"
/>
</div>
<div>
<label
class="block text-sm text-gray-700 mb-2"
>
Temperature
</label>
<input
type="number"
id="test-temperature"
class="input-field"
value="1.0"
min="0"
max="2"
step="0.1"
/>
</div>
<div>
<label
class="flex items-center gap-2 text-sm text-gray-700 cursor-pointer"
>
<input
type="checkbox"
id="test-stream"
class="rounded"
/>
<span>Enable Streaming</span>
</label>
</div>
</div>
</details>
<div class="flex gap-3 mt-6">
<button
onclick="sendTestMessage()"
id="test-send-btn"
class="btn-primary flex-1"
>
Send Message
</button>
<button
onclick="clearTestResponse()"
class="btn-secondary px-6"
>
Clear
</button>
</div>
</div>
<div id="test-response-container" class="card hidden">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold">Response</h3>
<div
class="flex items-center gap-2 text-sm text-gray-600"
>
<span id="test-response-time">-</span>
</div>
</div>
<div id="test-loading" class="hidden">
<div
class="flex items-center gap-3 text-gray-600"
>
<svg
class="animate-spin h-5 w-5"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<span>Generating response...</span>
</div>
</div>
<div id="test-response-content" class="hidden">
<div
class="bg-gray-50 rounded-lg p-4 border border-gray-200"
>
<pre
id="test-response-text"
class="whitespace-pre-wrap font-mono text-sm text-gray-800"
></pre>
</div>
<div
class="mt-4 grid grid-cols-2 gap-4 text-sm"
>
<div>
<span class="text-gray-600"
>Input Tokens:</span
>
<span
class="font-semibold ml-2"
id="test-input-tokens"
>-</span
>
</div>
<div>
<span class="text-gray-600"
>Output Tokens:</span
>
<span
class="font-semibold ml-2"
id="test-output-tokens"
>-</span
>
</div>
<div>
<span class="text-gray-600"
>Stop Reason:</span
>
<span
class="font-semibold ml-2"
id="test-stop-reason"
>-</span
>
</div>
<div>
<span class="text-gray-600"
>Model:</span
>
<span
class="font-semibold ml-2"
id="test-response-model"
>-</span
>
</div>
</div>
</div>
<div id="test-error" class="hidden">
<div
class="bg-red-50 border border-red-200 rounded-lg p-4 text-red-800"
>
<div class="font-semibold mb-2">Error</div>
<div
id="test-error-message"
class="text-sm"
></div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
<script>
const appState = {
config: null,
loaded: false,
editingProvider: null, editingModel: null, };
function notify(message, status = "primary") {
if (typeof UIkit === "undefined" || !UIkit.notification) {
alert(message);
return;
}
UIkit.notification({
message: message,
status: status,
pos: "top-right",
timeout: 3000,
});
}
function notifySuccess(message) {
notify(message, "success");
}
function notifyError(message) {
notify(message, "danger");
}
function notifyWarning(message) {
notify(message, "warning");
}
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
const STORAGE_KEY = "ccm_config";
function saveToLocalStorage(config) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
return true;
} catch (error) {
console.error("Failed to save to localStorage:", error);
return false;
}
}
function loadFromLocalStorage() {
try {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : null;
} catch (error) {
console.error("Failed to load from localStorage:", error);
return null;
}
}
async function loadConfig() {
try {
const response = await fetch("/api/config/json");
const config = await response.json();
appState.config = config;
appState.loaded = true;
saveToLocalStorage(config);
return config;
} catch (error) {
console.error("Failed to load config:", error);
notifyError("Failed to load configuration");
return null;
}
}
async function syncToServer() {
try {
console.log("=== Syncing to server ===");
console.log("appState.config.router:", appState.config.router);
console.log("Full config being sent:", appState.config);
const response = await fetch("/api/config/json", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(appState.config),
});
console.log("Server response status:", response.status);
if (response.ok) {
saveToLocalStorage(appState.config);
} else {
const errorText = await response.text();
console.error("Server error:", errorText);
}
return response.ok;
} catch (error) {
console.error("Failed to sync to server:", error);
return false;
}
}
function getURLParams() {
return new URLSearchParams(window.location.search);
}
function updateURL(params, replace = false) {
const url = new URL(window.location);
Object.entries(params).forEach(([key, value]) => {
if (value === null || value === undefined) {
url.searchParams.delete(key);
} else {
url.searchParams.set(key, value);
}
});
if (replace) {
window.history.replaceState({}, "", url);
} else {
window.history.pushState({}, "", url);
}
handleRoute();
}
function navigate(params) {
updateURL(params, false);
}
function showTab(tabName) {
navigate({ tab: tabName, view: null });
}
function handleRoute() {
const params = getURLParams();
const tab = params.get("tab") || "overview";
const view = params.get("view");
document
.querySelectorAll(".tab-content")
.forEach((el) => el.classList.add("hidden"));
document.querySelectorAll('[id^="tab-"]').forEach((el) => {
el.classList.remove("tab-active");
el.classList.add("text-gray-600");
});
document
.getElementById("content-" + tab)
.classList.remove("hidden");
const tabBtn = document.getElementById("tab-" + tab);
if (tabBtn) {
tabBtn.classList.add("tab-active");
tabBtn.classList.remove("text-gray-600");
}
if (tab === "providers") {
if (view === "add") {
document
.getElementById("providers-list-view")
.classList.add("hidden");
document
.getElementById("providers-add-view")
.classList.remove("hidden");
} else {
document
.getElementById("providers-list-view")
.classList.remove("hidden");
document
.getElementById("providers-add-view")
.classList.add("hidden");
renderProvidersList();
}
} else if (tab === "models") {
if (view === "add") {
document
.getElementById("models-list-view")
.classList.add("hidden");
document
.getElementById("models-add-view")
.classList.remove("hidden");
renderAddModelView();
} else {
document
.getElementById("models-list-view")
.classList.remove("hidden");
document
.getElementById("models-add-view")
.classList.add("hidden");
renderModelsList();
}
} else if (tab === "test") {
loadTestModels();
} else if (tab === "settings") {
loadSettingsTab();
} else if (tab === "router") {
loadRouterTab();
}
}
function loadRouterTab() {
if (!appState.loaded) return;
const config = appState.config;
const defaultSelect = document.querySelector('[name="default_model"]');
const thinkSelect = document.querySelector('[name="think_model"]');
const backgroundSelect = document.querySelector('[name="background_model"]');
const websearchSelect = document.querySelector('[name="websearch_model"]');
if (defaultSelect) defaultSelect.value = config.router.default || "";
if (thinkSelect) thinkSelect.value = config.router.think || "";
if (backgroundSelect) backgroundSelect.value = config.router.background || "";
if (websearchSelect) websearchSelect.value = config.router.websearch || "";
}
function loadSettingsTab() {
if (!appState.loaded) return;
const router = appState.config.router;
const autoMapInput = document.getElementById("auto-map-regex");
if (autoMapInput) {
autoMapInput.value = router.auto_map_regex || "";
}
const backgroundRegexInput =
document.getElementById("background-regex");
if (backgroundRegexInput) {
backgroundRegexInput.value = router.background_regex || "";
}
loadOAuthTokens();
}
function renderProvidersList() {
if (!appState.loaded) return;
const providers = appState.config.providers || [];
const emptyState = document.getElementById("empty-providers");
const providersList = document.getElementById("providers-list");
const existingCards = providersList.querySelectorAll(
".card:not(#empty-providers)",
);
existingCards.forEach((card) => card.remove());
if (providers.length === 0) {
emptyState.classList.remove("hidden");
} else {
emptyState.classList.add("hidden");
providers.forEach((provider, index) => {
const providerCard = document.createElement("div");
providerCard.className = "card fade-in";
providerCard.id = `provider-card-${index}`;
const authType = provider.auth_type || 'apikey';
const isOAuth = authType === 'oauth';
const authBadge = isOAuth
? '<span class="px-2 py-1 bg-purple-50 text-purple-600 rounded-full text-xs font-semibold">OAuth</span>'
: '<span class="px-2 py-1 bg-gray-50 text-gray-600 rounded-full text-xs font-semibold">API Key</span>';
const authStatus = isOAuth
? 'OAuth authenticated'
: 'API key registered';
providerCard.innerHTML = `
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<h3 class="text-xl font-bold">${escapeHtml(provider.name)}</h3>
<span class="px-3 py-1 ${provider.enabled ? "bg-blue-50 text-blue-600" : "bg-gray-100 text-gray-500"} rounded-full text-sm font-semibold">
${provider.enabled ? "Active" : "Inactive"}
</span>
${authBadge}
</div>
<p class="text-gray-600 mb-4">${escapeHtml(provider.provider_type)}</p>
<div class="text-sm text-gray-500">
${authStatus}
</div>
</div>
<div class="flex gap-2">
<button class="btn-secondary" onclick="editProvider(${index})">Edit</button>
<button class="btn-secondary text-red-600" onclick="deleteProvider(${index})">Delete</button>
</div>
</div>
`;
providersList.insertBefore(providerCard, emptyState);
});
}
}
function addProviderCardToUI(provider, index) {
const providersList = document.getElementById("providers-list");
const emptyState = document.getElementById("empty-providers");
const providerCard = document.createElement("div");
providerCard.className = "card fade-in";
providerCard.id = `provider-card-${index}`;
const authType = provider.auth_type || 'apikey';
const isOAuth = authType === 'oauth';
const authBadge = isOAuth
? '<span class="px-2 py-1 bg-purple-50 text-purple-600 rounded-full text-xs font-semibold">OAuth</span>'
: '<span class="px-2 py-1 bg-gray-50 text-gray-600 rounded-full text-xs font-semibold">API Key</span>';
const authStatus = isOAuth
? 'OAuth authenticated'
: 'API key registered';
providerCard.innerHTML = `
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<h3 class="text-xl font-bold">${escapeHtml(provider.name)}</h3>
<span class="px-3 py-1 ${provider.enabled ? "bg-blue-50 text-blue-600" : "bg-gray-100 text-gray-500"} rounded-full text-sm font-semibold">
${provider.enabled ? "Active" : "Inactive"}
</span>
${authBadge}
</div>
<p class="text-gray-600 mb-4">${escapeHtml(provider.provider_type)}</p>
<div class="text-sm text-gray-500">
${authStatus}
</div>
</div>
<div class="flex gap-2">
<button class="btn-secondary" onclick="editProvider(${index})">Edit</button>
<button class="btn-secondary text-red-600" onclick="deleteProvider(${index})">Delete</button>
</div>
</div>
`;
providersList.insertBefore(providerCard, emptyState);
}
async function deleteProvider(index) {
if (
!confirm("Are you sure you want to delete this provider?")
) {
return;
}
const card = document.getElementById(`provider-card-${index}`);
try {
card.classList.add("fade-out");
appState.config.providers.splice(index, 1);
saveToLocalStorage(appState.config);
setTimeout(() => {
renderProvidersList();
renderOverview();
}, 300);
notifySuccess("Provider deleted (click Save All to apply)");
} catch (error) {
console.error("Failed to delete provider:", error);
card.classList.remove("fade-out");
notifyError("Failed to delete provider");
}
}
function editProvider(index) {
if (!appState.loaded || !appState.config.providers[index]) {
notifyError("Provider not found");
return;
}
appState.editingProvider = index;
const provider = appState.config.providers[index];
navigate({ tab: "providers", view: "add" });
setTimeout(() => {
const form = document.getElementById("add-provider-form");
if (!form) return;
const providerTypeRadio = form.querySelector(
`input[name="provider_type"][value="${provider.provider_type}"]`,
);
if (providerTypeRadio) {
providerTypeRadio.checked = true;
}
form.querySelector('[name="provider_name"]').value =
provider.name;
form.querySelector('[name="api_key"]').value =
provider.api_key || "";
const baseUrlField =
form.querySelector('[name="base_url"]');
if (baseUrlField && provider.base_url) {
baseUrlField.value = provider.base_url;
}
document.querySelector(
"#providers-add-view h1",
).textContent = "Provider Edit";
document.querySelector(
"#providers-add-view > p",
).textContent = "Edit provider information";
const submitBtn = form.querySelector(
'button[type="submit"]',
);
if (submitBtn) {
submitBtn.textContent = "Edit Provider";
}
}, 100);
}
function showAddProvider() {
appState.editingProvider = null;
navigate({ tab: "providers", view: "add" });
setTimeout(() => {
document.querySelector(
"#providers-add-view h1",
).textContent = "Provider Add";
document.querySelector(
"#providers-add-view > p",
).textContent = "Enter your API key to start using";
const form = document.getElementById("add-provider-form");
if (form) {
form.reset();
const submitBtn = form.querySelector(
'button[type="submit"]',
);
if (submitBtn) {
submitBtn.textContent = "Add Provider";
}
}
}, 100);
}
function showProvidersList() {
navigate({ tab: "providers", view: null });
}
let mappingCount = 1;
function renderModelsList() {
if (!appState.loaded) return;
const models = appState.config.models || [];
const emptyState = document.getElementById("empty-models");
const modelsList = document.getElementById("models-list");
const existingCards = modelsList.querySelectorAll(
".card:not(#empty-models)",
);
existingCards.forEach((card) => card.remove());
if (models.length === 0) {
emptyState.classList.remove("hidden");
} else {
emptyState.classList.add("hidden");
models.forEach((model, index) => {
const modelCard = document.createElement("div");
modelCard.className = "card fade-in";
modelCard.id = `model-card-${index}`;
const mappingsHtml = model.mappings
.sort((a, b) => a.priority - b.priority)
.map((mapping) => {
const isPrimary = mapping.priority === 1;
return `
<div class="flex items-center gap-2 text-sm">
<span class="px-2 py-1 ${isPrimary ? "bg-blue-50 text-blue-600" : "bg-gray-100 text-gray-600"} rounded-lg font-semibold">Priority ${mapping.priority}</span>
<span class="text-gray-600">${escapeHtml(mapping.provider)} → ${escapeHtml(mapping.actual_model)}</span>
</div>
`;
})
.join("");
modelCard.innerHTML = `
<div class="flex items-start justify-between">
<div class="flex-1">
<h3 class="text-xl font-bold mb-4">${escapeHtml(model.name)}</h3>
<div class="space-y-2">
${mappingsHtml}
</div>
</div>
<div class="flex gap-2">
<button class="btn-secondary" onclick="editModel(${index})">Edit</button>
<button class="btn-secondary text-red-600" onclick="deleteModel(${index})">Delete</button>
</div>
</div>
`;
modelsList.insertBefore(modelCard, emptyState);
});
}
}
function addModelCardToUI(model, index) {
const modelsList = document.getElementById("models-list");
const emptyState = document.getElementById("empty-models");
const modelCard = document.createElement("div");
modelCard.className = "card fade-in";
modelCard.id = `model-card-${index}`;
const mappingsHtml = model.mappings
.sort((a, b) => a.priority - b.priority)
.map((mapping) => {
const isPrimary = mapping.priority === 1;
return `
<div class="flex items-center gap-2 text-sm">
<span class="px-2 py-1 ${isPrimary ? "bg-blue-50 text-blue-600" : "bg-gray-100 text-gray-600"} rounded-lg font-semibold">Priority ${mapping.priority}</span>
<span class="text-gray-600">${escapeHtml(mapping.provider)} → ${escapeHtml(mapping.actual_model)}</span>
</div>
`;
})
.join("");
modelCard.innerHTML = `
<div class="flex items-start justify-between">
<div class="flex-1">
<h3 class="text-xl font-bold mb-4">${escapeHtml(model.name)}</h3>
<div class="space-y-2">
${mappingsHtml}
</div>
</div>
<div class="flex gap-2">
<button class="btn-secondary" onclick="editModel(${index})">Edit</button>
<button class="btn-secondary text-red-600" onclick="deleteModel(${index})">Delete</button>
</div>
</div>
`;
modelsList.insertBefore(modelCard, emptyState);
}
async function deleteModel(index) {
if (!confirm("Are you sure you want to delete this model?")) {
return;
}
const card = document.getElementById(`model-card-${index}`);
try {
card.classList.add("fade-out");
appState.config.models.splice(index, 1);
saveToLocalStorage(appState.config);
setTimeout(() => {
renderModelsList();
renderOverview();
}, 300);
notifySuccess("Model deleted (click Save All to apply)");
} catch (error) {
console.error("Failed to delete model:", error);
card.classList.remove("fade-out");
notifyError("Failed to delete model");
}
}
function editModel(index) {
if (!appState.loaded || !appState.config.models[index]) {
notifyError("Model not found");
return;
}
appState.editingModel = index;
const model = appState.config.models[index];
navigate({ tab: "models", view: "add" });
setTimeout(() => {
const form = document.getElementById("add-model-form");
if (!form) return;
form.querySelector('[name="model_name"]').value =
model.name;
const mappingsContainer =
document.getElementById("provider-mappings");
mappingsContainer.innerHTML = "";
mappingCount = model.mappings.length;
model.mappings.forEach((mapping, index) => {
const isPrimary = mapping.priority === 1;
const mappingDiv = document.createElement("div");
mappingDiv.className = isPrimary
? "provider-mapping border-2 border-blue-200 bg-blue-50 rounded-xl p-6"
: "provider-mapping border-2 border-gray-200 rounded-xl p-6";
mappingDiv.setAttribute(
"data-priority",
mapping.priority,
);
const providers = appState.config.providers || [];
const providerOptions = providers
.filter((p) => p.enabled)
.map(
(p) =>
`<option value="${escapeHtml(p.name)}" ${p.name === mapping.provider ? "selected" : ""}>${escapeHtml(p.name)} (${escapeHtml(p.provider_type)})</option>`,
)
.join("");
if (isPrimary) {
mappingDiv.innerHTML = `
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<span class="px-3 py-1 bg-blue-600 text-white rounded-lg font-bold text-sm">Priority 1</span>
<span class="text-blue-600 font-semibold">Primary</span>
</div>
</div>
<div class="space-y-4">
<div>
<label class="label">Select Provider</label>
<select name="mappings[${index}][provider]" class="input-field" required>
<option value="">Choose a provider</option>
${providerOptions}
</select>
</div>
<div>
<label class="label">Model Name</label>
<input type="text" name="mappings[${index}][actual_model]" class="input-field font-mono" value="${escapeHtml(mapping.actual_model)}" placeholder="e.g., claude-sonnet-4-5 or anthropic/claude-sonnet-4-5" required>
<div class="helper-text">The actual model ID used by the provider</div>
</div>
</div>
`;
} else {
mappingDiv.innerHTML = `
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<span class="px-3 py-1 bg-gray-200 text-gray-700 rounded-lg font-bold text-sm">Priority ${mapping.priority}</span>
<span class="text-gray-600 font-semibold">Fallback</span>
</div>
<div class="flex gap-2">
<button type="button" onclick="moveMappingUp(this)" class="p-2 hover:bg-gray-100 rounded-lg">
<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="M5 15l7-7 7 7"/>
</svg>
</button>
<button type="button" onclick="moveMappingDown(this)" class="p-2 hover:bg-gray-100 rounded-lg">
<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="M19 9l-7 7-7-7"/>
</svg>
</button>
<button type="button" onclick="removeMapping(this)" class="p-2 hover:bg-red-50 text-red-600 rounded-lg">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
<div class="space-y-4">
<div>
<label class="label">Select Provider</label>
<select name="mappings[${index}][provider]" class="input-field" required>
<option value="">Choose a provider</option>
${providerOptions}
</select>
</div>
<div>
<label class="label">Model Name</label>
<input type="text" name="mappings[${index}][actual_model]" class="input-field font-mono" value="${escapeHtml(mapping.actual_model)}" placeholder="e.g., claude-sonnet-4-5 or anthropic/claude-sonnet-4-5" required>
<div class="helper-text">The actual model ID used by the provider</div>
</div>
</div>
`;
}
mappingsContainer.appendChild(mappingDiv);
});
document.querySelector("#models-add-view h1").textContent =
"Edit Model";
document.querySelector("#models-add-view > p").textContent =
"Edit model information";
const submitBtn = form.querySelector(
'button[type="submit"]',
);
if (submitBtn) {
submitBtn.textContent = "Edit Model";
}
}, 100);
}
function renderAddModelView() {
if (!appState.loaded) return;
const providers = appState.config.providers || [];
const providerOptions = providers
.filter((p) => p.enabled)
.map(
(p) =>
`<option value="${escapeHtml(p.name)}">${escapeHtml(p.name)} (${escapeHtml(p.provider_type)})</option>`,
)
.join("");
mappingCount = 1;
document.getElementById("provider-mappings").innerHTML = `
<div class="provider-mapping border-2 border-blue-200 bg-blue-50 rounded-xl p-6" data-priority="1">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<span class="px-3 py-1 bg-blue-600 text-white rounded-lg font-bold text-sm">Priority 1</span>
<span class="text-blue-600 font-semibold">Primary</span>
</div>
</div>
<div class="space-y-4">
<div>
<label class="label">Select Provider</label>
<select name="mappings[0][provider]" class="input-field" required>
<option value="">Choose a provider</option>
${providerOptions}
</select>
</div>
<div>
<label class="label">Model Name</label>
<input type="text" name="mappings[0][actual_model]" class="input-field font-mono" placeholder="e.g., claude-sonnet-4-5 or anthropic/claude-sonnet-4-5" required>
<div class="helper-text">The actual model ID used by the provider</div>
</div>
</div>
</div>
`;
}
function showAddModel() {
appState.editingModel = null;
navigate({ tab: "models", view: "add" });
setTimeout(() => {
document.querySelector("#models-add-view h1").textContent =
"Add Model";
document.querySelector("#models-add-view > p").textContent =
"Select a configured provider to create a model";
const form = document.getElementById("add-model-form");
if (form) {
form.reset();
const submitBtn = form.querySelector(
'button[type="submit"]',
);
if (submitBtn) {
submitBtn.textContent = "Add Model";
}
}
renderAddModelView();
}, 100);
}
function showModelsList() {
navigate({ tab: "models", view: null });
}
function addProviderMapping() {
if (!appState.loaded) return;
mappingCount++;
const priority = mappingCount;
const mappingsContainer =
document.getElementById("provider-mappings");
const providers = appState.config.providers || [];
const providerOptions = providers
.filter((p) => p.enabled)
.map(
(p) =>
`<option value="${escapeHtml(p.name)}">${escapeHtml(p.name)} (${escapeHtml(p.provider_type)})</option>`,
)
.join("");
const newMapping = document.createElement("div");
newMapping.className =
"provider-mapping border-2 border-gray-200 rounded-xl p-6";
newMapping.setAttribute("data-priority", priority);
newMapping.innerHTML = `
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<span class="px-3 py-1 bg-gray-200 text-gray-700 rounded-lg font-bold text-sm">Priority ${priority}</span>
<span class="text-gray-600 font-semibold">Fallback</span>
</div>
<div class="flex gap-2">
<button type="button" onclick="moveMappingUp(this)" class="p-2 hover:bg-gray-100 rounded-lg" ${priority === 2 ? "disabled" : ""}>
<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="M5 15l7-7 7 7"/>
</svg>
</button>
<button type="button" onclick="moveMappingDown(this)" class="p-2 hover:bg-gray-100 rounded-lg">
<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="M19 9l-7 7-7-7"/>
</svg>
</button>
<button type="button" onclick="removeMapping(this)" class="p-2 hover:bg-red-50 text-red-600 rounded-lg">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
<div class="space-y-4">
<div>
<label class="label">Select Provider</label>
<select name="mappings[${priority - 1}][provider]" class="input-field" required>
<option value="">Choose a provider</option>
${providerOptions}
</select>
</div>
<div>
<label class="label">Model Name</label>
<input type="text" name="mappings[${priority - 1}][actual_model]" class="input-field font-mono" placeholder="e.g., claude-sonnet-4-5 or anthropic/claude-sonnet-4-5" required>
<div class="helper-text">The actual model ID used by the provider</div>
</div>
</div>
`;
mappingsContainer.appendChild(newMapping);
}
function removeMapping(btn) {
const mapping = btn.closest(".provider-mapping");
mapping.remove();
updateMappingPriorities();
}
function moveMappingUp(btn) {
const mapping = btn.closest(".provider-mapping");
const prev = mapping.previousElementSibling;
if (prev) {
mapping.parentNode.insertBefore(mapping, prev);
updateMappingPriorities();
}
}
function moveMappingDown(btn) {
const mapping = btn.closest(".provider-mapping");
const next = mapping.nextElementSibling;
if (next) {
mapping.parentNode.insertBefore(next, mapping);
updateMappingPriorities();
}
}
function updateMappingPriorities() {
const mappings = document.querySelectorAll(".provider-mapping");
mappings.forEach((mapping, index) => {
const priority = index + 1;
mapping.setAttribute("data-priority", priority);
const badge = mapping.querySelector(".px-3");
const label = mapping.querySelector(".font-semibold");
if (priority === 1) {
badge.className =
"px-3 py-1 bg-blue-600 text-white rounded-lg font-bold text-sm";
badge.textContent = "Priority 1";
label.className = "text-blue-600 font-semibold";
label.textContent = "Primary";
mapping.className =
"provider-mapping border-2 border-blue-200 bg-blue-50 rounded-xl p-6";
} else {
badge.className =
"px-3 py-1 bg-gray-200 text-gray-700 rounded-lg font-bold text-sm";
badge.textContent = `Priority ${priority}`;
label.className = "text-gray-600 font-semibold";
label.textContent = "Fallback";
mapping.className =
"provider-mapping border-2 border-gray-200 rounded-xl p-6";
}
mapping
.querySelectorAll("select, input")
.forEach((input) => {
const name = input.name;
input.name = name.replace(/\[\d+\]/, `[${index}]`);
});
});
}
function renderOverview() {
if (!appState.loaded) return;
const config = appState.config;
document.getElementById("current-default").textContent =
config.router.default || "-";
document.getElementById("current-think").textContent =
config.router.think || "Not configured";
document.getElementById("current-background").textContent =
config.router.background || "Not configured";
document.getElementById("current-websearch").textContent =
config.router.websearch || "Not configured";
document.getElementById("server-address").textContent =
`${config.server.host}:${config.server.port}`;
const providerCount = config.providers
? config.providers.length
: 0;
document.getElementById("provider-count").textContent =
`${providerCount}`;
const modelCount = config.models ? config.models.length : 0;
document.getElementById("model-count-overview").textContent =
`${modelCount}`;
populateModelSelects(config.models || []);
document.querySelector('[name="default_model"]').value =
config.router.default || "";
document.querySelector('[name="think_model"]').value =
config.router.think || "";
document.querySelector('[name="background_model"]').value =
config.router.background || "";
document.querySelector('[name="websearch_model"]').value =
config.router.websearch || "";
loadTestModels();
}
function populateModelSelects(models) {
const selects = document.querySelectorAll(
'select[name$="_model"]',
);
selects.forEach((select) => {
const isRequired = select.required;
const currentValue = select.value;
select.innerHTML = isRequired
? '<option value="">Select a model</option>'
: '<option value="">Not configured</option>';
models.forEach((model) => {
const option = document.createElement("option");
option.value = model.name;
option.textContent = model.name;
if (currentValue === model.name) {
option.selected = true;
}
select.appendChild(option);
});
});
}
let autoSaveTimers = {};
function debounce(key, callback, delay = 1000) {
clearTimeout(autoSaveTimers[key]);
autoSaveTimers[key] = setTimeout(callback, delay);
}
function showAutoSaveIndicator() {
const indicator = document.createElement("div");
indicator.textContent = "✓ Auto-saved";
indicator.className =
"fixed top-20 right-4 bg-green-50 text-green-600 px-4 py-2 rounded-lg shadow-lg text-sm font-medium z-50";
document.body.appendChild(indicator);
setTimeout(() => {
indicator.style.opacity = "0";
indicator.style.transition = "opacity 0.3s";
setTimeout(() => indicator.remove(), 300);
}, 2000);
}
function setupRouterAutoSave() {
console.log("=== setupRouterAutoSave called ===");
const routerForm = document.getElementById("router-form");
console.log("Router form:", routerForm);
if (!routerForm) {
console.warn("Router form not found!");
return;
}
const inputs = routerForm.querySelectorAll(
"input, select, textarea",
);
console.log(`Found ${inputs.length} inputs in router form`);
inputs.forEach((input, index) => {
console.log(`Setting up listener for input ${index}:`, input.name, input.tagName);
input.addEventListener("change", (e) => {
console.log("=== Change event fired ===", e.target.name, "=", e.target.value);
debounce(
"router",
() => {
console.log("=== Router form change detected (after debounce) ===");
const formData = new FormData(routerForm);
const defaultModel =
formData.get("default_model");
const thinkModel = formData.get("think_model");
const backgroundModel = formData.get("background_model");
const websearchModel = formData.get("websearch_model");
console.log("FormData values:", {
default: defaultModel,
think: thinkModel,
background: backgroundModel,
websearch: websearchModel
});
if (!defaultModel) return;
appState.config.router.default = defaultModel;
if (thinkModel) {
appState.config.router.think = thinkModel;
} else {
delete appState.config.router.think;
}
if (backgroundModel) {
appState.config.router.background =
backgroundModel;
} else {
delete appState.config.router.background;
}
if (websearchModel) {
appState.config.router.websearch =
websearchModel;
} else {
delete appState.config.router.websearch;
}
console.log("Updated appState.config.router:", appState.config.router);
saveToLocalStorage(appState.config);
showAutoSaveIndicator();
renderOverview();
},
500,
);
});
});
}
function setupSettingsAutoSave() {
const settingsForm = document.getElementById("settings-form");
if (!settingsForm) return;
const inputs = settingsForm.querySelectorAll(
"input, select, textarea",
);
inputs.forEach((input) => {
input.addEventListener("change", () => {
debounce(
"settings",
() => {
const autoMapRegex =
document.getElementById(
"auto-map-regex",
).value;
if (autoMapRegex) {
appState.config.router.auto_map_regex =
autoMapRegex;
} else {
delete appState.config.router
.auto_map_regex;
}
const backgroundRegex =
document.getElementById(
"background-regex",
).value;
if (backgroundRegex) {
appState.config.router.background_regex =
backgroundRegex;
} else {
delete appState.config.router
.background_regex;
}
saveToLocalStorage(appState.config);
showAutoSaveIndicator();
renderOverview();
},
500,
);
});
});
}
document
.getElementById("add-model-form")
.addEventListener("submit", async function (e) {
e.preventDefault();
const formData = new FormData(e.target);
const modelName = formData.get("model_name")?.trim();
if (!modelName) {
notifyError("Please enter a model name.");
return;
}
const mappings = [];
document
.querySelectorAll(".provider-mapping")
.forEach((mapping, index) => {
const provider = formData
.get(`mappings[${index}][provider]`)
?.trim();
const actualModel = formData
.get(`mappings[${index}][actual_model]`)
?.trim();
if (provider && actualModel) {
mappings.push({
priority: index + 1,
provider: provider,
actual_model: actualModel,
});
}
});
if (mappings.length === 0) {
notifyError(
"Please add at least one provider mapping.",
);
return;
}
const modelData = {
name: modelName,
mappings: mappings,
};
try {
const isEditing = appState.editingModel !== null;
if (isEditing) {
const editIndex = appState.editingModel;
if (
appState.config.models &&
appState.config.models.some(
(m, idx) =>
idx !== editIndex &&
m.name.toLowerCase() ===
modelName.toLowerCase(),
)
) {
notifyError(
`A model named "${modelName}" already exists. Please use a different name.`,
);
return;
}
appState.config.models[editIndex] = modelData;
saveToLocalStorage(appState.config);
notifySuccess("Model updated and auto-saved");
appState.editingModel = null;
e.target.reset();
navigate({ tab: "models", view: null });
} else {
if (
appState.config.models &&
appState.config.models.some(
(m) =>
m.name.toLowerCase() ===
modelName.toLowerCase(),
)
) {
notifyError(
`A model named "${modelName}" already exists. Please use a different name.`,
);
return;
}
if (!appState.config.models) {
appState.config.models = [];
}
appState.config.models.push(modelData);
saveToLocalStorage(appState.config);
notifySuccess("Model added and auto-saved");
e.target.reset();
navigate({ tab: "models", view: null });
}
} catch (error) {
console.error("Failed to save model:", error);
notifyError("Failed to save model");
}
});
document
.getElementById("add-provider-form")
.addEventListener("submit", async function (e) {
e.preventDefault();
const formData = new FormData(e.target);
const providerName = formData.get("provider_name")?.trim();
const providerType = formData.get("provider_type")?.trim();
const authType = formData.get("auth_type")?.trim() || "apikey";
const apiKey = formData.get("api_key")?.trim();
const baseUrl = formData.get("base_url")?.trim();
if (!providerName) {
notifyError("Please enter a provider name.");
return;
}
if (!providerType) {
notifyError("Please select a provider type.");
return;
}
if (authType === "apikey" && !apiKey) {
notifyError("Please enter an API key.");
return;
}
const oauthProviderId = sessionStorage.getItem('oauth_provider_id');
if (authType === "oauth" && !oauthProviderId) {
notifyError("Please complete OAuth authentication first.");
return;
}
const providerData = {
name: providerName,
provider_type: providerType,
auth_type: authType,
models: [], enabled: true,
};
if (authType === "oauth") {
providerData.oauth_provider = oauthProviderId;
} else {
providerData.api_key = apiKey;
}
if (baseUrl) {
providerData.base_url = baseUrl;
}
try {
const isEditing = appState.editingProvider !== null;
if (isEditing) {
const editIndex = appState.editingProvider;
if (
appState.config.providers &&
appState.config.providers.some(
(p, idx) =>
idx !== editIndex &&
p.name.toLowerCase() ===
providerName.toLowerCase(),
)
) {
notifyError(
`A provider named "${providerName}" already exists. Please use a different name.`,
);
return;
}
providerData.models =
appState.config.providers[editIndex].models ||
[];
appState.config.providers[editIndex] = providerData;
saveToLocalStorage(appState.config);
notifySuccess("Provider updated and auto-saved");
appState.editingProvider = null;
e.target.reset();
navigate({ tab: "providers", view: null });
} else {
if (
appState.config.providers &&
appState.config.providers.some(
(p) =>
p.name.toLowerCase() ===
providerName.toLowerCase(),
)
) {
notifyError(
`A provider named "${providerName}" already exists. Please use a different name.`,
);
return;
}
if (!appState.config.providers) {
appState.config.providers = [];
}
appState.config.providers.push(providerData);
saveToLocalStorage(appState.config);
notifySuccess("Provider added and auto-saved");
e.target.reset();
navigate({ tab: "providers", view: null });
}
} catch (error) {
console.error("Failed to save provider:", error);
notifyError("Failed to save provider");
}
});
async function restartServer() {
if (!confirm("Are you sure you want to restart the server?"))
return;
try {
await fetch("/api/restart", { method: "POST" });
notifySuccess("Server restarted");
} catch (error) {
console.error("Failed to restart server:", error);
notifyError("Failed to restart server");
}
}
async function saveAllConfig() {
console.log("Saving all configuration...");
try {
const success = await syncToServer();
if (success) {
updateLastSaved();
notifySuccess("All settings saved to server");
renderOverview();
} else {
notifyError("Failed to save to server");
}
} catch (error) {
console.error("Failed to save all config:", error);
notifyError("Failed to save");
}
}
async function saveAndRestart() {
if (
!confirm(
"Save settings and Are you sure you want to restart the server?",
)
)
return;
try {
await saveAllConfig();
setTimeout(async () => {
await fetch("/api/restart", { method: "POST" });
notifySuccess("Server restarted");
}, 500);
} catch (error) {
console.error("Failed to save and restart:", error);
notifyError("Failed to save and restart");
}
}
function updateLastSaved() {
const now = new Date();
const timeStr = now.toLocaleTimeString("ko-KR", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
document.getElementById("last-saved").textContent = timeStr;
}
function loadTestModels() {
if (!appState.loaded) return;
const models = appState.config.models || [];
const select = document.getElementById("test-model-select");
select.innerHTML =
'<option value="">Choose a model...</option>';
models.forEach((model) => {
const option = document.createElement("option");
option.value = model.name;
option.textContent = model.name;
select.appendChild(option);
});
}
function updateTestProviders() {
const modelSelect =
document.getElementById("test-model-select");
const providerSelect = document.getElementById(
"test-provider-select",
);
const providerSelection = document.getElementById(
"test-provider-selection",
);
const selectedModelName = modelSelect.value;
if (!selectedModelName || !appState.loaded) {
providerSelection.classList.add("hidden");
return;
}
providerSelect.innerHTML =
'<option value="">Use default routing...</option>';
const modelConfig = appState.config.models.find(
(m) => m.name === selectedModelName,
);
if (
modelConfig &&
modelConfig.mappings &&
modelConfig.mappings.length > 0
) {
const sortedMappings = [...modelConfig.mappings].sort(
(a, b) => a.priority - b.priority,
);
sortedMappings.forEach((mapping) => {
const option = document.createElement("option");
option.value = mapping.provider;
option.textContent = `${mapping.provider} (${mapping.actual_model}) - Priority ${mapping.priority}`;
providerSelect.appendChild(option);
});
providerSelection.classList.remove("hidden");
} else {
providerSelection.classList.add("hidden");
}
}
async function sendTestMessage() {
const modelSelect =
document.getElementById("test-model-select");
const providerSelect = document.getElementById(
"test-provider-select",
);
const messageInput =
document.getElementById("test-message-input");
const maxTokens = parseInt(
document.getElementById("test-max-tokens").value,
);
const temperature = parseFloat(
document.getElementById("test-temperature").value,
);
const stream = document.getElementById("test-stream").checked;
const model = modelSelect.value;
const provider = providerSelect.value;
const message = messageInput.value.trim();
if (!model) {
notifyError("Please select a model");
return;
}
if (!message) {
notifyError("Please enter a message");
return;
}
const responseContainer = document.getElementById(
"test-response-container",
);
const loadingDiv = document.getElementById("test-loading");
const contentDiv = document.getElementById(
"test-response-content",
);
const errorDiv = document.getElementById("test-error");
responseContainer.classList.remove("hidden");
loadingDiv.classList.remove("hidden");
contentDiv.classList.add("hidden");
errorDiv.classList.add("hidden");
const sendBtn = document.getElementById("test-send-btn");
sendBtn.disabled = true;
sendBtn.textContent = "Sending...";
const startTime = Date.now();
try {
const headers = {
"Content-Type": "application/json",
"anthropic-version": "2023-06-01",
};
if (provider) {
headers["X-Provider"] = provider;
}
const response = await fetch("/v1/messages", {
method: "POST",
headers: headers,
body: JSON.stringify({
model: model,
max_tokens: maxTokens,
temperature: temperature,
stream: stream,
messages: [
{
role: "user",
content: message,
},
],
}),
});
const endTime = Date.now();
const duration = ((endTime - startTime) / 1000).toFixed(2);
if (!response.ok) {
const errorData = await response.json();
throw new Error(
errorData.error?.message ||
`HTTP ${response.status}`,
);
}
if (stream) {
await handleStreamingResponse(response, duration);
} else {
const data = await response.json();
displayTestResponse(data, duration);
}
} catch (error) {
console.error("Test message error:", error);
loadingDiv.classList.add("hidden");
errorDiv.classList.remove("hidden");
document.getElementById("test-error-message").textContent =
error.message;
} finally {
sendBtn.disabled = false;
sendBtn.textContent = "Send Message";
}
}
async function handleStreamingResponse(response, duration) {
const loadingDiv = document.getElementById("test-loading");
const contentDiv = document.getElementById(
"test-response-content",
);
const responseText =
document.getElementById("test-response-text");
loadingDiv.classList.add("hidden");
contentDiv.classList.remove("hidden");
responseText.textContent = "";
const reader = response.body.getReader();
const decoder = new TextDecoder();
let fullText = "";
let inputTokens = 0;
let outputTokens = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split("\n");
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") continue;
try {
const event = JSON.parse(data);
if (event.type === "content_block_delta") {
const text = event.delta?.text || "";
fullText += text;
responseText.textContent = fullText;
} else if (event.type === "message_start") {
inputTokens =
event.message?.usage
?.input_tokens || 0;
} else if (event.type === "message_delta") {
outputTokens =
event.usage?.output_tokens || 0;
}
} catch (e) {
}
}
}
}
document.getElementById("test-response-time").textContent =
`${duration}s`;
document.getElementById("test-input-tokens").textContent =
inputTokens;
document.getElementById("test-output-tokens").textContent =
outputTokens;
document.getElementById("test-stop-reason").textContent =
"end_turn";
document.getElementById("test-response-model").textContent =
document.getElementById("test-model-select").value;
} catch (error) {
console.error("Streaming error:", error);
throw error;
}
}
function displayTestResponse(data, duration) {
const loadingDiv = document.getElementById("test-loading");
const contentDiv = document.getElementById(
"test-response-content",
);
loadingDiv.classList.add("hidden");
contentDiv.classList.remove("hidden");
const textContent = data.content
.filter((c) => c.type === "text")
.map((c) => c.text)
.join("\n");
document.getElementById("test-response-text").textContent =
textContent;
document.getElementById("test-response-time").textContent =
`${duration}s`;
document.getElementById("test-input-tokens").textContent =
data.usage?.input_tokens || "-";
document.getElementById("test-output-tokens").textContent =
data.usage?.output_tokens || "-";
document.getElementById("test-stop-reason").textContent =
data.stop_reason || "-";
document.getElementById("test-response-model").textContent =
data.model || "-";
}
function clearTestResponse() {
document.getElementById("test-message-input").value = "";
document
.getElementById("test-response-container")
.classList.add("hidden");
document.getElementById("test-response-text").textContent = "";
}
function updateOAuthLabel() {
const providerType = document.querySelector('input[name="provider_type"]:checked')?.value;
const oauthLabel = document.getElementById('oauth-label-title');
const oauthDescription = document.getElementById('oauth-label-description');
const step1Instruction = document.getElementById('oauth-step1-instruction');
const step2Instructions = document.getElementById('oauth-step2-instructions');
if (providerType === 'openai') {
oauthLabel.textContent = 'OAuth (ChatGPT Plus/Pro)';
oauthDescription.textContent = 'Free for ChatGPT Plus/Pro subscribers';
step1Instruction.textContent = 'Click the button below to authenticate with your ChatGPT Plus/Pro account.';
step2Instructions.innerHTML = `
<li>Log in to your ChatGPT Plus/Pro account</li>
<li>Click "Allow" to authorize</li>
<li>Copy the authorization code</li>
<li>Paste it in the field below</li>
`;
} else {
oauthLabel.textContent = 'OAuth (Claude Pro/Max)';
oauthDescription.textContent = 'Free for Claude Max subscribers';
step1Instruction.textContent = 'Click the button below to authenticate with your Claude Pro/Max account.';
step2Instructions.innerHTML = `
<li>Log in to your Claude Pro/Max account</li>
<li>Click "Allow" to authorize</li>
<li>Copy the authorization code</li>
<li>Paste it in the field below</li>
`;
}
}
function toggleAuthMethod() {
const authType = document.querySelector('input[name="auth_type"]:checked').value;
const apiKeySection = document.getElementById('api-key-section');
const oauthSection = document.getElementById('oauth-section');
const apiKeyInput = document.getElementById('api-key-input');
if (authType === 'oauth') {
apiKeySection.classList.add('hidden');
oauthSection.classList.remove('hidden');
apiKeyInput.removeAttribute('required');
document.getElementById('oauth-step-1').classList.remove('hidden');
document.getElementById('oauth-step-2').classList.add('hidden');
document.getElementById('oauth-step-3').classList.add('hidden');
document.getElementById('oauth-code-input').value = '';
} else {
apiKeySection.classList.remove('hidden');
oauthSection.classList.add('hidden');
apiKeyInput.setAttribute('required', 'required');
}
}
async function loadOAuthTokens() {
const loadingEl = document.getElementById('oauth-tokens-loading');
const listEl = document.getElementById('oauth-tokens-list');
const emptyEl = document.getElementById('oauth-tokens-empty');
try {
const response = await fetch('/api/oauth/tokens');
if (!response.ok) {
throw new Error('Failed to load OAuth tokens');
}
const tokens = await response.json();
loadingEl.classList.add('hidden');
if (tokens.length === 0) {
emptyEl.classList.remove('hidden');
listEl.classList.add('hidden');
} else {
emptyEl.classList.add('hidden');
listEl.classList.remove('hidden');
listEl.innerHTML = tokens.map(token => {
const expiresAt = new Date(token.expires_at);
const now = new Date();
const isExpired = expiresAt <= now;
const needsRefresh = token.needs_refresh;
return `
<div class="border-2 ${isExpired ? 'border-red-200 bg-red-50' : needsRefresh ? 'border-yellow-200 bg-yellow-50' : 'border-green-200 bg-green-50'} rounded-xl p-4">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<h3 class="font-bold">${escapeHtml(token.provider_id)}</h3>
${isExpired ? '<span class="px-2 py-1 bg-red-100 text-red-700 rounded-full text-xs font-semibold">Expired</span>' : needsRefresh ? '<span class="px-2 py-1 bg-yellow-100 text-yellow-700 rounded-full text-xs font-semibold">Needs Refresh</span>' : '<span class="px-2 py-1 bg-green-100 text-green-700 rounded-full text-xs font-semibold">Active</span>'}
</div>
<div class="text-sm text-gray-600">
Expires: ${expiresAt.toLocaleString()}
</div>
</div>
<div class="flex gap-2">
<button
class="px-3 py-1 bg-blue-100 text-blue-700 rounded-lg text-sm font-semibold hover:bg-blue-200 transition-all"
onclick="refreshOAuthToken('${escapeHtml(token.provider_id)}')"
>
Refresh
</button>
<button
class="px-3 py-1 bg-red-100 text-red-700 rounded-lg text-sm font-semibold hover:bg-red-200 transition-all"
onclick="deleteOAuthToken('${escapeHtml(token.provider_id)}')"
>
Delete
</button>
</div>
</div>
</div>
`;
}).join('');
}
} catch (error) {
console.error('Failed to load OAuth tokens:', error);
loadingEl.innerHTML = '<div class="text-red-600">Failed to load OAuth tokens</div>';
}
}
async function refreshOAuthToken(providerId) {
try {
const response = await fetch('/api/oauth/tokens/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider_id: providerId })
});
if (!response.ok) {
throw new Error('Failed to refresh token');
}
notifySuccess(`Token refreshed for ${providerId}`);
await loadOAuthTokens(); } catch (error) {
console.error('Failed to refresh token:', error);
notifyError(`Failed to refresh token: ${error.message}`);
}
}
async function deleteOAuthToken(providerId) {
if (!confirm(`Are you sure you want to delete the OAuth token for "${providerId}"?`)) {
return;
}
try {
const response = await fetch('/api/oauth/tokens/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider_id: providerId })
});
if (!response.ok) {
throw new Error('Failed to delete token');
}
notifySuccess(`OAuth token deleted for ${providerId}`);
await loadOAuthTokens(); } catch (error) {
console.error('Failed to delete token:', error);
notifyError(`Failed to delete token: ${error.message}`);
}
}
async function startOAuthFlow() {
try {
const providerType = document.querySelector('input[name="provider_type"]:checked')?.value;
let oauth_type = 'max';
if (providerType === 'openai') {
oauth_type = 'openai-codex';
}
const response = await fetch('/api/oauth/authorize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ oauth_type })
});
if (!response.ok) {
throw new Error('Failed to get authorization URL');
}
const data = await response.json();
const { url, verifier } = data;
console.log('🔐 Storing OAuth verifier:', verifier);
console.log('🔐 Storing OAuth type:', oauth_type);
sessionStorage.setItem('oauth_verifier', verifier);
sessionStorage.setItem('oauth_type', oauth_type);
window.open(url, 'OAuth Authorization', 'width=600,height=800');
document.getElementById('oauth-step-1').classList.add('hidden');
document.getElementById('oauth-step-2').classList.remove('hidden');
notifySuccess('Authorization window opened. Please complete authentication and paste the code.');
} catch (error) {
console.error('OAuth flow error:', error);
notifyError(`Failed to start OAuth: ${error.message}`);
}
}
async function completeOAuthFlow() {
try {
const code = document.getElementById('oauth-code-input').value.trim();
if (!code) {
notifyError('Please paste the authorization code');
return;
}
const verifier = sessionStorage.getItem('oauth_verifier');
if (!verifier) {
throw new Error('OAuth verifier not found. Please start the flow again.');
}
const oauthType = sessionStorage.getItem('oauth_type');
if (!oauthType) {
throw new Error('OAuth type not found. Please start the flow again.');
}
const providerName = document.querySelector('input[name="provider_name"]').value || 'claude-max';
const providerId = `${providerName}-oauth`;
console.log('🔐 Exchanging code with oauth_type:', oauthType);
console.log('🔐 Provider ID:', providerId);
const exchangeResponse = await fetch('/api/oauth/exchange', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code: code,
verifier: verifier,
provider_id: providerId,
oauth_type: oauthType
})
});
if (!exchangeResponse.ok) {
const error = await exchangeResponse.text();
throw new Error(`Failed to exchange code: ${error}`);
}
const tokenData = await exchangeResponse.json();
document.getElementById('oauth-step-2').classList.add('hidden');
document.getElementById('oauth-step-3').classList.remove('hidden');
sessionStorage.setItem('oauth_provider_id', providerId);
notifySuccess(`OAuth authentication successful! Token saved for ${providerId}`);
} catch (error) {
console.error('OAuth completion error:', error);
notifyError(`Authentication failed: ${error.message}`);
}
}
function cancelOAuthFlow() {
document.getElementById('oauth-step-2').classList.add('hidden');
document.getElementById('oauth-step-1').classList.remove('hidden');
document.getElementById('oauth-code-input').value = '';
sessionStorage.removeItem('oauth_verifier');
notifySuccess('OAuth flow canceled');
}
window.addEventListener("DOMContentLoaded", async () => {
await loadConfig();
handleRoute();
renderOverview();
updateLastSaved();
loadTestModels();
setupRouterAutoSave();
setupSettingsAutoSave();
const providerTypeInputs = document.querySelectorAll('input[name="provider_type"]');
providerTypeInputs.forEach(input => {
input.addEventListener('change', updateOAuthLabel);
});
updateOAuthLabel();
});
window.addEventListener("popstate", handleRoute);
</script>
</body>
</html>