mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 08:15:17 +00:00
Merge pull request #246 from kaitranntt/dev
feat(release): kiro/ghcp provider discovery + claude-first quota bar
This commit is contained in:
8 files changed
+647
-37
No files matched your search
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kaitranntt/ccs",
|
||||
"version": "7.12.1",
|
||||
"version": "7.12.1-dev.2",
|
||||
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
@@ -12,6 +12,7 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { CLIProxyProvider } from './types';
|
||||
import { getCliproxyDir, getAuthDir } from './config-generator';
|
||||
import { PROVIDER_TYPE_VALUES } from './auth/auth-types';
|
||||
|
||||
/** Account information */
|
||||
export interface AccountInfo {
|
||||
@@ -431,26 +432,33 @@ export function discoverExistingAccounts(): void {
|
||||
// Skip if no type field
|
||||
if (!data.type) continue;
|
||||
|
||||
// Map token type values to internal provider names
|
||||
// CLIProxyAPI uses different type values in tokens (e.g., "antigravity" vs "agy")
|
||||
const typeToProvider: Record<string, CLIProxyProvider> = {
|
||||
gemini: 'gemini',
|
||||
antigravity: 'agy',
|
||||
codex: 'codex',
|
||||
qwen: 'qwen',
|
||||
iflow: 'iflow',
|
||||
};
|
||||
|
||||
// Build reverse mapping from PROVIDER_TYPE_VALUES (type value -> provider)
|
||||
// e.g., "antigravity" -> "agy", "kiro" -> "kiro", "codewhisperer" -> "kiro"
|
||||
const typeValue = data.type.toLowerCase();
|
||||
const provider = typeToProvider[typeValue];
|
||||
let provider: CLIProxyProvider | undefined;
|
||||
for (const [prov, typeValues] of Object.entries(PROVIDER_TYPE_VALUES)) {
|
||||
if (typeValues.includes(typeValue)) {
|
||||
provider = prov as CLIProxyProvider;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if unknown provider type
|
||||
if (!provider) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract email if available
|
||||
const email = data.email || undefined;
|
||||
// Extract email if available, fallback to filename
|
||||
let email = data.email || undefined;
|
||||
|
||||
// Fallback: extract email from filename (e.g., "kiro-google-user@example.com.json")
|
||||
if (!email && file.includes('@')) {
|
||||
const match = file.match(/([^-]+@[^.]+\.[^.]+)(?=\.json$)/);
|
||||
if (match) {
|
||||
email = match[1];
|
||||
}
|
||||
}
|
||||
|
||||
const accountId = email || 'default';
|
||||
|
||||
// Initialize provider section if needed
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* Account Manager Discovery Tests
|
||||
*
|
||||
* Tests for discoverExistingAccounts() function including:
|
||||
* - Kiro/GHCP provider mapping
|
||||
* - Email extraction from filename
|
||||
* - Multiple accounts per provider
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
describe('Account Manager - discoverExistingAccounts', () => {
|
||||
let testDir;
|
||||
let originalCcsHome;
|
||||
let accountManager;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create temp directory for test
|
||||
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-'));
|
||||
const ccsDir = path.join(testDir, '.ccs', 'cliproxy');
|
||||
const authDir = path.join(ccsDir, 'auth');
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
|
||||
// Mock CCS_HOME to use temp directory
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = testDir;
|
||||
|
||||
// Clear cache and reload
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/account-manager')];
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/config-generator')];
|
||||
delete require.cache[require.resolve('../../../dist/utils/config-manager')];
|
||||
accountManager = require('../../../dist/cliproxy/account-manager');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore CCS_HOME
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
|
||||
// Cleanup temp directory
|
||||
fs.rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function createAuthFile(filename, content) {
|
||||
const authDir = path.join(testDir, '.ccs', 'cliproxy', 'auth');
|
||||
fs.writeFileSync(path.join(authDir, filename), JSON.stringify(content));
|
||||
}
|
||||
|
||||
function getAccountsFile() {
|
||||
const accountsPath = path.join(testDir, '.ccs', 'cliproxy', 'accounts.json');
|
||||
if (fs.existsSync(accountsPath)) {
|
||||
return JSON.parse(fs.readFileSync(accountsPath, 'utf-8'));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Provider Type Mapping Tests
|
||||
// =========================================================================
|
||||
describe('Provider Type Mapping', () => {
|
||||
it('discovers kiro accounts with type="kiro"', () => {
|
||||
createAuthFile('kiro-google-user@example.com.json', {
|
||||
type: 'kiro',
|
||||
email: '',
|
||||
provider: 'Google',
|
||||
profile_arn: 'arn:aws:codewhisperer:us-east-1::profile/123',
|
||||
});
|
||||
|
||||
accountManager.discoverExistingAccounts();
|
||||
const accounts = getAccountsFile();
|
||||
|
||||
assert(accounts, 'accounts.json should be created');
|
||||
assert(accounts.providers.kiro, 'kiro provider should exist');
|
||||
assert.strictEqual(
|
||||
Object.keys(accounts.providers.kiro.accounts).length,
|
||||
1,
|
||||
'Should have 1 kiro account'
|
||||
);
|
||||
});
|
||||
|
||||
it('discovers kiro accounts with type="codewhisperer"', () => {
|
||||
createAuthFile('codewhisperer-user@example.com.json', {
|
||||
type: 'codewhisperer',
|
||||
email: 'user@example.com',
|
||||
});
|
||||
|
||||
accountManager.discoverExistingAccounts();
|
||||
const accounts = getAccountsFile();
|
||||
|
||||
assert(accounts.providers.kiro, 'codewhisperer type should map to kiro provider');
|
||||
});
|
||||
|
||||
it('discovers ghcp accounts with type="github-copilot"', () => {
|
||||
createAuthFile('ghcp-user@example.com.json', {
|
||||
type: 'github-copilot',
|
||||
email: 'user@example.com',
|
||||
});
|
||||
|
||||
accountManager.discoverExistingAccounts();
|
||||
const accounts = getAccountsFile();
|
||||
|
||||
assert(accounts.providers.ghcp, 'github-copilot type should map to ghcp provider');
|
||||
});
|
||||
|
||||
it('discovers ghcp accounts with type="copilot"', () => {
|
||||
createAuthFile('copilot-user@example.com.json', {
|
||||
type: 'copilot',
|
||||
email: 'user@example.com',
|
||||
});
|
||||
|
||||
accountManager.discoverExistingAccounts();
|
||||
const accounts = getAccountsFile();
|
||||
|
||||
assert(accounts.providers.ghcp, 'copilot type should map to ghcp provider');
|
||||
});
|
||||
|
||||
it('handles case-insensitive type values', () => {
|
||||
createAuthFile('kiro-test.json', {
|
||||
type: 'KIRO',
|
||||
email: 'test@example.com',
|
||||
});
|
||||
|
||||
accountManager.discoverExistingAccounts();
|
||||
const accounts = getAccountsFile();
|
||||
|
||||
assert(accounts.providers.kiro, 'KIRO (uppercase) should map to kiro provider');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Email Extraction Tests
|
||||
// =========================================================================
|
||||
describe('Email Extraction from Filename', () => {
|
||||
it('extracts email from filename when data.email is empty', () => {
|
||||
createAuthFile('kiro-google-user@example.com.json', {
|
||||
type: 'kiro',
|
||||
email: '',
|
||||
provider: 'Google',
|
||||
});
|
||||
|
||||
accountManager.discoverExistingAccounts();
|
||||
const accounts = getAccountsFile();
|
||||
|
||||
const accountIds = Object.keys(accounts.providers.kiro.accounts);
|
||||
assert.strictEqual(accountIds[0], 'user@example.com', 'Should extract email from filename');
|
||||
});
|
||||
|
||||
it('uses data.email when available', () => {
|
||||
createAuthFile('kiro-test.json', {
|
||||
type: 'kiro',
|
||||
email: 'actual@email.com',
|
||||
});
|
||||
|
||||
accountManager.discoverExistingAccounts();
|
||||
const accounts = getAccountsFile();
|
||||
|
||||
const accountIds = Object.keys(accounts.providers.kiro.accounts);
|
||||
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', () => {
|
||||
createAuthFile('kiro-nomail.json', {
|
||||
type: 'kiro',
|
||||
email: '',
|
||||
});
|
||||
|
||||
accountManager.discoverExistingAccounts();
|
||||
const accounts = getAccountsFile();
|
||||
|
||||
const accountIds = Object.keys(accounts.providers.kiro.accounts);
|
||||
assert.strictEqual(accountIds[0], 'default', 'Should fall back to "default"');
|
||||
});
|
||||
|
||||
it('handles dots in email local part', () => {
|
||||
createAuthFile('kiro-google-first.last@example.com.json', {
|
||||
type: 'kiro',
|
||||
email: '',
|
||||
});
|
||||
|
||||
accountManager.discoverExistingAccounts();
|
||||
const accounts = getAccountsFile();
|
||||
|
||||
const accountIds = Object.keys(accounts.providers.kiro.accounts);
|
||||
assert.strictEqual(
|
||||
accountIds[0],
|
||||
'first.last@example.com',
|
||||
'Should handle dots in local part'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles plus addressing in email', () => {
|
||||
createAuthFile('kiro-google-user+tag@example.com.json', {
|
||||
type: 'kiro',
|
||||
email: '',
|
||||
});
|
||||
|
||||
accountManager.discoverExistingAccounts();
|
||||
const accounts = getAccountsFile();
|
||||
|
||||
const accountIds = Object.keys(accounts.providers.kiro.accounts);
|
||||
assert.strictEqual(accountIds[0], 'user+tag@example.com', 'Should handle plus addressing');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Multiple Accounts Tests
|
||||
// =========================================================================
|
||||
describe('Multiple Accounts', () => {
|
||||
it('discovers multiple kiro accounts', () => {
|
||||
createAuthFile('kiro-google-user1@example.com.json', {
|
||||
type: 'kiro',
|
||||
email: '',
|
||||
});
|
||||
createAuthFile('kiro-google-user2@example.com.json', {
|
||||
type: 'kiro',
|
||||
email: '',
|
||||
});
|
||||
|
||||
accountManager.discoverExistingAccounts();
|
||||
const accounts = getAccountsFile();
|
||||
|
||||
assert.strictEqual(
|
||||
Object.keys(accounts.providers.kiro.accounts).length,
|
||||
2,
|
||||
'Should discover 2 kiro accounts'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not overwrite existing accounts', () => {
|
||||
// Create first account
|
||||
createAuthFile('kiro-google-user1@example.com.json', {
|
||||
type: 'kiro',
|
||||
email: '',
|
||||
});
|
||||
accountManager.discoverExistingAccounts();
|
||||
|
||||
// Add another file and run again
|
||||
createAuthFile('kiro-google-user2@example.com.json', {
|
||||
type: 'kiro',
|
||||
email: '',
|
||||
});
|
||||
|
||||
// Reload module to clear in-memory cache
|
||||
delete require.cache[require.resolve('../../../dist/cliproxy/account-manager')];
|
||||
accountManager = require('../../../dist/cliproxy/account-manager');
|
||||
accountManager.discoverExistingAccounts();
|
||||
|
||||
const accounts = getAccountsFile();
|
||||
assert.strictEqual(
|
||||
Object.keys(accounts.providers.kiro.accounts).length,
|
||||
2,
|
||||
'Should have 2 accounts without overwriting'
|
||||
);
|
||||
});
|
||||
|
||||
it('sets first discovered account as default', () => {
|
||||
createAuthFile('kiro-google-user@example.com.json', {
|
||||
type: 'kiro',
|
||||
email: '',
|
||||
});
|
||||
|
||||
accountManager.discoverExistingAccounts();
|
||||
const accounts = getAccountsFile();
|
||||
|
||||
assert.strictEqual(
|
||||
accounts.providers.kiro.default,
|
||||
'user@example.com',
|
||||
'First account should be default'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Edge Cases
|
||||
// =========================================================================
|
||||
describe('Edge Cases', () => {
|
||||
it('skips files without type field', () => {
|
||||
createAuthFile('invalid.json', {
|
||||
email: 'test@example.com',
|
||||
});
|
||||
|
||||
accountManager.discoverExistingAccounts();
|
||||
const accounts = getAccountsFile();
|
||||
|
||||
// Should either be null or have no providers
|
||||
if (accounts) {
|
||||
assert.strictEqual(Object.keys(accounts.providers).length, 0, 'Should have no providers');
|
||||
}
|
||||
});
|
||||
|
||||
it('skips files with unknown type', () => {
|
||||
createAuthFile('unknown.json', {
|
||||
type: 'unknown-provider',
|
||||
email: 'test@example.com',
|
||||
});
|
||||
|
||||
accountManager.discoverExistingAccounts();
|
||||
const accounts = getAccountsFile();
|
||||
|
||||
if (accounts) {
|
||||
assert.strictEqual(
|
||||
Object.keys(accounts.providers).length,
|
||||
0,
|
||||
'Should skip unknown providers'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('skips non-JSON files', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(testDir, '.ccs', 'cliproxy', 'auth', 'readme.txt'),
|
||||
'This is not JSON'
|
||||
);
|
||||
|
||||
accountManager.discoverExistingAccounts();
|
||||
const accounts = getAccountsFile();
|
||||
|
||||
// Should not throw, should skip the file
|
||||
if (accounts) {
|
||||
assert.strictEqual(Object.keys(accounts.providers).length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
it('handles malformed JSON gracefully', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(testDir, '.ccs', 'cliproxy', 'auth', 'bad.json'),
|
||||
'{not valid json'
|
||||
);
|
||||
|
||||
// Should not throw
|
||||
assert.doesNotThrow(() => {
|
||||
accountManager.discoverExistingAccounts();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles missing auth directory', () => {
|
||||
// Remove auth directory
|
||||
fs.rmSync(path.join(testDir, '.ccs', 'cliproxy', 'auth'), { recursive: true });
|
||||
|
||||
// Should not throw
|
||||
assert.doesNotThrow(() => {
|
||||
accountManager.discoverExistingAccounts();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,13 @@
|
||||
* Account Card Component for Flow Visualization
|
||||
*/
|
||||
|
||||
import { cn, sortModelsByPriority, formatResetTime, getEarliestResetTime } from '@/lib/utils';
|
||||
import {
|
||||
cn,
|
||||
sortModelsByPriority,
|
||||
formatResetTime,
|
||||
getEarliestResetTime,
|
||||
getMinClaudeQuota,
|
||||
} from '@/lib/utils';
|
||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import { GripVertical, Loader2, Clock } from 'lucide-react';
|
||||
import { useAccountQuota } from '@/hooks/use-cliproxy-stats';
|
||||
@@ -83,10 +89,8 @@ export function AccountCard({
|
||||
account.id,
|
||||
isAgy
|
||||
);
|
||||
const avgQuota =
|
||||
quota?.success && quota.models.length > 0
|
||||
? Math.round(quota.models.reduce((sum, m) => sum + m.percentage, 0) / quota.models.length)
|
||||
: null;
|
||||
// Show minimum quota of Claude models (primary), fallback to min of all models
|
||||
const minQuota = quota?.success ? getMinClaudeQuota(quota.models) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -136,7 +140,7 @@ export function AccountCard({
|
||||
<Loader2 className="w-2.5 h-2.5 animate-spin" />
|
||||
<span>Quota...</span>
|
||||
</div>
|
||||
) : avgQuota !== null ? (
|
||||
) : minQuota !== null ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -148,27 +152,27 @@ export function AccountCard({
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-mono font-bold',
|
||||
avgQuota > 50
|
||||
minQuota > 50
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: avgQuota > 20
|
||||
: minQuota > 20
|
||||
? 'text-amber-500'
|
||||
: 'text-red-500'
|
||||
)}
|
||||
>
|
||||
{avgQuota}%
|
||||
{minQuota}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-muted dark:bg-zinc-800/50 h-1 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all',
|
||||
avgQuota > 50
|
||||
minQuota > 50
|
||||
? 'bg-emerald-500'
|
||||
: avgQuota > 20
|
||||
: minQuota > 20
|
||||
? 'bg-amber-500'
|
||||
: 'bg-red-500'
|
||||
)}
|
||||
style={{ width: `${avgQuota}%` }}
|
||||
style={{ width: `${minQuota}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,7 +23,13 @@ import {
|
||||
CheckCircle2,
|
||||
HelpCircle,
|
||||
} from 'lucide-react';
|
||||
import { cn, sortModelsByPriority, formatResetTime, getEarliestResetTime } from '@/lib/utils';
|
||||
import {
|
||||
cn,
|
||||
sortModelsByPriority,
|
||||
formatResetTime,
|
||||
getEarliestResetTime,
|
||||
getMinClaudeQuota,
|
||||
} from '@/lib/utils';
|
||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||
import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats';
|
||||
import type { AccountItemProps } from './types';
|
||||
@@ -98,11 +104,8 @@ export function AccountItem({
|
||||
const runtimeLastUsed = stats?.accountStats?.[account.email || account.id]?.lastUsedAt;
|
||||
const wasRecentlyUsed = isRecentlyUsed(runtimeLastUsed);
|
||||
|
||||
// Calculate average quota across all models
|
||||
const avgQuota =
|
||||
quota?.success && quota.models.length > 0
|
||||
? Math.round(quota.models.reduce((sum, m) => sum + m.percentage, 0) / quota.models.length)
|
||||
: null;
|
||||
// Show minimum quota of Claude models (primary), fallback to min of all models
|
||||
const minQuota = quota?.success ? getMinClaudeQuota(quota.models) : null;
|
||||
|
||||
// Get earliest reset time
|
||||
const nextReset =
|
||||
@@ -179,7 +182,7 @@ export function AccountItem({
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
<span>Loading quota...</span>
|
||||
</div>
|
||||
) : avgQuota !== null ? (
|
||||
) : minQuota !== null ? (
|
||||
<div className="space-y-1.5">
|
||||
{/* Status indicator based on runtime usage, not file state */}
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
@@ -210,11 +213,11 @@ export function AccountItem({
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress
|
||||
value={avgQuota}
|
||||
value={minQuota}
|
||||
className="h-2 flex-1"
|
||||
indicatorClassName={getQuotaColor(avgQuota)}
|
||||
indicatorClassName={getQuotaColor(minQuota)}
|
||||
/>
|
||||
<span className="text-xs font-medium w-10 text-right">{avgQuota}%</span>
|
||||
<span className="text-xs font-medium w-10 text-right">{minQuota}%</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-xs">
|
||||
|
||||
@@ -23,7 +23,8 @@ const badgeVariants = cva(
|
||||
);
|
||||
|
||||
interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
|
||||
@@ -116,3 +116,27 @@ export function getEarliestResetTime<T extends { resetTime: string | null }>(
|
||||
null as string | null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the minimum quota percentage from Claude models (primary usage).
|
||||
* Falls back to minimum of all models if no Claude models exist.
|
||||
* Returns null if no valid models or quota data.
|
||||
*/
|
||||
export function getMinClaudeQuota<
|
||||
T extends { name: string; displayName?: string; percentage: number },
|
||||
>(models: T[]): number | null {
|
||||
if (models.length === 0) return null;
|
||||
|
||||
const claudeModels = models.filter((m) => {
|
||||
const name = (m.displayName || m.name || '').toLowerCase();
|
||||
return name.includes('claude');
|
||||
});
|
||||
|
||||
const targetModels = claudeModels.length > 0 ? claudeModels : models;
|
||||
const percentages = targetModels
|
||||
.map((m) => m.percentage)
|
||||
.filter((p) => typeof p === 'number' && isFinite(p));
|
||||
|
||||
if (percentages.length === 0) return null;
|
||||
return Math.min(...percentages);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Tests for quota utility functions
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getMinClaudeQuota, sortModelsByPriority, getEarliestResetTime } from '@/lib/utils';
|
||||
|
||||
describe('getMinClaudeQuota', () => {
|
||||
describe('basic functionality', () => {
|
||||
it('returns null for empty models array', () => {
|
||||
expect(getMinClaudeQuota([])).toBeNull();
|
||||
});
|
||||
|
||||
it('returns minimum of Claude models when present', () => {
|
||||
const models = [
|
||||
{ name: 'claude-opus-4', displayName: 'Claude Opus 4', percentage: 95 },
|
||||
{ name: 'claude-sonnet-4', displayName: 'Claude Sonnet 4', percentage: 90 },
|
||||
{ name: 'gemini-2.5-flash', displayName: 'Gemini 2.5 Flash', percentage: 100 },
|
||||
];
|
||||
expect(getMinClaudeQuota(models)).toBe(90);
|
||||
});
|
||||
|
||||
it('falls back to minimum of all models when no Claude models', () => {
|
||||
const models = [
|
||||
{ name: 'gemini-2.5-flash', displayName: 'Gemini 2.5 Flash', percentage: 100 },
|
||||
{ name: 'gemini-3-pro', displayName: 'Gemini 3 Pro', percentage: 98 },
|
||||
];
|
||||
expect(getMinClaudeQuota(models)).toBe(98);
|
||||
});
|
||||
|
||||
it('handles single Claude model', () => {
|
||||
const models = [{ name: 'claude-opus-4', displayName: 'Claude Opus 4', percentage: 85 }];
|
||||
expect(getMinClaudeQuota(models)).toBe(85);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Claude detection', () => {
|
||||
it('detects Claude from displayName', () => {
|
||||
const models = [
|
||||
{ name: 'model-abc', displayName: 'Claude Opus 4.5', percentage: 75 },
|
||||
{ name: 'gemini-flash', displayName: 'Gemini Flash', percentage: 100 },
|
||||
];
|
||||
expect(getMinClaudeQuota(models)).toBe(75);
|
||||
});
|
||||
|
||||
it('detects Claude from name when displayName is missing', () => {
|
||||
const models = [
|
||||
{ name: 'claude-sonnet-4-thinking', percentage: 80 },
|
||||
{ name: 'gemini-2.5-flash', percentage: 100 },
|
||||
];
|
||||
expect(getMinClaudeQuota(models)).toBe(80);
|
||||
});
|
||||
|
||||
it('is case-insensitive for Claude detection', () => {
|
||||
const models = [
|
||||
{ name: 'CLAUDE-OPUS', displayName: 'CLAUDE OPUS', percentage: 70 },
|
||||
{ name: 'gemini', displayName: 'Gemini', percentage: 100 },
|
||||
];
|
||||
expect(getMinClaudeQuota(models)).toBe(70);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles models with 0% quota', () => {
|
||||
const models = [
|
||||
{ name: 'claude-opus', displayName: 'Claude Opus', percentage: 0 },
|
||||
{ name: 'claude-sonnet', displayName: 'Claude Sonnet', percentage: 50 },
|
||||
];
|
||||
expect(getMinClaudeQuota(models)).toBe(0);
|
||||
});
|
||||
|
||||
it('handles models with 100% quota', () => {
|
||||
const models = [{ name: 'claude-opus', displayName: 'Claude Opus', percentage: 100 }];
|
||||
expect(getMinClaudeQuota(models)).toBe(100);
|
||||
});
|
||||
|
||||
it('handles undefined displayName gracefully', () => {
|
||||
const models = [{ name: 'claude-opus', displayName: undefined, percentage: 85 }];
|
||||
expect(getMinClaudeQuota(models)).toBe(85);
|
||||
});
|
||||
|
||||
it('handles empty string displayName', () => {
|
||||
const models = [{ name: 'claude-opus', displayName: '', percentage: 85 }];
|
||||
expect(getMinClaudeQuota(models)).toBe(85);
|
||||
});
|
||||
|
||||
it('filters out NaN percentages', () => {
|
||||
const models = [
|
||||
{ name: 'claude-opus', displayName: 'Claude Opus', percentage: NaN },
|
||||
{ name: 'claude-sonnet', displayName: 'Claude Sonnet', percentage: 80 },
|
||||
];
|
||||
expect(getMinClaudeQuota(models)).toBe(80);
|
||||
});
|
||||
|
||||
it('filters out Infinity percentages', () => {
|
||||
const models = [
|
||||
{ name: 'claude-opus', displayName: 'Claude Opus', percentage: Infinity },
|
||||
{ name: 'claude-sonnet', displayName: 'Claude Sonnet', percentage: 75 },
|
||||
];
|
||||
expect(getMinClaudeQuota(models)).toBe(75);
|
||||
});
|
||||
|
||||
it('returns null when all percentages are invalid', () => {
|
||||
const models = [
|
||||
{ name: 'claude-opus', displayName: 'Claude Opus', percentage: NaN },
|
||||
{ name: 'claude-sonnet', displayName: 'Claude Sonnet', percentage: Infinity },
|
||||
];
|
||||
expect(getMinClaudeQuota(models)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('real-world scenarios', () => {
|
||||
it('matches screenshot scenario - Claude 95%, Gemini 100%', () => {
|
||||
const models = [
|
||||
{
|
||||
name: 'claude-opus-4-5-thinking',
|
||||
displayName: 'Claude Opus 4.5 (Thinking)',
|
||||
percentage: 95,
|
||||
},
|
||||
{ name: 'claude-sonnet-4-5', displayName: 'Claude Sonnet 4.5', percentage: 95 },
|
||||
{
|
||||
name: 'claude-sonnet-4-5-thinking',
|
||||
displayName: 'Claude Sonnet 4.5 (Thinking)',
|
||||
percentage: 95,
|
||||
},
|
||||
{ name: 'gemini-2-5-flash', displayName: 'Gemini 2.5 Flash', percentage: 100 },
|
||||
{
|
||||
name: 'gemini-2-5-flash-thinking',
|
||||
displayName: 'Gemini 2.5 Flash (Thinking)',
|
||||
percentage: 100,
|
||||
},
|
||||
{ name: 'gemini-2-5-flash-lite', displayName: 'Gemini 2.5 Flash Lite', percentage: 100 },
|
||||
{ name: 'gemini-2-5-pro', displayName: 'Gemini 2.5 Pro', percentage: 100 },
|
||||
{ name: 'gemini-3-flash', displayName: 'Gemini 3 Flash', percentage: 98 },
|
||||
{ name: 'gemini-3-pro-high', displayName: 'Gemini 3 Pro (High)', percentage: 100 },
|
||||
{ name: 'gemini-3-pro-low', displayName: 'Gemini 3 Pro (Low)', percentage: 100 },
|
||||
{ name: 'gpt-oss-120b', displayName: 'GPT-OSS 120B (Medium)', percentage: 95 },
|
||||
];
|
||||
// Should return 95 (min of Claude), not 99 (avg of all)
|
||||
expect(getMinClaudeQuota(models)).toBe(95);
|
||||
});
|
||||
|
||||
it('handles mixed Claude percentages', () => {
|
||||
const models = [
|
||||
{ name: 'claude-opus-4', displayName: 'Claude Opus 4', percentage: 80 },
|
||||
{ name: 'claude-sonnet-4', displayName: 'Claude Sonnet 4', percentage: 95 },
|
||||
{ name: 'claude-haiku-3', displayName: 'Claude Haiku 3', percentage: 100 },
|
||||
{ name: 'gemini-flash', displayName: 'Gemini Flash', percentage: 100 },
|
||||
];
|
||||
expect(getMinClaudeQuota(models)).toBe(80);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortModelsByPriority', () => {
|
||||
it('sorts Claude models first', () => {
|
||||
const models = [
|
||||
{ name: 'gemini-flash', displayName: 'Gemini Flash' },
|
||||
{ name: 'claude-opus', displayName: 'Claude Opus' },
|
||||
{ name: 'gpt-4', displayName: 'GPT-4' },
|
||||
];
|
||||
const sorted = sortModelsByPriority(models);
|
||||
expect(sorted[0].name).toBe('claude-opus');
|
||||
expect(sorted[1].name).toBe('gemini-flash');
|
||||
expect(sorted[2].name).toBe('gpt-4');
|
||||
});
|
||||
|
||||
it('sorts alphabetically within same priority', () => {
|
||||
const models = [
|
||||
{ name: 'claude-sonnet', displayName: 'Claude Sonnet' },
|
||||
{ name: 'claude-opus', displayName: 'Claude Opus' },
|
||||
{ name: 'claude-haiku', displayName: 'Claude Haiku' },
|
||||
];
|
||||
const sorted = sortModelsByPriority(models);
|
||||
expect(sorted[0].displayName).toBe('Claude Haiku');
|
||||
expect(sorted[1].displayName).toBe('Claude Opus');
|
||||
expect(sorted[2].displayName).toBe('Claude Sonnet');
|
||||
});
|
||||
|
||||
it('does not mutate original array', () => {
|
||||
const models = [
|
||||
{ name: 'gemini-flash', displayName: 'Gemini Flash' },
|
||||
{ name: 'claude-opus', displayName: 'Claude Opus' },
|
||||
];
|
||||
const original = [...models];
|
||||
sortModelsByPriority(models);
|
||||
expect(models).toEqual(original);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEarliestResetTime', () => {
|
||||
it('returns null for empty array', () => {
|
||||
expect(getEarliestResetTime([])).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when all resetTime are null', () => {
|
||||
const models = [{ resetTime: null }, { resetTime: null }];
|
||||
expect(getEarliestResetTime(models)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns earliest reset time', () => {
|
||||
const models = [
|
||||
{ resetTime: '2026-01-01T16:00:00Z' },
|
||||
{ resetTime: '2026-01-01T14:00:00Z' },
|
||||
{ resetTime: '2026-01-01T18:00:00Z' },
|
||||
];
|
||||
expect(getEarliestResetTime(models)).toBe('2026-01-01T14:00:00Z');
|
||||
});
|
||||
|
||||
it('handles mixed null and valid reset times', () => {
|
||||
const models = [
|
||||
{ resetTime: null },
|
||||
{ resetTime: '2026-01-01T14:00:00Z' },
|
||||
{ resetTime: null },
|
||||
];
|
||||
expect(getEarliestResetTime(models)).toBe('2026-01-01T14:00:00Z');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user