mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +00:00
feat(cliproxy): expose codex weekly reset schedule in quota views
This commit is contained in:
@@ -238,6 +238,7 @@ Re-creates symlinks for shared commands, skills, and settings.
|
||||
|
||||
```bash
|
||||
ccs cliproxy doctor # Check quota status for all agy accounts
|
||||
ccs cliproxy quota # Show agy/codex/gemini quotas (Codex: 5h + weekly reset schedule)
|
||||
```
|
||||
|
||||
**Auto-Failover**: When an Antigravity account runs out of quota, CCS automatically switches to another account with remaining capacity. Shared GCP project accounts are excluded (pooled quota).
|
||||
|
||||
@@ -10,7 +10,7 @@ import * as path from 'node:path';
|
||||
import { getAuthDir } from './config-generator';
|
||||
import { getProviderAccounts, getPausedDir } from './account-manager';
|
||||
import { sanitizeEmail, isTokenExpired } from './auth-utils';
|
||||
import type { CodexQuotaResult, CodexQuotaWindow } from './quota-types';
|
||||
import type { CodexQuotaResult, CodexQuotaWindow, CodexCoreUsageSummary } from './quota-types';
|
||||
|
||||
/** ChatGPT backend API base URL */
|
||||
const CODEX_API_BASE = 'https://chatgpt.com/backend-api';
|
||||
@@ -57,6 +57,99 @@ interface CodexWindowData {
|
||||
resetAfterSeconds?: number | null;
|
||||
}
|
||||
|
||||
type CodexWindowKind =
|
||||
| 'usage-5h'
|
||||
| 'usage-weekly'
|
||||
| 'code-review-5h'
|
||||
| 'code-review-weekly'
|
||||
| 'code-review'
|
||||
| 'unknown';
|
||||
|
||||
function getCodexWindowKind(label: string): CodexWindowKind {
|
||||
const lower = (label || '').toLowerCase();
|
||||
const isCodeReview = lower.includes('code review') || lower.includes('code_review');
|
||||
const isPrimary = lower.includes('primary');
|
||||
const isSecondary = lower.includes('secondary');
|
||||
|
||||
if (isCodeReview) {
|
||||
if (isPrimary) return 'code-review-5h';
|
||||
if (isSecondary) return 'code-review-weekly';
|
||||
return 'code-review';
|
||||
}
|
||||
|
||||
if (isPrimary) return 'usage-5h';
|
||||
if (isSecondary) return 'usage-weekly';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build explicit 5h + weekly usage summary from raw Codex windows.
|
||||
* Falls back to shortest/longest reset windows if API labels change.
|
||||
*/
|
||||
export function buildCodexCoreUsageSummary(windows: CodexQuotaWindow[]): CodexCoreUsageSummary {
|
||||
if (!windows || windows.length === 0) {
|
||||
return { fiveHour: null, weekly: null };
|
||||
}
|
||||
|
||||
let fiveHourWindow: CodexQuotaWindow | null = null;
|
||||
let weeklyWindow: CodexQuotaWindow | null = null;
|
||||
const nonCodeReviewWindows: CodexQuotaWindow[] = [];
|
||||
|
||||
for (const window of windows) {
|
||||
const kind = getCodexWindowKind(window.label);
|
||||
if (kind === 'usage-5h') {
|
||||
if (!fiveHourWindow) fiveHourWindow = window;
|
||||
nonCodeReviewWindows.push(window);
|
||||
continue;
|
||||
}
|
||||
if (kind === 'usage-weekly') {
|
||||
if (!weeklyWindow) weeklyWindow = window;
|
||||
nonCodeReviewWindows.push(window);
|
||||
continue;
|
||||
}
|
||||
if (kind === 'unknown') {
|
||||
nonCodeReviewWindows.push(window);
|
||||
}
|
||||
}
|
||||
|
||||
if ((!fiveHourWindow || !weeklyWindow) && nonCodeReviewWindows.length > 0) {
|
||||
const withReset = nonCodeReviewWindows
|
||||
.filter(
|
||||
(w) =>
|
||||
typeof w.resetAfterSeconds === 'number' &&
|
||||
isFinite(w.resetAfterSeconds) &&
|
||||
w.resetAfterSeconds >= 0
|
||||
)
|
||||
.sort((a, b) => (a.resetAfterSeconds || 0) - (b.resetAfterSeconds || 0));
|
||||
|
||||
if (!fiveHourWindow) {
|
||||
fiveHourWindow = withReset[0] || nonCodeReviewWindows[0] || null;
|
||||
}
|
||||
|
||||
if (!weeklyWindow) {
|
||||
weeklyWindow =
|
||||
withReset.length > 1
|
||||
? withReset[withReset.length - 1]
|
||||
: nonCodeReviewWindows.find((w) => w !== fiveHourWindow) || null;
|
||||
}
|
||||
}
|
||||
|
||||
const mapWindow = (window: CodexQuotaWindow | null): CodexCoreUsageSummary['fiveHour'] => {
|
||||
if (!window) return null;
|
||||
return {
|
||||
label: window.label,
|
||||
remainingPercent: window.remainingPercent,
|
||||
resetAfterSeconds: window.resetAfterSeconds,
|
||||
resetAt: window.resetAt,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
fiveHour: mapWindow(fiveHourWindow),
|
||||
weekly: mapWindow(weeklyWindow),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read auth data from Codex auth file
|
||||
*/
|
||||
@@ -295,6 +388,7 @@ export async function fetchCodexQuota(
|
||||
|
||||
const data = (await response.json()) as CodexUsageResponse;
|
||||
const windows = buildCodexQuotaWindows(data);
|
||||
const coreUsage = buildCodexCoreUsageSummary(windows);
|
||||
|
||||
// Extract plan type
|
||||
const planTypeRaw = data.plan_type || data.planType;
|
||||
@@ -311,6 +405,7 @@ export async function fetchCodexQuota(
|
||||
return {
|
||||
success: true,
|
||||
windows,
|
||||
coreUsage,
|
||||
planType,
|
||||
lastUpdated: Date.now(),
|
||||
accountId,
|
||||
|
||||
@@ -27,6 +27,26 @@ export interface CodexQuotaWindow {
|
||||
resetAt: string | null;
|
||||
}
|
||||
|
||||
/** Core Codex usage window (5h/weekly) extracted from raw windows */
|
||||
export interface CodexCoreUsageWindow {
|
||||
/** Source window label */
|
||||
label: string;
|
||||
/** Percentage remaining (0-100) */
|
||||
remainingPercent: number;
|
||||
/** Seconds until quota resets, null if unknown */
|
||||
resetAfterSeconds: number | null;
|
||||
/** ISO timestamp when quota resets, null if unknown */
|
||||
resetAt: string | null;
|
||||
}
|
||||
|
||||
/** Core Codex usage summary with explicit 5h and weekly windows */
|
||||
export interface CodexCoreUsageSummary {
|
||||
/** Short-cycle usage limit window (typically 5h) */
|
||||
fiveHour: CodexCoreUsageWindow | null;
|
||||
/** Long-cycle usage limit window (typically weekly) */
|
||||
weekly: CodexCoreUsageWindow | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex quota fetch result
|
||||
*/
|
||||
@@ -35,6 +55,8 @@ export interface CodexQuotaResult {
|
||||
success: boolean;
|
||||
/** Quota windows (primary, secondary, code review) */
|
||||
windows: CodexQuotaWindow[];
|
||||
/** Explicit core usage windows (5h + weekly) for easier reset display */
|
||||
coreUsage?: CodexCoreUsageSummary;
|
||||
/** Plan type: free, plus, team, or null if unknown */
|
||||
planType: 'free' | 'plus' | 'team' | null;
|
||||
/** Timestamp of fetch */
|
||||
|
||||
@@ -54,7 +54,7 @@ export async function showHelp(): Promise<void> {
|
||||
['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 providers'],
|
||||
['quota', 'Show quota status for all providers (Codex includes 5h + weekly reset)'],
|
||||
['quota --provider <name>', 'Filter by provider (agy|codex|gemini)'],
|
||||
],
|
||||
],
|
||||
|
||||
@@ -67,7 +67,13 @@ function formatResetTime(seconds: number): string {
|
||||
if (seconds <= 0) return 'now';
|
||||
if (seconds < 60) return `in ${seconds}s`;
|
||||
if (seconds < 3600) return `in ${Math.round(seconds / 60)}m`;
|
||||
return `in ${Math.round(seconds / 3600)}h`;
|
||||
if (seconds < 86400) return `in ${Math.round(seconds / 3600)}h`;
|
||||
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.round((seconds % 86400) / 3600);
|
||||
if (hours <= 0) return `in ${days}d`;
|
||||
if (hours >= 24) return `in ${days + 1}d`;
|
||||
return `in ${days}d ${hours}h`;
|
||||
}
|
||||
|
||||
function formatResetTimeISO(isoTime: string): string {
|
||||
@@ -78,6 +84,40 @@ function formatResetTimeISO(isoTime: string): string {
|
||||
return formatResetTime(seconds);
|
||||
}
|
||||
|
||||
function formatAbsoluteResetTime(isoTime: string): string | null {
|
||||
if (!isoTime) return null;
|
||||
const resetDate = new Date(isoTime);
|
||||
if (isNaN(resetDate.getTime())) return null;
|
||||
const date = resetDate.toLocaleDateString(undefined, {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
const time = resetDate.toLocaleTimeString(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
return `${date} ${time}`;
|
||||
}
|
||||
|
||||
function formatCodexWindowReset(
|
||||
window: Pick<CodexQuotaResult['windows'][number], 'resetAfterSeconds' | 'resetAt'>
|
||||
): string | null {
|
||||
if (typeof window.resetAfterSeconds === 'number' && isFinite(window.resetAfterSeconds)) {
|
||||
const relative = formatResetTime(Math.max(0, window.resetAfterSeconds));
|
||||
if (window.resetAfterSeconds >= 86400 && window.resetAt) {
|
||||
const absolute = formatAbsoluteResetTime(window.resetAt);
|
||||
return absolute ? `${relative} (${absolute})` : relative;
|
||||
}
|
||||
return relative;
|
||||
}
|
||||
|
||||
if (window.resetAt) {
|
||||
return formatResetTimeISO(window.resetAt);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
type CodexWindowKind =
|
||||
| 'usage-5h'
|
||||
| 'usage-weekly'
|
||||
@@ -286,15 +326,45 @@ function displayCodexQuotaSection(results: { account: string; quota: CodexQuotaR
|
||||
|
||||
console.log(` ${statusIcon}${account}${defaultMark}${planBadge}`);
|
||||
|
||||
const coreUsageSummary = quota.coreUsage ?? {
|
||||
fiveHour: fiveHourWindow
|
||||
? {
|
||||
label: fiveHourWindow.label,
|
||||
remainingPercent: fiveHourWindow.remainingPercent,
|
||||
resetAfterSeconds: fiveHourWindow.resetAfterSeconds,
|
||||
resetAt: fiveHourWindow.resetAt,
|
||||
}
|
||||
: null,
|
||||
weekly: weeklyWindow
|
||||
? {
|
||||
label: weeklyWindow.label,
|
||||
remainingPercent: weeklyWindow.remainingPercent,
|
||||
resetAfterSeconds: weeklyWindow.resetAfterSeconds,
|
||||
resetAt: weeklyWindow.resetAt,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
const resetParts: string[] = [];
|
||||
const fiveHourReset = coreUsageSummary.fiveHour
|
||||
? formatCodexWindowReset(coreUsageSummary.fiveHour)
|
||||
: null;
|
||||
const weeklyReset = coreUsageSummary.weekly
|
||||
? formatCodexWindowReset(coreUsageSummary.weekly)
|
||||
: null;
|
||||
if (fiveHourReset) resetParts.push(`5h ${fiveHourReset}`);
|
||||
if (weeklyReset) resetParts.push(`weekly ${weeklyReset}`);
|
||||
if (resetParts.length > 0) {
|
||||
console.log(` ${dim(`Reset schedule: ${resetParts.join(' | ')}`)}`);
|
||||
}
|
||||
|
||||
const orderedWindows = [fiveHourWindow, weeklyWindow, ...quota.windows].filter(
|
||||
(w, index, arr): w is NonNullable<typeof w> => !!w && arr.indexOf(w) === index
|
||||
);
|
||||
|
||||
for (const window of orderedWindows) {
|
||||
const bar = formatQuotaBar(window.remainingPercent);
|
||||
const resetLabel = window.resetAfterSeconds
|
||||
? dim(` Resets ${formatResetTime(window.resetAfterSeconds)}`)
|
||||
: '';
|
||||
const resetValue = formatCodexWindowReset(window);
|
||||
const resetLabel = resetValue ? dim(` Resets ${resetValue}`) : '';
|
||||
console.log(
|
||||
` ${getCodexWindowDisplayLabel(window, orderedWindows).padEnd(24)} ${bar} ${window.remainingPercent.toFixed(0)}%${resetLabel}`
|
||||
);
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { buildCodexQuotaWindows } from '../../../src/cliproxy/quota-fetcher-codex';
|
||||
import {
|
||||
buildCodexQuotaWindows,
|
||||
buildCodexCoreUsageSummary,
|
||||
} from '../../../src/cliproxy/quota-fetcher-codex';
|
||||
|
||||
describe('Codex Quota Fetcher', () => {
|
||||
describe('buildCodexQuotaWindows', () => {
|
||||
@@ -187,4 +190,69 @@ describe('Codex Quota Fetcher', () => {
|
||||
expect(windows[0].remainingPercent).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCodexCoreUsageSummary', () => {
|
||||
it('extracts 5h and weekly windows from labeled usage windows', () => {
|
||||
const windows = buildCodexQuotaWindows({
|
||||
rate_limit: {
|
||||
primary_window: {
|
||||
used_percent: 35,
|
||||
reset_after_seconds: 18000,
|
||||
},
|
||||
secondary_window: {
|
||||
used_percent: 60,
|
||||
reset_after_seconds: 604800,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const summary = buildCodexCoreUsageSummary(windows);
|
||||
|
||||
expect(summary.fiveHour?.label).toBe('Primary');
|
||||
expect(summary.fiveHour?.remainingPercent).toBe(65);
|
||||
expect(summary.fiveHour?.resetAfterSeconds).toBe(18000);
|
||||
expect(summary.weekly?.label).toBe('Secondary');
|
||||
expect(summary.weekly?.remainingPercent).toBe(40);
|
||||
expect(summary.weekly?.resetAfterSeconds).toBe(604800);
|
||||
});
|
||||
|
||||
it('falls back to shortest and longest reset windows when labels are unknown', () => {
|
||||
const windows = [
|
||||
{
|
||||
label: 'Window A',
|
||||
usedPercent: 20,
|
||||
remainingPercent: 80,
|
||||
resetAfterSeconds: 18000,
|
||||
resetAt: '2026-02-15T15:00:00Z',
|
||||
},
|
||||
{
|
||||
label: 'Window B',
|
||||
usedPercent: 45,
|
||||
remainingPercent: 55,
|
||||
resetAfterSeconds: 604800,
|
||||
resetAt: '2026-02-21T10:00:00Z',
|
||||
},
|
||||
{
|
||||
label: 'Code Review (Primary)',
|
||||
usedPercent: 10,
|
||||
remainingPercent: 90,
|
||||
resetAfterSeconds: 3600,
|
||||
resetAt: '2026-02-15T11:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
const summary = buildCodexCoreUsageSummary(windows);
|
||||
|
||||
expect(summary.fiveHour?.label).toBe('Window A');
|
||||
expect(summary.fiveHour?.resetAfterSeconds).toBe(18000);
|
||||
expect(summary.weekly?.label).toBe('Window B');
|
||||
expect(summary.weekly?.resetAfterSeconds).toBe(604800);
|
||||
});
|
||||
|
||||
it('returns null summaries when no windows are available', () => {
|
||||
const summary = buildCodexCoreUsageSummary([]);
|
||||
expect(summary.fiveHour).toBeNull();
|
||||
expect(summary.weekly).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +68,8 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
||||
if (isCodexQuotaResult(quota)) {
|
||||
const { fiveHourWindow, weeklyWindow, codeReviewWindows, unknownWindows } =
|
||||
getCodexQuotaBreakdown(quota.windows);
|
||||
const fiveHourResetAt = quota.coreUsage?.fiveHour?.resetAt ?? fiveHourWindow?.resetAt ?? null;
|
||||
const weeklyResetAt = quota.coreUsage?.weekly?.resetAt ?? weeklyWindow?.resetAt ?? null;
|
||||
const orderedWindows = [fiveHourWindow, weeklyWindow, ...codeReviewWindows, ...unknownWindows]
|
||||
.filter((w): w is NonNullable<typeof w> => !!w)
|
||||
.filter(
|
||||
@@ -92,7 +94,11 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
|
||||
<span className="font-mono">{w.remainingPercent}%</span>
|
||||
</div>
|
||||
))}
|
||||
<ResetTimeIndicator resetTime={resetTime} />
|
||||
<CodexResetIndicators
|
||||
fiveHourResetTime={fiveHourResetAt}
|
||||
weeklyResetTime={weeklyResetAt}
|
||||
fallbackResetTime={resetTime}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -132,3 +138,40 @@ function ResetTimeIndicator({ resetTime }: { resetTime: string | null }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodexResetIndicators({
|
||||
fiveHourResetTime,
|
||||
weeklyResetTime,
|
||||
fallbackResetTime,
|
||||
}: {
|
||||
fiveHourResetTime: string | null;
|
||||
weeklyResetTime: string | null;
|
||||
fallbackResetTime: string | null;
|
||||
}) {
|
||||
const hasSpecificReset = !!fiveHourResetTime || !!weeklyResetTime;
|
||||
if (!hasSpecificReset && !fallbackResetTime) return null;
|
||||
|
||||
return (
|
||||
<div className="pt-1 border-t border-border/50 space-y-1">
|
||||
{fiveHourResetTime && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="w-3 h-3 text-blue-400" />
|
||||
<span className="text-blue-400 font-medium">
|
||||
5h resets {formatResetTime(fiveHourResetTime)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{weeklyResetTime && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="w-3 h-3 text-indigo-400" />
|
||||
<span className="text-indigo-400 font-medium">
|
||||
Weekly resets {formatResetTime(weeklyResetTime)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!hasSpecificReset && fallbackResetTime && (
|
||||
<ResetTimeIndicator resetTime={fallbackResetTime} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -183,12 +183,34 @@ export interface CodexQuotaWindow {
|
||||
resetAt: string | null;
|
||||
}
|
||||
|
||||
/** Core Codex usage window (5h/weekly) extracted from raw windows */
|
||||
export interface CodexCoreUsageWindow {
|
||||
/** Source window label */
|
||||
label: string;
|
||||
/** Percentage remaining (0-100) */
|
||||
remainingPercent: number;
|
||||
/** Seconds until quota resets, null if unknown */
|
||||
resetAfterSeconds: number | null;
|
||||
/** ISO timestamp when quota resets, null if unknown */
|
||||
resetAt: string | null;
|
||||
}
|
||||
|
||||
/** Core Codex usage summary with explicit 5h and weekly windows */
|
||||
export interface CodexCoreUsageSummary {
|
||||
/** Short-cycle usage limit window (typically 5h) */
|
||||
fiveHour: CodexCoreUsageWindow | null;
|
||||
/** Long-cycle usage limit window (typically weekly) */
|
||||
weekly: CodexCoreUsageWindow | null;
|
||||
}
|
||||
|
||||
/** Codex quota result */
|
||||
export interface CodexQuotaResult {
|
||||
/** Whether fetch succeeded */
|
||||
success: boolean;
|
||||
/** Quota windows (primary, secondary, code review) */
|
||||
windows: CodexQuotaWindow[];
|
||||
/** Explicit core usage windows (5h + weekly) for easier reset display */
|
||||
coreUsage?: CodexCoreUsageSummary;
|
||||
/** Plan type: free, plus, team, or null if unknown */
|
||||
planType: 'free' | 'plus' | 'team' | null;
|
||||
/** Timestamp of fetch */
|
||||
|
||||
Reference in New Issue
Block a user