Merge pull request #221 from Jeeltilva/main

feat: Implement API and UI for toggling Claude CLI between proxy and …
This commit is contained in:
Badri Narayanan S
2026-02-01 16:29:52 +05:30
committed by GitHub
7 changed files with 471 additions and 235 deletions

View File

@@ -356,6 +356,7 @@ Each account object in `accounts.json` contains:
- `/api/accounts/*` - Account management (list, add, remove, refresh)
- `/api/config/*` - Server configuration (read/write)
- `/api/claude/config` - Claude CLI settings
- `/api/claude/mode` - Switch between Proxy/Paid mode (updates settings.json)
- `/api/logs/stream` - SSE endpoint for real-time logs
- `/api/stats/history` - Retrieve 30-day request history (sorted chronologically)
- `/api/auth/url` - Generate Google OAuth URL

View File

@@ -132,7 +132,10 @@ You can configure these settings in two ways:
1. Open the WebUI at `http://localhost:8080`.
2. Go to **Settings****Claude CLI**.
3. Select your preferred models and click **Apply to Claude CLI**.
3. Use the **Connection Mode** toggle to switch between:
- **Proxy Mode**: Uses the local proxy server (Antigravity Cloud Code). Configure models, base URL, and presets here.
- **Paid Mode**: Uses the official Anthropic Credits directly (requires your own subscription). This hides proxy settings to prevent accidental misconfiguration.
4. Click **Apply to Claude CLI** to save your changes.
> [!TIP] > **Configuration Precedence**: System environment variables (set in shell profile like `.zshrc`) take precedence over the `settings.json` file. If you use the Web Console to manage settings, ensure you haven't manually exported conflicting variables in your terminal.
@@ -221,6 +224,18 @@ claude
> **Note:** If Claude Code asks you to select a login method, add `"hasCompletedOnboarding": true` to `~/.claude.json` (macOS/Linux) or `%USERPROFILE%\.claude.json` (Windows), then restart your terminal and try again.
### Proxy Mode vs. Paid Mode
Toggle in **Settings****Claude CLI**:
| Feature | 🔌 Proxy Mode | 💳 Paid Mode |
| :--- | :--- | :--- |
| **Backend** | Local Server (Antigravity) | Official Anthropic Credits |
| **Cost** | Free (Google Cloud) | Paid (Anthropic Credits) |
| **Models** | Claude + Gemini | Claude Only |
**Paid Mode** automatically clears proxy settings so you can use your official Anthropic account directly.
### Multiple Claude Code Instances (Optional)
To run both the official Claude Code and Antigravity version simultaneously, add this alias:

5
package-lock.json generated
View File

@@ -395,7 +395,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -1416,7 +1415,6 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -1793,7 +1791,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -2522,7 +2519,6 @@
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@@ -2647,7 +2643,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},

2
public/css/style.css generated

File diff suppressed because one or more lines are too long

View File

@@ -12,6 +12,10 @@ window.Components.claudeConfig = () => ({
restoring: false,
gemini1mSuffix: false,
// Mode toggle state (proxy/paid)
currentMode: 'proxy', // 'proxy' or 'paid'
modeLoading: false,
// Presets state
presets: [],
selectedPresetName: '',
@@ -34,6 +38,7 @@ window.Components.claudeConfig = () => ({
if (this.activeTab === 'claude') {
this.fetchConfig();
this.fetchPresets();
this.fetchMode();
}
// Watch local activeTab (from parent settings scope, skip initial trigger)
@@ -41,6 +46,7 @@ window.Components.claudeConfig = () => ({
if (tab === 'claude' && oldTab !== undefined) {
this.fetchConfig();
this.fetchPresets();
this.fetchMode();
}
});
@@ -416,5 +422,70 @@ window.Components.claudeConfig = () => ({
} finally {
this.deletingPreset = false;
}
},
// ==========================================
// Mode Toggle (Proxy/Paid)
// ==========================================
/**
* Fetch current mode from server
*/
async fetchMode() {
const password = Alpine.store('global').webuiPassword;
try {
const { response, newPassword } = await window.utils.request('/api/claude/mode', {}, password);
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
if (data.status === 'ok') {
this.currentMode = data.mode;
}
} catch (e) {
console.error('Failed to fetch mode:', e);
}
},
/**
* Toggle between proxy and paid mode
* @param {string} newMode - Target mode ('proxy' or 'paid')
*/
async toggleMode(newMode) {
if (this.modeLoading || newMode === this.currentMode) return;
this.modeLoading = true;
const password = Alpine.store('global').webuiPassword;
try {
const { response, newPassword } = await window.utils.request('/api/claude/mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: newMode })
}, password);
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
if (data.status === 'ok') {
this.currentMode = data.mode;
this.config = data.config || this.config;
Alpine.store('global').showToast(data.message, 'success');
// Refresh the config and mode state
await this.fetchConfig();
await this.fetchMode();
} else {
throw new Error(data.error || 'Failed to switch mode');
}
} catch (e) {
Alpine.store('global').showToast(
(Alpine.store('global').t('modeToggleFailed') || 'Failed to switch mode') + ': ' + e.message,
'error'
);
} finally {
this.modeLoading = false;
}
}
});

View File

@@ -73,8 +73,7 @@
</label>
<select
class="select select-bordered select-sm w-full bg-space-800 border-space-border/50 text-gray-300 focus:border-neon-purple focus:ring-1 focus:ring-neon-purple/50 font-medium transition-all !py-0 leading-tight"
:value="$store.global.lang"
@change="$store.global.setLang($event.target.value)">
:value="$store.global.lang" @change="$store.global.setLang($event.target.value)">
<option value="en">English</option>
<option value="zh">中文</option>
<option value="tr">Türkçe</option>
@@ -95,13 +94,11 @@
<input type="range" min="10" max="300" class="custom-range custom-range-purple flex-1"
x-model.number="$store.settings.refreshInterval"
:style="`background-size: ${($store.settings.refreshInterval - 10) / 2.9}% 100%`"
@change="$store.settings.saveSettings(true)"
aria-label="Polling interval slider">
@change="$store.settings.saveSettings(true)" aria-label="Polling interval slider">
<input type="number" min="10" max="300"
class="input input-sm input-bordered w-20 bg-space-800 border-space-border text-white font-mono text-center"
x-model.number="$store.settings.refreshInterval"
@change="$store.settings.saveSettings(true)"
aria-label="Polling interval value">
@change="$store.settings.saveSettings(true)" aria-label="Polling interval value">
</div>
<div class="w-full flex justify-between text-xs px-2 mt-2 text-gray-600 font-mono">
<span>10s</span>
@@ -118,15 +115,14 @@
x-text="$store.settings.logLimit + ' ' + $store.global.t('lines')"></span>
</label>
<div class="flex gap-3 items-center">
<input type="range" min="500" max="5000" step="500" class="custom-range custom-range-purple flex-1"
<input type="range" min="500" max="5000" step="500"
class="custom-range custom-range-purple flex-1"
x-model.number="$store.settings.logLimit"
:style="`background-size: ${($store.settings.logLimit - 500) / 45}% 100%`"
@change="$store.settings.saveSettings(true)"
aria-label="Log buffer size slider">
@change="$store.settings.saveSettings(true)" aria-label="Log buffer size slider">
<input type="number" min="500" max="5000" step="500"
class="input input-sm input-bordered w-24 bg-space-800 border-space-border text-white font-mono text-center"
x-model.number="$store.settings.logLimit"
@change="$store.settings.saveSettings(true)"
x-model.number="$store.settings.logLimit" @change="$store.settings.saveSettings(true)"
aria-label="Log buffer size value">
</div>
<div class="w-full flex justify-between text-xs px-2 mt-2 text-gray-600 font-mono">
@@ -194,41 +190,101 @@
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span class="text-gray-400">
<span x-text="$store.global.t('claudeSettingsAlertPrefix')">Settings below directly modify</span>
<span x-text="$store.global.t('claudeSettingsAlertPrefix')">Settings below directly
modify</span>
<code class="text-neon-cyan font-mono" x-text="configPath">~/.claude/settings.json</code>.
<span x-text="$store.global.t('claudeSettingsAlertSuffix')">Restart Claude CLI to apply.</span>
</span>
</div>
<!-- Mode Toggle (Proxy/Paid) -->
<div class="card bg-space-900/30 border border-space-border/50 p-5">
<label class="label text-xs uppercase text-gray-500 font-semibold mb-3">Connection Mode</label>
<div class="flex items-center justify-between">
<div class="flex flex-col gap-1">
<div class="flex items-center gap-2">
<span class="text-sm font-semibold"
:class="currentMode === 'proxy' ? 'text-neon-purple' : 'text-neon-green'">
<template x-if="currentMode === 'proxy'"><span>🔌 Proxy Mode</span></template>
<template x-if="currentMode === 'paid'"><span>💳 Paid Mode</span></template>
</span>
<span x-show="modeLoading"
class="loading loading-spinner loading-xs text-gray-400"></span>
</div>
<span class="text-[11px] text-gray-500">
<template x-if="currentMode === 'proxy'"><span>Using local proxy server
(localhost:8080)</span></template>
<template x-if="currentMode === 'paid'"><span>Using official Anthropic API (requires
subscription)</span></template>
</span>
</div>
<div class="flex items-center gap-3">
<span class="text-xs font-medium"
:class="currentMode === 'proxy' ? 'text-neon-purple' : 'text-gray-500'">Proxy</span>
<input type="checkbox" class="toggle toggle-sm"
:class="currentMode === 'paid' ? 'toggle-success' : 'toggle-secondary'"
:checked="currentMode === 'paid'" :disabled="modeLoading"
@change="toggleMode($event.target.checked ? 'paid' : 'proxy')"
aria-label="Toggle between Proxy and Paid mode">
<span class="text-xs font-medium"
:class="currentMode === 'paid' ? 'text-neon-green' : 'text-gray-500'">Paid</span>
</div>
</div>
</div>
<!-- Paid Mode Info (shown only in paid mode) -->
<div x-show="currentMode === 'paid'" class="card bg-neon-green/5 border border-neon-green/30 p-5">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-neon-green flex-shrink-0 mt-0.5" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<p class="text-sm font-semibold text-neon-green">Claude CLI is using the official Anthropic
API</p>
<p class="text-[11px] text-gray-400 mt-1">All proxy configuration has been removed. Claude
CLI uses your Anthropic subscription directly.</p>
<p class="text-[10px] text-gray-500 mt-2">Switch to Proxy mode to configure model routing
and presets.</p>
</div>
</div>
</div>
<!-- Configuration Presets -->
<div class="card bg-space-900/30 border border-neon-cyan/30 p-5">
<div x-show="currentMode === 'proxy'" class="card bg-space-900/30 border border-neon-cyan/30 p-5">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 text-neon-cyan" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 text-neon-cyan" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
<label class="text-xs uppercase text-neon-cyan font-semibold" x-text="$store.global.t('configPresets') || 'Configuration Presets'">Configuration Presets</label>
<label class="text-xs uppercase text-neon-cyan font-semibold"
x-text="$store.global.t('configPresets') || 'Configuration Presets'">Configuration
Presets</label>
</div>
<button class="btn btn-xs btn-ghost text-neon-cyan hover:bg-neon-cyan/10 gap-1"
@click="saveCurrentAsPreset()"
:disabled="savingPreset">
<svg x-show="!savingPreset" xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
@click="saveCurrentAsPreset()" :disabled="savingPreset">
<svg x-show="!savingPreset" xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5"
fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 4v16m8-8H4" />
</svg>
<span x-show="!savingPreset" x-text="$store.global.t('saveAsPreset') || 'Save as Preset'">Save as Preset</span>
<span x-show="!savingPreset"
x-text="$store.global.t('saveAsPreset') || 'Save as Preset'">Save as Preset</span>
<span x-show="savingPreset" class="loading loading-spinner loading-xs"></span>
</button>
</div>
<div class="flex gap-2">
<select
class="select select-sm bg-space-800 border-space-border text-white flex-1 font-mono text-xs"
:disabled="presets.length === 0"
:value="selectedPresetName"
@change="onPresetSelect($event.target.value)"
aria-label="Select preset">
:disabled="presets.length === 0" :value="selectedPresetName"
@change="onPresetSelect($event.target.value)" aria-label="Select preset">
<option value="" disabled x-show="presets.length === 0">No presets available</option>
<template x-for="preset in presets" :key="preset.name">
<option :value="preset.name" x-text="preset.name" :selected="preset.name === selectedPresetName"></option>
<option :value="preset.name" x-text="preset.name"
:selected="preset.name === selectedPresetName"></option>
</template>
</select>
<button class="btn btn-sm btn-ghost text-red-400 hover:bg-red-500/10"
@@ -236,8 +292,10 @@
:disabled="!selectedPresetName || presets.length === 0 || deletingPreset"
:title="$store.global.t('deletePreset') || 'Delete preset'">
<span x-show="!deletingPreset">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</span>
<span x-show="deletingPreset" class="loading loading-spinner loading-xs"></span>
@@ -247,7 +305,7 @@
</div>
<!-- Base URL -->
<div class="card bg-space-900/30 border border-space-border/50 p-5">
<div x-show="currentMode === 'proxy'" class="card bg-space-900/30 border border-space-border/50 p-5">
<label class="label text-xs uppercase text-gray-500 font-semibold mb-2"
x-text="$store.global.t('proxyConnection')">Proxy Connection</label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
@@ -266,7 +324,7 @@
</div>
<!-- Models Selection -->
<div class="card bg-space-900/30 border border-space-border/50 p-5">
<div x-show="currentMode === 'proxy'" class="card bg-space-900/30 border border-space-border/50 p-5">
<label class="label text-xs uppercase text-gray-500 font-semibold mb-2"
x-text="$store.global.t('modelSelection')">Model Selection</label>
@@ -276,10 +334,8 @@
<label class="label pt-0 pb-1 text-[11px] text-gray-400 font-bold tracking-wider"
x-text="$store.global.t('primaryModel')">Primary Model</label>
<div class="relative w-full" x-data="{ open: false, searchTerm: '' }">
<input type="text"
:value="open ? searchTerm : config.env.ANTHROPIC_MODEL"
@input="searchTerm = $event.target.value"
@focus="open = true; searchTerm = ''"
<input type="text" :value="open ? searchTerm : config.env.ANTHROPIC_MODEL"
@input="searchTerm = $event.target.value" @focus="open = true; searchTerm = ''"
@click.away="open = false; searchTerm = ''"
class="input input-sm w-full font-mono text-xs !bg-space-800 !border-space-border !text-white focus:!bg-space-800 focus:!border-neon-cyan pr-8 placeholder:!text-gray-600"
:placeholder="open ? $store.global.t('typeToSearch') : ''"
@@ -305,13 +361,16 @@
:class="$store.data.getModelFamily(modelId) === 'claude' ? 'bg-neon-purple shadow-[0_0_5px_rgba(168,85,247,0.5)]' : ($store.data.getModelFamily(modelId) === 'gemini' ? 'bg-neon-green shadow-[0_0_5px_rgba(34,197,94,0.5)]' : 'bg-gray-600')"></span>
<span x-text="modelId"></span>
</div>
<template x-if="gemini1mSuffix && $store.data.getModelFamily(modelId) === 'gemini'">
<span class="text-[10px] bg-neon-green/10 text-neon-green px-1.5 py-0.5 rounded border border-neon-green/20 font-bold uppercase tracking-tighter">[1M]</span>
<template
x-if="gemini1mSuffix && $store.data.getModelFamily(modelId) === 'gemini'">
<span
class="text-[10px] bg-neon-green/10 text-neon-green px-1.5 py-0.5 rounded border border-neon-green/20 font-bold uppercase tracking-tighter">[1M]</span>
</template>
</a>
</li>
</template>
<li x-show="$store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase())).length === 0">
<li
x-show="$store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase())).length === 0">
<span class="text-xs text-gray-500 italic py-2"
x-text="$store.global.t('noMatchingModels')">No matching models</span>
</li>
@@ -325,10 +384,8 @@
<label class="label pt-0 pb-1 text-[11px] text-gray-400 font-bold tracking-wider"
x-text="$store.global.t('subAgentModel')">Sub-agent Model</label>
<div class="relative w-full" x-data="{ open: false, searchTerm: '' }">
<input type="text"
:value="open ? searchTerm : config.env.CLAUDE_CODE_SUBAGENT_MODEL"
@input="searchTerm = $event.target.value"
@focus="open = true; searchTerm = ''"
<input type="text" :value="open ? searchTerm : config.env.CLAUDE_CODE_SUBAGENT_MODEL"
@input="searchTerm = $event.target.value" @focus="open = true; searchTerm = ''"
@click.away="open = false; searchTerm = ''"
class="input input-sm w-full font-mono text-xs !bg-space-800 !border-space-border !text-white focus:!bg-space-800 focus:!border-neon-purple pr-8 placeholder:!text-gray-600"
:placeholder="open ? $store.global.t('typeToSearch') : ''"
@@ -354,13 +411,16 @@
:class="$store.data.getModelFamily(modelId) === 'claude' ? 'bg-neon-purple shadow-[0_0_5px_rgba(168,85,247,0.5)]' : ($store.data.getModelFamily(modelId) === 'gemini' ? 'bg-neon-green shadow-[0_0_5px_rgba(34,197,94,0.5)]' : 'bg-gray-600')"></span>
<span x-text="modelId"></span>
</div>
<template x-if="gemini1mSuffix && $store.data.getModelFamily(modelId) === 'gemini'">
<span class="text-[10px] bg-neon-green/10 text-neon-green px-1.5 py-0.5 rounded border border-neon-green/20 font-bold uppercase tracking-tighter">[1M]</span>
<template
x-if="gemini1mSuffix && $store.data.getModelFamily(modelId) === 'gemini'">
<span
class="text-[10px] bg-neon-green/10 text-neon-green px-1.5 py-0.5 rounded border border-neon-green/20 font-bold uppercase tracking-tighter">[1M]</span>
</template>
</a>
</li>
</template>
<li x-show="$store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase())).length === 0">
<li
x-show="$store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase())).length === 0">
<span class="text-xs text-gray-500 italic py-2"
x-text="$store.global.t('noMatchingModels')">No matching models</span>
</li>
@@ -380,10 +440,8 @@
<label class="label pt-0 pb-1 text-[10px] text-gray-500 uppercase font-bold"
x-text="$store.global.t('opusAlias')">Opus Alias</label>
<div class="relative w-full" x-data="{ open: false, searchTerm: '' }">
<input type="text"
:value="open ? searchTerm : config.env.ANTHROPIC_DEFAULT_OPUS_MODEL"
@input="searchTerm = $event.target.value"
@focus="open = true; searchTerm = ''"
<input type="text" :value="open ? searchTerm : config.env.ANTHROPIC_DEFAULT_OPUS_MODEL"
@input="searchTerm = $event.target.value" @focus="open = true; searchTerm = ''"
@click.away="open = false; searchTerm = ''"
class="input input-sm w-full font-mono text-xs !bg-space-800 !border-space-border !text-white focus:!bg-space-800 focus:!border-neon-cyan pr-8 placeholder:!text-gray-600"
:placeholder="open ? $store.global.t('searchPlaceholder') : ''"
@@ -408,13 +466,16 @@
:class="$store.data.getModelFamily(modelId) === 'claude' ? 'bg-neon-purple shadow-[0_0_5px_rgba(168,85,247,0.5)]' : ($store.data.getModelFamily(modelId) === 'gemini' ? 'bg-neon-green shadow-[0_0_5px_rgba(34,197,94,0.5)]' : 'bg-gray-600')"></span>
<span x-text="modelId"></span>
</div>
<template x-if="gemini1mSuffix && $store.data.getModelFamily(modelId) === 'gemini'">
<span class="text-[10px] bg-neon-green/10 text-neon-green px-1.5 py-0.5 rounded border border-neon-green/20 font-bold uppercase tracking-tighter">[1M]</span>
<template
x-if="gemini1mSuffix && $store.data.getModelFamily(modelId) === 'gemini'">
<span
class="text-[10px] bg-neon-green/10 text-neon-green px-1.5 py-0.5 rounded border border-neon-green/20 font-bold uppercase tracking-tighter">[1M]</span>
</template>
</a>
</li>
</template>
<li x-show="$store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase())).length === 0">
<li
x-show="$store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase())).length === 0">
<span class="text-xs text-gray-500 italic py-2"
x-text="$store.global.t('noMatchingModels')">No matching models</span>
</li>
@@ -428,8 +489,7 @@
<div class="relative w-full" x-data="{ open: false, searchTerm: '' }">
<input type="text"
:value="open ? searchTerm : config.env.ANTHROPIC_DEFAULT_SONNET_MODEL"
@input="searchTerm = $event.target.value"
@focus="open = true; searchTerm = ''"
@input="searchTerm = $event.target.value" @focus="open = true; searchTerm = ''"
@click.away="open = false; searchTerm = ''"
class="input input-sm w-full font-mono text-xs !bg-space-800 !border-space-border !text-white focus:!bg-space-800 focus:!border-neon-cyan pr-8 placeholder:!text-gray-600"
:placeholder="open ? $store.global.t('searchPlaceholder') : ''"
@@ -454,13 +514,16 @@
:class="$store.data.getModelFamily(modelId) === 'claude' ? 'bg-neon-purple shadow-[0_0_5px_rgba(168,85,247,0.5)]' : ($store.data.getModelFamily(modelId) === 'gemini' ? 'bg-neon-green shadow-[0_0_5px_rgba(34,197,94,0.5)]' : 'bg-gray-600')"></span>
<span x-text="modelId"></span>
</div>
<template x-if="gemini1mSuffix && $store.data.getModelFamily(modelId) === 'gemini'">
<span class="text-[10px] bg-neon-green/10 text-neon-green px-1.5 py-0.5 rounded border border-neon-green/20 font-bold uppercase tracking-tighter">[1M]</span>
<template
x-if="gemini1mSuffix && $store.data.getModelFamily(modelId) === 'gemini'">
<span
class="text-[10px] bg-neon-green/10 text-neon-green px-1.5 py-0.5 rounded border border-neon-green/20 font-bold uppercase tracking-tighter">[1M]</span>
</template>
</a>
</li>
</template>
<li x-show="$store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase())).length === 0">
<li
x-show="$store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase())).length === 0">
<span class="text-xs text-gray-500 italic py-2"
x-text="$store.global.t('noMatchingModels')">No matching models</span>
</li>
@@ -472,10 +535,8 @@
<label class="label pt-0 pb-1 text-[10px] text-gray-500 uppercase font-bold"
x-text="$store.global.t('haikuAlias')">Haiku Alias</label>
<div class="relative w-full" x-data="{ open: false, searchTerm: '' }">
<input type="text"
:value="open ? searchTerm : config.env.ANTHROPIC_DEFAULT_HAIKU_MODEL"
@input="searchTerm = $event.target.value"
@focus="open = true; searchTerm = ''"
<input type="text" :value="open ? searchTerm : config.env.ANTHROPIC_DEFAULT_HAIKU_MODEL"
@input="searchTerm = $event.target.value" @focus="open = true; searchTerm = ''"
@click.away="open = false; searchTerm = ''"
class="input input-sm w-full font-mono text-xs !bg-space-800 !border-space-border !text-white focus:!bg-space-800 focus:!border-neon-cyan pr-8 placeholder:!text-gray-600"
:placeholder="open ? $store.global.t('searchPlaceholder') : ''"
@@ -500,13 +561,16 @@
:class="$store.data.getModelFamily(modelId) === 'claude' ? 'bg-neon-purple shadow-[0_0_5px_rgba(168,85,247,0.5)]' : ($store.data.getModelFamily(modelId) === 'gemini' ? 'bg-neon-green shadow-[0_0_5px_rgba(34,197,94,0.5)]' : 'bg-gray-600')"></span>
<span x-text="modelId"></span>
</div>
<template x-if="gemini1mSuffix && $store.data.getModelFamily(modelId) === 'gemini'">
<span class="text-[10px] bg-neon-green/10 text-neon-green px-1.5 py-0.5 rounded border border-neon-green/20 font-bold uppercase tracking-tighter">[1M]</span>
<template
x-if="gemini1mSuffix && $store.data.getModelFamily(modelId) === 'gemini'">
<span
class="text-[10px] bg-neon-green/10 text-neon-green px-1.5 py-0.5 rounded border border-neon-green/20 font-bold uppercase tracking-tighter">[1M]</span>
</template>
</a>
</li>
</template>
<li x-show="$store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase())).length === 0">
<li
x-show="$store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase())).length === 0">
<span class="text-xs text-gray-500 italic py-2"
x-text="$store.global.t('noMatchingModels')">No matching models</span>
</li>
@@ -517,14 +581,15 @@
</div>
<!-- MCP CLI Experimental Mode -->
<div class="card bg-space-900/30 border border-space-border/50 p-5">
<div x-show="currentMode === 'proxy'" class="card bg-space-900/30 border border-space-border/50 p-5">
<div class="flex items-center justify-between">
<div class="flex flex-col gap-1">
<span class="text-sm font-medium transition-colors"
:class="config.env.ENABLE_EXPERIMENTAL_MCP_CLI === 'true' ? 'text-neon-green' : 'text-gray-300'"
x-text="$store.global.t('mcpCliExperimental')">Experimental MCP CLI</span>
<span class="text-[11px] text-gray-500" x-text="$store.global.t('mcpCliDesc')">
Enables experimental MCP integration for reliable tool usage with reduced context consumption.
Enables experimental MCP integration for reliable tool usage with reduced context
consumption.
</span>
</div>
<label class="relative inline-flex items-center cursor-pointer">
@@ -540,7 +605,7 @@
</div>
<!-- Gemini 1M Context Suffix Toggle -->
<div class="card bg-space-900/30 border border-space-border/50 p-5">
<div x-show="currentMode === 'proxy'" class="card bg-space-900/30 border border-space-border/50 p-5">
<div class="flex items-center justify-between">
<div class="flex flex-col gap-1">
<span class="text-sm font-medium transition-colors"
@@ -554,8 +619,7 @@
</span>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" class="sr-only peer"
:checked="gemini1mSuffix"
<input type="checkbox" class="sr-only peer" :checked="gemini1mSuffix"
@change="toggleGemini1mSuffix($event.target.checked)"
aria-label="Gemini 1M context mode toggle">
<div
@@ -565,8 +629,9 @@
</div>
</div>
<div class="flex justify-end pt-2 gap-3">
<button class="btn btn-sm btn-ghost border border-space-border/50 hover:border-red-500/30 hover:bg-red-500/5 text-gray-400 hover:text-red-400 px-6 gap-2"
<div x-show="currentMode === 'proxy'" class="flex justify-end pt-2 gap-3">
<button
class="btn btn-sm btn-ghost border border-space-border/50 hover:border-red-500/30 hover:bg-red-500/5 text-gray-400 hover:text-red-400 px-6 gap-2"
@click="restoreDefaultClaudeConfig" :disabled="restoring">
<svg x-show="!restoring" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
@@ -591,20 +656,23 @@
<div class="modal-box bg-space-900 border-2 border-red-500/50">
<h3 class="font-bold text-lg text-red-400 flex items-center gap-2">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span x-text="$store.global.t('confirmRestoreTitle')">Confirm Restore</span>
</h3>
<p class="py-4 text-gray-300" x-text="$store.global.t('confirmRestoreMessage')">
Are you sure you want to restore Claude CLI to default settings? This will remove proxy configuration.
Are you sure you want to restore Claude CLI to default settings? This will remove proxy
configuration.
</p>
<div class="modal-action">
<button class="btn btn-ghost text-gray-400" onclick="document.getElementById('restore_defaults_modal').close()"
<button class="btn btn-ghost text-gray-400"
onclick="document.getElementById('restore_defaults_modal').close()"
x-text="$store.global.t('cancel')">Cancel</button>
<button class="btn bg-red-500 hover:bg-red-600 border-none text-white" @click="executeRestore()"
:disabled="restoring"
:class="{ 'loading': restoring }">
<span x-text="$store.global.t('confirmRestore')" x-show="!restoring">Confirm Restore</span>
<button class="btn bg-red-500 hover:bg-red-600 border-none text-white"
@click="executeRestore()" :disabled="restoring" :class="{ 'loading': restoring }">
<span x-text="$store.global.t('confirmRestore')" x-show="!restoring">Confirm
Restore</span>
</button>
</div>
</div>
@@ -618,19 +686,24 @@
<div class="modal-box bg-space-900 border-2 border-yellow-500/50">
<h3 class="font-bold text-lg text-yellow-400 flex items-center gap-2">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span x-text="$store.global.t('unsavedChangesTitle') || 'Unsaved Changes'">Unsaved Changes</span>
<span x-text="$store.global.t('unsavedChangesTitle') || 'Unsaved Changes'">Unsaved
Changes</span>
</h3>
<p class="py-4 text-gray-300">
<span x-text="$store.global.t('unsavedChangesMessage') || 'Your current configuration doesn\'t match any saved preset.'">Your current configuration doesn't match any saved preset.</span>
<span
x-text="$store.global.t('unsavedChangesMessage') || 'Your current configuration doesn\'t match any saved preset.'">Your
current configuration doesn't match any saved preset.</span>
<br><br>
<span class="text-yellow-400/80" x-text="'Load &quot;' + pendingPresetName + '&quot; and lose current changes?'"></span>
</p>
<div class="modal-action">
<button class="btn btn-ghost text-gray-400" @click="cancelLoadPreset()"
x-text="$store.global.t('cancel')">Cancel</button>
<button class="btn bg-yellow-500 hover:bg-yellow-600 border-none text-black" @click="confirmLoadPreset()">
<button class="btn bg-yellow-500 hover:bg-yellow-600 border-none text-black"
@click="confirmLoadPreset()">
<span x-text="$store.global.t('loadAnyway') || 'Load Anyway'">Load Anyway</span>
</button>
</div>
@@ -645,16 +718,19 @@
<div class="modal-box bg-space-900 border-2 border-neon-cyan/50">
<h3 class="font-bold text-lg text-neon-cyan flex items-center gap-2">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
<span x-text="$store.global.t('savePresetTitle') || 'Save Preset'">Save Preset</span>
</h3>
<p class="py-2 text-gray-400 text-sm" x-text="$store.global.t('savePresetDesc') || 'Save the current configuration as a reusable preset.'">
<p class="py-2 text-gray-400 text-sm"
x-text="$store.global.t('savePresetDesc') || 'Save the current configuration as a reusable preset.'">
Save the current configuration as a reusable preset.
</p>
<div class="form-control mt-4">
<label class="label">
<span class="label-text text-gray-300" x-text="$store.global.t('presetName') || 'Preset Name'">Preset Name</span>
<span class="label-text text-gray-300"
x-text="$store.global.t('presetName') || 'Preset Name'">Preset Name</span>
</label>
<input type="text" x-model="newPresetName"
class="input input-sm input-bordered bg-space-800 border-space-border text-white w-full"
@@ -664,13 +740,14 @@
aria-label="Preset name">
</div>
<div class="modal-action">
<button class="btn btn-ghost text-gray-400" @click="newPresetName = ''; document.getElementById('save_preset_modal').close()"
<button class="btn btn-ghost text-gray-400"
@click="newPresetName = ''; document.getElementById('save_preset_modal').close()"
x-text="$store.global.t('cancel')">Cancel</button>
<button class="btn bg-neon-cyan hover:bg-cyan-600 border-none text-black"
@click="executeSavePreset(newPresetName)"
:disabled="!newPresetName.trim() || savingPreset"
:class="{ 'loading': savingPreset }">
<span x-show="!savingPreset" x-text="$store.global.t('savePreset') || 'Save Preset'">Save Preset</span>
:disabled="!newPresetName.trim() || savingPreset" :class="{ 'loading': savingPreset }">
<span x-show="!savingPreset"
x-text="$store.global.t('savePreset') || 'Save Preset'">Save Preset</span>
</button>
</div>
</div>
@@ -686,11 +763,15 @@
<div class="flex items-center justify-between">
<div>
<div class="text-sm text-gray-400" x-text="$store.global.t('modelsDesc')">Configure model visibility, pinning, and request mapping.</div>
<div class="text-xs text-gray-600 mt-1" x-text="$store.global.t('modelMappingHint')">Model mapping: server-side redirection. Claude Code users: see 'Claude CLI' tab for client-side setup.</div>
<div class="text-sm text-gray-400" x-text="$store.global.t('modelsDesc')">Configure model
visibility, pinning, and request mapping.</div>
<div class="text-xs text-gray-600 mt-1" x-text="$store.global.t('modelMappingHint')">Model
mapping: server-side redirection. Claude Code users: see 'Claude CLI' tab for client-side
setup.</div>
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-500" x-text="$store.global.t('showHidden')">Show Hidden Models</span>
<span class="text-xs text-gray-500" x-text="$store.global.t('showHidden')">Show Hidden
Models</span>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" class="sr-only peer"
:checked="$store.settings.showHiddenModels === true"
@@ -709,7 +790,8 @@
<thead>
<tr>
<th class="pl-4 w-5/12" x-text="$store.global.t('modelId')">Model ID</th>
<th class="w-5/12" x-text="$store.global.t('modelMapping')">Mapping (Target Model ID)</th>
<th class="w-5/12" x-text="$store.global.t('modelMapping')">Mapping (Target Model ID)
</th>
<th class="w-2/12 text-right pr-4" x-text="$store.global.t('actions')">Actions</th>
</tr>
</thead>
@@ -717,8 +799,7 @@
<template x-for="modelId in $store.data.models" :key="modelId">
<tr class="hover:bg-white/5 transition-colors group"
:class="isHidden ? 'opacity-50' : ''"
x-show="!isHidden || $store.settings.showHiddenModels"
x-data="{
x-show="!isHidden || $store.settings.showHiddenModels" x-data="{
newMapping: '',
get config() { return $store.data.modelConfig[modelId] || {} },
get isPinned() { return !!this.config.pinned },
@@ -749,14 +830,14 @@
</svg>
</div>
<div x-show="isEditing(modelId)" class="flex items-center gap-1">
<select x-model="newMapping"
:x-ref="'input-' + modelId"
<select x-model="newMapping" :x-ref="'input-' + modelId"
class="select select-sm bg-space-800 border-space-border text-white focus:outline-none focus:border-neon-cyan flex-1 font-mono text-xs !h-8 min-h-0"
@keydown.enter="await updateModelConfig(modelId, { mapping: newMapping }); stopEditing()"
@keydown.escape="newMapping = config.mapping || ''; stopEditing()">
<option value="" x-text="$store.global.t('none')">None</option>
<template x-for="mId in $store.data.models" :key="mId">
<option :value="mId" x-text="mId" :selected="mId === newMapping"></option>
<option :value="mId" x-text="mId" :selected="mId === newMapping">
</option>
</template>
</select>
<button class="btn-action-success"
@@ -777,14 +858,14 @@
stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<button x-show="config.mapping"
class="btn-action-danger"
<button x-show="config.mapping" class="btn-action-danger"
@click="await updateModelConfig(modelId, { mapping: '' }); stopEditing()"
:title="$store.global.t('delete')">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
@@ -890,8 +971,7 @@
</div>
<!-- Debug Mode -->
<div
class="form-control view-card border-space-border/50 hover:border-neon-purple/50">
<div class="form-control view-card border-space-border/50 hover:border-neon-purple/50">
<div class="flex items-center justify-between">
<div class="flex flex-col gap-1">
<span class="text-sm font-medium text-gray-200"
@@ -902,8 +982,7 @@
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" class="sr-only peer" :checked="serverConfig.debug === true"
@change="toggleDebug($el.checked)"
aria-label="Debug mode toggle">
@change="toggleDebug($el.checked)" aria-label="Debug mode toggle">
<div
class="w-9 h-5 bg-space-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-gray-600 after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-neon-purple peer-checked:after:bg-white">
</div>
@@ -912,8 +991,7 @@
</div>
<!-- Token Cache -->
<div
class="form-control view-card border-space-border/50 hover:border-neon-green/50">
<div class="form-control view-card border-space-border/50 hover:border-neon-green/50">
<div class="flex items-center justify-between">
<div class="flex flex-col gap-1">
<span class="text-sm font-medium text-gray-200"
@@ -926,8 +1004,7 @@
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" class="sr-only peer"
:checked="serverConfig.persistTokenCache === true"
@change="toggleTokenCache($el.checked)"
aria-label="Persist token cache toggle">
@change="toggleTokenCache($el.checked)" aria-label="Persist token cache toggle">
<div
class="w-9 h-5 bg-space-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-gray-600 after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-neon-green peer-checked:after:bg-white">
</div>
@@ -946,12 +1023,10 @@
<input type="range" min="1" max="100" class="custom-range custom-range-cyan flex-1"
:value="serverConfig.maxAccounts || 10"
:style="`background-size: ${((serverConfig.maxAccounts || 10) - 1) / 99 * 100}% 100%`"
@input="toggleMaxAccounts($event.target.value)"
aria-label="Max accounts slider">
@input="toggleMaxAccounts($event.target.value)" aria-label="Max accounts slider">
<input type="number" min="1" max="100"
class="input input-xs input-bordered w-16 bg-space-800 border-space-border text-white font-mono text-center"
:value="serverConfig.maxAccounts || 10"
@change="toggleMaxAccounts($event.target.value)"
:value="serverConfig.maxAccounts || 10" @change="toggleMaxAccounts($event.target.value)"
aria-label="Max accounts value">
</div>
<span class="text-[11px] text-gray-500 mt-1">Maximum number of Google accounts allowed</span>
@@ -971,17 +1046,19 @@
<div class="flex flex-col gap-1 flex-1">
<span class="text-sm font-medium text-gray-200"
x-text="$store.global.t('selectionStrategy')">Selection Strategy</span>
<span class="text-[11px] text-gray-500"
x-text="currentStrategyDescription()">How accounts are selected for requests</span>
<span class="text-[11px] text-gray-500" x-text="currentStrategyDescription()">How
accounts are selected for requests</span>
</div>
<select
class="select bg-space-800 border-space-border text-gray-200 focus:border-neon-cyan focus:ring-neon-cyan/20 w-64"
:value="serverConfig.accountSelection?.strategy || 'hybrid'"
@change="toggleStrategy($el.value)"
aria-label="Account selection strategy">
<option value="hybrid" x-text="$store.global.t('strategyHybridLabel')">Hybrid (Smart Distribution)</option>
<option value="sticky" x-text="$store.global.t('strategyStickyLabel')">Sticky (Cache Optimized)</option>
<option value="round-robin" x-text="$store.global.t('strategyRoundRobinLabel')">Round Robin (Load Balanced)</option>
@change="toggleStrategy($el.value)" aria-label="Account selection strategy">
<option value="hybrid" x-text="$store.global.t('strategyHybridLabel')">Hybrid (Smart
Distribution)</option>
<option value="sticky" x-text="$store.global.t('strategyStickyLabel')">Sticky (Cache
Optimized)</option>
<option value="round-robin" x-text="$store.global.t('strategyRoundRobinLabel')">Round
Robin (Load Balanced)</option>
</select>
</div>
</div>
@@ -1035,13 +1112,11 @@
<input type="range" min="1" max="20" class="custom-range custom-range-purple flex-1"
:value="serverConfig.maxRetries || 5"
:style="`background-size: ${((serverConfig.maxRetries || 5) - 1) / 19 * 100}% 100%`"
@input="toggleMaxRetries($event.target.value)"
aria-label="Max retries slider">
@input="toggleMaxRetries($event.target.value)" aria-label="Max retries slider">
<input type="number" min="1" max="20"
class="input input-xs input-bordered w-16 bg-space-800 border-space-border text-white font-mono text-center"
:value="serverConfig.maxRetries || 5"
@change="toggleMaxRetries($event.target.value)"
aria-label="Max retries value">
@change="toggleMaxRetries($event.target.value)" aria-label="Max retries value">
</div>
</div>
@@ -1119,7 +1194,8 @@
aria-label="Default cooldown value">
</div>
<p class="text-[9px] text-gray-600 mt-1 leading-tight"
x-text="$store.global.t('defaultCooldownDesc')">Fallback cooldown when API doesn't provide a reset time.</p>
x-text="$store.global.t('defaultCooldownDesc')">Fallback cooldown when API doesn't
provide a reset time.</p>
</div>
<div class="form-control">
@@ -1143,7 +1219,8 @@
aria-label="Max wait before error value">
</div>
<p class="text-[9px] text-gray-600 mt-1 leading-tight"
x-text="$store.global.t('maxWaitDesc')">If all accounts are rate-limited longer than this, error immediately.</p>
x-text="$store.global.t('maxWaitDesc')">If all accounts are rate-limited longer than
this, error immediately.</p>
</div>
</div>
@@ -1175,13 +1252,15 @@
aria-label="Rate limit dedup window value">
</div>
<p class="text-[9px] text-gray-600 mt-1 leading-tight"
x-text="$store.global.t('rateLimitDedupWindowDesc')">Prevents concurrent retry storms.</p>
x-text="$store.global.t('rateLimitDedupWindowDesc')">Prevents concurrent retry
storms.</p>
</div>
<div class="form-control">
<label class="label pt-0">
<span class="label-text text-gray-400 text-xs"
x-text="$store.global.t('maxConsecutiveFailures')">Max Consecutive Failures</span>
x-text="$store.global.t('maxConsecutiveFailures')">Max Consecutive
Failures</span>
<span class="label-text-alt font-mono text-neon-cyan text-xs font-semibold"
x-text="serverConfig.maxConsecutiveFailures || 3"></span>
</label>
@@ -1199,7 +1278,8 @@
aria-label="Max consecutive failures value">
</div>
<p class="text-[9px] text-gray-600 mt-1 leading-tight"
x-text="$store.global.t('maxConsecutiveFailuresDesc')">Failures before extended cooldown.</p>
x-text="$store.global.t('maxConsecutiveFailuresDesc')">Failures before extended
cooldown.</p>
</div>
<div class="form-control">
@@ -1223,7 +1303,8 @@
aria-label="Extended cooldown value">
</div>
<p class="text-[9px] text-gray-600 mt-1 leading-tight"
x-text="$store.global.t('extendedCooldownDesc')">Applied after max consecutive failures.</p>
x-text="$store.global.t('extendedCooldownDesc')">Applied after max consecutive
failures.</p>
</div>
<div class="form-control">
@@ -1247,7 +1328,8 @@
aria-label="Max capacity retries value">
</div>
<p class="text-[9px] text-gray-600 mt-1 leading-tight"
x-text="$store.global.t('maxCapacityRetriesDesc')">Retries before switching accounts.</p>
x-text="$store.global.t('maxCapacityRetriesDesc')">Retries before switching
accounts.</p>
</div>
</div>
</div>
@@ -1282,8 +1364,7 @@
</label>
<input type="password" x-model="passwordDialog.oldPassword"
class="input input-sm input-bordered bg-space-800 border-space-border text-white w-full"
:placeholder="$store.global.t('passwordEmptyDesc')"
aria-label="Current password">
:placeholder="$store.global.t('passwordEmptyDesc')" aria-label="Current password">
</div>
<div class="form-control">
@@ -1293,8 +1374,7 @@
</label>
<input type="password" x-model="passwordDialog.newPassword"
class="input input-sm input-bordered bg-space-800 border-space-border text-white w-full"
:placeholder="$store.global.t('passwordLengthDesc')"
aria-label="New password">
:placeholder="$store.global.t('passwordLengthDesc')" aria-label="New password">
</div>
<div class="form-control">
@@ -1305,8 +1385,7 @@
<input type="password" x-model="passwordDialog.confirmPassword"
class="input input-sm input-bordered bg-space-800 border-space-border text-white w-full"
:placeholder="$store.global.t('passwordConfirmDesc')"
@keydown.enter="changePassword()"
aria-label="Confirm new password">
@keydown.enter="changePassword()" aria-label="Confirm new password">
</div>
</div>

View File

@@ -15,7 +15,7 @@
import path from 'path';
import express from 'express';
import { getPublicConfig, saveConfig, config } from '../config.js';
import { DEFAULT_PORT, ACCOUNT_CONFIG_PATH, MAX_ACCOUNTS } from '../constants.js';
import { DEFAULT_PORT, ACCOUNT_CONFIG_PATH, MAX_ACCOUNTS, DEFAULT_PRESETS } from '../constants.js';
import { readClaudeConfig, updateClaudeConfig, replaceClaudeConfig, getClaudeConfigPath, readPresets, savePreset, deletePreset } from '../utils/claude-config.js';
import { logger } from '../utils/logger.js';
import { getAuthorizationUrl, completeOAuthFlow, startCallbackServer } from '../auth/oauth.js';
@@ -614,10 +614,85 @@ export function mountWebUI(app, dirname, accountManager) {
}
});
// ==========================================
// Claude CLI Mode Toggle API (Proxy/Paid)
// ==========================================
/**
* GET /api/claude/mode - Get current mode (proxy or paid)
* Returns 'proxy' if ANTHROPIC_BASE_URL is set to localhost, 'paid' otherwise
*/
app.get('/api/claude/mode', async (req, res) => {
try {
const claudeConfig = await readClaudeConfig();
const baseUrl = claudeConfig.env?.ANTHROPIC_BASE_URL || '';
// Determine mode based on ANTHROPIC_BASE_URL
const isProxy = baseUrl.includes('localhost') || baseUrl.includes('127.0.0.1');
res.json({
status: 'ok',
mode: isProxy ? 'proxy' : 'paid'
});
} catch (error) {
res.status(500).json({ status: 'error', error: error.message });
}
});
/**
* POST /api/claude/mode - Switch between proxy and paid mode
* Body: { mode: 'proxy' | 'paid' }
*
* When switching to 'paid' mode:
* - Removes the entire 'env' object from settings.json
* - Claude CLI uses its built-in defaults (official Anthropic API)
*
* When switching to 'proxy' mode:
* - Sets 'env' to the first default preset config (from constants.js)
*/
app.post('/api/claude/mode', async (req, res) => {
try {
const { mode } = req.body;
if (!mode || !['proxy', 'paid'].includes(mode)) {
return res.status(400).json({
status: 'error',
error: 'mode must be "proxy" or "paid"'
});
}
const claudeConfig = await readClaudeConfig();
if (mode === 'proxy') {
// Switch to proxy mode - use first default preset config (e.g., "Claude Thinking")
claudeConfig.env = { ...DEFAULT_PRESETS[0].config };
} else {
// Switch to paid mode - remove env entirely
delete claudeConfig.env;
}
// Save the updated config
const newConfig = await replaceClaudeConfig(claudeConfig);
logger.info(`[WebUI] Switched Claude CLI to ${mode} mode`);
res.json({
status: 'ok',
mode,
config: newConfig,
message: `Switched to ${mode === 'proxy' ? 'Proxy' : 'Paid (Anthropic API)'} mode. Restart Claude CLI to apply.`
});
} catch (error) {
logger.error('[WebUI] Error switching mode:', error);
res.status(500).json({ status: 'error', error: error.message });
}
});
// ==========================================
// Claude CLI Presets API
// ==========================================
/**
* GET /api/claude/presets - Get all saved presets
*/