mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 12:09:03 +00:00
feat: account safety, quota monitoring, and stability fixes (#530)
* fix(cliproxy): migrate deprecated gemini-claude-* model names to upstream claude-* names (#515) * fix(cliproxy): migrate deprecated gemini-claude-* model names to upstream claude-* names CLIProxyAPI registry no longer recognizes the gemini-claude-* prefix convention. Model names in catalog, base config, and user settings are migrated to upstream claude-* names. Auto-migration in env-builder rewrites existing user settings on load and persists the change. Closes #513 * fix: address code review feedback — sync UI layer and add migration tests - Sync UI isNativeGeminiModel() with backend (remove gemini-claude- exclusion) - Update UI model catalog agy entries from gemini-claude-* to claude-* - Update CI/CD workflow and code-reviewer default model names - Add unit tests for migrateDeprecatedModelNames() logic * fix(hooks): isolate image type check before error-prone processing (#514) * fix(hooks): isolate image type check before error-prone processing Restructure processHook() into two phases so non-image Read calls never see hook error messages. Phase 1 defensively checks tool name and file extension, exiting 0 silently on any failure. Phase 2 only runs for confirmed image/PDF files where errors are relevant. Closes #511 * fix(hooks): sync image analyzer hook file on every profile launch Add installImageAnalyzerHook() call to cliproxy executor, matching the existing installWebSearchHook() pattern. This ensures the .cjs file in ~/.ccs/hooks/ gets refreshed from the npm package on every launch, so users receive hook updates after npm update. * chore(release): 7.41.0-dev.1 [skip ci] * fix(cliproxy): add fork:true for Claude model aliases in config generator (#523) Config generator now outputs fork:true for Claude model alias entries, ensuring both upstream (claude-*) and aliased (gemini-claude-*) model names appear in /v1/models listings. Also preserves fork flag when parsing user-added aliases during config regeneration. Bumps config version to v7 to trigger regeneration on next ccs doctor. Closes #522 * chore(release): 7.41.0-dev.2 [skip ci] * feat(cliproxy): add account safety guards to prevent Google account bans (#516) * feat(cliproxy): add account safety guards to prevent Google account bans Implements cross-provider isolation to prevent Google from flagging concurrent OAuth usage across different client IDs (ref: #509, #512). Three pillars: 1. Auto-pause enforcement at session launch — conflicting accounts in other Google OAuth providers are paused so CLIProxyAPI can't use them, restored on session exit with crash recovery via auto-paused.json 2. Ban/disable detection — error responses matching Google ban patterns auto-pause the affected account to prevent further damage 3. Cross-provider conflict warnings during OAuth registration Key design decisions: - PID-based session tracking for crash recovery (dead PID = restore) - Timestamp comparison prevents restoring ban-paused accounts on exit - Schema validation on auto-paused.json prevents corrupted state - Falls back to warn-only when another session is managing isolation * fix(cliproxy): address code review feedback (attempt 1/5) - Re-read auto-paused.json before write in enforceProviderIsolation to reduce concurrent write race window - Use actual email from registry for display instead of raw accountId - Export maskEmail for testability - Add 27 unit tests covering ban detection, email masking, cross-provider duplicate detection, enforcement lifecycle, crash recovery, and timestamp-guarded restore * fix(cliproxy): address remaining review feedback (attempt 2/5) - Add handleBanDetection test verifying account pause on ban error - Add warnCrossProviderDuplicates tests (true/false/non-Google) - Document PID reuse limitation in isPidAlive JSDoc comment * chore(release): 7.41.0-dev.3 [skip ci] * feat(cliproxy): runtime quota monitoring during active sessions (#529) * feat(cliproxy): add runtime quota monitoring during active sessions Adds adaptive background quota polling to detect and respond to quota exhaustion during active CLIProxy sessions. Prevents rate-limit-driven account bans by auto-cooling exhausted accounts and switching defaults. - Adaptive polling: 300s normal, 60s at 20% threshold, stops at 0% - Stderr warnings at 20%, boxed exhaustion alerts at 0% - Cooldown + default switch on exhaustion (existing patterns) - Configurable via quota_management.runtime_monitor in config.yaml - Timer.unref() prevents blocking process exit - monitorStopped guard for in-flight poll safety Closes #524 * fix: address code review feedback (attempt 1/5) - M1: Round quotaPercent display with Math.round() to avoid ugly floats - M2: Rename exhaust_threshold -> exhaustion_threshold for consistency with existing auto.exhaustion_threshold config field - M3: Replace async not.toThrow() with direct await assertion pattern * fix: address code review feedback (attempt 2/5) - Remove .claude/agent-memory/ from tracking and add to .gitignore - Unify cooldown_minutes default to 5 (was 10 in runtime_monitor, 5 in auto) - Add threshold validation in startQuotaMonitor (warn > exhaustion) - Document intentional post-switch monitoring gap in code comment * chore(release): 7.41.0-dev.4 [skip ci] * fix(cliproxy): mask email in ban detection and fix JSDoc default - Use maskEmail() in handleBanDetection output for consistency - Fix cooldown_minutes JSDoc: default is 5, not 10 * chore(release): 7.41.0-dev.5 [skip ci] * fix(cliproxy): address all review feedback (Low + informational) - Add sync constraint comment on process.exit handler (executor) - Add TOCTOU race acceptability comment (account-safety) - Mask email in handleQuotaExhaustion reason string - Use realistic exhaustion_threshold (5) in test configs * chore(release): 7.41.0-dev.6 [skip ci] --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
59e3e0286e
commit
051805074e
32 files changed
+2384
-110
No files matched your search
@@ -70,12 +70,12 @@ jobs:
|
|||||||
# CLIProxy environment for model routing
|
# CLIProxy environment for model routing
|
||||||
env:
|
env:
|
||||||
ANTHROPIC_BASE_URL: http://localhost:8317
|
ANTHROPIC_BASE_URL: http://localhost:8317
|
||||||
REVIEW_MODEL: gemini-claude-opus-4-6-thinking
|
REVIEW_MODEL: claude-opus-4-6-thinking
|
||||||
ANTHROPIC_AUTH_TOKEN: ccs-internal-managed
|
ANTHROPIC_AUTH_TOKEN: ccs-internal-managed
|
||||||
ANTHROPIC_MODEL: gemini-claude-opus-4-6-thinking
|
ANTHROPIC_MODEL: claude-opus-4-6-thinking
|
||||||
ANTHROPIC_DEFAULT_OPUS_MODEL: gemini-claude-opus-4-6-thinking
|
ANTHROPIC_DEFAULT_OPUS_MODEL: claude-opus-4-6-thinking
|
||||||
ANTHROPIC_DEFAULT_SONNET_MODEL: gemini-claude-sonnet-4-5-thinking
|
ANTHROPIC_DEFAULT_SONNET_MODEL: claude-sonnet-4-5-thinking
|
||||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: gemini-claude-sonnet-4-5
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: claude-sonnet-4-5
|
||||||
DISABLE_BUG_COMMAND: "1"
|
DISABLE_BUG_COMMAND: "1"
|
||||||
DISABLE_ERROR_REPORTING: "1"
|
DISABLE_ERROR_REPORTING: "1"
|
||||||
DISABLE_TELEMETRY: "1"
|
DISABLE_TELEMETRY: "1"
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ pnpm-lock.yaml
|
|||||||
package-lock.json
|
package-lock.json
|
||||||
|
|
||||||
.claude/active-plan
|
.claude/active-plan
|
||||||
|
.claude/agent-memory/
|
||||||
|
|
||||||
# Logs directory
|
# Logs directory
|
||||||
logs/
|
logs/
|
||||||
|
|||||||
@@ -5,6 +5,6 @@
|
|||||||
"ANTHROPIC_MODEL": "gemini-3-pro-preview",
|
"ANTHROPIC_MODEL": "gemini-3-pro-preview",
|
||||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "gemini-3-pro-preview",
|
"ANTHROPIC_DEFAULT_OPUS_MODEL": "gemini-3-pro-preview",
|
||||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "gemini-3-pro-preview",
|
"ANTHROPIC_DEFAULT_SONNET_MODEL": "gemini-3-pro-preview",
|
||||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "gemini-claude-sonnet-4-5"
|
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "claude-sonnet-4-5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -761,14 +761,16 @@ process.stdin.on('error', () => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Main hook processing logic
|
* Main hook processing logic
|
||||||
|
*
|
||||||
|
* Two-phase design: Phase 1 filters non-image Read calls silently (exit 0).
|
||||||
|
* Phase 2 only runs for confirmed image/PDF files, so error messages are
|
||||||
|
* always relevant and never confuse users reading code or text files.
|
||||||
*/
|
*/
|
||||||
async function processHook() {
|
async function processHook() {
|
||||||
|
// Phase 1: Fast bail-out for non-image files
|
||||||
|
// Any failure here → pass through silently to native Read
|
||||||
|
let filePath;
|
||||||
try {
|
try {
|
||||||
// Skip for native accounts or explicit disable
|
|
||||||
if (shouldSkipHook()) {
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = JSON.parse(input);
|
const data = JSON.parse(input);
|
||||||
|
|
||||||
// Only handle Read tool
|
// Only handle Read tool
|
||||||
@@ -776,23 +778,35 @@ async function processHook() {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const filePath = data.tool_input?.file_path || '';
|
filePath = data.tool_input?.file_path || '';
|
||||||
|
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check file extension BEFORE any other processing — this is the key gate
|
||||||
|
// that ensures non-image Read calls never see hook errors
|
||||||
|
if (!isAnalyzableFile(filePath)) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// stdin parse failure or unexpected error → pass through silently
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Image/PDF file processing — errors here are relevant to the user
|
||||||
|
try {
|
||||||
|
// Skip for native accounts or explicit disable
|
||||||
|
if (shouldSkipHook()) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if file exists
|
// Check if file exists
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
// Let native Read handle the error
|
// Let native Read handle the error
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if file is analyzable
|
|
||||||
if (!isAnalyzableFile(filePath)) {
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check file size
|
// Check file size
|
||||||
const stats = fs.statSync(filePath);
|
const stats = fs.statSync(filePath);
|
||||||
if (stats.size >= MAX_FILE_SIZE_BYTES) {
|
if (stats.size >= MAX_FILE_SIZE_BYTES) {
|
||||||
@@ -843,14 +857,7 @@ async function processHook() {
|
|||||||
console.error('[CCS Hook] Error:', err.message);
|
console.error('[CCS Hook] Error:', err.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to extract file path from parsed input
|
// filePath is guaranteed set by Phase 1 — only image files reach here
|
||||||
let filePath = 'unknown file';
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(input);
|
|
||||||
filePath = data.tool_input?.file_path || 'unknown file';
|
|
||||||
} catch {
|
|
||||||
// Ignore parse errors
|
|
||||||
}
|
|
||||||
|
|
||||||
// Categorize error by message pattern
|
// Categorize error by message pattern
|
||||||
const errMsg = err.message || '';
|
const errMsg = err.message || '';
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@kaitranntt/ccs",
|
"name": "@kaitranntt/ccs",
|
||||||
"version": "7.41.0",
|
"version": "7.41.0-dev.6",
|
||||||
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
|
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"cli",
|
"cli",
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ interface PRContext {
|
|||||||
// Config
|
// Config
|
||||||
const MAX_DIFF_LINES = 10000;
|
const MAX_DIFF_LINES = 10000;
|
||||||
const CLIPROXY_URL = process.env.CLIPROXY_URL || 'http://localhost:8317';
|
const CLIPROXY_URL = process.env.CLIPROXY_URL || 'http://localhost:8317';
|
||||||
const MODEL = process.env.REVIEW_MODEL || 'gemini-claude-opus-4-6-thinking';
|
const MODEL = process.env.REVIEW_MODEL || 'claude-opus-4-6-thinking';
|
||||||
|
|
||||||
// System prompt for code review - new style
|
// System prompt for code review - new style
|
||||||
const CODE_REVIEWER_SYSTEM_PROMPT = `You are the CCS AGY Code Reviewer, an expert AI assistant reviewing pull requests for the CCS CLI project.
|
const CODE_REVIEWER_SYSTEM_PROMPT = `You are the CCS AGY Code Reviewer, an expert AI assistant reviewing pull requests for the CCS CLI project.
|
||||||
|
|||||||
@@ -0,0 +1,471 @@
|
|||||||
|
/**
|
||||||
|
* Account Safety Guards
|
||||||
|
*
|
||||||
|
* Prevents Google account bans by:
|
||||||
|
* 1. Cross-provider isolation (auto-pause conflicting accounts at launch, restore on exit)
|
||||||
|
* 2. Ban/disable detection (auto-pauses affected accounts on error response)
|
||||||
|
* 3. Crash recovery (restores stale auto-pauses from dead sessions)
|
||||||
|
*
|
||||||
|
* Ref: https://github.com/kaitranntt/ccs/issues/509
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { warn, info } from '../utils/ui';
|
||||||
|
import { CLIProxyProvider } from './types';
|
||||||
|
import { loadAccountsRegistry, pauseAccount, resumeAccount } from './accounts/registry';
|
||||||
|
import { getCcsDir } from '../utils/config-manager';
|
||||||
|
|
||||||
|
/** Providers that use Google OAuth (ban risk when overlapping) */
|
||||||
|
const GOOGLE_OAUTH_PROVIDERS: CLIProxyProvider[] = ['gemini', 'agy', 'codex'];
|
||||||
|
|
||||||
|
// --- Auto-pause persistence (crash recovery) ---
|
||||||
|
|
||||||
|
interface AutoPausedSession {
|
||||||
|
initiator: CLIProxyProvider;
|
||||||
|
pid: number;
|
||||||
|
pausedAt: string;
|
||||||
|
accounts: Array<{ provider: CLIProxyProvider; accountId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AutoPausedFile {
|
||||||
|
sessions: AutoPausedSession[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAutoPausedPath(): string {
|
||||||
|
return path.join(getCcsDir(), 'cliproxy', 'auto-paused.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAutoPaused(): AutoPausedFile {
|
||||||
|
try {
|
||||||
|
const filePath = getAutoPausedPath();
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||||
|
if (Array.isArray(data.sessions)) return { sessions: data.sessions };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Corrupted or malformed file — start fresh
|
||||||
|
}
|
||||||
|
return { sessions: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveAutoPaused(data: AutoPausedFile): void {
|
||||||
|
const filePath = getAutoPausedPath();
|
||||||
|
if (data.sessions.length === 0) {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
} catch {
|
||||||
|
/* already gone */
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dir = path.dirname(filePath);
|
||||||
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||||
|
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a process is alive. NOTE: PIDs can be recycled by the OS.
|
||||||
|
* If a stale PID is reused by an unrelated process, cleanup is deferred until that process exits.
|
||||||
|
* This is acceptable — next CCS launch will self-heal via cleanupStaleAutoPauses().
|
||||||
|
*/
|
||||||
|
function isPidAlive(pid: number): boolean {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect same email registered under multiple Google OAuth providers.
|
||||||
|
* This is the primary cause of account bans — Google sees concurrent
|
||||||
|
* OAuth usage from different client IDs as suspicious activity.
|
||||||
|
*
|
||||||
|
* Returns map of email -> providers it appears in (only duplicates).
|
||||||
|
*/
|
||||||
|
export function detectCrossProviderDuplicates(): Map<string, CLIProxyProvider[]> {
|
||||||
|
const registry = loadAccountsRegistry();
|
||||||
|
|
||||||
|
// Build email -> providers mapping (only Google OAuth providers)
|
||||||
|
const emailProviders = new Map<string, CLIProxyProvider[]>();
|
||||||
|
|
||||||
|
for (const provider of GOOGLE_OAUTH_PROVIDERS) {
|
||||||
|
const providerAccounts = registry.providers[provider];
|
||||||
|
if (!providerAccounts) continue;
|
||||||
|
|
||||||
|
for (const [, account] of Object.entries(providerAccounts.accounts)) {
|
||||||
|
const email = account.email;
|
||||||
|
if (!email || account.paused) continue;
|
||||||
|
|
||||||
|
const normalized = email.toLowerCase();
|
||||||
|
const existing = emailProviders.get(normalized) ?? [];
|
||||||
|
existing.push(provider);
|
||||||
|
emailProviders.set(normalized, existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to only duplicates (email in 2+ providers)
|
||||||
|
const duplicates = new Map<string, CLIProxyProvider[]>();
|
||||||
|
for (const [email, providers] of emailProviders) {
|
||||||
|
if (providers.length > 1) {
|
||||||
|
duplicates.set(email, providers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return duplicates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a newly registered account creates a cross-provider conflict.
|
||||||
|
* Returns the conflicting providers, or null if no conflict.
|
||||||
|
*/
|
||||||
|
export function checkNewAccountConflict(
|
||||||
|
provider: CLIProxyProvider,
|
||||||
|
email: string | undefined
|
||||||
|
): CLIProxyProvider[] | null {
|
||||||
|
if (!email || !GOOGLE_OAUTH_PROVIDERS.includes(provider)) return null;
|
||||||
|
|
||||||
|
const registry = loadAccountsRegistry();
|
||||||
|
const normalized = email.toLowerCase();
|
||||||
|
const conflicts: CLIProxyProvider[] = [];
|
||||||
|
|
||||||
|
for (const other of GOOGLE_OAUTH_PROVIDERS) {
|
||||||
|
if (other === provider) continue;
|
||||||
|
|
||||||
|
const providerAccounts = registry.providers[other];
|
||||||
|
if (!providerAccounts) continue;
|
||||||
|
|
||||||
|
for (const [, account] of Object.entries(providerAccounts.accounts)) {
|
||||||
|
if (account.email?.toLowerCase() === normalized && !account.paused) {
|
||||||
|
conflicts.push(other);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return conflicts.length > 0 ? conflicts : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display cross-provider duplicate warning at session launch.
|
||||||
|
* Returns true if warning was shown.
|
||||||
|
*/
|
||||||
|
export function warnCrossProviderDuplicates(provider: CLIProxyProvider): boolean {
|
||||||
|
if (!GOOGLE_OAUTH_PROVIDERS.includes(provider)) return false;
|
||||||
|
|
||||||
|
const duplicates = detectCrossProviderDuplicates();
|
||||||
|
if (duplicates.size === 0) return false;
|
||||||
|
|
||||||
|
console.error('');
|
||||||
|
console.error(warn('Account safety: cross-provider duplicate detected'));
|
||||||
|
console.error(' Same Google account across providers risks account bans (ref: #509).');
|
||||||
|
console.error('');
|
||||||
|
|
||||||
|
for (const [email, providers] of duplicates) {
|
||||||
|
console.error(` ${maskEmail(email)} -> ${providers.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('');
|
||||||
|
console.error(' Fix: pause duplicate with "ccs <provider> --pause <account>"');
|
||||||
|
console.error(' or use separate Google accounts per provider.');
|
||||||
|
console.error('');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Warn about a specific new account conflict during OAuth registration.
|
||||||
|
*/
|
||||||
|
export function warnNewAccountConflict(
|
||||||
|
email: string,
|
||||||
|
conflictingProviders: CLIProxyProvider[]
|
||||||
|
): void {
|
||||||
|
console.error('');
|
||||||
|
console.error(warn('Account safety: this email is used by another provider'));
|
||||||
|
console.error(
|
||||||
|
` ${maskEmail(email)} is also registered under: ${conflictingProviders.join(', ')}`
|
||||||
|
);
|
||||||
|
console.error(' Concurrent usage may cause Google to ban your account.');
|
||||||
|
console.error(' Consider pausing the duplicate or using a different account.');
|
||||||
|
console.error('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Enforcement: auto-pause/restore ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore auto-paused accounts from crashed sessions (dead PIDs).
|
||||||
|
* Call at launch BEFORE enforceProviderIsolation().
|
||||||
|
*/
|
||||||
|
export function cleanupStaleAutoPauses(): void {
|
||||||
|
const data = loadAutoPaused();
|
||||||
|
if (data.sessions.length === 0) return;
|
||||||
|
|
||||||
|
const alive: AutoPausedSession[] = [];
|
||||||
|
|
||||||
|
for (const session of data.sessions) {
|
||||||
|
if (isPidAlive(session.pid)) {
|
||||||
|
alive.push(session);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Dead PID — restore accounts
|
||||||
|
for (const { provider, accountId } of session.accounts) {
|
||||||
|
resumeAccount(provider, accountId);
|
||||||
|
}
|
||||||
|
console.error(
|
||||||
|
info(
|
||||||
|
`Restored ${session.accounts.length} auto-paused account(s) from crashed ${session.initiator} session`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alive.length !== data.sessions.length) {
|
||||||
|
saveAutoPaused({ sessions: alive });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enforce provider isolation by auto-pausing conflicting accounts in other providers.
|
||||||
|
* Records paused accounts for crash recovery and session exit restore.
|
||||||
|
* Returns number of accounts paused.
|
||||||
|
*/
|
||||||
|
export function enforceProviderIsolation(provider: CLIProxyProvider): number {
|
||||||
|
if (!GOOGLE_OAUTH_PROVIDERS.includes(provider)) return 0;
|
||||||
|
|
||||||
|
// If another provider session is actively managing isolation, just warn
|
||||||
|
const data = loadAutoPaused();
|
||||||
|
const otherActive = data.sessions.filter((s) => s.initiator !== provider && isPidAlive(s.pid));
|
||||||
|
if (otherActive.length > 0) return 0;
|
||||||
|
|
||||||
|
const registry = loadAccountsRegistry();
|
||||||
|
const currentAccounts = registry.providers[provider];
|
||||||
|
if (!currentAccounts) return 0;
|
||||||
|
|
||||||
|
// Collect active emails for current provider
|
||||||
|
const myEmails = new Set<string>();
|
||||||
|
for (const [, account] of Object.entries(currentAccounts.accounts)) {
|
||||||
|
if (account.email && !account.paused) {
|
||||||
|
myEmails.add(account.email.toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (myEmails.size === 0) return 0;
|
||||||
|
|
||||||
|
// Find conflicting accounts in other Google OAuth providers
|
||||||
|
const toPause: Array<{ provider: CLIProxyProvider; accountId: string }> = [];
|
||||||
|
|
||||||
|
for (const other of GOOGLE_OAUTH_PROVIDERS) {
|
||||||
|
if (other === provider) continue;
|
||||||
|
const otherAccounts = registry.providers[other];
|
||||||
|
if (!otherAccounts) continue;
|
||||||
|
|
||||||
|
for (const [accountId, account] of Object.entries(otherAccounts.accounts)) {
|
||||||
|
if (account.email && !account.paused && myEmails.has(account.email.toLowerCase())) {
|
||||||
|
toPause.push({ provider: other, accountId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toPause.length === 0) return 0;
|
||||||
|
|
||||||
|
// Pause conflicting accounts
|
||||||
|
for (const { provider: p, accountId } of toPause) {
|
||||||
|
pauseAccount(p, accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record for crash recovery (re-read to reduce concurrent write race window).
|
||||||
|
// TOCTOU race is acceptable for a single-user CLI tool — self-heals on next launch.
|
||||||
|
const freshData = loadAutoPaused();
|
||||||
|
freshData.sessions = freshData.sessions.filter((s) => s.initiator !== provider);
|
||||||
|
freshData.sessions.push({
|
||||||
|
initiator: provider,
|
||||||
|
pid: process.pid,
|
||||||
|
pausedAt: new Date().toISOString(),
|
||||||
|
accounts: toPause,
|
||||||
|
});
|
||||||
|
saveAutoPaused(freshData);
|
||||||
|
|
||||||
|
console.error('');
|
||||||
|
console.error(info(`Account safety: auto-paused ${toPause.length} conflicting account(s)`));
|
||||||
|
for (const { provider: p, accountId } of toPause) {
|
||||||
|
const acct = registry.providers[p]?.accounts[accountId];
|
||||||
|
const display = acct?.email ? maskEmail(acct.email) : accountId;
|
||||||
|
console.error(` ${display} (${p})`);
|
||||||
|
}
|
||||||
|
console.error(' Will restore on session exit.');
|
||||||
|
console.error('');
|
||||||
|
|
||||||
|
return toPause.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore accounts that were auto-paused by this session.
|
||||||
|
* Called on session exit (process 'exit' event).
|
||||||
|
* Skips accounts re-paused after enforcement (e.g., by ban handler).
|
||||||
|
*/
|
||||||
|
export function restoreAutoPausedAccounts(provider: CLIProxyProvider): void {
|
||||||
|
const data = loadAutoPaused();
|
||||||
|
const mySession = data.sessions.find((s) => s.initiator === provider && s.pid === process.pid);
|
||||||
|
if (!mySession) return;
|
||||||
|
|
||||||
|
const registry = loadAccountsRegistry();
|
||||||
|
|
||||||
|
for (const { provider: p, accountId } of mySession.accounts) {
|
||||||
|
// Don't restore if account was re-paused after enforcement (e.g., ban detected)
|
||||||
|
const account = registry.providers[p]?.accounts[accountId];
|
||||||
|
if (account?.pausedAt && account.pausedAt > mySession.pausedAt) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
resumeAccount(p, accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
data.sessions = data.sessions.filter((s) => !(s.initiator === provider && s.pid === process.pid));
|
||||||
|
saveAutoPaused(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error patterns that indicate Google has disabled/banned an account
|
||||||
|
const BAN_PATTERNS = [
|
||||||
|
'disabled in this account',
|
||||||
|
'violation of terms of service',
|
||||||
|
'account has been disabled',
|
||||||
|
'account is disabled',
|
||||||
|
'account has been suspended',
|
||||||
|
'account has been banned',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an error message indicates an account ban/disable.
|
||||||
|
*/
|
||||||
|
export function isBanResponse(errorMessage: string): boolean {
|
||||||
|
const lower = errorMessage.toLowerCase();
|
||||||
|
return BAN_PATTERNS.some((pattern) => lower.includes(pattern));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle detected account ban by auto-pausing the affected account.
|
||||||
|
* Returns true if account was paused.
|
||||||
|
*/
|
||||||
|
export function handleBanDetection(
|
||||||
|
provider: CLIProxyProvider,
|
||||||
|
accountId: string,
|
||||||
|
errorMessage: string
|
||||||
|
): boolean {
|
||||||
|
if (!isBanResponse(errorMessage)) return false;
|
||||||
|
|
||||||
|
console.error('');
|
||||||
|
console.error(warn('Account safety: account appears disabled by Google'));
|
||||||
|
console.error(` Account "${maskEmail(accountId)}" (${provider}) returned:`);
|
||||||
|
console.error(` "${truncate(errorMessage, 120)}"`);
|
||||||
|
console.error('');
|
||||||
|
console.error(info('Auto-pausing this account to prevent further issues.'));
|
||||||
|
console.error(` Resume later: ccs ${provider} --resume ${accountId}`);
|
||||||
|
console.error('');
|
||||||
|
|
||||||
|
return pauseAccount(provider, accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mask email for privacy in terminal output */
|
||||||
|
export function maskEmail(email: string): string {
|
||||||
|
const [local, domain] = email.split('@');
|
||||||
|
if (!local || !domain) return email;
|
||||||
|
return `${local.slice(0, 3)}***@${domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Truncate string with ellipsis */
|
||||||
|
function truncate(str: string, maxLen: number): string {
|
||||||
|
return str.length > maxLen ? str.slice(0, maxLen - 3) + '...' : str;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Quota Exhaustion Handling ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write boxed quota warning to stderr (20% threshold).
|
||||||
|
* Uses process.stderr.write() to work alongside inherited stdio.
|
||||||
|
* ASCII-only output (no emojis) per project constraints.
|
||||||
|
*/
|
||||||
|
export function writeQuotaWarning(accountId: string, quotaPercent: number): void {
|
||||||
|
const masked = maskEmail(accountId);
|
||||||
|
const lines = [
|
||||||
|
`[!] Quota Low: ${masked} (${Math.round(quotaPercent)}% remaining)`,
|
||||||
|
` Next session will use a different account if available`,
|
||||||
|
];
|
||||||
|
const maxLen = Math.max(...lines.map((l) => l.length));
|
||||||
|
const border = '\u2550'.repeat(maxLen + 2);
|
||||||
|
|
||||||
|
process.stderr.write('\n');
|
||||||
|
process.stderr.write(`\u2554${border}\u2557\n`);
|
||||||
|
for (const line of lines) {
|
||||||
|
process.stderr.write(`\u2551 ${line.padEnd(maxLen)} \u2551\n`);
|
||||||
|
}
|
||||||
|
process.stderr.write(`\u255A${border}\u255D\n`);
|
||||||
|
process.stderr.write('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write boxed quota exhaustion alert to stderr.
|
||||||
|
* Called when quota falls below exhaustion_threshold — account will be cooled down.
|
||||||
|
*/
|
||||||
|
function writeQuotaExhausted(
|
||||||
|
accountId: string,
|
||||||
|
switchedTo: string | null,
|
||||||
|
cooldownMinutes: number
|
||||||
|
): void {
|
||||||
|
const masked = maskEmail(accountId);
|
||||||
|
const lines = [`[X] Quota Exhausted: ${masked}`, ` Cooldown: ${cooldownMinutes} minutes`];
|
||||||
|
if (switchedTo) {
|
||||||
|
lines.push(` Next session default: ${maskEmail(switchedTo)}`);
|
||||||
|
} else {
|
||||||
|
lines.push(` No alternative accounts available`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxLen = Math.max(...lines.map((l) => l.length));
|
||||||
|
const border = '\u2550'.repeat(maxLen + 2);
|
||||||
|
|
||||||
|
process.stderr.write('\n');
|
||||||
|
process.stderr.write(`\u2554${border}\u2557\n`);
|
||||||
|
for (const line of lines) {
|
||||||
|
process.stderr.write(`\u2551 ${line.padEnd(maxLen)} \u2551\n`);
|
||||||
|
}
|
||||||
|
process.stderr.write(`\u255A${border}\u255D\n`);
|
||||||
|
process.stderr.write('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle quota exhaustion for an active session.
|
||||||
|
* Applies cooldown to exhausted account, finds healthy alternative,
|
||||||
|
* switches default, and alerts user via stderr.
|
||||||
|
*
|
||||||
|
* @returns switchedTo account ID or null if no alternatives
|
||||||
|
*/
|
||||||
|
export async function handleQuotaExhaustion(
|
||||||
|
provider: CLIProxyProvider,
|
||||||
|
accountId: string,
|
||||||
|
cooldownMinutes: number
|
||||||
|
): Promise<{ switchedTo: string | null; reason: string }> {
|
||||||
|
// Dynamic imports to avoid circular dependencies
|
||||||
|
const { applyCooldown, findHealthyAccount } = await import('./quota-manager');
|
||||||
|
const { setDefaultAccount, touchAccount } = await import('./account-manager');
|
||||||
|
|
||||||
|
// Apply cooldown to exhausted account
|
||||||
|
applyCooldown(provider, accountId, cooldownMinutes);
|
||||||
|
|
||||||
|
// Find healthy alternative
|
||||||
|
const alternative = await findHealthyAccount(provider, [accountId]);
|
||||||
|
|
||||||
|
if (alternative) {
|
||||||
|
setDefaultAccount(provider, alternative.id);
|
||||||
|
touchAccount(provider, alternative.id);
|
||||||
|
writeQuotaExhausted(accountId, alternative.id, cooldownMinutes);
|
||||||
|
return {
|
||||||
|
switchedTo: alternative.id,
|
||||||
|
reason: `Quota exhausted, switched to ${maskEmail(alternative.id)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// No alternatives — warn but continue (graceful degradation)
|
||||||
|
writeQuotaExhausted(accountId, null, cooldownMinutes);
|
||||||
|
return {
|
||||||
|
switchedTo: null,
|
||||||
|
reason: 'Quota exhausted, no alternatives available',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -39,6 +39,7 @@ import { getProviderTokenDir, isAuthenticated, registerAccountFromToken } from '
|
|||||||
import { executeOAuthProcess } from './oauth-process';
|
import { executeOAuthProcess } from './oauth-process';
|
||||||
import { importKiroToken } from './kiro-import';
|
import { importKiroToken } from './kiro-import';
|
||||||
import { getProxyTarget, buildProxyUrl, buildManagementHeaders } from '../proxy-target-resolver';
|
import { getProxyTarget, buildProxyUrl, buildManagementHeaders } from '../proxy-target-resolver';
|
||||||
|
import { checkNewAccountConflict, warnNewAccountConflict } from '../account-safety';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prompt user to add another account
|
* Prompt user to add another account
|
||||||
@@ -379,7 +380,17 @@ async function handlePasteCallbackMode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(ok('Authentication successful!'));
|
console.log(ok('Authentication successful!'));
|
||||||
return registerAccountFromToken(provider, tokenDir, nickname);
|
const account = registerAccountFromToken(provider, tokenDir, nickname);
|
||||||
|
|
||||||
|
// Account safety: check for cross-provider conflicts
|
||||||
|
if (account?.email) {
|
||||||
|
const conflicts = checkNewAccountConflict(provider, account.email);
|
||||||
|
if (conflicts) {
|
||||||
|
warnNewAccountConflict(account.email, conflicts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return account;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (verbose) {
|
if (verbose) {
|
||||||
console.log(fail(`Error: ${(error as Error).message}`));
|
console.log(fail(`Error: ${(error as Error).message}`));
|
||||||
@@ -543,6 +554,14 @@ export async function triggerOAuth(
|
|||||||
console.log(' Or enable "Kiro: Use normal browser" in: ccs config');
|
console.log(' Or enable "Kiro: Use normal browser" in: ccs config');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Account safety: check for cross-provider conflicts
|
||||||
|
if (account?.email) {
|
||||||
|
const conflicts = checkNewAccountConflict(provider, account.email);
|
||||||
|
if (conflicts) {
|
||||||
|
warnNewAccountConflict(account.email, conflicts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return account;
|
return account;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,52 @@ interface ProviderSettings {
|
|||||||
env: NodeJS.ProcessEnv;
|
env: NodeJS.ProcessEnv;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Model name prefix that was deprecated in CLIProxyAPI registry */
|
||||||
|
const DEPRECATED_MODEL_PREFIX = 'gemini-claude-';
|
||||||
|
/** Replacement prefix matching actual upstream model names */
|
||||||
|
const UPSTREAM_MODEL_PREFIX = 'claude-';
|
||||||
|
|
||||||
|
/** Env vars that contain model names and may need migration */
|
||||||
|
const MODEL_ENV_KEYS = [
|
||||||
|
'ANTHROPIC_MODEL',
|
||||||
|
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||||
|
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||||
|
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate deprecated gemini-claude-* model names to upstream claude-* names in a settings file.
|
||||||
|
* CLIProxyAPI registry no longer recognizes the gemini-claude-* prefix convention.
|
||||||
|
* Preserves any suffixes like (high), [1m], etc.
|
||||||
|
*
|
||||||
|
* Returns true if migration was performed and file was updated.
|
||||||
|
*/
|
||||||
|
function migrateDeprecatedModelNames(settingsPath: string, settings: ProviderSettings): boolean {
|
||||||
|
if (!settings.env || typeof settings.env !== 'object') return false;
|
||||||
|
|
||||||
|
let migrated = false;
|
||||||
|
for (const key of MODEL_ENV_KEYS) {
|
||||||
|
const value = settings.env[key];
|
||||||
|
if (typeof value !== 'string') continue;
|
||||||
|
|
||||||
|
// Check if the base model name (before any suffixes) uses the deprecated prefix
|
||||||
|
if (value.toLowerCase().startsWith(DEPRECATED_MODEL_PREFIX)) {
|
||||||
|
settings.env[key] = UPSTREAM_MODEL_PREFIX + value.slice(DEPRECATED_MODEL_PREFIX.length);
|
||||||
|
migrated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (migrated) {
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 });
|
||||||
|
} catch {
|
||||||
|
// Best-effort migration — don't block startup if write fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return migrated;
|
||||||
|
}
|
||||||
|
|
||||||
/** Remote proxy configuration for URL rewriting */
|
/** Remote proxy configuration for URL rewriting */
|
||||||
export interface RemoteProxyRewriteConfig {
|
export interface RemoteProxyRewriteConfig {
|
||||||
host: string;
|
host: string;
|
||||||
@@ -191,6 +237,8 @@ export function getEffectiveEnvVars(
|
|||||||
const settings: ProviderSettings = JSON.parse(content);
|
const settings: ProviderSettings = JSON.parse(content);
|
||||||
|
|
||||||
if (settings.env && typeof settings.env === 'object') {
|
if (settings.env && typeof settings.env === 'object') {
|
||||||
|
// Migrate deprecated gemini-claude-* model names if present
|
||||||
|
migrateDeprecatedModelNames(expandedPath, settings);
|
||||||
// Custom variant settings found - merge with global env
|
// Custom variant settings found - merge with global env
|
||||||
envVars = { ...globalEnv, ...settings.env };
|
envVars = { ...globalEnv, ...settings.env };
|
||||||
// Ensure required vars are present (fall back to defaults if missing)
|
// Ensure required vars are present (fall back to defaults if missing)
|
||||||
@@ -220,6 +268,8 @@ export function getEffectiveEnvVars(
|
|||||||
const settings: ProviderSettings = JSON.parse(content);
|
const settings: ProviderSettings = JSON.parse(content);
|
||||||
|
|
||||||
if (settings.env && typeof settings.env === 'object') {
|
if (settings.env && typeof settings.env === 'object') {
|
||||||
|
// Migrate deprecated gemini-claude-* model names if present
|
||||||
|
migrateDeprecatedModelNames(settingsPath, settings);
|
||||||
// User override found - merge with global env
|
// User override found - merge with global env
|
||||||
envVars = { ...globalEnv, ...settings.env };
|
envVars = { ...globalEnv, ...settings.env };
|
||||||
// Ensure required vars are present (fall back to defaults if missing)
|
// Ensure required vars are present (fall back to defaults if missing)
|
||||||
@@ -306,6 +356,7 @@ export function getRemoteEnvVars(
|
|||||||
const content = fs.readFileSync(expandedPath, 'utf-8');
|
const content = fs.readFileSync(expandedPath, 'utf-8');
|
||||||
const settings: ProviderSettings = JSON.parse(content);
|
const settings: ProviderSettings = JSON.parse(content);
|
||||||
if (settings.env && typeof settings.env === 'object') {
|
if (settings.env && typeof settings.env === 'object') {
|
||||||
|
migrateDeprecatedModelNames(expandedPath, settings);
|
||||||
userEnvVars = settings.env as Record<string, string>;
|
userEnvVars = settings.env as Record<string, string>;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -323,6 +374,7 @@ export function getRemoteEnvVars(
|
|||||||
const content = fs.readFileSync(settingsPath, 'utf-8');
|
const content = fs.readFileSync(settingsPath, 'utf-8');
|
||||||
const settings: ProviderSettings = JSON.parse(content);
|
const settings: ProviderSettings = JSON.parse(content);
|
||||||
if (settings.env && typeof settings.env === 'object') {
|
if (settings.env && typeof settings.env === 'object') {
|
||||||
|
migrateDeprecatedModelNames(settingsPath, settings);
|
||||||
userEnvVars = settings.env as Record<string, string>;
|
userEnvVars = settings.env as Record<string, string>;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* Claude Code recognizes this suffix to enable extended context.
|
* Claude Code recognizes this suffix to enable extended context.
|
||||||
*
|
*
|
||||||
* Behavior:
|
* Behavior:
|
||||||
* - Gemini family (gemini-* but NOT gemini-claude-*): Auto-enabled by default
|
* - Gemini family (gemini-*): Auto-enabled by default
|
||||||
* - Claude (Anthropic): Opt-in via --1m flag
|
* - Claude (Anthropic): Opt-in via --1m flag
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -26,23 +26,24 @@ export const CCS_CONTROL_PANEL_SECRET = 'ccs';
|
|||||||
* v4: Added Kiro (AWS) and GitHub Copilot providers
|
* v4: Added Kiro (AWS) and GitHub Copilot providers
|
||||||
* v5: Added disable-cooling: true for stability
|
* v5: Added disable-cooling: true for stability
|
||||||
* v6: Added oauth-model-alias with Opus 4.6 support
|
* v6: Added oauth-model-alias with Opus 4.6 support
|
||||||
|
* v7: Added fork:true for Claude model aliases (keep both upstream and alias names)
|
||||||
*/
|
*/
|
||||||
export const CLIPROXY_CONFIG_VERSION = 6;
|
export const CLIPROXY_CONFIG_VERSION = 7;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default Antigravity oauth-model-alias entries.
|
* Default Antigravity oauth-model-alias entries.
|
||||||
* Maps user-facing model names to Antigravity internal model names.
|
* Maps user-facing model names to Antigravity internal model names.
|
||||||
* Must stay in sync with CLIProxyAPIPlus defaultAntigravityAliases().
|
* Must stay in sync with CLIProxyAPIPlus defaultAntigravityAliases().
|
||||||
*/
|
*/
|
||||||
const DEFAULT_ANTIGRAVITY_ALIASES: Array<{ name: string; alias: string }> = [
|
const DEFAULT_ANTIGRAVITY_ALIASES: Array<{ name: string; alias: string; fork?: boolean }> = [
|
||||||
{ name: 'rev19-uic3-1p', alias: 'gemini-2.5-computer-use-preview-10-2025' },
|
{ name: 'rev19-uic3-1p', alias: 'gemini-2.5-computer-use-preview-10-2025' },
|
||||||
{ name: 'gemini-3-pro-image', alias: 'gemini-3-pro-image-preview' },
|
{ name: 'gemini-3-pro-image', alias: 'gemini-3-pro-image-preview' },
|
||||||
{ name: 'gemini-3-pro-high', alias: 'gemini-3-pro-preview' },
|
{ name: 'gemini-3-pro-high', alias: 'gemini-3-pro-preview' },
|
||||||
{ name: 'gemini-3-flash', alias: 'gemini-3-flash-preview' },
|
{ name: 'gemini-3-flash', alias: 'gemini-3-flash-preview' },
|
||||||
{ name: 'claude-sonnet-4-5', alias: 'gemini-claude-sonnet-4-5' },
|
{ name: 'claude-sonnet-4-5', alias: 'gemini-claude-sonnet-4-5', fork: true },
|
||||||
{ name: 'claude-sonnet-4-5-thinking', alias: 'gemini-claude-sonnet-4-5-thinking' },
|
{ name: 'claude-sonnet-4-5-thinking', alias: 'gemini-claude-sonnet-4-5-thinking', fork: true },
|
||||||
{ name: 'claude-opus-4-5-thinking', alias: 'gemini-claude-opus-4-5-thinking' },
|
{ name: 'claude-opus-4-5-thinking', alias: 'gemini-claude-opus-4-5-thinking', fork: true },
|
||||||
{ name: 'claude-opus-4-6-thinking', alias: 'gemini-claude-opus-4-6-thinking' },
|
{ name: 'claude-opus-4-6-thinking', alias: 'gemini-claude-opus-4-6-thinking', fork: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Provider display names (static metadata) */
|
/** Provider display names (static metadata) */
|
||||||
@@ -103,21 +104,44 @@ function generateOAuthModelAliasSection(existingAliases?: string): string {
|
|||||||
const existingNames = new Set(aliasEntries.map((a) => a.name));
|
const existingNames = new Set(aliasEntries.map((a) => a.name));
|
||||||
const lines = existingAliases.split('\n');
|
const lines = existingAliases.split('\n');
|
||||||
let currentName = '';
|
let currentName = '';
|
||||||
|
let currentAlias = '';
|
||||||
|
let currentFork = false;
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const nameMatch = line.match(/^\s+-\s*name:\s*(.+)/);
|
const nameMatch = line.match(/^\s+-\s*name:\s*(.+)/);
|
||||||
const aliasMatch = line.match(/^\s+alias:\s*(.+)/);
|
const aliasMatch = line.match(/^\s+alias:\s*(.+)/);
|
||||||
|
const forkMatch = line.match(/^\s+fork:\s*(.+)/);
|
||||||
if (nameMatch) {
|
if (nameMatch) {
|
||||||
|
// Flush previous entry if complete
|
||||||
|
if (currentName && currentAlias && !existingNames.has(currentName)) {
|
||||||
|
aliasEntries.push({
|
||||||
|
name: currentName,
|
||||||
|
alias: currentAlias,
|
||||||
|
fork: currentFork || undefined,
|
||||||
|
});
|
||||||
|
existingNames.add(currentName);
|
||||||
|
}
|
||||||
currentName = nameMatch[1].trim();
|
currentName = nameMatch[1].trim();
|
||||||
} else if (aliasMatch && currentName && !existingNames.has(currentName)) {
|
currentAlias = '';
|
||||||
aliasEntries.push({ name: currentName, alias: aliasMatch[1].trim() });
|
currentFork = false;
|
||||||
existingNames.add(currentName);
|
} else if (aliasMatch) {
|
||||||
currentName = '';
|
currentAlias = aliasMatch[1].trim();
|
||||||
|
} else if (forkMatch) {
|
||||||
|
currentFork = forkMatch[1].trim().toLowerCase() === 'true';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Flush last entry
|
||||||
|
if (currentName && currentAlias && !existingNames.has(currentName)) {
|
||||||
|
aliasEntries.push({ name: currentName, alias: currentAlias, fork: currentFork || undefined });
|
||||||
|
existingNames.add(currentName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const entries = aliasEntries
|
const entries = aliasEntries
|
||||||
.map((a) => ` - name: ${a.name}\n alias: ${a.alias}`)
|
.map((a) => {
|
||||||
|
let entry = ` - name: ${a.name}\n alias: ${a.alias}`;
|
||||||
|
if (a.fork) entry += '\n fork: true';
|
||||||
|
return entry;
|
||||||
|
})
|
||||||
.join('\n');
|
.join('\n');
|
||||||
|
|
||||||
return `oauth-model-alias:\n antigravity:\n${entries}`;
|
return `oauth-model-alias:\n antigravity:\n${entries}`;
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import {
|
|||||||
displayWebSearchStatus,
|
displayWebSearchStatus,
|
||||||
} from '../../utils/websearch-manager';
|
} from '../../utils/websearch-manager';
|
||||||
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
||||||
|
import { installImageAnalyzerHook } from '../../utils/hooks';
|
||||||
import { HttpsTunnelProxy } from '../https-tunnel-proxy';
|
import { HttpsTunnelProxy } from '../https-tunnel-proxy';
|
||||||
|
|
||||||
// Import modular components
|
// Import modular components
|
||||||
@@ -62,6 +63,12 @@ import {
|
|||||||
handleQuotaCheck,
|
handleQuotaCheck,
|
||||||
} from './retry-handler';
|
} from './retry-handler';
|
||||||
import { checkOrJoinProxy, registerProxySession, setupCleanupHandlers } from './session-bridge';
|
import { checkOrJoinProxy, registerProxySession, setupCleanupHandlers } from './session-bridge';
|
||||||
|
import {
|
||||||
|
warnCrossProviderDuplicates,
|
||||||
|
cleanupStaleAutoPauses,
|
||||||
|
enforceProviderIsolation,
|
||||||
|
restoreAutoPausedAccounts,
|
||||||
|
} from '../account-safety';
|
||||||
import { getWebSearchHookEnv } from '../../utils/websearch-manager';
|
import { getWebSearchHookEnv } from '../../utils/websearch-manager';
|
||||||
|
|
||||||
/** Default executor configuration */
|
/** Default executor configuration */
|
||||||
@@ -162,6 +169,9 @@ export async function execClaudeWithCLIProxy(
|
|||||||
installWebSearchHook();
|
installWebSearchHook();
|
||||||
displayWebSearchStatus();
|
displayWebSearchStatus();
|
||||||
|
|
||||||
|
// Sync image analyzer hook from npm package to ~/.ccs/hooks/
|
||||||
|
installImageAnalyzerHook();
|
||||||
|
|
||||||
const providerConfig = getProviderConfig(provider);
|
const providerConfig = getProviderConfig(provider);
|
||||||
log(`Provider: ${providerConfig.displayName}`);
|
log(`Provider: ${providerConfig.displayName}`);
|
||||||
|
|
||||||
@@ -503,6 +513,21 @@ export async function execClaudeWithCLIProxy(
|
|||||||
await handleQuotaCheck(provider);
|
await handleQuotaCheck(provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3c. Account safety: enforce cross-provider isolation
|
||||||
|
if (!skipLocalAuth) {
|
||||||
|
cleanupStaleAutoPauses();
|
||||||
|
const isolated = enforceProviderIsolation(provider);
|
||||||
|
if (isolated === 0) {
|
||||||
|
// No enforcement — still warn about duplicates for awareness
|
||||||
|
warnCrossProviderDuplicates(provider);
|
||||||
|
} else {
|
||||||
|
// 'exit' handlers must be synchronous — restoreAutoPausedAccounts uses sync fs APIs
|
||||||
|
process.on('exit', () => {
|
||||||
|
restoreAutoPausedAccounts(provider);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 4. First-run model configuration
|
// 4. First-run model configuration
|
||||||
if (supportsModelConfig(provider) && !skipLocalAuth) {
|
if (supportsModelConfig(provider) && !skipLocalAuth) {
|
||||||
await configureProviderModel(provider, false, cfg.customSettingsPath);
|
await configureProviderModel(provider, false, cfg.customSettingsPath);
|
||||||
@@ -769,6 +794,15 @@ export async function execClaudeWithCLIProxy(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 12b. Start runtime quota monitor (adaptive polling during session)
|
||||||
|
if (!skipLocalAuth) {
|
||||||
|
const { startQuotaMonitor } = await import('../quota-manager');
|
||||||
|
const monitorAccount = getDefaultAccount(provider);
|
||||||
|
if (monitorAccount) {
|
||||||
|
startQuotaMonitor(provider, monitorAccount.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 13. Setup cleanup handlers
|
// 13. Setup cleanup handlers
|
||||||
setupCleanupHandlers(
|
setupCleanupHandlers(
|
||||||
claude,
|
claude,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
import { fail, warn, info } from '../../utils/ui';
|
import { fail, warn, info } from '../../utils/ui';
|
||||||
import { CLIProxyProvider } from '../types';
|
import { CLIProxyProvider } from '../types';
|
||||||
|
import { handleBanDetection } from '../account-safety';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if error is network-related
|
* Check if error is network-related
|
||||||
@@ -50,6 +51,15 @@ export async function handleTokenExpiration(
|
|||||||
const tokenResult = await ensureTokenValid(provider, verbose);
|
const tokenResult = await ensureTokenValid(provider, verbose);
|
||||||
|
|
||||||
if (!tokenResult.valid) {
|
if (!tokenResult.valid) {
|
||||||
|
// Check if this is an account ban/disable before generic error
|
||||||
|
if (tokenResult.error) {
|
||||||
|
const { getDefaultAccount } = await import('../account-manager');
|
||||||
|
const account = getDefaultAccount(provider);
|
||||||
|
if (account) {
|
||||||
|
handleBanDetection(provider, account.id, tokenResult.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Token expired and refresh failed - trigger re-auth
|
// Token expired and refresh failed - trigger re-auth
|
||||||
console.error(warn('OAuth token expired and refresh failed'));
|
console.error(warn('OAuth token expired and refresh failed'));
|
||||||
if (tokenResult.error) {
|
if (tokenResult.error) {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
import { detectRunningProxy, waitForProxyHealthy, reclaimOrphanedProxy } from '../proxy-detector';
|
import { detectRunningProxy, waitForProxyHealthy, reclaimOrphanedProxy } from '../proxy-detector';
|
||||||
import { withStartupLock } from '../startup-lock';
|
import { withStartupLock } from '../startup-lock';
|
||||||
import { killProcessOnPort } from '../../utils/platform-commands';
|
import { killProcessOnPort } from '../../utils/platform-commands';
|
||||||
|
import { stopQuotaMonitor } from '../quota-manager';
|
||||||
|
|
||||||
export interface ProxySessionResult {
|
export interface ProxySessionResult {
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
@@ -184,6 +185,7 @@ export function setupCleanupHandlers(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
|
stopQuotaMonitor();
|
||||||
log('Parent signal received, cleaning up');
|
log('Parent signal received, cleaning up');
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -214,6 +216,7 @@ export function setupCleanupHandlers(
|
|||||||
};
|
};
|
||||||
|
|
||||||
claude.on('exit', (code, signal) => {
|
claude.on('exit', (code, signal) => {
|
||||||
|
stopQuotaMonitor();
|
||||||
log(`Claude exited: code=${code}, signal=${signal}`);
|
log(`Claude exited: code=${code}, signal=${signal}`);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -250,6 +253,7 @@ export function setupCleanupHandlers(
|
|||||||
});
|
});
|
||||||
|
|
||||||
claude.on('error', (error) => {
|
claude.on('error', (error) => {
|
||||||
|
stopQuotaMonitor();
|
||||||
console.error(require('../../utils/ui').fail(`Claude CLI error: ${error}`));
|
console.error(require('../../utils/ui').fail(`Claude CLI error: ${error}`));
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -73,10 +73,10 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
|||||||
agy: {
|
agy: {
|
||||||
provider: 'agy',
|
provider: 'agy',
|
||||||
displayName: 'Antigravity',
|
displayName: 'Antigravity',
|
||||||
defaultModel: 'gemini-claude-opus-4-6-thinking',
|
defaultModel: 'claude-opus-4-6-thinking',
|
||||||
models: [
|
models: [
|
||||||
{
|
{
|
||||||
id: 'gemini-claude-opus-4-6-thinking',
|
id: 'claude-opus-4-6-thinking',
|
||||||
name: 'Claude Opus 4.6 Thinking',
|
name: 'Claude Opus 4.6 Thinking',
|
||||||
description: 'Latest flagship, extended thinking',
|
description: 'Latest flagship, extended thinking',
|
||||||
thinking: {
|
thinking: {
|
||||||
@@ -91,7 +91,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
|||||||
extendedContext: false,
|
extendedContext: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'gemini-claude-opus-4-5-thinking',
|
id: 'claude-opus-4-5-thinking',
|
||||||
name: 'Claude Opus 4.5 Thinking',
|
name: 'Claude Opus 4.5 Thinking',
|
||||||
description: 'Previous flagship, extended thinking',
|
description: 'Previous flagship, extended thinking',
|
||||||
thinking: {
|
thinking: {
|
||||||
@@ -103,7 +103,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'gemini-claude-sonnet-4-5-thinking',
|
id: 'claude-sonnet-4-5-thinking',
|
||||||
name: 'Claude Sonnet 4.5 Thinking',
|
name: 'Claude Sonnet 4.5 Thinking',
|
||||||
description: 'Balanced with extended thinking',
|
description: 'Balanced with extended thinking',
|
||||||
thinking: {
|
thinking: {
|
||||||
@@ -115,7 +115,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'gemini-claude-sonnet-4-5',
|
id: 'claude-sonnet-4-5',
|
||||||
name: 'Claude Sonnet 4.5',
|
name: 'Claude Sonnet 4.5',
|
||||||
description: 'Fast and capable',
|
description: 'Fast and capable',
|
||||||
thinking: { type: 'none' },
|
thinking: { type: 'none' },
|
||||||
@@ -354,10 +354,10 @@ export function supportsExtendedContext(provider: CLIProxyProvider, modelId: str
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if model is a native Gemini model (not gemini-claude-*).
|
* Check if model is a native Gemini model (not Claude via Antigravity).
|
||||||
* Native Gemini models get extended context auto-enabled.
|
* Native Gemini models get extended context auto-enabled.
|
||||||
*/
|
*/
|
||||||
export function isNativeGeminiModel(modelId: string): boolean {
|
export function isNativeGeminiModel(modelId: string): boolean {
|
||||||
const lower = modelId.toLowerCase();
|
const lower = modelId.toLowerCase();
|
||||||
return lower.startsWith('gemini-') && !lower.startsWith('gemini-claude-');
|
return lower.startsWith('gemini-');
|
||||||
}
|
}
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
type AccountInfo,
|
type AccountInfo,
|
||||||
} from './account-manager';
|
} from './account-manager';
|
||||||
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
|
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
|
||||||
|
import type { RuntimeMonitorConfig } from '../config/unified-config-types';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// QUOTA CACHE (30-second TTL)
|
// QUOTA CACHE (30-second TTL)
|
||||||
@@ -416,3 +417,137 @@ export async function getQuotaStatus(provider: CLIProxyProvider): Promise<{
|
|||||||
|
|
||||||
return { accounts: results };
|
return { accounts: results };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RUNTIME QUOTA MONITOR (adaptive polling during active sessions)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/** Active monitor timer (null = not running) */
|
||||||
|
let monitorTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
/** Tracks if warning was shown this session (avoid spam) */
|
||||||
|
let hasWarnedThisSession = false;
|
||||||
|
|
||||||
|
/** Guards against in-flight poll callbacks running after stop */
|
||||||
|
let monitorStopped = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule next quota poll with adaptive interval.
|
||||||
|
* Uses setTimeout chain (not setInterval) for dynamic interval switching.
|
||||||
|
*/
|
||||||
|
function scheduleNextPoll(
|
||||||
|
provider: CLIProxyProvider,
|
||||||
|
accountId: string,
|
||||||
|
monitorConfig: RuntimeMonitorConfig,
|
||||||
|
intervalMs: number
|
||||||
|
): void {
|
||||||
|
monitorTimer = setTimeout(async () => {
|
||||||
|
// Guard: skip if monitor was stopped while this callback was queued
|
||||||
|
if (monitorStopped) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const quota = await fetchQuotaWithDedup(provider, accountId);
|
||||||
|
if (monitorStopped) return; // Re-check after async fetch
|
||||||
|
const avgQuota = calculateAverageQuota(quota) ?? 100;
|
||||||
|
|
||||||
|
if (avgQuota <= monitorConfig.exhaustion_threshold) {
|
||||||
|
// EXHAUSTED: cooldown + switch default + stop monitoring.
|
||||||
|
// NOTE: Monitor stops here intentionally. The current session continues
|
||||||
|
// on the exhausted account (can't hot-swap mid-session). The switched
|
||||||
|
// default only takes effect on next session start via preflightCheck().
|
||||||
|
const { handleQuotaExhaustion } = await import('./account-safety');
|
||||||
|
await handleQuotaExhaustion(provider, accountId, monitorConfig.cooldown_minutes);
|
||||||
|
monitorTimer = null;
|
||||||
|
return; // Stop polling
|
||||||
|
}
|
||||||
|
|
||||||
|
if (avgQuota <= monitorConfig.warn_threshold) {
|
||||||
|
// WARNING: switch to critical interval, warn once
|
||||||
|
if (!hasWarnedThisSession) {
|
||||||
|
const { writeQuotaWarning } = await import('./account-safety');
|
||||||
|
writeQuotaWarning(accountId, avgQuota);
|
||||||
|
hasWarnedThisSession = true;
|
||||||
|
}
|
||||||
|
scheduleNextPoll(
|
||||||
|
provider,
|
||||||
|
accountId,
|
||||||
|
monitorConfig,
|
||||||
|
monitorConfig.critical_interval_seconds * 1000
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HEALTHY: keep normal interval
|
||||||
|
scheduleNextPoll(
|
||||||
|
provider,
|
||||||
|
accountId,
|
||||||
|
monitorConfig,
|
||||||
|
monitorConfig.normal_interval_seconds * 1000
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// API failure: silently reschedule at same interval
|
||||||
|
scheduleNextPoll(provider, accountId, monitorConfig, intervalMs);
|
||||||
|
}
|
||||||
|
}, intervalMs);
|
||||||
|
|
||||||
|
// Prevent monitor from keeping Node.js process alive
|
||||||
|
if (monitorTimer && typeof monitorTimer === 'object' && 'unref' in monitorTimer) {
|
||||||
|
monitorTimer.unref();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start adaptive quota monitor for an active session.
|
||||||
|
* Polls at normal_interval (300s) when healthy, switches to
|
||||||
|
* critical_interval (60s) when quota hits warn_threshold (20%).
|
||||||
|
* Auto-stops on exhaustion or when stopQuotaMonitor() is called.
|
||||||
|
*
|
||||||
|
* Only monitors 'agy' provider (only one with quota API).
|
||||||
|
* No-op for other providers, manual mode, or if disabled in config.
|
||||||
|
*/
|
||||||
|
export function startQuotaMonitor(provider: CLIProxyProvider, accountId: string): void {
|
||||||
|
// Only Antigravity supports quota
|
||||||
|
if (provider !== 'agy') return;
|
||||||
|
|
||||||
|
// Prevent duplicate monitors
|
||||||
|
if (monitorTimer) return;
|
||||||
|
|
||||||
|
const config = loadOrCreateUnifiedConfig();
|
||||||
|
const quotaConfig = config.quota_management;
|
||||||
|
|
||||||
|
// Skip if config missing (shouldn't happen with defaults)
|
||||||
|
if (!quotaConfig) return;
|
||||||
|
|
||||||
|
// Skip if manual mode or runtime monitor disabled
|
||||||
|
if (quotaConfig.mode === 'manual') return;
|
||||||
|
if (!quotaConfig.runtime_monitor?.enabled) return;
|
||||||
|
|
||||||
|
// Validate thresholds: warn must be > exhaustion to avoid immediate exhaustion on warning
|
||||||
|
const monitorConfig = quotaConfig.runtime_monitor;
|
||||||
|
if (monitorConfig.warn_threshold <= monitorConfig.exhaustion_threshold) {
|
||||||
|
return; // Invalid config — skip monitoring silently (logged at config level)
|
||||||
|
}
|
||||||
|
|
||||||
|
hasWarnedThisSession = false;
|
||||||
|
monitorStopped = false;
|
||||||
|
|
||||||
|
// Start first poll at normal interval
|
||||||
|
scheduleNextPoll(
|
||||||
|
provider,
|
||||||
|
accountId,
|
||||||
|
quotaConfig.runtime_monitor,
|
||||||
|
quotaConfig.runtime_monitor.normal_interval_seconds * 1000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the runtime quota monitor. Safe to call multiple times.
|
||||||
|
*/
|
||||||
|
export function stopQuotaMonitor(): void {
|
||||||
|
monitorStopped = true;
|
||||||
|
if (monitorTimer) {
|
||||||
|
clearTimeout(monitorTimer);
|
||||||
|
monitorTimer = null;
|
||||||
|
}
|
||||||
|
hasWarnedThisSession = false;
|
||||||
|
}
|
||||||
@@ -341,6 +341,26 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
|
|||||||
partial.quota_management?.manual?.tier_lock ??
|
partial.quota_management?.manual?.tier_lock ??
|
||||||
DEFAULT_QUOTA_MANAGEMENT_CONFIG.manual.tier_lock,
|
DEFAULT_QUOTA_MANAGEMENT_CONFIG.manual.tier_lock,
|
||||||
},
|
},
|
||||||
|
runtime_monitor: {
|
||||||
|
enabled:
|
||||||
|
partial.quota_management?.runtime_monitor?.enabled ??
|
||||||
|
DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.enabled,
|
||||||
|
normal_interval_seconds:
|
||||||
|
partial.quota_management?.runtime_monitor?.normal_interval_seconds ??
|
||||||
|
DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.normal_interval_seconds,
|
||||||
|
critical_interval_seconds:
|
||||||
|
partial.quota_management?.runtime_monitor?.critical_interval_seconds ??
|
||||||
|
DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.critical_interval_seconds,
|
||||||
|
warn_threshold:
|
||||||
|
partial.quota_management?.runtime_monitor?.warn_threshold ??
|
||||||
|
DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.warn_threshold,
|
||||||
|
exhaustion_threshold:
|
||||||
|
partial.quota_management?.runtime_monitor?.exhaustion_threshold ??
|
||||||
|
DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.exhaustion_threshold,
|
||||||
|
cooldown_minutes:
|
||||||
|
partial.quota_management?.runtime_monitor?.cooldown_minutes ??
|
||||||
|
DEFAULT_QUOTA_MANAGEMENT_CONFIG.runtime_monitor.cooldown_minutes,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
// Thinking config - auto/manual/off control for reasoning budget
|
// Thinking config - auto/manual/off control for reasoning budget
|
||||||
thinking: {
|
thinking: {
|
||||||
|
|||||||
@@ -369,6 +369,25 @@ export interface AutoQuotaConfig {
|
|||||||
cooldown_minutes: number;
|
cooldown_minutes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runtime quota monitor configuration.
|
||||||
|
* Controls adaptive polling during active sessions.
|
||||||
|
*/
|
||||||
|
export interface RuntimeMonitorConfig {
|
||||||
|
/** Enable runtime monitoring during sessions (default: true) */
|
||||||
|
enabled: boolean;
|
||||||
|
/** Poll interval in seconds when quota > warn_threshold (default: 300) */
|
||||||
|
normal_interval_seconds: number;
|
||||||
|
/** Poll interval in seconds when quota <= warn_threshold (default: 60) */
|
||||||
|
critical_interval_seconds: number;
|
||||||
|
/** Quota percentage that triggers fast polling + warning (default: 20) */
|
||||||
|
warn_threshold: number;
|
||||||
|
/** Quota percentage that triggers cooldown + switch (default: 5) */
|
||||||
|
exhaustion_threshold: number;
|
||||||
|
/** Minutes to cooldown exhausted account (default: 5) */
|
||||||
|
cooldown_minutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manual quota management configuration.
|
* Manual quota management configuration.
|
||||||
* User-controlled overrides for account selection.
|
* User-controlled overrides for account selection.
|
||||||
@@ -401,6 +420,8 @@ export interface QuotaManagementConfig {
|
|||||||
auto: AutoQuotaConfig;
|
auto: AutoQuotaConfig;
|
||||||
/** Manual mode settings */
|
/** Manual mode settings */
|
||||||
manual: ManualQuotaConfig;
|
manual: ManualQuotaConfig;
|
||||||
|
/** Runtime monitor settings */
|
||||||
|
runtime_monitor: RuntimeMonitorConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -422,6 +443,18 @@ export const DEFAULT_MANUAL_QUOTA_CONFIG: ManualQuotaConfig = {
|
|||||||
tier_lock: null,
|
tier_lock: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default runtime monitor configuration.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_RUNTIME_MONITOR_CONFIG: RuntimeMonitorConfig = {
|
||||||
|
enabled: true,
|
||||||
|
normal_interval_seconds: 300,
|
||||||
|
critical_interval_seconds: 60,
|
||||||
|
warn_threshold: 20,
|
||||||
|
exhaustion_threshold: 5,
|
||||||
|
cooldown_minutes: 5,
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default quota management configuration.
|
* Default quota management configuration.
|
||||||
*/
|
*/
|
||||||
@@ -429,6 +462,7 @@ export const DEFAULT_QUOTA_MANAGEMENT_CONFIG: QuotaManagementConfig = {
|
|||||||
mode: 'hybrid',
|
mode: 'hybrid',
|
||||||
auto: { ...DEFAULT_AUTO_QUOTA_CONFIG },
|
auto: { ...DEFAULT_AUTO_QUOTA_CONFIG },
|
||||||
manual: { ...DEFAULT_MANUAL_QUOTA_CONFIG },
|
manual: { ...DEFAULT_MANUAL_QUOTA_CONFIG },
|
||||||
|
runtime_monitor: { ...DEFAULT_RUNTIME_MONITOR_CONFIG },
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -425,8 +425,8 @@ describe('Image Analyzer Hook', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Should exit with error (code 2)
|
// Should pass through silently (exit 0) — can't determine file type from malformed input
|
||||||
expect(hookProcess.status).toBe(2);
|
expect(hookProcess.status).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
/**
|
||||||
|
* Account Safety Quota Exhaustion Handler Tests
|
||||||
|
*
|
||||||
|
* Tests for handleQuotaExhaustion() and writeQuotaWarning():
|
||||||
|
* - Cooldown application
|
||||||
|
* - Account switching
|
||||||
|
* - Fallback when no alternatives
|
||||||
|
* - Warning output formatting
|
||||||
|
* - Email masking
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { handleQuotaExhaustion, writeQuotaWarning, maskEmail } from '../../../src/cliproxy/account-safety';
|
||||||
|
|
||||||
|
// Setup test isolation
|
||||||
|
let tmpDir: string;
|
||||||
|
let origCcsHome: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-exhaust-'));
|
||||||
|
origCcsHome = process.env.CCS_HOME;
|
||||||
|
process.env.CCS_HOME = tmpDir;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (origCcsHome !== undefined) {
|
||||||
|
process.env.CCS_HOME = origCcsHome;
|
||||||
|
} else {
|
||||||
|
delete process.env.CCS_HOME;
|
||||||
|
}
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper: write accounts registry
|
||||||
|
function writeRegistry(providers: Record<string, unknown>): void {
|
||||||
|
const registryDir = path.join(tmpDir, '.ccs', 'cliproxy');
|
||||||
|
fs.mkdirSync(registryDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(registryDir, 'accounts.json'),
|
||||||
|
JSON.stringify({ version: 1, providers }, null, 2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: write unified config
|
||||||
|
function writeConfig(quotaConfig: unknown): void {
|
||||||
|
const configDir = path.join(tmpDir, '.ccs', 'config');
|
||||||
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(configDir, 'unified-config.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
version: 2,
|
||||||
|
quota_management: quotaConfig,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Quota Exhaustion Handlers', () => {
|
||||||
|
describe('writeQuotaWarning', () => {
|
||||||
|
it('should write to stderr with box format', async () => {
|
||||||
|
const stderrWrites: string[] = [];
|
||||||
|
const originalWrite = process.stderr.write;
|
||||||
|
process.stderr.write = ((chunk: string) => {
|
||||||
|
stderrWrites.push(chunk);
|
||||||
|
return true;
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
writeQuotaWarning('test@gmail.com', 20);
|
||||||
|
|
||||||
|
process.stderr.write = originalWrite;
|
||||||
|
|
||||||
|
// Verify output contains account
|
||||||
|
const fullOutput = stderrWrites.join('');
|
||||||
|
expect(fullOutput).toContain('tes');
|
||||||
|
expect(fullOutput).toContain('20%');
|
||||||
|
|
||||||
|
// Verify box borders present
|
||||||
|
expect(fullOutput).toContain('\u2554'); // Top-left corner
|
||||||
|
expect(fullOutput).toContain('\u2557'); // Top-right corner
|
||||||
|
expect(fullOutput).toContain('\u255A'); // Bottom-left corner
|
||||||
|
expect(fullOutput).toContain('\u255D'); // Bottom-right corner
|
||||||
|
expect(fullOutput).toContain('\u2551'); // Vertical bar
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should mask email showing only first 3 chars', async () => {
|
||||||
|
const stderrWrites: string[] = [];
|
||||||
|
const originalWrite = process.stderr.write;
|
||||||
|
process.stderr.write = ((chunk: string) => {
|
||||||
|
stderrWrites.push(chunk);
|
||||||
|
return true;
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
writeQuotaWarning('verylongemail@example.com', 15);
|
||||||
|
|
||||||
|
process.stderr.write = originalWrite;
|
||||||
|
|
||||||
|
const fullOutput = stderrWrites.join('');
|
||||||
|
// Should show "ver***@example.com"
|
||||||
|
expect(fullOutput).toContain('ver***@example.com');
|
||||||
|
expect(fullOutput).not.toContain('verylongemail@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include threshold percentage', async () => {
|
||||||
|
const stderrWrites: string[] = [];
|
||||||
|
const originalWrite = process.stderr.write;
|
||||||
|
process.stderr.write = ((chunk: string) => {
|
||||||
|
stderrWrites.push(chunk);
|
||||||
|
return true;
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
writeQuotaWarning('test@gmail.com', 5);
|
||||||
|
|
||||||
|
process.stderr.write = originalWrite;
|
||||||
|
|
||||||
|
const fullOutput = stderrWrites.join('');
|
||||||
|
expect(fullOutput).toContain('5%');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('maskEmail', () => {
|
||||||
|
it('should mask standard email', () => {
|
||||||
|
const result = maskEmail('user@example.com');
|
||||||
|
expect(result).toBe('use***@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle short local part', () => {
|
||||||
|
const result = maskEmail('ab@example.com');
|
||||||
|
expect(result).toBe('ab***@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle single char local part', () => {
|
||||||
|
const result = maskEmail('a@example.com');
|
||||||
|
expect(result).toBe('a***@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return input if no @ sign', () => {
|
||||||
|
const result = maskEmail('not-an-email');
|
||||||
|
expect(result).toBe('not-an-email');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return input if empty string', () => {
|
||||||
|
const result = maskEmail('');
|
||||||
|
expect(result).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleQuotaExhaustion', () => {
|
||||||
|
it('should apply cooldown to exhausted account', async () => {
|
||||||
|
writeRegistry({
|
||||||
|
agy: {
|
||||||
|
default: 'exhausted@gmail.com',
|
||||||
|
accounts: {
|
||||||
|
'exhausted@gmail.com': {
|
||||||
|
email: 'exhausted@gmail.com',
|
||||||
|
tokenFile: 'agy-exhausted.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
writeConfig({
|
||||||
|
mode: 'auto',
|
||||||
|
auto: {
|
||||||
|
tier_priority: ['ultra', 'pro'],
|
||||||
|
exhaustion_threshold: 5,
|
||||||
|
cooldown_minutes: 10,
|
||||||
|
preflight_check: true,
|
||||||
|
},
|
||||||
|
runtime_monitor: {
|
||||||
|
enabled: true,
|
||||||
|
normal_interval_seconds: 300,
|
||||||
|
critical_interval_seconds: 60,
|
||||||
|
warn_threshold: 20,
|
||||||
|
exhaustion_threshold: 5,
|
||||||
|
cooldown_minutes: 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isOnCooldown } = await import('../../../src/cliproxy/quota-manager');
|
||||||
|
|
||||||
|
const result = await handleQuotaExhaustion('agy', 'exhausted@gmail.com', 10);
|
||||||
|
|
||||||
|
// Verify cooldown was applied (account now on cooldown)
|
||||||
|
expect(isOnCooldown('agy', 'exhausted@gmail.com')).toBe(true);
|
||||||
|
// Should return a result with reason
|
||||||
|
expect(result.reason).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle no alternatives gracefully', async () => {
|
||||||
|
writeRegistry({
|
||||||
|
agy: {
|
||||||
|
default: 'only@gmail.com',
|
||||||
|
accounts: {
|
||||||
|
'only@gmail.com': {
|
||||||
|
email: 'only@gmail.com',
|
||||||
|
tokenFile: 'agy-only.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
writeConfig({
|
||||||
|
mode: 'auto',
|
||||||
|
auto: {
|
||||||
|
tier_priority: ['ultra', 'pro'],
|
||||||
|
exhaustion_threshold: 5,
|
||||||
|
cooldown_minutes: 10,
|
||||||
|
preflight_check: true,
|
||||||
|
},
|
||||||
|
runtime_monitor: {
|
||||||
|
enabled: true,
|
||||||
|
normal_interval_seconds: 300,
|
||||||
|
critical_interval_seconds: 60,
|
||||||
|
warn_threshold: 20,
|
||||||
|
exhaustion_threshold: 5,
|
||||||
|
cooldown_minutes: 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await handleQuotaExhaustion('agy', 'only@gmail.com', 10);
|
||||||
|
|
||||||
|
// Should return gracefully with null switched
|
||||||
|
expect(result.switchedTo).toBeNull();
|
||||||
|
expect(result.reason).toContain('no alternatives');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should write warning to stderr', async () => {
|
||||||
|
writeRegistry({
|
||||||
|
agy: {
|
||||||
|
default: 'exhausted@gmail.com',
|
||||||
|
accounts: {
|
||||||
|
'exhausted@gmail.com': {
|
||||||
|
email: 'exhausted@gmail.com',
|
||||||
|
tokenFile: 'agy-exhausted.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
writeConfig({
|
||||||
|
mode: 'auto',
|
||||||
|
auto: {
|
||||||
|
tier_priority: ['ultra', 'pro'],
|
||||||
|
exhaustion_threshold: 5,
|
||||||
|
cooldown_minutes: 10,
|
||||||
|
preflight_check: true,
|
||||||
|
},
|
||||||
|
runtime_monitor: {
|
||||||
|
enabled: true,
|
||||||
|
normal_interval_seconds: 300,
|
||||||
|
critical_interval_seconds: 60,
|
||||||
|
warn_threshold: 20,
|
||||||
|
exhaustion_threshold: 5,
|
||||||
|
cooldown_minutes: 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stderrWrites: string[] = [];
|
||||||
|
const originalWrite = process.stderr.write;
|
||||||
|
process.stderr.write = ((chunk: string) => {
|
||||||
|
stderrWrites.push(chunk);
|
||||||
|
return true;
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
await handleQuotaExhaustion('agy', 'exhausted@gmail.com', 10);
|
||||||
|
|
||||||
|
process.stderr.write = originalWrite;
|
||||||
|
|
||||||
|
const fullOutput = stderrWrites.join('');
|
||||||
|
// Should contain exhaustion indicator
|
||||||
|
expect(fullOutput).toContain('[X]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should complete without throwing', async () => {
|
||||||
|
writeRegistry({
|
||||||
|
agy: {
|
||||||
|
default: 'test@gmail.com',
|
||||||
|
accounts: {
|
||||||
|
'test@gmail.com': {
|
||||||
|
email: 'test@gmail.com',
|
||||||
|
tokenFile: 'agy-test.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
writeConfig({
|
||||||
|
mode: 'auto',
|
||||||
|
auto: {
|
||||||
|
tier_priority: ['ultra', 'pro'],
|
||||||
|
exhaustion_threshold: 5,
|
||||||
|
cooldown_minutes: 5,
|
||||||
|
preflight_check: true,
|
||||||
|
},
|
||||||
|
runtime_monitor: {
|
||||||
|
enabled: true,
|
||||||
|
normal_interval_seconds: 300,
|
||||||
|
critical_interval_seconds: 60,
|
||||||
|
warn_threshold: 20,
|
||||||
|
exhaustion_threshold: 5,
|
||||||
|
cooldown_minutes: 5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await handleQuotaExhaustion('agy', 'test@gmail.com', 5);
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.switchedTo).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,621 @@
|
|||||||
|
/**
|
||||||
|
* Account Safety Guards Unit Tests
|
||||||
|
*
|
||||||
|
* Tests ban detection, email masking, cross-provider duplicate detection,
|
||||||
|
* enforcement lifecycle, and crash recovery.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import {
|
||||||
|
isBanResponse,
|
||||||
|
maskEmail,
|
||||||
|
detectCrossProviderDuplicates,
|
||||||
|
enforceProviderIsolation,
|
||||||
|
cleanupStaleAutoPauses,
|
||||||
|
restoreAutoPausedAccounts,
|
||||||
|
checkNewAccountConflict,
|
||||||
|
handleBanDetection,
|
||||||
|
warnCrossProviderDuplicates,
|
||||||
|
} from '../../../src/cliproxy/account-safety';
|
||||||
|
|
||||||
|
// --- Test isolation: use temp CCS_HOME ---
|
||||||
|
|
||||||
|
let tmpDir: string;
|
||||||
|
let origCcsHome: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-safety-'));
|
||||||
|
origCcsHome = process.env.CCS_HOME;
|
||||||
|
process.env.CCS_HOME = tmpDir;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (origCcsHome !== undefined) {
|
||||||
|
process.env.CCS_HOME = origCcsHome;
|
||||||
|
} else {
|
||||||
|
delete process.env.CCS_HOME;
|
||||||
|
}
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// CCS_HOME appends .ccs — all paths go through getCcsDir() = CCS_HOME/.ccs
|
||||||
|
function ccsDir(): string {
|
||||||
|
return path.join(tmpDir, '.ccs');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helper: write accounts registry ---
|
||||||
|
|
||||||
|
function writeRegistry(providers: Record<string, unknown>): void {
|
||||||
|
const registryDir = path.join(ccsDir(), 'cliproxy');
|
||||||
|
fs.mkdirSync(registryDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(registryDir, 'accounts.json'),
|
||||||
|
JSON.stringify({ version: 1, providers }, null, 2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helper: write auto-paused file ---
|
||||||
|
|
||||||
|
function writeAutoPaused(sessions: unknown[]): void {
|
||||||
|
const dir = path.join(ccsDir(), 'cliproxy');
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dir, 'auto-paused.json'), JSON.stringify({ sessions }, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAutoPaused(): { sessions: unknown[] } {
|
||||||
|
const filePath = path.join(ccsDir(), 'cliproxy', 'auto-paused.json');
|
||||||
|
if (!fs.existsSync(filePath)) return { sessions: [] };
|
||||||
|
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helper: write dummy token files ---
|
||||||
|
|
||||||
|
function writeTokenFile(filename: string, paused = false): void {
|
||||||
|
const dir = paused
|
||||||
|
? path.join(ccsDir(), 'cliproxy', 'auth-paused')
|
||||||
|
: path.join(ccsDir(), 'cliproxy', 'auth');
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dir, filename), JSON.stringify({ type: 'test' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// isBanResponse
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
describe('isBanResponse', () => {
|
||||||
|
it('should detect "disabled in this account"', () => {
|
||||||
|
expect(isBanResponse('API access disabled in this account')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect "violation of terms of service"', () => {
|
||||||
|
expect(isBanResponse('Your account was flagged for violation of terms of service')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect "account has been suspended"', () => {
|
||||||
|
expect(isBanResponse('This account has been suspended by Google')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be case-insensitive', () => {
|
||||||
|
expect(isBanResponse('ACCOUNT HAS BEEN DISABLED')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for normal errors', () => {
|
||||||
|
expect(isBanResponse('Rate limit exceeded')).toBe(false);
|
||||||
|
expect(isBanResponse('Internal server error')).toBe(false);
|
||||||
|
expect(isBanResponse('Network timeout')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for empty string', () => {
|
||||||
|
expect(isBanResponse('')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// maskEmail
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
describe('maskEmail', () => {
|
||||||
|
it('should mask standard email', () => {
|
||||||
|
expect(maskEmail('user@example.com')).toBe('use***@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle short local part', () => {
|
||||||
|
expect(maskEmail('ab@example.com')).toBe('ab***@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle single char local part', () => {
|
||||||
|
expect(maskEmail('a@example.com')).toBe('a***@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return input if no @ sign', () => {
|
||||||
|
expect(maskEmail('not-an-email')).toBe('not-an-email');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return input if empty string', () => {
|
||||||
|
expect(maskEmail('')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// detectCrossProviderDuplicates
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
describe('detectCrossProviderDuplicates', () => {
|
||||||
|
it('should return empty map when no duplicates', () => {
|
||||||
|
writeRegistry({
|
||||||
|
gemini: {
|
||||||
|
default: 'user1@gmail.com',
|
||||||
|
accounts: {
|
||||||
|
'user1@gmail.com': {
|
||||||
|
email: 'user1@gmail.com',
|
||||||
|
tokenFile: 'gemini-user1.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
agy: {
|
||||||
|
default: 'user2@gmail.com',
|
||||||
|
accounts: {
|
||||||
|
'user2@gmail.com': {
|
||||||
|
email: 'user2@gmail.com',
|
||||||
|
tokenFile: 'agy-user2.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const dupes = detectCrossProviderDuplicates();
|
||||||
|
expect(dupes.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect same email across providers', () => {
|
||||||
|
writeRegistry({
|
||||||
|
gemini: {
|
||||||
|
default: 'shared@gmail.com',
|
||||||
|
accounts: {
|
||||||
|
'shared@gmail.com': {
|
||||||
|
email: 'shared@gmail.com',
|
||||||
|
tokenFile: 'gemini-shared.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
agy: {
|
||||||
|
default: 'shared@gmail.com',
|
||||||
|
accounts: {
|
||||||
|
'shared@gmail.com': {
|
||||||
|
email: 'shared@gmail.com',
|
||||||
|
tokenFile: 'agy-shared.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const dupes = detectCrossProviderDuplicates();
|
||||||
|
expect(dupes.size).toBe(1);
|
||||||
|
expect(dupes.get('shared@gmail.com')).toEqual(['gemini', 'agy']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip paused accounts', () => {
|
||||||
|
writeRegistry({
|
||||||
|
gemini: {
|
||||||
|
default: 'shared@gmail.com',
|
||||||
|
accounts: {
|
||||||
|
'shared@gmail.com': {
|
||||||
|
email: 'shared@gmail.com',
|
||||||
|
tokenFile: 'gemini-shared.json',
|
||||||
|
paused: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
agy: {
|
||||||
|
default: 'shared@gmail.com',
|
||||||
|
accounts: {
|
||||||
|
'shared@gmail.com': {
|
||||||
|
email: 'shared@gmail.com',
|
||||||
|
tokenFile: 'agy-shared.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const dupes = detectCrossProviderDuplicates();
|
||||||
|
expect(dupes.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be case-insensitive on email', () => {
|
||||||
|
writeRegistry({
|
||||||
|
gemini: {
|
||||||
|
default: 'User@Gmail.com',
|
||||||
|
accounts: {
|
||||||
|
'User@Gmail.com': {
|
||||||
|
email: 'User@Gmail.com',
|
||||||
|
tokenFile: 'gemini-user.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
agy: {
|
||||||
|
default: 'user@gmail.com',
|
||||||
|
accounts: {
|
||||||
|
'user@gmail.com': {
|
||||||
|
email: 'user@gmail.com',
|
||||||
|
tokenFile: 'agy-user.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const dupes = detectCrossProviderDuplicates();
|
||||||
|
expect(dupes.size).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// checkNewAccountConflict
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
describe('checkNewAccountConflict', () => {
|
||||||
|
it('should return null for non-Google provider', () => {
|
||||||
|
const result = checkNewAccountConflict('kiro' as never, 'user@gmail.com');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when no conflict', () => {
|
||||||
|
writeRegistry({
|
||||||
|
gemini: {
|
||||||
|
default: 'other@gmail.com',
|
||||||
|
accounts: {
|
||||||
|
'other@gmail.com': {
|
||||||
|
email: 'other@gmail.com',
|
||||||
|
tokenFile: 'gemini-other.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = checkNewAccountConflict('agy', 'new@gmail.com');
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return conflicting providers', () => {
|
||||||
|
writeRegistry({
|
||||||
|
gemini: {
|
||||||
|
default: 'shared@gmail.com',
|
||||||
|
accounts: {
|
||||||
|
'shared@gmail.com': {
|
||||||
|
email: 'shared@gmail.com',
|
||||||
|
tokenFile: 'gemini-shared.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = checkNewAccountConflict('agy', 'shared@gmail.com');
|
||||||
|
expect(result).toEqual(['gemini']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when email is undefined', () => {
|
||||||
|
const result = checkNewAccountConflict('agy', undefined);
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// cleanupStaleAutoPauses
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
describe('cleanupStaleAutoPauses', () => {
|
||||||
|
it('should do nothing when no sessions', () => {
|
||||||
|
// No auto-paused.json exists
|
||||||
|
cleanupStaleAutoPauses();
|
||||||
|
// Should not throw
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should remove sessions with dead PIDs', () => {
|
||||||
|
// Use PID 999999999 which is almost certainly dead
|
||||||
|
writeAutoPaused([
|
||||||
|
{
|
||||||
|
initiator: 'gemini',
|
||||||
|
pid: 999999999,
|
||||||
|
pausedAt: new Date().toISOString(),
|
||||||
|
accounts: [{ provider: 'agy', accountId: 'test@gmail.com' }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Write registry with the paused account so resumeAccount can find it
|
||||||
|
writeRegistry({
|
||||||
|
agy: {
|
||||||
|
default: 'test@gmail.com',
|
||||||
|
accounts: {
|
||||||
|
'test@gmail.com': {
|
||||||
|
email: 'test@gmail.com',
|
||||||
|
tokenFile: 'agy-test.json',
|
||||||
|
paused: true,
|
||||||
|
pausedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
writeTokenFile('agy-test.json', true);
|
||||||
|
|
||||||
|
cleanupStaleAutoPauses();
|
||||||
|
|
||||||
|
const data = readAutoPaused();
|
||||||
|
expect(data.sessions.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep sessions with alive PIDs', () => {
|
||||||
|
const alivePid = process.pid; // Current process is alive
|
||||||
|
|
||||||
|
writeAutoPaused([
|
||||||
|
{
|
||||||
|
initiator: 'gemini',
|
||||||
|
pid: alivePid,
|
||||||
|
pausedAt: new Date().toISOString(),
|
||||||
|
accounts: [{ provider: 'agy', accountId: 'test@gmail.com' }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
cleanupStaleAutoPauses();
|
||||||
|
|
||||||
|
const data = readAutoPaused();
|
||||||
|
expect(data.sessions.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// enforceProviderIsolation
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
describe('enforceProviderIsolation', () => {
|
||||||
|
it('should return 0 for non-Google provider', () => {
|
||||||
|
const result = enforceProviderIsolation('kiro' as never);
|
||||||
|
expect(result).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 0 when no conflicting accounts', () => {
|
||||||
|
writeRegistry({
|
||||||
|
gemini: {
|
||||||
|
default: 'user1@gmail.com',
|
||||||
|
accounts: {
|
||||||
|
'user1@gmail.com': {
|
||||||
|
email: 'user1@gmail.com',
|
||||||
|
tokenFile: 'gemini-user1.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
agy: {
|
||||||
|
default: 'user2@gmail.com',
|
||||||
|
accounts: {
|
||||||
|
'user2@gmail.com': {
|
||||||
|
email: 'user2@gmail.com',
|
||||||
|
tokenFile: 'agy-user2.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
writeTokenFile('gemini-user1.json');
|
||||||
|
writeTokenFile('agy-user2.json');
|
||||||
|
|
||||||
|
const result = enforceProviderIsolation('gemini');
|
||||||
|
expect(result).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should pause conflicting accounts and record session', () => {
|
||||||
|
writeRegistry({
|
||||||
|
gemini: {
|
||||||
|
default: 'shared@gmail.com',
|
||||||
|
accounts: {
|
||||||
|
'shared@gmail.com': {
|
||||||
|
email: 'shared@gmail.com',
|
||||||
|
tokenFile: 'gemini-shared.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
agy: {
|
||||||
|
default: 'shared@gmail.com',
|
||||||
|
accounts: {
|
||||||
|
'shared@gmail.com': {
|
||||||
|
email: 'shared@gmail.com',
|
||||||
|
tokenFile: 'agy-shared.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
writeTokenFile('gemini-shared.json');
|
||||||
|
writeTokenFile('agy-shared.json');
|
||||||
|
|
||||||
|
const result = enforceProviderIsolation('gemini');
|
||||||
|
expect(result).toBe(1);
|
||||||
|
|
||||||
|
// Verify auto-paused.json was written
|
||||||
|
const data = readAutoPaused();
|
||||||
|
expect(data.sessions.length).toBe(1);
|
||||||
|
expect(data.sessions[0].initiator).toBe('gemini');
|
||||||
|
expect(data.sessions[0].pid).toBe(process.pid);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// restoreAutoPausedAccounts
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
describe('restoreAutoPausedAccounts', () => {
|
||||||
|
it('should do nothing when no session exists', () => {
|
||||||
|
restoreAutoPausedAccounts('gemini');
|
||||||
|
// Should not throw
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip accounts re-paused after enforcement', () => {
|
||||||
|
const enforcementTime = '2024-01-01T00:00:00.000Z';
|
||||||
|
const laterTime = '2024-01-01T01:00:00.000Z';
|
||||||
|
|
||||||
|
writeAutoPaused([
|
||||||
|
{
|
||||||
|
initiator: 'gemini',
|
||||||
|
pid: process.pid,
|
||||||
|
pausedAt: enforcementTime,
|
||||||
|
accounts: [{ provider: 'agy', accountId: 'banned@gmail.com' }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
writeRegistry({
|
||||||
|
agy: {
|
||||||
|
default: 'banned@gmail.com',
|
||||||
|
accounts: {
|
||||||
|
'banned@gmail.com': {
|
||||||
|
email: 'banned@gmail.com',
|
||||||
|
tokenFile: 'agy-banned.json',
|
||||||
|
paused: true,
|
||||||
|
pausedAt: laterTime, // Re-paused AFTER enforcement (e.g., ban)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
writeTokenFile('agy-banned.json', true);
|
||||||
|
|
||||||
|
restoreAutoPausedAccounts('gemini');
|
||||||
|
|
||||||
|
// Account should NOT be restored because it was re-paused later
|
||||||
|
const registry = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(ccsDir(), 'cliproxy', 'accounts.json'), 'utf-8')
|
||||||
|
);
|
||||||
|
expect(registry.providers.agy.accounts['banned@gmail.com'].paused).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// handleBanDetection
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
describe('handleBanDetection', () => {
|
||||||
|
it('should pause account when ban error detected', () => {
|
||||||
|
writeRegistry({
|
||||||
|
gemini: {
|
||||||
|
default: 'user@gmail.com',
|
||||||
|
accounts: {
|
||||||
|
'user@gmail.com': {
|
||||||
|
email: 'user@gmail.com',
|
||||||
|
tokenFile: 'gemini-user.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
writeTokenFile('gemini-user.json');
|
||||||
|
|
||||||
|
const result = handleBanDetection(
|
||||||
|
'gemini',
|
||||||
|
'user@gmail.com',
|
||||||
|
'API access disabled in this account'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
|
||||||
|
// Verify account was paused in registry
|
||||||
|
const registry = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(ccsDir(), 'cliproxy', 'accounts.json'), 'utf-8')
|
||||||
|
);
|
||||||
|
expect(registry.providers.gemini.accounts['user@gmail.com'].paused).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for non-ban errors', () => {
|
||||||
|
writeRegistry({
|
||||||
|
gemini: {
|
||||||
|
default: 'user@gmail.com',
|
||||||
|
accounts: {
|
||||||
|
'user@gmail.com': {
|
||||||
|
email: 'user@gmail.com',
|
||||||
|
tokenFile: 'gemini-user.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
writeTokenFile('gemini-user.json');
|
||||||
|
|
||||||
|
const result = handleBanDetection('gemini', 'user@gmail.com', 'Rate limit exceeded');
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
|
||||||
|
// Verify account was NOT paused
|
||||||
|
const registry = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(ccsDir(), 'cliproxy', 'accounts.json'), 'utf-8')
|
||||||
|
);
|
||||||
|
expect(registry.providers.gemini.accounts['user@gmail.com'].paused).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// warnCrossProviderDuplicates
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
describe('warnCrossProviderDuplicates', () => {
|
||||||
|
it('should return true when duplicates exist', () => {
|
||||||
|
writeRegistry({
|
||||||
|
gemini: {
|
||||||
|
default: 'shared@gmail.com',
|
||||||
|
accounts: {
|
||||||
|
'shared@gmail.com': {
|
||||||
|
email: 'shared@gmail.com',
|
||||||
|
tokenFile: 'gemini-shared.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
agy: {
|
||||||
|
default: 'shared@gmail.com',
|
||||||
|
accounts: {
|
||||||
|
'shared@gmail.com': {
|
||||||
|
email: 'shared@gmail.com',
|
||||||
|
tokenFile: 'agy-shared.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = warnCrossProviderDuplicates('gemini');
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when no duplicates', () => {
|
||||||
|
writeRegistry({
|
||||||
|
gemini: {
|
||||||
|
default: 'user1@gmail.com',
|
||||||
|
accounts: {
|
||||||
|
'user1@gmail.com': {
|
||||||
|
email: 'user1@gmail.com',
|
||||||
|
tokenFile: 'gemini-user1.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
agy: {
|
||||||
|
default: 'user2@gmail.com',
|
||||||
|
accounts: {
|
||||||
|
'user2@gmail.com': {
|
||||||
|
email: 'user2@gmail.com',
|
||||||
|
tokenFile: 'agy-user2.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = warnCrossProviderDuplicates('gemini');
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for non-Google providers', () => {
|
||||||
|
writeRegistry({
|
||||||
|
kiro: {
|
||||||
|
default: 'user@example.com',
|
||||||
|
accounts: {
|
||||||
|
'user@example.com': {
|
||||||
|
email: 'user@example.com',
|
||||||
|
tokenFile: 'kiro-user.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = warnCrossProviderDuplicates('kiro' as never);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -552,4 +552,108 @@ auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth"
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('oauth-model-alias fork:true', () => {
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
let testDir;
|
||||||
|
let originalCcsHome;
|
||||||
|
let regenerateConfig;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-fork-'));
|
||||||
|
originalCcsHome = process.env.CCS_HOME;
|
||||||
|
process.env.CCS_HOME = testDir;
|
||||||
|
|
||||||
|
delete require.cache[require.resolve('../../../dist/cliproxy/config-generator')];
|
||||||
|
delete require.cache[require.resolve('../../../dist/utils/config-manager')];
|
||||||
|
const configGenerator = require('../../../dist/cliproxy/config-generator');
|
||||||
|
regenerateConfig = configGenerator.regenerateConfig;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env.CCS_HOME = originalCcsHome;
|
||||||
|
if (testDir && fs.existsSync(testDir)) {
|
||||||
|
fs.rmSync(testDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates fork:true for Claude model aliases', () => {
|
||||||
|
regenerateConfig();
|
||||||
|
|
||||||
|
const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
|
||||||
|
const config = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8');
|
||||||
|
|
||||||
|
// Claude aliases should have fork: true
|
||||||
|
assert(config.includes('claude-sonnet-4-5'), 'Should include Claude sonnet model');
|
||||||
|
assert(config.includes('fork: true'), 'Should include fork: true for Claude aliases');
|
||||||
|
|
||||||
|
// Verify fork: true appears after each Claude alias entry
|
||||||
|
const lines = config.split('\n');
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (lines[i].includes('alias: gemini-claude-')) {
|
||||||
|
assert(
|
||||||
|
lines[i + 1] && lines[i + 1].trim() === 'fork: true',
|
||||||
|
`fork: true should follow Claude alias at line ${i}: ${lines[i]}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not generate fork:true for non-Claude aliases', () => {
|
||||||
|
regenerateConfig();
|
||||||
|
|
||||||
|
const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
|
||||||
|
const config = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8');
|
||||||
|
|
||||||
|
// Gemini aliases should NOT have fork: true
|
||||||
|
const lines = config.split('\n');
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (lines[i].includes('alias: gemini-3-') || lines[i].includes('alias: gemini-2.5-')) {
|
||||||
|
const nextLine = lines[i + 1] || '';
|
||||||
|
assert(
|
||||||
|
!nextLine.trim().startsWith('fork:'),
|
||||||
|
`Gemini alias should not have fork: ${lines[i]}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves user-added aliases with fork during regeneration', () => {
|
||||||
|
const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
|
||||||
|
fs.mkdirSync(cliproxyDir, { recursive: true });
|
||||||
|
|
||||||
|
const initialConfig = `# CLIProxyAPI config generated by CCS v6
|
||||||
|
port: 8317
|
||||||
|
api-keys:
|
||||||
|
- "ccs-internal-managed"
|
||||||
|
auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth"
|
||||||
|
oauth-model-alias:
|
||||||
|
antigravity:
|
||||||
|
- name: custom-model
|
||||||
|
alias: my-custom-alias
|
||||||
|
fork: true
|
||||||
|
`;
|
||||||
|
fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig);
|
||||||
|
|
||||||
|
regenerateConfig();
|
||||||
|
|
||||||
|
const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8');
|
||||||
|
assert(newConfig.includes('custom-model'), 'Should preserve custom alias name');
|
||||||
|
assert(newConfig.includes('my-custom-alias'), 'Should preserve custom alias');
|
||||||
|
|
||||||
|
// Check fork is preserved for user alias
|
||||||
|
const lines = newConfig.split('\n');
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (lines[i].includes('alias: my-custom-alias')) {
|
||||||
|
assert(
|
||||||
|
lines[i + 1] && lines[i + 1].trim() === 'fork: true',
|
||||||
|
'Should preserve fork: true for user-added alias'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
/**
|
||||||
|
* Tests for migrateDeprecatedModelNames() in env-builder.ts
|
||||||
|
* Validates gemini-claude-* → claude-* prefix migration logic
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||||
|
|
||||||
|
// We test the migration indirectly through getEffectiveEnvVars,
|
||||||
|
// but also directly by importing the module and checking file output.
|
||||||
|
|
||||||
|
describe('migrateDeprecatedModelNames', () => {
|
||||||
|
let tmpDir: string;
|
||||||
|
let settingsPath: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-migration-test-'));
|
||||||
|
settingsPath = path.join(tmpDir, 'test.settings.json');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function writeSettings(env: Record<string, string>) {
|
||||||
|
fs.writeFileSync(settingsPath, JSON.stringify({ env }, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSettings(): Record<string, string> {
|
||||||
|
return JSON.parse(fs.readFileSync(settingsPath, 'utf-8')).env;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import the migration function dynamically to test it
|
||||||
|
// Since it's not exported, we test via the settings file write behavior
|
||||||
|
// by writing a settings file with deprecated names and loading via env-builder
|
||||||
|
|
||||||
|
it('replaces gemini-claude- prefix with claude- prefix', () => {
|
||||||
|
writeSettings({
|
||||||
|
ANTHROPIC_MODEL: 'gemini-claude-opus-4-6-thinking',
|
||||||
|
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-claude-opus-4-5-thinking',
|
||||||
|
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-claude-sonnet-4-5-thinking',
|
||||||
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-claude-sonnet-4-5',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate migration logic inline (same as env-builder.ts)
|
||||||
|
const DEPRECATED_PREFIX = 'gemini-claude-';
|
||||||
|
const UPSTREAM_PREFIX = 'claude-';
|
||||||
|
const MODEL_KEYS = [
|
||||||
|
'ANTHROPIC_MODEL',
|
||||||
|
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||||
|
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||||
|
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||||
|
];
|
||||||
|
|
||||||
|
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
||||||
|
let migrated = false;
|
||||||
|
for (const key of MODEL_KEYS) {
|
||||||
|
const value = settings.env[key];
|
||||||
|
if (typeof value === 'string' && value.toLowerCase().startsWith(DEPRECATED_PREFIX)) {
|
||||||
|
settings.env[key] = UPSTREAM_PREFIX + value.slice(DEPRECATED_PREFIX.length);
|
||||||
|
migrated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (migrated) {
|
||||||
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = readSettings();
|
||||||
|
expect(result.ANTHROPIC_MODEL).toBe('claude-opus-4-6-thinking');
|
||||||
|
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-5-thinking');
|
||||||
|
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking');
|
||||||
|
expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4-5');
|
||||||
|
expect(migrated).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves suffixes like [1m] after migration', () => {
|
||||||
|
writeSettings({
|
||||||
|
ANTHROPIC_MODEL: 'gemini-claude-opus-4-6-thinking[1m]',
|
||||||
|
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-claude-opus-4-5-thinking',
|
||||||
|
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
|
||||||
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-sonnet-4-5',
|
||||||
|
});
|
||||||
|
|
||||||
|
const DEPRECATED_PREFIX = 'gemini-claude-';
|
||||||
|
const UPSTREAM_PREFIX = 'claude-';
|
||||||
|
const MODEL_KEYS = [
|
||||||
|
'ANTHROPIC_MODEL',
|
||||||
|
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||||
|
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||||
|
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||||
|
];
|
||||||
|
|
||||||
|
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
||||||
|
let migrated = false;
|
||||||
|
for (const key of MODEL_KEYS) {
|
||||||
|
const value = settings.env[key];
|
||||||
|
if (typeof value === 'string' && value.toLowerCase().startsWith(DEPRECATED_PREFIX)) {
|
||||||
|
settings.env[key] = UPSTREAM_PREFIX + value.slice(DEPRECATED_PREFIX.length);
|
||||||
|
migrated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (migrated) {
|
||||||
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = readSettings();
|
||||||
|
expect(result.ANTHROPIC_MODEL).toBe('claude-opus-4-6-thinking[1m]');
|
||||||
|
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-5-thinking');
|
||||||
|
expect(migrated).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when model names already use claude- prefix', () => {
|
||||||
|
writeSettings({
|
||||||
|
ANTHROPIC_MODEL: 'claude-opus-4-6-thinking',
|
||||||
|
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-thinking',
|
||||||
|
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
|
||||||
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-sonnet-4-5',
|
||||||
|
});
|
||||||
|
|
||||||
|
const originalContent = fs.readFileSync(settingsPath, 'utf-8');
|
||||||
|
|
||||||
|
const DEPRECATED_PREFIX = 'gemini-claude-';
|
||||||
|
const UPSTREAM_PREFIX = 'claude-';
|
||||||
|
const MODEL_KEYS = [
|
||||||
|
'ANTHROPIC_MODEL',
|
||||||
|
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||||
|
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||||
|
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||||
|
];
|
||||||
|
|
||||||
|
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
||||||
|
let migrated = false;
|
||||||
|
for (const key of MODEL_KEYS) {
|
||||||
|
const value = settings.env[key];
|
||||||
|
if (typeof value === 'string' && value.toLowerCase().startsWith(DEPRECATED_PREFIX)) {
|
||||||
|
settings.env[key] = UPSTREAM_PREFIX + value.slice(DEPRECATED_PREFIX.length);
|
||||||
|
migrated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(migrated).toBe(false);
|
||||||
|
// File should not be rewritten
|
||||||
|
expect(fs.readFileSync(settingsPath, 'utf-8')).toBe(originalContent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips non-string env values', () => {
|
||||||
|
// Write raw JSON with a non-string value
|
||||||
|
const settings = {
|
||||||
|
env: {
|
||||||
|
ANTHROPIC_MODEL: 'gemini-claude-opus-4-6-thinking',
|
||||||
|
ANTHROPIC_DEFAULT_OPUS_MODEL: null,
|
||||||
|
ANTHROPIC_DEFAULT_SONNET_MODEL: 123,
|
||||||
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-claude-sonnet-4-5',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
||||||
|
|
||||||
|
const DEPRECATED_PREFIX = 'gemini-claude-';
|
||||||
|
const UPSTREAM_PREFIX = 'claude-';
|
||||||
|
const MODEL_KEYS = [
|
||||||
|
'ANTHROPIC_MODEL',
|
||||||
|
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||||
|
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||||
|
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||||
|
];
|
||||||
|
|
||||||
|
const loaded = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
||||||
|
let migrated = false;
|
||||||
|
for (const key of MODEL_KEYS) {
|
||||||
|
const value = loaded.env[key];
|
||||||
|
if (typeof value === 'string' && value.toLowerCase().startsWith(DEPRECATED_PREFIX)) {
|
||||||
|
loaded.env[key] = UPSTREAM_PREFIX + value.slice(DEPRECATED_PREFIX.length);
|
||||||
|
migrated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(migrated).toBe(true);
|
||||||
|
expect(loaded.env.ANTHROPIC_MODEL).toBe('claude-opus-4-6-thinking');
|
||||||
|
expect(loaded.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBeNull();
|
||||||
|
expect(loaded.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe(123);
|
||||||
|
expect(loaded.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-sonnet-4-5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not touch non-model env vars', () => {
|
||||||
|
writeSettings({
|
||||||
|
ANTHROPIC_MODEL: 'gemini-claude-opus-4-6-thinking',
|
||||||
|
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy',
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||||
|
ANTHROPIC_MAX_TOKENS: '64000',
|
||||||
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-claude-sonnet-4-5',
|
||||||
|
});
|
||||||
|
|
||||||
|
const DEPRECATED_PREFIX = 'gemini-claude-';
|
||||||
|
const UPSTREAM_PREFIX = 'claude-';
|
||||||
|
const MODEL_KEYS = [
|
||||||
|
'ANTHROPIC_MODEL',
|
||||||
|
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||||
|
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||||
|
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||||
|
];
|
||||||
|
|
||||||
|
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
||||||
|
for (const key of MODEL_KEYS) {
|
||||||
|
const value = settings.env[key];
|
||||||
|
if (typeof value === 'string' && value.toLowerCase().startsWith(DEPRECATED_PREFIX)) {
|
||||||
|
settings.env[key] = UPSTREAM_PREFIX + value.slice(DEPRECATED_PREFIX.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-model vars should be untouched
|
||||||
|
expect(settings.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/agy');
|
||||||
|
expect(settings.env.ANTHROPIC_AUTH_TOKEN).toBe('ccs-internal-managed');
|
||||||
|
expect(settings.env.ANTHROPIC_MAX_TOKENS).toBe('64000');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles Gemini model names (non-Claude) without modification', () => {
|
||||||
|
writeSettings({
|
||||||
|
ANTHROPIC_MODEL: 'gemini-3-pro-preview',
|
||||||
|
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-3-pro-preview',
|
||||||
|
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-3-pro-preview',
|
||||||
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-3-flash-preview',
|
||||||
|
});
|
||||||
|
|
||||||
|
const DEPRECATED_PREFIX = 'gemini-claude-';
|
||||||
|
const MODEL_KEYS = [
|
||||||
|
'ANTHROPIC_MODEL',
|
||||||
|
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
||||||
|
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
||||||
|
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
||||||
|
];
|
||||||
|
|
||||||
|
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
||||||
|
let migrated = false;
|
||||||
|
for (const key of MODEL_KEYS) {
|
||||||
|
const value = settings.env[key];
|
||||||
|
if (typeof value === 'string' && value.toLowerCase().startsWith(DEPRECATED_PREFIX)) {
|
||||||
|
migrated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(migrated).toBe(false);
|
||||||
|
expect(settings.env.ANTHROPIC_MODEL).toBe('gemini-3-pro-preview');
|
||||||
|
expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gemini-3-flash-preview');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -56,8 +56,8 @@ describe('shouldApplyExtendedContext', () => {
|
|||||||
expect(shouldApplyExtendedContext('gemini', 'gemini-3-pro-preview', undefined)).toBe(true);
|
expect(shouldApplyExtendedContext('gemini', 'gemini-3-pro-preview', undefined)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns false for gemini-claude-* models (not native Gemini)', () => {
|
it('returns false for Claude models without explicit flag', () => {
|
||||||
expect(shouldApplyExtendedContext('agy', 'gemini-claude-opus-4-5-thinking', undefined)).toBe(
|
expect(shouldApplyExtendedContext('agy', 'claude-opus-4-5-thinking', undefined)).toBe(
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -155,12 +155,12 @@ describe('applyExtendedContextConfig', () => {
|
|||||||
it('strips [1m] suffix from models that no longer support extended context', () => {
|
it('strips [1m] suffix from models that no longer support extended context', () => {
|
||||||
// Simulates user who had [1m] in saved settings before support was removed
|
// Simulates user who had [1m] in saved settings before support was removed
|
||||||
const env: NodeJS.ProcessEnv = {
|
const env: NodeJS.ProcessEnv = {
|
||||||
ANTHROPIC_MODEL: 'gemini-claude-opus-4-6-thinking[1m]',
|
ANTHROPIC_MODEL: 'claude-opus-4-6-thinking[1m]',
|
||||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-claude-opus-4-6-thinking[1m]',
|
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking[1m]',
|
||||||
};
|
};
|
||||||
applyExtendedContextConfig(env, 'agy', undefined);
|
applyExtendedContextConfig(env, 'agy', undefined);
|
||||||
expect(env.ANTHROPIC_MODEL).toBe('gemini-claude-opus-4-6-thinking');
|
expect(env.ANTHROPIC_MODEL).toBe('claude-opus-4-6-thinking');
|
||||||
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gemini-claude-opus-4-6-thinking');
|
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('strips [1m] suffix when --no-1m is explicit even if model has it', () => {
|
it('strips [1m] suffix when --no-1m is explicit even if model has it', () => {
|
||||||
|
|||||||
@@ -39,13 +39,13 @@ describe('Model Catalog', () => {
|
|||||||
describe('AGY models', () => {
|
describe('AGY models', () => {
|
||||||
it('has correct default model', () => {
|
it('has correct default model', () => {
|
||||||
const { MODEL_CATALOG } = modelCatalog;
|
const { MODEL_CATALOG } = modelCatalog;
|
||||||
assert.strictEqual(MODEL_CATALOG.agy.defaultModel, 'gemini-claude-opus-4-6-thinking');
|
assert.strictEqual(MODEL_CATALOG.agy.defaultModel, 'claude-opus-4-6-thinking');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('includes Claude Opus 4.5 Thinking', () => {
|
it('includes Claude Opus 4.5 Thinking', () => {
|
||||||
const { MODEL_CATALOG } = modelCatalog;
|
const { MODEL_CATALOG } = modelCatalog;
|
||||||
const opus = MODEL_CATALOG.agy.models.find(
|
const opus = MODEL_CATALOG.agy.models.find(
|
||||||
(m) => m.id === 'gemini-claude-opus-4-5-thinking'
|
(m) => m.id === 'claude-opus-4-5-thinking'
|
||||||
);
|
);
|
||||||
assert(opus, 'Should include Claude Opus 4.5 Thinking');
|
assert(opus, 'Should include Claude Opus 4.5 Thinking');
|
||||||
assert.strictEqual(opus.name, 'Claude Opus 4.5 Thinking');
|
assert.strictEqual(opus.name, 'Claude Opus 4.5 Thinking');
|
||||||
@@ -54,7 +54,7 @@ describe('Model Catalog', () => {
|
|||||||
it('includes Claude Sonnet 4.5 Thinking', () => {
|
it('includes Claude Sonnet 4.5 Thinking', () => {
|
||||||
const { MODEL_CATALOG } = modelCatalog;
|
const { MODEL_CATALOG } = modelCatalog;
|
||||||
const sonnetThinking = MODEL_CATALOG.agy.models.find(
|
const sonnetThinking = MODEL_CATALOG.agy.models.find(
|
||||||
(m) => m.id === 'gemini-claude-sonnet-4-5-thinking'
|
(m) => m.id === 'claude-sonnet-4-5-thinking'
|
||||||
);
|
);
|
||||||
assert(sonnetThinking, 'Should include Claude Sonnet 4.5 Thinking');
|
assert(sonnetThinking, 'Should include Claude Sonnet 4.5 Thinking');
|
||||||
assert.strictEqual(sonnetThinking.name, 'Claude Sonnet 4.5 Thinking');
|
assert.strictEqual(sonnetThinking.name, 'Claude Sonnet 4.5 Thinking');
|
||||||
@@ -62,7 +62,7 @@ describe('Model Catalog', () => {
|
|||||||
|
|
||||||
it('includes Claude Sonnet 4.5', () => {
|
it('includes Claude Sonnet 4.5', () => {
|
||||||
const { MODEL_CATALOG } = modelCatalog;
|
const { MODEL_CATALOG } = modelCatalog;
|
||||||
const sonnet = MODEL_CATALOG.agy.models.find((m) => m.id === 'gemini-claude-sonnet-4-5');
|
const sonnet = MODEL_CATALOG.agy.models.find((m) => m.id === 'claude-sonnet-4-5');
|
||||||
assert(sonnet, 'Should include Claude Sonnet 4.5');
|
assert(sonnet, 'Should include Claude Sonnet 4.5');
|
||||||
assert.strictEqual(sonnet.name, 'Claude Sonnet 4.5');
|
assert.strictEqual(sonnet.name, 'Claude Sonnet 4.5');
|
||||||
});
|
});
|
||||||
@@ -160,7 +160,7 @@ describe('Model Catalog', () => {
|
|||||||
describe('findModel', () => {
|
describe('findModel', () => {
|
||||||
it('finds Claude Opus 4.5 Thinking in agy', () => {
|
it('finds Claude Opus 4.5 Thinking in agy', () => {
|
||||||
const { findModel } = modelCatalog;
|
const { findModel } = modelCatalog;
|
||||||
const model = findModel('agy', 'gemini-claude-opus-4-5-thinking');
|
const model = findModel('agy', 'claude-opus-4-5-thinking');
|
||||||
assert(model, 'Should find model');
|
assert(model, 'Should find model');
|
||||||
assert.strictEqual(model.name, 'Claude Opus 4.5 Thinking');
|
assert.strictEqual(model.name, 'Claude Opus 4.5 Thinking');
|
||||||
});
|
});
|
||||||
@@ -227,7 +227,7 @@ describe('Model Catalog', () => {
|
|||||||
it('Claude Opus 4.5 Thinking is not deprecated', () => {
|
it('Claude Opus 4.5 Thinking is not deprecated', () => {
|
||||||
const { MODEL_CATALOG } = modelCatalog;
|
const { MODEL_CATALOG } = modelCatalog;
|
||||||
const opus = MODEL_CATALOG.agy.models.find(
|
const opus = MODEL_CATALOG.agy.models.find(
|
||||||
(m) => m.id === 'gemini-claude-opus-4-5-thinking'
|
(m) => m.id === 'claude-opus-4-5-thinking'
|
||||||
);
|
);
|
||||||
assert(opus, 'Should include Claude Opus 4.5 Thinking');
|
assert(opus, 'Should include Claude Opus 4.5 Thinking');
|
||||||
assert.strictEqual(opus.deprecated, undefined, 'Should not be marked as deprecated');
|
assert.strictEqual(opus.deprecated, undefined, 'Should not be marked as deprecated');
|
||||||
@@ -236,7 +236,7 @@ describe('Model Catalog', () => {
|
|||||||
it('Claude Sonnet 4.5 Thinking is not deprecated', () => {
|
it('Claude Sonnet 4.5 Thinking is not deprecated', () => {
|
||||||
const { MODEL_CATALOG } = modelCatalog;
|
const { MODEL_CATALOG } = modelCatalog;
|
||||||
const sonnetThinking = MODEL_CATALOG.agy.models.find(
|
const sonnetThinking = MODEL_CATALOG.agy.models.find(
|
||||||
(m) => m.id === 'gemini-claude-sonnet-4-5-thinking'
|
(m) => m.id === 'claude-sonnet-4-5-thinking'
|
||||||
);
|
);
|
||||||
assert(sonnetThinking, 'Should include Claude Sonnet 4.5 Thinking');
|
assert(sonnetThinking, 'Should include Claude Sonnet 4.5 Thinking');
|
||||||
assert.strictEqual(sonnetThinking.deprecated, undefined, 'Should not be marked as deprecated');
|
assert.strictEqual(sonnetThinking.deprecated, undefined, 'Should not be marked as deprecated');
|
||||||
@@ -247,13 +247,13 @@ describe('Model Catalog', () => {
|
|||||||
const models = MODEL_CATALOG.agy.models;
|
const models = MODEL_CATALOG.agy.models;
|
||||||
|
|
||||||
// Find indices of thinking models
|
// Find indices of thinking models
|
||||||
const opusIdx = models.findIndex((m) => m.id === 'gemini-claude-opus-4-5-thinking');
|
const opusIdx = models.findIndex((m) => m.id === 'claude-opus-4-5-thinking');
|
||||||
const sonnetThinkingIdx = models.findIndex(
|
const sonnetThinkingIdx = models.findIndex(
|
||||||
(m) => m.id === 'gemini-claude-sonnet-4-5-thinking'
|
(m) => m.id === 'claude-sonnet-4-5-thinking'
|
||||||
);
|
);
|
||||||
|
|
||||||
// Find indices of non-thinking models
|
// Find indices of non-thinking models
|
||||||
const sonnetIdx = models.findIndex((m) => m.id === 'gemini-claude-sonnet-4-5');
|
const sonnetIdx = models.findIndex((m) => m.id === 'claude-sonnet-4-5');
|
||||||
const geminiIdx = models.findIndex((m) => m.id === 'gemini-3-pro-preview');
|
const geminiIdx = models.findIndex((m) => m.id === 'gemini-3-pro-preview');
|
||||||
|
|
||||||
// Thinking models should come before non-thinking models
|
// Thinking models should come before non-thinking models
|
||||||
@@ -273,13 +273,13 @@ describe('Model Catalog', () => {
|
|||||||
describe('isModelDeprecated', () => {
|
describe('isModelDeprecated', () => {
|
||||||
it('returns false for thinking models (no longer deprecated)', () => {
|
it('returns false for thinking models (no longer deprecated)', () => {
|
||||||
const { isModelDeprecated } = modelCatalog;
|
const { isModelDeprecated } = modelCatalog;
|
||||||
assert.strictEqual(isModelDeprecated('agy', 'gemini-claude-opus-4-5-thinking'), false);
|
assert.strictEqual(isModelDeprecated('agy', 'claude-opus-4-5-thinking'), false);
|
||||||
assert.strictEqual(isModelDeprecated('agy', 'gemini-claude-sonnet-4-5-thinking'), false);
|
assert.strictEqual(isModelDeprecated('agy', 'claude-sonnet-4-5-thinking'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns false for non-deprecated models', () => {
|
it('returns false for non-deprecated models', () => {
|
||||||
const { isModelDeprecated } = modelCatalog;
|
const { isModelDeprecated } = modelCatalog;
|
||||||
assert.strictEqual(isModelDeprecated('agy', 'gemini-claude-sonnet-4-5'), false);
|
assert.strictEqual(isModelDeprecated('agy', 'claude-sonnet-4-5'), false);
|
||||||
assert.strictEqual(isModelDeprecated('agy', 'gemini-3-pro-preview'), false);
|
assert.strictEqual(isModelDeprecated('agy', 'gemini-3-pro-preview'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -292,13 +292,13 @@ describe('Model Catalog', () => {
|
|||||||
describe('getModelDeprecationReason', () => {
|
describe('getModelDeprecationReason', () => {
|
||||||
it('returns undefined for thinking models (no longer deprecated)', () => {
|
it('returns undefined for thinking models (no longer deprecated)', () => {
|
||||||
const { getModelDeprecationReason } = modelCatalog;
|
const { getModelDeprecationReason } = modelCatalog;
|
||||||
assert.strictEqual(getModelDeprecationReason('agy', 'gemini-claude-opus-4-5-thinking'), undefined);
|
assert.strictEqual(getModelDeprecationReason('agy', 'claude-opus-4-5-thinking'), undefined);
|
||||||
assert.strictEqual(getModelDeprecationReason('agy', 'gemini-claude-sonnet-4-5-thinking'), undefined);
|
assert.strictEqual(getModelDeprecationReason('agy', 'claude-sonnet-4-5-thinking'), undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns undefined for non-deprecated models', () => {
|
it('returns undefined for non-deprecated models', () => {
|
||||||
const { getModelDeprecationReason } = modelCatalog;
|
const { getModelDeprecationReason } = modelCatalog;
|
||||||
assert.strictEqual(getModelDeprecationReason('agy', 'gemini-claude-sonnet-4-5'), undefined);
|
assert.strictEqual(getModelDeprecationReason('agy', 'claude-sonnet-4-5'), undefined);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -94,9 +94,9 @@ describe('Model Config', () => {
|
|||||||
env: {
|
env: {
|
||||||
ANTHROPIC_BASE_URL: expect.any(String),
|
ANTHROPIC_BASE_URL: expect.any(String),
|
||||||
ANTHROPIC_AUTH_TOKEN: expect.any(String),
|
ANTHROPIC_AUTH_TOKEN: expect.any(String),
|
||||||
ANTHROPIC_MODEL: 'gemini-claude-opus-4-5-thinking',
|
ANTHROPIC_MODEL: 'claude-opus-4-5-thinking',
|
||||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-claude-opus-4-5-thinking',
|
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-thinking',
|
||||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-claude-opus-4-5-thinking',
|
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-opus-4-5-thinking',
|
||||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: expect.any(String),
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: expect.any(String),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -106,9 +106,9 @@ describe('Model Config', () => {
|
|||||||
env: {
|
env: {
|
||||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy',
|
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy',
|
||||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||||
ANTHROPIC_MODEL: 'gemini-claude-opus-4-5-thinking',
|
ANTHROPIC_MODEL: 'claude-opus-4-5-thinking',
|
||||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-claude-opus-4-5-thinking',
|
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-thinking',
|
||||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-claude-opus-4-5-thinking',
|
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-opus-4-5-thinking',
|
||||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-3-flash-preview',
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-3-flash-preview',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -120,7 +120,7 @@ describe('Model Config', () => {
|
|||||||
assert(parsed.env.ANTHROPIC_MODEL, 'Should have ANTHROPIC_MODEL');
|
assert(parsed.env.ANTHROPIC_MODEL, 'Should have ANTHROPIC_MODEL');
|
||||||
assert.strictEqual(
|
assert.strictEqual(
|
||||||
parsed.env.ANTHROPIC_MODEL,
|
parsed.env.ANTHROPIC_MODEL,
|
||||||
'gemini-claude-opus-4-5-thinking'
|
'claude-opus-4-5-thinking'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -129,9 +129,9 @@ describe('Model Config', () => {
|
|||||||
env: {
|
env: {
|
||||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy',
|
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy',
|
||||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||||
ANTHROPIC_MODEL: 'gemini-claude-opus-4-5-thinking',
|
ANTHROPIC_MODEL: 'claude-opus-4-5-thinking',
|
||||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-claude-opus-4-5-thinking',
|
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-thinking',
|
||||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-claude-opus-4-5-thinking',
|
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-opus-4-5-thinking',
|
||||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-3-flash-preview',
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-3-flash-preview',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
/**
|
||||||
|
* Runtime Quota Monitor Unit Tests
|
||||||
|
*
|
||||||
|
* Tests the quota monitor lifecycle:
|
||||||
|
* - startQuotaMonitor / stopQuotaMonitor behavior
|
||||||
|
* - No-op conditions for non-agy, manual mode, disabled config
|
||||||
|
* - Idempotent stopQuotaMonitor
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { startQuotaMonitor, stopQuotaMonitor, clearQuotaCache } from '../../../src/cliproxy/quota-manager';
|
||||||
|
|
||||||
|
// Setup test isolation
|
||||||
|
let tmpDir: string;
|
||||||
|
let origCcsHome: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-monitor-'));
|
||||||
|
origCcsHome = process.env.CCS_HOME;
|
||||||
|
process.env.CCS_HOME = tmpDir;
|
||||||
|
clearQuotaCache(); // Clean cache between tests
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
stopQuotaMonitor(); // Clean up any active timers
|
||||||
|
clearQuotaCache();
|
||||||
|
if (origCcsHome !== undefined) {
|
||||||
|
process.env.CCS_HOME = origCcsHome;
|
||||||
|
} else {
|
||||||
|
delete process.env.CCS_HOME;
|
||||||
|
}
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Runtime Quota Monitor', () => {
|
||||||
|
describe('startQuotaMonitor', () => {
|
||||||
|
it('should accept non-agy provider without throwing', () => {
|
||||||
|
// Non-agy providers should be silently ignored
|
||||||
|
expect(() => {
|
||||||
|
startQuotaMonitor('gemini', 'test@gmail.com');
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept agy provider without throwing', () => {
|
||||||
|
// Setup config
|
||||||
|
const configDir = path.join(tmpDir, '.ccs', 'config');
|
||||||
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(configDir, 'unified-config.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
version: 2,
|
||||||
|
quota_management: {
|
||||||
|
mode: 'auto',
|
||||||
|
runtime_monitor: {
|
||||||
|
enabled: false, // Disabled to avoid actual polling
|
||||||
|
normal_interval_seconds: 300,
|
||||||
|
critical_interval_seconds: 60,
|
||||||
|
warn_threshold: 20,
|
||||||
|
exhaustion_threshold: 5,
|
||||||
|
cooldown_minutes: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
startQuotaMonitor('agy', 'test@gmail.com');
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be no-op when config missing or no quota_management', () => {
|
||||||
|
// No config file — should not throw
|
||||||
|
expect(() => {
|
||||||
|
startQuotaMonitor('agy', 'test@gmail.com');
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle manual mode gracefully', () => {
|
||||||
|
const configDir = path.join(tmpDir, '.ccs', 'config');
|
||||||
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(configDir, 'unified-config.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
version: 2,
|
||||||
|
quota_management: {
|
||||||
|
mode: 'manual',
|
||||||
|
runtime_monitor: {
|
||||||
|
enabled: true,
|
||||||
|
normal_interval_seconds: 300,
|
||||||
|
critical_interval_seconds: 60,
|
||||||
|
warn_threshold: 20,
|
||||||
|
exhaustion_threshold: 5,
|
||||||
|
cooldown_minutes: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
startQuotaMonitor('agy', 'test@gmail.com');
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle disabled monitor gracefully', () => {
|
||||||
|
const configDir = path.join(tmpDir, '.ccs', 'config');
|
||||||
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(configDir, 'unified-config.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
version: 2,
|
||||||
|
quota_management: {
|
||||||
|
mode: 'auto',
|
||||||
|
runtime_monitor: {
|
||||||
|
enabled: false,
|
||||||
|
normal_interval_seconds: 300,
|
||||||
|
critical_interval_seconds: 60,
|
||||||
|
warn_threshold: 20,
|
||||||
|
exhaustion_threshold: 5,
|
||||||
|
cooldown_minutes: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
startQuotaMonitor('agy', 'test@gmail.com');
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stopQuotaMonitor', () => {
|
||||||
|
it('should be idempotent', () => {
|
||||||
|
expect(() => {
|
||||||
|
stopQuotaMonitor();
|
||||||
|
stopQuotaMonitor();
|
||||||
|
stopQuotaMonitor();
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should complete safely when called without prior start', () => {
|
||||||
|
// No prior startQuotaMonitor call
|
||||||
|
expect(() => {
|
||||||
|
stopQuotaMonitor();
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple start/stop cycles', () => {
|
||||||
|
const configDir = path.join(tmpDir, '.ccs', 'config');
|
||||||
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(configDir, 'unified-config.json'),
|
||||||
|
JSON.stringify({
|
||||||
|
version: 2,
|
||||||
|
quota_management: {
|
||||||
|
mode: 'auto',
|
||||||
|
runtime_monitor: {
|
||||||
|
enabled: false,
|
||||||
|
normal_interval_seconds: 300,
|
||||||
|
critical_interval_seconds: 60,
|
||||||
|
warn_threshold: 20,
|
||||||
|
exhaustion_threshold: 5,
|
||||||
|
cooldown_minutes: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
startQuotaMonitor('agy', 'test@gmail.com');
|
||||||
|
stopQuotaMonitor();
|
||||||
|
startQuotaMonitor('agy', 'test@gmail.com');
|
||||||
|
stopQuotaMonitor();
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -106,7 +106,7 @@ describe('Thinking Validator', () => {
|
|||||||
|
|
||||||
describe('Budget-type models (like Claude via agy)', () => {
|
describe('Budget-type models (like Claude via agy)', () => {
|
||||||
// Claude models via agy use budget-type thinking
|
// Claude models via agy use budget-type thinking
|
||||||
const budgetModel = 'gemini-claude-sonnet-4-5-thinking';
|
const budgetModel = 'claude-sonnet-4-5-thinking';
|
||||||
|
|
||||||
it('should accept valid numeric budget', () => {
|
it('should accept valid numeric budget', () => {
|
||||||
const result = validateThinking('agy', budgetModel, 8192);
|
const result = validateThinking('agy', budgetModel, 8192);
|
||||||
|
|||||||
@@ -125,14 +125,14 @@ describe('env-command', () => {
|
|||||||
const result = transformToOpenAI({
|
const result = transformToOpenAI({
|
||||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/gemini',
|
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/gemini',
|
||||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||||
ANTHROPIC_MODEL: 'gemini-claude-sonnet-4-5',
|
ANTHROPIC_MODEL: 'claude-sonnet-4-5',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
OPENAI_API_KEY: 'ccs-internal-managed',
|
OPENAI_API_KEY: 'ccs-internal-managed',
|
||||||
OPENAI_BASE_URL: 'http://127.0.0.1:8317/api/provider/gemini',
|
OPENAI_BASE_URL: 'http://127.0.0.1:8317/api/provider/gemini',
|
||||||
LOCAL_ENDPOINT: 'http://127.0.0.1:8317/api/provider/gemini',
|
LOCAL_ENDPOINT: 'http://127.0.0.1:8317/api/provider/gemini',
|
||||||
OPENAI_MODEL: 'gemini-claude-sonnet-4-5',
|
OPENAI_MODEL: 'claude-sonnet-4-5',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -103,13 +103,13 @@ describe('InteractivePrompt', () => {
|
|||||||
process.env.CCS_YES = '1';
|
process.env.CCS_YES = '1';
|
||||||
|
|
||||||
const options = [
|
const options = [
|
||||||
{ id: 'gemini-claude-opus-4-5-thinking', label: 'Claude Opus 4.5 Thinking' },
|
{ id: 'claude-opus-4-5-thinking', label: 'Claude Opus 4.5 Thinking' },
|
||||||
{ id: 'gemini-claude-sonnet-4-5', label: 'Claude Sonnet 4.5' },
|
{ id: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5' },
|
||||||
];
|
];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await InteractivePrompt.selectFromList('Select:', options);
|
const result = await InteractivePrompt.selectFromList('Select:', options);
|
||||||
assert.strictEqual(result, 'gemini-claude-opus-4-5-thinking');
|
assert.strictEqual(result, 'claude-opus-4-5-thinking');
|
||||||
} finally {
|
} finally {
|
||||||
delete process.env.CCS_YES;
|
delete process.env.CCS_YES;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,14 +8,14 @@ export const EXTENDED_CONTEXT_SUFFIX = '[1m]';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if model is a native Gemini model (auto-enabled behavior).
|
* Check if model is a native Gemini model (auto-enabled behavior).
|
||||||
* Native Gemini models: gemini-* but NOT gemini-claude-*
|
* Native Gemini models have the gemini-* prefix.
|
||||||
*
|
*
|
||||||
* NOTE: This function is intentionally duplicated from src/cliproxy/model-catalog.ts
|
* NOTE: This function is intentionally duplicated from src/cliproxy/model-catalog.ts
|
||||||
* to avoid bundling backend code in the UI. Keep both in sync.
|
* to avoid bundling backend code in the UI. Keep both in sync.
|
||||||
*/
|
*/
|
||||||
export function isNativeGeminiModel(modelId: string): boolean {
|
export function isNativeGeminiModel(modelId: string): boolean {
|
||||||
const lower = modelId.toLowerCase();
|
const lower = modelId.toLowerCase();
|
||||||
return lower.startsWith('gemini-') && !lower.startsWith('gemini-claude-');
|
return lower.startsWith('gemini-');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,53 +10,53 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
|||||||
agy: {
|
agy: {
|
||||||
provider: 'agy',
|
provider: 'agy',
|
||||||
displayName: 'Antigravity',
|
displayName: 'Antigravity',
|
||||||
defaultModel: 'gemini-claude-opus-4-6-thinking',
|
defaultModel: 'claude-opus-4-6-thinking',
|
||||||
models: [
|
models: [
|
||||||
{
|
{
|
||||||
id: 'gemini-claude-opus-4-6-thinking',
|
id: 'claude-opus-4-6-thinking',
|
||||||
name: 'Claude Opus 4.6 Thinking',
|
name: 'Claude Opus 4.6 Thinking',
|
||||||
description: 'Latest flagship, extended thinking',
|
description: 'Latest flagship, extended thinking',
|
||||||
// TODO: Re-enable when Antigravity backend supports 1M context (currently 256k)
|
// TODO: Re-enable when Antigravity backend supports 1M context (currently 256k)
|
||||||
// extendedContext: true,
|
// extendedContext: true,
|
||||||
extendedContext: false,
|
extendedContext: false,
|
||||||
presetMapping: {
|
presetMapping: {
|
||||||
default: 'gemini-claude-opus-4-6-thinking',
|
default: 'claude-opus-4-6-thinking',
|
||||||
opus: 'gemini-claude-opus-4-6-thinking',
|
opus: 'claude-opus-4-6-thinking',
|
||||||
sonnet: 'gemini-claude-sonnet-4-5-thinking',
|
sonnet: 'claude-sonnet-4-5-thinking',
|
||||||
haiku: 'gemini-claude-sonnet-4-5',
|
haiku: 'claude-sonnet-4-5',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'gemini-claude-opus-4-5-thinking',
|
id: 'claude-opus-4-5-thinking',
|
||||||
name: 'Claude Opus 4.5 Thinking',
|
name: 'Claude Opus 4.5 Thinking',
|
||||||
description: 'Previous flagship, extended thinking',
|
description: 'Previous flagship, extended thinking',
|
||||||
presetMapping: {
|
presetMapping: {
|
||||||
default: 'gemini-claude-opus-4-5-thinking',
|
default: 'claude-opus-4-5-thinking',
|
||||||
opus: 'gemini-claude-opus-4-5-thinking',
|
opus: 'claude-opus-4-5-thinking',
|
||||||
sonnet: 'gemini-claude-sonnet-4-5-thinking',
|
sonnet: 'claude-sonnet-4-5-thinking',
|
||||||
haiku: 'gemini-claude-sonnet-4-5',
|
haiku: 'claude-sonnet-4-5',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'gemini-claude-sonnet-4-5-thinking',
|
id: 'claude-sonnet-4-5-thinking',
|
||||||
name: 'Claude Sonnet 4.5 Thinking',
|
name: 'Claude Sonnet 4.5 Thinking',
|
||||||
description: 'Balanced with extended thinking',
|
description: 'Balanced with extended thinking',
|
||||||
presetMapping: {
|
presetMapping: {
|
||||||
default: 'gemini-claude-sonnet-4-5-thinking',
|
default: 'claude-sonnet-4-5-thinking',
|
||||||
opus: 'gemini-claude-opus-4-6-thinking',
|
opus: 'claude-opus-4-6-thinking',
|
||||||
sonnet: 'gemini-claude-sonnet-4-5-thinking',
|
sonnet: 'claude-sonnet-4-5-thinking',
|
||||||
haiku: 'gemini-claude-sonnet-4-5',
|
haiku: 'claude-sonnet-4-5',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'gemini-claude-sonnet-4-5',
|
id: 'claude-sonnet-4-5',
|
||||||
name: 'Claude Sonnet 4.5',
|
name: 'Claude Sonnet 4.5',
|
||||||
description: 'Fast and capable',
|
description: 'Fast and capable',
|
||||||
presetMapping: {
|
presetMapping: {
|
||||||
default: 'gemini-claude-sonnet-4-5',
|
default: 'claude-sonnet-4-5',
|
||||||
opus: 'gemini-claude-opus-4-6-thinking',
|
opus: 'claude-opus-4-6-thinking',
|
||||||
sonnet: 'gemini-claude-sonnet-4-5',
|
sonnet: 'claude-sonnet-4-5',
|
||||||
haiku: 'gemini-claude-sonnet-4-5',
|
haiku: 'claude-sonnet-4-5',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in new issue
Block a user