mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 00:16:38 +00:00
Merge pull request #288 from kaitranntt/dev
feat(cliproxy): add hybrid quota management with pause/resume controls
This commit is contained in:
28 files changed
+1676
-95
No files matched your search
@@ -33,7 +33,8 @@ CLI wrapper for instant switching between multiple Claude accounts and alternati
|
||||
| Using `chore:` for dev→main PR | No npm release triggered | Use `feat:` or `fix:` prefix |
|
||||
| Committing directly to `main` or `dev` | Bypasses CI/review | Always use PRs |
|
||||
| Manual version bump or git tag | Conflicts with semantic-release | Let CI handle versioning |
|
||||
| Forgetting `--help` update | CLI docs out of sync | Update src/ccs.ts, lib/ccs, lib/ccs.ps1 |
|
||||
| Forgetting `--help` update | CLI docs out of sync | Update `src/commands/help-command.ts` |
|
||||
| Forgetting docs update | User docs out of sync | Update `docs/` and CCS docs submodule |
|
||||
|
||||
## Quality Gates (MANDATORY)
|
||||
|
||||
@@ -99,10 +100,61 @@ bun run validate # Step 3: Final check (must pass)
|
||||
2. **TTY-aware colors** - Respect NO_COLOR env var
|
||||
3. **Non-invasive** - NEVER modify `~/.claude/settings.json`
|
||||
4. **Cross-platform parity** - bash/PowerShell/Node.js must behave identically
|
||||
5. **CLI documentation** - ALL changes MUST update `--help` in src/ccs.ts, lib/ccs, lib/ccs.ps1
|
||||
5. **CLI documentation** - ALL CLI changes MUST update respective `--help` handler (see table below)
|
||||
6. **Idempotent** - All install operations safe to run multiple times
|
||||
7. **Dashboard parity** - Configuration features MUST work in both CLI and Dashboard
|
||||
|
||||
### Help Location Reference
|
||||
|
||||
| Command | Help Handler Location |
|
||||
|---------|----------------------|
|
||||
| `ccs --help` | `src/commands/help-command.ts` |
|
||||
| `ccs cliproxy --help` | `src/commands/cliproxy-command.ts` → `showHelp()` |
|
||||
| `ccs auth --help` | `src/commands/auth-command.ts` |
|
||||
| `ccs api --help` | `src/commands/api-command.ts` |
|
||||
| `ccs copilot --help` | `src/commands/copilot-command.ts` |
|
||||
|
||||
**Note:** `lib/ccs` and `lib/ccs.ps1` are bootstrap wrappers only—they delegate to Node.js and contain no help text.
|
||||
|
||||
## Documentation Requirements (MANDATORY)
|
||||
|
||||
**Documentation is a first-class citizen. ALL user-facing changes require docs updates.**
|
||||
|
||||
### Local Documentation (`docs/`)
|
||||
|
||||
Update local `docs/` folder for:
|
||||
- Architecture changes
|
||||
- Internal API documentation
|
||||
- Development guides
|
||||
|
||||
### CCS Docs Submodule (Owner Only)
|
||||
|
||||
**For @kaitranntt (repository owner):** When adding/changing CLI commands or config options, you MUST also update the CCS docs submodule at `~/CloudPersonal/ccs/docs/`:
|
||||
|
||||
| Change Type | Files to Update |
|
||||
|-------------|-----------------|
|
||||
| New CLI command/flag | `reference/cli-commands.mdx` |
|
||||
| New config option | `reference/config-schema.mdx` |
|
||||
| Provider feature | `providers/<provider>.mdx` |
|
||||
| New feature | `features/<feature>.mdx` |
|
||||
|
||||
**Workflow for docs submodule:**
|
||||
```bash
|
||||
cd ~/CloudPersonal/ccs/docs/
|
||||
git checkout main && git pull
|
||||
# Make changes
|
||||
git add -A && git commit -m "docs: <description>"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
**For external contributors:** Document changes in PR description. Owner will sync to CCS docs.
|
||||
|
||||
### Pre-Commit Docs Checklist
|
||||
|
||||
- [ ] Respective `--help` updated (see Help Location Reference table)
|
||||
- [ ] Local `docs/` updated if architecture changed
|
||||
- [ ] CCS docs submodule updated (owner) or PR description includes docs (contributor)
|
||||
|
||||
## Feature Interface Requirements
|
||||
|
||||
| Feature Type | CLI | Dashboard | Example |
|
||||
@@ -285,10 +337,14 @@ rm -rf ~/.ccs # Clean environment
|
||||
|
||||
**Code:**
|
||||
- [ ] Conventional commit format (`feat:`, `fix:`, etc.)
|
||||
- [ ] `--help` updated (src/ccs.ts, lib/ccs, lib/ccs.ps1) — if CLI changed
|
||||
- [ ] Respective `--help` updated (see Help Location Reference) — if CLI changed
|
||||
- [ ] Tests added/updated — if behavior changed
|
||||
- [ ] README.md updated — if user-facing
|
||||
|
||||
**Documentation:**
|
||||
- [ ] CCS docs updated (owner: `~/CloudPersonal/ccs/docs/`) — if CLI/config changed
|
||||
- [ ] Local `docs/` updated — if architecture changed
|
||||
|
||||
**Standards:**
|
||||
- [ ] ASCII only (NO emojis), NO_COLOR respected
|
||||
- [ ] YAGNI/KISS/DRY alignment verified
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CCS Code Standards
|
||||
|
||||
Last Updated: 2025-12-21
|
||||
Last Updated: 2026-01-06
|
||||
|
||||
Code standards, modularization patterns, and conventions for the CCS codebase.
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# CCS Codebase Summary
|
||||
|
||||
Last Updated: 2025-12-22
|
||||
Last Updated: 2026-01-06
|
||||
|
||||
Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, and v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers.
|
||||
Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers, and v7.14 Hybrid Quota Management.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
@@ -71,6 +71,8 @@ src/
|
||||
│ ├── cliproxy-executor.ts # Main executor (666 lines)
|
||||
│ ├── config-generator.ts # Config file generation (531 lines)
|
||||
│ ├── account-manager.ts # Account management (509 lines)
|
||||
│ ├── quota-manager.ts # Hybrid quota management (NEW v7.14)
|
||||
│ ├── quota-fetcher.ts # Provider quota API integration (NEW v7.14)
|
||||
│ ├── platform-detector.ts # OS/arch detection
|
||||
│ ├── binary-manager.ts # Binary download/update
|
||||
│ ├── auth-handler.ts # Authentication handling
|
||||
@@ -79,8 +81,8 @@ src/
|
||||
│ ├── service-manager.ts # Background service
|
||||
│ ├── proxy-detector.ts # Running proxy detection
|
||||
│ ├── startup-lock.ts # Race condition prevention
|
||||
│ ├── remote-proxy-client.ts # Remote proxy health checks (NEW v7.1)
|
||||
│ ├── proxy-config-resolver.ts # CLI/env/config merging (NEW v7.1)
|
||||
│ ├── remote-proxy-client.ts # Remote proxy health checks (v7.1)
|
||||
│ ├── proxy-config-resolver.ts # CLI/env/config merging (v7.1)
|
||||
│ ├── types.ts # ResolvedProxyConfig for local/remote modes
|
||||
│ └── [more files...]
|
||||
│
|
||||
@@ -164,6 +166,7 @@ src/
|
||||
| Auth | `auth/`, `cliproxy/auth/` | Authentication across providers |
|
||||
| Config | `config/`, `types/` | Configuration & type definitions |
|
||||
| Providers | `cliproxy/`, `copilot/`, `glmt/` | Provider integrations (7 CLIProxy providers: gemini, codex, agy, qwen, iflow, kiro, ghcp) |
|
||||
| Quota | `cliproxy/quota-*.ts`, `account-manager.ts` | Hybrid quota management (v7.14) |
|
||||
| Remote Proxy | `cliproxy/remote-*.ts`, `proxy-config-resolver.ts` | Remote CLIProxy support (v7.1) |
|
||||
| Services | `web-server/`, `api/` | HTTP server, API services |
|
||||
| Utilities | `utils/`, `management/` | Helpers, diagnostics |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CCS Product Development Requirements (PDR)
|
||||
|
||||
Last Updated: 2025-12-22
|
||||
Last Updated: 2026-01-06
|
||||
|
||||
## Product Overview
|
||||
|
||||
@@ -8,9 +8,9 @@ Last Updated: 2025-12-22
|
||||
|
||||
**Tagline**: The universal AI profile manager for Claude Code
|
||||
|
||||
**Description**: CLI wrapper enabling seamless switching between multiple Claude accounts and alternative AI providers (GLM, Gemini, Codex, OpenRouter) with a React-based dashboard for configuration management. Supports both local and remote CLIProxyAPI instances.
|
||||
**Description**: CLI wrapper enabling seamless switching between multiple Claude accounts and alternative AI providers (GLM, Gemini, Codex, OpenRouter, Qwen, Kimi, DeepSeek) with a React-based dashboard for configuration management. Supports both local and remote CLIProxyAPI instances with hybrid quota management.
|
||||
|
||||
**Current Version**: v7.2.x (Kiro + GitHub Copilot OAuth providers)
|
||||
**Current Version**: v7.14.x (Hybrid Quota Management + Pause/Resume)
|
||||
|
||||
---
|
||||
|
||||
@@ -98,6 +98,14 @@ CCS provides:
|
||||
- Protocol-based default ports (443 for HTTPS, 8317 for HTTP)
|
||||
- Dashboard UI for remote server configuration and testing
|
||||
|
||||
### FR-009: Quota Management (v7.14)
|
||||
- Pause/resume individual accounts via `ccs cliproxy pause/resume <account>`
|
||||
- Check quota status via `ccs cliproxy status [account]`
|
||||
- Auto-failover when account exhausted
|
||||
- Tier detection: free/paid/unknown
|
||||
- Pre-flight quota checks before session start
|
||||
- Dashboard UI with pause/resume toggles and tier badges
|
||||
|
||||
---
|
||||
|
||||
## Non-Functional Requirements
|
||||
@@ -216,6 +224,13 @@ CCS provides:
|
||||
- [x] Authorization Code flow for Kiro (port 9876)
|
||||
- [x] Device Code flow for ghcp (no local port needed)
|
||||
|
||||
### v7.14 Release (Complete)
|
||||
- [x] Hybrid quota management with auto-failover
|
||||
- [x] `ccs cliproxy pause/resume/status` commands
|
||||
- [x] API tier detection (free/paid/unknown)
|
||||
- [x] Dashboard pause/resume toggles and tier badges
|
||||
- [x] Pre-flight quota checks before session start
|
||||
|
||||
### v8.0 Release (Planned - Q1 2026)
|
||||
- [ ] Multiple CLIProxyAPI instances (load balancing, failover)
|
||||
- [ ] Native git worktree support
|
||||
@@ -239,6 +254,11 @@ CCS provides:
|
||||
- AWS Kiro (Authorization Code OAuth)
|
||||
- Z.AI GLM API
|
||||
- OpenRouter API
|
||||
- Moonshot Kimi API
|
||||
- DeepSeek API
|
||||
- Alibaba Qwen API
|
||||
- Minimax API
|
||||
- Azure Foundry API
|
||||
|
||||
### Third-Party Libraries
|
||||
- Express.js (web server)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CCS Project Roadmap
|
||||
|
||||
Last Updated: 2025-12-22
|
||||
Last Updated: 2026-01-06
|
||||
|
||||
Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans.
|
||||
|
||||
@@ -23,6 +23,7 @@ All major modularization work is complete. The codebase evolved from monolithic
|
||||
| 9 | Test Infrastructure | 99 UI tests + 539 CLI tests, 90% coverage |
|
||||
| 10 | Remote CLIProxy | `proxy-config-resolver.ts`, `remote-proxy-client.ts` |
|
||||
| 11 | Kiro + ghcp Providers | OAuth support via CLIProxyAPIPlus (v7.2) |
|
||||
| 12 | Hybrid Quota Management | `quota-manager.ts`, `quota-fetcher.ts` (v7.14) |
|
||||
|
||||
**Metrics Achieved**:
|
||||
- Files >500 lines: 12 -> 5 (-58%)
|
||||
@@ -153,6 +154,7 @@ worktrees:
|
||||
- **#158**: Fix AGY OAuth flow
|
||||
- **#157**: ~~Add Kiro auth support from CLIProxyAPIPlus~~ **COMPLETE** (v7.2)
|
||||
- GitHub Copilot (ghcp) Device Code flow **COMPLETE** (v7.2)
|
||||
- Hybrid quota management **COMPLETE** (v7.14)
|
||||
|
||||
---
|
||||
|
||||
@@ -163,9 +165,10 @@ worktrees:
|
||||
| Modularization (Phases 1-9) | COMPLETE | - |
|
||||
| Remote CLIProxy Support (#142) | COMPLETE | v7.1 |
|
||||
| Kiro + GitHub Copilot OAuth (#157) | COMPLETE | v7.2 |
|
||||
| Hybrid Quota Management | COMPLETE | v7.14 |
|
||||
| Critical Bug Fixes (#158, #155, #124) | PLANNED | Q1 2026 |
|
||||
| Multiple CLIProxyAPI Instances | PLANNED | Q1 2026 |
|
||||
| Git Worktree Support | PLANNED | Q1 2026 |
|
||||
| Git Worktree Support | PLANNED | Q2 2026 |
|
||||
| Enhanced Model Support | PLANNED | Q2 2026 |
|
||||
|
||||
---
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CCS System Architecture
|
||||
|
||||
Last Updated: 2025-12-22
|
||||
Last Updated: 2026-01-06
|
||||
|
||||
High-level architecture documentation for the CCS (Claude Code Switch) system.
|
||||
|
||||
@@ -8,12 +8,12 @@ High-level architecture documentation for the CCS (Claude Code Switch) system.
|
||||
|
||||
## System Overview
|
||||
|
||||
CCS is a CLI wrapper that enables seamless switching between multiple Claude accounts and alternative AI providers (GLM, Gemini, Codex, Kiro, GitHub Copilot). It consists of two main components:
|
||||
CCS is a CLI wrapper that enables seamless switching between multiple Claude accounts and alternative AI providers (GLM, Gemini, Codex, Kiro, GitHub Copilot, OpenRouter, Qwen, Kimi, DeepSeek). It consists of two main components:
|
||||
|
||||
1. **CLI Application** (`src/`) - Node.js TypeScript CLI
|
||||
2. **Dashboard UI** (`ui/`) - React web application served by Express
|
||||
|
||||
CCS v7.2 adds Kiro (AWS) and GitHub Copilot (ghcp) OAuth providers via CLIProxyAPIPlus.
|
||||
CCS v7.14 adds Hybrid Quota Management with pause/resume/status commands and auto-failover.
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
@@ -131,7 +131,9 @@ CCS v7.2 adds Kiro (AWS) and GitHub Copilot (ghcp) OAuth providers via CLIProxyA
|
||||
| cliproxy-executor| | copilot-package- | | glmt-proxy |
|
||||
| config-generator | | manager | | delta-accumulator|
|
||||
| account-manager | | [copilot logic] | | pipeline/ |
|
||||
| auth/ | +------------------+ +------------------+
|
||||
| quota-manager | +------------------+ +------------------+
|
||||
| quota-fetcher |
|
||||
| auth/ |
|
||||
| binary/ |
|
||||
| services/ |
|
||||
+------------------+
|
||||
@@ -424,6 +426,55 @@ CCS v7.2 adds Kiro (AWS) and GitHub Copilot (ghcp) OAuth providers via CLIProxyA
|
||||
CCS_PROXY_FALLBACK_ENABLED Enable fallback (true/false)
|
||||
```
|
||||
|
||||
### Quota Management Flow (v7.14)
|
||||
|
||||
```
|
||||
+===========================================================================+
|
||||
| Quota Management Architecture |
|
||||
+===========================================================================+
|
||||
|
||||
Pre-Flight Check (before session start)
|
||||
|
|
||||
v
|
||||
+------------------+
|
||||
| quota-manager.ts | Hybrid quota management
|
||||
+------------------+
|
||||
|
|
||||
+---> Get all active accounts for provider
|
||||
|
|
||||
+---> For each account:
|
||||
| |
|
||||
| v
|
||||
| +------------------+
|
||||
| | quota-fetcher.ts | Provider-specific API calls
|
||||
| +------------------+
|
||||
| |
|
||||
| +---> Check isPaused flag --> Skip if paused
|
||||
| |
|
||||
| +---> Fetch quota from provider API
|
||||
| | - Gemini: /models endpoint
|
||||
| | - Codex: /api/v1/account
|
||||
| | - Kiro: /api/usage
|
||||
| |
|
||||
| +---> Detect tier (free/paid/unknown)
|
||||
| |
|
||||
| +---> Check exhaustion status
|
||||
|
|
||||
+---> Select best account (not paused, not exhausted)
|
||||
|
|
||||
+---> Auto-failover to next account if current exhausted
|
||||
|
||||
CLI Commands:
|
||||
ccs cliproxy pause <account> --> Set isPaused=true in account-manager
|
||||
ccs cliproxy resume <account> --> Set isPaused=false
|
||||
ccs cliproxy status [account] --> Display quota + tier info
|
||||
|
||||
Dashboard UI:
|
||||
- Pause/Resume toggle per account
|
||||
- Tier badge (free/paid/unknown)
|
||||
- Quota usage display
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration Architecture
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# WebSearch Configuration Guide
|
||||
|
||||
Last Updated: 2026-01-06
|
||||
|
||||
CCS provides automatic web search capability for all profiles, including third-party providers that cannot access Anthropic's native WebSearch API.
|
||||
|
||||
## How WebSearch Works
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kaitranntt/ccs",
|
||||
"version": "7.14.0",
|
||||
"version": "7.14.0-dev.4",
|
||||
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
+202
-11
@@ -14,6 +14,15 @@ import { CLIProxyProvider } from './types';
|
||||
import { getCliproxyDir, getAuthDir } from './config-generator';
|
||||
import { PROVIDER_TYPE_VALUES } from './auth/auth-types';
|
||||
|
||||
/** Account tier for quota management (free vs paid - no Pro/Ultra distinction needed) */
|
||||
export type AccountTier = 'free' | 'paid' | 'unknown';
|
||||
|
||||
/**
|
||||
* Providers that typically have empty email in OAuth token files.
|
||||
* For these providers, nickname is used as accountId instead of email.
|
||||
*/
|
||||
export const PROVIDERS_WITHOUT_EMAIL: CLIProxyProvider[] = ['kiro', 'ghcp'];
|
||||
|
||||
/** Account information */
|
||||
export interface AccountInfo {
|
||||
/** Account identifier (email or custom name) */
|
||||
@@ -32,6 +41,12 @@ export interface AccountInfo {
|
||||
createdAt: string;
|
||||
/** Last usage time */
|
||||
lastUsedAt?: string;
|
||||
/** User-paused state (skip in quota rotation) */
|
||||
paused?: boolean;
|
||||
/** ISO timestamp when paused */
|
||||
pausedAt?: string;
|
||||
/** Account tier: free or paid (Pro/Ultra combined) */
|
||||
tier?: AccountTier;
|
||||
}
|
||||
|
||||
/** Provider accounts configuration */
|
||||
@@ -89,7 +104,7 @@ export function generateNickname(email?: string): string {
|
||||
|
||||
/**
|
||||
* Validate nickname
|
||||
* Rules: 1-50 chars, any non-whitespace allowed (permissive)
|
||||
* Rules: 1-50 chars, no whitespace, URL-safe, no reserved patterns
|
||||
* @returns null if valid, error message if invalid
|
||||
*/
|
||||
export function validateNickname(nickname: string): string | null {
|
||||
@@ -102,6 +117,14 @@ export function validateNickname(nickname: string): string | null {
|
||||
if (/\s/.test(nickname)) {
|
||||
return 'Nickname cannot contain whitespace';
|
||||
}
|
||||
// Block URL-unsafe chars that break routing
|
||||
if (/[%\/&?#]/.test(nickname)) {
|
||||
return 'Nickname cannot contain special URL characters (%, /, &, ?, #)';
|
||||
}
|
||||
// Block reserved patterns used by auto-discovery (kiro-1, ghcp-2, etc.)
|
||||
if (/^(kiro|ghcp)-\d+$/i.test(nickname)) {
|
||||
return 'Nickname cannot match reserved pattern (kiro-N, ghcp-N)';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -257,6 +280,14 @@ export function findAccountByQuery(provider: CLIProxyProvider, query: string): A
|
||||
/**
|
||||
* Register a new account
|
||||
* Called after successful OAuth to record the account
|
||||
*
|
||||
* For providers without email (kiro, ghcp):
|
||||
* - nickname is REQUIRED and used as accountId
|
||||
* - Uniqueness is enforced to prevent overwriting
|
||||
*
|
||||
* For providers with email:
|
||||
* - email is used as accountId
|
||||
* - nickname is auto-generated from email if not provided
|
||||
*/
|
||||
export function registerAccount(
|
||||
provider: CLIProxyProvider,
|
||||
@@ -279,12 +310,44 @@ export function registerAccount(
|
||||
throw new Error('Failed to initialize provider accounts');
|
||||
}
|
||||
|
||||
// Determine account ID - use email if available, otherwise extract from filename
|
||||
const accountId = extractAccountIdFromTokenFile(tokenFile, email);
|
||||
const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0;
|
||||
// Determine account ID based on provider type
|
||||
let accountId: string;
|
||||
let accountNickname: string;
|
||||
|
||||
// Generate nickname if not provided
|
||||
const accountNickname = nickname || generateNickname(email);
|
||||
if (PROVIDERS_WITHOUT_EMAIL.includes(provider)) {
|
||||
// For kiro/ghcp: nickname is REQUIRED and used as accountId
|
||||
if (!nickname || nickname === 'default') {
|
||||
throw new Error(
|
||||
`Nickname is required when adding ${provider} accounts. ` +
|
||||
`Use --nickname <name> or provide a nickname in the UI.`
|
||||
);
|
||||
}
|
||||
|
||||
// Validate nickname format
|
||||
const validationError = validateNickname(nickname);
|
||||
if (validationError) {
|
||||
throw new Error(validationError);
|
||||
}
|
||||
|
||||
// Check uniqueness
|
||||
for (const [existingId, _account] of Object.entries(providerAccounts.accounts)) {
|
||||
if (existingId.toLowerCase() === nickname.toLowerCase()) {
|
||||
throw new Error(
|
||||
`An account with nickname "${nickname}" already exists for ${provider}. ` +
|
||||
`Choose a different nickname.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
accountId = nickname;
|
||||
accountNickname = nickname;
|
||||
} else {
|
||||
// For other providers: use email as accountId, fallback to filename extraction
|
||||
accountId = extractAccountIdFromTokenFile(tokenFile, email);
|
||||
accountNickname = nickname || generateNickname(email);
|
||||
}
|
||||
|
||||
const isFirstAccount = Object.keys(providerAccounts.accounts).length === 0;
|
||||
|
||||
// Create or update account
|
||||
providerAccounts.accounts[accountId] = {
|
||||
@@ -330,6 +393,76 @@ export function setDefaultAccount(provider: CLIProxyProvider, accountId: string)
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause an account (skip in quota rotation)
|
||||
*/
|
||||
export function pauseAccount(provider: CLIProxyProvider, accountId: string): boolean {
|
||||
const registry = loadAccountsRegistry();
|
||||
const providerAccounts = registry.providers[provider];
|
||||
|
||||
if (!providerAccounts?.accounts[accountId]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
providerAccounts.accounts[accountId].paused = true;
|
||||
providerAccounts.accounts[accountId].pausedAt = new Date().toISOString();
|
||||
saveAccountsRegistry(registry);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a paused account
|
||||
*/
|
||||
export function resumeAccount(provider: CLIProxyProvider, accountId: string): boolean {
|
||||
const registry = loadAccountsRegistry();
|
||||
const providerAccounts = registry.providers[provider];
|
||||
|
||||
if (!providerAccounts?.accounts[accountId]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
providerAccounts.accounts[accountId].paused = false;
|
||||
providerAccounts.accounts[accountId].pausedAt = undefined;
|
||||
saveAccountsRegistry(registry);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an account is paused
|
||||
*/
|
||||
export function isAccountPaused(provider: CLIProxyProvider, accountId: string): boolean {
|
||||
const accounts = getProviderAccounts(provider);
|
||||
const account = accounts.find((a) => a.id === accountId);
|
||||
return account?.paused ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update account tier
|
||||
*/
|
||||
export function setAccountTier(
|
||||
provider: CLIProxyProvider,
|
||||
accountId: string,
|
||||
tier: AccountTier
|
||||
): boolean {
|
||||
const registry = loadAccountsRegistry();
|
||||
const providerAccounts = registry.providers[provider];
|
||||
|
||||
if (!providerAccounts?.accounts[accountId]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
providerAccounts.accounts[accountId].tier = tier;
|
||||
saveAccountsRegistry(registry);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get non-paused accounts for a provider
|
||||
*/
|
||||
export function getActiveAccounts(provider: CLIProxyProvider): AccountInfo[] {
|
||||
return getProviderAccounts(provider).filter((a) => !a.paused);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an account
|
||||
*/
|
||||
@@ -428,6 +561,10 @@ export function getAccountTokenPath(provider: CLIProxyProvider, accountId?: stri
|
||||
/**
|
||||
* Auto-discover accounts from existing token files
|
||||
* Called during migration or first run to populate accounts registry
|
||||
*
|
||||
* For kiro/ghcp providers without email, generates unique accountId from:
|
||||
* 1. OAuth provider + profile ID from filename (e.g., github-ABC123)
|
||||
* 2. Fallback: provider + index (e.g., kiro-1, kiro-2)
|
||||
*/
|
||||
export function discoverExistingAccounts(): void {
|
||||
const authDir = getAuthDir();
|
||||
@@ -478,13 +615,10 @@ export function discoverExistingAccounts(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Use unified ID extraction: email or filename-based unique ID
|
||||
const accountId = extractAccountIdFromTokenFile(file, email);
|
||||
|
||||
// Initialize provider section if needed
|
||||
if (!registry.providers[provider]) {
|
||||
registry.providers[provider] = {
|
||||
default: accountId,
|
||||
default: 'default',
|
||||
accounts: {},
|
||||
};
|
||||
}
|
||||
@@ -492,11 +626,45 @@ export function discoverExistingAccounts(): void {
|
||||
const providerAccounts = registry.providers[provider];
|
||||
if (!providerAccounts) continue;
|
||||
|
||||
// Skip if token file already registered (under any accountId)
|
||||
const existingTokenFiles = Object.values(providerAccounts.accounts).map((a) => a.tokenFile);
|
||||
if (existingTokenFiles.includes(file)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine accountId based on provider type
|
||||
let accountId: string;
|
||||
|
||||
if (PROVIDERS_WITHOUT_EMAIL.includes(provider) && !email) {
|
||||
// For kiro/ghcp without email: extract from filename or generate unique
|
||||
// Pattern: kiro-github-ABC123.json -> github-ABC123
|
||||
const filenameId = extractAccountIdFromTokenFile(file, undefined);
|
||||
|
||||
if (filenameId !== 'default') {
|
||||
accountId = filenameId;
|
||||
} else {
|
||||
// Generate unique ID: provider + incrementing index
|
||||
let index = 1;
|
||||
while (providerAccounts.accounts[`${provider}-${index}`]) {
|
||||
index++;
|
||||
}
|
||||
accountId = `${provider}-${index}`;
|
||||
}
|
||||
} else {
|
||||
// For providers with email: use email or filename extraction
|
||||
accountId = extractAccountIdFromTokenFile(file, email);
|
||||
}
|
||||
|
||||
// Skip if account already registered
|
||||
if (providerAccounts.accounts[accountId]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Set as default if first account
|
||||
if (Object.keys(providerAccounts.accounts).length === 0) {
|
||||
providerAccounts.default = accountId;
|
||||
}
|
||||
|
||||
// Get file stats for creation time
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
@@ -516,7 +684,30 @@ export function discoverExistingAccounts(): void {
|
||||
}
|
||||
}
|
||||
|
||||
saveAccountsRegistry(registry);
|
||||
// Reload-merge pattern: reduce race condition with concurrent OAuth registration
|
||||
// Reload fresh registry and merge discovered accounts (fresh registry wins on conflicts)
|
||||
const freshRegistry = loadAccountsRegistry();
|
||||
for (const [providerName, discovered] of Object.entries(registry.providers)) {
|
||||
if (!discovered) continue;
|
||||
const prov = providerName as CLIProxyProvider;
|
||||
if (!freshRegistry.providers[prov]) {
|
||||
freshRegistry.providers[prov] = discovered;
|
||||
} else {
|
||||
// Merge accounts, preferring fresh registry's existing entries
|
||||
const freshProviderAccounts = freshRegistry.providers[prov];
|
||||
if (!freshProviderAccounts) continue;
|
||||
for (const [id, meta] of Object.entries(discovered.accounts)) {
|
||||
if (!freshProviderAccounts.accounts[id]) {
|
||||
freshProviderAccounts.accounts[id] = meta;
|
||||
// Set default if none exists
|
||||
if (!freshProviderAccounts.default || freshProviderAccounts.default === 'default') {
|
||||
freshProviderAccounts.default = id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
saveAccountsRegistry(freshRegistry);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,6 +20,8 @@ import {
|
||||
getProviderAccounts,
|
||||
getDefaultAccount,
|
||||
touchAccount,
|
||||
PROVIDERS_WITHOUT_EMAIL,
|
||||
validateNickname,
|
||||
} from '../account-manager';
|
||||
import {
|
||||
enhancedPreflightOAuthCheck,
|
||||
@@ -49,6 +51,74 @@ async function promptAddAccount(): Promise<boolean> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt user for account nickname (required for kiro/ghcp)
|
||||
* Returns null if user cancels
|
||||
*/
|
||||
async function promptNickname(
|
||||
provider: CLIProxyProvider,
|
||||
existingAccounts: AccountInfo[]
|
||||
): Promise<string | null> {
|
||||
const readline = await import('readline');
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
const existingNicknames = existingAccounts.map(
|
||||
(a) => a.nickname?.toLowerCase() || a.id.toLowerCase()
|
||||
);
|
||||
|
||||
console.log('');
|
||||
console.log(info(`${provider} accounts require a unique nickname to distinguish them.`));
|
||||
if (existingNicknames.length > 0) {
|
||||
console.log(` Existing: ${existingNicknames.join(', ')}`);
|
||||
}
|
||||
|
||||
return new Promise<string | null>((resolve) => {
|
||||
let resolved = false;
|
||||
|
||||
// Handle Ctrl+C gracefully (only if not already resolved)
|
||||
rl.on('close', () => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
|
||||
const askForNickname = () => {
|
||||
rl.question('[?] Enter a nickname for this account: ', (answer) => {
|
||||
const nickname = answer.trim();
|
||||
|
||||
if (!nickname) {
|
||||
console.log(fail('Nickname cannot be empty'));
|
||||
askForNickname();
|
||||
return;
|
||||
}
|
||||
|
||||
const validationError = validateNickname(nickname);
|
||||
if (validationError) {
|
||||
console.log(fail(validationError));
|
||||
askForNickname();
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingNicknames.includes(nickname.toLowerCase())) {
|
||||
console.log(fail(`Nickname "${nickname}" is already in use. Choose a different one.`));
|
||||
askForNickname();
|
||||
return;
|
||||
}
|
||||
|
||||
resolved = true;
|
||||
rl.close();
|
||||
resolve(nickname);
|
||||
});
|
||||
};
|
||||
|
||||
askForNickname();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run pre-flight OAuth checks
|
||||
*/
|
||||
@@ -127,7 +197,21 @@ export async function triggerOAuth(
|
||||
options: OAuthOptions = {}
|
||||
): Promise<AccountInfo | null> {
|
||||
const oauthConfig = getOAuthConfig(provider);
|
||||
const { verbose = false, add = false, nickname, fromUI = false, noIncognito = true } = options;
|
||||
const { verbose = false, add = false, fromUI = false, noIncognito = true } = options;
|
||||
let { nickname } = options;
|
||||
|
||||
// Check for existing accounts
|
||||
const existingAccounts = getProviderAccounts(provider);
|
||||
|
||||
// For kiro/ghcp: require nickname if not provided (CLI only, not fromUI)
|
||||
if (PROVIDERS_WITHOUT_EMAIL.includes(provider) && !nickname && !fromUI) {
|
||||
const promptedNickname = await promptNickname(provider, existingAccounts);
|
||||
if (!promptedNickname) {
|
||||
console.log(info('Cancelled'));
|
||||
return null;
|
||||
}
|
||||
nickname = promptedNickname;
|
||||
}
|
||||
|
||||
// Handle --import flag: skip OAuth and import from Kiro IDE directly
|
||||
if (options.import && provider === 'kiro') {
|
||||
@@ -144,8 +228,6 @@ export async function triggerOAuth(
|
||||
const headless = options.headless ?? isHeadlessEnvironment();
|
||||
const isDeviceCodeFlow = callbackPort === null;
|
||||
|
||||
// Check for existing accounts
|
||||
const existingAccounts = getProviderAccounts(provider);
|
||||
if (existingAccounts.length > 0 && !add) {
|
||||
console.log('');
|
||||
console.log(
|
||||
|
||||
@@ -58,7 +58,7 @@ import {
|
||||
import { detectRunningProxy, waitForProxyHealthy, reclaimOrphanedProxy } from './proxy-detector';
|
||||
import { withStartupLock } from './startup-lock';
|
||||
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
|
||||
import { fetchAccountQuota, findAvailableAccount } from './quota-fetcher';
|
||||
import { preflightCheck } from './quota-manager';
|
||||
|
||||
/** Default executor configuration */
|
||||
const DEFAULT_CONFIG: ExecutorConfig = {
|
||||
@@ -462,38 +462,30 @@ export async function execClaudeWithCLIProxy(
|
||||
if (tokenResult.refreshed) {
|
||||
log('Token was refreshed proactively');
|
||||
}
|
||||
|
||||
// 3a-1. Update lastUsedAt for the account being used
|
||||
// This ensures dashboard shows accurate "Last used" timestamps
|
||||
const usedAccount = getDefaultAccount(provider);
|
||||
if (usedAccount) {
|
||||
touchAccount(provider, usedAccount.id);
|
||||
}
|
||||
}
|
||||
|
||||
// 3b. Preflight quota check - auto-switch to account with quota before launch
|
||||
// Only for agy (Antigravity) which has quota tracking
|
||||
// Uses quota-manager for caching, tier priority, and cooldown support
|
||||
if (provider === 'agy') {
|
||||
const defaultAccount = getDefaultAccount(provider);
|
||||
if (defaultAccount) {
|
||||
log(`Checking quota for ${defaultAccount.email || defaultAccount.id}`);
|
||||
const quota = await fetchAccountQuota(provider, defaultAccount.id);
|
||||
const preflight = await preflightCheck(provider);
|
||||
|
||||
// Check if current account is exhausted (no model with >5% quota)
|
||||
const hasQuota = quota.success && quota.models.some((m) => m.percentage > 5);
|
||||
if (!preflight.proceed) {
|
||||
console.error(fail(`Cannot start session: ${preflight.reason}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!hasQuota && quota.success) {
|
||||
// Current account exhausted, try to find alternative
|
||||
log('Current account quota exhausted, searching for alternatives...');
|
||||
const alternative = await findAvailableAccount(provider, defaultAccount.id);
|
||||
|
||||
if (alternative) {
|
||||
// Auto-switch to account with remaining quota
|
||||
setDefaultAccount(provider, alternative.account.id);
|
||||
touchAccount(provider, alternative.account.id);
|
||||
console.log(
|
||||
info(
|
||||
`Auto-switched to ${alternative.account.email || alternative.account.id} (current account quota exhausted)`
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// No alternatives available - warn but continue
|
||||
console.log(warn('All accounts appear quota-exhausted'));
|
||||
console.log(` Run: ccs cliproxy doctor`);
|
||||
}
|
||||
if (preflight.switchedFrom) {
|
||||
console.log(info(`Auto-switched to ${preflight.accountId}`));
|
||||
console.log(` Reason: ${preflight.reason}`);
|
||||
if (preflight.quotaPercent !== undefined) {
|
||||
console.log(` New account quota: ${preflight.quotaPercent.toFixed(1)}%`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+136
-29
@@ -9,7 +9,12 @@ import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { getAuthDir } from './config-generator';
|
||||
import { CLIProxyProvider } from './types';
|
||||
import { getProviderAccounts, type AccountInfo } from './account-manager';
|
||||
import {
|
||||
getProviderAccounts,
|
||||
setAccountTier,
|
||||
type AccountInfo,
|
||||
type AccountTier,
|
||||
} from './account-manager';
|
||||
|
||||
/** Individual model quota info */
|
||||
export interface ModelQuota {
|
||||
@@ -45,6 +50,8 @@ export interface QuotaResult {
|
||||
accountId?: string;
|
||||
/** GCP project ID for this account */
|
||||
projectId?: string;
|
||||
/** Detected account tier based on model access */
|
||||
tier?: AccountTier;
|
||||
}
|
||||
|
||||
/** Google Cloud Code API endpoints */
|
||||
@@ -104,9 +111,21 @@ interface TokenRefreshResponse {
|
||||
error_description?: string;
|
||||
}
|
||||
|
||||
/** Tier info from loadCodeAssist */
|
||||
interface TierInfo {
|
||||
id?: string;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
/** loadCodeAssist response */
|
||||
interface LoadCodeAssistResponse {
|
||||
cloudaicompanionProject?: string | { id?: string };
|
||||
/** Current tier (may be trial/temporary) */
|
||||
currentTier?: TierInfo;
|
||||
/** Paid tier (reflects actual subscription - takes priority) */
|
||||
paidTier?: TierInfo;
|
||||
/** Array of allowed tiers - use isDefault=true to find active tier (CLIProxyAPIPlus approach) */
|
||||
allowedTiers?: TierInfo[];
|
||||
}
|
||||
|
||||
/** fetchAvailableModels response model */
|
||||
@@ -268,11 +287,56 @@ function readAuthData(provider: CLIProxyProvider, accountId: string): AuthData |
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project ID via loadCodeAssist endpoint
|
||||
* Map tier ID string to AccountTier type
|
||||
* Simplified: anything with 'pro' or 'ultra' = paid, 'free'/'legacy' = free
|
||||
*/
|
||||
async function getProjectId(
|
||||
accessToken: string
|
||||
): Promise<{ projectId: string | null; error?: string; isUnprovisioned?: boolean }> {
|
||||
function mapTierString(tierStr: string | undefined): AccountTier {
|
||||
if (!tierStr) return 'unknown';
|
||||
const normalized = tierStr.toLowerCase();
|
||||
if (normalized.includes('ultra') || normalized.includes('pro')) return 'paid';
|
||||
if (normalized.includes('free') || normalized.includes('legacy')) {
|
||||
return 'free';
|
||||
}
|
||||
// "standard-tier" and other unknown values should NOT map to 'free'
|
||||
// Let inferTierFromModels handle the detection
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer tier from model access patterns.
|
||||
* - Paid: Has access to Claude models OR premium Gemini models
|
||||
* - Free: Only basic models
|
||||
*
|
||||
* Claude models are Ultra-exclusive, premium Gemini indicates Pro/Ultra.
|
||||
* Both are "paid" tier for our purposes.
|
||||
*/
|
||||
function inferTierFromModels(models: ModelQuota[]): AccountTier {
|
||||
if (models.length === 0) return 'unknown';
|
||||
|
||||
// Check for Claude models (Ultra-exclusive) or premium Gemini (Pro/Ultra)
|
||||
const hasPaidAccess = models.some((m) => {
|
||||
const name = m.name.toLowerCase();
|
||||
return (
|
||||
name.includes('claude') ||
|
||||
name.includes('gemini-3-pro') ||
|
||||
name.includes('gemini-2.5-pro') ||
|
||||
name.includes('gemini-pro-high')
|
||||
);
|
||||
});
|
||||
|
||||
return hasPaidAccess ? 'paid' : 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project ID and tier via loadCodeAssist endpoint
|
||||
* Uses allowedTiers array with isDefault=true (CLIProxyAPIPlus approach)
|
||||
*/
|
||||
async function getProjectId(accessToken: string): Promise<{
|
||||
projectId: string | null;
|
||||
tier?: AccountTier;
|
||||
error?: string;
|
||||
isUnprovisioned?: boolean;
|
||||
}> {
|
||||
const url = `${ANTIGRAVITY_API_BASE}/${ANTIGRAVITY_API_VERSION}:loadCodeAssist`;
|
||||
|
||||
const controller = new AbortController();
|
||||
@@ -327,7 +391,20 @@ async function getProjectId(
|
||||
};
|
||||
}
|
||||
|
||||
return { projectId: projectId.trim() };
|
||||
// Extract tier using CLIProxyAPIPlus approach:
|
||||
// 1. Find tier with isDefault=true in allowedTiers array
|
||||
// 2. Fallback to paidTier > currentTier
|
||||
let tierStr: string | undefined;
|
||||
if (data.allowedTiers && Array.isArray(data.allowedTiers)) {
|
||||
const defaultTier = data.allowedTiers.find((t) => t.isDefault);
|
||||
tierStr = defaultTier?.id;
|
||||
}
|
||||
if (!tierStr) {
|
||||
tierStr = data.paidTier?.id || data.currentTier?.id;
|
||||
}
|
||||
const tier = mapTierString(tierStr);
|
||||
|
||||
return { projectId: projectId.trim(), tier };
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId);
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
@@ -489,43 +566,73 @@ export async function fetchAccountQuota(
|
||||
}
|
||||
}
|
||||
|
||||
// Get project ID - prefer stored value, fallback to API call
|
||||
// Get project ID and tier - prefer stored project ID, but always call API for tier
|
||||
let projectId = authData.projectId;
|
||||
if (!projectId) {
|
||||
let lastProjectResult = await getProjectId(accessToken);
|
||||
if (!lastProjectResult.projectId) {
|
||||
// If project ID fetch fails, it might be token issue - try refresh if we haven't
|
||||
if (authData.refreshToken && accessToken === authData.accessToken) {
|
||||
const refreshResult = await refreshAccessToken(authData.refreshToken);
|
||||
if (refreshResult.accessToken) {
|
||||
accessToken = refreshResult.accessToken;
|
||||
lastProjectResult = await getProjectId(accessToken);
|
||||
}
|
||||
}
|
||||
if (!lastProjectResult.projectId) {
|
||||
return {
|
||||
success: false,
|
||||
models: [],
|
||||
lastUpdated: Date.now(),
|
||||
error: lastProjectResult.error || 'Failed to retrieve project ID',
|
||||
isUnprovisioned: lastProjectResult.isUnprovisioned,
|
||||
};
|
||||
let apiTier: AccountTier = 'unknown';
|
||||
|
||||
// Always call loadCodeAssist to get accurate tier from API
|
||||
let lastProjectResult = await getProjectId(accessToken);
|
||||
|
||||
if (!lastProjectResult.projectId && !projectId) {
|
||||
// If project ID fetch fails, it might be token issue - try refresh if we haven't
|
||||
if (authData.refreshToken && accessToken === authData.accessToken) {
|
||||
const refreshResult = await refreshAccessToken(authData.refreshToken);
|
||||
if (refreshResult.accessToken) {
|
||||
accessToken = refreshResult.accessToken;
|
||||
lastProjectResult = await getProjectId(accessToken);
|
||||
}
|
||||
}
|
||||
projectId = lastProjectResult.projectId;
|
||||
if (!lastProjectResult.projectId) {
|
||||
return {
|
||||
success: false,
|
||||
models: [],
|
||||
lastUpdated: Date.now(),
|
||||
error: lastProjectResult.error || 'Failed to retrieve project ID',
|
||||
isUnprovisioned: lastProjectResult.isUnprovisioned,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Use API project ID if available, else fallback to stored
|
||||
projectId = lastProjectResult.projectId || projectId;
|
||||
apiTier = lastProjectResult.tier || 'unknown';
|
||||
|
||||
// Fetch models with quota
|
||||
const result = await fetchAvailableModels(accessToken, projectId);
|
||||
const result = await fetchAvailableModels(accessToken, projectId as string);
|
||||
|
||||
// If quota fetch fails with auth error and we haven't refreshed yet, try refresh
|
||||
if (!result.success && result.error?.includes('expired') && authData.refreshToken) {
|
||||
const refreshResult = await refreshAccessToken(authData.refreshToken);
|
||||
if (refreshResult.accessToken) {
|
||||
return fetchAvailableModels(refreshResult.accessToken, projectId);
|
||||
const retryResult = await fetchAvailableModels(
|
||||
refreshResult.accessToken,
|
||||
projectId as string
|
||||
);
|
||||
// Determine tier: model access (Claude = Ultra) > API tier > fallback
|
||||
if (retryResult.success) {
|
||||
let finalTier = inferTierFromModels(retryResult.models);
|
||||
if (finalTier === 'unknown') {
|
||||
finalTier = apiTier !== 'unknown' ? apiTier : 'paid';
|
||||
}
|
||||
retryResult.tier = finalTier;
|
||||
retryResult.accountId = accountId;
|
||||
setAccountTier(provider, accountId, finalTier);
|
||||
}
|
||||
return retryResult;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine tier: model access > API tier > fallback to paid
|
||||
if (result.success) {
|
||||
let finalTier = inferTierFromModels(result.models);
|
||||
if (finalTier === 'unknown') {
|
||||
finalTier = apiTier !== 'unknown' ? apiTier : 'paid';
|
||||
}
|
||||
result.tier = finalTier;
|
||||
result.accountId = accountId;
|
||||
setAccountTier(provider, accountId, finalTier);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* Quota Manager for Hybrid Auto+Manual Account Selection
|
||||
*
|
||||
* Provides pre-flight quota checking with caching, tier-based failover,
|
||||
* and cooldown tracking for exhausted accounts.
|
||||
*
|
||||
* Key features:
|
||||
* - 30-second in-memory cache for quota results
|
||||
* - Tier-priority failover (ultra > pro by default)
|
||||
* - Cooldown tracking for exhausted accounts
|
||||
* - Respects paused accounts from manual config
|
||||
* - Graceful degradation on API failures
|
||||
*/
|
||||
|
||||
import { CLIProxyProvider } from './types';
|
||||
import { QuotaResult, fetchAccountQuota } from './quota-fetcher';
|
||||
import {
|
||||
getDefaultAccount,
|
||||
getProviderAccounts,
|
||||
isAccountPaused,
|
||||
setDefaultAccount,
|
||||
touchAccount,
|
||||
type AccountInfo,
|
||||
} from './account-manager';
|
||||
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
|
||||
|
||||
// ============================================================================
|
||||
// QUOTA CACHE (30-second TTL)
|
||||
// ============================================================================
|
||||
|
||||
interface CacheEntry {
|
||||
result: QuotaResult;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const CACHE_TTL_MS = 30_000; // 30 seconds
|
||||
const quotaCache = new Map<string, CacheEntry>();
|
||||
|
||||
// Request deduplication: track in-flight fetch promises to avoid parallel duplicate requests
|
||||
const pendingFetches = new Map<string, Promise<QuotaResult>>();
|
||||
|
||||
function getCacheKey(provider: CLIProxyProvider, accountId: string): string {
|
||||
return `${provider}:${accountId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached quota result if still valid
|
||||
*/
|
||||
export function getCachedQuota(provider: CLIProxyProvider, accountId: string): QuotaResult | null {
|
||||
const key = getCacheKey(provider, accountId);
|
||||
const entry = quotaCache.get(key);
|
||||
|
||||
if (!entry) return null;
|
||||
|
||||
if (Date.now() - entry.timestamp > CACHE_TTL_MS) {
|
||||
quotaCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache quota result
|
||||
*/
|
||||
export function setCachedQuota(
|
||||
provider: CLIProxyProvider,
|
||||
accountId: string,
|
||||
result: QuotaResult
|
||||
): void {
|
||||
const key = getCacheKey(provider, accountId);
|
||||
quotaCache.set(key, { result, timestamp: Date.now() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached quota results
|
||||
*/
|
||||
export function clearQuotaCache(): void {
|
||||
quotaCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch quota with request deduplication
|
||||
* If a fetch for this account is already in progress, return the existing promise
|
||||
*/
|
||||
async function fetchQuotaWithDedup(
|
||||
provider: CLIProxyProvider,
|
||||
accountId: string
|
||||
): Promise<QuotaResult> {
|
||||
const key = getCacheKey(provider, accountId);
|
||||
|
||||
// Check if fetch already in progress
|
||||
const pending = pendingFetches.get(key);
|
||||
if (pending) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
// Start new fetch and track it
|
||||
const fetchPromise = fetchAccountQuota(provider, accountId)
|
||||
.then((result) => {
|
||||
setCachedQuota(provider, accountId, result);
|
||||
return result;
|
||||
})
|
||||
.catch((): QuotaResult => {
|
||||
return { success: false, models: [], lastUpdated: Date.now() };
|
||||
})
|
||||
.finally(() => {
|
||||
pendingFetches.delete(key);
|
||||
});
|
||||
|
||||
pendingFetches.set(key, fetchPromise);
|
||||
return fetchPromise;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// COOLDOWN TRACKING
|
||||
// ============================================================================
|
||||
|
||||
interface CooldownEntry {
|
||||
until: number; // timestamp when cooldown expires
|
||||
}
|
||||
|
||||
const cooldownMap = new Map<string, CooldownEntry>();
|
||||
|
||||
/**
|
||||
* Check if account is on cooldown
|
||||
*/
|
||||
export function isOnCooldown(provider: CLIProxyProvider, accountId: string): boolean {
|
||||
const key = getCacheKey(provider, accountId);
|
||||
const entry = cooldownMap.get(key);
|
||||
|
||||
if (!entry) return false;
|
||||
|
||||
if (Date.now() > entry.until) {
|
||||
cooldownMap.delete(key);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply cooldown to an exhausted account
|
||||
*/
|
||||
export function applyCooldown(
|
||||
provider: CLIProxyProvider,
|
||||
accountId: string,
|
||||
minutes: number
|
||||
): void {
|
||||
const key = getCacheKey(provider, accountId);
|
||||
cooldownMap.set(key, { until: Date.now() + minutes * 60 * 1000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cooldown for an account
|
||||
*/
|
||||
export function clearCooldown(provider: CLIProxyProvider, accountId: string): void {
|
||||
const key = getCacheKey(provider, accountId);
|
||||
cooldownMap.delete(key);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PRE-FLIGHT CHECK
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Result of pre-flight quota check
|
||||
*/
|
||||
export interface PreflightResult {
|
||||
/** Whether to proceed with session */
|
||||
proceed: boolean;
|
||||
/** Account to use (may differ from original default) */
|
||||
accountId: string;
|
||||
/** If switched, the original account ID */
|
||||
switchedFrom?: string;
|
||||
/** Reason for switch or failure */
|
||||
reason?: string;
|
||||
/** Average quota percentage of selected account */
|
||||
quotaPercent?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate average quota percentage from models
|
||||
*/
|
||||
function calculateAverageQuota(quota: QuotaResult): number {
|
||||
if (!quota.success || quota.models.length === 0) {
|
||||
return 100; // Assume OK if no data
|
||||
}
|
||||
const total = quota.models.reduce((sum, m) => sum + m.percentage, 0);
|
||||
return total / quota.models.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find healthy account with remaining quota
|
||||
* Respects tier priority and skips paused/cooldown accounts
|
||||
*/
|
||||
export async function findHealthyAccount(
|
||||
provider: CLIProxyProvider,
|
||||
exclude: string[]
|
||||
): Promise<{ id: string; tier: string; lastQuota: number } | null> {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
const tierPriority = config.quota_management?.auto?.tier_priority ?? ['paid'];
|
||||
const threshold = config.quota_management?.auto?.exhaustion_threshold ?? 5;
|
||||
|
||||
const accounts = getProviderAccounts(provider);
|
||||
|
||||
// Filter available accounts
|
||||
const available = accounts.filter(
|
||||
(a) =>
|
||||
!exclude.includes(a.id) && !isAccountPaused(provider, a.id) && !isOnCooldown(provider, a.id)
|
||||
);
|
||||
|
||||
if (available.length === 0) return null;
|
||||
|
||||
// Fetch quota for each available account (with caching and deduplication)
|
||||
const withQuotas = await Promise.all(
|
||||
available.map(async (account) => {
|
||||
let quota = getCachedQuota(provider, account.id);
|
||||
if (!quota) {
|
||||
quota = await fetchQuotaWithDedup(provider, account.id);
|
||||
}
|
||||
|
||||
const avgQuota = calculateAverageQuota(quota);
|
||||
|
||||
return {
|
||||
id: account.id,
|
||||
tier: account.tier || 'paid',
|
||||
lastQuota: avgQuota,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
// Filter by threshold
|
||||
const healthy = withQuotas.filter((a) => a.lastQuota >= threshold);
|
||||
if (healthy.length === 0) return null;
|
||||
|
||||
// Sort by tier priority then quota descending
|
||||
healthy.sort((a, b) => {
|
||||
const tierA = tierPriority.indexOf(a.tier);
|
||||
const tierB = tierPriority.indexOf(b.tier);
|
||||
const tierOrderA = tierA === -1 ? 999 : tierA;
|
||||
const tierOrderB = tierB === -1 ? 999 : tierB;
|
||||
|
||||
if (tierOrderA !== tierOrderB) return tierOrderA - tierOrderB;
|
||||
return b.lastQuota - a.lastQuota;
|
||||
});
|
||||
|
||||
return healthy[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find and switch to a healthy account
|
||||
*/
|
||||
async function findAndSwitch(
|
||||
provider: CLIProxyProvider,
|
||||
excludeAccountId: string,
|
||||
reason: string
|
||||
): Promise<PreflightResult> {
|
||||
const alternative = await findHealthyAccount(provider, [excludeAccountId]);
|
||||
|
||||
if (!alternative) {
|
||||
// No alternatives: use original anyway (graceful degradation)
|
||||
return {
|
||||
proceed: true,
|
||||
accountId: excludeAccountId,
|
||||
reason: `${reason}, no alternatives available`,
|
||||
};
|
||||
}
|
||||
|
||||
// Switch default
|
||||
setDefaultAccount(provider, alternative.id);
|
||||
touchAccount(provider, alternative.id);
|
||||
|
||||
return {
|
||||
proceed: true,
|
||||
accountId: alternative.id,
|
||||
switchedFrom: excludeAccountId,
|
||||
reason,
|
||||
quotaPercent: alternative.lastQuota,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform pre-flight quota check before session start
|
||||
*
|
||||
* Checks if default account has sufficient quota, auto-switches if needed.
|
||||
* Respects paused accounts, tier priority, and cooldown settings.
|
||||
*
|
||||
* @param provider - CLIProxy provider (only 'agy' supports quota)
|
||||
* @returns PreflightResult with account to use and any switch info
|
||||
*/
|
||||
export async function preflightCheck(provider: CLIProxyProvider): Promise<PreflightResult> {
|
||||
// Only Antigravity supports quota checking
|
||||
if (provider !== 'agy') {
|
||||
const defaultAccount = getDefaultAccount(provider);
|
||||
return { proceed: true, accountId: defaultAccount?.id || '' };
|
||||
}
|
||||
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
const quotaConfig = config.quota_management;
|
||||
|
||||
// Skip if preflight disabled or mode is manual
|
||||
if (!quotaConfig?.auto?.preflight_check || quotaConfig?.mode === 'manual') {
|
||||
const defaultAccount = getDefaultAccount(provider);
|
||||
return { proceed: true, accountId: defaultAccount?.id || '' };
|
||||
}
|
||||
|
||||
const defaultAccount = getDefaultAccount(provider);
|
||||
if (!defaultAccount) {
|
||||
return { proceed: false, accountId: '', reason: 'No accounts configured' };
|
||||
}
|
||||
|
||||
// Check forced_default override (manual mode)
|
||||
const forcedDefault = quotaConfig.manual?.forced_default;
|
||||
if (forcedDefault) {
|
||||
const forcedAccount = getProviderAccounts(provider).find((a) => a.id === forcedDefault);
|
||||
if (forcedAccount) {
|
||||
return { proceed: true, accountId: forcedAccount.id, reason: 'Forced default override' };
|
||||
}
|
||||
}
|
||||
|
||||
// Check if default is paused
|
||||
if (isAccountPaused(provider, defaultAccount.id)) {
|
||||
return await findAndSwitch(provider, defaultAccount.id, 'Default account is paused');
|
||||
}
|
||||
|
||||
// Check cooldown
|
||||
if (isOnCooldown(provider, defaultAccount.id)) {
|
||||
return await findAndSwitch(provider, defaultAccount.id, 'Default account on cooldown');
|
||||
}
|
||||
|
||||
// Check quota (with cache and deduplication)
|
||||
let quota = getCachedQuota(provider, defaultAccount.id);
|
||||
if (!quota) {
|
||||
quota = await fetchQuotaWithDedup(provider, defaultAccount.id);
|
||||
}
|
||||
|
||||
// Calculate average quota
|
||||
const avgQuota = calculateAverageQuota(quota);
|
||||
const threshold = quotaConfig.auto?.exhaustion_threshold ?? 5;
|
||||
|
||||
if (avgQuota < threshold) {
|
||||
// Apply cooldown to exhausted account
|
||||
applyCooldown(provider, defaultAccount.id, quotaConfig.auto?.cooldown_minutes ?? 5);
|
||||
return await findAndSwitch(
|
||||
provider,
|
||||
defaultAccount.id,
|
||||
`Quota exhausted (${avgQuota.toFixed(1)}%)`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
proceed: true,
|
||||
accountId: defaultAccount.id,
|
||||
quotaPercent: avgQuota,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get quota status for all accounts of a provider
|
||||
* Used by CLI status command
|
||||
*/
|
||||
export async function getQuotaStatus(provider: CLIProxyProvider): Promise<{
|
||||
accounts: Array<{
|
||||
account: AccountInfo;
|
||||
quota: number;
|
||||
paused: boolean;
|
||||
onCooldown: boolean;
|
||||
isDefault: boolean;
|
||||
}>;
|
||||
}> {
|
||||
const accounts = getProviderAccounts(provider);
|
||||
const defaultAccount = getDefaultAccount(provider);
|
||||
|
||||
const results = await Promise.all(
|
||||
accounts.map(async (account) => {
|
||||
let quota = getCachedQuota(provider, account.id);
|
||||
if (!quota && provider === 'agy') {
|
||||
quota = await fetchQuotaWithDedup(provider, account.id);
|
||||
}
|
||||
|
||||
const avgQuota = quota ? calculateAverageQuota(quota) : 100;
|
||||
|
||||
return {
|
||||
account,
|
||||
quota: avgQuota,
|
||||
paused: isAccountPaused(provider, account.id),
|
||||
onCooldown: isOnCooldown(provider, account.id),
|
||||
isDefault: defaultAccount?.id === account.id,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return { accounts: results };
|
||||
}
|
||||
@@ -20,8 +20,15 @@
|
||||
|
||||
import * as path from 'path';
|
||||
import { getAllAuthStatus, getOAuthConfig, triggerOAuth } from '../cliproxy/auth-handler';
|
||||
import { getProviderAccounts } from '../cliproxy/account-manager';
|
||||
import {
|
||||
getProviderAccounts,
|
||||
setDefaultAccount,
|
||||
pauseAccount,
|
||||
resumeAccount,
|
||||
findAccountByQuery,
|
||||
} from '../cliproxy/account-manager';
|
||||
import { fetchAllProviderQuotas } from '../cliproxy/quota-fetcher';
|
||||
import { isOnCooldown } from '../cliproxy/quota-manager';
|
||||
import { CLIPROXY_FALLBACK_VERSION } from '../cliproxy/platform-detector';
|
||||
import { CLIPROXY_PROFILES, CLIProxyProfileName } from '../auth/profile-detector';
|
||||
import { supportsModelConfig, getProviderCatalog, ModelEntry } from '../cliproxy/model-catalog';
|
||||
@@ -544,6 +551,15 @@ async function showHelp(): Promise<void> {
|
||||
['remove <name>', 'Remove a CLIProxy variant profile'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'Quota Management:',
|
||||
[
|
||||
['default <account>', 'Set default account for rotation'],
|
||||
['pause <account>', 'Pause account (skip in rotation)'],
|
||||
['resume <account>', 'Resume paused account'],
|
||||
['quota', 'Show quota status for all accounts'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'Proxy Lifecycle:',
|
||||
[
|
||||
@@ -691,6 +707,202 @@ function formatQuotaBar(percentage: number): string {
|
||||
return `[${filledChar.repeat(filled)}${' '.repeat(empty)}]`;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// QUOTA MANAGEMENT COMMANDS
|
||||
// ============================================================================
|
||||
|
||||
async function handleSetDefault(args: string[]): Promise<void> {
|
||||
await initUI();
|
||||
const parsed = parseProfileArgs(args);
|
||||
|
||||
if (!parsed.name) {
|
||||
console.log(fail('Usage: ccs cliproxy default <account> [--provider <provider>]'));
|
||||
console.log('');
|
||||
console.log('Examples:');
|
||||
console.log(' ccs cliproxy default ultra@gmail.com');
|
||||
console.log(' ccs cliproxy default john --provider agy');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const provider = (parsed.provider || 'agy') as CLIProxyProvider;
|
||||
const account = findAccountByQuery(provider, parsed.name);
|
||||
|
||||
if (!account) {
|
||||
console.log(fail(`Account not found: ${parsed.name}`));
|
||||
console.log('');
|
||||
const accounts = getProviderAccounts(provider);
|
||||
if (accounts.length > 0) {
|
||||
console.log('Available accounts:');
|
||||
for (const acc of accounts) {
|
||||
const badge = acc.isDefault ? color(' (current default)', 'info') : '';
|
||||
console.log(` - ${acc.email || acc.id}${badge}`);
|
||||
}
|
||||
} else {
|
||||
console.log(`No accounts found for provider: ${provider}`);
|
||||
console.log(`Run: ccs ${provider} --auth`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const success = setDefaultAccount(provider, account.id);
|
||||
|
||||
if (success) {
|
||||
console.log(ok(`Default account set to: ${account.email || account.id}`));
|
||||
console.log(info(`Provider: ${provider}`));
|
||||
} else {
|
||||
console.log(fail('Failed to set default account'));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePauseAccount(args: string[]): Promise<void> {
|
||||
await initUI();
|
||||
const parsed = parseProfileArgs(args);
|
||||
|
||||
if (!parsed.name) {
|
||||
console.log(fail('Usage: ccs cliproxy pause <account> [--provider <provider>]'));
|
||||
console.log('');
|
||||
console.log('Pauses an account so it will be skipped in quota rotation.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const provider = (parsed.provider || 'agy') as CLIProxyProvider;
|
||||
const account = findAccountByQuery(provider, parsed.name);
|
||||
|
||||
if (!account) {
|
||||
console.log(fail(`Account not found: ${parsed.name}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (account.paused) {
|
||||
console.log(warn(`Account already paused: ${account.email || account.id}`));
|
||||
console.log(info(`Paused at: ${account.pausedAt || 'unknown'}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const success = pauseAccount(provider, account.id);
|
||||
|
||||
if (success) {
|
||||
console.log(ok(`Account paused: ${account.email || account.id}`));
|
||||
console.log(info('Account will be skipped in quota rotation'));
|
||||
} else {
|
||||
console.log(fail('Failed to pause account'));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResumeAccount(args: string[]): Promise<void> {
|
||||
await initUI();
|
||||
const parsed = parseProfileArgs(args);
|
||||
|
||||
if (!parsed.name) {
|
||||
console.log(fail('Usage: ccs cliproxy resume <account> [--provider <provider>]'));
|
||||
console.log('');
|
||||
console.log('Resumes a paused account for quota rotation.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const provider = (parsed.provider || 'agy') as CLIProxyProvider;
|
||||
const account = findAccountByQuery(provider, parsed.name);
|
||||
|
||||
if (!account) {
|
||||
console.log(fail(`Account not found: ${parsed.name}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!account.paused) {
|
||||
console.log(warn(`Account is not paused: ${account.email || account.id}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const success = resumeAccount(provider, account.id);
|
||||
|
||||
if (success) {
|
||||
console.log(ok(`Account resumed: ${account.email || account.id}`));
|
||||
console.log(info('Account is now active in quota rotation'));
|
||||
} else {
|
||||
console.log(fail('Failed to resume account'));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleQuotaStatus(): Promise<void> {
|
||||
await initUI();
|
||||
console.log(header('Quota Status'));
|
||||
console.log('');
|
||||
|
||||
const provider: CLIProxyProvider = 'agy';
|
||||
const accounts = getProviderAccounts(provider);
|
||||
|
||||
if (accounts.length === 0) {
|
||||
console.log(info('No Antigravity accounts configured'));
|
||||
console.log(` Run: ${color('ccs agy --auth', 'command')} to authenticate`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(dim('Fetching quotas...'));
|
||||
const quotaResult = await fetchAllProviderQuotas(provider);
|
||||
|
||||
// Build table rows
|
||||
const rows: string[][] = [];
|
||||
for (const account of accounts) {
|
||||
const quotaData = quotaResult.accounts.find((q) => q.account.id === account.id);
|
||||
const quota = quotaData?.quota;
|
||||
|
||||
// Calculate average quota
|
||||
let avgQuota = 'N/A';
|
||||
if (quota?.success && quota.models.length > 0) {
|
||||
const avg = Math.round(
|
||||
quota.models.reduce((sum, m) => sum + m.percentage, 0) / quota.models.length
|
||||
);
|
||||
avgQuota = `${avg}%`;
|
||||
}
|
||||
|
||||
// Build status badges
|
||||
const statusParts: string[] = [];
|
||||
if (account.paused) statusParts.push(color('PAUSED', 'warning'));
|
||||
if (isOnCooldown(provider, account.id)) statusParts.push(color('COOLDOWN', 'warning'));
|
||||
|
||||
const defaultMark = account.isDefault ? color('*', 'success') : ' ';
|
||||
const tier = account.tier || 'unknown';
|
||||
const status = statusParts.join(', ');
|
||||
|
||||
rows.push([
|
||||
defaultMark,
|
||||
account.nickname || account.email || account.id,
|
||||
tier,
|
||||
avgQuota,
|
||||
status,
|
||||
]);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log(
|
||||
table(rows, {
|
||||
head: ['', 'Account', 'Tier', 'Quota', 'Status'],
|
||||
colWidths: [3, 30, 10, 10, 20],
|
||||
})
|
||||
);
|
||||
console.log('');
|
||||
console.log(info(`Default account marked with ${color('*', 'success')}`));
|
||||
console.log('');
|
||||
|
||||
// Show summary of paused/cooldown accounts
|
||||
const pausedCount = accounts.filter((a) => a.paused).length;
|
||||
const cooldownCount = accounts.filter((a) => isOnCooldown(provider, a.id)).length;
|
||||
if (pausedCount > 0) {
|
||||
console.log(
|
||||
warn(`${pausedCount} account(s) paused - use 'ccs cliproxy resume <account>' to re-enable`)
|
||||
);
|
||||
}
|
||||
if (cooldownCount > 0) {
|
||||
console.log(info(`${cooldownCount} account(s) on cooldown (exhausted recently)`));
|
||||
}
|
||||
if (pausedCount > 0 || cooldownCount > 0) {
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MAIN ROUTER
|
||||
// ============================================================================
|
||||
@@ -734,6 +946,27 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Quota management commands
|
||||
if (command === 'default') {
|
||||
await handleSetDefault(args.slice(1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'pause') {
|
||||
await handlePauseAccount(args.slice(1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'resume') {
|
||||
await handleResumeAccount(args.slice(1));
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === 'quota') {
|
||||
await handleQuotaStatus();
|
||||
return;
|
||||
}
|
||||
|
||||
const installIdx = args.indexOf('--install');
|
||||
if (installIdx !== -1) {
|
||||
let version = args[installIdx + 1];
|
||||
|
||||
@@ -259,6 +259,10 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
|
||||
['ccs cliproxy doctor', 'Quota diagnostics (Antigravity)'],
|
||||
['ccs cliproxy --install <ver>', 'Install specific version (e.g., 6.6.6)'],
|
||||
['ccs cliproxy --latest', 'Update to latest version'],
|
||||
['', ''], // Spacer
|
||||
['ccs cliproxy pause <p> <a>', 'Pause account from rotation'],
|
||||
['ccs cliproxy resume <p> <a>', 'Resume paused account'],
|
||||
['ccs cliproxy status [provider]', 'Show quota/tier/pause status'],
|
||||
]);
|
||||
|
||||
// CLI Proxy configuration flags (new)
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
DEFAULT_COPILOT_CONFIG,
|
||||
DEFAULT_GLOBAL_ENV,
|
||||
DEFAULT_CLIPROXY_SERVER_CONFIG,
|
||||
DEFAULT_QUOTA_MANAGEMENT_CONFIG,
|
||||
GlobalEnvConfig,
|
||||
} from './unified-config-types';
|
||||
import { isUnifiedConfigEnabled } from './feature-flags';
|
||||
@@ -212,6 +213,35 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
|
||||
DEFAULT_CLIPROXY_SERVER_CONFIG.local.auto_start,
|
||||
},
|
||||
},
|
||||
// Quota management config - hybrid auto+manual account selection
|
||||
quota_management: {
|
||||
mode: partial.quota_management?.mode ?? DEFAULT_QUOTA_MANAGEMENT_CONFIG.mode,
|
||||
auto: {
|
||||
preflight_check:
|
||||
partial.quota_management?.auto?.preflight_check ??
|
||||
DEFAULT_QUOTA_MANAGEMENT_CONFIG.auto.preflight_check,
|
||||
exhaustion_threshold:
|
||||
partial.quota_management?.auto?.exhaustion_threshold ??
|
||||
DEFAULT_QUOTA_MANAGEMENT_CONFIG.auto.exhaustion_threshold,
|
||||
tier_priority:
|
||||
partial.quota_management?.auto?.tier_priority ??
|
||||
DEFAULT_QUOTA_MANAGEMENT_CONFIG.auto.tier_priority,
|
||||
cooldown_minutes:
|
||||
partial.quota_management?.auto?.cooldown_minutes ??
|
||||
DEFAULT_QUOTA_MANAGEMENT_CONFIG.auto.cooldown_minutes,
|
||||
},
|
||||
manual: {
|
||||
paused_accounts:
|
||||
partial.quota_management?.manual?.paused_accounts ??
|
||||
DEFAULT_QUOTA_MANAGEMENT_CONFIG.manual.paused_accounts,
|
||||
forced_default:
|
||||
partial.quota_management?.manual?.forced_default ??
|
||||
DEFAULT_QUOTA_MANAGEMENT_CONFIG.manual.forced_default,
|
||||
tier_lock:
|
||||
partial.quota_management?.manual?.tier_lock ??
|
||||
DEFAULT_QUOTA_MANAGEMENT_CONFIG.manual.tier_lock,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
* Version 4 = Copilot API integration (GitHub Copilot proxy)
|
||||
* Version 5 = Remote proxy configuration (connect to remote CLIProxyAPI)
|
||||
* Version 6 = Customizable auth tokens (API key and management secret)
|
||||
* Version 7 = Quota management for hybrid auto+manual account control
|
||||
*/
|
||||
export const UNIFIED_CONFIG_VERSION = 6;
|
||||
export const UNIFIED_CONFIG_VERSION = 7;
|
||||
|
||||
/**
|
||||
* Account configuration (formerly in profiles.json).
|
||||
@@ -342,12 +343,93 @@ export interface WebSearchConfig {
|
||||
customMcp?: unknown[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// QUOTA MANAGEMENT CONFIGURATION (v7+)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Auto quota management configuration.
|
||||
* Controls automatic failover behavior.
|
||||
*/
|
||||
export interface AutoQuotaConfig {
|
||||
/** Enable pre-flight quota check before requests (default: true) */
|
||||
preflight_check: boolean;
|
||||
/** Quota percentage below which account is "exhausted" (default: 5) */
|
||||
exhaustion_threshold: number;
|
||||
/** Tier priority for failover, highest to lowest (default: ['paid']) */
|
||||
tier_priority: string[];
|
||||
/** Minutes to skip exhausted account before retry (default: 5) */
|
||||
cooldown_minutes: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual quota management configuration.
|
||||
* User-controlled overrides for account selection.
|
||||
*/
|
||||
export interface ManualQuotaConfig {
|
||||
/** User-paused accounts (stored in accounts.json) */
|
||||
paused_accounts: string[];
|
||||
/** Force use of specific account (overrides auto-selection) */
|
||||
forced_default: string | null;
|
||||
/** Lock to specific tier only */
|
||||
tier_lock: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quota management mode.
|
||||
* - auto: Fully automatic failover based on quota
|
||||
* - manual: User controls everything, no auto-switching
|
||||
* - hybrid: Auto-failover with user overrides (default)
|
||||
*/
|
||||
export type QuotaManagementMode = 'auto' | 'manual' | 'hybrid';
|
||||
|
||||
/**
|
||||
* Quota management configuration section.
|
||||
* Controls hybrid auto+manual account selection for multi-account setups.
|
||||
*/
|
||||
export interface QuotaManagementConfig {
|
||||
/** Management mode (default: hybrid) */
|
||||
mode: QuotaManagementMode;
|
||||
/** Auto mode settings */
|
||||
auto: AutoQuotaConfig;
|
||||
/** Manual mode settings */
|
||||
manual: ManualQuotaConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default auto quota configuration.
|
||||
*/
|
||||
export const DEFAULT_AUTO_QUOTA_CONFIG: AutoQuotaConfig = {
|
||||
preflight_check: true,
|
||||
exhaustion_threshold: 5,
|
||||
tier_priority: ['paid'],
|
||||
cooldown_minutes: 5,
|
||||
};
|
||||
|
||||
/**
|
||||
* Default manual quota configuration.
|
||||
*/
|
||||
export const DEFAULT_MANUAL_QUOTA_CONFIG: ManualQuotaConfig = {
|
||||
paused_accounts: [],
|
||||
forced_default: null,
|
||||
tier_lock: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Default quota management configuration.
|
||||
*/
|
||||
export const DEFAULT_QUOTA_MANAGEMENT_CONFIG: QuotaManagementConfig = {
|
||||
mode: 'hybrid',
|
||||
auto: { ...DEFAULT_AUTO_QUOTA_CONFIG },
|
||||
manual: { ...DEFAULT_MANUAL_QUOTA_CONFIG },
|
||||
};
|
||||
|
||||
/**
|
||||
* Main unified configuration structure.
|
||||
* Stored in ~/.ccs/config.yaml
|
||||
*/
|
||||
export interface UnifiedConfig {
|
||||
/** Config version (5 for remote proxy support) */
|
||||
/** Config version (7 for quota management) */
|
||||
version: number;
|
||||
/** Default profile name to use when none specified */
|
||||
default?: string;
|
||||
@@ -367,6 +449,8 @@ export interface UnifiedConfig {
|
||||
copilot?: CopilotConfig;
|
||||
/** CLIProxy server configuration for remote/local mode */
|
||||
cliproxy_server?: CliproxyServerConfig;
|
||||
/** Quota management configuration (v7+) */
|
||||
quota_management?: QuotaManagementConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -455,6 +539,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
|
||||
},
|
||||
copilot: { ...DEFAULT_COPILOT_CONFIG },
|
||||
cliproxy_server: { ...DEFAULT_CLIPROXY_SERVER_CONFIG },
|
||||
quota_management: { ...DEFAULT_QUOTA_MANAGEMENT_CONFIG },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+15
-1
@@ -32,8 +32,22 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
|
||||
const server = http.createServer(app);
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
// JSON body parsing
|
||||
// JSON body parsing with error handler for malformed JSON
|
||||
app.use(express.json());
|
||||
app.use(
|
||||
(
|
||||
err: Error & { status?: number; body?: string },
|
||||
_req: express.Request,
|
||||
res: express.Response,
|
||||
next: express.NextFunction
|
||||
) => {
|
||||
if (err instanceof SyntaxError && err.status === 400 && 'body' in err) {
|
||||
res.status(400).json({ error: 'Invalid JSON in request body' });
|
||||
return;
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
);
|
||||
|
||||
// REST API routes (modularized)
|
||||
const { apiRoutes } = await import('./routes/index');
|
||||
|
||||
@@ -23,7 +23,11 @@ import {
|
||||
getProviderAccounts,
|
||||
setDefaultAccount as setDefaultAccountFn,
|
||||
removeAccount as removeAccountFn,
|
||||
pauseAccount as pauseAccountFn,
|
||||
resumeAccount as resumeAccountFn,
|
||||
touchAccount,
|
||||
PROVIDERS_WITHOUT_EMAIL,
|
||||
validateNickname,
|
||||
} from '../../cliproxy/account-manager';
|
||||
import { getProxyTarget } from '../../cliproxy/proxy-target-resolver';
|
||||
import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher';
|
||||
@@ -269,13 +273,80 @@ router.delete('/accounts/:provider/:accountId', (req: Request, res: Response): v
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/cliproxy/accounts/:provider/:accountId/pause - Pause an account
|
||||
* Paused accounts are skipped during quota rotation
|
||||
*/
|
||||
router.post('/accounts/:provider/:accountId/pause', (req: Request, res: Response): void => {
|
||||
const target = getProxyTarget();
|
||||
if (target.isRemote) {
|
||||
res.status(501).json({ error: 'Account management not available in remote mode' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { provider, accountId } = req.params;
|
||||
|
||||
if (!validProviders.includes(provider as CLIProxyProvider)) {
|
||||
res.status(400).json({ error: `Invalid provider: ${provider}` });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const success = pauseAccountFn(provider as CLIProxyProvider, accountId);
|
||||
if (success) {
|
||||
res.json({ provider, accountId, paused: true });
|
||||
} else {
|
||||
res
|
||||
.status(404)
|
||||
.json({ error: `Account '${accountId}' not found for provider '${provider}'` });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to pause account';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/cliproxy/accounts/:provider/:accountId/resume - Resume a paused account
|
||||
*/
|
||||
router.post('/accounts/:provider/:accountId/resume', (req: Request, res: Response): void => {
|
||||
const target = getProxyTarget();
|
||||
if (target.isRemote) {
|
||||
res.status(501).json({ error: 'Account management not available in remote mode' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { provider, accountId } = req.params;
|
||||
|
||||
if (!validProviders.includes(provider as CLIProxyProvider)) {
|
||||
res.status(400).json({ error: `Invalid provider: ${provider}` });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const success = resumeAccountFn(provider as CLIProxyProvider, accountId);
|
||||
if (success) {
|
||||
res.json({ provider, accountId, paused: false });
|
||||
} else {
|
||||
res
|
||||
.status(404)
|
||||
.json({ error: `Account '${accountId}' not found for provider '${provider}'` });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to resume account';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/cliproxy/auth/:provider/start - Start OAuth flow for a provider
|
||||
* Opens browser for authentication and returns account info when complete
|
||||
*/
|
||||
router.post('/:provider/start', async (req: Request, res: Response): Promise<void> => {
|
||||
const { provider } = req.params;
|
||||
const { nickname, noIncognito: noIncognitoBody } = req.body;
|
||||
const { nickname: nicknameRaw, noIncognito: noIncognitoBody } = req.body;
|
||||
// Trim nickname for consistency with CLI (oauth-handler.ts trims input)
|
||||
const nickname = typeof nicknameRaw === 'string' ? nicknameRaw.trim() : nicknameRaw;
|
||||
|
||||
// Validate provider
|
||||
if (!validProviders.includes(provider as CLIProxyProvider)) {
|
||||
@@ -283,6 +354,39 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
|
||||
return;
|
||||
}
|
||||
|
||||
// For kiro/ghcp: nickname is required
|
||||
if (PROVIDERS_WITHOUT_EMAIL.includes(provider as CLIProxyProvider)) {
|
||||
if (!nickname) {
|
||||
res.status(400).json({
|
||||
error: `Nickname is required for ${provider} accounts. Please provide a unique nickname.`,
|
||||
code: 'NICKNAME_REQUIRED',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const validationError = validateNickname(nickname);
|
||||
if (validationError) {
|
||||
res.status(400).json({
|
||||
error: validationError,
|
||||
code: 'INVALID_NICKNAME',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check uniqueness
|
||||
const existingAccounts = getProviderAccounts(provider as CLIProxyProvider);
|
||||
const existingNicknames = existingAccounts.map(
|
||||
(a) => a.nickname?.toLowerCase() || a.id.toLowerCase()
|
||||
);
|
||||
if (existingNicknames.includes(nickname.toLowerCase())) {
|
||||
res.status(400).json({
|
||||
error: `Nickname "${nickname}" is already in use. Choose a different one.`,
|
||||
code: 'NICKNAME_EXISTS',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check Kiro no-incognito setting from config (or request body)
|
||||
// Default to true (use normal browser) for reliability - incognito often fails
|
||||
let noIncognito = true;
|
||||
|
||||
@@ -164,7 +164,7 @@ describe('Account Manager - discoverExistingAccounts', () => {
|
||||
assert.strictEqual(accountIds[0], 'actual@email.com', 'Should use data.email when available');
|
||||
});
|
||||
|
||||
it('falls back to "default" when no email in file or filename', () => {
|
||||
it('generates unique ID for kiro/ghcp when no email in file or filename', () => {
|
||||
createAuthFile('kiro-nomail.json', {
|
||||
type: 'kiro',
|
||||
email: '',
|
||||
@@ -174,7 +174,8 @@ describe('Account Manager - discoverExistingAccounts', () => {
|
||||
const accounts = getAccountsFile();
|
||||
|
||||
const accountIds = Object.keys(accounts.providers.kiro.accounts);
|
||||
assert.strictEqual(accountIds[0], 'default', 'Should fall back to "default"');
|
||||
// For kiro/ghcp without email, generates unique ID like "kiro-1"
|
||||
assert.strictEqual(accountIds[0], 'kiro-1', 'Should generate unique ID for kiro without email');
|
||||
});
|
||||
|
||||
it('handles dots in email local part', () => {
|
||||
@@ -274,6 +275,72 @@ describe('Account Manager - discoverExistingAccounts', () => {
|
||||
'First account should be default'
|
||||
);
|
||||
});
|
||||
|
||||
it('generates sequential IDs for multiple kiro files without email', () => {
|
||||
// Create multiple kiro files without email in filename
|
||||
// Files like "kiro-nomail.json" don't match oauth pattern (need 2+ hyphens)
|
||||
createAuthFile('kiro-account1.json', {
|
||||
type: 'kiro',
|
||||
email: '',
|
||||
});
|
||||
createAuthFile('kiro-account2.json', {
|
||||
type: 'kiro',
|
||||
email: '',
|
||||
});
|
||||
|
||||
accountManager.discoverExistingAccounts();
|
||||
const accounts = getAccountsFile();
|
||||
|
||||
const accountIds = Object.keys(accounts.providers.kiro.accounts);
|
||||
assert.strictEqual(accountIds.length, 2, 'Should have 2 accounts');
|
||||
// Both should have kiro-N format since filenames don't match oauth pattern (1 hyphen only)
|
||||
assert(accountIds.includes('kiro-1'), 'Should have kiro-1');
|
||||
assert(accountIds.includes('kiro-2'), 'Should have kiro-2');
|
||||
});
|
||||
|
||||
it('skips to next ID when collision exists', () => {
|
||||
// Pre-create accounts.json with kiro-1 already registered
|
||||
const accountsPath = path.join(testDir, '.ccs', 'cliproxy', 'accounts.json');
|
||||
const existingRegistry = {
|
||||
version: 1,
|
||||
providers: {
|
||||
kiro: {
|
||||
default: 'kiro-1',
|
||||
accounts: {
|
||||
'kiro-1': {
|
||||
tokenFile: 'kiro-existing.json',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(accountsPath, JSON.stringify(existingRegistry));
|
||||
|
||||
// Create the existing token file (matches registry)
|
||||
createAuthFile('kiro-existing.json', {
|
||||
type: 'kiro',
|
||||
email: '',
|
||||
});
|
||||
|
||||
// Create new file that will need auto-generated ID (single hyphen = no oauth pattern match)
|
||||
createAuthFile('kiro-newaccount.json', {
|
||||
type: 'kiro',
|
||||
email: '',
|
||||
});
|
||||
|
||||
// Reload module to pick up pre-existing accounts.json
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/account-manager')];
|
||||
accountManager = require('../../../dist/cliproxy/account-manager');
|
||||
accountManager.discoverExistingAccounts();
|
||||
|
||||
const accounts = getAccountsFile();
|
||||
const accountIds = Object.keys(accounts.providers.kiro.accounts);
|
||||
|
||||
assert.strictEqual(accountIds.length, 2, 'Should have 2 accounts');
|
||||
assert(accountIds.includes('kiro-1'), 'Should keep existing kiro-1');
|
||||
assert(accountIds.includes('kiro-2'), 'New account should be kiro-2 (skipped kiro-1)');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
HelpCircle,
|
||||
Pause,
|
||||
Play,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
cn,
|
||||
@@ -86,7 +88,9 @@ export function AccountItem({
|
||||
account,
|
||||
onSetDefault,
|
||||
onRemove,
|
||||
onPauseToggle,
|
||||
isRemoving,
|
||||
isPausingAccount,
|
||||
privacyMode,
|
||||
showQuota,
|
||||
}: AccountItemProps) {
|
||||
@@ -139,6 +143,27 @@ export function AccountItem({
|
||||
Default
|
||||
</Badge>
|
||||
)}
|
||||
{account.tier && account.tier !== 'unknown' && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-[10px] h-4 px-1.5 uppercase',
|
||||
account.tier === 'paid' && 'border-blue-500 text-blue-600',
|
||||
account.tier === 'free' && 'border-gray-400 text-gray-500'
|
||||
)}
|
||||
>
|
||||
{account.tier}
|
||||
</Badge>
|
||||
)}
|
||||
{account.paused && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] h-4 px-1.5 border-yellow-500 text-yellow-600"
|
||||
>
|
||||
<Pause className="w-2 h-2 mr-0.5" />
|
||||
Paused
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{account.lastUsedAt && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
|
||||
@@ -162,6 +187,24 @@ export function AccountItem({
|
||||
Set as default
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onPauseToggle && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onPauseToggle(!account.paused)}
|
||||
disabled={isPausingAccount}
|
||||
>
|
||||
{account.paused ? (
|
||||
<>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
{isPausingAccount ? 'Resuming...' : 'Resume account'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pause className="w-4 h-4 mr-2" />
|
||||
{isPausingAccount ? 'Pausing...' : 'Pause account'}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={onRemove}
|
||||
|
||||
@@ -15,7 +15,10 @@ interface AccountsSectionProps {
|
||||
onAddAccount: () => void;
|
||||
onSetDefault: (accountId: string) => void;
|
||||
onRemoveAccount: (accountId: string) => void;
|
||||
onPauseToggle?: (accountId: string, paused: boolean) => void;
|
||||
isRemovingAccount?: boolean;
|
||||
/** Pause/resume mutation in progress */
|
||||
isPausingAccount?: boolean;
|
||||
privacyMode?: boolean;
|
||||
/** Show quota bars for accounts (only applicable for 'agy' provider) */
|
||||
showQuota?: boolean;
|
||||
@@ -31,7 +34,9 @@ export function AccountsSection({
|
||||
onAddAccount,
|
||||
onSetDefault,
|
||||
onRemoveAccount,
|
||||
onPauseToggle,
|
||||
isRemovingAccount,
|
||||
isPausingAccount,
|
||||
privacyMode,
|
||||
showQuota,
|
||||
isKiro,
|
||||
@@ -65,7 +70,11 @@ export function AccountsSection({
|
||||
account={account}
|
||||
onSetDefault={() => onSetDefault(account.id)}
|
||||
onRemove={() => onRemoveAccount(account.id)}
|
||||
onPauseToggle={
|
||||
onPauseToggle ? (paused) => onPauseToggle(account.id, paused) : undefined
|
||||
}
|
||||
isRemoving={isRemovingAccount}
|
||||
isPausingAccount={isPausingAccount}
|
||||
privacyMode={privacyMode}
|
||||
showQuota={showQuota}
|
||||
/>
|
||||
|
||||
@@ -38,7 +38,9 @@ export function ProviderEditor({
|
||||
onAddAccount,
|
||||
onSetDefault,
|
||||
onRemoveAccount,
|
||||
onPauseToggle,
|
||||
isRemovingAccount,
|
||||
isPausingAccount,
|
||||
}: ProviderEditorProps) {
|
||||
const [customPresetOpen, setCustomPresetOpen] = useState(false);
|
||||
const { privacyMode } = usePrivacy();
|
||||
@@ -200,7 +202,9 @@ export function ProviderEditor({
|
||||
onAddAccount={onAddAccount}
|
||||
onSetDefault={onSetDefault}
|
||||
onRemoveAccount={onRemoveAccount}
|
||||
onPauseToggle={onPauseToggle}
|
||||
isRemovingAccount={isRemovingAccount}
|
||||
isPausingAccount={isPausingAccount}
|
||||
privacyMode={privacyMode}
|
||||
isRemoteMode={isRemoteMode}
|
||||
/>
|
||||
|
||||
@@ -36,7 +36,10 @@ interface ModelConfigTabProps {
|
||||
onAddAccount: () => void;
|
||||
onSetDefault: (accountId: string) => void;
|
||||
onRemoveAccount: (accountId: string) => void;
|
||||
onPauseToggle?: (accountId: string, paused: boolean) => void;
|
||||
isRemovingAccount?: boolean;
|
||||
/** Pause/resume mutation in progress */
|
||||
isPausingAccount?: boolean;
|
||||
privacyMode?: boolean;
|
||||
/** True if connected to remote CLIProxy (quota not available) */
|
||||
isRemoteMode?: boolean;
|
||||
@@ -60,7 +63,9 @@ export function ModelConfigTab({
|
||||
onAddAccount,
|
||||
onSetDefault,
|
||||
onRemoveAccount,
|
||||
onPauseToggle,
|
||||
isRemovingAccount,
|
||||
isPausingAccount,
|
||||
privacyMode,
|
||||
isRemoteMode,
|
||||
}: ModelConfigTabProps) {
|
||||
@@ -134,7 +139,9 @@ export function ModelConfigTab({
|
||||
onAddAccount={onAddAccount}
|
||||
onSetDefault={onSetDefault}
|
||||
onRemoveAccount={onRemoveAccount}
|
||||
onPauseToggle={onPauseToggle}
|
||||
isRemovingAccount={isRemovingAccount}
|
||||
isPausingAccount={isPausingAccount}
|
||||
privacyMode={privacyMode}
|
||||
showQuota={provider === 'agy' && !isRemoteMode}
|
||||
isKiro={isKiro}
|
||||
|
||||
@@ -30,14 +30,20 @@ export interface ProviderEditorProps {
|
||||
onAddAccount: () => void;
|
||||
onSetDefault: (accountId: string) => void;
|
||||
onRemoveAccount: (accountId: string) => void;
|
||||
onPauseToggle?: (accountId: string, paused: boolean) => void;
|
||||
isRemovingAccount?: boolean;
|
||||
/** Pause/resume mutation in progress */
|
||||
isPausingAccount?: boolean;
|
||||
}
|
||||
|
||||
export interface AccountItemProps {
|
||||
account: OAuthAccount;
|
||||
onSetDefault: () => void;
|
||||
onRemove: () => void;
|
||||
onPauseToggle?: (paused: boolean) => void;
|
||||
isRemoving?: boolean;
|
||||
/** Pause/resume mutation in progress */
|
||||
isPausingAccount?: boolean;
|
||||
privacyMode?: boolean;
|
||||
/** Show quota bar (only for 'agy' provider) */
|
||||
showQuota?: boolean;
|
||||
|
||||
@@ -118,6 +118,40 @@ export function useRemoveAccount() {
|
||||
});
|
||||
}
|
||||
|
||||
export function usePauseAccount() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ provider, accountId }: { provider: string; accountId: string }) =>
|
||||
api.cliproxy.accounts.pause(provider, accountId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
toast.success('Account paused');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useResumeAccount() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ provider, accountId }: { provider: string; accountId: string }) =>
|
||||
api.cliproxy.accounts.resume(provider, accountId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
toast.success('Account resumed');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// OAuth flow hook
|
||||
export function useStartAuth() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -71,11 +71,18 @@ export interface UpdateVariant {
|
||||
export interface OAuthAccount {
|
||||
id: string;
|
||||
email?: string;
|
||||
nickname?: string;
|
||||
provider: 'gemini' | 'codex' | 'agy' | 'qwen' | 'iflow' | 'kiro' | 'ghcp';
|
||||
isDefault: boolean;
|
||||
tokenFile: string;
|
||||
createdAt: string;
|
||||
lastUsedAt?: string;
|
||||
/** Whether account is paused (skipped in quota rotation) */
|
||||
paused?: boolean;
|
||||
/** ISO timestamp when account was paused */
|
||||
pausedAt?: string;
|
||||
/** Account tier: free or paid (Pro/Ultra combined) */
|
||||
tier?: 'free' | 'paid' | 'unknown';
|
||||
}
|
||||
|
||||
export interface AuthStatus {
|
||||
@@ -392,6 +399,16 @@ export const api = {
|
||||
}),
|
||||
remove: (provider: string, accountId: string) =>
|
||||
request(`/cliproxy/auth/accounts/${provider}/${accountId}`, { method: 'DELETE' }),
|
||||
pause: (provider: string, accountId: string) =>
|
||||
request<{ provider: string; accountId: string; paused: boolean }>(
|
||||
`/cliproxy/auth/accounts/${provider}/${accountId}/pause`,
|
||||
{ method: 'POST' }
|
||||
),
|
||||
resume: (provider: string, accountId: string) =>
|
||||
request<{ provider: string; accountId: string; paused: boolean }>(
|
||||
`/cliproxy/auth/accounts/${provider}/${accountId}/resume`,
|
||||
{ method: 'POST' }
|
||||
),
|
||||
},
|
||||
// OAuth flow
|
||||
auth: {
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
useCliproxyAuth,
|
||||
useSetDefaultAccount,
|
||||
useRemoveAccount,
|
||||
usePauseAccount,
|
||||
useResumeAccount,
|
||||
useDeleteVariant,
|
||||
} from '@/hooks/use-cliproxy';
|
||||
import type { AuthStatus, Variant } from '@/lib/api-client';
|
||||
@@ -179,6 +181,8 @@ export function CliproxyPage() {
|
||||
const { data: variantsData, isFetching } = useCliproxy();
|
||||
const setDefaultMutation = useSetDefaultAccount();
|
||||
const removeMutation = useRemoveAccount();
|
||||
const pauseMutation = usePauseAccount();
|
||||
const resumeMutation = useResumeAccount();
|
||||
const deleteMutation = useDeleteVariant();
|
||||
|
||||
// Selection state: either a provider or a variant
|
||||
@@ -216,6 +220,16 @@ export function CliproxyPage() {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
};
|
||||
|
||||
const handlePauseToggle = (provider: string, accountId: string, paused: boolean) => {
|
||||
// Prevent rapid clicks while mutation is pending
|
||||
if (pauseMutation.isPending || resumeMutation.isPending) return;
|
||||
if (paused) {
|
||||
pauseMutation.mutate({ provider, accountId });
|
||||
} else {
|
||||
resumeMutation.mutate({ provider, accountId });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectProvider = (provider: string) => {
|
||||
setSelectedProvider(provider);
|
||||
setSelectedVariant(null);
|
||||
@@ -361,7 +375,11 @@ export function CliproxyPage() {
|
||||
accountId,
|
||||
})
|
||||
}
|
||||
onPauseToggle={(accountId, paused) =>
|
||||
handlePauseToggle(selectedVariantData.provider, accountId, paused)
|
||||
}
|
||||
isRemovingAccount={removeMutation.isPending}
|
||||
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
|
||||
/>
|
||||
) : selectedStatus ? (
|
||||
<ProviderEditor
|
||||
@@ -389,7 +407,11 @@ export function CliproxyPage() {
|
||||
accountId,
|
||||
})
|
||||
}
|
||||
onPauseToggle={(accountId, paused) =>
|
||||
handlePauseToggle(selectedStatus.provider, accountId, paused)
|
||||
}
|
||||
isRemovingAccount={removeMutation.isPending}
|
||||
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
|
||||
/>
|
||||
) : (
|
||||
<EmptyProviderState onSetup={() => setWizardOpen(true)} />
|
||||
|
||||
Reference in new issue
Block a user