Compare commits

..

14 Commits

Author SHA1 Message Date
1050b96d2a fix: make systemd service portable across machines
- Add start-proxy.sh wrapper that handles nvm/fnm/volta/asdf
- Add install-service.sh for one-command installation
- Remove hardcoded node version paths from service file
2026-02-01 20:12:56 +01:00
5deb0b0754 a service file 2026-02-01 17:01:58 +01:00
Badri Narayanan S
2ea9f4ba8e docs: refactor README by moving sections to docs/ folder
Move 11 documentation sections to separate markdown files in docs/:
- models.md, load-balancing.md, web-console.md, configuration.md
- menubar-app.md, api-endpoints.md, testing.md, troubleshooting.md
- safety-notices.md, legal.md, development.md

README now contains a Documentation section with links to each doc.
Also moved donation link to above Star History section.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 21:00:53 +05:30
Badri Narayanan S
b72aa0e056 fix: handle thinking-only responses in stress test
Count responses with thinking content (but no text) as successful,
and validate actual response status instead of hardcoding 200.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 20:31:10 +05:30
Badri Narayanan S
dfc054ac9e chore: bump version to 2.5.0
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 20:20:06 +05:30
Badri Narayanan S
90b38bbb56 feat: validate model IDs before processing requests
Add model validation cache with 5-minute TTL to reject invalid model IDs
upfront instead of sending them to the API. This provides better error
messages and avoids unnecessary API calls.

- Add MODEL_VALIDATION_CACHE_TTL_MS constant (5 min)
- Add isValidModel() with lazy cache population
- Warm cache when listModels() is called
- Validate model ID in /v1/messages before processing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 19:07:23 +05:30
Badri Narayanan S
2ab0c7943d fix: fail immediately on 400 errors instead of cycling accounts
400 errors (INVALID_ARGUMENT) are client errors that won't be fixed by
switching accounts. Previously the proxy would cycle through all accounts
before returning the error. Now it fails immediately.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 18:36:07 +05:30
Badri Narayanan S
ae2cdc0227 fix: correct indentation in toggleMaxAccounts
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 17:18:31 +05:30
jgor20
a43d2332ca feat: per-account quota threshold protection (#212)
feat: per-account quota threshold protection

Resolves #135

- Adds configurable quota protection with three-tier threshold resolution (per-model → per-account → global)
- New global Minimum Quota Level slider in Settings
- Per-account threshold settings via Account Settings modal
- Draggable per-account threshold markers on model quota bars
- Backend: PATCH /api/accounts/:email endpoint, globalQuotaThreshold config
- i18n: quota protection keys for all 5 languages
2026-02-01 17:15:46 +05:30
Badri Narayanan S
33584d31bb fix: suppress count_tokens 501 error logs
Use startsWith() for count_tokens URL check to match requests with
query parameters (e.g., ?beta=true).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 16:55:31 +05:30
Badri Narayanan S
f80e60668c fix: improve mode toggle robustness and add i18n support
- Make mode detection more robust (handle ::1, 0.0.0.0)
- Add getProxyPort() to parse port from ANTHROPIC_BASE_URL dynamically
- Add i18n translation keys for mode toggle in all 5 languages
- Update settings.html to use translation keys and dynamic port

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 16:47:30 +05:30
Badri Narayanan S
cf2af0ba4b Merge pull request #221 from Jeeltilva/main
feat: Implement API and UI for toggling Claude CLI between proxy and …
2026-02-01 16:29:52 +05:30
Badri Narayanan S
02f8e2e323 Merge main into PR #221 to resolve conflicts
Resolved merge conflicts in public/views/settings.html:
- Fixed HTML entity escaping for quote characters in presetHint text
- Fixed HTML entity escaping for pendingPresetName text
2026-02-01 16:20:55 +05:30
JEEL TILVA
7985524d49 feat: Implement API and UI for toggling Claude CLI between proxy and paid modes 2026-01-30 13:45:16 +05:30
46 changed files with 1959 additions and 653 deletions

View File

@@ -153,7 +153,8 @@ public/
│ ├── settings-store.js # Settings management store │ ├── settings-store.js # Settings management store
│ ├── components/ # UI Components │ ├── components/ # UI Components
│ │ ├── dashboard.js # Main dashboard orchestrator │ │ ├── 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 │ │ ├── logs-viewer.js # Live log streaming
│ │ ├── claude-config.js # CLI settings editor │ │ ├── claude-config.js # CLI settings editor
│ │ ├── server-config.js # Server settings UI │ │ ├── server-config.js # Server settings UI
@@ -184,6 +185,7 @@ public/
- Strategies: `sticky` (cache-optimized), `round-robin` (load-balanced), `hybrid` (smart distribution) - 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/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/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/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/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()`) - **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)` - Scoring formula: `score = (Health × 2) + ((Tokens / MaxTokens × 100) × 5) + (Quota × 1) + (LRU × 0.1)`
- Health scores: Track success/failure patterns with passive recovery - Health scores: Track success/failure patterns with passive recovery
- Token buckets: Client-side rate limiting (50 tokens, 6 per minute regeneration) - 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 - LRU freshness: Prefer accounts that have rested longer
- **Emergency/Last Resort Fallback**: When all accounts are exhausted: - **Emergency/Last Resort Fallback**: When all accounts are exhausted:
- Emergency fallback: Bypasses health check, adds 250ms throttle delay - Emergency fallback: Bypasses health check, adds 250ms throttle delay
- Last resort fallback: Bypasses both health and token checks, adds 500ms throttle delay - Last resort fallback: Bypasses both health and token checks, adds 500ms throttle delay
- Configuration in `src/config.js` under `accountSelection` - 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:** **Account Data Model:**
Each account object in `accounts.json` contains: Each account object in `accounts.json` contains:
- **Basic Info**: `email`, `source` (oauth/manual/database), `enabled`, `lastUsed` - **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`) - `tier`: 'free' | 'pro' | 'ultra' (detected from `paidTier` or `currentTier`)
- **Quota**: `{ models: {}, lastChecked }` - model-specific quota cache - **Quota**: `{ models: {}, lastChecked }` - model-specific quota cache
- `models[modelId]`: `{ remainingFraction, resetTime }` from `fetchAvailableModels` API - `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) - **Rate Limits**: `modelRateLimits[modelId]` - temporary rate limit state (in-memory during runtime)
- **Validity**: `isInvalid`, `invalidReason` - tracks accounts needing re-authentication - **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 - Layered architecture: Service Layer (`account-actions.js`) → Component Layer → UI
- **Features**: - **Features**:
- Real-time dashboard with Chart.js visualization and subscription tier distribution - 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 - OAuth flow handling via popup window
- Live log streaming via Server-Sent Events (SSE) - Live log streaming via Server-Sent Events (SSE)
- Config editor for both Proxy and Claude CLI (`~/.claude/settings.json`) - 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 - **Security**: Optional password protection via `WEBUI_PASSWORD` env var
- **Config Redaction**: Sensitive values (passwords, tokens) are redacted in API responses - **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) - **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 ## Testing Notes
@@ -353,14 +370,16 @@ Each account object in `accounts.json` contains:
**WebUI APIs:** **WebUI APIs:**
- `/api/accounts/*` - Account management (list, add, remove, refresh) - `/api/accounts/*` - Account management (list, add, remove, refresh, threshold settings)
- `/api/config/*` - Server configuration (read/write) - `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/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/logs/stream` - SSE endpoint for real-time logs
- `/api/stats/history` - Retrieve 30-day request history (sorted chronologically) - `/api/stats/history` - Retrieve 30-day request history (sorted chronologically)
- `/api/auth/url` - Generate Google OAuth URL - `/api/auth/url` - Generate Google OAuth URL
- `/account-limits` - Fetch account quotas and subscription data - `/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) - Query params: `?format=table` (ASCII table) or `?includeHistory=true` (adds usage stats)
## Frontend Development ## Frontend Development

412
README.md
View File

@@ -4,8 +4,6 @@
[![npm downloads](https://img.shields.io/npm/dm/antigravity-claude-proxy.svg)](https://www.npmjs.com/package/antigravity-claude-proxy) [![npm downloads](https://img.shields.io/npm/dm/antigravity-claude-proxy.svg)](https://www.npmjs.com/package/antigravity-claude-proxy)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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**. 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**.
![Antigravity Claude Proxy Banner](images/banner.png) ![Antigravity Claude Proxy Banner](images/banner.png)
@@ -132,7 +130,10 @@ You can configure these settings in two ways:
1. Open the WebUI at `http://localhost:8080`. 1. Open the WebUI at `http://localhost:8080`.
2. Go to **Settings****Claude CLI**. 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. > [!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. > **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) ### Multiple Claude Code Instances (Optional)
To run both the official Claude Code and Antigravity version simultaneously, add this alias: 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 ## Documentation
### Claude Models - [Available Models](docs/models.md)
- [Multi-Account Load Balancing](docs/load-balancing.md)
| Model ID | Description | - [Web Management Console](docs/web-console.md)
| ---------------------------- | ---------------------------------------- | - [Advanced Configuration](docs/configuration.md)
| `claude-sonnet-4-5-thinking` | Claude Sonnet 4.5 with extended thinking | - [macOS Menu Bar App](docs/menubar-app.md)
| `claude-opus-4-5-thinking` | Claude Opus 4.5 with extended thinking | - [API Endpoints](docs/api-endpoints.md)
| `claude-sonnet-4-5` | Claude Sonnet 4.5 without thinking | - [Testing](docs/testing.md)
- [Troubleshooting](docs/troubleshooting.md)
### Gemini Models - [Safety, Usage, and Risk Notices](docs/safety-notices.md)
- [Legal](docs/legal.md)
| Model ID | Description | - [Development](docs/development.md)
| ------------------- | ------------------------------- |
| `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).
![Antigravity Console](images/webui-dashboard.png)
### 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.
![AntiGravity Claude Proxy Bar](https://github.com/IrvanFza/antigravity-claude-proxy-bar/blob/main/images/application.png?raw=true)
### 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
--- ---
@@ -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 ## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=badrisnarayanan/antigravity-claude-proxy&type=date&legend=top-left&cache-control=no-cache)](https://www.star-history.com/#badrisnarayanan/antigravity-claude-proxy&type=date&legend=top-left) [![Star History Chart](https://api.star-history.com/svg?repos=badrisnarayanan/antigravity-claude-proxy&type=date&legend=top-left&cache-control=no-cache)](https://www.star-history.com/#badrisnarayanan/antigravity-claude-proxy&type=date&legend=top-left)

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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.
![AntiGravity Claude Proxy Bar](https://github.com/IrvanFza/antigravity-claude-proxy-bar/blob/main/images/application.png?raw=true)
## 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
View 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
View 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
View 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
View 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
View 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
View 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).
![Antigravity Console](../images/webui-dashboard.png)
## 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
View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "antigravity-claude-proxy", "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", "description": "Proxy server to use Antigravity's Claude models with Claude Code CLI",
"main": "src/index.js", "main": "src/index.js",
"type": "module", "type": "module",

2
public/css/style.css generated

File diff suppressed because one or more lines are too long

View File

@@ -182,6 +182,129 @@ window.Components.accountManager = () => ({
document.getElementById('quota_modal').showModal(); 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 * Get main model quota for display
* Prioritizes flagship models (Opus > Sonnet > Flash) * Prioritizes flagship models (Opus > Sonnet > Flash)

View File

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

View File

@@ -6,6 +6,33 @@
window.Components = window.Components || {}; window.Components = window.Components || {};
window.Components.models = () => ({ 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, editingModelId: null,
newMapping: '', newMapping: '',
@@ -21,6 +48,188 @@ window.Components.models = () => ({
this.editingModelId = null; 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() { init() {
// Ensure data is fetched when this tab becomes active (skip initial trigger) // Ensure data is fetched when this tab becomes active (skip initial trigger)
this.$watch('$store.global.activeTab', (val, oldVal) => { this.$watch('$store.global.activeTab', (val, oldVal) => {

View File

@@ -250,6 +250,46 @@ window.Components.serverConfig = () => ({
(v) => window.Validators.validateTimeout(v, MAX_WAIT_MIN, MAX_WAIT_MAX)); (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) { toggleMaxAccounts(value) {
const { MAX_ACCOUNTS_MIN, MAX_ACCOUNTS_MAX } = window.AppConstants.VALIDATION; const { MAX_ACCOUNTS_MIN, MAX_ACCOUNTS_MAX } = window.AppConstants.VALIDATION;
this.saveConfigField('maxAccounts', value, 'Max Accounts', this.saveConfigField('maxAccounts', value, 'Max Accounts',

View File

@@ -87,7 +87,11 @@ window.AppConstants.VALIDATION = {
// Capacity retries (1 - 10) // Capacity retries (1 - 10)
MAX_CAPACITY_RETRIES_MIN: 1, 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
}; };
/** /**

View File

@@ -13,6 +13,7 @@ document.addEventListener('alpine:init', () => {
modelConfig: {}, // Model metadata (hidden, pinned, alias) modelConfig: {}, // Model metadata (hidden, pinned, alias)
quotaRows: [], // Filtered view quotaRows: [], // Filtered view
usageHistory: {}, // Usage statistics history (from /account-limits?includeHistory=true) 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) maxAccounts: 10, // Maximum number of accounts allowed (from config)
loading: false, loading: false,
initialLoad: true, // Track first load for skeleton screen initialLoad: true, // Track first load for skeleton screen
@@ -116,6 +117,7 @@ document.addEventListener('alpine:init', () => {
this.models = data.models; this.models = data.models;
} }
this.modelConfig = data.modelConfig || {}; this.modelConfig = data.modelConfig || {};
this.globalQuotaThreshold = data.globalQuotaThreshold || 0;
// Store usage history if included (for dashboard) // Store usage history if included (for dashboard)
if (data.history) { if (data.history) {
@@ -236,6 +238,8 @@ document.addEventListener('alpine:init', () => {
let totalQuotaSum = 0; let totalQuotaSum = 0;
let validAccountCount = 0; let validAccountCount = 0;
let minResetTime = null; let minResetTime = null;
let maxEffectiveThreshold = 0;
const globalThreshold = this.globalQuotaThreshold || 0;
this.accounts.forEach(acc => { this.accounts.forEach(acc => {
if (acc.enabled === false) return; if (acc.enabled === false) return;
@@ -255,11 +259,26 @@ document.addEventListener('alpine:init', () => {
minResetTime = limit.resetTime; 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({ quotaInfo.push({
email: acc.email.split('@')[0], email: acc.email.split('@')[0],
fullEmail: acc.email, fullEmail: acc.email,
pct: pct, 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; 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({ rows.push({
modelId, modelId,
displayName: modelId, // Simplified: no longer using alias displayName: modelId, // Simplified: no longer using alias
@@ -279,7 +302,9 @@ document.addEventListener('alpine:init', () => {
quotaInfo, quotaInfo,
pinned: !!config.pinned, pinned: !!config.pinned,
hidden: !!isHidden, // Use computed visibility 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
}); });
}); });

View File

@@ -237,6 +237,11 @@ window.translations.en = {
defaultCooldownDesc: "Fallback cooldown when API doesn't provide a reset time.", defaultCooldownDesc: "Fallback cooldown when API doesn't provide a reset time.",
maxWaitThreshold: "Max Wait Before Error", maxWaitThreshold: "Max Wait Before Error",
maxWaitDesc: "If all accounts are rate-limited longer than this, error immediately instead of waiting.", 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 // Error Handling Tuning
errorHandlingTuning: "Error Handling Tuning", errorHandlingTuning: "Error Handling Tuning",
rateLimitDedupWindow: "Rate Limit Dedup Window", rateLimitDedupWindow: "Rate Limit Dedup Window",
@@ -364,4 +369,14 @@ window.translations.en = {
mustBeAtMost: "{fieldName} must be at most {max}", mustBeAtMost: "{fieldName} must be at most {max}",
cannotBeEmpty: "{fieldName} cannot be empty", cannotBeEmpty: "{fieldName} cannot be empty",
mustBeTrueOrFalse: "Value must be true or false", 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",
}; };

View File

@@ -270,6 +270,11 @@ window.translations.id = {
defaultCooldownDesc: "Cooldown bawaan jika API tidak memberikan waktu reset.", defaultCooldownDesc: "Cooldown bawaan jika API tidak memberikan waktu reset.",
maxWaitThreshold: "Batas Tunggu Maksimal", maxWaitThreshold: "Batas Tunggu Maksimal",
maxWaitDesc: "Jika semua akun terkena rate limit lebih lama dari ini, langsung gagal.", 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 // Error Handling Tuning
errorHandlingTuning: "Penyetelan Penanganan Error", errorHandlingTuning: "Penyetelan Penanganan Error",
rateLimitDedupWindow: "Jendela Deduplikasi Rate Limit", rateLimitDedupWindow: "Jendela Deduplikasi Rate Limit",
@@ -409,4 +414,14 @@ window.translations.id = {
strategyUpdated: "Strategi diubah ke: {strategy}", strategyUpdated: "Strategi diubah ke: {strategy}",
failedToUpdateStrategy: "Gagal memperbarui strategi", failedToUpdateStrategy: "Gagal memperbarui strategi",
invalidStrategy: "Strategi tidak valid dipilih", 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",
}; };

View File

@@ -215,6 +215,11 @@ window.translations.pt = {
defaultCooldownDesc: "Resfriamento de fallback quando a API não fornece tempo de reset.", defaultCooldownDesc: "Resfriamento de fallback quando a API não fornece tempo de reset.",
maxWaitThreshold: "Limiar Máximo de Espera (Sticky)", maxWaitThreshold: "Limiar Máximo de Espera (Sticky)",
maxWaitDesc: "Tempo máximo para aguardar uma conta sticky resetar antes de trocar.", 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 // Ajuste de Tratamento de Erros
errorHandlingTuning: "Ajuste de Tratamento de Erros", errorHandlingTuning: "Ajuste de Tratamento de Erros",
rateLimitDedupWindow: "Janela de Deduplicação de Rate Limit", rateLimitDedupWindow: "Janela de Deduplicação de Rate Limit",
@@ -305,4 +310,14 @@ window.translations.pt = {
strategyUpdated: "Estratégia atualizada para: {strategy}", strategyUpdated: "Estratégia atualizada para: {strategy}",
failedToUpdateStrategy: "Falha ao atualizar estratégia", failedToUpdateStrategy: "Falha ao atualizar estratégia",
invalidStrategy: "Estratégia inválida selecionada", 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",
}; };

View File

@@ -219,6 +219,11 @@ window.translations.tr = {
defaultCooldownDesc: "API sıfırlama zamanı sağlamadığında yedek soğuma süresi.", defaultCooldownDesc: "API sıfırlama zamanı sağlamadığında yedek soğuma süresi.",
maxWaitThreshold: "Maksimum Bekleme Eşiği (Yapışkan)", 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.", 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ı // Hata İşleme Ayarları
errorHandlingTuning: "Hata İşleme Ayarları", errorHandlingTuning: "Hata İşleme Ayarları",
rateLimitDedupWindow: "Hız Sınırı Tekilleştirme Penceresi", rateLimitDedupWindow: "Hız Sınırı Tekilleştirme Penceresi",
@@ -355,4 +360,14 @@ window.translations.tr = {
strategyUpdated: "Strateji şu şekilde güncellendi: {strategy}", strategyUpdated: "Strateji şu şekilde güncellendi: {strategy}",
failedToUpdateStrategy: "Strateji güncellenemedi", failedToUpdateStrategy: "Strateji güncellenemedi",
invalidStrategy: "Geçersiz strateji seçildi", 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",
}; };

View File

@@ -237,6 +237,11 @@ window.translations.zh = {
defaultCooldownDesc: "当 API 未提供重置时间时的备用冷却时间。", defaultCooldownDesc: "当 API 未提供重置时间时的备用冷却时间。",
maxWaitThreshold: "最大等待阈值", maxWaitThreshold: "最大等待阈值",
maxWaitDesc: "如果所有账号的限流时间超过此阈值,立即返回错误而非等待。", maxWaitDesc: "如果所有账号的限流时间超过此阈值,立即返回错误而非等待。",
// 配额保护
quotaProtection: "配额保护",
minimumQuotaLevel: "最低配额水平",
minimumQuotaLevelDesc: "当配额低于此水平时切换账号。每个账号的单独设置优先。",
quotaDisabled: "已禁用",
// 错误处理调优 // 错误处理调优
errorHandlingTuning: "错误处理调优", errorHandlingTuning: "错误处理调优",
rateLimitDedupWindow: "限流去重窗口", rateLimitDedupWindow: "限流去重窗口",
@@ -370,4 +375,14 @@ window.translations.zh = {
strategyUpdated: "策略已更新为: {strategy}", strategyUpdated: "策略已更新为: {strategy}",
failedToUpdateStrategy: "更新策略失败", failedToUpdateStrategy: "更新策略失败",
invalidStrategy: "选择了无效的策略", 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: "切换模式失败",
}; };

View File

@@ -180,6 +180,9 @@
<template x-if="quota.percent === null"> <template x-if="quota.percent === null">
<span class="text-xs text-gray-600">-</span> <span class="text-xs text-gray-600">-</span>
</template> </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> </div>
</td> </td>
<td class="py-4"> <td class="py-4">
@@ -204,6 +207,17 @@
x-text="$store.global.t('fix')"> x-text="$store.global.t('fix')">
FIX FIX
</button> </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" <button class="p-2 rounded hover:bg-white/10 text-gray-500 hover:text-white transition-colors"
@click="refreshAccount(acc.email)" @click="refreshAccount(acc.email)"
:disabled="refreshing" :disabled="refreshing"
@@ -357,4 +371,154 @@
<button x-text="$store.global.t('close')">close</button> <button x-text="$store.global.t('close')">close</button>
</form> </form>
</dialog> </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> </div>

View File

@@ -190,9 +190,41 @@
<span class="text-gray-500 text-[10px]" <span class="text-gray-500 text-[10px]"
x-text="row.quotaInfo.filter(q => q.pct > 0).length + '/' + row.quotaInfo.length"></span> x-text="row.quotaInfo.filter(q => q.pct > 0).length + '/' + row.quotaInfo.length"></span>
</div> </div>
<progress class="progress w-full h-1 bg-space-800" <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')" :class="row.avgQuota > 50 ? 'progress-gradient-success' : (row.avgQuota > 0 ? 'progress-gradient-warning' : 'progress-gradient-error')"
:value="row.avgQuota" max="100"></progress> :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> </div>
</td> </td>
<td class="font-mono text-xs"> <td class="font-mono text-xs">
@@ -219,7 +251,8 @@
<div class="flex flex-wrap gap-1 justify-start"> <div class="flex flex-wrap gap-1 justify-start">
<!-- Visible accounts (limited to maxVisible) --> <!-- Visible accounts (limited to maxVisible) -->
<template x-for="(q, idx) in row.quotaInfo.slice(0, maxVisible)" :key="q.fullEmail"> <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" <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')"> :class="q.pct > 50 ? 'bg-neon-green opacity-80' : (q.pct > 0 ? 'bg-yellow-500 opacity-80' : 'bg-red-900 opacity-50')">
</div> </div>
@@ -228,7 +261,7 @@
<!-- Overflow indicator --> <!-- Overflow indicator -->
<template x-if="row.quotaInfo.length > maxVisible"> <template x-if="row.quotaInfo.length > maxVisible">
<div class="tooltip tooltip-left" <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"> <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> <span class="text-[8px] text-gray-400 font-bold leading-none" x-text="`+${row.quotaInfo.length - maxVisible}`"></span>
</div> </div>

View File

@@ -73,8 +73,7 @@
</label> </label>
<select <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" 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" :value="$store.global.lang" @change="$store.global.setLang($event.target.value)">
@change="$store.global.setLang($event.target.value)">
<option value="en">English</option> <option value="en">English</option>
<option value="zh">中文</option> <option value="zh">中文</option>
<option value="tr">Türkçe</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" <input type="range" min="10" max="300" class="custom-range custom-range-purple flex-1"
x-model.number="$store.settings.refreshInterval" x-model.number="$store.settings.refreshInterval"
:style="`background-size: ${($store.settings.refreshInterval - 10) / 2.9}% 100%`" :style="`background-size: ${($store.settings.refreshInterval - 10) / 2.9}% 100%`"
@change="$store.settings.saveSettings(true)" @change="$store.settings.saveSettings(true)" aria-label="Polling interval slider">
aria-label="Polling interval slider">
<input type="number" min="10" max="300" <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" 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" x-model.number="$store.settings.refreshInterval"
@change="$store.settings.saveSettings(true)" @change="$store.settings.saveSettings(true)" aria-label="Polling interval value">
aria-label="Polling interval value">
</div> </div>
<div class="w-full flex justify-between text-xs px-2 mt-2 text-gray-600 font-mono"> <div class="w-full flex justify-between text-xs px-2 mt-2 text-gray-600 font-mono">
<span>10s</span> <span>10s</span>
@@ -118,15 +115,14 @@
x-text="$store.settings.logLimit + ' ' + $store.global.t('lines')"></span> x-text="$store.settings.logLimit + ' ' + $store.global.t('lines')"></span>
</label> </label>
<div class="flex gap-3 items-center"> <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" x-model.number="$store.settings.logLimit"
:style="`background-size: ${($store.settings.logLimit - 500) / 45}% 100%`" :style="`background-size: ${($store.settings.logLimit - 500) / 45}% 100%`"
@change="$store.settings.saveSettings(true)" @change="$store.settings.saveSettings(true)" aria-label="Log buffer size slider">
aria-label="Log buffer size slider">
<input type="number" min="500" max="5000" step="500" <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" 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" x-model.number="$store.settings.logLimit" @change="$store.settings.saveSettings(true)"
@change="$store.settings.saveSettings(true)"
aria-label="Log buffer size value"> aria-label="Log buffer size value">
</div> </div>
<div class="w-full flex justify-between text-xs px-2 mt-2 text-gray-600 font-mono"> <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> d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg> </svg>
<span class="text-gray-400"> <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>. <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 x-text="$store.global.t('claudeSettingsAlertSuffix')">Restart Claude CLI to apply.</span>
</span> </span>
</div> </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 --> <!-- 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 justify-between mb-4">
<div class="flex items-center gap-2"> <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"> <svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 text-neon-cyan" fill="none"
<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" /> 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> </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> </div>
<button class="btn btn-xs btn-ghost text-neon-cyan hover:bg-neon-cyan/10 gap-1" <button class="btn btn-xs btn-ghost text-neon-cyan hover:bg-neon-cyan/10 gap-1"
@click="saveCurrentAsPreset()" @click="saveCurrentAsPreset()" :disabled="savingPreset">
:disabled="savingPreset"> <svg x-show="!savingPreset" xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5"
<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"> fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 4v16m8-8H4" />
</svg> </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> <span x-show="savingPreset" class="loading loading-spinner loading-xs"></span>
</button> </button>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<select <select
class="select select-sm bg-space-800 border-space-border text-white flex-1 font-mono text-xs" class="select select-sm bg-space-800 border-space-border text-white flex-1 font-mono text-xs"
:disabled="presets.length === 0" :disabled="presets.length === 0" :value="selectedPresetName"
:value="selectedPresetName" @change="onPresetSelect($event.target.value)" aria-label="Select preset">
@change="onPresetSelect($event.target.value)"
aria-label="Select preset">
<option value="" disabled x-show="presets.length === 0">No presets available</option> <option value="" disabled x-show="presets.length === 0">No presets available</option>
<template x-for="preset in presets" :key="preset.name"> <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> </template>
</select> </select>
<button class="btn btn-sm btn-ghost text-red-400 hover:bg-red-500/10" <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" :disabled="!selectedPresetName || presets.length === 0 || deletingPreset"
:title="$store.global.t('deletePreset') || 'Delete preset'"> :title="$store.global.t('deletePreset') || 'Delete preset'">
<span x-show="!deletingPreset"> <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"> <svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24"
<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="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> </svg>
</span> </span>
<span x-show="deletingPreset" class="loading loading-spinner loading-xs"></span> <span x-show="deletingPreset" class="loading loading-spinner loading-xs"></span>
@@ -247,7 +303,7 @@
</div> </div>
<!-- Base URL --> <!-- 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" <label class="label text-xs uppercase text-gray-500 font-semibold mb-2"
x-text="$store.global.t('proxyConnection')">Proxy Connection</label> x-text="$store.global.t('proxyConnection')">Proxy Connection</label>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
@@ -266,7 +322,7 @@
</div> </div>
<!-- Models Selection --> <!-- 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" <label class="label text-xs uppercase text-gray-500 font-semibold mb-2"
x-text="$store.global.t('modelSelection')">Model Selection</label> 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" <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> x-text="$store.global.t('primaryModel')">Primary Model</label>
<div class="relative w-full" x-data="{ open: false, searchTerm: '' }"> <div class="relative w-full" x-data="{ open: false, searchTerm: '' }">
<input type="text" <input type="text" :value="open ? searchTerm : config.env.ANTHROPIC_MODEL"
:value="open ? searchTerm : config.env.ANTHROPIC_MODEL" @input="searchTerm = $event.target.value" @focus="open = true; searchTerm = ''"
@input="searchTerm = $event.target.value"
@focus="open = true; searchTerm = ''"
@click.away="open = false; 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" 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') : ''" :placeholder="open ? $store.global.t('typeToSearch') : ''"
@@ -305,13 +359,16 @@
:class="$store.data.getModelFamily(modelId) === 'claude' ? 'bg-neon-purple shadow-[0_0_5px_rgba(168,85,247,0.5)]' : ($store.data.getModelFamily(modelId) === 'gemini' ? 'bg-neon-green shadow-[0_0_5px_rgba(34,197,94,0.5)]' : 'bg-gray-600')"></span> :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> <span x-text="modelId"></span>
</div> </div>
<template x-if="gemini1mSuffix && $store.data.getModelFamily(modelId) === 'gemini'"> <template
<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> 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> </template>
</a> </a>
</li> </li>
</template> </template>
<li x-show="$store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase())).length === 0"> <li
x-show="$store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase())).length === 0">
<span class="text-xs text-gray-500 italic py-2" <span class="text-xs text-gray-500 italic py-2"
x-text="$store.global.t('noMatchingModels')">No matching models</span> x-text="$store.global.t('noMatchingModels')">No matching models</span>
</li> </li>
@@ -325,10 +382,8 @@
<label class="label pt-0 pb-1 text-[11px] text-gray-400 font-bold tracking-wider" <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> x-text="$store.global.t('subAgentModel')">Sub-agent Model</label>
<div class="relative w-full" x-data="{ open: false, searchTerm: '' }"> <div class="relative w-full" x-data="{ open: false, searchTerm: '' }">
<input type="text" <input type="text" :value="open ? searchTerm : config.env.CLAUDE_CODE_SUBAGENT_MODEL"
:value="open ? searchTerm : config.env.CLAUDE_CODE_SUBAGENT_MODEL" @input="searchTerm = $event.target.value" @focus="open = true; searchTerm = ''"
@input="searchTerm = $event.target.value"
@focus="open = true; searchTerm = ''"
@click.away="open = false; 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" 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') : ''" :placeholder="open ? $store.global.t('typeToSearch') : ''"
@@ -354,13 +409,16 @@
:class="$store.data.getModelFamily(modelId) === 'claude' ? 'bg-neon-purple shadow-[0_0_5px_rgba(168,85,247,0.5)]' : ($store.data.getModelFamily(modelId) === 'gemini' ? 'bg-neon-green shadow-[0_0_5px_rgba(34,197,94,0.5)]' : 'bg-gray-600')"></span> :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> <span x-text="modelId"></span>
</div> </div>
<template x-if="gemini1mSuffix && $store.data.getModelFamily(modelId) === 'gemini'"> <template
<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> 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> </template>
</a> </a>
</li> </li>
</template> </template>
<li x-show="$store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase())).length === 0"> <li
x-show="$store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase())).length === 0">
<span class="text-xs text-gray-500 italic py-2" <span class="text-xs text-gray-500 italic py-2"
x-text="$store.global.t('noMatchingModels')">No matching models</span> x-text="$store.global.t('noMatchingModels')">No matching models</span>
</li> </li>
@@ -380,10 +438,8 @@
<label class="label pt-0 pb-1 text-[10px] text-gray-500 uppercase font-bold" <label class="label pt-0 pb-1 text-[10px] text-gray-500 uppercase font-bold"
x-text="$store.global.t('opusAlias')">Opus Alias</label> x-text="$store.global.t('opusAlias')">Opus Alias</label>
<div class="relative w-full" x-data="{ open: false, searchTerm: '' }"> <div class="relative w-full" x-data="{ open: false, searchTerm: '' }">
<input type="text" <input type="text" :value="open ? searchTerm : config.env.ANTHROPIC_DEFAULT_OPUS_MODEL"
:value="open ? searchTerm : config.env.ANTHROPIC_DEFAULT_OPUS_MODEL" @input="searchTerm = $event.target.value" @focus="open = true; searchTerm = ''"
@input="searchTerm = $event.target.value"
@focus="open = true; searchTerm = ''"
@click.away="open = false; 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" 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') : ''" :placeholder="open ? $store.global.t('searchPlaceholder') : ''"
@@ -408,13 +464,16 @@
:class="$store.data.getModelFamily(modelId) === 'claude' ? 'bg-neon-purple shadow-[0_0_5px_rgba(168,85,247,0.5)]' : ($store.data.getModelFamily(modelId) === 'gemini' ? 'bg-neon-green shadow-[0_0_5px_rgba(34,197,94,0.5)]' : 'bg-gray-600')"></span> :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> <span x-text="modelId"></span>
</div> </div>
<template x-if="gemini1mSuffix && $store.data.getModelFamily(modelId) === 'gemini'"> <template
<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> 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> </template>
</a> </a>
</li> </li>
</template> </template>
<li x-show="$store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase())).length === 0"> <li
x-show="$store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase())).length === 0">
<span class="text-xs text-gray-500 italic py-2" <span class="text-xs text-gray-500 italic py-2"
x-text="$store.global.t('noMatchingModels')">No matching models</span> x-text="$store.global.t('noMatchingModels')">No matching models</span>
</li> </li>
@@ -428,8 +487,7 @@
<div class="relative w-full" x-data="{ open: false, searchTerm: '' }"> <div class="relative w-full" x-data="{ open: false, searchTerm: '' }">
<input type="text" <input type="text"
:value="open ? searchTerm : config.env.ANTHROPIC_DEFAULT_SONNET_MODEL" :value="open ? searchTerm : config.env.ANTHROPIC_DEFAULT_SONNET_MODEL"
@input="searchTerm = $event.target.value" @input="searchTerm = $event.target.value" @focus="open = true; searchTerm = ''"
@focus="open = true; searchTerm = ''"
@click.away="open = false; 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" 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') : ''" :placeholder="open ? $store.global.t('searchPlaceholder') : ''"
@@ -454,13 +512,16 @@
:class="$store.data.getModelFamily(modelId) === 'claude' ? 'bg-neon-purple shadow-[0_0_5px_rgba(168,85,247,0.5)]' : ($store.data.getModelFamily(modelId) === 'gemini' ? 'bg-neon-green shadow-[0_0_5px_rgba(34,197,94,0.5)]' : 'bg-gray-600')"></span> :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> <span x-text="modelId"></span>
</div> </div>
<template x-if="gemini1mSuffix && $store.data.getModelFamily(modelId) === 'gemini'"> <template
<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> 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> </template>
</a> </a>
</li> </li>
</template> </template>
<li x-show="$store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase())).length === 0"> <li
x-show="$store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase())).length === 0">
<span class="text-xs text-gray-500 italic py-2" <span class="text-xs text-gray-500 italic py-2"
x-text="$store.global.t('noMatchingModels')">No matching models</span> x-text="$store.global.t('noMatchingModels')">No matching models</span>
</li> </li>
@@ -472,10 +533,8 @@
<label class="label pt-0 pb-1 text-[10px] text-gray-500 uppercase font-bold" <label class="label pt-0 pb-1 text-[10px] text-gray-500 uppercase font-bold"
x-text="$store.global.t('haikuAlias')">Haiku Alias</label> x-text="$store.global.t('haikuAlias')">Haiku Alias</label>
<div class="relative w-full" x-data="{ open: false, searchTerm: '' }"> <div class="relative w-full" x-data="{ open: false, searchTerm: '' }">
<input type="text" <input type="text" :value="open ? searchTerm : config.env.ANTHROPIC_DEFAULT_HAIKU_MODEL"
:value="open ? searchTerm : config.env.ANTHROPIC_DEFAULT_HAIKU_MODEL" @input="searchTerm = $event.target.value" @focus="open = true; searchTerm = ''"
@input="searchTerm = $event.target.value"
@focus="open = true; searchTerm = ''"
@click.away="open = false; 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" 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') : ''" :placeholder="open ? $store.global.t('searchPlaceholder') : ''"
@@ -500,13 +559,16 @@
:class="$store.data.getModelFamily(modelId) === 'claude' ? 'bg-neon-purple shadow-[0_0_5px_rgba(168,85,247,0.5)]' : ($store.data.getModelFamily(modelId) === 'gemini' ? 'bg-neon-green shadow-[0_0_5px_rgba(34,197,94,0.5)]' : 'bg-gray-600')"></span> :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> <span x-text="modelId"></span>
</div> </div>
<template x-if="gemini1mSuffix && $store.data.getModelFamily(modelId) === 'gemini'"> <template
<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> 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> </template>
</a> </a>
</li> </li>
</template> </template>
<li x-show="$store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase())).length === 0"> <li
x-show="$store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase())).length === 0">
<span class="text-xs text-gray-500 italic py-2" <span class="text-xs text-gray-500 italic py-2"
x-text="$store.global.t('noMatchingModels')">No matching models</span> x-text="$store.global.t('noMatchingModels')">No matching models</span>
</li> </li>
@@ -517,14 +579,15 @@
</div> </div>
<!-- MCP CLI Experimental Mode --> <!-- 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 items-center justify-between">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<span class="text-sm font-medium transition-colors" <span class="text-sm font-medium transition-colors"
:class="config.env.ENABLE_EXPERIMENTAL_MCP_CLI === 'true' ? 'text-neon-green' : 'text-gray-300'" :class="config.env.ENABLE_EXPERIMENTAL_MCP_CLI === 'true' ? 'text-neon-green' : 'text-gray-300'"
x-text="$store.global.t('mcpCliExperimental')">Experimental MCP CLI</span> x-text="$store.global.t('mcpCliExperimental')">Experimental MCP CLI</span>
<span class="text-[11px] text-gray-500" x-text="$store.global.t('mcpCliDesc')"> <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> </span>
</div> </div>
<label class="relative inline-flex items-center cursor-pointer"> <label class="relative inline-flex items-center cursor-pointer">
@@ -540,7 +603,7 @@
</div> </div>
<!-- Gemini 1M Context Suffix Toggle --> <!-- 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 items-center justify-between">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<span class="text-sm font-medium transition-colors" <span class="text-sm font-medium transition-colors"
@@ -554,8 +617,7 @@
</span> </span>
</div> </div>
<label class="relative inline-flex items-center cursor-pointer"> <label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" class="sr-only peer" <input type="checkbox" class="sr-only peer" :checked="gemini1mSuffix"
:checked="gemini1mSuffix"
@change="toggleGemini1mSuffix($event.target.checked)" @change="toggleGemini1mSuffix($event.target.checked)"
aria-label="Gemini 1M context mode toggle"> aria-label="Gemini 1M context mode toggle">
<div <div
@@ -565,8 +627,9 @@
</div> </div>
</div> </div>
<div class="flex justify-end pt-2 gap-3"> <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" <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"> @click="restoreDefaultClaudeConfig" :disabled="restoring">
<svg x-show="!restoring" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" <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"> <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"> <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"> <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> </svg>
<span x-text="$store.global.t('confirmRestoreTitle')">Confirm Restore</span> <span x-text="$store.global.t('confirmRestoreTitle')">Confirm Restore</span>
</h3> </h3>
<p class="py-4 text-gray-300" x-text="$store.global.t('confirmRestoreMessage')"> <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> </p>
<div class="modal-action"> <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> x-text="$store.global.t('cancel')">Cancel</button>
<button class="btn bg-red-500 hover:bg-red-600 border-none text-white" @click="executeRestore()" <button class="btn bg-red-500 hover:bg-red-600 border-none text-white"
:disabled="restoring" @click="executeRestore()" :disabled="restoring" :class="{ 'loading': restoring }">
:class="{ 'loading': restoring }"> <span x-text="$store.global.t('confirmRestore')" x-show="!restoring">Confirm
<span x-text="$store.global.t('confirmRestore')" x-show="!restoring">Confirm Restore</span> Restore</span>
</button> </button>
</div> </div>
</div> </div>
@@ -618,19 +684,24 @@
<div class="modal-box bg-space-900 border-2 border-yellow-500/50"> <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"> <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"> <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> </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> </h3>
<p class="py-4 text-gray-300"> <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> <br><br>
<span class="text-yellow-400/80" x-text="'Load &quot;' + pendingPresetName + '&quot; and lose current changes?'"></span> <span class="text-yellow-400/80" x-text="'Load &quot;' + pendingPresetName + '&quot; and lose current changes?'"></span>
</p> </p>
<div class="modal-action"> <div class="modal-action">
<button class="btn btn-ghost text-gray-400" @click="cancelLoadPreset()" <button class="btn btn-ghost text-gray-400" @click="cancelLoadPreset()"
x-text="$store.global.t('cancel')">Cancel</button> 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> <span x-text="$store.global.t('loadAnyway') || 'Load Anyway'">Load Anyway</span>
</button> </button>
</div> </div>
@@ -645,16 +716,19 @@
<div class="modal-box bg-space-900 border-2 border-neon-cyan/50"> <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"> <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"> <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> </svg>
<span x-text="$store.global.t('savePresetTitle') || 'Save Preset'">Save Preset</span> <span x-text="$store.global.t('savePresetTitle') || 'Save Preset'">Save Preset</span>
</h3> </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. Save the current configuration as a reusable preset.
</p> </p>
<div class="form-control mt-4"> <div class="form-control mt-4">
<label class="label"> <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> </label>
<input type="text" x-model="newPresetName" <input type="text" x-model="newPresetName"
class="input input-sm input-bordered bg-space-800 border-space-border text-white w-full" class="input input-sm input-bordered bg-space-800 border-space-border text-white w-full"
@@ -664,13 +738,14 @@
aria-label="Preset name"> aria-label="Preset name">
</div> </div>
<div class="modal-action"> <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> x-text="$store.global.t('cancel')">Cancel</button>
<button class="btn bg-neon-cyan hover:bg-cyan-600 border-none text-black" <button class="btn bg-neon-cyan hover:bg-cyan-600 border-none text-black"
@click="executeSavePreset(newPresetName)" @click="executeSavePreset(newPresetName)"
:disabled="!newPresetName.trim() || savingPreset" :disabled="!newPresetName.trim() || savingPreset" :class="{ 'loading': savingPreset }">
:class="{ 'loading': savingPreset }"> <span x-show="!savingPreset"
<span x-show="!savingPreset" x-text="$store.global.t('savePreset') || 'Save Preset'">Save Preset</span> x-text="$store.global.t('savePreset') || 'Save Preset'">Save Preset</span>
</button> </button>
</div> </div>
</div> </div>
@@ -686,11 +761,15 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <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-sm text-gray-400" x-text="$store.global.t('modelsDesc')">Configure model
<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> 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>
<div class="flex items-center gap-2"> <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"> <label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" class="sr-only peer" <input type="checkbox" class="sr-only peer"
:checked="$store.settings.showHiddenModels === true" :checked="$store.settings.showHiddenModels === true"
@@ -709,7 +788,8 @@
<thead> <thead>
<tr> <tr>
<th class="pl-4 w-5/12" x-text="$store.global.t('modelId')">Model ID</th> <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> <th class="w-2/12 text-right pr-4" x-text="$store.global.t('actions')">Actions</th>
</tr> </tr>
</thead> </thead>
@@ -717,8 +797,7 @@
<template x-for="modelId in $store.data.models" :key="modelId"> <template x-for="modelId in $store.data.models" :key="modelId">
<tr class="hover:bg-white/5 transition-colors group" <tr class="hover:bg-white/5 transition-colors group"
:class="isHidden ? 'opacity-50' : ''" :class="isHidden ? 'opacity-50' : ''"
x-show="!isHidden || $store.settings.showHiddenModels" x-show="!isHidden || $store.settings.showHiddenModels" x-data="{
x-data="{
newMapping: '', newMapping: '',
get config() { return $store.data.modelConfig[modelId] || {} }, get config() { return $store.data.modelConfig[modelId] || {} },
get isPinned() { return !!this.config.pinned }, get isPinned() { return !!this.config.pinned },
@@ -749,14 +828,14 @@
</svg> </svg>
</div> </div>
<div x-show="isEditing(modelId)" class="flex items-center gap-1"> <div x-show="isEditing(modelId)" class="flex items-center gap-1">
<select x-model="newMapping" <select x-model="newMapping" :x-ref="'input-' + modelId"
: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" 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.enter="await updateModelConfig(modelId, { mapping: newMapping }); stopEditing()"
@keydown.escape="newMapping = config.mapping || ''; stopEditing()"> @keydown.escape="newMapping = config.mapping || ''; stopEditing()">
<option value="" x-text="$store.global.t('none')">None</option> <option value="" x-text="$store.global.t('none')">None</option>
<template x-for="mId in $store.data.models" :key="mId"> <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> </template>
</select> </select>
<button class="btn-action-success" <button class="btn-action-success"
@@ -777,14 +856,14 @@
stroke-width="2" d="M6 18L18 6M6 6l12 12" /> stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
<button x-show="config.mapping" <button x-show="config.mapping" class="btn-action-danger"
class="btn-action-danger"
@click="await updateModelConfig(modelId, { mapping: '' }); stopEditing()" @click="await updateModelConfig(modelId, { mapping: '' }); stopEditing()"
:title="$store.global.t('delete')"> :title="$store.global.t('delete')">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none" <svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" fill="none"
viewBox="0 0 24 24" stroke="currentColor"> viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" <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> </svg>
</button> </button>
</div> </div>
@@ -890,8 +969,7 @@
</div> </div>
<!-- Debug Mode --> <!-- Debug Mode -->
<div <div class="form-control view-card border-space-border/50 hover:border-neon-purple/50">
class="form-control view-card border-space-border/50 hover:border-neon-purple/50">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<span class="text-sm font-medium text-gray-200" <span class="text-sm font-medium text-gray-200"
@@ -902,8 +980,7 @@
</div> </div>
<label class="relative inline-flex items-center cursor-pointer"> <label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" class="sr-only peer" :checked="serverConfig.debug === true" <input type="checkbox" class="sr-only peer" :checked="serverConfig.debug === true"
@change="toggleDebug($el.checked)" @change="toggleDebug($el.checked)" aria-label="Debug mode toggle">
aria-label="Debug mode toggle">
<div <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"> 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> </div>
@@ -912,8 +989,7 @@
</div> </div>
<!-- Token Cache --> <!-- Token Cache -->
<div <div class="form-control view-card border-space-border/50 hover:border-neon-green/50">
class="form-control view-card border-space-border/50 hover:border-neon-green/50">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<span class="text-sm font-medium text-gray-200" <span class="text-sm font-medium text-gray-200"
@@ -926,8 +1002,7 @@
<label class="relative inline-flex items-center cursor-pointer"> <label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" class="sr-only peer" <input type="checkbox" class="sr-only peer"
:checked="serverConfig.persistTokenCache === true" :checked="serverConfig.persistTokenCache === true"
@change="toggleTokenCache($el.checked)" @change="toggleTokenCache($el.checked)" aria-label="Persist token cache toggle">
aria-label="Persist token cache toggle">
<div <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"> 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> </div>
@@ -946,12 +1021,10 @@
<input type="range" min="1" max="100" class="custom-range custom-range-cyan flex-1" <input type="range" min="1" max="100" class="custom-range custom-range-cyan flex-1"
:value="serverConfig.maxAccounts || 10" :value="serverConfig.maxAccounts || 10"
:style="`background-size: ${((serverConfig.maxAccounts || 10) - 1) / 99 * 100}% 100%`" :style="`background-size: ${((serverConfig.maxAccounts || 10) - 1) / 99 * 100}% 100%`"
@input="toggleMaxAccounts($event.target.value)" @input="toggleMaxAccounts($event.target.value)" aria-label="Max accounts slider">
aria-label="Max accounts slider">
<input type="number" min="1" max="100" <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" class="input input-xs input-bordered w-16 bg-space-800 border-space-border text-white font-mono text-center"
:value="serverConfig.maxAccounts || 10" :value="serverConfig.maxAccounts || 10" @change="toggleMaxAccounts($event.target.value)"
@change="toggleMaxAccounts($event.target.value)"
aria-label="Max accounts value"> aria-label="Max accounts value">
</div> </div>
<span class="text-[11px] text-gray-500 mt-1">Maximum number of Google accounts allowed</span> <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"> <div class="flex flex-col gap-1 flex-1">
<span class="text-sm font-medium text-gray-200" <span class="text-sm font-medium text-gray-200"
x-text="$store.global.t('selectionStrategy')">Selection Strategy</span> x-text="$store.global.t('selectionStrategy')">Selection Strategy</span>
<span class="text-[11px] text-gray-500" <span class="text-[11px] text-gray-500" x-text="currentStrategyDescription()">How
x-text="currentStrategyDescription()">How accounts are selected for requests</span> accounts are selected for requests</span>
</div> </div>
<select <select
class="select bg-space-800 border-space-border text-gray-200 focus:border-neon-cyan focus:ring-neon-cyan/20 w-64" 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'" :value="serverConfig.accountSelection?.strategy || 'hybrid'"
@change="toggleStrategy($el.value)" @change="toggleStrategy($el.value)" aria-label="Account selection strategy">
aria-label="Account selection strategy"> <option value="hybrid" x-text="$store.global.t('strategyHybridLabel')">Hybrid (Smart
<option value="hybrid" x-text="$store.global.t('strategyHybridLabel')">Hybrid (Smart Distribution)</option> Distribution)</option>
<option value="sticky" x-text="$store.global.t('strategyStickyLabel')">Sticky (Cache Optimized)</option> <option value="sticky" x-text="$store.global.t('strategyStickyLabel')">Sticky (Cache
<option value="round-robin" x-text="$store.global.t('strategyRoundRobinLabel')">Round Robin (Load Balanced)</option> Optimized)</option>
<option value="round-robin" x-text="$store.global.t('strategyRoundRobinLabel')">Round
Robin (Load Balanced)</option>
</select> </select>
</div> </div>
</div> </div>
@@ -1035,13 +1110,11 @@
<input type="range" min="1" max="20" class="custom-range custom-range-purple flex-1" <input type="range" min="1" max="20" class="custom-range custom-range-purple flex-1"
:value="serverConfig.maxRetries || 5" :value="serverConfig.maxRetries || 5"
:style="`background-size: ${((serverConfig.maxRetries || 5) - 1) / 19 * 100}% 100%`" :style="`background-size: ${((serverConfig.maxRetries || 5) - 1) / 19 * 100}% 100%`"
@input="toggleMaxRetries($event.target.value)" @input="toggleMaxRetries($event.target.value)" aria-label="Max retries slider">
aria-label="Max retries slider">
<input type="number" min="1" max="20" <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" class="input input-xs input-bordered w-16 bg-space-800 border-space-border text-white font-mono text-center"
:value="serverConfig.maxRetries || 5" :value="serverConfig.maxRetries || 5"
@change="toggleMaxRetries($event.target.value)" @change="toggleMaxRetries($event.target.value)" aria-label="Max retries value">
aria-label="Max retries value">
</div> </div>
</div> </div>
@@ -1119,7 +1192,8 @@
aria-label="Default cooldown value"> aria-label="Default cooldown value">
</div> </div>
<p class="text-[9px] text-gray-600 mt-1 leading-tight" <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>
<div class="form-control"> <div class="form-control">
@@ -1143,7 +1217,40 @@
aria-label="Max wait before error value"> aria-label="Max wait before error value">
</div> </div>
<p class="text-[9px] text-gray-600 mt-1 leading-tight" <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>
</div> </div>
@@ -1175,13 +1282,15 @@
aria-label="Rate limit dedup window value"> aria-label="Rate limit dedup window value">
</div> </div>
<p class="text-[9px] text-gray-600 mt-1 leading-tight" <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>
<div class="form-control"> <div class="form-control">
<label class="label pt-0"> <label class="label pt-0">
<span class="label-text text-gray-400 text-xs" <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" <span class="label-text-alt font-mono text-neon-cyan text-xs font-semibold"
x-text="serverConfig.maxConsecutiveFailures || 3"></span> x-text="serverConfig.maxConsecutiveFailures || 3"></span>
</label> </label>
@@ -1199,7 +1308,8 @@
aria-label="Max consecutive failures value"> aria-label="Max consecutive failures value">
</div> </div>
<p class="text-[9px] text-gray-600 mt-1 leading-tight" <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>
<div class="form-control"> <div class="form-control">
@@ -1223,7 +1333,8 @@
aria-label="Extended cooldown value"> aria-label="Extended cooldown value">
</div> </div>
<p class="text-[9px] text-gray-600 mt-1 leading-tight" <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>
<div class="form-control"> <div class="form-control">
@@ -1247,7 +1358,8 @@
aria-label="Max capacity retries value"> aria-label="Max capacity retries value">
</div> </div>
<p class="text-[9px] text-gray-600 mt-1 leading-tight" <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> </div>
</div> </div>
@@ -1282,8 +1394,7 @@
</label> </label>
<input type="password" x-model="passwordDialog.oldPassword" <input type="password" x-model="passwordDialog.oldPassword"
class="input input-sm input-bordered bg-space-800 border-space-border text-white w-full" class="input input-sm input-bordered bg-space-800 border-space-border text-white w-full"
:placeholder="$store.global.t('passwordEmptyDesc')" :placeholder="$store.global.t('passwordEmptyDesc')" aria-label="Current password">
aria-label="Current password">
</div> </div>
<div class="form-control"> <div class="form-control">
@@ -1293,8 +1404,7 @@
</label> </label>
<input type="password" x-model="passwordDialog.newPassword" <input type="password" x-model="passwordDialog.newPassword"
class="input input-sm input-bordered bg-space-800 border-space-border text-white w-full" class="input input-sm input-bordered bg-space-800 border-space-border text-white w-full"
:placeholder="$store.global.t('passwordLengthDesc')" :placeholder="$store.global.t('passwordLengthDesc')" aria-label="New password">
aria-label="New password">
</div> </div>
<div class="form-control"> <div class="form-control">
@@ -1305,8 +1415,7 @@
<input type="password" x-model="passwordDialog.confirmPassword" <input type="password" x-model="passwordDialog.confirmPassword"
class="input input-sm input-bordered bg-space-800 border-space-border text-white w-full" class="input input-sm input-bordered bg-space-800 border-space-border text-white w-full"
:placeholder="$store.global.t('passwordConfirmDesc')" :placeholder="$store.global.t('passwordConfirmDesc')"
@keydown.enter="changePassword()" @keydown.enter="changePassword()" aria-label="Confirm new password">
aria-label="Confirm new password">
</div> </div>
</div> </div>

View File

@@ -5,6 +5,7 @@
*/ */
import { ACCOUNT_CONFIG_PATH } from '../constants.js'; import { ACCOUNT_CONFIG_PATH } from '../constants.js';
import { config } from '../config.js';
import { loadAccounts, loadDefaultAccount, saveAccounts } from './storage.js'; import { loadAccounts, loadDefaultAccount, saveAccounts } from './storage.js';
import { import {
isAllRateLimited as checkAllRateLimited, isAllRateLimited as checkAllRateLimited,
@@ -33,7 +34,6 @@ import {
} from './credentials.js'; } from './credentials.js';
import { createStrategy, getStrategyLabel, DEFAULT_STRATEGY } from './strategies/index.js'; import { createStrategy, getStrategyLabel, DEFAULT_STRATEGY } from './strategies/index.js';
import { logger } from '../utils/logger.js'; import { logger } from '../utils/logger.js';
import { config } from '../config.js';
export class AccountManager { export class AccountManager {
#accounts = []; #accounts = [];
@@ -433,7 +433,10 @@ export class AccountManager {
modelRateLimits: a.modelRateLimits || {}, modelRateLimits: a.modelRateLimits || {},
isInvalid: a.isInvalid || false, isInvalid: a.isInvalid || false,
invalidReason: a.invalidReason || null, invalidReason: a.invalidReason || null,
lastUsed: a.lastUsed lastUsed: a.lastUsed,
// Include quota threshold settings
quotaThreshold: a.quotaThreshold,
modelQuotaThresholds: a.modelQuotaThresholds || {}
})) }))
}; };
} }

View File

@@ -34,7 +34,10 @@ export async function loadAccounts(configPath = ACCOUNT_CONFIG_PATH) {
modelRateLimits: acc.modelRateLimits || {}, modelRateLimits: acc.modelRateLimits || {},
// New fields for subscription and quota tracking // New fields for subscription and quota tracking
subscription: acc.subscription || { tier: 'unknown', projectId: null, detectedAt: null }, 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 || {}; const settings = config.settings || {};
@@ -123,7 +126,10 @@ export async function saveAccounts(configPath, accounts, settings, activeIndex)
lastUsed: acc.lastUsed, lastUsed: acc.lastUsed,
// Persist subscription and quota data // Persist subscription and quota data
subscription: acc.subscription || { tier: 'unknown', projectId: null, detectedAt: null }, 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, settings: settings,
activeIndex: activeIndex activeIndex: activeIndex

View File

@@ -18,6 +18,7 @@
import { BaseStrategy } from './base-strategy.js'; import { BaseStrategy } from './base-strategy.js';
import { HealthTracker, TokenBucketTracker, QuotaTracker } from './trackers/index.js'; import { HealthTracker, TokenBucketTracker, QuotaTracker } from './trackers/index.js';
import { logger } from '../../utils/logger.js'; import { logger } from '../../utils/logger.js';
import { config } from '../../config.js';
// Default weights for scoring // Default weights for scoring
const DEFAULT_WEIGHTS = { const DEFAULT_WEIGHTS = {
@@ -168,8 +169,12 @@ export class HybridStrategy extends BaseStrategy {
} }
// Quota availability check (exclude critically low quota) // Quota availability check (exclude critically low quota)
if (this.#quotaTracker.isQuotaCritical(account, modelId)) { // Threshold priority: per-model > per-account > global > default
logger.debug(`[HybridStrategy] Excluding ${account.email}: quota critically low for ${modelId}`); 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; return false;
} }
@@ -311,7 +316,10 @@ export class HybridStrategy extends BaseStrategy {
accountsWithoutTokens.push(account.email); accountsWithoutTokens.push(account.email);
continue; 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++; criticalQuotaCount++;
continue; continue;
} }

View File

@@ -51,15 +51,19 @@ export class QuotaTracker {
* Check if an account has critically low quota for a model * Check if an account has critically low quota for a model
* @param {Object} account - Account object * @param {Object} account - Account object
* @param {string} modelId - Model ID to check * @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 * @returns {boolean} True if quota is at or below critical threshold
*/ */
isQuotaCritical(account, modelId) { isQuotaCritical(account, modelId, thresholdOverride) {
const fraction = this.getQuotaFraction(account, modelId); const fraction = this.getQuotaFraction(account, modelId);
// Unknown quota = not critical (assume OK) // Unknown quota = not critical (assume OK)
if (fraction === null) return false; if (fraction === null) return false;
// Only apply critical check if data is fresh // Only apply critical check if data is fresh
if (!this.isQuotaFresh(account)) return false; 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;
} }
/** /**

View File

@@ -12,12 +12,12 @@
// Re-export public API // Re-export public API
export { sendMessage } from './message-handler.js'; export { sendMessage } from './message-handler.js';
export { sendMessageStream } from './streaming-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 // Default export for backwards compatibility
import { sendMessage } from './message-handler.js'; import { sendMessage } from './message-handler.js';
import { sendMessageStream } from './streaming-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 { export default {
sendMessage, sendMessage,
@@ -25,5 +25,6 @@ export default {
listModels, listModels,
fetchAvailableModels, fetchAvailableModels,
getModelQuotas, getModelQuotas,
getSubscriptionTier getSubscriptionTier,
isValidModel
}; };

View File

@@ -396,6 +396,13 @@ export async function sendMessage(anthropicRequest, accountManager, fallbackEnab
throw new Error(`CAPACITY_EXHAUSTED: ${errorText}`); 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}`); lastError = new Error(`API error ${response.status}: ${errorText}`);
// Try next endpoint for 403/404/5xx errors (matches opencode-antigravity-auth behavior) // Try next endpoint for 403/404/5xx errors (matches opencode-antigravity-auth behavior)
if (response.status === 403 || response.status === 404) { if (response.status === 403 || response.status === 404) {
@@ -430,6 +437,10 @@ export async function sendMessage(anthropicRequest, accountManager, fallbackEnab
if (isRateLimitError(endpointError)) { if (isRateLimitError(endpointError)) {
throw endpointError; // Re-throw to trigger account switch 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); logger.warn(`[CloudCode] Error at ${endpoint}:`, endpointError.message);
lastError = endpointError; lastError = endpointError;
endpointIndex++; endpointIndex++;

View File

@@ -9,10 +9,18 @@ import {
ANTIGRAVITY_HEADERS, ANTIGRAVITY_HEADERS,
LOAD_CODE_ASSIST_ENDPOINTS, LOAD_CODE_ASSIST_ENDPOINTS,
LOAD_CODE_ASSIST_HEADERS, LOAD_CODE_ASSIST_HEADERS,
getModelFamily getModelFamily,
MODEL_VALIDATION_CACHE_TTL_MS
} from '../constants.js'; } from '../constants.js';
import { logger } from '../utils/logger.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) * Check if a model is supported (Claude or Gemini)
* @param {string} modelId - Model ID to check * @param {string} modelId - Model ID to check
@@ -46,6 +54,10 @@ export async function listModels(token) {
description: modelData.displayName || modelId description: modelData.displayName || modelId
})); }));
// Warm the model validation cache
modelCache.validModels = new Set(modelList.map(m => m.id));
modelCache.lastFetched = Date.now();
return { return {
object: 'list', object: 'list',
data: modelList 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.'); logger.warn('[CloudCode] Failed to detect subscription tier from all endpoints. Defaulting to free.');
return { tier: 'free', projectId: null }; 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;
}
}

View File

@@ -389,6 +389,13 @@ export async function* sendMessageStream(anthropicRequest, accountManager, fallb
throw new Error(`CAPACITY_EXHAUSTED: ${errorText}`); 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}`); lastError = new Error(`API error ${response.status}: ${errorText}`);
// Try next endpoint for 403/404/5xx errors (matches opencode-antigravity-auth behavior) // 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)) { if (isEmptyResponseError(endpointError)) {
throw 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); logger.warn(`[CloudCode] Stream error at ${endpoint}:`, endpointError.message);
lastError = endpointError; lastError = endpointError;
endpointIndex++; endpointIndex++;

View File

@@ -16,6 +16,7 @@ const DEFAULT_CONFIG = {
defaultCooldownMs: 10000, // 10 seconds defaultCooldownMs: 10000, // 10 seconds
maxWaitBeforeErrorMs: 120000, // 2 minutes maxWaitBeforeErrorMs: 120000, // 2 minutes
maxAccounts: 10, // Maximum number of accounts allowed 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) // Rate limit handling (matches opencode-antigravity-auth)
rateLimitDedupWindowMs: 2000, // 2 seconds - prevents concurrent retry storms rateLimitDedupWindowMs: 2000, // 2 seconds - prevents concurrent retry storms
maxConsecutiveFailures: 3, // Before applying extended cooldown maxConsecutiveFailures: 3, // Before applying extended cooldown

View File

@@ -156,6 +156,9 @@ export const GEMINI_SKIP_SIGNATURE = 'skip_thought_signature_validator';
// Cache TTL for Gemini thoughtSignatures (2 hours) // Cache TTL for Gemini thoughtSignatures (2 hours)
export const GEMINI_SIGNATURE_CACHE_TTL_MS = 2 * 60 * 60 * 1000; 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). * Get the model family from model name (dynamic detection, no hardcoded list).
* @param {string} modelName - The model name from the request * @param {string} modelName - The model name from the request
@@ -295,6 +298,7 @@ export default {
GEMINI_MAX_OUTPUT_TOKENS, GEMINI_MAX_OUTPUT_TOKENS,
GEMINI_SKIP_SIGNATURE, GEMINI_SKIP_SIGNATURE,
GEMINI_SIGNATURE_CACHE_TTL_MS, GEMINI_SIGNATURE_CACHE_TTL_MS,
MODEL_VALIDATION_CACHE_TTL_MS,
getModelFamily, getModelFamily,
isThinkingModel, isThinkingModel,
OAUTH_CONFIG, OAUTH_CONFIG,

View File

@@ -8,7 +8,7 @@ import express from 'express';
import cors from 'cors'; import cors from 'cors';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; 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 { mountWebUI } from './webui/index.js';
import { config } from './config.js'; import { config } from './config.js';
@@ -189,7 +189,7 @@ app.use((req, res, next) => {
const logMsg = `[${req.method}] ${req.originalUrl} ${status} (${duration}ms)`; const logMsg = `[${req.method}] ${req.originalUrl} ${status} (${duration}ms)`;
// Skip standard logging for event logging batch unless in debug mode // 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) { if (logger.isDebugEnabled) {
logger.debug(logMsg); logger.debug(logMsg);
} }
@@ -557,6 +557,7 @@ app.get('/account-limits', async (req, res) => {
totalAccounts: allAccounts.length, totalAccounts: allAccounts.length,
models: sortedModels, models: sortedModels,
modelConfig: config.modelMapping || {}, modelConfig: config.modelMapping || {},
globalQuotaThreshold: config.globalQuotaThreshold || 0,
accounts: accountLimits.map(acc => { accounts: accountLimits.map(acc => {
// Merge quota data with account metadata // Merge quota data with account metadata
const metadata = accountMetadataMap.get(acc.email) || {}; const metadata = accountMetadataMap.get(acc.email) || {};
@@ -572,6 +573,9 @@ app.get('/account-limits', async (req, res) => {
invalidReason: metadata.invalidReason || null, invalidReason: metadata.invalidReason || null,
lastUsed: metadata.lastUsed || null, lastUsed: metadata.lastUsed || null,
modelRateLimits: metadata.modelRateLimits || {}, modelRateLimits: metadata.modelRateLimits || {},
// Quota threshold settings
quotaThreshold: metadata.quotaThreshold,
modelQuotaThresholds: metadata.modelQuotaThresholds || {},
// Subscription data (new) // Subscription data (new)
subscription: acc.subscription || metadata.subscription || { tier: 'unknown', projectId: null }, subscription: acc.subscription || metadata.subscription || { tier: 'unknown', projectId: null },
// Quota limits // Quota limits
@@ -716,6 +720,18 @@ app.post('/v1/messages', async (req, res) => {
const modelId = requestedModel; 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. // 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 we have some available accounts, we try them first.
if (accountManager.isAllRateLimited(modelId)) { if (accountManager.isAllRateLimited(modelId)) {

View File

@@ -15,7 +15,7 @@
import path from 'path'; import path from 'path';
import express from 'express'; import express from 'express';
import { getPublicConfig, saveConfig, config } from '../config.js'; 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 { readClaudeConfig, updateClaudeConfig, replaceClaudeConfig, getClaudeConfigPath, readPresets, savePreset, deletePreset } from '../utils/claude-config.js';
import { logger } from '../utils/logger.js'; import { logger } from '../utils/logger.js';
import { getAuthorizationUrl, completeOAuthFlow, startCallbackServer } from '../auth/oauth.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 * POST /api/accounts/reload - Reload accounts from disk
*/ */
@@ -387,7 +457,7 @@ export function mountWebUI(app, dirname, accountManager) {
*/ */
app.post('/api/config', (req, res) => { app.post('/api/config', (req, res) => {
try { 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) // Only allow updating specific fields (security)
const updates = {}; const updates = {};
@@ -416,6 +486,9 @@ export function mountWebUI(app, dirname, accountManager) {
if (typeof maxAccounts === 'number' && maxAccounts >= 1 && maxAccounts <= 100) { if (typeof maxAccounts === 'number' && maxAccounts >= 1 && maxAccounts <= 100) {
updates.maxAccounts = maxAccounts; updates.maxAccounts = maxAccounts;
} }
if (typeof globalQuotaThreshold === 'number' && globalQuotaThreshold >= 0 && globalQuotaThreshold < 1) {
updates.globalQuotaThreshold = globalQuotaThreshold;
}
if (typeof rateLimitDedupWindowMs === 'number' && rateLimitDedupWindowMs >= 1000 && rateLimitDedupWindowMs <= 30000) { if (typeof rateLimitDedupWindowMs === 'number' && rateLimitDedupWindowMs >= 1000 && rateLimitDedupWindowMs <= 30000) {
updates.rateLimitDedupWindowMs = rateLimitDedupWindowMs; 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 // Claude CLI Presets API
// ========================================== // ==========================================
/** /**
* GET /api/claude/presets - Get all saved presets * GET /api/claude/presets - Get all saved presets
*/ */

View File

@@ -42,6 +42,7 @@ async function sendStreamingRequest(id) {
const reader = response.body.getReader(); const reader = response.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let fullText = ''; let fullText = '';
let hasThinking = false;
let eventCount = 0; let eventCount = 0;
while (true) { while (true) {
@@ -60,9 +61,13 @@ async function sendStreamingRequest(id) {
const event = JSON.parse(data); const event = JSON.parse(data);
eventCount++; eventCount++;
// Extract text from content_block_delta events // Extract text or thinking from content_block_delta events
if (event.type === 'content_block_delta' && event.delta?.text) { if (event.type === 'content_block_delta') {
if (event.delta?.text) {
fullText += event.delta.text; fullText += event.delta.text;
} else if (event.delta?.thinking) {
hasThinking = true;
}
} }
} catch (e) { } catch (e) {
// Ignore parse errors for partial chunks // Ignore parse errors for partial chunks
@@ -72,9 +77,16 @@ async function sendStreamingRequest(id) {
} }
const totalElapsed = Date.now() - startTime; const totalElapsed = Date.now() - startTime;
const textPreview = fullText.substring(0, 50) || 'No text'; const hasContent = fullText.length > 0 || hasThinking;
console.log(`[${id}] ✅ 200 after ${totalElapsed}ms (${eventCount} events): "${textPreview}..."`);
return { id, success: true, status: 200, elapsed: totalElapsed, eventCount }; 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) { } catch (error) {
const elapsed = Date.now() - startTime; const elapsed = Date.now() - startTime;
console.log(`[${id}] ❌ Error after ${elapsed}ms: ${error.message}`); console.log(`[${id}] ❌ Error after ${elapsed}ms: ${error.message}`);