mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 08:15:17 +00:00
Merge pull request #338 from kaitranntt/kai/fix/pause-account-cliproxy-usage
fix(cliproxy): move token files when pausing/resuming accounts
This commit is contained in:
3 files changed
+114
-8
No files matched your search
@@ -6,6 +6,7 @@
|
||||
*
|
||||
* Account storage: ~/.ccs/cliproxy/accounts.json
|
||||
* Token storage: ~/.ccs/cliproxy/auth/ (flat structure, CLIProxyAPI discovers by type field)
|
||||
* Paused tokens: ~/.ccs/cliproxy/auth-paused/ (sibling dir, outside CLIProxyAPI scan path)
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
@@ -137,6 +138,19 @@ export function getAccountsRegistryPath(): string {
|
||||
return path.join(getCliproxyDir(), 'accounts.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get path to paused tokens directory
|
||||
* Paused tokens are moved here so CLIProxyAPI won't discover them
|
||||
*
|
||||
* Uses sibling directory (auth-paused/) instead of subdirectory (auth/paused/)
|
||||
* because CLIProxyAPI's watcher uses filepath.Walk() which recursively scans
|
||||
* all subdirectories of auth/. A sibling directory is completely outside
|
||||
* CLIProxyAPI's scan path, preventing token refresh loops.
|
||||
*/
|
||||
export function getPausedDir(): string {
|
||||
return path.join(getCliproxyDir(), 'auth-paused');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load accounts registry
|
||||
*/
|
||||
@@ -178,10 +192,12 @@ export function saveAccountsRegistry(registry: AccountsRegistry): void {
|
||||
/**
|
||||
* Sync registry with actual token files
|
||||
* Removes stale entries where token file no longer exists
|
||||
* For paused accounts, checks both auth/ and paused/ directories
|
||||
* Called automatically when loading accounts
|
||||
*/
|
||||
function syncRegistryWithTokenFiles(registry: AccountsRegistry): boolean {
|
||||
const authDir = getAuthDir();
|
||||
const pausedDir = getPausedDir();
|
||||
let modified = false;
|
||||
|
||||
for (const [_providerName, providerAccounts] of Object.entries(registry.providers)) {
|
||||
@@ -191,7 +207,14 @@ function syncRegistryWithTokenFiles(registry: AccountsRegistry): boolean {
|
||||
|
||||
for (const [accountId, meta] of Object.entries(providerAccounts.accounts)) {
|
||||
const tokenPath = path.join(authDir, meta.tokenFile);
|
||||
if (!fs.existsSync(tokenPath)) {
|
||||
const pausedPath = path.join(pausedDir, meta.tokenFile);
|
||||
|
||||
// For paused accounts, check paused dir; for active accounts, check auth dir
|
||||
const expectedPath = meta.paused ? pausedPath : tokenPath;
|
||||
// Also accept if file exists in either location (handles edge cases)
|
||||
const existsAnywhere = fs.existsSync(tokenPath) || fs.existsSync(pausedPath);
|
||||
|
||||
if (!fs.existsSync(expectedPath) && !existsAnywhere) {
|
||||
staleIds.push(accountId);
|
||||
}
|
||||
}
|
||||
@@ -406,6 +429,7 @@ export function setDefaultAccount(provider: CLIProxyProvider, accountId: string)
|
||||
|
||||
/**
|
||||
* Pause an account (skip in quota rotation)
|
||||
* Moves token file to paused/ subdir so CLIProxyAPI won't discover it
|
||||
*/
|
||||
export function pauseAccount(provider: CLIProxyProvider, accountId: string): boolean {
|
||||
const registry = loadAccountsRegistry();
|
||||
@@ -415,6 +439,32 @@ export function pauseAccount(provider: CLIProxyProvider, accountId: string): boo
|
||||
return false;
|
||||
}
|
||||
|
||||
const accountMeta = providerAccounts.accounts[accountId];
|
||||
|
||||
// Skip if already paused (idempotent)
|
||||
if (accountMeta.paused) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const authDir = getAuthDir();
|
||||
const pausedDir = getPausedDir();
|
||||
const tokenPath = path.join(authDir, accountMeta.tokenFile);
|
||||
const pausedPath = path.join(pausedDir, accountMeta.tokenFile);
|
||||
|
||||
// Move token file to paused directory (if it exists in auth dir)
|
||||
if (fs.existsSync(tokenPath)) {
|
||||
try {
|
||||
// Create paused directory if it doesn't exist
|
||||
if (!fs.existsSync(pausedDir)) {
|
||||
fs.mkdirSync(pausedDir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
fs.renameSync(tokenPath, pausedPath);
|
||||
} catch {
|
||||
// File operation failed, but continue with registry update
|
||||
// syncRegistryWithTokenFiles() will handle recovery on next load
|
||||
}
|
||||
}
|
||||
|
||||
providerAccounts.accounts[accountId].paused = true;
|
||||
providerAccounts.accounts[accountId].pausedAt = new Date().toISOString();
|
||||
saveAccountsRegistry(registry);
|
||||
@@ -423,6 +473,7 @@ export function pauseAccount(provider: CLIProxyProvider, accountId: string): boo
|
||||
|
||||
/**
|
||||
* Resume a paused account
|
||||
* Moves token file back from paused/ to auth/ so CLIProxyAPI can discover it
|
||||
*/
|
||||
export function resumeAccount(provider: CLIProxyProvider, accountId: string): boolean {
|
||||
const registry = loadAccountsRegistry();
|
||||
@@ -432,6 +483,28 @@ export function resumeAccount(provider: CLIProxyProvider, accountId: string): bo
|
||||
return false;
|
||||
}
|
||||
|
||||
const accountMeta = providerAccounts.accounts[accountId];
|
||||
|
||||
// Skip if already active (idempotent)
|
||||
if (!accountMeta.paused) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const authDir = getAuthDir();
|
||||
const pausedDir = getPausedDir();
|
||||
const tokenPath = path.join(authDir, accountMeta.tokenFile);
|
||||
const pausedPath = path.join(pausedDir, accountMeta.tokenFile);
|
||||
|
||||
// Move token file back from paused directory (if it exists in paused dir)
|
||||
if (fs.existsSync(pausedPath)) {
|
||||
try {
|
||||
fs.renameSync(pausedPath, tokenPath);
|
||||
} catch {
|
||||
// File operation failed, but continue with registry update
|
||||
// syncRegistryWithTokenFiles() will handle recovery on next load
|
||||
}
|
||||
}
|
||||
|
||||
providerAccounts.accounts[accountId].paused = false;
|
||||
providerAccounts.accounts[accountId].pausedAt = undefined;
|
||||
saveAccountsRegistry(registry);
|
||||
@@ -485,11 +558,12 @@ export function removeAccount(provider: CLIProxyProvider, accountId: string): bo
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get token file to delete
|
||||
// Get token file to delete (check both auth and paused directories)
|
||||
const tokenFile = providerAccounts.accounts[accountId].tokenFile;
|
||||
const tokenPath = path.join(getAuthDir(), tokenFile);
|
||||
const pausedPath = path.join(getPausedDir(), tokenFile);
|
||||
|
||||
// Delete token file
|
||||
// Delete token file from auth directory
|
||||
if (fs.existsSync(tokenPath)) {
|
||||
try {
|
||||
fs.unlinkSync(tokenPath);
|
||||
@@ -498,6 +572,15 @@ export function removeAccount(provider: CLIProxyProvider, accountId: string): bo
|
||||
}
|
||||
}
|
||||
|
||||
// Also delete from paused directory if it exists there
|
||||
if (fs.existsSync(pausedPath)) {
|
||||
try {
|
||||
fs.unlinkSync(pausedPath);
|
||||
} catch {
|
||||
// Ignore deletion errors
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from registry
|
||||
delete providerAccounts.accounts[accountId];
|
||||
|
||||
@@ -558,6 +641,7 @@ export function touchAccount(provider: CLIProxyProvider, accountId: string): voi
|
||||
|
||||
/**
|
||||
* Get token file path for an account
|
||||
* Returns path in paused/ dir if account is paused, otherwise auth/
|
||||
*/
|
||||
export function getAccountTokenPath(provider: CLIProxyProvider, accountId?: string): string | null {
|
||||
const account = accountId ? getAccount(provider, accountId) : getDefaultAccount(provider);
|
||||
@@ -566,7 +650,9 @@ export function getAccountTokenPath(provider: CLIProxyProvider, accountId?: stri
|
||||
return null;
|
||||
}
|
||||
|
||||
return path.join(getAuthDir(), account.tokenFile);
|
||||
// Return path from paused directory if account is paused
|
||||
const baseDir = account.paused ? getPausedDir() : getAuthDir();
|
||||
return path.join(baseDir, account.tokenFile);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,6 +11,7 @@ import { getAuthDir } from './config-generator';
|
||||
import { CLIProxyProvider } from './types';
|
||||
import {
|
||||
getProviderAccounts,
|
||||
isAccountPaused,
|
||||
setAccountTier,
|
||||
type AccountInfo,
|
||||
type AccountTier,
|
||||
@@ -553,6 +554,18 @@ export async function fetchAccountQuota(
|
||||
};
|
||||
}
|
||||
|
||||
// Check if account is paused (token moved to auth-paused/ directory)
|
||||
if (isAccountPaused(provider, accountId)) {
|
||||
const error = 'Account is paused';
|
||||
if (verbose) console.error(`[i] ${error}`);
|
||||
return {
|
||||
success: false,
|
||||
models: [],
|
||||
lastUpdated: Date.now(),
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
// Read auth data from auth file
|
||||
const authData = readAuthData(provider, accountId);
|
||||
if (!authData) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
getMinClaudeQuota,
|
||||
} from '@/lib/utils';
|
||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import { GripVertical, Loader2, Clock } from 'lucide-react';
|
||||
import { GripVertical, Loader2, Clock, Pause } from 'lucide-react';
|
||||
import { useAccountQuota } from '@/hooks/use-cliproxy-stats';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
@@ -202,9 +202,16 @@ export function AccountCard({
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : quota?.error ? (
|
||||
<div className="text-[8px] text-muted-foreground/60 truncate" title={quota.error}>
|
||||
{quota.error.length > 20 ? `${quota.error.slice(0, 18)}...` : quota.error}
|
||||
</div>
|
||||
quota.error === 'Account is paused' ? (
|
||||
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400">
|
||||
<Pause className="w-3 h-3" />
|
||||
<span className="text-[9px] font-medium uppercase tracking-wide">Paused</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[8px] text-muted-foreground/60 truncate" title={quota.error}>
|
||||
{quota.error.length > 20 ? `${quota.error.slice(0, 18)}...` : quota.error}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user