Compare commits
14 Commits
793b20db59
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1050b96d2a | |||
| 5deb0b0754 | |||
|
|
2ea9f4ba8e | ||
|
|
b72aa0e056 | ||
|
|
dfc054ac9e | ||
|
|
90b38bbb56 | ||
|
|
2ab0c7943d | ||
|
|
ae2cdc0227 | ||
|
|
a43d2332ca | ||
|
|
33584d31bb | ||
|
|
f80e60668c | ||
|
|
cf2af0ba4b | ||
|
|
02f8e2e323 | ||
|
|
7985524d49 |
33
CLAUDE.md
33
CLAUDE.md
@@ -153,7 +153,8 @@ public/
|
||||
│ ├── settings-store.js # Settings management store
|
||||
│ ├── components/ # UI Components
|
||||
│ │ ├── dashboard.js # Main dashboard orchestrator
|
||||
│ │ ├── account-manager.js # Account list & OAuth handling
|
||||
│ │ ├── account-manager.js # Account list, OAuth, & threshold settings
|
||||
│ │ ├── models.js # Model list with draggable quota threshold markers
|
||||
│ │ ├── logs-viewer.js # Live log streaming
|
||||
│ │ ├── claude-config.js # CLI settings editor
|
||||
│ │ ├── server-config.js # Server settings UI
|
||||
@@ -184,6 +185,7 @@ public/
|
||||
- Strategies: `sticky` (cache-optimized), `round-robin` (load-balanced), `hybrid` (smart distribution)
|
||||
- **src/auth/**: Authentication including Google OAuth, token extraction, database access, and auto-rebuild of native modules
|
||||
- **src/format/**: Format conversion between Anthropic and Google Generative AI formats
|
||||
- **src/config.js**: Runtime configuration with defaults (`globalQuotaThreshold`, `maxAccounts`, `accountSelection`, etc.)
|
||||
- **src/constants.js**: API endpoints, model mappings, fallback config, OAuth config, and all configuration values
|
||||
- **src/modules/usage-stats.js**: Tracks request volume by model/family, persists 30-day history to JSON, and auto-prunes old data.
|
||||
- **src/fallback-config.js**: Model fallback mappings (`getFallbackModel()`, `hasFallback()`)
|
||||
@@ -217,13 +219,24 @@ public/
|
||||
- Scoring formula: `score = (Health × 2) + ((Tokens / MaxTokens × 100) × 5) + (Quota × 1) + (LRU × 0.1)`
|
||||
- Health scores: Track success/failure patterns with passive recovery
|
||||
- Token buckets: Client-side rate limiting (50 tokens, 6 per minute regeneration)
|
||||
- Quota awareness: Accounts with critical quota (<5%) are deprioritized
|
||||
- Quota awareness: Accounts below configurable quota threshold are deprioritized
|
||||
- LRU freshness: Prefer accounts that have rested longer
|
||||
- **Emergency/Last Resort Fallback**: When all accounts are exhausted:
|
||||
- Emergency fallback: Bypasses health check, adds 250ms throttle delay
|
||||
- Last resort fallback: Bypasses both health and token checks, adds 500ms throttle delay
|
||||
- Configuration in `src/config.js` under `accountSelection`
|
||||
|
||||
**Quota Threshold (Quota Protection):**
|
||||
- Configurable minimum quota level before the proxy switches to another account
|
||||
- Three-tier threshold resolution (highest priority first):
|
||||
1. **Per-model**: `account.modelQuotaThresholds[modelId]` - override for specific models
|
||||
2. **Per-account**: `account.quotaThreshold` - account-level default
|
||||
3. **Global**: `config.globalQuotaThreshold` - server-wide default (0 = disabled)
|
||||
- All thresholds are stored as fractions (0-0.99), displayed as percentages (0-99%) in the UI
|
||||
- Global threshold configurable via WebUI Settings → Quota Protection
|
||||
- Per-account and per-model thresholds configurable via Account Settings modal or draggable markers on model quota bars
|
||||
- Used by `QuotaTracker.isQuotaCritical()` in the hybrid strategy to exclude low-quota accounts
|
||||
|
||||
**Account Data Model:**
|
||||
Each account object in `accounts.json` contains:
|
||||
- **Basic Info**: `email`, `source` (oauth/manual/database), `enabled`, `lastUsed`
|
||||
@@ -232,6 +245,9 @@ Each account object in `accounts.json` contains:
|
||||
- `tier`: 'free' | 'pro' | 'ultra' (detected from `paidTier` or `currentTier`)
|
||||
- **Quota**: `{ models: {}, lastChecked }` - model-specific quota cache
|
||||
- `models[modelId]`: `{ remainingFraction, resetTime }` from `fetchAvailableModels` API
|
||||
- **Quota Thresholds**: Per-account quota protection settings
|
||||
- `quotaThreshold`: Account-level minimum quota fraction (0-0.99, `undefined` = use global)
|
||||
- `modelQuotaThresholds`: `{ [modelId]: fraction }` - per-model overrides (takes priority over account-level)
|
||||
- **Rate Limits**: `modelRateLimits[modelId]` - temporary rate limit state (in-memory during runtime)
|
||||
- **Validity**: `isInvalid`, `invalidReason` - tracks accounts needing re-authentication
|
||||
|
||||
@@ -287,7 +303,8 @@ Each account object in `accounts.json` contains:
|
||||
- Layered architecture: Service Layer (`account-actions.js`) → Component Layer → UI
|
||||
- **Features**:
|
||||
- Real-time dashboard with Chart.js visualization and subscription tier distribution
|
||||
- Account list with tier badges (Ultra/Pro/Free) and quota progress bars
|
||||
- Account list with tier badges (Ultra/Pro/Free), quota progress bars, and per-account threshold settings
|
||||
- Model quota bars with draggable per-account threshold markers (color-coded, with overlap handling)
|
||||
- OAuth flow handling via popup window
|
||||
- Live log streaming via Server-Sent Events (SSE)
|
||||
- Config editor for both Proxy and Claude CLI (`~/.claude/settings.json`)
|
||||
@@ -300,7 +317,7 @@ Each account object in `accounts.json` contains:
|
||||
- **Security**: Optional password protection via `WEBUI_PASSWORD` env var
|
||||
- **Config Redaction**: Sensitive values (passwords, tokens) are redacted in API responses
|
||||
- **Smart Refresh**: Client-side polling with ±20% jitter and tab visibility detection (3x slower when hidden)
|
||||
- **i18n Support**: English, Chinese (中文), Indonesian (Bahasa), Portuguese (PT-BR)
|
||||
- **i18n Support**: English, Chinese (中文), Indonesian (Bahasa), Portuguese (PT-BR), Turkish (Türkçe)
|
||||
|
||||
## Testing Notes
|
||||
|
||||
@@ -353,14 +370,16 @@ Each account object in `accounts.json` contains:
|
||||
|
||||
**WebUI APIs:**
|
||||
|
||||
- `/api/accounts/*` - Account management (list, add, remove, refresh)
|
||||
- `/api/config/*` - Server configuration (read/write)
|
||||
- `/api/accounts/*` - Account management (list, add, remove, refresh, threshold settings)
|
||||
- `PATCH /api/accounts/:email` - Update account quota thresholds (`quotaThreshold`, `modelQuotaThresholds`)
|
||||
- `/api/config/*` - Server configuration (read/write, includes `globalQuotaThreshold`)
|
||||
- `/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
|
||||
- `/account-limits` - Fetch account quotas and subscription data
|
||||
- Returns: `{ accounts: [{ email, subscription: { tier, projectId }, limits: {...} }], models: [...] }`
|
||||
- Returns: `{ accounts: [{ email, subscription, limits, quotaThreshold, modelQuotaThresholds, ... }], models: [...], globalQuotaThreshold }`
|
||||
- Query params: `?format=table` (ASCII table) or `?includeHistory=true` (adds usage stats)
|
||||
|
||||
## Frontend Development
|
||||
|
||||
412
README.md
412
README.md
@@ -4,8 +4,6 @@
|
||||
[](https://www.npmjs.com/package/antigravity-claude-proxy)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
<a href="https://buymeacoffee.com/badrinarayanans" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="50"></a>
|
||||
|
||||
A proxy server that exposes an **Anthropic-compatible API** backed by **Antigravity's Cloud Code**, letting you use Claude and Gemini models with **Claude Code CLI**.
|
||||
|
||||

|
||||
@@ -132,7 +130,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 +222,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:
|
||||
@@ -248,384 +261,19 @@ Then run `claude` for official API or `claude-antigravity` for this proxy.
|
||||
|
||||
---
|
||||
|
||||
## Available Models
|
||||
|
||||
### Claude Models
|
||||
|
||||
| Model ID | Description |
|
||||
| ---------------------------- | ---------------------------------------- |
|
||||
| `claude-sonnet-4-5-thinking` | Claude Sonnet 4.5 with extended thinking |
|
||||
| `claude-opus-4-5-thinking` | Claude Opus 4.5 with extended thinking |
|
||||
| `claude-sonnet-4-5` | Claude Sonnet 4.5 without thinking |
|
||||
|
||||
### Gemini Models
|
||||
|
||||
| Model ID | Description |
|
||||
| ------------------- | ------------------------------- |
|
||||
| `gemini-3-flash` | Gemini 3 Flash with thinking |
|
||||
| `gemini-3-pro-low` | Gemini 3 Pro Low with thinking |
|
||||
| `gemini-3-pro-high` | Gemini 3 Pro High with thinking |
|
||||
|
||||
Gemini models include full thinking support with `thoughtSignature` handling for multi-turn conversations.
|
||||
|
||||
---
|
||||
|
||||
## Multi-Account Load Balancing
|
||||
|
||||
When you add multiple accounts, the proxy intelligently distributes requests across them using configurable selection strategies.
|
||||
|
||||
### Account Selection Strategies
|
||||
|
||||
Choose a strategy based on your needs:
|
||||
|
||||
| Strategy | Best For | Description |
|
||||
| --- | --- | --- |
|
||||
| **Hybrid** (Default) | Most users | Smart selection combining health score, token bucket rate limiting, quota awareness, and LRU freshness |
|
||||
| **Sticky** | Prompt caching | Stays on the same account to maximize cache hits, switches only when rate-limited |
|
||||
| **Round-Robin** | Even distribution | Cycles through accounts sequentially for balanced load |
|
||||
|
||||
**Configure via CLI:**
|
||||
|
||||
```bash
|
||||
antigravity-claude-proxy start --strategy=hybrid # Default: smart distribution
|
||||
antigravity-claude-proxy start --strategy=sticky # Cache-optimized
|
||||
antigravity-claude-proxy start --strategy=round-robin # Load-balanced
|
||||
```
|
||||
|
||||
**Or via WebUI:** Settings → Server → Account Selection Strategy
|
||||
|
||||
### How It Works
|
||||
|
||||
- **Health Score Tracking**: Accounts earn points for successful requests and lose points for failures/rate-limits
|
||||
- **Token Bucket Rate Limiting**: Client-side throttling with regenerating tokens (50 max, 6/minute)
|
||||
- **Quota Awareness**: Accounts with critical quota (<5%) are deprioritized; exhausted accounts trigger emergency fallback
|
||||
- **Emergency Fallback**: When all accounts appear exhausted, bypasses checks with throttle delays (250-500ms)
|
||||
- **Automatic Cooldown**: Rate-limited accounts recover automatically after reset time expires
|
||||
- **Invalid Account Detection**: Accounts needing re-authentication are marked and skipped
|
||||
- **Prompt Caching Support**: Session IDs derived from conversation enable cache hits across turns
|
||||
|
||||
### Monitoring
|
||||
|
||||
Check account status, subscription tiers, and quota anytime:
|
||||
|
||||
```bash
|
||||
# Web UI: http://localhost:8080/ (Accounts tab - shows tier badges and quota progress)
|
||||
# CLI Table:
|
||||
curl "http://localhost:8080/account-limits?format=table"
|
||||
```
|
||||
|
||||
#### CLI Management Reference
|
||||
|
||||
If you prefer using the terminal for management:
|
||||
|
||||
```bash
|
||||
# List all accounts
|
||||
antigravity-claude-proxy accounts list
|
||||
|
||||
# Verify account health
|
||||
antigravity-claude-proxy accounts verify
|
||||
|
||||
# Interactive CLI menu
|
||||
antigravity-claude-proxy accounts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Web Management Console
|
||||
|
||||
The proxy includes a built-in, modern web interface for real-time monitoring and configuration. Access the console at: `http://localhost:8080` (default port).
|
||||
|
||||

|
||||
|
||||
### Key Features
|
||||
|
||||
- **Real-time Dashboard**: Monitor request volume, active accounts, model health, and subscription tier distribution.
|
||||
- **Visual Model Quota**: Track per-model usage and next reset times with color-coded progress indicators.
|
||||
- **Account Management**: Add/remove Google accounts via OAuth, view subscription tiers (Free/Pro/Ultra) and quota status at a glance.
|
||||
- **Manual OAuth Mode**: Add accounts on headless servers by copying the OAuth URL and pasting the authorization code.
|
||||
- **Claude CLI Configuration**: Edit your `~/.claude/settings.json` directly from the browser.
|
||||
- **Persistent History**: Tracks request volume by model family for 30 days, persisting across server restarts.
|
||||
- **Time Range Filtering**: Analyze usage trends over 1H, 6H, 24H, 7D, or All Time periods.
|
||||
- **Smart Analysis**: Auto-select top 5 most used models or toggle between Family/Model views.
|
||||
- **Live Logs**: Stream server logs with level-based filtering and search.
|
||||
- **Advanced Tuning**: Configure retries, timeouts, and debug mode on the fly.
|
||||
- **Multi-language Interface**: Full support for English, Chinese (中文), Indonesian (Bahasa), and Portuguese (PT-BR).
|
||||
|
||||
---
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
While most users can use the default settings, you can tune the proxy behavior via the **Settings → Server** tab in the WebUI or by creating a `config.json` file.
|
||||
|
||||
### Configurable Options
|
||||
|
||||
- **API Key Authentication**: Protect `/v1/*` API endpoints with `API_KEY` env var or `apiKey` in config.
|
||||
- **WebUI Password**: Secure your dashboard with `WEBUI_PASSWORD` env var or in config.
|
||||
- **Custom Port**: Change the default `8080` port.
|
||||
- **Retry Logic**: Configure `maxRetries`, `retryBaseMs`, and `retryMaxMs`.
|
||||
- **Rate Limit Handling**: Comprehensive rate limit detection from headers and error messages with intelligent retry-after parsing.
|
||||
- **Load Balancing**: Adjust `defaultCooldownMs` and `maxWaitBeforeErrorMs`.
|
||||
- **Persistence**: Enable `persistTokenCache` to save OAuth sessions across restarts.
|
||||
- **Max Accounts**: Set `maxAccounts` (1-100) to limit the number of Google accounts. Default: 10.
|
||||
- **Endpoint Fallback**: Automatic 403/404 endpoint fallback for API compatibility.
|
||||
|
||||
Refer to `config.example.json` for a complete list of fields and documentation.
|
||||
|
||||
---
|
||||
|
||||
## macOS Menu Bar App
|
||||
|
||||
For macOS users who prefer a native experience, there's a companion menu bar app that provides quick access to server controls without touching the terminal. Get it from: [antigravity-claude-proxy-bar](https://github.com/IrvanFza/antigravity-claude-proxy-bar)
|
||||
|
||||
> **Note:** This is a GUI wrapper only. You still need to install and setup the proxy server first using one of the [installation methods](#installation) above.
|
||||
|
||||

|
||||
|
||||
### Key Features
|
||||
|
||||
- **Server Control**: Start/stop the proxy server with a single click or ⌘S shortcut.
|
||||
- **Status Indicator**: Menu bar icon shows server running state at a glance.
|
||||
- **WebUI Access**: Open the web management console directly from the menu.
|
||||
- **Port Configuration**: Customize the proxy server port (default: 8080).
|
||||
- **Account Selection Strategy**: Choose between Hybrid, Sticky, or Round-Robin load balancing strategies.
|
||||
- **Auto-Start Options**: Launch server on app start and launch app at login.
|
||||
- **Native Experience**: Clean, native SwiftUI interface designed for macOS.
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
| ----------------- | ------ | --------------------------------------------------------------------- |
|
||||
| `/health` | GET | Health check |
|
||||
| `/account-limits` | GET | Account status and quota limits (add `?format=table` for ASCII table) |
|
||||
| `/v1/messages` | POST | Anthropic Messages API |
|
||||
| `/v1/models` | GET | List available models |
|
||||
| `/refresh-token` | POST | Force token refresh |
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
Run the test suite (requires server running):
|
||||
|
||||
```bash
|
||||
# Start server in one terminal
|
||||
npm start
|
||||
|
||||
# Run tests in another terminal
|
||||
npm test
|
||||
```
|
||||
|
||||
Individual tests:
|
||||
|
||||
```bash
|
||||
npm run test:signatures # Thinking signatures
|
||||
npm run test:multiturn # Multi-turn with tools
|
||||
npm run test:streaming # Streaming SSE events
|
||||
npm run test:interleaved # Interleaved thinking
|
||||
npm run test:images # Image processing
|
||||
npm run test:caching # Prompt caching
|
||||
npm run test:strategies # Account selection strategies
|
||||
npm run test:cache-control # Cache control field stripping
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Windows: OAuth Port Error (EACCES)
|
||||
|
||||
On Windows, the default OAuth callback port (51121) may be reserved by Hyper-V, WSL2, or Docker. If you see:
|
||||
|
||||
```
|
||||
Error: listen EACCES: permission denied 0.0.0.0:51121
|
||||
```
|
||||
|
||||
The proxy will automatically try fallback ports (51122-51126). If all ports fail, try these solutions:
|
||||
|
||||
#### Option 1: Use a Custom Port (Recommended)
|
||||
|
||||
Set a custom port outside the reserved range:
|
||||
|
||||
```bash
|
||||
# Windows PowerShell
|
||||
$env:OAUTH_CALLBACK_PORT = "3456"
|
||||
antigravity-claude-proxy start
|
||||
|
||||
# Windows CMD
|
||||
set OAUTH_CALLBACK_PORT=3456
|
||||
antigravity-claude-proxy start
|
||||
|
||||
# Or add to your .env file
|
||||
OAUTH_CALLBACK_PORT=3456
|
||||
```
|
||||
|
||||
#### Option 2: Reset Windows NAT
|
||||
|
||||
Run as Administrator:
|
||||
|
||||
```powershell
|
||||
net stop winnat
|
||||
net start winnat
|
||||
```
|
||||
|
||||
#### Option 3: Check Reserved Ports
|
||||
|
||||
See which ports are reserved:
|
||||
|
||||
```powershell
|
||||
netsh interface ipv4 show excludedportrange protocol=tcp
|
||||
```
|
||||
|
||||
If 51121 is in a reserved range, use Option 1 with a port outside those ranges.
|
||||
|
||||
#### Option 4: Permanently Exclude Port (Admin)
|
||||
|
||||
Reserve the port before Hyper-V claims it (run as Administrator):
|
||||
|
||||
```powershell
|
||||
netsh int ipv4 add excludedportrange protocol=tcp startport=51121 numberofports=1
|
||||
```
|
||||
|
||||
> **Note:** The server automatically tries fallback ports (51122-51126) if the primary port fails.
|
||||
|
||||
---
|
||||
|
||||
### "Could not extract token from Antigravity"
|
||||
|
||||
If using single-account mode with Antigravity:
|
||||
|
||||
1. Make sure Antigravity app is installed and running
|
||||
2. Ensure you're logged in to Antigravity
|
||||
|
||||
Or add accounts via OAuth instead: `antigravity-claude-proxy accounts add`
|
||||
|
||||
### 401 Authentication Errors
|
||||
|
||||
The token might have expired. Try:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/refresh-token
|
||||
```
|
||||
|
||||
Or re-authenticate the account:
|
||||
|
||||
```bash
|
||||
antigravity-claude-proxy accounts
|
||||
```
|
||||
|
||||
### Rate Limiting (429)
|
||||
|
||||
With multiple accounts, the proxy automatically switches to the next available account. With a single account, you'll need to wait for the rate limit to reset.
|
||||
|
||||
### Account Shows as "Invalid"
|
||||
|
||||
Re-authenticate the account:
|
||||
|
||||
```bash
|
||||
antigravity-claude-proxy accounts
|
||||
# Choose "Re-authenticate" for the invalid account
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Safety, Usage, and Risk Notices
|
||||
|
||||
### Intended Use
|
||||
|
||||
- Personal / internal development only
|
||||
- Respect internal quotas and data handling policies
|
||||
- Not for production services or bypassing intended limits
|
||||
|
||||
### Not Suitable For
|
||||
|
||||
- Production application traffic
|
||||
- High-volume automated extraction
|
||||
- Any use that violates Acceptable Use Policies
|
||||
|
||||
### Warning (Assumption of Risk)
|
||||
|
||||
By using this software, you acknowledge and accept the following:
|
||||
|
||||
- **Terms of Service risk**: This approach may violate the Terms of Service of AI model providers (Anthropic, Google, etc.). You are solely responsible for ensuring compliance with all applicable terms and policies.
|
||||
|
||||
- **Account risk**: Providers may detect this usage pattern and take punitive action, including suspension, permanent ban, or loss of access to paid subscriptions.
|
||||
|
||||
- **No guarantees**: Providers may change APIs, authentication, or policies at any time, which can break this method without notice.
|
||||
|
||||
- **Assumption of risk**: You assume all legal, financial, and technical risks. The authors and contributors of this project bear no responsibility for any consequences arising from your use.
|
||||
|
||||
**Use at your own risk. Proceed only if you understand and accept these risks.**
|
||||
|
||||
---
|
||||
|
||||
## Legal
|
||||
|
||||
- **Not affiliated with Google or Anthropic.** This is an independent open-source project and is not endorsed by, sponsored by, or affiliated with Google LLC or Anthropic PBC.
|
||||
|
||||
- "Antigravity", "Gemini", "Google Cloud", and "Google" are trademarks of Google LLC.
|
||||
|
||||
- "Claude" and "Anthropic" are trademarks of Anthropic PBC.
|
||||
|
||||
- Software is provided "as is", without warranty. You are responsible for complying with all applicable Terms of Service and Acceptable Use Policies.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
### For Developers & Contributors
|
||||
|
||||
This project uses a local Tailwind CSS build system. CSS is pre-compiled and included in the repository, so you can run the project immediately after cloning.
|
||||
|
||||
#### Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/badri-s2001/antigravity-claude-proxy.git
|
||||
cd antigravity-claude-proxy
|
||||
npm install # Automatically builds CSS via prepare hook
|
||||
npm start # Start server (no rebuild needed)
|
||||
```
|
||||
|
||||
#### Frontend Development
|
||||
|
||||
If you need to modify styles in `public/css/src/input.css`:
|
||||
|
||||
```bash
|
||||
# Option 1: Build once
|
||||
npm run build:css
|
||||
|
||||
# Option 2: Watch for changes (auto-rebuild)
|
||||
npm run watch:css
|
||||
|
||||
# Option 3: Watch both CSS and server (recommended)
|
||||
npm run dev:full
|
||||
```
|
||||
|
||||
**File Structure:**
|
||||
- `public/css/src/input.css` - Source CSS with Tailwind `@apply` directives (edit this)
|
||||
- `public/css/style.css` - Compiled & minified CSS (auto-generated, don't edit)
|
||||
- `tailwind.config.js` - Tailwind configuration
|
||||
- `postcss.config.js` - PostCSS configuration
|
||||
|
||||
#### Backend-Only Development
|
||||
|
||||
If you're only working on backend code and don't need frontend dev tools:
|
||||
|
||||
```bash
|
||||
npm install --production # Skip devDependencies (saves ~20MB)
|
||||
npm start
|
||||
```
|
||||
|
||||
**Note:** Pre-compiled CSS is committed to the repository, so you don't need to rebuild unless modifying styles.
|
||||
|
||||
#### Project Structure
|
||||
|
||||
See [CLAUDE.md](./CLAUDE.md) for detailed architecture documentation, including:
|
||||
- Request flow and module organization
|
||||
- Frontend architecture (Alpine.js + Tailwind)
|
||||
- Service layer patterns (`ErrorHandler.withLoading`, `AccountActions`)
|
||||
- Dashboard module documentation
|
||||
## Documentation
|
||||
|
||||
- [Available Models](docs/models.md)
|
||||
- [Multi-Account Load Balancing](docs/load-balancing.md)
|
||||
- [Web Management Console](docs/web-console.md)
|
||||
- [Advanced Configuration](docs/configuration.md)
|
||||
- [macOS Menu Bar App](docs/menubar-app.md)
|
||||
- [API Endpoints](docs/api-endpoints.md)
|
||||
- [Testing](docs/testing.md)
|
||||
- [Troubleshooting](docs/troubleshooting.md)
|
||||
- [Safety, Usage, and Risk Notices](docs/safety-notices.md)
|
||||
- [Legal](docs/legal.md)
|
||||
- [Development](docs/development.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -644,6 +292,8 @@ MIT
|
||||
|
||||
---
|
||||
|
||||
<a href="https://buymeacoffee.com/badrinarayanans" target="_blank"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" height="50"></a>
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#badrisnarayanan/antigravity-claude-proxy&type=date&legend=top-left)
|
||||
|
||||
31
docs/antigravity-claude-proxy.service
Normal file
31
docs/antigravity-claude-proxy.service
Normal file
@@ -0,0 +1,31 @@
|
||||
# Antigravity Claude Proxy - systemd user service
|
||||
#
|
||||
# Installation:
|
||||
# 1. Copy this file to ~/.config/systemd/user/antigravity-claude-proxy.service
|
||||
# 2. Copy start-proxy.sh to a permanent location (e.g., ~/.local/bin/)
|
||||
# 3. Update PROXY_SCRIPT below to point to start-proxy.sh
|
||||
# 4. Run: systemctl --user daemon-reload
|
||||
# 5. Run: systemctl --user enable --now antigravity-claude-proxy
|
||||
#
|
||||
# Management:
|
||||
# systemctl --user status antigravity-claude-proxy
|
||||
# systemctl --user restart antigravity-claude-proxy
|
||||
# journalctl --user -u antigravity-claude-proxy -f
|
||||
|
||||
[Unit]
|
||||
Description=Antigravity Claude Proxy Server
|
||||
Documentation=https://github.com/badri-s2001/antigravity-claude-proxy
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# Customize the port as needed
|
||||
Environment=PORT=3001
|
||||
# Path to the wrapper script - update this to match your installation
|
||||
# The wrapper script handles nvm/fnm/volta/asdf automatically
|
||||
ExecStart=%h/.local/bin/start-proxy.sh start
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
9
docs/api-endpoints.md
Normal file
9
docs/api-endpoints.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# API Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
| ----------------- | ------ | --------------------------------------------------------------------- |
|
||||
| `/health` | GET | Health check |
|
||||
| `/account-limits` | GET | Account status and quota limits (add `?format=table` for ASCII table) |
|
||||
| `/v1/messages` | POST | Anthropic Messages API |
|
||||
| `/v1/models` | GET | List available models |
|
||||
| `/refresh-token` | POST | Force token refresh |
|
||||
18
docs/configuration.md
Normal file
18
docs/configuration.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Advanced Configuration
|
||||
|
||||
While most users can use the default settings, you can tune the proxy behavior via the **Settings → Server** tab in the WebUI or by creating a `config.json` file.
|
||||
|
||||
## Configurable Options
|
||||
|
||||
- **API Key Authentication**: Protect `/v1/*` API endpoints with `API_KEY` env var or `apiKey` in config.
|
||||
- **WebUI Password**: Secure your dashboard with `WEBUI_PASSWORD` env var or in config.
|
||||
- **Custom Port**: Change the default `8080` port.
|
||||
- **Retry Logic**: Configure `maxRetries`, `retryBaseMs`, and `retryMaxMs`.
|
||||
- **Rate Limit Handling**: Comprehensive rate limit detection from headers and error messages with intelligent retry-after parsing.
|
||||
- **Load Balancing**: Adjust `defaultCooldownMs` and `maxWaitBeforeErrorMs`.
|
||||
- **Persistence**: Enable `persistTokenCache` to save OAuth sessions across restarts.
|
||||
- **Max Accounts**: Set `maxAccounts` (1-100) to limit the number of Google accounts. Default: 10.
|
||||
- **Quota Threshold**: Set `globalQuotaThreshold` (0-0.99) to switch accounts before quota drops below a minimum level. Supports per-account and per-model overrides.
|
||||
- **Endpoint Fallback**: Automatic 403/404 endpoint fallback for API compatibility.
|
||||
|
||||
Refer to `config.example.json` for a complete list of fields and documentation.
|
||||
54
docs/development.md
Normal file
54
docs/development.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Development
|
||||
|
||||
## For Developers & Contributors
|
||||
|
||||
This project uses a local Tailwind CSS build system. CSS is pre-compiled and included in the repository, so you can run the project immediately after cloning.
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/badri-s2001/antigravity-claude-proxy.git
|
||||
cd antigravity-claude-proxy
|
||||
npm install # Automatically builds CSS via prepare hook
|
||||
npm start # Start server (no rebuild needed)
|
||||
```
|
||||
|
||||
### Frontend Development
|
||||
|
||||
If you need to modify styles in `public/css/src/input.css`:
|
||||
|
||||
```bash
|
||||
# Option 1: Build once
|
||||
npm run build:css
|
||||
|
||||
# Option 2: Watch for changes (auto-rebuild)
|
||||
npm run watch:css
|
||||
|
||||
# Option 3: Watch both CSS and server (recommended)
|
||||
npm run dev:full
|
||||
```
|
||||
|
||||
**File Structure:**
|
||||
- `public/css/src/input.css` - Source CSS with Tailwind `@apply` directives (edit this)
|
||||
- `public/css/style.css` - Compiled & minified CSS (auto-generated, don't edit)
|
||||
- `tailwind.config.js` - Tailwind configuration
|
||||
- `postcss.config.js` - PostCSS configuration
|
||||
|
||||
### Backend-Only Development
|
||||
|
||||
If you're only working on backend code and don't need frontend dev tools:
|
||||
|
||||
```bash
|
||||
npm install --production # Skip devDependencies (saves ~20MB)
|
||||
npm start
|
||||
```
|
||||
|
||||
**Note:** Pre-compiled CSS is committed to the repository, so you don't need to rebuild unless modifying styles.
|
||||
|
||||
### Project Structure
|
||||
|
||||
See [CLAUDE.md](../CLAUDE.md) for detailed architecture documentation, including:
|
||||
- Request flow and module organization
|
||||
- Frontend architecture (Alpine.js + Tailwind)
|
||||
- Service layer patterns (`ErrorHandler.withLoading`, `AccountActions`)
|
||||
- Dashboard module documentation
|
||||
34
docs/install-service.sh
Executable file
34
docs/install-service.sh
Executable file
@@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
# Install antigravity-claude-proxy as a systemd user service
|
||||
# Run from anywhere - automatically detects paths
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
LOCAL_BIN="$HOME/.local/bin"
|
||||
SYSTEMD_USER_DIR="$HOME/.config/systemd/user"
|
||||
|
||||
echo "Installing antigravity-claude-proxy systemd service..."
|
||||
|
||||
# Create directories if needed
|
||||
mkdir -p "$LOCAL_BIN"
|
||||
mkdir -p "$SYSTEMD_USER_DIR"
|
||||
|
||||
# Copy wrapper script
|
||||
cp "$SCRIPT_DIR/start-proxy.sh" "$LOCAL_BIN/"
|
||||
chmod +x "$LOCAL_BIN/start-proxy.sh"
|
||||
echo " Installed: $LOCAL_BIN/start-proxy.sh"
|
||||
|
||||
# Copy service file
|
||||
cp "$SCRIPT_DIR/antigravity-claude-proxy.service" "$SYSTEMD_USER_DIR/"
|
||||
echo " Installed: $SYSTEMD_USER_DIR/antigravity-claude-proxy.service"
|
||||
|
||||
# Reload systemd
|
||||
systemctl --user daemon-reload
|
||||
echo " Reloaded systemd user daemon"
|
||||
|
||||
echo ""
|
||||
echo "Installation complete. Commands:"
|
||||
echo " systemctl --user enable --now antigravity-claude-proxy # Start and enable on boot"
|
||||
echo " systemctl --user status antigravity-claude-proxy # Check status"
|
||||
echo " journalctl --user -u antigravity-claude-proxy -f # View logs"
|
||||
9
docs/legal.md
Normal file
9
docs/legal.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Legal
|
||||
|
||||
- **Not affiliated with Google or Anthropic.** This is an independent open-source project and is not endorsed by, sponsored by, or affiliated with Google LLC or Anthropic PBC.
|
||||
|
||||
- "Antigravity", "Gemini", "Google Cloud", and "Google" are trademarks of Google LLC.
|
||||
|
||||
- "Claude" and "Anthropic" are trademarks of Anthropic PBC.
|
||||
|
||||
- Software is provided "as is", without warranty. You are responsible for complying with all applicable Terms of Service and Acceptable Use Policies.
|
||||
59
docs/load-balancing.md
Normal file
59
docs/load-balancing.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Multi-Account Load Balancing
|
||||
|
||||
When you add multiple accounts, the proxy intelligently distributes requests across them using configurable selection strategies.
|
||||
|
||||
## Account Selection Strategies
|
||||
|
||||
Choose a strategy based on your needs:
|
||||
|
||||
| Strategy | Best For | Description |
|
||||
| --- | --- | --- |
|
||||
| **Hybrid** (Default) | Most users | Smart selection combining health score, token bucket rate limiting, quota awareness, and LRU freshness |
|
||||
| **Sticky** | Prompt caching | Stays on the same account to maximize cache hits, switches only when rate-limited |
|
||||
| **Round-Robin** | Even distribution | Cycles through accounts sequentially for balanced load |
|
||||
|
||||
**Configure via CLI:**
|
||||
|
||||
```bash
|
||||
antigravity-claude-proxy start --strategy=hybrid # Default: smart distribution
|
||||
antigravity-claude-proxy start --strategy=sticky # Cache-optimized
|
||||
antigravity-claude-proxy start --strategy=round-robin # Load-balanced
|
||||
```
|
||||
|
||||
**Or via WebUI:** Settings → Server → Account Selection Strategy
|
||||
|
||||
## How It Works
|
||||
|
||||
- **Health Score Tracking**: Accounts earn points for successful requests and lose points for failures/rate-limits
|
||||
- **Token Bucket Rate Limiting**: Client-side throttling with regenerating tokens (50 max, 6/minute)
|
||||
- **Quota Awareness**: Accounts below configurable quota thresholds are deprioritized; exhausted accounts trigger emergency fallback
|
||||
- **Quota Protection**: Set minimum quota levels globally, per-account, or per-model to switch accounts before quota runs out
|
||||
- **Emergency Fallback**: When all accounts appear exhausted, bypasses checks with throttle delays (250-500ms)
|
||||
- **Automatic Cooldown**: Rate-limited accounts recover automatically after reset time expires
|
||||
- **Invalid Account Detection**: Accounts needing re-authentication are marked and skipped
|
||||
- **Prompt Caching Support**: Session IDs derived from conversation enable cache hits across turns
|
||||
|
||||
## Monitoring
|
||||
|
||||
Check account status, subscription tiers, and quota anytime:
|
||||
|
||||
```bash
|
||||
# Web UI: http://localhost:8080/ (Accounts tab - shows tier badges and quota progress)
|
||||
# CLI Table:
|
||||
curl "http://localhost:8080/account-limits?format=table"
|
||||
```
|
||||
|
||||
### CLI Management Reference
|
||||
|
||||
If you prefer using the terminal for management:
|
||||
|
||||
```bash
|
||||
# List all accounts
|
||||
antigravity-claude-proxy accounts list
|
||||
|
||||
# Verify account health
|
||||
antigravity-claude-proxy accounts verify
|
||||
|
||||
# Interactive CLI menu
|
||||
antigravity-claude-proxy accounts
|
||||
```
|
||||
17
docs/menubar-app.md
Normal file
17
docs/menubar-app.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# macOS Menu Bar App
|
||||
|
||||
For macOS users who prefer a native experience, there's a companion menu bar app that provides quick access to server controls without touching the terminal. Get it from: [antigravity-claude-proxy-bar](https://github.com/IrvanFza/antigravity-claude-proxy-bar)
|
||||
|
||||
> **Note:** This is a GUI wrapper only. You still need to install and setup the proxy server first using one of the [installation methods](../README.md#installation) above.
|
||||
|
||||

|
||||
|
||||
## Key Features
|
||||
|
||||
- **Server Control**: Start/stop the proxy server with a single click or ⌘S shortcut.
|
||||
- **Status Indicator**: Menu bar icon shows server running state at a glance.
|
||||
- **WebUI Access**: Open the web management console directly from the menu.
|
||||
- **Port Configuration**: Customize the proxy server port (default: 8080).
|
||||
- **Account Selection Strategy**: Choose between Hybrid, Sticky, or Round-Robin load balancing strategies.
|
||||
- **Auto-Start Options**: Launch server on app start and launch app at login.
|
||||
- **Native Experience**: Clean, native SwiftUI interface designed for macOS.
|
||||
19
docs/models.md
Normal file
19
docs/models.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Available Models
|
||||
|
||||
## Claude Models
|
||||
|
||||
| Model ID | Description |
|
||||
| ---------------------------- | ---------------------------------------- |
|
||||
| `claude-sonnet-4-5-thinking` | Claude Sonnet 4.5 with extended thinking |
|
||||
| `claude-opus-4-5-thinking` | Claude Opus 4.5 with extended thinking |
|
||||
| `claude-sonnet-4-5` | Claude Sonnet 4.5 without thinking |
|
||||
|
||||
## Gemini Models
|
||||
|
||||
| Model ID | Description |
|
||||
| ------------------- | ------------------------------- |
|
||||
| `gemini-3-flash` | Gemini 3 Flash with thinking |
|
||||
| `gemini-3-pro-low` | Gemini 3 Pro Low with thinking |
|
||||
| `gemini-3-pro-high` | Gemini 3 Pro High with thinking |
|
||||
|
||||
Gemini models include full thinking support with `thoughtSignature` handling for multi-turn conversations.
|
||||
27
docs/safety-notices.md
Normal file
27
docs/safety-notices.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Safety, Usage, and Risk Notices
|
||||
|
||||
## Intended Use
|
||||
|
||||
- Personal / internal development only
|
||||
- Respect internal quotas and data handling policies
|
||||
- Not for production services or bypassing intended limits
|
||||
|
||||
## Not Suitable For
|
||||
|
||||
- Production application traffic
|
||||
- High-volume automated extraction
|
||||
- Any use that violates Acceptable Use Policies
|
||||
|
||||
## Warning (Assumption of Risk)
|
||||
|
||||
By using this software, you acknowledge and accept the following:
|
||||
|
||||
- **Terms of Service risk**: This approach may violate the Terms of Service of AI model providers (Anthropic, Google, etc.). You are solely responsible for ensuring compliance with all applicable terms and policies.
|
||||
|
||||
- **Account risk**: Providers may detect this usage pattern and take punitive action, including suspension, permanent ban, or loss of access to paid subscriptions.
|
||||
|
||||
- **No guarantees**: Providers may change APIs, authentication, or policies at any time, which can break this method without notice.
|
||||
|
||||
- **Assumption of risk**: You assume all legal, financial, and technical risks. The authors and contributors of this project bear no responsibility for any consequences arising from your use.
|
||||
|
||||
**Use at your own risk. Proceed only if you understand and accept these risks.**
|
||||
49
docs/start-proxy.sh
Executable file
49
docs/start-proxy.sh
Executable file
@@ -0,0 +1,49 @@
|
||||
#!/bin/bash
|
||||
# Wrapper script for antigravity-claude-proxy that handles various Node.js version managers
|
||||
# Used by the systemd service to ensure node is available in PATH
|
||||
|
||||
set -e
|
||||
|
||||
# Try nvm
|
||||
if [ -z "$NVM_DIR" ]; then
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
fi
|
||||
if [ -s "$NVM_DIR/nvm.sh" ]; then
|
||||
source "$NVM_DIR/nvm.sh"
|
||||
fi
|
||||
|
||||
# Try fnm
|
||||
if command -v fnm &> /dev/null; then
|
||||
eval "$(fnm env)"
|
||||
elif [ -d "$HOME/.fnm" ]; then
|
||||
export PATH="$HOME/.fnm:$PATH"
|
||||
eval "$(fnm env 2>/dev/null)" || true
|
||||
fi
|
||||
|
||||
# Try volta
|
||||
if [ -d "$HOME/.volta" ]; then
|
||||
export VOLTA_HOME="$HOME/.volta"
|
||||
export PATH="$VOLTA_HOME/bin:$PATH"
|
||||
fi
|
||||
|
||||
# Try asdf
|
||||
if [ -s "$HOME/.asdf/asdf.sh" ]; then
|
||||
source "$HOME/.asdf/asdf.sh"
|
||||
fi
|
||||
|
||||
# Verify node is available
|
||||
if ! command -v node &> /dev/null; then
|
||||
echo "Error: node not found in PATH after sourcing version managers" >&2
|
||||
echo "PATH: $PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify antigravity-claude-proxy is available
|
||||
if ! command -v antigravity-claude-proxy &> /dev/null; then
|
||||
echo "Error: antigravity-claude-proxy not found in PATH" >&2
|
||||
echo "Install with: npm install -g antigravity-claude-proxy" >&2
|
||||
echo "PATH: $PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec antigravity-claude-proxy "$@"
|
||||
24
docs/testing.md
Normal file
24
docs/testing.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Testing
|
||||
|
||||
Run the test suite (requires server running):
|
||||
|
||||
```bash
|
||||
# Start server in one terminal
|
||||
npm start
|
||||
|
||||
# Run tests in another terminal
|
||||
npm test
|
||||
```
|
||||
|
||||
Individual tests:
|
||||
|
||||
```bash
|
||||
npm run test:signatures # Thinking signatures
|
||||
npm run test:multiturn # Multi-turn with tools
|
||||
npm run test:streaming # Streaming SSE events
|
||||
npm run test:interleaved # Interleaved thinking
|
||||
npm run test:images # Image processing
|
||||
npm run test:caching # Prompt caching
|
||||
npm run test:strategies # Account selection strategies
|
||||
npm run test:cache-control # Cache control field stripping
|
||||
```
|
||||
95
docs/troubleshooting.md
Normal file
95
docs/troubleshooting.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Troubleshooting
|
||||
|
||||
## Windows: OAuth Port Error (EACCES)
|
||||
|
||||
On Windows, the default OAuth callback port (51121) may be reserved by Hyper-V, WSL2, or Docker. If you see:
|
||||
|
||||
```
|
||||
Error: listen EACCES: permission denied 0.0.0.0:51121
|
||||
```
|
||||
|
||||
The proxy will automatically try fallback ports (51122-51126). If all ports fail, try these solutions:
|
||||
|
||||
### Option 1: Use a Custom Port (Recommended)
|
||||
|
||||
Set a custom port outside the reserved range:
|
||||
|
||||
```bash
|
||||
# Windows PowerShell
|
||||
$env:OAUTH_CALLBACK_PORT = "3456"
|
||||
antigravity-claude-proxy start
|
||||
|
||||
# Windows CMD
|
||||
set OAUTH_CALLBACK_PORT=3456
|
||||
antigravity-claude-proxy start
|
||||
|
||||
# Or add to your .env file
|
||||
OAUTH_CALLBACK_PORT=3456
|
||||
```
|
||||
|
||||
### Option 2: Reset Windows NAT
|
||||
|
||||
Run as Administrator:
|
||||
|
||||
```powershell
|
||||
net stop winnat
|
||||
net start winnat
|
||||
```
|
||||
|
||||
### Option 3: Check Reserved Ports
|
||||
|
||||
See which ports are reserved:
|
||||
|
||||
```powershell
|
||||
netsh interface ipv4 show excludedportrange protocol=tcp
|
||||
```
|
||||
|
||||
If 51121 is in a reserved range, use Option 1 with a port outside those ranges.
|
||||
|
||||
### Option 4: Permanently Exclude Port (Admin)
|
||||
|
||||
Reserve the port before Hyper-V claims it (run as Administrator):
|
||||
|
||||
```powershell
|
||||
netsh int ipv4 add excludedportrange protocol=tcp startport=51121 numberofports=1
|
||||
```
|
||||
|
||||
> **Note:** The server automatically tries fallback ports (51122-51126) if the primary port fails.
|
||||
|
||||
---
|
||||
|
||||
## "Could not extract token from Antigravity"
|
||||
|
||||
If using single-account mode with Antigravity:
|
||||
|
||||
1. Make sure Antigravity app is installed and running
|
||||
2. Ensure you're logged in to Antigravity
|
||||
|
||||
Or add accounts via OAuth instead: `antigravity-claude-proxy accounts add`
|
||||
|
||||
## 401 Authentication Errors
|
||||
|
||||
The token might have expired. Try:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8080/refresh-token
|
||||
```
|
||||
|
||||
Or re-authenticate the account:
|
||||
|
||||
```bash
|
||||
antigravity-claude-proxy accounts
|
||||
```
|
||||
|
||||
## Rate Limiting (429)
|
||||
|
||||
With multiple accounts, the proxy automatically switches to the next available account. With a single account, you'll need to wait for the rate limit to reset.
|
||||
|
||||
## Account Shows as "Invalid"
|
||||
|
||||
Re-authenticate the account:
|
||||
|
||||
```bash
|
||||
antigravity-claude-proxy accounts
|
||||
# Choose "Re-authenticate" for the invalid account
|
||||
```
|
||||
20
docs/web-console.md
Normal file
20
docs/web-console.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Web Management Console
|
||||
|
||||
The proxy includes a built-in, modern web interface for real-time monitoring and configuration. Access the console at: `http://localhost:8080` (default port).
|
||||
|
||||

|
||||
|
||||
## Key Features
|
||||
|
||||
- **Real-time Dashboard**: Monitor request volume, active accounts, model health, and subscription tier distribution.
|
||||
- **Visual Model Quota**: Track per-model usage and next reset times with color-coded progress indicators and draggable per-account threshold markers.
|
||||
- **Account Management**: Add/remove Google accounts via OAuth, view subscription tiers (Free/Pro/Ultra), quota status, and per-account threshold settings.
|
||||
- **Manual OAuth Mode**: Add accounts on headless servers by copying the OAuth URL and pasting the authorization code.
|
||||
- **Claude CLI Configuration**: Edit your `~/.claude/settings.json` directly from the browser.
|
||||
- **Persistent History**: Tracks request volume by model family for 30 days, persisting across server restarts.
|
||||
- **Time Range Filtering**: Analyze usage trends over 1H, 6H, 24H, 7D, or All Time periods.
|
||||
- **Smart Analysis**: Auto-select top 5 most used models or toggle between Family/Model views.
|
||||
- **Live Logs**: Stream server logs with level-based filtering and search.
|
||||
- **Quota Protection**: Set global or per-account minimum quota thresholds to proactively switch accounts before quota runs out.
|
||||
- **Advanced Tuning**: Configure retries, timeouts, and debug mode on the fly.
|
||||
- **Multi-language Interface**: Full support for English, Chinese (中文), Indonesian (Bahasa), Portuguese (PT-BR), and Turkish (Türkçe).
|
||||
5
package-lock.json
generated
5
package-lock.json
generated
@@ -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"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "antigravity-claude-proxy",
|
||||
"version": "2.4.2",
|
||||
"version": "2.5.0",
|
||||
"description": "Proxy server to use Antigravity's Claude models with Claude Code CLI",
|
||||
"main": "src/index.js",
|
||||
"type": "module",
|
||||
|
||||
2
public/css/style.css
generated
2
public/css/style.css
generated
File diff suppressed because one or more lines are too long
@@ -182,6 +182,129 @@ window.Components.accountManager = () => ({
|
||||
document.getElementById('quota_modal').showModal();
|
||||
},
|
||||
|
||||
// Threshold settings
|
||||
thresholdDialog: {
|
||||
email: '',
|
||||
quotaThreshold: null, // null means use global
|
||||
modelQuotaThresholds: {},
|
||||
saving: false,
|
||||
addingModel: false,
|
||||
newModelId: '',
|
||||
newModelThreshold: 10
|
||||
},
|
||||
|
||||
openThresholdModal(account) {
|
||||
this.thresholdDialog = {
|
||||
email: account.email,
|
||||
// Convert from fraction (0-1) to percentage (0-99) for display
|
||||
quotaThreshold: account.quotaThreshold !== undefined ? Math.round(account.quotaThreshold * 100) : null,
|
||||
modelQuotaThresholds: Object.fromEntries(
|
||||
Object.entries(account.modelQuotaThresholds || {}).map(([k, v]) => [k, Math.round(v * 100)])
|
||||
),
|
||||
saving: false,
|
||||
addingModel: false,
|
||||
newModelId: '',
|
||||
newModelThreshold: 10
|
||||
};
|
||||
document.getElementById('threshold_modal').showModal();
|
||||
},
|
||||
|
||||
async saveAccountThreshold() {
|
||||
const store = Alpine.store('global');
|
||||
this.thresholdDialog.saving = true;
|
||||
|
||||
try {
|
||||
// Convert percentage back to fraction
|
||||
const quotaThreshold = this.thresholdDialog.quotaThreshold !== null && this.thresholdDialog.quotaThreshold !== ''
|
||||
? parseFloat(this.thresholdDialog.quotaThreshold) / 100
|
||||
: null;
|
||||
|
||||
// Convert model thresholds from percentage to fraction
|
||||
const modelQuotaThresholds = {};
|
||||
for (const [modelId, pct] of Object.entries(this.thresholdDialog.modelQuotaThresholds)) {
|
||||
modelQuotaThresholds[modelId] = parseFloat(pct) / 100;
|
||||
}
|
||||
|
||||
const { response, newPassword } = await window.utils.request(
|
||||
`/api/accounts/${encodeURIComponent(this.thresholdDialog.email)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ quotaThreshold, modelQuotaThresholds })
|
||||
},
|
||||
store.webuiPassword
|
||||
);
|
||||
if (newPassword) store.webuiPassword = newPassword;
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status === 'ok') {
|
||||
store.showToast('Settings saved', 'success');
|
||||
Alpine.store('data').fetchData();
|
||||
document.getElementById('threshold_modal').close();
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to save settings');
|
||||
}
|
||||
} catch (e) {
|
||||
store.showToast('Failed to save settings: ' + e.message, 'error');
|
||||
} finally {
|
||||
this.thresholdDialog.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
clearAccountThreshold() {
|
||||
this.thresholdDialog.quotaThreshold = null;
|
||||
},
|
||||
|
||||
// Per-model threshold methods
|
||||
addModelThreshold() {
|
||||
this.thresholdDialog.addingModel = true;
|
||||
this.thresholdDialog.newModelId = '';
|
||||
this.thresholdDialog.newModelThreshold = 10;
|
||||
},
|
||||
|
||||
updateModelThreshold(modelId, value) {
|
||||
const numValue = parseInt(value);
|
||||
if (!isNaN(numValue) && numValue >= 0 && numValue <= 99) {
|
||||
this.thresholdDialog.modelQuotaThresholds[modelId] = numValue;
|
||||
}
|
||||
},
|
||||
|
||||
removeModelThreshold(modelId) {
|
||||
delete this.thresholdDialog.modelQuotaThresholds[modelId];
|
||||
},
|
||||
|
||||
confirmAddModelThreshold() {
|
||||
const modelId = this.thresholdDialog.newModelId;
|
||||
const threshold = parseInt(this.thresholdDialog.newModelThreshold) || 10;
|
||||
|
||||
if (modelId && threshold >= 0 && threshold <= 99) {
|
||||
this.thresholdDialog.modelQuotaThresholds[modelId] = threshold;
|
||||
this.thresholdDialog.addingModel = false;
|
||||
this.thresholdDialog.newModelId = '';
|
||||
this.thresholdDialog.newModelThreshold = 10;
|
||||
}
|
||||
},
|
||||
|
||||
getAvailableModelsForThreshold() {
|
||||
// Get models from data store, exclude already configured ones
|
||||
const allModels = Alpine.store('data').models || [];
|
||||
const configured = Object.keys(this.thresholdDialog.modelQuotaThresholds);
|
||||
return allModels.filter(m => !configured.includes(m));
|
||||
},
|
||||
|
||||
getEffectiveThreshold(account) {
|
||||
// Return display string for effective threshold
|
||||
if (account.quotaThreshold !== undefined) {
|
||||
return Math.round(account.quotaThreshold * 100) + '%';
|
||||
}
|
||||
// If no per-account threshold, show global value
|
||||
const globalThreshold = Alpine.store('data').globalQuotaThreshold;
|
||||
if (globalThreshold > 0) {
|
||||
return Math.round(globalThreshold * 100) + '% (global)';
|
||||
}
|
||||
return 'Global';
|
||||
},
|
||||
|
||||
/**
|
||||
* Get main model quota for display
|
||||
* Prioritizes flagship models (Opus > Sonnet > Flash)
|
||||
|
||||
@@ -12,6 +12,24 @@ window.Components.claudeConfig = () => ({
|
||||
restoring: false,
|
||||
gemini1mSuffix: false,
|
||||
|
||||
// Mode toggle state (proxy/paid)
|
||||
currentMode: 'proxy', // 'proxy' or 'paid'
|
||||
modeLoading: false,
|
||||
|
||||
/**
|
||||
* Extract port from ANTHROPIC_BASE_URL for display
|
||||
* @returns {string} Port number or '8080' as fallback
|
||||
*/
|
||||
getProxyPort() {
|
||||
const baseUrl = this.config?.env?.ANTHROPIC_BASE_URL || '';
|
||||
try {
|
||||
const url = new URL(baseUrl);
|
||||
return url.port || '8080';
|
||||
} catch {
|
||||
return '8080';
|
||||
}
|
||||
},
|
||||
|
||||
// Presets state
|
||||
presets: [],
|
||||
selectedPresetName: '',
|
||||
@@ -34,6 +52,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 +60,7 @@ window.Components.claudeConfig = () => ({
|
||||
if (tab === 'claude' && oldTab !== undefined) {
|
||||
this.fetchConfig();
|
||||
this.fetchPresets();
|
||||
this.fetchMode();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -416,5 +436,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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,6 +6,33 @@
|
||||
window.Components = window.Components || {};
|
||||
|
||||
window.Components.models = () => ({
|
||||
// Color palette for per-account threshold markers
|
||||
thresholdColors: [
|
||||
{ bg: '#eab308', shadow: 'rgba(234,179,8,0.5)' }, // yellow
|
||||
{ bg: '#06b6d4', shadow: 'rgba(6,182,212,0.5)' }, // cyan
|
||||
{ bg: '#a855f7', shadow: 'rgba(168,85,247,0.5)' }, // purple
|
||||
{ bg: '#22c55e', shadow: 'rgba(34,197,94,0.5)' }, // green
|
||||
{ bg: '#ef4444', shadow: 'rgba(239,68,68,0.5)' }, // red
|
||||
{ bg: '#f97316', shadow: 'rgba(249,115,22,0.5)' }, // orange
|
||||
{ bg: '#ec4899', shadow: 'rgba(236,72,153,0.5)' }, // pink
|
||||
{ bg: '#8b5cf6', shadow: 'rgba(139,92,246,0.5)' }, // violet
|
||||
],
|
||||
|
||||
getThresholdColor(index) {
|
||||
return this.thresholdColors[index % this.thresholdColors.length];
|
||||
},
|
||||
|
||||
// Drag state for threshold markers
|
||||
dragging: {
|
||||
active: false,
|
||||
email: null,
|
||||
modelId: null,
|
||||
barRect: null,
|
||||
currentPct: 0,
|
||||
originalPct: 0
|
||||
},
|
||||
|
||||
// Model editing state (from main)
|
||||
editingModelId: null,
|
||||
newMapping: '',
|
||||
|
||||
@@ -21,6 +48,188 @@ window.Components.models = () => ({
|
||||
this.editingModelId = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Start dragging a threshold marker
|
||||
*/
|
||||
startDrag(event, q, row) {
|
||||
// Find the progress bar element (closest .relative container)
|
||||
const markerEl = event.currentTarget;
|
||||
const barContainer = markerEl.parentElement;
|
||||
const barRect = barContainer.getBoundingClientRect();
|
||||
|
||||
this.dragging = {
|
||||
active: true,
|
||||
email: q.fullEmail,
|
||||
modelId: row.modelId,
|
||||
barRect,
|
||||
currentPct: q.thresholdPct,
|
||||
originalPct: q.thresholdPct
|
||||
};
|
||||
|
||||
// Prevent text selection while dragging
|
||||
document.body.classList.add('select-none');
|
||||
|
||||
// Bind document-level listeners for smooth dragging outside the marker
|
||||
this._onDrag = (e) => this.onDrag(e);
|
||||
this._endDrag = () => this.endDrag();
|
||||
document.addEventListener('mousemove', this._onDrag);
|
||||
document.addEventListener('mouseup', this._endDrag);
|
||||
document.addEventListener('touchmove', this._onDrag, { passive: false });
|
||||
document.addEventListener('touchend', this._endDrag);
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle drag movement — compute percentage from mouse position
|
||||
*/
|
||||
onDrag(event) {
|
||||
if (!this.dragging.active) return;
|
||||
event.preventDefault();
|
||||
|
||||
const clientX = event.touches ? event.touches[0].clientX : event.clientX;
|
||||
const { left, width } = this.dragging.barRect;
|
||||
let pct = Math.round((clientX - left) / width * 100);
|
||||
pct = Math.max(0, Math.min(99, pct));
|
||||
|
||||
this.dragging.currentPct = pct;
|
||||
},
|
||||
|
||||
/**
|
||||
* End drag — save the new threshold value
|
||||
*/
|
||||
endDrag() {
|
||||
if (!this.dragging.active) return;
|
||||
|
||||
// Clean up listeners
|
||||
document.removeEventListener('mousemove', this._onDrag);
|
||||
document.removeEventListener('mouseup', this._endDrag);
|
||||
document.removeEventListener('touchmove', this._onDrag);
|
||||
document.removeEventListener('touchend', this._endDrag);
|
||||
document.body.classList.remove('select-none');
|
||||
|
||||
const { email, modelId, currentPct, originalPct } = this.dragging;
|
||||
|
||||
// Only save if value actually changed
|
||||
if (currentPct !== originalPct) {
|
||||
// Optimistic in-place update: mutate existing quotaInfo entries directly
|
||||
// to avoid full DOM rebuild from computeQuotaRows()
|
||||
const dataStore = Alpine.store('data');
|
||||
const account = dataStore.accounts.find(a => a.email === email);
|
||||
if (account) {
|
||||
if (!account.modelQuotaThresholds) account.modelQuotaThresholds = {};
|
||||
if (currentPct === 0) {
|
||||
delete account.modelQuotaThresholds[modelId];
|
||||
} else {
|
||||
account.modelQuotaThresholds[modelId] = currentPct / 100;
|
||||
}
|
||||
}
|
||||
// Patch quotaRows in-place so Alpine updates without tearing down DOM
|
||||
const rows = dataStore.quotaRows || [];
|
||||
for (const row of rows) {
|
||||
if (row.modelId !== modelId) continue;
|
||||
for (const q of row.quotaInfo) {
|
||||
if (q.fullEmail !== email) continue;
|
||||
q.thresholdPct = currentPct;
|
||||
}
|
||||
// Recompute row-level threshold stats
|
||||
const activePcts = row.quotaInfo.map(q => q.thresholdPct).filter(t => t > 0);
|
||||
row.effectiveThresholdPct = activePcts.length > 0 ? Math.max(...activePcts) : 0;
|
||||
row.hasVariedThresholds = new Set(activePcts).size > 1;
|
||||
}
|
||||
this.dragging.active = false;
|
||||
this.saveModelThreshold(email, modelId, currentPct);
|
||||
} else {
|
||||
this.dragging.active = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Save a per-model threshold for an account via PATCH
|
||||
*/
|
||||
async saveModelThreshold(email, modelId, pct) {
|
||||
const store = Alpine.store('global');
|
||||
const dataStore = Alpine.store('data');
|
||||
|
||||
const account = dataStore.accounts.find(a => a.email === email);
|
||||
if (!account) return;
|
||||
|
||||
// Snapshot for rollback on failure
|
||||
const previousModelThresholds = account.modelQuotaThresholds ? { ...account.modelQuotaThresholds } : {};
|
||||
|
||||
// Build full modelQuotaThresholds for API (full replacement, not merge)
|
||||
const existingModelThresholds = { ...(account.modelQuotaThresholds || {}) };
|
||||
|
||||
// Preserve the account-level quotaThreshold
|
||||
const quotaThreshold = account.quotaThreshold !== undefined ? account.quotaThreshold : null;
|
||||
|
||||
try {
|
||||
const { response, newPassword } = await window.utils.request(
|
||||
`/api/accounts/${encodeURIComponent(email)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ quotaThreshold, modelQuotaThresholds: existingModelThresholds })
|
||||
},
|
||||
store.webuiPassword
|
||||
);
|
||||
if (newPassword) store.webuiPassword = newPassword;
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status === 'ok') {
|
||||
const label = pct === 0 ? 'removed' : pct + '%';
|
||||
store.showToast(`${email.split('@')[0]} ${modelId} threshold: ${label}`, 'success');
|
||||
// Skip fetchData() — optimistic update is already applied,
|
||||
// next polling cycle will sync server state
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to save threshold');
|
||||
}
|
||||
} catch (e) {
|
||||
// Revert optimistic update on failure
|
||||
account.modelQuotaThresholds = previousModelThresholds;
|
||||
dataStore.computeQuotaRows();
|
||||
store.showToast('Failed to save threshold: ' + e.message, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if a specific marker is currently being dragged
|
||||
*/
|
||||
isDragging(q, row) {
|
||||
return this.dragging.active && this.dragging.email === q.fullEmail && this.dragging.modelId === row.modelId;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the display percentage for a marker (live during drag, stored otherwise)
|
||||
*/
|
||||
getMarkerPct(q, row) {
|
||||
if (this.isDragging(q, row)) return this.dragging.currentPct;
|
||||
return q.thresholdPct;
|
||||
},
|
||||
|
||||
/**
|
||||
* Compute pixel offset for overlapping markers so stacked ones fan out.
|
||||
* Markers within 2% of each other are considered overlapping.
|
||||
* Returns a CSS pixel offset string (e.g., '6px' or '-6px').
|
||||
*/
|
||||
getMarkerOffset(q, row, qIdx) {
|
||||
const pct = this.getMarkerPct(q, row);
|
||||
const visible = row.quotaInfo.filter(item => item.thresholdPct > 0 || this.isDragging(item, row));
|
||||
// Find all markers within 2% of this one
|
||||
const cluster = [];
|
||||
visible.forEach((item, idx) => {
|
||||
const itemPct = this.getMarkerPct(item, row);
|
||||
if (Math.abs(itemPct - pct) <= 2) {
|
||||
cluster.push({ item, idx });
|
||||
}
|
||||
});
|
||||
if (cluster.length <= 1) return '0px';
|
||||
// Find position of this marker within its cluster
|
||||
const posInCluster = cluster.findIndex(c => c.item.fullEmail === q.fullEmail);
|
||||
// Spread markers 10px apart, centered on the base position
|
||||
const spread = 10;
|
||||
const totalWidth = (cluster.length - 1) * spread;
|
||||
return (posInCluster * spread - totalWidth / 2) + 'px';
|
||||
},
|
||||
|
||||
init() {
|
||||
// Ensure data is fetched when this tab becomes active (skip initial trigger)
|
||||
this.$watch('$store.global.activeTab', (val, oldVal) => {
|
||||
|
||||
@@ -250,6 +250,46 @@ window.Components.serverConfig = () => ({
|
||||
(v) => window.Validators.validateTimeout(v, MAX_WAIT_MIN, MAX_WAIT_MAX));
|
||||
},
|
||||
|
||||
toggleGlobalQuotaThreshold(value) {
|
||||
const { GLOBAL_QUOTA_THRESHOLD_MIN, GLOBAL_QUOTA_THRESHOLD_MAX } = window.AppConstants.VALIDATION;
|
||||
const store = Alpine.store('global');
|
||||
const pct = parseInt(value);
|
||||
if (isNaN(pct) || pct < GLOBAL_QUOTA_THRESHOLD_MIN || pct > GLOBAL_QUOTA_THRESHOLD_MAX) return;
|
||||
|
||||
// Store as percentage in UI, convert to fraction for backend
|
||||
const fraction = pct / 100;
|
||||
|
||||
if (this.debounceTimers['globalQuotaThreshold']) {
|
||||
clearTimeout(this.debounceTimers['globalQuotaThreshold']);
|
||||
}
|
||||
|
||||
const previousValue = this.serverConfig.globalQuotaThreshold;
|
||||
this.serverConfig.globalQuotaThreshold = fraction;
|
||||
|
||||
this.debounceTimers['globalQuotaThreshold'] = setTimeout(async () => {
|
||||
try {
|
||||
const { response, newPassword } = await window.utils.request('/api/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ globalQuotaThreshold: fraction })
|
||||
}, store.webuiPassword);
|
||||
|
||||
if (newPassword) store.webuiPassword = newPassword;
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status === 'ok') {
|
||||
store.showToast(store.t('fieldUpdated', { displayName: 'Minimum Quota Level', value: pct + '%' }), 'success');
|
||||
await this.fetchServerConfig();
|
||||
} else {
|
||||
throw new Error(data.error || store.t('failedToUpdateField', { displayName: 'Minimum Quota Level' }));
|
||||
}
|
||||
} catch (e) {
|
||||
this.serverConfig.globalQuotaThreshold = previousValue;
|
||||
store.showToast(store.t('failedToUpdateField', { displayName: 'Minimum Quota Level' }) + ': ' + e.message, 'error');
|
||||
}
|
||||
}, window.AppConstants.INTERVALS.CONFIG_DEBOUNCE);
|
||||
},
|
||||
|
||||
toggleMaxAccounts(value) {
|
||||
const { MAX_ACCOUNTS_MIN, MAX_ACCOUNTS_MAX } = window.AppConstants.VALIDATION;
|
||||
this.saveConfigField('maxAccounts', value, 'Max Accounts',
|
||||
|
||||
@@ -87,7 +87,11 @@ window.AppConstants.VALIDATION = {
|
||||
|
||||
// Capacity retries (1 - 10)
|
||||
MAX_CAPACITY_RETRIES_MIN: 1,
|
||||
MAX_CAPACITY_RETRIES_MAX: 10
|
||||
MAX_CAPACITY_RETRIES_MAX: 10,
|
||||
|
||||
// Global quota threshold (0 - 99%)
|
||||
GLOBAL_QUOTA_THRESHOLD_MIN: 0,
|
||||
GLOBAL_QUOTA_THRESHOLD_MAX: 99
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,7 @@ document.addEventListener('alpine:init', () => {
|
||||
modelConfig: {}, // Model metadata (hidden, pinned, alias)
|
||||
quotaRows: [], // Filtered view
|
||||
usageHistory: {}, // Usage statistics history (from /account-limits?includeHistory=true)
|
||||
globalQuotaThreshold: 0, // Global minimum quota threshold (fraction 0-0.99)
|
||||
maxAccounts: 10, // Maximum number of accounts allowed (from config)
|
||||
loading: false,
|
||||
initialLoad: true, // Track first load for skeleton screen
|
||||
@@ -116,6 +117,7 @@ document.addEventListener('alpine:init', () => {
|
||||
this.models = data.models;
|
||||
}
|
||||
this.modelConfig = data.modelConfig || {};
|
||||
this.globalQuotaThreshold = data.globalQuotaThreshold || 0;
|
||||
|
||||
// Store usage history if included (for dashboard)
|
||||
if (data.history) {
|
||||
@@ -236,6 +238,8 @@ document.addEventListener('alpine:init', () => {
|
||||
let totalQuotaSum = 0;
|
||||
let validAccountCount = 0;
|
||||
let minResetTime = null;
|
||||
let maxEffectiveThreshold = 0;
|
||||
const globalThreshold = this.globalQuotaThreshold || 0;
|
||||
|
||||
this.accounts.forEach(acc => {
|
||||
if (acc.enabled === false) return;
|
||||
@@ -255,11 +259,26 @@ document.addEventListener('alpine:init', () => {
|
||||
minResetTime = limit.resetTime;
|
||||
}
|
||||
|
||||
// Resolve effective threshold: per-model > per-account > global
|
||||
const accModelThreshold = acc.modelQuotaThresholds?.[modelId];
|
||||
const accThreshold = acc.quotaThreshold;
|
||||
const effective = accModelThreshold ?? accThreshold ?? globalThreshold;
|
||||
if (effective > maxEffectiveThreshold) {
|
||||
maxEffectiveThreshold = effective;
|
||||
}
|
||||
|
||||
// Determine threshold source for display
|
||||
let thresholdSource = 'global';
|
||||
if (accModelThreshold !== undefined) thresholdSource = 'model';
|
||||
else if (accThreshold !== undefined) thresholdSource = 'account';
|
||||
|
||||
quotaInfo.push({
|
||||
email: acc.email.split('@')[0],
|
||||
fullEmail: acc.email,
|
||||
pct: pct,
|
||||
resetTime: limit.resetTime
|
||||
resetTime: limit.resetTime,
|
||||
thresholdPct: Math.round(effective * 100),
|
||||
thresholdSource
|
||||
});
|
||||
});
|
||||
|
||||
@@ -268,6 +287,10 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
if (!showExhausted && minQuota === 0) return;
|
||||
|
||||
// Check if thresholds vary across accounts
|
||||
const uniqueThresholds = new Set(quotaInfo.map(q => q.thresholdPct));
|
||||
const hasVariedThresholds = uniqueThresholds.size > 1;
|
||||
|
||||
rows.push({
|
||||
modelId,
|
||||
displayName: modelId, // Simplified: no longer using alias
|
||||
@@ -279,7 +302,9 @@ document.addEventListener('alpine:init', () => {
|
||||
quotaInfo,
|
||||
pinned: !!config.pinned,
|
||||
hidden: !!isHidden, // Use computed visibility
|
||||
activeCount: quotaInfo.filter(q => q.pct > 0).length
|
||||
activeCount: quotaInfo.filter(q => q.pct > 0).length,
|
||||
effectiveThresholdPct: Math.round(maxEffectiveThreshold * 100),
|
||||
hasVariedThresholds
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -237,6 +237,11 @@ window.translations.en = {
|
||||
defaultCooldownDesc: "Fallback cooldown when API doesn't provide a reset time.",
|
||||
maxWaitThreshold: "Max Wait Before Error",
|
||||
maxWaitDesc: "If all accounts are rate-limited longer than this, error immediately instead of waiting.",
|
||||
// Quota Protection
|
||||
quotaProtection: "Quota Protection",
|
||||
minimumQuotaLevel: "Minimum Quota Level",
|
||||
minimumQuotaLevelDesc: "Switch accounts when quota drops below this level. Per-account overrides take priority.",
|
||||
quotaDisabled: "Disabled",
|
||||
// Error Handling Tuning
|
||||
errorHandlingTuning: "Error Handling Tuning",
|
||||
rateLimitDedupWindow: "Rate Limit Dedup Window",
|
||||
@@ -364,4 +369,14 @@ window.translations.en = {
|
||||
mustBeAtMost: "{fieldName} must be at most {max}",
|
||||
cannotBeEmpty: "{fieldName} cannot be empty",
|
||||
mustBeTrueOrFalse: "Value must be true or false",
|
||||
// Mode Toggle (Proxy/Paid)
|
||||
connectionMode: "Connection Mode",
|
||||
proxyMode: "Proxy Mode",
|
||||
paidMode: "Paid Mode",
|
||||
usingLocalProxy: "Using local proxy server (localhost:{port})",
|
||||
usingOfficialApi: "Using official Anthropic API (requires subscription)",
|
||||
paidModeTitle: "Claude CLI is using the official Anthropic API",
|
||||
paidModeDesc: "All proxy configuration has been removed. Claude CLI uses your Anthropic subscription directly.",
|
||||
paidModeHint: "Switch to Proxy mode to configure model routing and presets.",
|
||||
modeToggleFailed: "Failed to switch mode",
|
||||
};
|
||||
|
||||
@@ -270,6 +270,11 @@ window.translations.id = {
|
||||
defaultCooldownDesc: "Cooldown bawaan jika API tidak memberikan waktu reset.",
|
||||
maxWaitThreshold: "Batas Tunggu Maksimal",
|
||||
maxWaitDesc: "Jika semua akun terkena rate limit lebih lama dari ini, langsung gagal.",
|
||||
// Perlindungan Kuota
|
||||
quotaProtection: "Perlindungan Kuota",
|
||||
minimumQuotaLevel: "Level Kuota Minimum",
|
||||
minimumQuotaLevelDesc: "Ganti akun ketika kuota turun di bawah level ini. Pengaturan per-akun lebih diutamakan.",
|
||||
quotaDisabled: "Nonaktif",
|
||||
// Error Handling Tuning
|
||||
errorHandlingTuning: "Penyetelan Penanganan Error",
|
||||
rateLimitDedupWindow: "Jendela Deduplikasi Rate Limit",
|
||||
@@ -409,4 +414,14 @@ window.translations.id = {
|
||||
strategyUpdated: "Strategi diubah ke: {strategy}",
|
||||
failedToUpdateStrategy: "Gagal memperbarui strategi",
|
||||
invalidStrategy: "Strategi tidak valid dipilih",
|
||||
// Mode Toggle (Proxy/Paid)
|
||||
connectionMode: "Mode Koneksi",
|
||||
proxyMode: "Mode Proxy",
|
||||
paidMode: "Mode Berbayar",
|
||||
usingLocalProxy: "Menggunakan server proxy lokal (localhost:{port})",
|
||||
usingOfficialApi: "Menggunakan API resmi Anthropic (memerlukan langganan)",
|
||||
paidModeTitle: "Claude CLI menggunakan API resmi Anthropic",
|
||||
paidModeDesc: "Semua konfigurasi proxy telah dihapus. Claude CLI menggunakan langganan Anthropic Anda secara langsung.",
|
||||
paidModeHint: "Beralih ke mode Proxy untuk mengonfigurasi routing model dan preset.",
|
||||
modeToggleFailed: "Gagal beralih mode",
|
||||
};
|
||||
|
||||
@@ -215,6 +215,11 @@ window.translations.pt = {
|
||||
defaultCooldownDesc: "Resfriamento de fallback quando a API não fornece tempo de reset.",
|
||||
maxWaitThreshold: "Limiar Máximo de Espera (Sticky)",
|
||||
maxWaitDesc: "Tempo máximo para aguardar uma conta sticky resetar antes de trocar.",
|
||||
// Proteção de Cota
|
||||
quotaProtection: "Proteção de Cota",
|
||||
minimumQuotaLevel: "Nível Mínimo de Cota",
|
||||
minimumQuotaLevelDesc: "Trocar de conta quando a cota cair abaixo deste nível. Configurações por conta têm prioridade.",
|
||||
quotaDisabled: "Desativado",
|
||||
// Ajuste de Tratamento de Erros
|
||||
errorHandlingTuning: "Ajuste de Tratamento de Erros",
|
||||
rateLimitDedupWindow: "Janela de Deduplicação de Rate Limit",
|
||||
@@ -305,4 +310,14 @@ window.translations.pt = {
|
||||
strategyUpdated: "Estratégia atualizada para: {strategy}",
|
||||
failedToUpdateStrategy: "Falha ao atualizar estratégia",
|
||||
invalidStrategy: "Estratégia inválida selecionada",
|
||||
// Mode Toggle (Proxy/Paid)
|
||||
connectionMode: "Modo de Conexão",
|
||||
proxyMode: "Modo Proxy",
|
||||
paidMode: "Modo Pago",
|
||||
usingLocalProxy: "Usando servidor proxy local (localhost:{port})",
|
||||
usingOfficialApi: "Usando API oficial da Anthropic (requer assinatura)",
|
||||
paidModeTitle: "Claude CLI está usando a API oficial da Anthropic",
|
||||
paidModeDesc: "Toda configuração de proxy foi removida. Claude CLI usa sua assinatura Anthropic diretamente.",
|
||||
paidModeHint: "Mude para modo Proxy para configurar roteamento de modelos e presets.",
|
||||
modeToggleFailed: "Falha ao alternar modo",
|
||||
};
|
||||
|
||||
@@ -219,6 +219,11 @@ window.translations.tr = {
|
||||
defaultCooldownDesc: "API sıfırlama zamanı sağlamadığında yedek soğuma süresi.",
|
||||
maxWaitThreshold: "Maksimum Bekleme Eşiği (Yapışkan)",
|
||||
maxWaitDesc: "Yapışkan bir hesabın değiştirmeden önce sıfırlanması için beklenecek maksimum süre.",
|
||||
// Kota Koruması
|
||||
quotaProtection: "Kota Koruması",
|
||||
minimumQuotaLevel: "Minimum Kota Seviyesi",
|
||||
minimumQuotaLevelDesc: "Kota bu seviyenin altına düştüğünde hesap değiştir. Hesap bazlı ayarlar önceliklidir.",
|
||||
quotaDisabled: "Devre Dışı",
|
||||
// Hata İşleme Ayarları
|
||||
errorHandlingTuning: "Hata İşleme Ayarları",
|
||||
rateLimitDedupWindow: "Hız Sınırı Tekilleştirme Penceresi",
|
||||
@@ -355,4 +360,14 @@ window.translations.tr = {
|
||||
strategyUpdated: "Strateji şu şekilde güncellendi: {strategy}",
|
||||
failedToUpdateStrategy: "Strateji güncellenemedi",
|
||||
invalidStrategy: "Geçersiz strateji seçildi",
|
||||
// Mode Toggle (Proxy/Paid)
|
||||
connectionMode: "Bağlantı Modu",
|
||||
proxyMode: "Proxy Modu",
|
||||
paidMode: "Ücretli Mod",
|
||||
usingLocalProxy: "Yerel proxy sunucusu kullanılıyor (localhost:{port})",
|
||||
usingOfficialApi: "Resmi Anthropic API kullanılıyor (abonelik gerektirir)",
|
||||
paidModeTitle: "Claude CLI resmi Anthropic API kullanıyor",
|
||||
paidModeDesc: "Tüm proxy yapılandırması kaldırıldı. Claude CLI doğrudan Anthropic aboneliğinizi kullanır.",
|
||||
paidModeHint: "Model yönlendirme ve ön ayarları yapılandırmak için Proxy moduna geçin.",
|
||||
modeToggleFailed: "Mod değiştirilemedi",
|
||||
};
|
||||
|
||||
@@ -237,6 +237,11 @@ window.translations.zh = {
|
||||
defaultCooldownDesc: "当 API 未提供重置时间时的备用冷却时间。",
|
||||
maxWaitThreshold: "最大等待阈值",
|
||||
maxWaitDesc: "如果所有账号的限流时间超过此阈值,立即返回错误而非等待。",
|
||||
// 配额保护
|
||||
quotaProtection: "配额保护",
|
||||
minimumQuotaLevel: "最低配额水平",
|
||||
minimumQuotaLevelDesc: "当配额低于此水平时切换账号。每个账号的单独设置优先。",
|
||||
quotaDisabled: "已禁用",
|
||||
// 错误处理调优
|
||||
errorHandlingTuning: "错误处理调优",
|
||||
rateLimitDedupWindow: "限流去重窗口",
|
||||
@@ -370,4 +375,14 @@ window.translations.zh = {
|
||||
strategyUpdated: "策略已更新为: {strategy}",
|
||||
failedToUpdateStrategy: "更新策略失败",
|
||||
invalidStrategy: "选择了无效的策略",
|
||||
// Mode Toggle (Proxy/Paid)
|
||||
connectionMode: "连接模式",
|
||||
proxyMode: "代理模式",
|
||||
paidMode: "付费模式",
|
||||
usingLocalProxy: "使用本地代理服务器 (localhost:{port})",
|
||||
usingOfficialApi: "使用官方 Anthropic API (需要订阅)",
|
||||
paidModeTitle: "Claude CLI 正在使用官方 Anthropic API",
|
||||
paidModeDesc: "所有代理配置已移除。Claude CLI 直接使用您的 Anthropic 订阅。",
|
||||
paidModeHint: "切换到代理模式以配置模型路由和预设。",
|
||||
modeToggleFailed: "切换模式失败",
|
||||
};
|
||||
|
||||
@@ -180,6 +180,9 @@
|
||||
<template x-if="quota.percent === null">
|
||||
<span class="text-xs text-gray-600">-</span>
|
||||
</template>
|
||||
<div x-show="acc.quotaThreshold !== undefined"
|
||||
class="text-[10px] font-mono text-neon-yellow/60 mt-0.5"
|
||||
x-text="'min: ' + getEffectiveThreshold(acc)"></div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-4">
|
||||
@@ -204,6 +207,17 @@
|
||||
x-text="$store.global.t('fix')">
|
||||
FIX
|
||||
</button>
|
||||
<!-- Settings Button (threshold) -->
|
||||
<button class="p-2 rounded hover:bg-white/10 text-gray-500 hover:text-neon-yellow transition-colors"
|
||||
@click="openThresholdModal(acc)"
|
||||
title="Account Settings">
|
||||
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="p-2 rounded hover:bg-white/10 text-gray-500 hover:text-white transition-colors"
|
||||
@click="refreshAccount(acc.email)"
|
||||
:disabled="refreshing"
|
||||
@@ -357,4 +371,154 @@
|
||||
<button x-text="$store.global.t('close')">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Threshold Settings Modal -->
|
||||
<dialog id="threshold_modal" class="modal backdrop-blur-sm">
|
||||
<div class="modal-box max-w-md w-full bg-space-900 border border-space-border text-gray-300 shadow-2xl p-6">
|
||||
<h3 class="font-bold text-xl text-white mb-2 flex items-center gap-2">
|
||||
<svg class="w-6 h-6 text-neon-yellow" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span>Account Settings</span>
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 font-mono mb-6" x-text="thresholdDialog.email"></p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Info Alert -->
|
||||
<div class="bg-space-800/50 border border-space-border/30 rounded-lg p-3">
|
||||
<p class="text-xs text-gray-400 leading-relaxed">
|
||||
Set a minimum quota level for this account. When the account's quota falls below this threshold,
|
||||
the proxy will switch to another account with more quota remaining.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Threshold Input -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text text-gray-400">Minimum Quota</span>
|
||||
<span class="label-text-alt font-mono text-neon-yellow"
|
||||
x-text="thresholdDialog.quotaThreshold !== null ? thresholdDialog.quotaThreshold + '%' : 'Default'"></span>
|
||||
</label>
|
||||
<div class="flex gap-3 items-center">
|
||||
<input type="range" min="0" max="99" step="1"
|
||||
class="custom-range custom-range-yellow flex-1"
|
||||
:value="thresholdDialog.quotaThreshold || 0"
|
||||
:style="`background-size: ${thresholdDialog.quotaThreshold || 0}% 100%`"
|
||||
@input="thresholdDialog.quotaThreshold = parseInt($event.target.value)"
|
||||
aria-label="Minimum quota threshold slider">
|
||||
<div class="flex items-center gap-1">
|
||||
<input type="number" min="0" max="99" step="1"
|
||||
class="input input-sm input-bordered w-16 bg-space-800 border-space-border text-white font-mono text-center"
|
||||
:value="thresholdDialog.quotaThreshold ?? ''"
|
||||
placeholder="-"
|
||||
@input="thresholdDialog.quotaThreshold = $event.target.value === '' ? null : parseInt($event.target.value)"
|
||||
aria-label="Minimum quota threshold value">
|
||||
<span class="text-gray-500 text-xs">%</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[10px] text-gray-600 mt-1">
|
||||
Leave empty to use the global threshold from Settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Clear Button -->
|
||||
<button class="btn btn-sm btn-ghost text-gray-500 hover:text-gray-300 w-full"
|
||||
@click="clearAccountThreshold()"
|
||||
x-show="thresholdDialog.quotaThreshold !== null">
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<span>Reset to Default</span>
|
||||
</button>
|
||||
|
||||
<!-- Per-Model Thresholds -->
|
||||
<div class="pt-4 border-t border-space-border/30">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="text-xs text-gray-400 font-semibold">Per-Model Overrides</span>
|
||||
<button class="btn btn-xs btn-ghost text-gray-500 hover:text-neon-yellow gap-1"
|
||||
@click="addModelThreshold()">
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Model threshold list -->
|
||||
<div class="space-y-2 max-h-40 overflow-y-auto custom-scrollbar">
|
||||
<template x-for="(threshold, modelId) in thresholdDialog.modelQuotaThresholds" :key="modelId">
|
||||
<div class="flex items-center gap-2 bg-space-800/50 rounded px-2 py-1.5">
|
||||
<span class="text-xs text-gray-300 flex-1 truncate font-mono" x-text="modelId"></span>
|
||||
<div class="flex items-center gap-1">
|
||||
<input type="number" min="0" max="99" step="1"
|
||||
class="input input-xs input-bordered w-14 bg-space-900 border-space-border text-white font-mono text-center"
|
||||
:value="threshold"
|
||||
@input="updateModelThreshold(modelId, $event.target.value)"
|
||||
aria-label="Model threshold">
|
||||
<span class="text-gray-500 text-[10px]">%</span>
|
||||
</div>
|
||||
<button class="btn btn-xs btn-ghost text-gray-500 hover:text-red-400 px-1"
|
||||
@click="removeModelThreshold(modelId)">
|
||||
<svg class="w-3 h-3" 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>
|
||||
</template>
|
||||
|
||||
<template x-if="Object.keys(thresholdDialog.modelQuotaThresholds).length === 0">
|
||||
<p class="text-[10px] text-gray-600 text-center py-2">
|
||||
No per-model overrides configured.
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Model Dialog (inline) -->
|
||||
<div x-show="thresholdDialog.addingModel" class="pt-3 border-t border-space-border/30" x-cloak>
|
||||
<div class="flex gap-2">
|
||||
<select class="select select-sm select-bordered flex-1 bg-space-800 border-space-border text-white text-xs"
|
||||
x-model="thresholdDialog.newModelId">
|
||||
<option value="">Select model...</option>
|
||||
<template x-for="model in getAvailableModelsForThreshold()" :key="model">
|
||||
<option :value="model" x-text="model"></option>
|
||||
</template>
|
||||
</select>
|
||||
<input type="number" min="0" max="99" step="1"
|
||||
class="input input-sm input-bordered w-16 bg-space-800 border-space-border text-white font-mono text-center"
|
||||
x-model="thresholdDialog.newModelThreshold"
|
||||
placeholder="10">
|
||||
<span class="text-gray-500 text-xs self-center">%</span>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-2">
|
||||
<button class="btn btn-xs btn-ghost text-gray-500 flex-1"
|
||||
@click="thresholdDialog.addingModel = false">
|
||||
Cancel
|
||||
</button>
|
||||
<button class="btn btn-xs btn-warning text-black flex-1"
|
||||
@click="confirmAddModelThreshold()"
|
||||
:disabled="!thresholdDialog.newModelId">
|
||||
Add Override
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-ghost hover:bg-white/10">Cancel</button>
|
||||
</form>
|
||||
<button class="btn btn-warning border-none text-black font-semibold"
|
||||
@click="saveAccountThreshold()"
|
||||
:disabled="thresholdDialog.saving"
|
||||
:class="{ 'loading': thresholdDialog.saving }">
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
@@ -190,9 +190,41 @@
|
||||
<span class="text-gray-500 text-[10px]"
|
||||
x-text="row.quotaInfo.filter(q => q.pct > 0).length + '/' + row.quotaInfo.length"></span>
|
||||
</div>
|
||||
<progress class="progress w-full h-1 bg-space-800"
|
||||
:class="row.avgQuota > 50 ? 'progress-gradient-success' : (row.avgQuota > 0 ? 'progress-gradient-warning' : 'progress-gradient-error')"
|
||||
:value="row.avgQuota" max="100"></progress>
|
||||
<div class="relative">
|
||||
<progress class="progress w-full h-1.5 bg-space-800"
|
||||
:class="row.avgQuota > 50 ? 'progress-gradient-success' : (row.avgQuota > 0 ? 'progress-gradient-warning' : 'progress-gradient-error')"
|
||||
:value="row.avgQuota" max="100"></progress>
|
||||
<!-- Per-account draggable quota threshold markers -->
|
||||
<template x-for="(q, qIdx) in row.quotaInfo.filter(q => q.thresholdPct > 0 || isDragging(q, row))" :key="q.fullEmail + '-threshold'">
|
||||
<div class="absolute -top-1 -bottom-1 group/marker"
|
||||
:class="isDragging(q, row) ? 'cursor-grabbing z-30' : 'cursor-grab'"
|
||||
:style="'left: calc(' + getMarkerPct(q, row) + '% - 4px); width: 9px; transform: translateX(' + (isDragging(q, row) ? '0px' : getMarkerOffset(q, row, qIdx)) + ');'"
|
||||
:title="q.email + ' min: ' + getMarkerPct(q, row) + '%'"
|
||||
@mousedown.prevent="startDrag($event, q, row)"
|
||||
@touchstart.prevent="startDrag($event, q, row)">
|
||||
<!-- Visible marker line -->
|
||||
<div class="absolute top-1 bottom-1 left-1 w-[3px] rounded-full"
|
||||
:class="isDragging(q, row) ? 'scale-y-150 brightness-150' : 'transition-all group-hover/marker:scale-y-150 group-hover/marker:brightness-125'"
|
||||
:style="'background-color: ' + getThresholdColor(qIdx).bg + '; box-shadow: 0 0 ' + (isDragging(q, row) ? '10px' : '6px') + ' ' + getThresholdColor(qIdx).shadow"></div>
|
||||
<!-- Tooltip popup (visible on hover or during drag) -->
|
||||
<div class="absolute -top-8 left-1/2 -translate-x-1/2 items-center gap-1.5
|
||||
whitespace-nowrap text-xs font-mono font-semibold px-2.5 py-1 rounded-md bg-space-900 border border-space-border shadow-xl shadow-black/40 z-20"
|
||||
:class="isDragging(q, row) ? 'flex' : 'hidden group-hover/marker:flex'"
|
||||
:style="'color: ' + getThresholdColor(qIdx).bg + '; border-color: ' + getThresholdColor(qIdx).bg + '40'">
|
||||
<span class="w-2 h-2 rounded-full flex-shrink-0" :style="'background-color: ' + getThresholdColor(qIdx).bg"></span>
|
||||
<span x-text="q.email"></span>
|
||||
<span class="text-gray-500">min</span>
|
||||
<span x-text="getMarkerPct(q, row) + '%'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Threshold text under bar -->
|
||||
<div x-show="row.effectiveThresholdPct > 0"
|
||||
class="text-[10px] font-mono text-gray-500 -mt-0.5"
|
||||
x-text="row.hasVariedThresholds
|
||||
? 'min: ' + Math.min(...row.quotaInfo.map(q => q.thresholdPct).filter(t => t > 0)) + '–' + row.effectiveThresholdPct + '%'
|
||||
: 'min: ' + row.effectiveThresholdPct + '%'"></div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="font-mono text-xs">
|
||||
@@ -219,7 +251,8 @@
|
||||
<div class="flex flex-wrap gap-1 justify-start">
|
||||
<!-- Visible accounts (limited to maxVisible) -->
|
||||
<template x-for="(q, idx) in row.quotaInfo.slice(0, maxVisible)" :key="q.fullEmail">
|
||||
<div class="tooltip tooltip-left" :data-tip="`${q.fullEmail} (${q.pct}%)`">
|
||||
<div class="tooltip tooltip-left"
|
||||
:data-tip="`${q.fullEmail} (${q.pct}%)` + (q.thresholdPct > 0 ? ` · min: ${q.thresholdPct}%` : '')">
|
||||
<div class="w-3 h-3 rounded-[2px] transition-all hover:scale-125 cursor-help"
|
||||
:class="q.pct > 50 ? 'bg-neon-green opacity-80' : (q.pct > 0 ? 'bg-yellow-500 opacity-80' : 'bg-red-900 opacity-50')">
|
||||
</div>
|
||||
@@ -228,7 +261,7 @@
|
||||
<!-- Overflow indicator -->
|
||||
<template x-if="row.quotaInfo.length > maxVisible">
|
||||
<div class="tooltip tooltip-left"
|
||||
:data-tip="row.quotaInfo.slice(maxVisible).map(q => `${q.fullEmail} (${q.pct}%)`).join('\n')">
|
||||
:data-tip="row.quotaInfo.slice(maxVisible).map(q => `${q.fullEmail} (${q.pct}%)` + (q.thresholdPct > 0 ? ` · min: ${q.thresholdPct}%` : '')).join('\n')">
|
||||
<div class="w-3 h-3 rounded-[2px] bg-gray-700/50 border border-gray-600 flex items-center justify-center cursor-help hover:bg-gray-600/70 transition-colors">
|
||||
<span class="text-[8px] text-gray-400 font-bold leading-none" x-text="`+${row.quotaInfo.length - maxVisible}`"></span>
|
||||
</div>
|
||||
|
||||
@@ -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,99 @@
|
||||
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"
|
||||
x-text="$store.global.t('connectionMode')">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>🔌 <span x-text="$store.global.t('proxyMode')">Proxy Mode</span></span></template>
|
||||
<template x-if="currentMode === 'paid'"><span>💳 <span x-text="$store.global.t('paidMode')">Paid Mode</span></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 x-text="$store.global.t('usingLocalProxy', {port: getProxyPort()})">Using local proxy server</span></template>
|
||||
<template x-if="currentMode === 'paid'"><span x-text="$store.global.t('usingOfficialApi')">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'"
|
||||
x-text="$store.global.t('proxyMode').split(' ')[0]">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'"
|
||||
x-text="$store.global.t('paidMode').split(' ')[0]">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" x-text="$store.global.t('paidModeTitle')">Claude CLI is using the official Anthropic API</p>
|
||||
<p class="text-[11px] text-gray-400 mt-1" x-text="$store.global.t('paidModeDesc')">All proxy configuration has been removed. Claude CLI uses your Anthropic subscription directly.</p>
|
||||
<p class="text-[10px] text-gray-500 mt-2" x-text="$store.global.t('paidModeHint')">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 +290,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 +303,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 +322,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 +332,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') : ''"
|
||||
@@ -293,25 +347,28 @@
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
class="absolute left-0 right-0 top-full mt-1 menu p-2 shadow-2xl bg-space-900 border border-space-border rounded-lg max-h-60 overflow-y-auto z-[100] custom-scrollbar">
|
||||
<template
|
||||
x-for="modelId in $store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase()))"
|
||||
:key="modelId">
|
||||
<li>
|
||||
<a @mousedown.prevent="selectModel('ANTHROPIC_MODEL', modelId); open = false; searchTerm = ''"
|
||||
class="font-mono text-xs py-2 hover:bg-space-800 border-b border-space-border/30 last:border-0 flex items-center justify-between gap-2"
|
||||
:class="config.env.ANTHROPIC_MODEL === modelId || config.env.ANTHROPIC_MODEL === modelId + '[1m]' ? 'text-neon-cyan bg-space-800/50' : 'text-gray-300'">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full"
|
||||
: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>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
<li x-show="$store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase())).length === 0">
|
||||
<template
|
||||
x-for="modelId in $store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase()))"
|
||||
:key="modelId">
|
||||
<li>
|
||||
<a @mousedown.prevent="selectModel('ANTHROPIC_MODEL', modelId); open = false; searchTerm = ''"
|
||||
class="font-mono text-xs py-2 hover:bg-space-800 border-b border-space-border/30 last:border-0 flex items-center justify-between gap-2"
|
||||
:class="config.env.ANTHROPIC_MODEL === modelId || config.env.ANTHROPIC_MODEL === modelId + '[1m]' ? 'text-neon-cyan bg-space-800/50' : 'text-gray-300'">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full"
|
||||
: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>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
<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 +382,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') : ''"
|
||||
@@ -342,25 +397,28 @@
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
class="absolute left-0 right-0 top-full mt-1 menu p-2 shadow-2xl bg-space-900 border border-space-border rounded-lg max-h-60 overflow-y-auto z-[100] custom-scrollbar">
|
||||
<template
|
||||
x-for="modelId in $store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase()))"
|
||||
:key="modelId">
|
||||
<li>
|
||||
<a @mousedown.prevent="selectModel('CLAUDE_CODE_SUBAGENT_MODEL', modelId); open = false; searchTerm = ''"
|
||||
class="font-mono text-xs py-2 hover:bg-space-800 border-b border-space-border/30 last:border-0 flex items-center justify-between gap-2"
|
||||
:class="config.env.CLAUDE_CODE_SUBAGENT_MODEL === modelId || config.env.CLAUDE_CODE_SUBAGENT_MODEL === modelId + '[1m]' ? 'text-neon-cyan bg-space-800/50' : 'text-gray-300'">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full"
|
||||
: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>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
<li x-show="$store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase())).length === 0">
|
||||
<template
|
||||
x-for="modelId in $store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase()))"
|
||||
:key="modelId">
|
||||
<li>
|
||||
<a @mousedown.prevent="selectModel('CLAUDE_CODE_SUBAGENT_MODEL', modelId); open = false; searchTerm = ''"
|
||||
class="font-mono text-xs py-2 hover:bg-space-800 border-b border-space-border/30 last:border-0 flex items-center justify-between gap-2"
|
||||
:class="config.env.CLAUDE_CODE_SUBAGENT_MODEL === modelId || config.env.CLAUDE_CODE_SUBAGENT_MODEL === modelId + '[1m]' ? 'text-neon-cyan bg-space-800/50' : 'text-gray-300'">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full"
|
||||
: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>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
<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 +438,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') : ''"
|
||||
@@ -396,25 +452,28 @@
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
class="absolute left-0 right-0 top-full mt-1 menu p-2 shadow-2xl bg-space-900 border border-space-border rounded-lg max-h-60 overflow-y-auto z-[100] custom-scrollbar">
|
||||
<template
|
||||
x-for="modelId in $store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase()))"
|
||||
:key="modelId">
|
||||
<li>
|
||||
<a @mousedown.prevent="selectModel('ANTHROPIC_DEFAULT_OPUS_MODEL', modelId); open = false; searchTerm = ''"
|
||||
class="font-mono text-xs py-2 hover:bg-space-800 border-b border-space-border/30 last:border-0 flex items-center justify-between gap-2"
|
||||
:class="config.env.ANTHROPIC_DEFAULT_OPUS_MODEL === modelId || config.env.ANTHROPIC_DEFAULT_OPUS_MODEL === modelId + '[1m]' ? 'text-neon-cyan bg-space-800/50' : 'text-gray-300'">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full"
|
||||
: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>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
<li x-show="$store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase())).length === 0">
|
||||
<template
|
||||
x-for="modelId in $store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase()))"
|
||||
:key="modelId">
|
||||
<li>
|
||||
<a @mousedown.prevent="selectModel('ANTHROPIC_DEFAULT_OPUS_MODEL', modelId); open = false; searchTerm = ''"
|
||||
class="font-mono text-xs py-2 hover:bg-space-800 border-b border-space-border/30 last:border-0 flex items-center justify-between gap-2"
|
||||
:class="config.env.ANTHROPIC_DEFAULT_OPUS_MODEL === modelId || config.env.ANTHROPIC_DEFAULT_OPUS_MODEL === modelId + '[1m]' ? 'text-neon-cyan bg-space-800/50' : 'text-gray-300'">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full"
|
||||
: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>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
<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 +487,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') : ''"
|
||||
@@ -442,25 +500,28 @@
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
class="absolute left-0 right-0 top-full mt-1 menu p-2 shadow-2xl bg-space-900 border border-space-border rounded-lg max-h-60 overflow-y-auto z-[100] custom-scrollbar">
|
||||
<template
|
||||
x-for="modelId in $store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase()))"
|
||||
:key="modelId">
|
||||
<li>
|
||||
<a @mousedown.prevent="selectModel('ANTHROPIC_DEFAULT_SONNET_MODEL', modelId); open = false; searchTerm = ''"
|
||||
class="font-mono text-xs py-2 hover:bg-space-800 border-b border-space-border/30 last:border-0 flex items-center justify-between gap-2"
|
||||
:class="config.env.ANTHROPIC_DEFAULT_SONNET_MODEL === modelId || config.env.ANTHROPIC_DEFAULT_SONNET_MODEL === modelId + '[1m]' ? 'text-neon-cyan bg-space-800/50' : 'text-gray-300'">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full"
|
||||
: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>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
<li x-show="$store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase())).length === 0">
|
||||
<template
|
||||
x-for="modelId in $store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase()))"
|
||||
:key="modelId">
|
||||
<li>
|
||||
<a @mousedown.prevent="selectModel('ANTHROPIC_DEFAULT_SONNET_MODEL', modelId); open = false; searchTerm = ''"
|
||||
class="font-mono text-xs py-2 hover:bg-space-800 border-b border-space-border/30 last:border-0 flex items-center justify-between gap-2"
|
||||
:class="config.env.ANTHROPIC_DEFAULT_SONNET_MODEL === modelId || config.env.ANTHROPIC_DEFAULT_SONNET_MODEL === modelId + '[1m]' ? 'text-neon-cyan bg-space-800/50' : 'text-gray-300'">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full"
|
||||
: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>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
<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 +533,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') : ''"
|
||||
@@ -488,25 +547,28 @@
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
class="absolute left-0 right-0 top-full mt-1 menu p-2 shadow-2xl bg-space-900 border border-space-border rounded-lg max-h-60 overflow-y-auto z-[100] custom-scrollbar">
|
||||
<template
|
||||
x-for="modelId in $store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase()))"
|
||||
:key="modelId">
|
||||
<li>
|
||||
<a @mousedown.prevent="selectModel('ANTHROPIC_DEFAULT_HAIKU_MODEL', modelId); open = false; searchTerm = ''"
|
||||
class="font-mono text-xs py-2 hover:bg-space-800 border-b border-space-border/30 last:border-0 flex items-center justify-between gap-2"
|
||||
:class="config.env.ANTHROPIC_DEFAULT_HAIKU_MODEL === modelId || config.env.ANTHROPIC_DEFAULT_HAIKU_MODEL === modelId + '[1m]' ? 'text-neon-cyan bg-space-800/50' : 'text-gray-300'">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full"
|
||||
: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>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
<li x-show="$store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase())).length === 0">
|
||||
<template
|
||||
x-for="modelId in $store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase()))"
|
||||
:key="modelId">
|
||||
<li>
|
||||
<a @mousedown.prevent="selectModel('ANTHROPIC_DEFAULT_HAIKU_MODEL', modelId); open = false; searchTerm = ''"
|
||||
class="font-mono text-xs py-2 hover:bg-space-800 border-b border-space-border/30 last:border-0 flex items-center justify-between gap-2"
|
||||
:class="config.env.ANTHROPIC_DEFAULT_HAIKU_MODEL === modelId || config.env.ANTHROPIC_DEFAULT_HAIKU_MODEL === modelId + '[1m]' ? 'text-neon-cyan bg-space-800/50' : 'text-gray-300'">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-1.5 h-1.5 rounded-full"
|
||||
: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>
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
<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 +579,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 +603,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 +617,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 +627,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 +654,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 +684,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 "' + pendingPresetName + '" 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 +716,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 +738,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 +761,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 +788,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 +797,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 +828,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 +856,14 @@
|
||||
stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<button x-show="config.mapping"
|
||||
class="btn-action-danger"
|
||||
@click="await updateModelConfig(modelId, { mapping: '' }); stopEditing()"
|
||||
:title="$store.global.t('delete')">
|
||||
<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 +969,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 +980,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 +989,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 +1002,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 +1021,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 +1044,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 +1110,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 +1192,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 +1217,40 @@
|
||||
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>
|
||||
|
||||
<!-- Quota Protection -->
|
||||
<div class="space-y-4 pt-2 border-t border-space-border/10">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-[10px] text-gray-500 font-bold uppercase tracking-widest"
|
||||
x-text="$store.global.t('quotaProtection')">Quota Protection</span>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label pt-0">
|
||||
<span class="label-text text-gray-400 text-xs"
|
||||
x-text="$store.global.t('minimumQuotaLevel')">Minimum Quota Level</span>
|
||||
<span class="label-text-alt font-mono text-neon-green text-xs font-semibold"
|
||||
x-text="Math.round((serverConfig.globalQuotaThreshold || 0) * 100) > 0 ? Math.round((serverConfig.globalQuotaThreshold || 0) * 100) + '%' : $store.global.t('quotaDisabled')"></span>
|
||||
</label>
|
||||
<div class="flex gap-3 items-center">
|
||||
<input type="range" min="0" max="99" step="1"
|
||||
class="custom-range custom-range-green flex-1"
|
||||
:value="Math.round((serverConfig.globalQuotaThreshold || 0) * 100)"
|
||||
:style="`background-size: ${Math.round((serverConfig.globalQuotaThreshold || 0) * 100) / 99 * 100}% 100%`"
|
||||
@input="toggleGlobalQuotaThreshold($event.target.value)"
|
||||
aria-label="Minimum quota level slider">
|
||||
<input type="number" min="0" max="99"
|
||||
class="input input-xs input-bordered w-16 bg-space-800 border-space-border text-white font-mono text-center"
|
||||
:value="Math.round((serverConfig.globalQuotaThreshold || 0) * 100)"
|
||||
@change="toggleGlobalQuotaThreshold($event.target.value)"
|
||||
aria-label="Minimum quota level value">
|
||||
</div>
|
||||
<p class="text-[9px] text-gray-600 mt-1 leading-tight"
|
||||
x-text="$store.global.t('minimumQuotaLevelDesc')">Switch accounts when quota drops below this level. Per-account overrides take priority.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1175,13 +1282,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 +1308,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 +1333,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 +1358,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 +1394,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 +1404,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 +1415,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>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { ACCOUNT_CONFIG_PATH } from '../constants.js';
|
||||
import { config } from '../config.js';
|
||||
import { loadAccounts, loadDefaultAccount, saveAccounts } from './storage.js';
|
||||
import {
|
||||
isAllRateLimited as checkAllRateLimited,
|
||||
@@ -33,7 +34,6 @@ import {
|
||||
} from './credentials.js';
|
||||
import { createStrategy, getStrategyLabel, DEFAULT_STRATEGY } from './strategies/index.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { config } from '../config.js';
|
||||
|
||||
export class AccountManager {
|
||||
#accounts = [];
|
||||
@@ -433,7 +433,10 @@ export class AccountManager {
|
||||
modelRateLimits: a.modelRateLimits || {},
|
||||
isInvalid: a.isInvalid || false,
|
||||
invalidReason: a.invalidReason || null,
|
||||
lastUsed: a.lastUsed
|
||||
lastUsed: a.lastUsed,
|
||||
// Include quota threshold settings
|
||||
quotaThreshold: a.quotaThreshold,
|
||||
modelQuotaThresholds: a.modelQuotaThresholds || {}
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,7 +34,10 @@ export async function loadAccounts(configPath = ACCOUNT_CONFIG_PATH) {
|
||||
modelRateLimits: acc.modelRateLimits || {},
|
||||
// New fields for subscription and quota tracking
|
||||
subscription: acc.subscription || { tier: 'unknown', projectId: null, detectedAt: null },
|
||||
quota: acc.quota || { models: {}, lastChecked: null }
|
||||
quota: acc.quota || { models: {}, lastChecked: null },
|
||||
// Quota threshold settings (per-account and per-model overrides)
|
||||
quotaThreshold: acc.quotaThreshold, // undefined means use global
|
||||
modelQuotaThresholds: acc.modelQuotaThresholds || {}
|
||||
}));
|
||||
|
||||
const settings = config.settings || {};
|
||||
@@ -123,7 +126,10 @@ export async function saveAccounts(configPath, accounts, settings, activeIndex)
|
||||
lastUsed: acc.lastUsed,
|
||||
// Persist subscription and quota data
|
||||
subscription: acc.subscription || { tier: 'unknown', projectId: null, detectedAt: null },
|
||||
quota: acc.quota || { models: {}, lastChecked: null }
|
||||
quota: acc.quota || { models: {}, lastChecked: null },
|
||||
// Persist quota threshold settings
|
||||
quotaThreshold: acc.quotaThreshold, // undefined omitted from JSON
|
||||
modelQuotaThresholds: Object.keys(acc.modelQuotaThresholds || {}).length > 0 ? acc.modelQuotaThresholds : undefined
|
||||
})),
|
||||
settings: settings,
|
||||
activeIndex: activeIndex
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import { BaseStrategy } from './base-strategy.js';
|
||||
import { HealthTracker, TokenBucketTracker, QuotaTracker } from './trackers/index.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { config } from '../../config.js';
|
||||
|
||||
// Default weights for scoring
|
||||
const DEFAULT_WEIGHTS = {
|
||||
@@ -168,8 +169,12 @@ export class HybridStrategy extends BaseStrategy {
|
||||
}
|
||||
|
||||
// Quota availability check (exclude critically low quota)
|
||||
if (this.#quotaTracker.isQuotaCritical(account, modelId)) {
|
||||
logger.debug(`[HybridStrategy] Excluding ${account.email}: quota critically low for ${modelId}`);
|
||||
// Threshold priority: per-model > per-account > global > default
|
||||
const effectiveThreshold = account.modelQuotaThresholds?.[modelId]
|
||||
?? account.quotaThreshold
|
||||
?? (config.globalQuotaThreshold || undefined);
|
||||
if (this.#quotaTracker.isQuotaCritical(account, modelId, effectiveThreshold)) {
|
||||
logger.debug(`[HybridStrategy] Excluding ${account.email}: quota critically low for ${modelId} (threshold: ${effectiveThreshold ?? 'default'})`);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -311,7 +316,10 @@ export class HybridStrategy extends BaseStrategy {
|
||||
accountsWithoutTokens.push(account.email);
|
||||
continue;
|
||||
}
|
||||
if (this.#quotaTracker.isQuotaCritical(account, modelId)) {
|
||||
const diagThreshold = account.modelQuotaThresholds?.[modelId]
|
||||
?? account.quotaThreshold
|
||||
?? (config.globalQuotaThreshold || undefined);
|
||||
if (this.#quotaTracker.isQuotaCritical(account, modelId, diagThreshold)) {
|
||||
criticalQuotaCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -51,15 +51,19 @@ export class QuotaTracker {
|
||||
* Check if an account has critically low quota for a model
|
||||
* @param {Object} account - Account object
|
||||
* @param {string} modelId - Model ID to check
|
||||
* @param {number} [thresholdOverride] - Optional threshold to use instead of default criticalThreshold
|
||||
* @returns {boolean} True if quota is at or below critical threshold
|
||||
*/
|
||||
isQuotaCritical(account, modelId) {
|
||||
isQuotaCritical(account, modelId, thresholdOverride) {
|
||||
const fraction = this.getQuotaFraction(account, modelId);
|
||||
// Unknown quota = not critical (assume OK)
|
||||
if (fraction === null) return false;
|
||||
// Only apply critical check if data is fresh
|
||||
if (!this.isQuotaFresh(account)) return false;
|
||||
return fraction <= this.#config.criticalThreshold;
|
||||
const threshold = (typeof thresholdOverride === 'number' && thresholdOverride > 0)
|
||||
? thresholdOverride
|
||||
: this.#config.criticalThreshold;
|
||||
return fraction <= threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
// Re-export public API
|
||||
export { sendMessage } from './message-handler.js';
|
||||
export { sendMessageStream } from './streaming-handler.js';
|
||||
export { listModels, fetchAvailableModels, getModelQuotas, getSubscriptionTier } from './model-api.js';
|
||||
export { listModels, fetchAvailableModels, getModelQuotas, getSubscriptionTier, isValidModel } from './model-api.js';
|
||||
|
||||
// Default export for backwards compatibility
|
||||
import { sendMessage } from './message-handler.js';
|
||||
import { sendMessageStream } from './streaming-handler.js';
|
||||
import { listModels, fetchAvailableModels, getModelQuotas, getSubscriptionTier } from './model-api.js';
|
||||
import { listModels, fetchAvailableModels, getModelQuotas, getSubscriptionTier, isValidModel } from './model-api.js';
|
||||
|
||||
export default {
|
||||
sendMessage,
|
||||
@@ -25,5 +25,6 @@ export default {
|
||||
listModels,
|
||||
fetchAvailableModels,
|
||||
getModelQuotas,
|
||||
getSubscriptionTier
|
||||
getSubscriptionTier,
|
||||
isValidModel
|
||||
};
|
||||
|
||||
@@ -396,6 +396,13 @@ export async function sendMessage(anthropicRequest, accountManager, fallbackEnab
|
||||
throw new Error(`CAPACITY_EXHAUSTED: ${errorText}`);
|
||||
}
|
||||
|
||||
// 400 errors are client errors - fail immediately, don't retry or switch accounts
|
||||
// Examples: token limit exceeded, invalid schema, malformed request
|
||||
if (response.status === 400) {
|
||||
logger.error(`[CloudCode] Invalid request (400): ${errorText.substring(0, 200)}`);
|
||||
throw new Error(`invalid_request_error: ${errorText}`);
|
||||
}
|
||||
|
||||
lastError = new Error(`API error ${response.status}: ${errorText}`);
|
||||
// Try next endpoint for 403/404/5xx errors (matches opencode-antigravity-auth behavior)
|
||||
if (response.status === 403 || response.status === 404) {
|
||||
@@ -430,6 +437,10 @@ export async function sendMessage(anthropicRequest, accountManager, fallbackEnab
|
||||
if (isRateLimitError(endpointError)) {
|
||||
throw endpointError; // Re-throw to trigger account switch
|
||||
}
|
||||
// 400 errors are client errors - re-throw immediately, don't retry
|
||||
if (endpointError.message?.includes('400')) {
|
||||
throw endpointError;
|
||||
}
|
||||
logger.warn(`[CloudCode] Error at ${endpoint}:`, endpointError.message);
|
||||
lastError = endpointError;
|
||||
endpointIndex++;
|
||||
|
||||
@@ -9,10 +9,18 @@ import {
|
||||
ANTIGRAVITY_HEADERS,
|
||||
LOAD_CODE_ASSIST_ENDPOINTS,
|
||||
LOAD_CODE_ASSIST_HEADERS,
|
||||
getModelFamily
|
||||
getModelFamily,
|
||||
MODEL_VALIDATION_CACHE_TTL_MS
|
||||
} from '../constants.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
// Model validation cache
|
||||
const modelCache = {
|
||||
validModels: new Set(),
|
||||
lastFetched: 0,
|
||||
fetchPromise: null // Prevents concurrent fetches
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a model is supported (Claude or Gemini)
|
||||
* @param {string} modelId - Model ID to check
|
||||
@@ -46,6 +54,10 @@ export async function listModels(token) {
|
||||
description: modelData.displayName || modelId
|
||||
}));
|
||||
|
||||
// Warm the model validation cache
|
||||
modelCache.validModels = new Set(modelList.map(m => m.id));
|
||||
modelCache.lastFetched = Date.now();
|
||||
|
||||
return {
|
||||
object: 'list',
|
||||
data: modelList
|
||||
@@ -246,3 +258,71 @@ export async function getSubscriptionTier(token) {
|
||||
logger.warn('[CloudCode] Failed to detect subscription tier from all endpoints. Defaulting to free.');
|
||||
return { tier: 'free', projectId: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate the model validation cache
|
||||
* @param {string} token - OAuth access token
|
||||
* @param {string} [projectId] - Optional project ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function populateModelCache(token, projectId = null) {
|
||||
const now = Date.now();
|
||||
|
||||
// Check if cache is fresh
|
||||
if (modelCache.validModels.size > 0 && (now - modelCache.lastFetched) < MODEL_VALIDATION_CACHE_TTL_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If already fetching, wait for it
|
||||
if (modelCache.fetchPromise) {
|
||||
await modelCache.fetchPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
// Start fetch
|
||||
modelCache.fetchPromise = (async () => {
|
||||
try {
|
||||
const data = await fetchAvailableModels(token, projectId);
|
||||
if (data && data.models) {
|
||||
const validIds = Object.keys(data.models).filter(modelId => isSupportedModel(modelId));
|
||||
modelCache.validModels = new Set(validIds);
|
||||
modelCache.lastFetched = Date.now();
|
||||
logger.debug(`[CloudCode] Model cache populated with ${validIds.length} models`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`[CloudCode] Failed to populate model cache: ${error.message}`);
|
||||
// Don't throw - validation should degrade gracefully
|
||||
} finally {
|
||||
modelCache.fetchPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
await modelCache.fetchPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model ID is valid (exists in the available models list)
|
||||
* Uses a cached model list with TTL-based refresh
|
||||
* @param {string} modelId - Model ID to validate
|
||||
* @param {string} token - OAuth access token for cache population
|
||||
* @param {string} [projectId] - Optional project ID
|
||||
* @returns {Promise<boolean>} True if model is valid
|
||||
*/
|
||||
export async function isValidModel(modelId, token, projectId = null) {
|
||||
try {
|
||||
// Populate cache if needed
|
||||
await populateModelCache(token, projectId);
|
||||
|
||||
// If cache is populated, validate against it
|
||||
if (modelCache.validModels.size > 0) {
|
||||
return modelCache.validModels.has(modelId);
|
||||
}
|
||||
|
||||
// Cache empty (fetch failed) - fail open, let API validate
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.debug(`[CloudCode] Model validation error: ${error.message}`);
|
||||
// Fail open - let the API validate
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,6 +389,13 @@ export async function* sendMessageStream(anthropicRequest, accountManager, fallb
|
||||
throw new Error(`CAPACITY_EXHAUSTED: ${errorText}`);
|
||||
}
|
||||
|
||||
// 400 errors are client errors - fail immediately, don't retry or switch accounts
|
||||
// Examples: token limit exceeded, invalid schema, malformed request
|
||||
if (response.status === 400) {
|
||||
logger.error(`[CloudCode] Invalid request (400): ${errorText.substring(0, 200)}`);
|
||||
throw new Error(`invalid_request_error: ${errorText}`);
|
||||
}
|
||||
|
||||
lastError = new Error(`API error ${response.status}: ${errorText}`);
|
||||
|
||||
// Try next endpoint for 403/404/5xx errors (matches opencode-antigravity-auth behavior)
|
||||
@@ -488,6 +495,10 @@ export async function* sendMessageStream(anthropicRequest, accountManager, fallb
|
||||
if (isEmptyResponseError(endpointError)) {
|
||||
throw endpointError;
|
||||
}
|
||||
// 400 errors are client errors - re-throw immediately, don't retry
|
||||
if (endpointError.message?.includes('400')) {
|
||||
throw endpointError;
|
||||
}
|
||||
logger.warn(`[CloudCode] Stream error at ${endpoint}:`, endpointError.message);
|
||||
lastError = endpointError;
|
||||
endpointIndex++;
|
||||
|
||||
@@ -16,6 +16,7 @@ const DEFAULT_CONFIG = {
|
||||
defaultCooldownMs: 10000, // 10 seconds
|
||||
maxWaitBeforeErrorMs: 120000, // 2 minutes
|
||||
maxAccounts: 10, // Maximum number of accounts allowed
|
||||
globalQuotaThreshold: 0, // 0 = disabled, 0.01-0.99 = minimum quota fraction before switching accounts
|
||||
// Rate limit handling (matches opencode-antigravity-auth)
|
||||
rateLimitDedupWindowMs: 2000, // 2 seconds - prevents concurrent retry storms
|
||||
maxConsecutiveFailures: 3, // Before applying extended cooldown
|
||||
|
||||
@@ -156,6 +156,9 @@ export const GEMINI_SKIP_SIGNATURE = 'skip_thought_signature_validator';
|
||||
// Cache TTL for Gemini thoughtSignatures (2 hours)
|
||||
export const GEMINI_SIGNATURE_CACHE_TTL_MS = 2 * 60 * 60 * 1000;
|
||||
|
||||
// Cache TTL for model validation (5 minutes)
|
||||
export const MODEL_VALIDATION_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Get the model family from model name (dynamic detection, no hardcoded list).
|
||||
* @param {string} modelName - The model name from the request
|
||||
@@ -295,6 +298,7 @@ export default {
|
||||
GEMINI_MAX_OUTPUT_TOKENS,
|
||||
GEMINI_SKIP_SIGNATURE,
|
||||
GEMINI_SIGNATURE_CACHE_TTL_MS,
|
||||
MODEL_VALIDATION_CACHE_TTL_MS,
|
||||
getModelFamily,
|
||||
isThinkingModel,
|
||||
OAUTH_CONFIG,
|
||||
|
||||
@@ -8,7 +8,7 @@ import express from 'express';
|
||||
import cors from 'cors';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { sendMessage, sendMessageStream, listModels, getModelQuotas, getSubscriptionTier } from './cloudcode/index.js';
|
||||
import { sendMessage, sendMessageStream, listModels, getModelQuotas, getSubscriptionTier, isValidModel } from './cloudcode/index.js';
|
||||
import { mountWebUI } from './webui/index.js';
|
||||
import { config } from './config.js';
|
||||
|
||||
@@ -189,7 +189,7 @@ app.use((req, res, next) => {
|
||||
const logMsg = `[${req.method}] ${req.originalUrl} ${status} (${duration}ms)`;
|
||||
|
||||
// Skip standard logging for event logging batch unless in debug mode
|
||||
if (req.originalUrl === '/api/event_logging/batch' || req.originalUrl === '/v1/messages/count_tokens' || req.originalUrl.startsWith('/.well-known/')) {
|
||||
if (req.originalUrl === '/api/event_logging/batch' || req.originalUrl.startsWith('/v1/messages/count_tokens') || req.originalUrl.startsWith('/.well-known/')) {
|
||||
if (logger.isDebugEnabled) {
|
||||
logger.debug(logMsg);
|
||||
}
|
||||
@@ -557,6 +557,7 @@ app.get('/account-limits', async (req, res) => {
|
||||
totalAccounts: allAccounts.length,
|
||||
models: sortedModels,
|
||||
modelConfig: config.modelMapping || {},
|
||||
globalQuotaThreshold: config.globalQuotaThreshold || 0,
|
||||
accounts: accountLimits.map(acc => {
|
||||
// Merge quota data with account metadata
|
||||
const metadata = accountMetadataMap.get(acc.email) || {};
|
||||
@@ -572,6 +573,9 @@ app.get('/account-limits', async (req, res) => {
|
||||
invalidReason: metadata.invalidReason || null,
|
||||
lastUsed: metadata.lastUsed || null,
|
||||
modelRateLimits: metadata.modelRateLimits || {},
|
||||
// Quota threshold settings
|
||||
quotaThreshold: metadata.quotaThreshold,
|
||||
modelQuotaThresholds: metadata.modelQuotaThresholds || {},
|
||||
// Subscription data (new)
|
||||
subscription: acc.subscription || metadata.subscription || { tier: 'unknown', projectId: null },
|
||||
// Quota limits
|
||||
@@ -716,6 +720,18 @@ app.post('/v1/messages', async (req, res) => {
|
||||
|
||||
const modelId = requestedModel;
|
||||
|
||||
// Validate model ID before processing
|
||||
const { account: validationAccount } = accountManager.selectAccount();
|
||||
if (validationAccount) {
|
||||
const token = await accountManager.getTokenForAccount(validationAccount);
|
||||
const projectId = validationAccount.subscription?.projectId || null;
|
||||
const valid = await isValidModel(modelId, token, projectId);
|
||||
|
||||
if (!valid) {
|
||||
throw new Error(`invalid_request_error: Invalid model: ${modelId}. Use /v1/models to see available models.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Optimistic Retry: If ALL accounts are rate-limited for this model, reset them to force a fresh check.
|
||||
// If we have some available accounts, we try them first.
|
||||
if (accountManager.isAllRateLimited(modelId)) {
|
||||
|
||||
@@ -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';
|
||||
@@ -232,6 +232,76 @@ export function mountWebUI(app, dirname, accountManager) {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/accounts/:email - Update account settings (thresholds)
|
||||
*/
|
||||
app.patch('/api/accounts/:email', async (req, res) => {
|
||||
try {
|
||||
const { email } = req.params;
|
||||
const { quotaThreshold, modelQuotaThresholds } = req.body;
|
||||
|
||||
const { accounts, settings, activeIndex } = await loadAccounts(ACCOUNT_CONFIG_PATH);
|
||||
const account = accounts.find(a => a.email === email);
|
||||
|
||||
if (!account) {
|
||||
return res.status(404).json({ status: 'error', error: `Account ${email} not found` });
|
||||
}
|
||||
|
||||
// Validate and update quotaThreshold (0-0.99 or null/undefined to clear)
|
||||
if (quotaThreshold !== undefined) {
|
||||
if (quotaThreshold === null) {
|
||||
delete account.quotaThreshold;
|
||||
} else if (typeof quotaThreshold === 'number' && quotaThreshold >= 0 && quotaThreshold < 1) {
|
||||
account.quotaThreshold = quotaThreshold;
|
||||
} else {
|
||||
return res.status(400).json({ status: 'error', error: 'quotaThreshold must be 0-0.99 or null' });
|
||||
}
|
||||
}
|
||||
|
||||
// Validate and update modelQuotaThresholds (full replacement, not merge)
|
||||
if (modelQuotaThresholds !== undefined) {
|
||||
if (modelQuotaThresholds === null || (typeof modelQuotaThresholds === 'object' && Object.keys(modelQuotaThresholds).length === 0)) {
|
||||
// Clear all model thresholds
|
||||
delete account.modelQuotaThresholds;
|
||||
} else if (typeof modelQuotaThresholds === 'object') {
|
||||
// Validate all thresholds first
|
||||
for (const [modelId, threshold] of Object.entries(modelQuotaThresholds)) {
|
||||
if (typeof threshold !== 'number' || threshold < 0 || threshold >= 1) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
error: `Invalid threshold for model ${modelId}: must be 0-0.99`
|
||||
});
|
||||
}
|
||||
}
|
||||
// Replace entire object (not merge)
|
||||
account.modelQuotaThresholds = { ...modelQuotaThresholds };
|
||||
} else {
|
||||
return res.status(400).json({ status: 'error', error: 'modelQuotaThresholds must be an object or null' });
|
||||
}
|
||||
}
|
||||
|
||||
await saveAccounts(ACCOUNT_CONFIG_PATH, accounts, settings, activeIndex);
|
||||
|
||||
// Reload AccountManager to pick up changes
|
||||
await accountManager.reload();
|
||||
|
||||
logger.info(`[WebUI] Account ${email} thresholds updated`);
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
message: `Account ${email} thresholds updated`,
|
||||
account: {
|
||||
email: account.email,
|
||||
quotaThreshold: account.quotaThreshold,
|
||||
modelQuotaThresholds: account.modelQuotaThresholds || {}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[WebUI] Error updating account thresholds:', error);
|
||||
res.status(500).json({ status: 'error', error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/accounts/reload - Reload accounts from disk
|
||||
*/
|
||||
@@ -387,7 +457,7 @@ export function mountWebUI(app, dirname, accountManager) {
|
||||
*/
|
||||
app.post('/api/config', (req, res) => {
|
||||
try {
|
||||
const { debug, logLevel, maxRetries, retryBaseMs, retryMaxMs, persistTokenCache, defaultCooldownMs, maxWaitBeforeErrorMs, maxAccounts, accountSelection, rateLimitDedupWindowMs, maxConsecutiveFailures, extendedCooldownMs, maxCapacityRetries } = req.body;
|
||||
const { debug, logLevel, maxRetries, retryBaseMs, retryMaxMs, persistTokenCache, defaultCooldownMs, maxWaitBeforeErrorMs, maxAccounts, globalQuotaThreshold, accountSelection, rateLimitDedupWindowMs, maxConsecutiveFailures, extendedCooldownMs, maxCapacityRetries } = req.body;
|
||||
|
||||
// Only allow updating specific fields (security)
|
||||
const updates = {};
|
||||
@@ -416,6 +486,9 @@ export function mountWebUI(app, dirname, accountManager) {
|
||||
if (typeof maxAccounts === 'number' && maxAccounts >= 1 && maxAccounts <= 100) {
|
||||
updates.maxAccounts = maxAccounts;
|
||||
}
|
||||
if (typeof globalQuotaThreshold === 'number' && globalQuotaThreshold >= 0 && globalQuotaThreshold < 1) {
|
||||
updates.globalQuotaThreshold = globalQuotaThreshold;
|
||||
}
|
||||
if (typeof rateLimitDedupWindowMs === 'number' && rateLimitDedupWindowMs >= 1000 && rateLimitDedupWindowMs <= 30000) {
|
||||
updates.rateLimitDedupWindowMs = rateLimitDedupWindowMs;
|
||||
}
|
||||
@@ -614,10 +687,90 @@ 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 && (
|
||||
baseUrl.includes('localhost') ||
|
||||
baseUrl.includes('127.0.0.1') ||
|
||||
baseUrl.includes('::1') ||
|
||||
baseUrl.includes('0.0.0.0')
|
||||
);
|
||||
|
||||
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
|
||||
*/
|
||||
@@ -831,18 +984,18 @@ export function mountWebUI(app, dirname, accountManager) {
|
||||
const { callbackInput, state } = req.body;
|
||||
|
||||
if (!callbackInput || !state) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
error: 'Missing callbackInput or state'
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
error: 'Missing callbackInput or state'
|
||||
});
|
||||
}
|
||||
|
||||
// Find the pending flow
|
||||
const flowData = pendingOAuthFlows.get(state);
|
||||
if (!flowData) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
error: 'OAuth flow not found. The account may have been already added via auto-callback. Please refresh the account list.'
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
error: 'OAuth flow not found. The account may have been already added via auto-callback. Please refresh the account list.'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -876,10 +1029,10 @@ export function mountWebUI(app, dirname, accountManager) {
|
||||
|
||||
logger.success(`[WebUI] Account ${accountData.email} added via manual callback`);
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
res.json({
|
||||
status: 'ok',
|
||||
email: accountData.email,
|
||||
message: `Account ${accountData.email} added successfully`
|
||||
message: `Account ${accountData.email} added successfully`
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[WebUI] Manual OAuth completion error:', error);
|
||||
|
||||
@@ -42,6 +42,7 @@ async function sendStreamingRequest(id) {
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let fullText = '';
|
||||
let hasThinking = false;
|
||||
let eventCount = 0;
|
||||
|
||||
while (true) {
|
||||
@@ -60,9 +61,13 @@ async function sendStreamingRequest(id) {
|
||||
const event = JSON.parse(data);
|
||||
eventCount++;
|
||||
|
||||
// Extract text from content_block_delta events
|
||||
if (event.type === 'content_block_delta' && event.delta?.text) {
|
||||
fullText += event.delta.text;
|
||||
// Extract text or thinking from content_block_delta events
|
||||
if (event.type === 'content_block_delta') {
|
||||
if (event.delta?.text) {
|
||||
fullText += event.delta.text;
|
||||
} else if (event.delta?.thinking) {
|
||||
hasThinking = true;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore parse errors for partial chunks
|
||||
@@ -72,9 +77,16 @@ async function sendStreamingRequest(id) {
|
||||
}
|
||||
|
||||
const totalElapsed = Date.now() - startTime;
|
||||
const textPreview = fullText.substring(0, 50) || 'No text';
|
||||
console.log(`[${id}] ✅ 200 after ${totalElapsed}ms (${eventCount} events): "${textPreview}..."`);
|
||||
return { id, success: true, status: 200, elapsed: totalElapsed, eventCount };
|
||||
const hasContent = fullText.length > 0 || hasThinking;
|
||||
|
||||
if (!hasContent) {
|
||||
console.log(`[${id}] ⚠️ ${response.status} after ${totalElapsed}ms (${eventCount} events): No content received`);
|
||||
return { id, success: false, status: response.status, elapsed: totalElapsed, eventCount };
|
||||
}
|
||||
|
||||
const textPreview = fullText.substring(0, 50) || '(thinking only)';
|
||||
console.log(`[${id}] ✅ ${response.status} after ${totalElapsed}ms (${eventCount} events): "${textPreview}..."`);
|
||||
return { id, success: true, status: response.status, elapsed: totalElapsed, eventCount };
|
||||
} catch (error) {
|
||||
const elapsed = Date.now() - startTime;
|
||||
console.log(`[${id}] ❌ Error after ${elapsed}ms: ${error.message}`);
|
||||
|
||||
Reference in New Issue
Block a user