From b6fb443f572cc5c1bb4e3763abd1aa6fa3ba14e5 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 8 Apr 2026 23:55:55 -0400 Subject: [PATCH 01/54] fix(cliproxy): refresh non-default gemini quota tokens --- src/cliproxy/auth-utils.ts | 33 +++++++-- src/cliproxy/quota-fetcher-gemini-cli.ts | 27 ++++--- tests/unit/cliproxy/auth-utils.test.ts | 27 ++++--- .../cliproxy/quota-fetcher-gemini-cli.test.ts | 72 ++++++++++++++++++- .../account/flow-viz/account-card.tsx | 2 +- .../account/shared/account-quota-panel.tsx | 21 +----- .../shared/quota-tooltip-content.tsx | 61 ++++++++++------ ui/src/components/ui/popover.tsx | 2 +- ui/src/components/ui/tooltip.tsx | 6 +- .../account/flow-viz/account-card.test.tsx | 3 + .../shared/quota-tooltip-content.test.tsx | 24 +++++++ 11 files changed, 207 insertions(+), 71 deletions(-) diff --git a/src/cliproxy/auth-utils.ts b/src/cliproxy/auth-utils.ts index 77e371b1..79f8628f 100644 --- a/src/cliproxy/auth-utils.ts +++ b/src/cliproxy/auth-utils.ts @@ -16,12 +16,35 @@ export function sanitizeEmail(email: string): string { * Check if token is expired based on the expired timestamp. * Returns false if timestamp is missing or invalid (fail-open for quota display). */ -export function isTokenExpired(expiredStr?: string): boolean { - if (!expiredStr) return false; +export function getTokenExpiryTimestamp(expiredValue?: string | number | null): number | null { + if (expiredValue === undefined || expiredValue === null || expiredValue === '') { + return null; + } + try { - const expiredDate = new Date(expiredStr); - return expiredDate.getTime() < Date.now(); + if (typeof expiredValue === 'number') { + return Number.isFinite(expiredValue) ? expiredValue : null; + } + + const trimmed = expiredValue.trim(); + if (!trimmed) { + return null; + } + + if (/^\d+$/.test(trimmed)) { + const numericTimestamp = Number(trimmed); + return Number.isFinite(numericTimestamp) ? numericTimestamp : null; + } + + const expiredDate = new Date(trimmed); + const expiredAt = expiredDate.getTime(); + return Number.isNaN(expiredAt) ? null : expiredAt; } catch { - return false; + return null; } } + +export function isTokenExpired(expiredValue?: string | number | null): boolean { + const expiredAt = getTokenExpiryTimestamp(expiredValue); + return expiredAt !== null ? expiredAt < Date.now() : false; +} diff --git a/src/cliproxy/quota-fetcher-gemini-cli.ts b/src/cliproxy/quota-fetcher-gemini-cli.ts index 0ec6afef..64882d4c 100644 --- a/src/cliproxy/quota-fetcher-gemini-cli.ts +++ b/src/cliproxy/quota-fetcher-gemini-cli.ts @@ -9,7 +9,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { getAuthDir } from './config-generator'; import { getProviderAccounts, getPausedDir } from './account-manager'; -import { sanitizeEmail, isTokenExpired } from './auth-utils'; +import { getTokenExpiryTimestamp, sanitizeEmail, isTokenExpired } from './auth-utils'; import { refreshGeminiToken } from './auth/gemini-token-refresh'; import { buildGeminiCliBucketsFromParsedBuckets, @@ -38,7 +38,7 @@ interface GeminiCliAuthData { accessToken: string; projectId: string | null; isExpired: boolean; - expiresAt: string | null; + expiresAt: string | number | null; } /** Raw bucket from API response */ @@ -130,17 +130,23 @@ function extractAccessToken(data: Record): string | null { * Extract expiry from Gemini auth file data * Handles both flat (expired) and nested (token.expiry) structures */ -function extractExpiry(data: Record): string | null { +function extractExpiry(data: Record): string | number | null { // Flat structure: { expired: "..." } if (typeof data.expired === 'string') { return data.expired; } + if (typeof data.expired === 'number') { + return data.expired; + } // Nested structure: { token: { expiry: "..." } } if (data.token && typeof data.token === 'object') { const token = data.token as Record; if (typeof token.expiry === 'string') { return token.expiry; } + if (typeof token.expiry === 'number') { + return token.expiry; + } } return null; } @@ -736,19 +742,20 @@ export async function fetchGeminiCliQuota( // Proactive refresh: refresh if expired OR expiring within 5 minutes const REFRESH_LEAD_TIME_MS = 5 * 60 * 1000; + const expiresAt = getTokenExpiryTimestamp(authData.expiresAt); const shouldRefresh = - authData.isExpired || - !authData.expiresAt || - new Date(authData.expiresAt).getTime() - Date.now() < REFRESH_LEAD_TIME_MS; + authData.isExpired || expiresAt === null || expiresAt - Date.now() < REFRESH_LEAD_TIME_MS; + let attemptedRefresh = false; if (shouldRefresh) { + attemptedRefresh = true; if (verbose) console.error( authData.isExpired ? '[i] Token expired, refreshing...' : '[i] Token expiring soon, proactive refresh...' ); - const refreshResult = await refreshGeminiToken(); + const refreshResult = await refreshGeminiToken(accountId); if (refreshResult.success) { if (verbose) console.error('[i] Token refreshed successfully'); @@ -776,10 +783,10 @@ export async function fetchGeminiCliQuota( // First attempt with current token const result = await fetchWithAuthData(authData, accountId, verbose); - // If 401 error and we haven't refreshed yet, try refresh and retry - if (result.needsReauth && result.error?.includes('expired')) { + // Retry once with an account-scoped refresh when the quota endpoint rejects auth. + if (result.needsReauth && !attemptedRefresh) { if (verbose) console.error('[i] Got 401, attempting refresh and retry...'); - const refreshResult = await refreshGeminiToken(); + const refreshResult = await refreshGeminiToken(accountId); if (refreshResult.success) { const refreshedAuthData = readGeminiCliAuthData(accountId); if (refreshedAuthData) { diff --git a/tests/unit/cliproxy/auth-utils.test.ts b/tests/unit/cliproxy/auth-utils.test.ts index a6003b80..b809031a 100644 --- a/tests/unit/cliproxy/auth-utils.test.ts +++ b/tests/unit/cliproxy/auth-utils.test.ts @@ -5,7 +5,11 @@ */ import { describe, it, expect } from 'bun:test'; -import { sanitizeEmail, isTokenExpired } from '../../../src/cliproxy/auth-utils'; +import { + getTokenExpiryTimestamp, + isTokenExpired, + sanitizeEmail, +} from '../../../src/cliproxy/auth-utils'; describe('Auth Utilities', () => { describe('sanitizeEmail', () => { @@ -75,14 +79,21 @@ describe('Auth Utilities', () => { expect(isTokenExpired(futureISO)).toBe(false); }); - it('should handle Unix timestamp strings', () => { - // JavaScript Date can parse numeric strings as timestamps + it('should handle Unix timestamp strings deterministically', () => { const pastTimestamp = String(Date.now() - 86400000); // Yesterday - // Note: Date parsing of pure numbers as strings is inconsistent - // This test documents the actual behavior - const result = isTokenExpired(pastTimestamp); - // The behavior depends on how Date parses the string - expect(typeof result).toBe('boolean'); + expect(isTokenExpired(pastTimestamp)).toBe(true); + }); + + it('should handle Unix timestamps provided as numbers', () => { + const futureTimestamp = Date.now() + 86400000; + expect(isTokenExpired(futureTimestamp)).toBe(false); + }); + + it('should expose normalized expiry timestamps for string and numeric inputs', () => { + const futureTimestamp = Date.now() + 60000; + expect(getTokenExpiryTimestamp(futureTimestamp)).toBe(futureTimestamp); + expect(getTokenExpiryTimestamp(String(futureTimestamp))).toBe(futureTimestamp); + expect(getTokenExpiryTimestamp('not-a-date')).toBeNull(); }); }); }); diff --git a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts index 8c2d1b18..6df70140 100644 --- a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts @@ -27,10 +27,10 @@ describe('Gemini CLI Quota Fetcher', () => { let refreshGeminiToken: typeof import('../../../src/cliproxy/auth/gemini-token-refresh').refreshGeminiToken; let getProviderAuthDir: typeof import('../../../src/cliproxy/config-generator').getProviderAuthDir; - function writeGeminiToken(token: Record): string { + function writeGeminiToken(token: Record, filename = 'gemini-test.json'): string { const authDir = getProviderAuthDir('gemini'); fs.mkdirSync(authDir, { recursive: true }); - const tokenPath = path.join(authDir, 'gemini-test.json'); + const tokenPath = path.join(authDir, filename); fs.writeFileSync(tokenPath, JSON.stringify(token, null, 2)); return tokenPath; } @@ -649,6 +649,74 @@ describe('Gemini CLI Quota Fetcher', () => { expect(result.error).toBe('Gemini quota service unavailable (HTTP 502)'); expect(result.errorDetail).toBe('[HTML error response omitted]'); }); + + it('refreshes the requested Gemini account instead of the default account', async () => { + writeGeminiToken( + { + type: 'gemini', + email: 'default@example.com', + project_id: 'default-project', + token: { + access_token: 'default-access-token', + refresh_token: 'default-refresh-token', + expiry: Date.now() + 60 * 60 * 1000, + client_id: 'default-client-id', + client_secret: 'default-client-secret', + token_uri: GOOGLE_TOKEN_URL, + }, + }, + 'gemini-default.json' + ); + + writeGeminiToken( + { + type: 'gemini', + email: 'target@example.com', + project_id: 'target-project', + token: { + access_token: 'target-stale-token', + refresh_token: 'target-refresh-token', + expiry: Date.now() - 1000, + client_id: 'target-client-id', + client_secret: 'target-client-secret', + token_uri: GOOGLE_TOKEN_URL, + }, + }, + 'gemini-target.json' + ); + + mockFetch([ + { + url: GOOGLE_TOKEN_URL, + method: 'POST', + response: { access_token: 'target-fresh-token', expires_in: 1800 }, + }, + { + url: GEMINI_QUOTA_URL, + method: 'POST', + status: 200, + response: { + buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.88 }], + }, + }, + { + url: GEMINI_CODE_ASSIST_URL, + method: 'POST', + status: 503, + response: { error: { message: 'supplementary unavailable' } }, + }, + ]); + + const result = await fetchGeminiCliQuota('target@example.com'); + + expect(result.success).toBe(true); + + const [refreshRequest, quotaRequest] = getCapturedFetchRequests(); + expect(refreshRequest.url).toBe(GOOGLE_TOKEN_URL); + expect(refreshRequest.body).toContain('refresh_token=target-refresh-token'); + expect(refreshRequest.body).not.toContain('default-refresh-token'); + expect(quotaRequest.headers.Authorization).toBe('Bearer target-fresh-token'); + }); }); describe('direct Gemini error helper coverage', () => { diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index b4f65156..e66e4195 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -249,7 +249,7 @@ export function AccountCard({ )} - + diff --git a/ui/src/components/account/shared/account-quota-panel.tsx b/ui/src/components/account/shared/account-quota-panel.tsx index 95c4fb57..88ffa4f9 100644 --- a/ui/src/components/account/shared/account-quota-panel.tsx +++ b/ui/src/components/account/shared/account-quota-panel.tsx @@ -250,7 +250,7 @@ export function AccountQuotaPanel({ )} - + @@ -306,23 +306,8 @@ export function AccountQuotaPanel({ )} - -
-

{failureInfo.summary}

- {failureInfo.actionHint && ( -

{failureInfo.actionHint}

- )} - {failureInfo.technicalDetail && ( -

- {failureInfo.technicalDetail} -

- )} - {failureInfo.rawDetail && ( -
-                {failureInfo.rawDetail}
-              
- )} -
+ + diff --git a/ui/src/components/shared/quota-tooltip-content.tsx b/ui/src/components/shared/quota-tooltip-content.tsx index 25edae1b..305cd98f 100644 --- a/ui/src/components/shared/quota-tooltip-content.tsx +++ b/ui/src/components/shared/quota-tooltip-content.tsx @@ -86,22 +86,35 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro if (!quota.success) { const failureInfo = getQuotaFailureInfo(quota); + const failureToneClass = + failureInfo?.tone === 'destructive' + ? 'text-destructive' + : failureInfo?.tone === 'warning' + ? 'text-amber-700 dark:text-amber-300' + : 'text-foreground'; + return ( -
-

- {failureInfo?.label || quota.error || 'Failed to load quota'} -

-

{failureInfo?.summary || quota.error}

+
+
+

+ {failureInfo?.label || quota.error || 'Failed to load quota'} +

+

+ {failureInfo?.summary || quota.error} +

+
{failureInfo?.actionHint && ( -

{failureInfo.actionHint}

+
+ {failureInfo.actionHint} +
)} {failureInfo?.technicalDetail && ( -

+

{failureInfo.technicalDetail} -

+
)} {failureInfo?.rawDetail && ( -
+          
             {failureInfo.rawDetail}
           
)} @@ -116,7 +129,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other']; return ( -
+

Model Quotas:

{tierOrder.map((tier, idx) => { const models = groups.get(tier); @@ -124,7 +137,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro const isFirst = tierOrder.slice(0, idx).every((t) => !groups.get(t)?.length); return (
- {!isFirst &&
} + {!isFirst &&
} {models.map((m) => (
@@ -159,7 +172,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro ); return ( -
+

Rate Limits:

{quota.planType &&

Plan: {quota.planType}

} {orderedWindows.map((w, index) => ( @@ -228,7 +241,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro null; return ( -
+

Rate Limits:

{orderedWindows.map((window, index) => (
!!bucket.resetTime); return ( -
+
{quota.tierLabel && (
Tier @@ -306,7 +319,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro const planLabel = formatPlanLabel(quota.planType); return ( -
+

Quota Snapshots:

{planLabel &&

Plan: {planLabel}

} {snapshotRows.map(({ label, snapshot }) => { @@ -344,9 +357,11 @@ function ResetTimeIndicator({ resetTime }: { resetTime: string | null }) { if (!resetTime) return null; return ( -
- - Resets {formatResetTime(resetTime)} +
+ + + Resets {formatResetTime(resetTime)} +
); } @@ -364,19 +379,19 @@ function CodexResetIndicators({ if (!hasSpecificReset && !fallbackResetTime) return null; return ( -
+
{fiveHourResetTime && (
- - + + 5h resets {formatResetTime(fiveHourResetTime)}
)} {weeklyResetTime && (
- - + + Weekly resets {formatResetTime(weeklyResetTime)}
diff --git a/ui/src/components/ui/popover.tsx b/ui/src/components/ui/popover.tsx index 07b74e1a..b78a6417 100644 --- a/ui/src/components/ui/popover.tsx +++ b/ui/src/components/ui/popover.tsx @@ -19,7 +19,7 @@ const PopoverContent = React.forwardRef< align={align} sideOffset={sideOffset} className={cn( - 'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', + 'z-50 w-80 max-w-[calc(100vw-2rem)] rounded-lg border border-border/70 bg-popover p-4 text-popover-foreground shadow-xl shadow-black/10 outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', className )} {...props} diff --git a/ui/src/components/ui/tooltip.tsx b/ui/src/components/ui/tooltip.tsx index d4011c57..2d5995df 100644 --- a/ui/src/components/ui/tooltip.tsx +++ b/ui/src/components/ui/tooltip.tsx @@ -32,7 +32,7 @@ function TooltipTrigger({ ...props }: React.ComponentProps) { @@ -42,13 +42,13 @@ function TooltipContent({ data-slot="tooltip-content" sideOffset={sideOffset} className={cn( - 'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance', + 'animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-w-sm origin-(--radix-tooltip-content-transform-origin) rounded-lg border border-border/70 bg-popover px-3 py-2 text-left text-xs leading-relaxed text-popover-foreground shadow-xl shadow-black/10', className )} {...props} > {children} - + ); diff --git a/ui/tests/unit/components/account/flow-viz/account-card.test.tsx b/ui/tests/unit/components/account/flow-viz/account-card.test.tsx index 8086b0a9..65401d33 100644 --- a/ui/tests/unit/components/account/flow-viz/account-card.test.tsx +++ b/ui/tests/unit/components/account/flow-viz/account-card.test.tsx @@ -133,6 +133,9 @@ describe('AccountCard grouped quota tooltip', () => { await userEvent.hover(screen.getByText('Business')); expect((await screen.findAllByText('Plan: team')).length).toBeGreaterThan(0); expect(screen.getAllByText('5h usage limit').length).toBeGreaterThan(0); + const tooltipContent = document.querySelector('[data-slot="tooltip-content"]'); + expect(tooltipContent?.className).toContain('bg-popover'); + expect(tooltipContent?.className).toContain('text-popover-foreground'); await userEvent.hover(screen.getByText('Personal')); expect((await screen.findAllByText('Plan: plus')).length).toBeGreaterThan(0); diff --git a/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx b/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx index 73ab0923..d47bca8b 100644 --- a/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx +++ b/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx @@ -90,4 +90,28 @@ describe('QuotaTooltipContent', () => { vi.useRealTimers(); }); + + it('renders failure summaries, action hints, and raw details with readable structure', () => { + const quota = createGeminiQuotaResult({ + success: false, + buckets: [], + error: 'Request had invalid authentication credentials.', + httpStatus: 401, + errorCode: 'UNAUTHENTICATED', + errorDetail: + '{"error":{"code":401,"message":"Request had invalid authentication credentials.","status":"UNAUTHENTICATED"}}', + actionHint: 'Run ccs gemini --auth to reconnect this account.', + needsReauth: true, + }); + + render(); + + expect(screen.getByText('Reauth')).toBeInTheDocument(); + expect(screen.getByText('Request had invalid authentication credentials.')).toBeInTheDocument(); + expect( + screen.getByText('Run ccs gemini --auth to reconnect this account.') + ).toBeInTheDocument(); + expect(screen.getByText('HTTP 401 | UNAUTHENTICATED')).toBeInTheDocument(); + expect(screen.getByText(/"status":"UNAUTHENTICATED"/)).toBeInTheDocument(); + }); }); From 7ac6d0a4d4a08113b48c008c1850dc6b089b66d1 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 9 Apr 2026 00:14:51 -0400 Subject: [PATCH 02/54] fix(review): harden quota retry and tooltip edge cases --- src/cliproxy/auth-utils.ts | 13 ++- src/cliproxy/quota-fetcher-gemini-cli.ts | 6 +- tests/unit/cliproxy/auth-utils.test.ts | 9 ++ .../cliproxy/quota-fetcher-gemini-cli.test.ts | 105 ++++++++++++++++++ .../account/flow-viz/account-card.tsx | 2 +- .../account/shared/account-quota-panel.tsx | 4 +- .../shared/quota-tooltip-content.tsx | 18 +-- ui/src/components/ui/tooltip.tsx | 2 +- .../account/flow-viz/account-card.test.tsx | 13 ++- .../shared/account-quota-panel.test.tsx | 52 +++++++++ .../shared/quota-tooltip-content.test.tsx | 3 +- 11 files changed, 206 insertions(+), 21 deletions(-) create mode 100644 ui/tests/unit/components/account/shared/account-quota-panel.test.tsx diff --git a/src/cliproxy/auth-utils.ts b/src/cliproxy/auth-utils.ts index 79f8628f..d7f19c2a 100644 --- a/src/cliproxy/auth-utils.ts +++ b/src/cliproxy/auth-utils.ts @@ -21,9 +21,18 @@ export function getTokenExpiryTimestamp(expiredValue?: string | number | null): return null; } + const normalizeNumericTimestamp = (value: number): number | null => { + if (!Number.isFinite(value) || value <= 0) { + return null; + } + + // Support Unix seconds from older token stores while preserving millisecond timestamps. + return value < 1_000_000_000_000 ? value * 1000 : value; + }; + try { if (typeof expiredValue === 'number') { - return Number.isFinite(expiredValue) ? expiredValue : null; + return normalizeNumericTimestamp(expiredValue); } const trimmed = expiredValue.trim(); @@ -33,7 +42,7 @@ export function getTokenExpiryTimestamp(expiredValue?: string | number | null): if (/^\d+$/.test(trimmed)) { const numericTimestamp = Number(trimmed); - return Number.isFinite(numericTimestamp) ? numericTimestamp : null; + return normalizeNumericTimestamp(numericTimestamp); } const expiredDate = new Date(trimmed); diff --git a/src/cliproxy/quota-fetcher-gemini-cli.ts b/src/cliproxy/quota-fetcher-gemini-cli.ts index 64882d4c..1dc8fe7a 100644 --- a/src/cliproxy/quota-fetcher-gemini-cli.ts +++ b/src/cliproxy/quota-fetcher-gemini-cli.ts @@ -745,10 +745,9 @@ export async function fetchGeminiCliQuota( const expiresAt = getTokenExpiryTimestamp(authData.expiresAt); const shouldRefresh = authData.isExpired || expiresAt === null || expiresAt - Date.now() < REFRESH_LEAD_TIME_MS; - let attemptedRefresh = false; + let refreshedBeforeQuotaFetch = false; if (shouldRefresh) { - attemptedRefresh = true; if (verbose) console.error( authData.isExpired @@ -758,6 +757,7 @@ export async function fetchGeminiCliQuota( const refreshResult = await refreshGeminiToken(accountId); if (refreshResult.success) { + refreshedBeforeQuotaFetch = true; if (verbose) console.error('[i] Token refreshed successfully'); // Re-read auth data after successful refresh const refreshedAuthData = readGeminiCliAuthData(accountId); @@ -784,7 +784,7 @@ export async function fetchGeminiCliQuota( const result = await fetchWithAuthData(authData, accountId, verbose); // Retry once with an account-scoped refresh when the quota endpoint rejects auth. - if (result.needsReauth && !attemptedRefresh) { + if (result.needsReauth && !refreshedBeforeQuotaFetch) { if (verbose) console.error('[i] Got 401, attempting refresh and retry...'); const refreshResult = await refreshGeminiToken(accountId); if (refreshResult.success) { diff --git a/tests/unit/cliproxy/auth-utils.test.ts b/tests/unit/cliproxy/auth-utils.test.ts index b809031a..f6944882 100644 --- a/tests/unit/cliproxy/auth-utils.test.ts +++ b/tests/unit/cliproxy/auth-utils.test.ts @@ -89,10 +89,19 @@ describe('Auth Utilities', () => { expect(isTokenExpired(futureTimestamp)).toBe(false); }); + it('should treat Unix-seconds values as seconds, not milliseconds', () => { + const futureUnixSeconds = Math.floor((Date.now() + 60000) / 1000); + expect(isTokenExpired(futureUnixSeconds)).toBe(false); + expect(isTokenExpired(String(futureUnixSeconds))).toBe(false); + }); + it('should expose normalized expiry timestamps for string and numeric inputs', () => { const futureTimestamp = Date.now() + 60000; expect(getTokenExpiryTimestamp(futureTimestamp)).toBe(futureTimestamp); expect(getTokenExpiryTimestamp(String(futureTimestamp))).toBe(futureTimestamp); + const futureUnixSeconds = Math.floor((Date.now() + 60000) / 1000); + expect(getTokenExpiryTimestamp(futureUnixSeconds)).toBe(futureUnixSeconds * 1000); + expect(getTokenExpiryTimestamp(String(futureUnixSeconds))).toBe(futureUnixSeconds * 1000); expect(getTokenExpiryTimestamp('not-a-date')).toBeNull(); }); }); diff --git a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts index 6df70140..1966c6d5 100644 --- a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts @@ -717,6 +717,111 @@ describe('Gemini CLI Quota Fetcher', () => { expect(refreshRequest.body).not.toContain('default-refresh-token'); expect(quotaRequest.headers.Authorization).toBe('Bearer target-fresh-token'); }); + + it('retries a 401 quota failure after a transient proactive refresh failure', async () => { + writeGeminiToken( + { + type: 'gemini', + email: 'retry@example.com', + project_id: 'retry-project', + token: { + access_token: 'retry-stale-token', + refresh_token: 'retry-refresh-token', + expiry: Date.now() + 60 * 1000, + client_id: 'retry-client-id', + client_secret: 'retry-client-secret', + token_uri: GOOGLE_TOKEN_URL, + }, + }, + 'gemini-retry.json' + ); + + mockFetch([ + { + url: GOOGLE_TOKEN_URL, + method: 'POST', + response: { access_token: 'unused-default', expires_in: 1800 }, + }, + { + url: GEMINI_QUOTA_URL, + method: 'POST', + status: 200, + response: { + buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.9 }], + }, + }, + { + url: GEMINI_CODE_ASSIST_URL, + method: 'POST', + status: 503, + response: { error: { message: 'supplementary unavailable' } }, + }, + ]); + + const originalFetch = globalThis.fetch; + let refreshAttempt = 0; + let quotaAttempt = 0; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + + if (url === GOOGLE_TOKEN_URL) { + refreshAttempt += 1; + return refreshAttempt === 1 + ? new Response(JSON.stringify({ error: 'temporarily_unavailable' }), { + status: 503, + headers: { 'Content-Type': 'application/json' }, + }) + : new Response(JSON.stringify({ access_token: 'retry-fresh-token', expires_in: 1800 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + if (url === GEMINI_QUOTA_URL) { + quotaAttempt += 1; + return quotaAttempt === 1 + ? new Response( + JSON.stringify({ + error: { + message: 'Session expired', + status: 'UNAUTHENTICATED', + }, + }), + { + status: 401, + headers: { 'Content-Type': 'application/json' }, + } + ) + : new Response( + JSON.stringify({ + buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.9 }], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + return originalFetch(input, init); + }) as typeof fetch; + + try { + const result = await fetchGeminiCliQuota('retry@example.com'); + + expect(result.success).toBe(true); + expect(refreshAttempt).toBe(2); + expect(quotaAttempt).toBe(2); + + const storedToken = JSON.parse( + fs.readFileSync(path.join(getProviderAuthDir('gemini'), 'gemini-retry.json'), 'utf8') + ) as { token?: { access_token?: string } }; + expect(storedToken.token?.access_token).toBe('retry-fresh-token'); + } finally { + globalThis.fetch = originalFetch; + } + }); }); describe('direct Gemini error helper coverage', () => { diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index e66e4195..debb9d8b 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -249,7 +249,7 @@ export function AccountCard({ )}
- + diff --git a/ui/src/components/account/shared/account-quota-panel.tsx b/ui/src/components/account/shared/account-quota-panel.tsx index 88ffa4f9..fc52019c 100644 --- a/ui/src/components/account/shared/account-quota-panel.tsx +++ b/ui/src/components/account/shared/account-quota-panel.tsx @@ -250,7 +250,7 @@ export function AccountQuotaPanel({
)} - + @@ -306,7 +306,7 @@ export function AccountQuotaPanel({
)} - + diff --git a/ui/src/components/shared/quota-tooltip-content.tsx b/ui/src/components/shared/quota-tooltip-content.tsx index 305cd98f..5dcda088 100644 --- a/ui/src/components/shared/quota-tooltip-content.tsx +++ b/ui/src/components/shared/quota-tooltip-content.tsx @@ -27,6 +27,8 @@ interface QuotaTooltipContentProps { resetTime: string | null; } +const lowQuotaTextClass = 'text-red-700 dark:text-red-400'; + function formatPlanLabel(planType: string | null | undefined): string | null { if (!planType) return null; const normalized = planType @@ -94,7 +96,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro : 'text-foreground'; return ( -
+

{failureInfo?.label || quota.error || 'Failed to load quota'} @@ -140,10 +142,10 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro {!isFirst &&

} {models.map((m) => (
- + {m.displayName} - + {m.percentage}%
@@ -180,7 +182,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro key={`${w.label}-${w.resetAt ?? 'no-reset'}-${index}`} className="flex justify-between gap-4" > - + {getCodexWindowDisplayLabel(w, orderedWindows)} {w.remainingPercent}% @@ -248,7 +250,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro key={`${window.rateLimitType}-${window.resetAt ?? 'no-reset'}-${window.status}-${index}`} className="flex justify-between gap-4" > - + {getClaudeWindowDisplayLabel(window.rateLimitType, window.label)} {window.remainingPercent}% @@ -285,7 +287,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro {quota.buckets.map((b) => (
- + {b.label} {b.tokenType ? ` (${b.tokenType})` : ''} @@ -327,8 +329,8 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro return (
- {label} - + {label} + {snapshot.unlimited ? 'Unlimited' : `${formatQuotaPercent(snapshot.percentRemaining)}%`} diff --git a/ui/src/components/ui/tooltip.tsx b/ui/src/components/ui/tooltip.tsx index 2d5995df..71e1ed7a 100644 --- a/ui/src/components/ui/tooltip.tsx +++ b/ui/src/components/ui/tooltip.tsx @@ -42,7 +42,7 @@ function TooltipContent({ data-slot="tooltip-content" sideOffset={sideOffset} className={cn( - 'animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-w-sm origin-(--radix-tooltip-content-transform-origin) rounded-lg border border-border/70 bg-popover px-3 py-2 text-left text-xs leading-relaxed text-popover-foreground shadow-xl shadow-black/10', + 'animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit max-w-[calc(100vw-2rem)] origin-(--radix-tooltip-content-transform-origin) rounded-lg border border-border/70 bg-popover px-3 py-2 text-left text-xs leading-relaxed text-popover-foreground shadow-xl shadow-black/10', className )} {...props} diff --git a/ui/tests/unit/components/account/flow-viz/account-card.test.tsx b/ui/tests/unit/components/account/flow-viz/account-card.test.tsx index 65401d33..539e48e3 100644 --- a/ui/tests/unit/components/account/flow-viz/account-card.test.tsx +++ b/ui/tests/unit/components/account/flow-viz/account-card.test.tsx @@ -131,14 +131,21 @@ describe('AccountCard grouped quota tooltip', () => { ); await userEvent.hover(screen.getByText('Business')); - expect((await screen.findAllByText('Plan: team')).length).toBeGreaterThan(0); + const businessPlan = (await screen.findAllByText('Plan: team')).find((node) => + node.closest('[data-slot="tooltip-content"]') + ); + expect(businessPlan).toBeInTheDocument(); expect(screen.getAllByText('5h usage limit').length).toBeGreaterThan(0); - const tooltipContent = document.querySelector('[data-slot="tooltip-content"]'); + const tooltipContent = businessPlan.closest('[data-slot="tooltip-content"]'); expect(tooltipContent?.className).toContain('bg-popover'); expect(tooltipContent?.className).toContain('text-popover-foreground'); + expect(tooltipContent?.className).toContain('max-w-[calc(100vw-2rem)]'); await userEvent.hover(screen.getByText('Personal')); - expect((await screen.findAllByText('Plan: plus')).length).toBeGreaterThan(0); + const personalPlan = (await screen.findAllByText('Plan: plus')).find((node) => + node.closest('[data-slot="tooltip-content"]') + ); + expect(personalPlan).toBeInTheDocument(); expect(screen.getAllByText('Weekly usage limit').length).toBeGreaterThan(0); }); }); diff --git a/ui/tests/unit/components/account/shared/account-quota-panel.test.tsx b/ui/tests/unit/components/account/shared/account-quota-panel.test.tsx new file mode 100644 index 00000000..4263945f --- /dev/null +++ b/ui/tests/unit/components/account/shared/account-quota-panel.test.tsx @@ -0,0 +1,52 @@ +import { render, screen, userEvent } from '@tests/setup/test-utils'; +import { describe, expect, it } from 'vitest'; +import { AccountQuotaPanel } from '@/components/account/shared/account-quota-panel'; +import type { GeminiCliQuotaResult } from '@/lib/api-client'; + +function createGeminiFailureQuota(): GeminiCliQuotaResult { + return { + success: false, + buckets: [], + projectId: 'test-project', + lastUpdated: Date.now(), + error: 'Request had invalid authentication credentials.', + httpStatus: 401, + errorCode: 'UNAUTHENTICATED', + errorDetail: + '{"error":{"code":401,"message":"Request had invalid authentication credentials.","status":"UNAUTHENTICATED"}}', + actionHint: 'Run ccs gemini --auth to reconnect this account.', + needsReauth: true, + }; +} + +describe('AccountQuotaPanel failure tooltip', () => { + it('renders the shared failure tooltip content with viewport-safe shell classes', async () => { + render( + + ); + + await userEvent.hover(screen.getByText('Reauth')); + + const summary = ( + await screen.findAllByText('Request had invalid authentication credentials.') + ).find((node) => node.closest('[data-slot="tooltip-content"]')); + expect(summary).toBeInTheDocument(); + const tooltipContent = summary.closest('[data-slot="tooltip-content"]'); + const actionHint = screen + .getAllByText('Run ccs gemini --auth to reconnect this account.') + .find((node) => node.closest('[data-slot="tooltip-content"]') === tooltipContent); + const technicalDetail = screen + .getAllByText('HTTP 401 | UNAUTHENTICATED') + .find((node) => node.closest('[data-slot="tooltip-content"]') === tooltipContent); + expect(actionHint).toBeInTheDocument(); + expect(technicalDetail).toBeInTheDocument(); + expect(tooltipContent?.className).toContain('max-w-[calc(100vw-2rem)]'); + expect(tooltipContent?.className).toContain('bg-popover'); + expect(tooltipContent?.className).toContain('text-popover-foreground'); + }); +}); diff --git a/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx b/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx index d47bca8b..bada72b1 100644 --- a/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx +++ b/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx @@ -104,7 +104,7 @@ describe('QuotaTooltipContent', () => { needsReauth: true, }); - render(); + const { container } = render(); expect(screen.getByText('Reauth')).toBeInTheDocument(); expect(screen.getByText('Request had invalid authentication credentials.')).toBeInTheDocument(); @@ -113,5 +113,6 @@ describe('QuotaTooltipContent', () => { ).toBeInTheDocument(); expect(screen.getByText('HTTP 401 | UNAUTHENTICATED')).toBeInTheDocument(); expect(screen.getByText(/"status":"UNAUTHENTICATED"/)).toBeInTheDocument(); + expect(container.firstChild).not.toHaveClass('min-w-[16rem]'); }); }); From 41d9fed961c4e6ab1034b99aceff024f6f7c1918 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 9 Apr 2026 04:54:07 +0000 Subject: [PATCH 03/54] chore(release): 7.67.0-dev.1 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b0b350af..faa9dc54 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.67.0", + "version": "7.67.0-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From a169917692016e1a01560c13c95a554da430cd38 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 9 Apr 2026 17:44:15 -0400 Subject: [PATCH 04/54] feat(ui): add paused filter to account flow viz --- .../account/flow-viz/flow-viz-header.tsx | 27 ++++++- ui/src/components/account/flow-viz/index.tsx | 72 ++++++++++++++----- .../account/flow-viz/provider-card.tsx | 3 +- ui/src/lib/i18n.ts | 2 + 4 files changed, 86 insertions(+), 18 deletions(-) diff --git a/ui/src/components/account/flow-viz/flow-viz-header.tsx b/ui/src/components/account/flow-viz/flow-viz-header.tsx index 3d66d3bd..05962237 100644 --- a/ui/src/components/account/flow-viz/flow-viz-header.tsx +++ b/ui/src/components/account/flow-viz/flow-viz-header.tsx @@ -4,12 +4,15 @@ import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; -import { ChevronRight, Eye, EyeOff, RotateCcw } from 'lucide-react'; +import { ChevronRight, Eye, EyeOff, ListFilter, RotateCcw } from 'lucide-react'; interface FlowVizHeaderProps { onBack?: () => void; showDetails: boolean; onToggleDetails: () => void; + showPausedAccounts: boolean; + pausedAccountsCount: number; + onTogglePausedAccounts: () => void; hasCustomPositions: boolean; onResetPositions: () => void; } @@ -18,6 +21,9 @@ export function FlowVizHeader({ onBack, showDetails, onToggleDetails, + showPausedAccounts, + pausedAccountsCount, + onTogglePausedAccounts, hasCustomPositions, onResetPositions, }: FlowVizHeaderProps) { @@ -48,6 +54,25 @@ export function FlowVizHeader({ {showDetails ? : } {showDetails ? t('flowViz.hideDetails') : t('flowViz.showDetails')} + {pausedAccountsCount > 0 && ( + + )} {hasCustomPositions && (
); diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index aba08e79..4d695cd7 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -675,6 +675,9 @@ const resources = { hideDetails: 'Hide Details', showPausedAccounts: 'Show Paused ({{count}})', hidePausedAccounts: 'Hide Paused ({{count}})', + visibleTotalRequests: 'Visible Requests', + visibleAccounts: 'Visible Accounts', + excludingPausedAccounts: 'Excluding {{count}} paused', resetLayout: 'Reset layout', provider: 'Provider', totalRequests: 'Total Requests', @@ -1990,6 +1993,9 @@ const resources = { hideDetails: '隐藏详情', showPausedAccounts: '显示已暂停 ({{count}})', hidePausedAccounts: '隐藏已暂停 ({{count}})', + visibleTotalRequests: '可见请求数', + visibleAccounts: '可见账号', + excludingPausedAccounts: '已排除 {{count}} 个已暂停', resetLayout: '重置布局', provider: '提供商', totalRequests: '总请求数', @@ -3351,6 +3357,9 @@ const resources = { hideDetails: 'Ẩn chi tiết', showPausedAccounts: 'Hiện tạm dừng ({{count}})', hidePausedAccounts: 'Ẩn tạm dừng ({{count}})', + visibleTotalRequests: 'Yêu cầu hiển thị', + visibleAccounts: 'Tài khoản hiển thị', + excludingPausedAccounts: 'Đang loại trừ {{count}} tài khoản tạm dừng', resetLayout: 'Đặt lại bố cục', provider: 'Nhà cung cấp', totalRequests: 'Tổng số yêu cầu', @@ -4737,6 +4746,9 @@ const resources = { hideDetails: '詳細を隠す', showPausedAccounts: '一時停止を表示 ({{count}})', hidePausedAccounts: '一時停止を非表示 ({{count}})', + visibleTotalRequests: '表示中のリクエスト', + visibleAccounts: '表示中のアカウント', + excludingPausedAccounts: '一時停止 {{count}} 件を除外中', resetLayout: 'レイアウトをリセット', provider: 'プロバイダー', totalRequests: '総リクエスト数', diff --git a/ui/tests/unit/components/account/flow-viz/index.test.tsx b/ui/tests/unit/components/account/flow-viz/index.test.tsx index 3db3da49..8cdb584d 100644 --- a/ui/tests/unit/components/account/flow-viz/index.test.tsx +++ b/ui/tests/unit/components/account/flow-viz/index.test.tsx @@ -15,6 +15,11 @@ vi.mock(import('react-i18next'), async (importOriginal) => { 'flowViz.hideDetails': 'Hide Details', 'flowViz.backToProviders': 'Back to providers', 'flowViz.resetLayout': 'Reset layout', + 'flowViz.provider': 'Provider', + 'flowViz.totalRequests': 'Total Requests', + 'flowViz.accounts': 'Accounts', + 'flowViz.visibleTotalRequests': 'Visible Requests', + 'flowViz.visibleAccounts': 'Visible Accounts', }; if (key === 'flowViz.showPausedAccounts') { @@ -25,6 +30,10 @@ vi.mock(import('react-i18next'), async (importOriginal) => { return `Hide Paused (${options?.count ?? 0})`; } + if (key === 'flowViz.excludingPausedAccounts') { + return `Excluding ${options?.count ?? 0} paused`; + } + return translations[key] ?? key; }, }), @@ -48,10 +57,8 @@ vi.mock('@/components/account/flow-viz/account-card', () => ({ ), })); -vi.mock('@/components/account/flow-viz/provider-card', () => ({ - ProviderCard: ({ providerData }: { providerData: ProviderData }) => ( -
{providerData.accounts.length} visible accounts
- ), +vi.mock('@/components/shared/provider-icon', () => ({ + ProviderIcon: ({ provider }: { provider: string }) =>
{provider}
, })); vi.mock('@/components/account/flow-viz/connection-timeline', () => ({ @@ -115,30 +122,31 @@ describe('AccountFlowViz paused account visibility', () => { expect(screen.getByRole('button', { name: 'Hide Paused (1)' })).toBeInTheDocument(); expect(screen.getByText('active@company.com (active)')).toBeInTheDocument(); expect(screen.getByText('paused@company.com (paused)')).toBeInTheDocument(); - expect(screen.getByText('2 visible accounts')).toBeInTheDocument(); + expect(screen.getByText('Total Requests')).toBeInTheDocument(); + expect(screen.getByText('Accounts')).toBeInTheDocument(); + expect(screen.getByText('7')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); expect(screen.getByText('7 timeline events')).toBeInTheDocument(); expect(screen.getByText('paths:paused-account,active-account')).toBeInTheDocument(); await userEvent.click(screen.getByRole('button', { name: 'Hide Paused (1)' })); - expect(screen.getByRole('button', { name: 'Show Paused (1)' })).toHaveAttribute( - 'aria-pressed', - 'true' - ); + expect(screen.getByRole('button', { name: 'Show Paused (1)' })).toBeInTheDocument(); expect(screen.getByText('active@company.com (active)')).toBeInTheDocument(); expect(screen.queryByText('paused@company.com (paused)')).not.toBeInTheDocument(); - expect(screen.getByText('1 visible accounts')).toBeInTheDocument(); + expect(screen.getByText('Visible Requests')).toBeInTheDocument(); + expect(screen.getByText('Visible Accounts')).toBeInTheDocument(); + expect(screen.getByText('Excluding 1 paused')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + expect(screen.getByText('1')).toBeInTheDocument(); expect(screen.getByText('3 timeline events')).toBeInTheDocument(); expect(screen.getByText('paths:active-account')).toBeInTheDocument(); await userEvent.click(screen.getByRole('button', { name: 'Show Paused (1)' })); - expect(screen.getByRole('button', { name: 'Hide Paused (1)' })).toHaveAttribute( - 'aria-pressed', - 'false' - ); + expect(screen.getByRole('button', { name: 'Hide Paused (1)' })).toBeInTheDocument(); expect(screen.getByText('paused@company.com (paused)')).toBeInTheDocument(); - expect(screen.getByText('2 visible accounts')).toBeInTheDocument(); + expect(screen.queryByText('Excluding 1 paused')).not.toBeInTheDocument(); }); it('ignores hidden paused-account drag offsets when deciding whether reset layout should stay visible', async () => { From aedccec0414fdffac049e4db8130e4f40b2cd744 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 9 Apr 2026 18:31:49 -0400 Subject: [PATCH 08/54] refactor(ui): align visible provider flow data --- ui/src/components/account/flow-viz/index.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/ui/src/components/account/flow-viz/index.tsx b/ui/src/components/account/flow-viz/index.tsx index a26d893b..8d9e4d61 100644 --- a/ui/src/components/account/flow-viz/index.tsx +++ b/ui/src/components/account/flow-viz/index.tsx @@ -49,18 +49,19 @@ export function AccountFlowViz({ () => new Set(visibleAccounts.map((account) => account.id)), [visibleAccounts] ); - const visibleProviderData = useMemo( - () => ({ - ...providerData, - accounts: visibleAccounts, - }), - [providerData, visibleAccounts] - ); const maxRequests = Math.max(...visibleAccounts.map((a) => a.successCount + a.failureCount), 1); const totalRequests = visibleAccounts.reduce( (acc, a) => acc + a.successCount + a.failureCount, 0 ); + const visibleProviderData = useMemo( + () => ({ + ...providerData, + accounts: visibleAccounts, + totalRequests, + }), + [providerData, visibleAccounts, totalRequests] + ); const calculatePaths = useCallback(() => { const newPaths = calculateBezierPaths({ containerRef, svgRef, accounts: visibleAccounts }); From 850f1ef95c4c81c9d147997589cef2153a0b0ad5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 10 Apr 2026 05:15:28 +0000 Subject: [PATCH 09/54] chore(release): 7.67.0-dev.2 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index faa9dc54..0b48264f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.67.0-dev.1", + "version": "7.67.0-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 4d5e52828b9d20759bf20e7b4353192fbecfc868 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 9 Apr 2026 17:41:15 -0400 Subject: [PATCH 10/54] feat(cliproxy): add pinned model routing prefixes --- src/cliproxy/catalog-routing.ts | 39 ++++ src/cliproxy/managed-model-prefixes.ts | 116 ++++++++++ src/commands/cliproxy/catalog-subcommand.ts | 69 +++++- src/commands/cliproxy/variant-subcommand.ts | 34 ++- src/shared/cliproxy-model-routing.ts | 205 ++++++++++++++++++ src/web-server/routes/catalog-routes.ts | 5 +- src/web-server/routes/cliproxy-auth-routes.ts | 24 ++ .../unit/cliproxy/model-routing-hints.test.ts | 64 ++++++ .../provider-editor/custom-preset-dialog.tsx | 5 + .../cliproxy/provider-editor/index.tsx | 3 + .../provider-editor/model-config-section.tsx | 34 ++- .../provider-editor/model-config-tab.tsx | 5 +- .../cliproxy/provider-editor/types.ts | 10 +- .../cliproxy/provider-model-selector.tsx | 110 +++++++++- ui/src/lib/api-client.ts | 24 ++ ui/src/pages/cliproxy.tsx | 3 + .../model-config-section.test.tsx | 65 ++++++ 17 files changed, 791 insertions(+), 24 deletions(-) create mode 100644 src/cliproxy/catalog-routing.ts create mode 100644 src/cliproxy/managed-model-prefixes.ts create mode 100644 src/shared/cliproxy-model-routing.ts create mode 100644 tests/unit/cliproxy/model-routing-hints.test.ts diff --git a/src/cliproxy/catalog-routing.ts b/src/cliproxy/catalog-routing.ts new file mode 100644 index 00000000..fc373df8 --- /dev/null +++ b/src/cliproxy/catalog-routing.ts @@ -0,0 +1,39 @@ +import { + buildCliproxyRoutingHints, + type CliproxyProviderRoutingHints, +} from '../shared/cliproxy-model-routing'; +import { fetchCliproxyModels } from './stats-fetcher'; +import { ensureManagedModelPrefixes } from './managed-model-prefixes'; +import { + getResolvedCatalogSnapshot, + type CatalogSource, + type ResolvedCatalogSnapshot, +} from './catalog-cache'; +import type { ProviderCatalog } from './model-catalog'; +import type { CLIProxyProvider } from './types'; + +export interface CatalogRoutingSnapshot { + catalogs: Partial>; + source: CatalogSource; + cacheAge: string | null; + routing: Partial>; +} + +export async function getCatalogRoutingSnapshot(): Promise { + try { + await ensureManagedModelPrefixes(); + } catch { + // Keep catalog rendering non-fatal when prefix sync is unavailable. + } + + const snapshot: ResolvedCatalogSnapshot = await getResolvedCatalogSnapshot(); + const modelsResponse = await fetchCliproxyModels(); + const routing = buildCliproxyRoutingHints(snapshot.catalogs, modelsResponse?.models ?? []); + + return { + catalogs: snapshot.catalogs, + source: snapshot.source, + cacheAge: snapshot.cacheAge, + routing, + }; +} diff --git a/src/cliproxy/managed-model-prefixes.ts b/src/cliproxy/managed-model-prefixes.ts new file mode 100644 index 00000000..6215ec67 --- /dev/null +++ b/src/cliproxy/managed-model-prefixes.ts @@ -0,0 +1,116 @@ +import { getManagedModelPrefix } from '../shared/cliproxy-model-routing'; +import { buildManagementHeaders, buildProxyUrl, getProxyTarget } from './proxy-target-resolver'; +import { mapExternalProviderName } from './provider-capabilities'; +import type { CLIProxyProvider } from './types'; + +interface ManagementAuthFileRecord { + account_type?: string; + name: string; + provider?: string; + type?: string; +} + +export interface ManagedPrefixSyncResult { + checked: number; + updated: number; +} + +function normalizeProvider(record: ManagementAuthFileRecord): CLIProxyProvider | null { + const providerName = record.provider?.trim() || record.type?.trim() || ''; + return providerName ? mapExternalProviderName(providerName) : null; +} + +async function listAuthFiles(): Promise { + const target = getProxyTarget(); + const response = await fetch(buildProxyUrl(target, '/v0/management/auth-files'), { + headers: buildManagementHeaders(target), + }); + + if (!response.ok) { + throw new Error(`auth file listing failed with status ${response.status}`); + } + + const data = (await response.json()) as { files?: ManagementAuthFileRecord[] }; + return Array.isArray(data.files) ? data.files : []; +} + +async function patchAuthFilePrefix(name: string, prefix: string): Promise { + const target = getProxyTarget(); + const response = await fetch(buildProxyUrl(target, '/v0/management/auth-files/fields'), { + method: 'PATCH', + headers: buildManagementHeaders(target, { + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ name, prefix }), + }); + + if (!response.ok) { + throw new Error(`auth file prefix patch failed for ${name} with status ${response.status}`); + } +} + +async function readAuthFilePrefix(name: string): Promise { + const target = getProxyTarget(); + const url = buildProxyUrl( + target, + `/v0/management/auth-files/download?name=${encodeURIComponent(name)}` + ); + const response = await fetch(url, { + headers: buildManagementHeaders(target), + }); + + if (!response.ok) { + throw new Error(`auth file download failed for ${name} with status ${response.status}`); + } + + const content = await response.text(); + try { + const parsed = JSON.parse(content) as { prefix?: unknown }; + return typeof parsed.prefix === 'string' ? parsed.prefix.trim() : null; + } catch { + return null; + } +} + +export async function ensureManagedModelPrefixes( + providers?: CLIProxyProvider[] +): Promise { + const files = await listAuthFiles(); + const allowedProviders = new Set((providers ?? []).map((provider) => provider.trim())); + let checked = 0; + let updated = 0; + + for (const record of files) { + if ((record.account_type || '').trim().toLowerCase() !== 'oauth') { + continue; + } + + const provider = normalizeProvider(record); + if (!provider) { + continue; + } + + if (allowedProviders.size > 0 && !allowedProviders.has(provider)) { + continue; + } + + const prefix = getManagedModelPrefix(provider); + if (!prefix) { + continue; + } + + try { + checked += 1; + const currentPrefix = await readAuthFilePrefix(record.name); + if (currentPrefix === prefix) { + continue; + } + await patchAuthFilePrefix(record.name, prefix); + updated += 1; + } catch { + // Best-effort repair: skip files that cannot be read or patched. + } + } + + return { checked, updated }; +} diff --git a/src/commands/cliproxy/catalog-subcommand.ts b/src/commands/cliproxy/catalog-subcommand.ts index 8bee3556..a26d05fb 100644 --- a/src/commands/cliproxy/catalog-subcommand.ts +++ b/src/commands/cliproxy/catalog-subcommand.ts @@ -6,9 +6,11 @@ import { getResolvedCatalog, refreshCatalogFromProxy, } from '../../cliproxy/catalog-cache'; +import { getCatalogRoutingSnapshot } from '../../cliproxy/catalog-routing'; import { getProxyTarget } from '../../cliproxy/proxy-target-resolver'; import type { CLIProxyProvider } from '../../cliproxy/types'; import type { RemoteModelInfo } from '../../cliproxy/management-api-types'; +import type { CliproxyProviderRoutingHints } from '../../shared/cliproxy-model-routing'; /** Fetch model definitions from CLIProxyAPI for all syncable providers */ async function fetchRemoteCatalogs( @@ -44,7 +46,8 @@ export async function handleCatalogStatus(verbose: boolean): Promise { console.log(header('Model Catalog')); console.log(''); - const cacheAge = getCacheAge(); + const routingSnapshot = await getCatalogRoutingSnapshot(); + const cacheAge = routingSnapshot.cacheAge ?? getCacheAge(); if (cacheAge) { console.log(` Cache: ${color('synced', 'success')} (${cacheAge})`); } else { @@ -55,14 +58,14 @@ export async function handleCatalogStatus(verbose: boolean): Promise { console.log(subheader('Providers:')); for (const provider of SYNCABLE_PROVIDERS) { - const catalog = getResolvedCatalog(provider); + const catalog = routingSnapshot.catalogs[provider] ?? getResolvedCatalog(provider); if (catalog) { const count = catalog.models.length; - console.log(` ${color(catalog.displayName.padEnd(20), 'command')} ${count} models`); + const routing = routingSnapshot.routing[provider]; + const suffix = renderRoutingSummary(routing); + console.log(` ${color(catalog.displayName.padEnd(20), 'command')} ${count} models${suffix}`); if (verbose) { - for (const model of catalog.models) { - console.log(dim(` - ${model.id} (${model.name})`)); - } + renderVerboseRouting(provider, catalog.models, routing); } } } @@ -74,6 +77,60 @@ export async function handleCatalogStatus(verbose: boolean): Promise { console.log(''); } +function renderRoutingSummary(routing: CliproxyProviderRoutingHints | undefined): string { + if (!routing) { + return ''; + } + + const parts = [`prefix ${routing.prefix}`]; + if (routing.shadowedCount > 0) { + parts.push(`${routing.shadowedCount} shadowed`); + } + if (routing.prefixOnlyCount > 0) { + parts.push(`${routing.prefixOnlyCount} prefix-only`); + } + return parts.length > 0 ? ` ${dim(`(${parts.join(', ')})`)}` : ''; +} + +function renderVerboseRouting( + provider: CLIProxyProvider, + models: Array<{ id: string; name: string }>, + routing: CliproxyProviderRoutingHints | undefined +): void { + if (!routing) { + for (const model of models) { + console.log(dim(` - ${model.id} (${model.name})`)); + } + return; + } + + const routingMap = new Map(routing.models.map((hint) => [hint.modelId, hint])); + for (const model of models) { + const hint = routingMap.get(model.id); + console.log(dim(` - ${model.id} (${model.name})`)); + if (!hint) { + continue; + } + + console.log(dim(` preferred: ${hint.recommendedModelId}`)); + if (hint.unprefixedStatus === 'safe') { + console.log(dim(` unprefixed: resolves to ${routing.displayName}`)); + continue; + } + + if (hint.unprefixedStatus === 'shadowed' && hint.effectiveDisplayName) { + console.log(dim(` unprefixed: currently resolves to ${hint.effectiveDisplayName}`)); + continue; + } + + console.log(dim(` unprefixed: not advertised, use ${hint.recommendedModelId}`)); + } + + if (provider === 'gemini' || provider === 'agy') { + console.log(dim(` short prefix stays backend-pinned even when unprefixed names overlap.`)); + } +} + /** Refresh catalog from CLIProxyAPI */ export async function handleCatalogRefresh(verbose: boolean): Promise { await initUI(); diff --git a/src/commands/cliproxy/variant-subcommand.ts b/src/commands/cliproxy/variant-subcommand.ts index be9cae13..3a30533c 100644 --- a/src/commands/cliproxy/variant-subcommand.ts +++ b/src/commands/cliproxy/variant-subcommand.ts @@ -11,6 +11,8 @@ import { getProviderAccounts } from '../../cliproxy/account-manager'; import { triggerOAuth } from '../../cliproxy/auth/oauth-handler'; import { CLIProxyProfileName, CLIPROXY_PROFILES } from '../../auth/profile-detector'; import { supportsModelConfig, getProviderCatalog, ModelEntry } from '../../cliproxy/model-catalog'; +import { ensureManagedModelPrefixes } from '../../cliproxy/managed-model-prefixes'; +import { getManagedModelPrefix } from '../../shared/cliproxy-model-routing'; import { CLIProxyProvider, CLIProxyBackend } from '../../cliproxy/types'; import type { TargetType } from '../../targets/target-adapter'; import { getPersistedTargetChoices, isPersistedTargetType } from '../../targets/target-metadata'; @@ -115,6 +117,11 @@ function formatModelOption(model: ModelEntry): string { return `${model.name}${tierBadge}`; } +function getSelectableModelId(provider: CLIProxyProvider, modelId: string): string { + const prefix = getManagedModelPrefix(provider); + return prefix ? `${prefix}/${modelId}` : modelId; +} + function getBackendLabel(backend: CLIProxyBackend): string { return backend === 'plus' ? 'CLIProxy Plus' : 'CLIProxy'; } @@ -174,9 +181,17 @@ async function selectTierConfig( // Select model let model: string | undefined; if (supportsModelConfig(provider as CLIProxyProvider)) { + try { + await ensureManagedModelPrefixes([provider as CLIProxyProvider]); + } catch { + // Keep interactive model selection available even when prefix repair fails. + } const catalog = getProviderCatalog(provider as CLIProxyProvider); if (catalog) { - const modelOptions = catalog.models.map((m) => ({ id: m.id, label: formatModelOption(m) })); + const modelOptions = catalog.models.map((m) => ({ + id: getSelectableModelId(provider as CLIProxyProvider, m.id), + label: formatModelOption(m), + })); const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel); model = await InteractivePrompt.selectFromList(`Model for ${tierName}:`, modelOptions, { defaultIndex: defaultIdx >= 0 ? defaultIdx : 0, @@ -426,9 +441,17 @@ export async function handleCreate( let model = parsedArgs.model; if (!model) { if (supportsModelConfig(provider as CLIProxyProvider)) { + try { + await ensureManagedModelPrefixes([provider as CLIProxyProvider]); + } catch { + // Keep variant creation available even when prefix repair fails. + } const catalog = getProviderCatalog(provider as CLIProxyProvider); if (catalog) { - const modelOptions = catalog.models.map((m) => ({ id: m.id, label: formatModelOption(m) })); + const modelOptions = catalog.models.map((m) => ({ + id: getSelectableModelId(provider as CLIProxyProvider, m.id), + label: formatModelOption(m), + })); const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel); model = await InteractivePrompt.selectFromList('Select model:', modelOptions, { defaultIndex: defaultIdx >= 0 ? defaultIdx : 0, @@ -667,10 +690,15 @@ export async function handleEdit( if (changeModel) { const providerForModel = newProvider || (variant.provider as CLIProxyProfileName); if (supportsModelConfig(providerForModel as CLIProxyProvider)) { + try { + await ensureManagedModelPrefixes([providerForModel as CLIProxyProvider]); + } catch { + // Keep edit flow available even when prefix repair fails. + } const catalog = getProviderCatalog(providerForModel as CLIProxyProvider); if (catalog) { const modelOptions = catalog.models.map((m) => ({ - id: m.id, + id: getSelectableModelId(providerForModel as CLIProxyProvider, m.id), label: formatModelOption(m), })); const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel); diff --git a/src/shared/cliproxy-model-routing.ts b/src/shared/cliproxy-model-routing.ts new file mode 100644 index 00000000..dcd06724 --- /dev/null +++ b/src/shared/cliproxy-model-routing.ts @@ -0,0 +1,205 @@ +export const MANAGED_MODEL_PREFIXES = { + gemini: 'gcli', + agy: 'agy', +} as const; + +export type ManagedModelPrefixProvider = keyof typeof MANAGED_MODEL_PREFIXES; +export type ModelRoutingStatus = 'safe' | 'shadowed' | 'prefix-only'; + +export interface CatalogLikeModel { + id: string; + name?: string; +} + +export interface CatalogLikeProvider { + provider: string; + displayName: string; + models: CatalogLikeModel[]; +} + +export interface MergedModelLike { + id: string; + owned_by?: string; + type?: string; +} + +export interface CliproxyModelRoutingHint { + modelId: string; + modelName: string; + prefix: string; + pinnedModelId: string; + recommendedModelId: string; + unprefixedStatus: ModelRoutingStatus; + effectiveProvider: string | null; + effectiveDisplayName: string | null; + effectiveOwnedBy: string | null; + summary: string; +} + +export interface CliproxyProviderRoutingHints { + provider: string; + displayName: string; + prefix: string; + safeCount: number; + shadowedCount: number; + prefixOnlyCount: number; + models: CliproxyModelRoutingHint[]; +} + +const PROVIDER_OWNER_HINTS: Record = { + gemini: ['google'], + agy: ['antigravity'], + claude: ['anthropic'], + codex: ['openai'], + qwen: ['alibaba', 'qwen'], + iflow: ['iflow'], + kimi: ['kimi', 'moonshot'], + kiro: ['kiro', 'aws'], + ghcp: ['github', 'copilot'], +}; + +function normalize(value: string | null | undefined): string { + return typeof value === 'string' ? value.trim().toLowerCase() : ''; +} + +function getManagedPrefix(provider: string): string | null { + const normalizedProvider = normalize(provider); + if (normalizedProvider in MANAGED_MODEL_PREFIXES) { + return MANAGED_MODEL_PREFIXES[normalizedProvider as ManagedModelPrefixProvider]; + } + return null; +} + +function getDisplayName( + provider: string, + catalogs: Partial> +): string | null { + const normalizedProvider = normalize(provider); + const catalog = catalogs[normalizedProvider]; + return catalog?.displayName?.trim() || null; +} + +function inferProvider(model: MergedModelLike): string | null { + const type = normalize(model.type); + const owner = normalize(model.owned_by); + + const directMatches: Record = { + antigravity: 'agy', + 'github-copilot': 'ghcp', + copilot: 'ghcp', + anthropic: 'claude', + 'gemini-cli': 'gemini', + }; + + if (type && directMatches[type]) { + return directMatches[type]; + } + + for (const [provider, hints] of Object.entries(PROVIDER_OWNER_HINTS)) { + if (hints.some((hint) => type.includes(hint) || owner.includes(hint))) { + return provider; + } + } + + return null; +} + +function buildSummary( + providerDisplayName: string, + hint: Pick< + CliproxyModelRoutingHint, + 'modelId' | 'pinnedModelId' | 'unprefixedStatus' | 'effectiveDisplayName' + > +): string { + if (hint.unprefixedStatus === 'safe') { + return `${hint.modelId} currently resolves to ${providerDisplayName}. Use ${hint.pinnedModelId} to keep it pinned.`; + } + + if (hint.unprefixedStatus === 'shadowed' && hint.effectiveDisplayName) { + return `${hint.modelId} currently resolves to ${hint.effectiveDisplayName}. Use ${hint.pinnedModelId} to force ${providerDisplayName}.`; + } + + return `${hint.modelId} is not advertised unprefixed right now. Use ${hint.pinnedModelId} to target ${providerDisplayName}.`; +} + +export function buildCliproxyRoutingHints( + catalogs: Partial>, + mergedModels: MergedModelLike[] +): Partial> { + const mergedModelMap = new Map(); + for (const model of mergedModels) { + const key = normalize(model.id); + if (key && !mergedModelMap.has(key)) { + mergedModelMap.set(key, model); + } + } + + const result: Partial> = {}; + + for (const [providerKey, catalog] of Object.entries(catalogs)) { + if (!catalog) { + continue; + } + + const prefix = getManagedPrefix(providerKey); + if (!prefix) { + continue; + } + + let safeCount = 0; + let shadowedCount = 0; + let prefixOnlyCount = 0; + + const models = catalog.models.map((model) => { + const mergedModel = mergedModelMap.get(normalize(model.id)); + const effectiveProvider = mergedModel ? inferProvider(mergedModel) : null; + const effectiveDisplayName = + effectiveProvider && getDisplayName(effectiveProvider, catalogs) + ? getDisplayName(effectiveProvider, catalogs) + : mergedModel?.owned_by?.trim() || null; + + let unprefixedStatus: ModelRoutingStatus = 'prefix-only'; + if (!mergedModel) { + prefixOnlyCount += 1; + } else if (effectiveProvider === providerKey) { + unprefixedStatus = 'safe'; + safeCount += 1; + } else { + unprefixedStatus = 'shadowed'; + shadowedCount += 1; + } + + const hint: CliproxyModelRoutingHint = { + modelId: model.id, + modelName: model.name?.trim() || model.id, + prefix, + pinnedModelId: `${prefix}/${model.id}`, + recommendedModelId: `${prefix}/${model.id}`, + unprefixedStatus, + effectiveProvider, + effectiveDisplayName, + effectiveOwnedBy: mergedModel?.owned_by?.trim() || null, + summary: '', + }; + + hint.summary = buildSummary(catalog.displayName, hint); + return hint; + }); + + result[providerKey] = { + provider: providerKey, + displayName: catalog.displayName, + prefix, + safeCount, + shadowedCount, + prefixOnlyCount, + models, + }; + } + + return result; +} + +export function getManagedModelPrefix(provider: string): string | null { + return getManagedPrefix(provider); +} diff --git a/src/web-server/routes/catalog-routes.ts b/src/web-server/routes/catalog-routes.ts index c2cfba08..ea27811b 100644 --- a/src/web-server/routes/catalog-routes.ts +++ b/src/web-server/routes/catalog-routes.ts @@ -1,5 +1,5 @@ import { Router, Request, Response } from 'express'; -import { getResolvedCatalogSnapshot } from '../../cliproxy/catalog-cache'; +import { getCatalogRoutingSnapshot } from '../../cliproxy/catalog-routing'; const router = Router(); @@ -9,9 +9,10 @@ const router = Router(); */ router.get('/', async (_req: Request, res: Response): Promise => { try { - const snapshot = await getResolvedCatalogSnapshot(); + const snapshot = await getCatalogRoutingSnapshot(); res.json({ catalogs: snapshot.catalogs, + routing: snapshot.routing, source: snapshot.source, cache: { synced: snapshot.source !== 'static' || snapshot.cacheAge !== null, diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 1744da18..8cf2d4b0 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -32,6 +32,7 @@ import { buildManagementHeaders, } from '../../cliproxy/proxy-target-resolver'; import { fetchRemoteAuthStatus } from '../../cliproxy/remote-auth-fetcher'; +import { ensureManagedModelPrefixes } from '../../cliproxy/managed-model-prefixes'; import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import { tryKiroImport } from '../../cliproxy/auth/kiro-import'; import { @@ -315,6 +316,12 @@ export function getStartAuthNicknameError( */ router.get('/', async (_req: Request, res: Response): Promise => { try { + try { + await ensureManagedModelPrefixes(); + } catch { + // Keep auth status available even when prefix repair cannot run. + } + // Check if remote mode is enabled const target = getProxyTarget(); if (target.isRemote) { @@ -386,6 +393,12 @@ router.get('/', async (_req: Request, res: Response): Promise => { */ router.get('/accounts', async (_req: Request, res: Response): Promise => { try { + try { + await ensureManagedModelPrefixes(); + } catch { + // Non-fatal: account listing should still work without prefix repair. + } + // Check if remote mode is enabled const target = getProxyTarget(); if (target.isRemote) { @@ -677,6 +690,12 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise { + it('uses short managed prefixes for overlapping Gemini and Antigravity models', () => { + const routing = buildCliproxyRoutingHints( + { + gemini: { + provider: 'gemini', + displayName: 'Gemini', + models: [{ id: 'gemini-3-flash-preview', name: 'Gemini Flash' }], + }, + agy: { + provider: 'agy', + displayName: 'Antigravity', + models: [{ id: 'gemini-3-flash', name: 'Gemini 3 Flash' }], + }, + }, + [ + { id: 'gemini-3-flash-preview', owned_by: 'antigravity', type: 'antigravity' }, + { id: 'gemini-3-flash', owned_by: 'antigravity', type: 'antigravity' }, + ] + ); + + expect(getManagedModelPrefix('gemini')).toBe('gcli'); + expect(getManagedModelPrefix('agy')).toBe('agy'); + + expect(routing.gemini?.models[0]).toMatchObject({ + recommendedModelId: 'gcli/gemini-3-flash-preview', + unprefixedStatus: 'shadowed', + effectiveProvider: 'agy', + effectiveDisplayName: 'Antigravity', + }); + + expect(routing.agy?.models[0]).toMatchObject({ + recommendedModelId: 'agy/gemini-3-flash', + unprefixedStatus: 'safe', + effectiveProvider: 'agy', + }); + }); + + it('marks models as prefix-only when they are not advertised unprefixed', () => { + const routing = buildCliproxyRoutingHints( + { + gemini: { + provider: 'gemini', + displayName: 'Gemini', + models: [{ id: 'gemini-3.1-pro-preview', name: 'Gemini 3.1 Pro' }], + }, + }, + [] + ); + + expect(routing.gemini?.prefixOnlyCount).toBe(1); + expect(routing.gemini?.models[0]).toMatchObject({ + recommendedModelId: 'gcli/gemini-3.1-pro-preview', + unprefixedStatus: 'prefix-only', + effectiveProvider: null, + }); + }); +}); diff --git a/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx b/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx index 6a494631..3b3671e2 100644 --- a/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx +++ b/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx @@ -29,6 +29,7 @@ export function CustomPresetDialog({ isSaving, catalog, allModels, + routing, }: CustomPresetDialogProps) { const { t } = useTranslation(); const [values, setValues] = useState(currentValues); @@ -78,6 +79,7 @@ export function CustomPresetDialog({ onChange={(model) => setValues({ ...values, default: model })} catalog={catalog} allModels={allModels} + routing={routing} /> setValues({ ...values, opus: model })} catalog={catalog} allModels={allModels} + routing={routing} /> setValues({ ...values, sonnet: model })} catalog={catalog} allModels={allModels} + routing={routing} /> setValues({ ...values, haiku: model })} catalog={catalog} allModels={allModels} + routing={routing} />
diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index bf0bac22..644d3d9c 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -33,6 +33,7 @@ export function ProviderEditor({ displayName, authStatus, catalog, + routing, logoProvider, baseProvider, isRemoteMode, @@ -263,6 +264,7 @@ export function ProviderEditor({ sonnetModel={sonnetModel} haikuModel={haikuModel} providerModels={providerModels} + routing={routing} extendedContextEnabled={extendedContextEnabled} onExtendedContextToggle={toggleExtendedContext} onApplyPreset={handleApplyPreset} @@ -349,6 +351,7 @@ export function ProviderEditor({ isSaving={createPresetMutation.isPending} catalog={catalog} allModels={providerModels} + routing={routing} />
); diff --git a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx index 0f9deffb..52163244 100644 --- a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx +++ b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx @@ -16,7 +16,10 @@ import type { ModelConfigSectionProps } from './types'; type CatalogPresetModel = NonNullable['models'][number]; -function getPresetUpdates(model: CatalogPresetModel): Record { +function getPresetUpdates( + model: CatalogPresetModel, + toPreferredModelId: (modelId: string) => string +): Record { const mapping = model.presetMapping || { default: model.id, opus: model.id, @@ -25,10 +28,10 @@ function getPresetUpdates(model: CatalogPresetModel): Record { }; return { - ANTHROPIC_MODEL: mapping.default, - ANTHROPIC_DEFAULT_OPUS_MODEL: mapping.opus, - ANTHROPIC_DEFAULT_SONNET_MODEL: mapping.sonnet, - ANTHROPIC_DEFAULT_HAIKU_MODEL: mapping.haiku, + ANTHROPIC_MODEL: toPreferredModelId(mapping.default), + ANTHROPIC_DEFAULT_OPUS_MODEL: toPreferredModelId(mapping.opus), + ANTHROPIC_DEFAULT_SONNET_MODEL: toPreferredModelId(mapping.sonnet), + ANTHROPIC_DEFAULT_HAIKU_MODEL: toPreferredModelId(mapping.haiku), }; } @@ -40,6 +43,7 @@ export function ModelConfigSection({ sonnetModel, haikuModel, providerModels, + routing, provider, extendedContextEnabled, onExtendedContextToggle, @@ -49,6 +53,14 @@ export function ModelConfigSection({ onDeletePreset, isDeletePending, }: ModelConfigSectionProps) { + const routingHintMap = useMemo( + () => + new Map((routing?.models ?? []).map((hint) => [hint.modelId.toLowerCase(), hint] as const)), + [routing] + ); + const toPreferredModelId = (modelId: string): string => + routingHintMap.get(modelId.toLowerCase())?.recommendedModelId ?? modelId; + const extendedContextModels = useMemo(() => { if (!catalog) return []; @@ -126,7 +138,7 @@ export function ModelConfigSection({ variant="outline" size="sm" className="text-xs h-7 gap-1" - onClick={() => onApplyPreset(getPresetUpdates(model))} + onClick={() => onApplyPreset(getPresetUpdates(model, toPreferredModelId))} > Configure which models to use for each tier

+ {routing ? ( +

+ Preferred pinned model names use the {routing.prefix}/ prefix. Unprefixed + names can still resolve to a different backend when providers overlap. +

+ ) : null} {provider === 'codex' && (

Codex tip: suffixes -medium, -high, and -xhigh{' '} @@ -209,6 +227,7 @@ export function ModelConfigSection({ onChange={(model) => onUpdateEnvValue('ANTHROPIC_MODEL', model)} catalog={catalog} allModels={providerModels} + routing={routing} /> {/* Extended Context Toggle - shows when any saved mapping supports it */} {extendedContextModels.length > 0 && onExtendedContextToggle && ( @@ -226,6 +245,7 @@ export function ModelConfigSection({ onChange={(model) => onUpdateEnvValue('ANTHROPIC_DEFAULT_OPUS_MODEL', model)} catalog={catalog} allModels={providerModels} + routing={routing} /> onUpdateEnvValue('ANTHROPIC_DEFAULT_SONNET_MODEL', model)} catalog={catalog} allModels={providerModels} + routing={routing} /> onUpdateEnvValue('ANTHROPIC_DEFAULT_HAIKU_MODEL', model)} catalog={catalog} allModels={providerModels} + routing={routing} />

diff --git a/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx b/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx index 320a0495..5ba8219e 100644 --- a/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx +++ b/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx @@ -10,7 +10,7 @@ import { ModelConfigSection } from './model-config-section'; import { AccountsSection } from './accounts-section'; import { api } from '@/lib/api-client'; import type { ProviderCatalog } from '../provider-model-selector'; -import type { OAuthAccount } from '@/lib/api-client'; +import type { OAuthAccount, CliproxyProviderRoutingHints } from '@/lib/api-client'; import { QUOTA_SUPPORTED_PROVIDERS, type QuotaSupportedProvider } from '@/hooks/use-cliproxy-stats'; interface ModelConfigTabProps { @@ -28,6 +28,7 @@ interface ModelConfigTabProps { sonnetModel?: string; haikuModel?: string; providerModels: Array<{ id: string; owned_by: string }>; + routing?: CliproxyProviderRoutingHints; /** Whether extended context (1M tokens) is enabled */ extendedContextEnabled?: boolean; /** Callback when extended context toggle changes */ @@ -71,6 +72,7 @@ export function ModelConfigTab({ sonnetModel, haikuModel, providerModels, + routing, extendedContextEnabled, onExtendedContextToggle, onApplyPreset, @@ -160,6 +162,7 @@ export function ModelConfigTab({ sonnetModel={sonnetModel} haikuModel={haikuModel} providerModels={providerModels} + routing={routing} provider={provider} extendedContextEnabled={extendedContextEnabled} onExtendedContextToggle={onExtendedContextToggle} diff --git a/ui/src/components/cliproxy/provider-editor/types.ts b/ui/src/components/cliproxy/provider-editor/types.ts index ebb7dde4..fc49b7e3 100644 --- a/ui/src/components/cliproxy/provider-editor/types.ts +++ b/ui/src/components/cliproxy/provider-editor/types.ts @@ -3,7 +3,12 @@ */ import type { ReactNode } from 'react'; -import type { AuthStatus, OAuthAccount, CliTarget } from '@/lib/api-client'; +import type { + AuthStatus, + OAuthAccount, + CliTarget, + CliproxyProviderRoutingHints, +} from '@/lib/api-client'; import type { ProviderCatalog } from '../provider-model-selector'; export interface SettingsResponse { @@ -20,6 +25,7 @@ export interface ProviderEditorProps { displayName: string; authStatus: AuthStatus; catalog?: ProviderCatalog; + routing?: CliproxyProviderRoutingHints; /** Provider type for logo display (defaults to provider) */ logoProvider?: string; /** Base provider for model filtering (defaults to provider). For variants, this is the parent provider. */ @@ -92,6 +98,7 @@ export interface CustomPresetDialogProps { isSaving?: boolean; catalog?: ProviderCatalog; allModels: { id: string; owned_by: string }[]; + routing?: CliproxyProviderRoutingHints; } export interface RawEditorSectionProps { @@ -118,6 +125,7 @@ export interface ModelConfigSectionProps { sonnetModel?: string; haikuModel?: string; providerModels: Array<{ id: string; owned_by: string }>; + routing?: CliproxyProviderRoutingHints; /** Provider name for display */ provider: string; /** Whether extended context (1M tokens) is enabled */ diff --git a/ui/src/components/cliproxy/provider-model-selector.tsx b/ui/src/components/cliproxy/provider-model-selector.tsx index 845c2910..d2924f50 100644 --- a/ui/src/components/cliproxy/provider-model-selector.tsx +++ b/ui/src/components/cliproxy/provider-model-selector.tsx @@ -11,6 +11,7 @@ import { useTranslation } from 'react-i18next'; import { Badge } from '@/components/ui/badge'; import { SearchableSelect } from '@/components/ui/searchable-select'; import { Skeleton } from '@/components/ui/skeleton'; +import type { CliproxyProviderRoutingHints } from '@/lib/api-client'; import { getCodexEffortDisplay } from '@/lib/codex-effort'; import { getResolvedCatalogModels } from '@/lib/model-catalogs'; import { cn } from '@/lib/utils'; @@ -291,9 +292,20 @@ interface FlexibleModelSelectorProps { onChange: (model: string) => void; catalog?: ProviderCatalog; allModels: { id: string; owned_by: string }[]; + routing?: CliproxyProviderRoutingHints; disabled?: boolean; } +function normalizeModelValue( + value: string | undefined, + routing?: CliproxyProviderRoutingHints +): string { + if (!value) return ''; + if (!routing?.prefix) return value; + const prefix = `${routing.prefix}/`; + return value.startsWith(prefix) ? value.slice(prefix.length) : value; +} + export function FlexibleModelSelector({ label, description, @@ -301,6 +313,7 @@ export function FlexibleModelSelector({ onChange, catalog, allModels, + routing, disabled, }: FlexibleModelSelectorProps) { const { t } = useTranslation(); @@ -310,22 +323,50 @@ export function FlexibleModelSelector({ [allModels, catalog] ); const catalogModelIds = new Set(resolvedCatalogModels.map((model) => model.id)); + const routingHints = useMemo( + () => + new Map((routing?.models ?? []).map((hint) => [hint.modelId.toLowerCase(), hint] as const)), + [routing] + ); + const selectedRoutingHint = useMemo( + () => routingHints.get(normalizeModelValue(value, routing).toLowerCase()), + [routing, routingHints, value] + ); const recommendedOptions = resolvedCatalogModels.map((model) => ({ - value: model.id, + value: routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? model.id, groupKey: 'recommended', - searchText: `${model.id} ${model.name}`, + searchText: `${model.id} ${model.name} ${routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? ''}`, keywords: [model.tier ?? '', catalog?.provider ?? ''], triggerContent: (
- {model.id} + + {routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? model.id} + + {routingHints.get(model.id.toLowerCase()) ? ( + + {routingHints.get(model.id.toLowerCase())?.prefix} + + ) : null} {isCodexProvider && }
), itemContent: (
- {model.id} + + {routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? model.id} + {model.tier === 'paid' && } + {routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'shadowed' ? ( + + Shadowed + + ) : null} + {routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'prefix-only' ? ( + + Prefix only + + ) : null} {isCodexProvider && }
), @@ -351,6 +392,33 @@ export function FlexibleModelSelector({
), })); + const selectedValueMissing = + Boolean(value) && + !recommendedOptions.some((option) => option.value === value) && + !allModelOptions.some((option) => option.value === value); + const legacySelectedOption = value + ? { + value, + groupKey: 'current', + searchText: value, + triggerContent: ( +
+ {value} + + Current + +
+ ), + itemContent: ( +
+ {value} + + Current + +
+ ), + } + : null; const hasAvailableModels = recommendedOptions.length + allModelOptions.length > 0; return ( @@ -372,6 +440,14 @@ export function FlexibleModelSelector({ } triggerClassName="h-9" groups={[ + ...(selectedValueMissing && legacySelectedOption + ? [ + { + key: 'current', + label: Current value, + }, + ] + : []), { key: 'recommended', label: ( @@ -387,8 +463,32 @@ export function FlexibleModelSelector({ ), }, ]} - options={[...recommendedOptions, ...allModelOptions]} + options={[ + ...(selectedValueMissing && legacySelectedOption ? [legacySelectedOption] : []), + ...recommendedOptions, + ...allModelOptions, + ]} /> + {selectedRoutingHint ? ( +
+
+ Preferred pinned model: {selectedRoutingHint.recommendedModelId} +
+

{selectedRoutingHint.summary}

+
+ ) : null} + {value && !selectedRoutingHint && normalizeModelValue(value, routing) !== value ? ( +
+ Using pinned model: {value} +
+ ) : null}
); } diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 54c491c0..9b28b188 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -468,6 +468,29 @@ export interface CliproxyCatalogModel { presetMapping?: CliproxyCatalogPresetMapping; } +export interface CliproxyModelRoutingHint { + modelId: string; + modelName: string; + prefix: string; + pinnedModelId: string; + recommendedModelId: string; + unprefixedStatus: 'safe' | 'shadowed' | 'prefix-only'; + effectiveProvider: string | null; + effectiveDisplayName: string | null; + effectiveOwnedBy: string | null; + summary: string; +} + +export interface CliproxyProviderRoutingHints { + provider: string; + displayName: string; + prefix: string; + safeCount: number; + shadowedCount: number; + prefixOnlyCount: number; + models: CliproxyModelRoutingHint[]; +} + export interface CliproxyProviderCatalog { provider: string; displayName: string; @@ -477,6 +500,7 @@ export interface CliproxyProviderCatalog { export interface CliproxyCatalogResponse { catalogs: Partial>; + routing?: Partial>; source: 'live' | 'cache' | 'static'; cache: { synced: boolean; diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index ee21191f..7c5b1e8f 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -264,6 +264,7 @@ export function CliproxyPage() { const isRemoteMode = authData?.source === 'remote'; const variants = useMemo(() => variantsData?.variants || [], [variantsData?.variants]); const catalogs = useMemo(() => buildUiCatalogs(catalogData?.catalogs), [catalogData?.catalogs]); + const routingHints = catalogData?.routing ?? {}; const fetchedCatalogsReady = Boolean(catalogData); // Wrapper to persist provider selection to localStorage @@ -459,6 +460,7 @@ export function CliproxyPage() { })} authStatus={parentAuthForVariant} catalog={catalogs[selectedVariantData.provider]} + routing={routingHints[selectedVariantData.provider]} logoProvider={selectedVariantData.provider} baseProvider={selectedVariantData.provider} defaultTarget={selectedVariantData.target} @@ -512,6 +514,7 @@ export function CliproxyPage() { displayName={selectedStatus.displayName} authStatus={selectedStatus} catalog={catalogs[selectedStatus.provider]} + routing={routingHints[selectedStatus.provider]} isRemoteMode={isRemoteMode} topNotice={ showAccountSafetyWarning ? ( diff --git a/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx b/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx index 6ecbdf4b..923e01e6 100644 --- a/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx +++ b/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx @@ -110,4 +110,69 @@ describe('ModelConfigSection presets', () => { ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gemini-3-9-flash-preview', }); }); + + it('applies managed short prefixes when routing guidance is available', async () => { + const onApplyPreset = vi.fn(); + + render( + + ); + + await userEvent.click(screen.getByRole('button', { name: 'Gemini Flash' })); + + expect(onApplyPreset).toHaveBeenCalledWith({ + ANTHROPIC_MODEL: 'gcli/gemini-3-flash-preview', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gcli/gemini-3.1-pro-preview', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gcli/gemini-3.1-pro-preview', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gcli/gemini-3-flash-preview', + }); + }); }); From b8a8f841536f89386e3452261037f0fdcc389059 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 9 Apr 2026 17:41:50 -0400 Subject: [PATCH 11/54] docs(roadmap): note cliproxy routing prefix clarity --- docs/project-roadmap.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 8ef5aee2..6ae4fc8e 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -1,6 +1,6 @@ # CCS Project Roadmap -Last Updated: 2026-04-08 +Last Updated: 2026-04-09 Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans. @@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes +- **2026-04-09**: **#938** Cliproxy model routing now exposes backend-pinned short prefixes for overlapping OAuth backends. CCS repairs managed OAuth auth-file prefixes for Gemini CLI (`gcli`) and Antigravity (`agy`), enriches `/api/cliproxy/catalog` with routing hints that show whether an unprefixed model is safe, shadowed, or prefix-only, upgrades `ccs cliproxy catalog` plus interactive variant model pickers to surface the pinned names, and updates the `ccs config` Cliproxy model selection UI so users can see the preferred call name and current effective backend before saving settings. - **2026-04-08**: **#931** `/cliproxy` model pickers now source their provider catalogs from CLIProxy management model definitions instead of treating the UI catalog file as the dropdown source of truth. CCS now refreshes live model definitions for Gemini, Codex, Claude, Antigravity, Qwen, iFlow, Kiro, GitHub Copilot, and Kimi through `/api/cliproxy/catalog`, overlays CCS-only preset/default metadata on top of those upstream models, keeps `/api/cliproxy/models` as the live availability feed, and falls back to cached/static catalogs when the proxy is unavailable so the dashboard never goes blank. - **2026-04-08**: **#929** Image Analysis hardening now makes the managed `ccs-image-analysis` MCP path authoritative on healthy Claude-target launches, suppresses stale CCS-managed image `Read` hooks instead of letting them compete with MCP, keeps the legacy hook available only as compatibility fallback when MCP provisioning fails, and extends self-heal to dashboard provisioning plus `ccs doctor --fix` so stale hook files and missing isolated MCP sync are repaired automatically. - **2026-04-07**: CLIProxy routing strategy is now a first-class CCS surface. Users can inspect and explicitly change `round-robin` vs `fill-first` from `ccs cliproxy routing` and from a native `/cliproxy` dashboard card. Local mode now persists the chosen startup default into CCS-managed CLIProxy config generation, while untouched installs remain on `round-robin`. CCS deliberately does not infer strategy from account composition. From 0c10cb6f47d23db33cbce17456d46aee1843b9ae Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 9 Apr 2026 18:01:01 -0400 Subject: [PATCH 12/54] fix(cliproxy): harden routing prefix sync and UI pinning --- src/cliproxy/catalog-routing.ts | 7 -- src/cliproxy/managed-model-prefixes.ts | 32 ++++++-- src/commands/cliproxy/catalog-subcommand.ts | 22 ++++-- src/commands/cliproxy/variant-subcommand.ts | 25 ++++-- src/shared/cliproxy-model-routing.ts | 21 ++++- src/web-server/index.ts | 10 +++ src/web-server/routes/cliproxy-auth-routes.ts | 12 --- .../unit/cliproxy/model-routing-hints.test.ts | 3 + .../provider-editor/custom-preset-dialog.tsx | 25 +++++- .../provider-editor/model-config-section.tsx | 26 +++++-- .../cliproxy/provider-model-selector.tsx | 58 +++++++++++--- ui/src/lib/api-client.ts | 1 + .../model-config-section.test.tsx | 77 +++++++++++++++++++ 13 files changed, 260 insertions(+), 59 deletions(-) diff --git a/src/cliproxy/catalog-routing.ts b/src/cliproxy/catalog-routing.ts index fc373df8..7b0c5ea3 100644 --- a/src/cliproxy/catalog-routing.ts +++ b/src/cliproxy/catalog-routing.ts @@ -3,7 +3,6 @@ import { type CliproxyProviderRoutingHints, } from '../shared/cliproxy-model-routing'; import { fetchCliproxyModels } from './stats-fetcher'; -import { ensureManagedModelPrefixes } from './managed-model-prefixes'; import { getResolvedCatalogSnapshot, type CatalogSource, @@ -20,12 +19,6 @@ export interface CatalogRoutingSnapshot { } export async function getCatalogRoutingSnapshot(): Promise { - try { - await ensureManagedModelPrefixes(); - } catch { - // Keep catalog rendering non-fatal when prefix sync is unavailable. - } - const snapshot: ResolvedCatalogSnapshot = await getResolvedCatalogSnapshot(); const modelsResponse = await fetchCliproxyModels(); const routing = buildCliproxyRoutingHints(snapshot.catalogs, modelsResponse?.models ?? []); diff --git a/src/cliproxy/managed-model-prefixes.ts b/src/cliproxy/managed-model-prefixes.ts index 6215ec67..2ab33041 100644 --- a/src/cliproxy/managed-model-prefixes.ts +++ b/src/cliproxy/managed-model-prefixes.ts @@ -10,6 +10,11 @@ interface ManagementAuthFileRecord { type?: string; } +interface AuthFileMetadata { + prefix: string | null; + provider: CLIProxyProvider | null; +} + export interface ManagedPrefixSyncResult { checked: number; updated: number; @@ -49,7 +54,7 @@ async function patchAuthFilePrefix(name: string, prefix: string): Promise } } -async function readAuthFilePrefix(name: string): Promise { +async function readAuthFileMetadata(name: string): Promise { const target = getProxyTarget(); const url = buildProxyUrl( target, @@ -65,10 +70,19 @@ async function readAuthFilePrefix(name: string): Promise { const content = await response.text(); try { - const parsed = JSON.parse(content) as { prefix?: unknown }; - return typeof parsed.prefix === 'string' ? parsed.prefix.trim() : null; + const parsed = JSON.parse(content) as { prefix?: unknown; provider?: unknown; type?: unknown }; + const providerName = + typeof parsed.provider === 'string' + ? parsed.provider + : typeof parsed.type === 'string' + ? parsed.type + : ''; + return { + prefix: typeof parsed.prefix === 'string' ? parsed.prefix.trim() : null, + provider: providerName ? mapExternalProviderName(providerName) : null, + }; } catch { - return null; + return { prefix: null, provider: null }; } } @@ -101,10 +115,18 @@ export async function ensureManagedModelPrefixes( try { checked += 1; - const currentPrefix = await readAuthFilePrefix(record.name); + const { prefix: currentPrefix, provider: fileProvider } = await readAuthFileMetadata( + record.name + ); + if (fileProvider !== provider) { + continue; + } if (currentPrefix === prefix) { continue; } + if (currentPrefix && currentPrefix !== prefix) { + continue; + } await patchAuthFilePrefix(record.name, prefix); updated += 1; } catch { diff --git a/src/commands/cliproxy/catalog-subcommand.ts b/src/commands/cliproxy/catalog-subcommand.ts index a26d05fb..13374e65 100644 --- a/src/commands/cliproxy/catalog-subcommand.ts +++ b/src/commands/cliproxy/catalog-subcommand.ts @@ -7,6 +7,7 @@ import { refreshCatalogFromProxy, } from '../../cliproxy/catalog-cache'; import { getCatalogRoutingSnapshot } from '../../cliproxy/catalog-routing'; +import { ensureManagedModelPrefixes } from '../../cliproxy/managed-model-prefixes'; import { getProxyTarget } from '../../cliproxy/proxy-target-resolver'; import type { CLIProxyProvider } from '../../cliproxy/types'; import type { RemoteModelInfo } from '../../cliproxy/management-api-types'; @@ -46,8 +47,17 @@ export async function handleCatalogStatus(verbose: boolean): Promise { console.log(header('Model Catalog')); console.log(''); - const routingSnapshot = await getCatalogRoutingSnapshot(); - const cacheAge = routingSnapshot.cacheAge ?? getCacheAge(); + let routingSnapshot: Awaited> | null = null; + if (verbose) { + try { + await ensureManagedModelPrefixes(); + routingSnapshot = await getCatalogRoutingSnapshot(); + } catch { + routingSnapshot = null; + } + } + + const cacheAge = routingSnapshot?.cacheAge ?? getCacheAge(); if (cacheAge) { console.log(` Cache: ${color('synced', 'success')} (${cacheAge})`); } else { @@ -58,10 +68,10 @@ export async function handleCatalogStatus(verbose: boolean): Promise { console.log(subheader('Providers:')); for (const provider of SYNCABLE_PROVIDERS) { - const catalog = routingSnapshot.catalogs[provider] ?? getResolvedCatalog(provider); + const catalog = routingSnapshot?.catalogs[provider] ?? getResolvedCatalog(provider); if (catalog) { const count = catalog.models.length; - const routing = routingSnapshot.routing[provider]; + const routing = routingSnapshot?.routing[provider]; const suffix = renderRoutingSummary(routing); console.log(` ${color(catalog.displayName.padEnd(20), 'command')} ${count} models${suffix}`); if (verbose) { @@ -112,7 +122,9 @@ function renderVerboseRouting( continue; } - console.log(dim(` preferred: ${hint.recommendedModelId}`)); + console.log( + dim(` ${hint.pinnedAvailable ? 'preferred' : 'suggested'}: ${hint.recommendedModelId}`) + ); if (hint.unprefixedStatus === 'safe') { console.log(dim(` unprefixed: resolves to ${routing.displayName}`)); continue; diff --git a/src/commands/cliproxy/variant-subcommand.ts b/src/commands/cliproxy/variant-subcommand.ts index 3a30533c..fd43ac93 100644 --- a/src/commands/cliproxy/variant-subcommand.ts +++ b/src/commands/cliproxy/variant-subcommand.ts @@ -10,9 +10,10 @@ import * as path from 'path'; import { getProviderAccounts } from '../../cliproxy/account-manager'; import { triggerOAuth } from '../../cliproxy/auth/oauth-handler'; import { CLIProxyProfileName, CLIPROXY_PROFILES } from '../../auth/profile-detector'; +import { getCatalogRoutingSnapshot } from '../../cliproxy/catalog-routing'; import { supportsModelConfig, getProviderCatalog, ModelEntry } from '../../cliproxy/model-catalog'; import { ensureManagedModelPrefixes } from '../../cliproxy/managed-model-prefixes'; -import { getManagedModelPrefix } from '../../shared/cliproxy-model-routing'; +import type { CliproxyProviderRoutingHints } from '../../shared/cliproxy-model-routing'; import { CLIProxyProvider, CLIProxyBackend } from '../../cliproxy/types'; import type { TargetType } from '../../targets/target-adapter'; import { getPersistedTargetChoices, isPersistedTargetType } from '../../targets/target-metadata'; @@ -117,9 +118,14 @@ function formatModelOption(model: ModelEntry): string { return `${model.name}${tierBadge}`; } -function getSelectableModelId(provider: CLIProxyProvider, modelId: string): string { - const prefix = getManagedModelPrefix(provider); - return prefix ? `${prefix}/${modelId}` : modelId; +function getSelectableModelId( + modelId: string, + routing: CliproxyProviderRoutingHints | undefined +): string { + const hint = routing?.models.find( + (entry) => entry.modelId.toLowerCase() === modelId.toLowerCase() + ); + return hint?.pinnedAvailable ? hint.recommendedModelId : modelId; } function getBackendLabel(backend: CLIProxyBackend): string { @@ -186,10 +192,11 @@ async function selectTierConfig( } catch { // Keep interactive model selection available even when prefix repair fails. } + const routing = (await getCatalogRoutingSnapshot()).routing[provider as CLIProxyProvider]; const catalog = getProviderCatalog(provider as CLIProxyProvider); if (catalog) { const modelOptions = catalog.models.map((m) => ({ - id: getSelectableModelId(provider as CLIProxyProvider, m.id), + id: getSelectableModelId(m.id, routing), label: formatModelOption(m), })); const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel); @@ -446,10 +453,11 @@ export async function handleCreate( } catch { // Keep variant creation available even when prefix repair fails. } + const routing = (await getCatalogRoutingSnapshot()).routing[provider as CLIProxyProvider]; const catalog = getProviderCatalog(provider as CLIProxyProvider); if (catalog) { const modelOptions = catalog.models.map((m) => ({ - id: getSelectableModelId(provider as CLIProxyProvider, m.id), + id: getSelectableModelId(m.id, routing), label: formatModelOption(m), })); const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel); @@ -695,10 +703,13 @@ export async function handleEdit( } catch { // Keep edit flow available even when prefix repair fails. } + const routing = (await getCatalogRoutingSnapshot()).routing[ + providerForModel as CLIProxyProvider + ]; const catalog = getProviderCatalog(providerForModel as CLIProxyProvider); if (catalog) { const modelOptions = catalog.models.map((m) => ({ - id: getSelectableModelId(providerForModel as CLIProxyProvider, m.id), + id: getSelectableModelId(m.id, routing), label: formatModelOption(m), })); const defaultIdx = catalog.models.findIndex((m) => m.id === catalog.defaultModel); diff --git a/src/shared/cliproxy-model-routing.ts b/src/shared/cliproxy-model-routing.ts index dcd06724..86c4e1ca 100644 --- a/src/shared/cliproxy-model-routing.ts +++ b/src/shared/cliproxy-model-routing.ts @@ -29,6 +29,7 @@ export interface CliproxyModelRoutingHint { prefix: string; pinnedModelId: string; recommendedModelId: string; + pinnedAvailable: boolean; unprefixedStatus: ModelRoutingStatus; effectiveProvider: string | null; effectiveDisplayName: string | null; @@ -108,9 +109,13 @@ function buildSummary( providerDisplayName: string, hint: Pick< CliproxyModelRoutingHint, - 'modelId' | 'pinnedModelId' | 'unprefixedStatus' | 'effectiveDisplayName' + 'modelId' | 'pinnedModelId' | 'pinnedAvailable' | 'unprefixedStatus' | 'effectiveDisplayName' > ): string { + if (!hint.pinnedAvailable) { + return `${hint.modelId} does not currently advertise a live pinned route for ${hint.pinnedModelId}. Reconnect or refresh managed prefixes before treating it as pinned.`; + } + if (hint.unprefixedStatus === 'safe') { return `${hint.modelId} currently resolves to ${providerDisplayName}. Use ${hint.pinnedModelId} to keep it pinned.`; } @@ -151,6 +156,15 @@ export function buildCliproxyRoutingHints( let prefixOnlyCount = 0; const models = catalog.models.map((model) => { + const pinnedCandidates = mergedModels + .filter((candidate) => normalize(candidate.id).endsWith(`/${normalize(model.id)}`)) + .filter((candidate) => inferProvider(candidate) === providerKey) + .map((candidate) => candidate.id) + .sort((left, right) => left.localeCompare(right)); + const managedPinnedId = `${prefix}/${model.id}`; + const recommendedModelId = pinnedCandidates.includes(managedPinnedId) + ? managedPinnedId + : (pinnedCandidates[0] ?? managedPinnedId); const mergedModel = mergedModelMap.get(normalize(model.id)); const effectiveProvider = mergedModel ? inferProvider(mergedModel) : null; const effectiveDisplayName = @@ -173,8 +187,9 @@ export function buildCliproxyRoutingHints( modelId: model.id, modelName: model.name?.trim() || model.id, prefix, - pinnedModelId: `${prefix}/${model.id}`, - recommendedModelId: `${prefix}/${model.id}`, + pinnedModelId: managedPinnedId, + recommendedModelId, + pinnedAvailable: pinnedCandidates.length > 0, unprefixedStatus, effectiveProvider, effectiveDisplayName, diff --git a/src/web-server/index.ts b/src/web-server/index.ts index fd2de5a6..9f5ff51f 100644 --- a/src/web-server/index.ts +++ b/src/web-server/index.ts @@ -13,6 +13,8 @@ import { WebSocketServer } from 'ws'; import { setupWebSocket } from './websocket'; import { createSessionMiddleware, authMiddleware } from './middleware/auth-middleware'; import { requestLoggingMiddleware } from './middleware/request-logging-middleware'; +import { ensureManagedModelPrefixes } from '../cliproxy/managed-model-prefixes'; +import { getProxyTarget } from '../cliproxy/proxy-target-resolver'; import { startAutoSyncWatcher, stopAutoSyncWatcher } from '../cliproxy/sync'; import { shutdownUsageAggregator } from './usage/aggregator'; import { createLogger } from '../services/logging'; @@ -119,6 +121,14 @@ export async function startServer(options: ServerOptions): Promise { + logger.warn('cliproxy.prefix_sync_failed', 'Managed model prefix repair failed', { + error: error instanceof Error ? error.message : String(error), + }); + }); + } + // Combined cleanup function const cleanup = () => { wsCleanup(); diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 8cf2d4b0..686278df 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -316,12 +316,6 @@ export function getStartAuthNicknameError( */ router.get('/', async (_req: Request, res: Response): Promise => { try { - try { - await ensureManagedModelPrefixes(); - } catch { - // Keep auth status available even when prefix repair cannot run. - } - // Check if remote mode is enabled const target = getProxyTarget(); if (target.isRemote) { @@ -393,12 +387,6 @@ router.get('/', async (_req: Request, res: Response): Promise => { */ router.get('/accounts', async (_req: Request, res: Response): Promise => { try { - try { - await ensureManagedModelPrefixes(); - } catch { - // Non-fatal: account listing should still work without prefix repair. - } - // Check if remote mode is enabled const target = getProxyTarget(); if (target.isRemote) { diff --git a/tests/unit/cliproxy/model-routing-hints.test.ts b/tests/unit/cliproxy/model-routing-hints.test.ts index 5074621c..07734dcb 100644 --- a/tests/unit/cliproxy/model-routing-hints.test.ts +++ b/tests/unit/cliproxy/model-routing-hints.test.ts @@ -30,6 +30,7 @@ describe('cliproxy model routing hints', () => { expect(routing.gemini?.models[0]).toMatchObject({ recommendedModelId: 'gcli/gemini-3-flash-preview', + pinnedAvailable: false, unprefixedStatus: 'shadowed', effectiveProvider: 'agy', effectiveDisplayName: 'Antigravity', @@ -37,6 +38,7 @@ describe('cliproxy model routing hints', () => { expect(routing.agy?.models[0]).toMatchObject({ recommendedModelId: 'agy/gemini-3-flash', + pinnedAvailable: false, unprefixedStatus: 'safe', effectiveProvider: 'agy', }); @@ -57,6 +59,7 @@ describe('cliproxy model routing hints', () => { expect(routing.gemini?.prefixOnlyCount).toBe(1); expect(routing.gemini?.models[0]).toMatchObject({ recommendedModelId: 'gcli/gemini-3.1-pro-preview', + pinnedAvailable: false, unprefixedStatus: 'prefix-only', effectiveProvider: null, }); diff --git a/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx b/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx index 3b3671e2..2116a44d 100644 --- a/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx +++ b/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx @@ -20,6 +20,25 @@ import { FlexibleModelSelector } from '../provider-model-selector'; import type { CustomPresetDialogProps, ModelMappingValues } from './types'; import { useTranslation } from 'react-i18next'; +function normalizePresetValues( + values: ModelMappingValues, + routing: CustomPresetDialogProps['routing'] +): ModelMappingValues { + const toPreferredModelId = (modelId: string): string => { + const hint = routing?.models.find( + (entry) => entry.modelId.toLowerCase() === modelId.toLowerCase() + ); + return hint?.pinnedAvailable ? hint.recommendedModelId : modelId; + }; + + return { + default: toPreferredModelId(values.default), + opus: toPreferredModelId(values.opus), + sonnet: toPreferredModelId(values.sonnet), + haiku: toPreferredModelId(values.haiku), + }; +} + export function CustomPresetDialog({ open, onClose, @@ -32,13 +51,15 @@ export function CustomPresetDialog({ routing, }: CustomPresetDialogProps) { const { t } = useTranslation(); - const [values, setValues] = useState(currentValues); + const [values, setValues] = useState( + normalizePresetValues(currentValues, routing) + ); const [presetName, setPresetName] = useState(''); // Reset values when dialog opens with current values const handleOpenChange = (isOpen: boolean) => { if (isOpen) { - setValues(currentValues); + setValues(normalizePresetValues(currentValues, routing)); setPresetName(''); } else { onClose(); diff --git a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx index 52163244..eb62216e 100644 --- a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx +++ b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx @@ -53,13 +53,16 @@ export function ModelConfigSection({ onDeletePreset, isDeletePending, }: ModelConfigSectionProps) { + const pinningReady = (routing?.models ?? []).some((hint) => hint.pinnedAvailable); const routingHintMap = useMemo( () => new Map((routing?.models ?? []).map((hint) => [hint.modelId.toLowerCase(), hint] as const)), [routing] ); const toPreferredModelId = (modelId: string): string => - routingHintMap.get(modelId.toLowerCase())?.recommendedModelId ?? modelId; + routingHintMap.get(modelId.toLowerCase())?.pinnedAvailable + ? (routingHintMap.get(modelId.toLowerCase())?.recommendedModelId ?? modelId) + : modelId; const extendedContextModels = useMemo(() => { if (!catalog) return []; @@ -160,10 +163,10 @@ export function ModelConfigSection({ className="text-xs h-7 gap-1 pr-6" onClick={() => { onApplyPreset({ - ANTHROPIC_MODEL: preset.default, - ANTHROPIC_DEFAULT_OPUS_MODEL: preset.opus, - ANTHROPIC_DEFAULT_SONNET_MODEL: preset.sonnet, - ANTHROPIC_DEFAULT_HAIKU_MODEL: preset.haiku, + ANTHROPIC_MODEL: toPreferredModelId(preset.default), + ANTHROPIC_DEFAULT_OPUS_MODEL: toPreferredModelId(preset.opus), + ANTHROPIC_DEFAULT_SONNET_MODEL: toPreferredModelId(preset.sonnet), + ANTHROPIC_DEFAULT_HAIKU_MODEL: toPreferredModelId(preset.haiku), }); }} > @@ -209,8 +212,17 @@ export function ModelConfigSection({

{routing ? (

- Preferred pinned model names use the {routing.prefix}/ prefix. Unprefixed - names can still resolve to a different backend when providers overlap. + {pinningReady ? ( + <> + Preferred pinned model names use the {routing.prefix}/ prefix. + Unprefixed names can still resolve to a different backend when providers overlap. + + ) : ( + <> + Managed pinning for {routing.prefix}/ is not currently advertised by + the proxy. Unprefixed names may still be ambiguous until prefix repair completes. + + )}

) : null} {provider === 'codex' && ( diff --git a/ui/src/components/cliproxy/provider-model-selector.tsx b/ui/src/components/cliproxy/provider-model-selector.tsx index d2924f50..0fa58b82 100644 --- a/ui/src/components/cliproxy/provider-model-selector.tsx +++ b/ui/src/components/cliproxy/provider-model-selector.tsx @@ -306,6 +306,16 @@ function normalizeModelValue( return value.startsWith(prefix) ? value.slice(prefix.length) : value; } +function getPreferredOptionValue( + modelId: string, + routingHint: CliproxyProviderRoutingHints['models'][number] | undefined +): string { + if (!routingHint?.pinnedAvailable) { + return modelId; + } + return routingHint.recommendedModelId; +} + export function FlexibleModelSelector({ label, description, @@ -334,16 +344,16 @@ export function FlexibleModelSelector({ ); const recommendedOptions = resolvedCatalogModels.map((model) => ({ - value: routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? model.id, + value: getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())), groupKey: 'recommended', searchText: `${model.id} ${model.name} ${routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? ''}`, keywords: [model.tier ?? '', catalog?.provider ?? ''], triggerContent: (
- {routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? model.id} + {getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))} - {routingHints.get(model.id.toLowerCase()) ? ( + {routingHints.get(model.id.toLowerCase())?.pinnedAvailable ? ( {routingHints.get(model.id.toLowerCase())?.prefix} @@ -354,7 +364,7 @@ export function FlexibleModelSelector({ itemContent: (
- {routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? model.id} + {getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))} {model.tier === 'paid' && } {routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'shadowed' ? ( @@ -375,19 +385,38 @@ export function FlexibleModelSelector({ const allModelOptions = allModels .filter((model) => !catalogModelIds.has(model.id)) .map((model) => ({ - value: model.id, + value: getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())), groupKey: 'all', - searchText: model.id, + searchText: `${model.id} ${routingHints.get(model.id.toLowerCase())?.recommendedModelId ?? ''}`, keywords: [model.owned_by], triggerContent: (
- {model.id} + + {getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))} + + {routingHints.get(model.id.toLowerCase())?.pinnedAvailable ? ( + + {routingHints.get(model.id.toLowerCase())?.prefix} + + ) : null} {isCodexProvider && }
), itemContent: (
- {model.id} + + {getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase()))} + + {routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'shadowed' ? ( + + Shadowed + + ) : null} + {routingHints.get(model.id.toLowerCase())?.unprefixedStatus === 'prefix-only' ? ( + + Prefix only + + ) : null} {isCodexProvider && }
), @@ -479,14 +508,21 @@ export function FlexibleModelSelector({ )} >
- Preferred pinned model: {selectedRoutingHint.recommendedModelId} + {selectedRoutingHint.pinnedAvailable + ? 'Preferred pinned model:' + : 'Pinned route status:'}{' '} + + {selectedRoutingHint.pinnedAvailable + ? selectedRoutingHint.recommendedModelId + : selectedRoutingHint.pinnedModelId} +

{selectedRoutingHint.summary}

) : null} {value && !selectedRoutingHint && normalizeModelValue(value, routing) !== value ? ( -
- Using pinned model: {value} +
+ Pinned model is not currently advertised by the proxy: {value}
) : null}
diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 9b28b188..d8493a99 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -474,6 +474,7 @@ export interface CliproxyModelRoutingHint { prefix: string; pinnedModelId: string; recommendedModelId: string; + pinnedAvailable: boolean; unprefixedStatus: 'safe' | 'shadowed' | 'prefix-only'; effectiveProvider: string | null; effectiveDisplayName: string | null; diff --git a/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx b/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx index 923e01e6..7fdabedb 100644 --- a/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx +++ b/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx @@ -137,6 +137,7 @@ describe('ModelConfigSection presets', () => { prefix: 'gcli', pinnedModelId: 'gcli/gemini-3.1-pro-preview', recommendedModelId: 'gcli/gemini-3.1-pro-preview', + pinnedAvailable: true, unprefixedStatus: 'shadowed', effectiveProvider: 'agy', effectiveDisplayName: 'Antigravity', @@ -149,6 +150,7 @@ describe('ModelConfigSection presets', () => { prefix: 'gcli', pinnedModelId: 'gcli/gemini-3-flash-preview', recommendedModelId: 'gcli/gemini-3-flash-preview', + pinnedAvailable: true, unprefixedStatus: 'shadowed', effectiveProvider: 'agy', effectiveDisplayName: 'Antigravity', @@ -175,4 +177,79 @@ describe('ModelConfigSection presets', () => { ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gcli/gemini-3-flash-preview', }); }); + + it('normalizes saved presets through preferred pinned model ids when live pinning is available', async () => { + const onApplyPreset = vi.fn(); + + render( + + ); + + await userEvent.click(screen.getByRole('button', { name: 'legacy' })); + + expect(onApplyPreset).toHaveBeenCalledWith({ + ANTHROPIC_MODEL: 'gcli/gemini-3-flash-preview', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gcli/gemini-3.1-pro-preview', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gcli/gemini-3.1-pro-preview', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gcli/gemini-3-flash-preview', + }); + }); }); From b6aa6fb0d4471b68fb795ef4ba2449f2a9f72788 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 9 Apr 2026 18:41:14 -0400 Subject: [PATCH 13/54] fix(cliproxy): cover managed prefix review follow-up --- src/commands/cliproxy/help-subcommand.ts | 5 +- .../cliproxy/managed-model-prefixes.test.ts | 190 ++++++++++++++++++ 2 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 tests/unit/cliproxy/managed-model-prefixes.test.ts diff --git a/src/commands/cliproxy/help-subcommand.ts b/src/commands/cliproxy/help-subcommand.ts index 9e1eae44..56a2863d 100644 --- a/src/commands/cliproxy/help-subcommand.ts +++ b/src/commands/cliproxy/help-subcommand.ts @@ -36,7 +36,7 @@ export async function showHelp(): Promise { [ 'Catalog Commands:', [ - ['catalog', 'Show catalog status (cached vs static)'], + ['catalog', 'Show catalog status, routing hints, and pinned short prefixes'], ['catalog refresh', 'Sync models from remote CLIProxy'], ['catalog reset', 'Clear cache, revert to static catalog'], ], @@ -85,7 +85,7 @@ export async function showHelp(): Promise { [ ['--backend ', 'Use specific backend: original | plus (default: from config)'], ['--target ', 'Default target for created/edited variants: claude | droid'], - ['--verbose, -v', 'Show detailed quota fetch diagnostics'], + ['--verbose, -v', 'Show detailed diagnostics including routing hints and quota fetches'], ], ], ]; @@ -100,6 +100,7 @@ export async function showHelp(): Promise { } console.log(dim(' Note: CLIProxy now persists by default. Use "stop" to terminate.')); + console.log(dim(' Routing: use gcli/ or agy/ to keep overlapping models pinned.')); console.log(''); console.log(subheader('Notes:')); console.log(` Default fallback version: ${color(getFallbackVersion(), 'info')}`); diff --git a/tests/unit/cliproxy/managed-model-prefixes.test.ts b/tests/unit/cliproxy/managed-model-prefixes.test.ts new file mode 100644 index 00000000..b79f8678 --- /dev/null +++ b/tests/unit/cliproxy/managed-model-prefixes.test.ts @@ -0,0 +1,190 @@ +import { afterEach, describe, expect, it, mock } from 'bun:test'; +import { ensureManagedModelPrefixes } from '../../../src/cliproxy/managed-model-prefixes'; + +const originalFetch = global.fetch; + +interface MockAuthFileRecord { + account_type?: string; + name: string; + provider?: string; + type?: string; +} + +interface DownloadResponse { + body: string; + status?: number; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function textResponse(body: string, status = 200): Response { + return new Response(body, { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function installFetchMock(options: { + files: MockAuthFileRecord[]; + downloads?: Record; + patchStatuses?: Record; +}) { + const requests: Array<{ url: string; method: string; body: string | undefined }> = []; + + global.fetch = mock((input: string | URL | Request, init?: RequestInit) => { + const url = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + const method = init?.method ?? 'GET'; + const body = typeof init?.body === 'string' ? init.body : undefined; + + requests.push({ url, method, body }); + + if (url.endsWith('/v0/management/auth-files') && method === 'GET') { + return Promise.resolve(jsonResponse({ files: options.files })); + } + + if (url.includes('/v0/management/auth-files/download') && method === 'GET') { + const name = new URL(url).searchParams.get('name'); + if (!name) { + return Promise.reject(new Error(`Missing auth file name for ${url}`)); + } + + const response = options.downloads?.[name]; + if (response instanceof Error) { + return Promise.reject(response); + } + if (!response) { + return Promise.resolve(textResponse('{}', 404)); + } + + return Promise.resolve(textResponse(response.body, response.status ?? 200)); + } + + if (url.endsWith('/v0/management/auth-files/fields') && method === 'PATCH') { + const payload = JSON.parse(body ?? '{}') as { name?: string }; + const status = (payload.name && options.patchStatuses?.[payload.name]) ?? 200; + return Promise.resolve(jsonResponse({ ok: status < 400 }, status)); + } + + return Promise.reject(new Error(`Unexpected fetch ${method} ${url}`)); + }) as typeof fetch; + + return requests; +} + +afterEach(() => { + global.fetch = originalFetch; +}); + +describe('ensureManagedModelPrefixes', () => { + it('patches missing managed prefixes for matching oauth providers', async () => { + const requests = installFetchMock({ + files: [ + { account_type: 'oauth', name: 'gemini-main', provider: 'gemini' }, + { account_type: 'oauth', name: 'agy-main', provider: 'antigravity' }, + { account_type: 'apikey', name: 'gemini-key', provider: 'gemini' }, + ], + downloads: { + 'gemini-main': { body: JSON.stringify({ prefix: null, provider: 'gemini' }) }, + 'agy-main': { body: JSON.stringify({ prefix: null, provider: 'antigravity' }) }, + }, + }); + + const result = await ensureManagedModelPrefixes(['gemini']); + + expect(result).toEqual({ checked: 1, updated: 1 }); + + const patchRequest = requests.find( + (request) => + request.url.endsWith('/v0/management/auth-files/fields') && request.method === 'PATCH' + ); + expect(patchRequest?.body).toBe(JSON.stringify({ name: 'gemini-main', prefix: 'gcli' })); + + const downloadedNames = requests + .filter((request) => request.url.includes('/v0/management/auth-files/download')) + .map((request) => new URL(request.url).searchParams.get('name')); + expect(downloadedNames).toEqual(['gemini-main']); + }); + + it('skips files that already have the managed prefix or a different custom prefix', async () => { + const requests = installFetchMock({ + files: [ + { account_type: 'oauth', name: 'gemini-managed', provider: 'gemini' }, + { account_type: 'oauth', name: 'gemini-custom', provider: 'gemini' }, + ], + downloads: { + 'gemini-managed': { body: JSON.stringify({ prefix: 'gcli', provider: 'gemini' }) }, + 'gemini-custom': { body: JSON.stringify({ prefix: 'team-a', provider: 'gemini' }) }, + }, + }); + + const result = await ensureManagedModelPrefixes(['gemini']); + + expect(result).toEqual({ checked: 2, updated: 0 }); + expect( + requests.some( + (request) => + request.url.endsWith('/v0/management/auth-files/fields') && request.method === 'PATCH' + ) + ).toBe(false); + }); + + it('skips patching when the downloaded auth file belongs to a different provider', async () => { + const requests = installFetchMock({ + files: [{ account_type: 'oauth', name: 'gemini-shadowed', provider: 'gemini' }], + downloads: { + 'gemini-shadowed': { + body: JSON.stringify({ prefix: null, provider: 'antigravity' }), + }, + }, + }); + + const result = await ensureManagedModelPrefixes(['gemini']); + + expect(result).toEqual({ checked: 1, updated: 0 }); + expect( + requests.some( + (request) => + request.url.endsWith('/v0/management/auth-files/fields') && request.method === 'PATCH' + ) + ).toBe(false); + }); + + it('swallows read and patch failures so later files can still be repaired', async () => { + const requests = installFetchMock({ + files: [ + { account_type: 'oauth', name: 'gemini-unreadable', provider: 'gemini' }, + { account_type: 'oauth', name: 'gemini-patch-fails', provider: 'gemini' }, + { account_type: 'oauth', name: 'gemini-success', provider: 'gemini' }, + ], + downloads: { + 'gemini-unreadable': new Error('network down'), + 'gemini-patch-fails': { body: JSON.stringify({ prefix: null, provider: 'gemini' }) }, + 'gemini-success': { body: JSON.stringify({ prefix: null, provider: 'gemini' }) }, + }, + patchStatuses: { + 'gemini-patch-fails': 500, + }, + }); + + const result = await ensureManagedModelPrefixes(['gemini']); + + expect(result).toEqual({ checked: 3, updated: 1 }); + + const patchPayloads = requests + .filter( + (request) => + request.url.endsWith('/v0/management/auth-files/fields') && request.method === 'PATCH' + ) + .map((request) => request.body); + expect(patchPayloads).toEqual([ + JSON.stringify({ name: 'gemini-patch-fails', prefix: 'gcli' }), + JSON.stringify({ name: 'gemini-success', prefix: 'gcli' }), + ]); + }); +}); From 40c4816acf36b3b79f13bf32862f7c88ed7debf4 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 9 Apr 2026 19:01:48 -0400 Subject: [PATCH 14/54] fix(cliproxy): harden pinned routing follow-up --- src/cliproxy/catalog-routing.ts | 2 +- src/cliproxy/managed-model-prefixes.ts | 48 +++++++++++++------ src/commands/cliproxy/variant-subcommand.ts | 2 +- src/shared/cliproxy-model-routing.ts | 8 ++-- src/web-server/routes/cliproxy-auth-routes.ts | 11 ++++- .../cliproxy/managed-model-prefixes.test.ts | 10 ++++ .../unit/cliproxy/model-routing-hints.test.ts | 20 ++++++++ .../provider-editor/custom-preset-dialog.tsx | 2 +- .../provider-editor/model-config-section.tsx | 4 +- .../cliproxy/provider-model-selector.tsx | 20 ++++++-- ui/src/hooks/use-cliproxy-auth-flow.ts | 8 ++++ ui/src/hooks/use-cliproxy.ts | 38 ++++++++------- ui/src/pages/cliproxy.tsx | 1 + 13 files changed, 124 insertions(+), 50 deletions(-) diff --git a/src/cliproxy/catalog-routing.ts b/src/cliproxy/catalog-routing.ts index 7b0c5ea3..93973777 100644 --- a/src/cliproxy/catalog-routing.ts +++ b/src/cliproxy/catalog-routing.ts @@ -20,7 +20,7 @@ export interface CatalogRoutingSnapshot { export async function getCatalogRoutingSnapshot(): Promise { const snapshot: ResolvedCatalogSnapshot = await getResolvedCatalogSnapshot(); - const modelsResponse = await fetchCliproxyModels(); + const modelsResponse = snapshot.source === 'live' ? await fetchCliproxyModels() : null; const routing = buildCliproxyRoutingHints(snapshot.catalogs, modelsResponse?.models ?? []); return { diff --git a/src/cliproxy/managed-model-prefixes.ts b/src/cliproxy/managed-model-prefixes.ts index 2ab33041..54a97e6d 100644 --- a/src/cliproxy/managed-model-prefixes.ts +++ b/src/cliproxy/managed-model-prefixes.ts @@ -3,6 +3,8 @@ import { buildManagementHeaders, buildProxyUrl, getProxyTarget } from './proxy-t import { mapExternalProviderName } from './provider-capabilities'; import type { CLIProxyProvider } from './types'; +const MANAGED_PREFIX_REQUEST_TIMEOUT_MS = 3000; + interface ManagementAuthFileRecord { account_type?: string; name: string; @@ -25,11 +27,24 @@ function normalizeProvider(record: ManagementAuthFileRecord): CLIProxyProvider | return providerName ? mapExternalProviderName(providerName) : null; } -async function listAuthFiles(): Promise { +async function fetchManagementEndpoint(path: string, init: RequestInit = {}): Promise { const target = getProxyTarget(); - const response = await fetch(buildProxyUrl(target, '/v0/management/auth-files'), { - headers: buildManagementHeaders(target), - }); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), MANAGED_PREFIX_REQUEST_TIMEOUT_MS); + + try { + return await fetch(buildProxyUrl(target, path), { + ...init, + headers: buildManagementHeaders(target, init.headers as Record | undefined), + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } +} + +async function listAuthFiles(): Promise { + const response = await fetchManagementEndpoint('/v0/management/auth-files'); if (!response.ok) { throw new Error(`auth file listing failed with status ${response.status}`); @@ -40,12 +55,11 @@ async function listAuthFiles(): Promise { } async function patchAuthFilePrefix(name: string, prefix: string): Promise { - const target = getProxyTarget(); - const response = await fetch(buildProxyUrl(target, '/v0/management/auth-files/fields'), { + const response = await fetchManagementEndpoint('/v0/management/auth-files/fields', { method: 'PATCH', - headers: buildManagementHeaders(target, { + headers: { 'Content-Type': 'application/json', - }), + }, body: JSON.stringify({ name, prefix }), }); @@ -55,14 +69,9 @@ async function patchAuthFilePrefix(name: string, prefix: string): Promise } async function readAuthFileMetadata(name: string): Promise { - const target = getProxyTarget(); - const url = buildProxyUrl( - target, + const response = await fetchManagementEndpoint( `/v0/management/auth-files/download?name=${encodeURIComponent(name)}` ); - const response = await fetch(url, { - headers: buildManagementHeaders(target), - }); if (!response.ok) { throw new Error(`auth file download failed for ${name} with status ${response.status}`); @@ -89,8 +98,17 @@ async function readAuthFileMetadata(name: string): Promise { export async function ensureManagedModelPrefixes( providers?: CLIProxyProvider[] ): Promise { + const allowedProviders = new Set( + (providers ?? []) + .map((provider) => provider.trim()) + .filter((provider) => getManagedModelPrefix(provider)) + ); + + if (providers && allowedProviders.size === 0) { + return { checked: 0, updated: 0 }; + } + const files = await listAuthFiles(); - const allowedProviders = new Set((providers ?? []).map((provider) => provider.trim())); let checked = 0; let updated = 0; diff --git a/src/commands/cliproxy/variant-subcommand.ts b/src/commands/cliproxy/variant-subcommand.ts index fd43ac93..b83c3453 100644 --- a/src/commands/cliproxy/variant-subcommand.ts +++ b/src/commands/cliproxy/variant-subcommand.ts @@ -125,7 +125,7 @@ function getSelectableModelId( const hint = routing?.models.find( (entry) => entry.modelId.toLowerCase() === modelId.toLowerCase() ); - return hint?.pinnedAvailable ? hint.recommendedModelId : modelId; + return hint?.recommendedModelId ?? modelId; } function getBackendLabel(backend: CLIProxyBackend): string { diff --git a/src/shared/cliproxy-model-routing.ts b/src/shared/cliproxy-model-routing.ts index 86c4e1ca..b6194ac4 100644 --- a/src/shared/cliproxy-model-routing.ts +++ b/src/shared/cliproxy-model-routing.ts @@ -162,9 +162,7 @@ export function buildCliproxyRoutingHints( .map((candidate) => candidate.id) .sort((left, right) => left.localeCompare(right)); const managedPinnedId = `${prefix}/${model.id}`; - const recommendedModelId = pinnedCandidates.includes(managedPinnedId) - ? managedPinnedId - : (pinnedCandidates[0] ?? managedPinnedId); + const pinnedAvailable = pinnedCandidates.includes(managedPinnedId); const mergedModel = mergedModelMap.get(normalize(model.id)); const effectiveProvider = mergedModel ? inferProvider(mergedModel) : null; const effectiveDisplayName = @@ -188,8 +186,8 @@ export function buildCliproxyRoutingHints( modelName: model.name?.trim() || model.id, prefix, pinnedModelId: managedPinnedId, - recommendedModelId, - pinnedAvailable: pinnedCandidates.length > 0, + recommendedModelId: managedPinnedId, + pinnedAvailable, unprefixedStatus, effectiveProvider, effectiveDisplayName, diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 686278df..0b2bdf01 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -1017,7 +1017,6 @@ router.get('/:provider/status', async (req: Request, res: Response): Promise { expect(downloadedNames).toEqual(['gemini-main']); }); + it('returns immediately when called for providers without managed prefixes', async () => { + const fetchMock = mock(() => Promise.reject(new Error('should not fetch'))); + global.fetch = fetchMock as typeof fetch; + + const result = await ensureManagedModelPrefixes(['codex']); + + expect(result).toEqual({ checked: 0, updated: 0 }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it('skips files that already have the managed prefix or a different custom prefix', async () => { const requests = installFetchMock({ files: [ diff --git a/tests/unit/cliproxy/model-routing-hints.test.ts b/tests/unit/cliproxy/model-routing-hints.test.ts index 07734dcb..2d1a21f3 100644 --- a/tests/unit/cliproxy/model-routing-hints.test.ts +++ b/tests/unit/cliproxy/model-routing-hints.test.ts @@ -64,4 +64,24 @@ describe('cliproxy model routing hints', () => { effectiveProvider: null, }); }); + + it('does not promote custom auth-file prefixes as managed pinned model ids', () => { + const routing = buildCliproxyRoutingHints( + { + gemini: { + provider: 'gemini', + displayName: 'Gemini', + models: [{ id: 'gemini-3-flash-preview', name: 'Gemini Flash' }], + }, + }, + [{ id: 'team-a/gemini-3-flash-preview', owned_by: 'google', type: 'gemini-cli' }] + ); + + expect(routing.gemini?.models[0]).toMatchObject({ + pinnedModelId: 'gcli/gemini-3-flash-preview', + recommendedModelId: 'gcli/gemini-3-flash-preview', + pinnedAvailable: false, + unprefixedStatus: 'prefix-only', + }); + }); }); diff --git a/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx b/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx index 2116a44d..ec21f4c4 100644 --- a/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx +++ b/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx @@ -28,7 +28,7 @@ function normalizePresetValues( const hint = routing?.models.find( (entry) => entry.modelId.toLowerCase() === modelId.toLowerCase() ); - return hint?.pinnedAvailable ? hint.recommendedModelId : modelId; + return hint?.recommendedModelId ?? modelId; }; return { diff --git a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx index eb62216e..9549ab18 100644 --- a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx +++ b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx @@ -60,9 +60,7 @@ export function ModelConfigSection({ [routing] ); const toPreferredModelId = (modelId: string): string => - routingHintMap.get(modelId.toLowerCase())?.pinnedAvailable - ? (routingHintMap.get(modelId.toLowerCase())?.recommendedModelId ?? modelId) - : modelId; + routingHintMap.get(modelId.toLowerCase())?.recommendedModelId ?? modelId; const extendedContextModels = useMemo(() => { if (!catalog) return []; diff --git a/ui/src/components/cliproxy/provider-model-selector.tsx b/ui/src/components/cliproxy/provider-model-selector.tsx index 0fa58b82..be7502bc 100644 --- a/ui/src/components/cliproxy/provider-model-selector.tsx +++ b/ui/src/components/cliproxy/provider-model-selector.tsx @@ -310,10 +310,7 @@ function getPreferredOptionValue( modelId: string, routingHint: CliproxyProviderRoutingHints['models'][number] | undefined ): string { - if (!routingHint?.pinnedAvailable) { - return modelId; - } - return routingHint.recommendedModelId; + return routingHint?.recommendedModelId ?? modelId; } export function FlexibleModelSelector({ @@ -338,6 +335,15 @@ export function FlexibleModelSelector({ new Map((routing?.models ?? []).map((hint) => [hint.modelId.toLowerCase(), hint] as const)), [routing] ); + const recommendedOptionValues = useMemo( + () => + new Set( + resolvedCatalogModels.map((model) => + getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())) + ) + ), + [resolvedCatalogModels, routingHints] + ); const selectedRoutingHint = useMemo( () => routingHints.get(normalizeModelValue(value, routing).toLowerCase()), [routing, routingHints, value] @@ -384,6 +390,12 @@ export function FlexibleModelSelector({ const allModelOptions = allModels .filter((model) => !catalogModelIds.has(model.id)) + .filter( + (model) => + !recommendedOptionValues.has( + getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())) + ) + ) .map((model) => ({ value: getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())), groupKey: 'all', diff --git a/ui/src/hooks/use-cliproxy-auth-flow.ts b/ui/src/hooks/use-cliproxy-auth-flow.ts index 1ac8199f..a49d0c25 100644 --- a/ui/src/hooks/use-cliproxy-auth-flow.ts +++ b/ui/src/hooks/use-cliproxy-auth-flow.ts @@ -47,6 +47,11 @@ const MAX_POLL_DURATION = 5 * 60 * 1000; /** Fail visibly after repeated poll transport errors instead of retrying forever */ const MAX_POLL_FAILURES = 3; +function invalidateCliproxyRoutingData(queryClient: ReturnType): void { + queryClient.invalidateQueries({ queryKey: ['cliproxy-catalog'] }); + queryClient.invalidateQueries({ queryKey: ['cliproxy-models'] }); +} + async function parseResponseBody(response: Response): Promise> { const text = await response.text(); if (!text) return {}; @@ -167,6 +172,7 @@ export function useCliproxyAuthFlow() { queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); queryClient.invalidateQueries({ queryKey: ['account-quota'] }); + invalidateCliproxyRoutingData(queryClient); toast.success(`${provider} authentication successful`); openedAuthUrlRef.current = false; setState(INITIAL_STATE); @@ -301,6 +307,7 @@ export function useCliproxyAuthFlow() { queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); queryClient.invalidateQueries({ queryKey: ['account-quota'] }); + invalidateCliproxyRoutingData(queryClient); // Note: No toast here - DeviceCodeDialog's useDeviceCode hook handles success toast // via deviceCodeCompleted WebSocket event to avoid duplicate toasts openedAuthUrlRef.current = false; @@ -467,6 +474,7 @@ export function useCliproxyAuthFlow() { queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); queryClient.invalidateQueries({ queryKey: ['account-quota'] }); + invalidateCliproxyRoutingData(queryClient); toast.success(`${currentProvider} authentication successful`); setState(INITIAL_STATE); } else { diff --git a/ui/src/hooks/use-cliproxy.ts b/ui/src/hooks/use-cliproxy.ts index d88e2b82..46d48a4e 100644 --- a/ui/src/hooks/use-cliproxy.ts +++ b/ui/src/hooks/use-cliproxy.ts @@ -14,6 +14,17 @@ import { } from '@/lib/api-client'; import { toast } from 'sonner'; +function invalidateCliproxyRoutingQueries(queryClient: ReturnType): void { + queryClient.invalidateQueries({ queryKey: ['cliproxy-catalog'] }); + queryClient.invalidateQueries({ queryKey: ['cliproxy-models'] }); +} + +function invalidateCliproxyAccountQueries(queryClient: ReturnType): void { + queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); + queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + invalidateCliproxyRoutingQueries(queryClient); +} + export function useCliproxy() { return useQuery({ queryKey: ['cliproxy'], @@ -128,8 +139,7 @@ export function useSetDefaultAccount() { mutationFn: ({ provider, accountId }: { provider: string; accountId: string }) => api.cliproxy.accounts.setDefault(provider, accountId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); - queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + invalidateCliproxyAccountQueries(queryClient); toast.success('Default account updated'); }, onError: (error: Error) => { @@ -145,8 +155,7 @@ export function useRemoveAccount() { mutationFn: ({ provider, accountId }: { provider: string; accountId: string }) => api.cliproxy.accounts.remove(provider, accountId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); - queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + invalidateCliproxyAccountQueries(queryClient); toast.success('Account removed'); }, onError: (error: Error) => { @@ -162,8 +171,7 @@ export function usePauseAccount() { mutationFn: ({ provider, accountId }: { provider: string; accountId: string }) => api.cliproxy.accounts.pause(provider, accountId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); - queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + invalidateCliproxyAccountQueries(queryClient); queryClient.invalidateQueries({ queryKey: ['cliproxy-stats'] }); toast.success('Account paused'); }, @@ -180,8 +188,7 @@ export function useResumeAccount() { mutationFn: ({ provider, accountId }: { provider: string; accountId: string }) => api.cliproxy.accounts.resume(provider, accountId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); - queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + invalidateCliproxyAccountQueries(queryClient); queryClient.invalidateQueries({ queryKey: ['cliproxy-stats'] }); toast.success('Account resumed'); }, @@ -198,8 +205,7 @@ export function useSoloAccount() { mutationFn: ({ provider, accountId }: { provider: string; accountId: string }) => api.cliproxy.accounts.solo(provider, accountId), onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); - queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + invalidateCliproxyAccountQueries(queryClient); queryClient.invalidateQueries({ queryKey: ['cliproxy-stats'] }); const pausedCount = data.paused.length; toast.success( @@ -219,8 +225,7 @@ export function useBulkPauseAccounts() { mutationFn: ({ provider, accountIds }: { provider: string; accountIds: string[] }) => api.cliproxy.accounts.bulkPause(provider, accountIds), onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); - queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + invalidateCliproxyAccountQueries(queryClient); queryClient.invalidateQueries({ queryKey: ['cliproxy-stats'] }); toast.success( `Paused ${data.succeeded.length} account${data.succeeded.length !== 1 ? 's' : ''}` @@ -244,8 +249,7 @@ export function useBulkResumeAccounts() { mutationFn: ({ provider, accountIds }: { provider: string; accountIds: string[] }) => api.cliproxy.accounts.bulkResume(provider, accountIds), onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); - queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + invalidateCliproxyAccountQueries(queryClient); queryClient.invalidateQueries({ queryKey: ['cliproxy-stats'] }); toast.success( `Resumed ${data.succeeded.length} account${data.succeeded.length !== 1 ? 's' : ''}` @@ -270,8 +274,7 @@ export function useStartAuth() { mutationFn: ({ provider, nickname }: { provider: string; nickname?: string }) => api.cliproxy.auth.start(provider, nickname), onSuccess: (_data, variables) => { - queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); - queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + invalidateCliproxyAccountQueries(queryClient); toast.success(`Account added for ${variables.provider}`); }, onError: (error: Error) => { @@ -297,8 +300,7 @@ export function useKiroImport() { return useMutation({ mutationFn: () => api.cliproxy.auth.kiroImport(), onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey: ['cliproxy-accounts'] }); - queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + invalidateCliproxyAccountQueries(queryClient); if (data.account) { toast.success(`Imported Kiro account: ${data.account.email || data.account.id}`); } else { diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index 7c5b1e8f..6aba8344 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -306,6 +306,7 @@ export function CliproxyPage() { queryClient.invalidateQueries({ queryKey: ['cliproxy'] }); queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); queryClient.invalidateQueries({ queryKey: ['cliproxy-catalog'] }); + queryClient.invalidateQueries({ queryKey: ['cliproxy-models'] }); }; const handlePauseToggle = (provider: string, accountId: string, paused: boolean) => { From eaddfc467fa4380d5c642ad7063442b6edc22efc Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 9 Apr 2026 19:09:32 -0400 Subject: [PATCH 15/54] fix(cliproxy): dedupe variant prefix sync --- src/commands/cliproxy/variant-subcommand.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/commands/cliproxy/variant-subcommand.ts b/src/commands/cliproxy/variant-subcommand.ts index b83c3453..bacc9c73 100644 --- a/src/commands/cliproxy/variant-subcommand.ts +++ b/src/commands/cliproxy/variant-subcommand.ts @@ -45,6 +45,17 @@ interface CliproxyProfileArgs { errors: string[]; } +const variantManagedPrefixProviders = new Set(); + +async function ensureVariantManagedModelPrefixes(provider: CLIProxyProvider): Promise { + if (variantManagedPrefixProviders.has(provider)) { + return; + } + + await ensureManagedModelPrefixes([provider]); + variantManagedPrefixProviders.add(provider); +} + function parseTargetValue(rawValue: string): TargetType | null { const normalized = rawValue.trim().toLowerCase(); if (isPersistedTargetType(normalized)) { @@ -188,7 +199,7 @@ async function selectTierConfig( let model: string | undefined; if (supportsModelConfig(provider as CLIProxyProvider)) { try { - await ensureManagedModelPrefixes([provider as CLIProxyProvider]); + await ensureVariantManagedModelPrefixes(provider as CLIProxyProvider); } catch { // Keep interactive model selection available even when prefix repair fails. } @@ -449,7 +460,7 @@ export async function handleCreate( if (!model) { if (supportsModelConfig(provider as CLIProxyProvider)) { try { - await ensureManagedModelPrefixes([provider as CLIProxyProvider]); + await ensureVariantManagedModelPrefixes(provider as CLIProxyProvider); } catch { // Keep variant creation available even when prefix repair fails. } @@ -699,7 +710,7 @@ export async function handleEdit( const providerForModel = newProvider || (variant.provider as CLIProxyProfileName); if (supportsModelConfig(providerForModel as CLIProxyProvider)) { try { - await ensureManagedModelPrefixes([providerForModel as CLIProxyProvider]); + await ensureVariantManagedModelPrefixes(providerForModel as CLIProxyProvider); } catch { // Keep edit flow available even when prefix repair fails. } From d87a126938d9d71b3bb0de163e4265111fa09944 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 01:15:10 -0400 Subject: [PATCH 16/54] test(cliproxy): cover managed pinned route hints --- .../unit/cliproxy/model-routing-hints.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/unit/cliproxy/model-routing-hints.test.ts b/tests/unit/cliproxy/model-routing-hints.test.ts index 2d1a21f3..a9033769 100644 --- a/tests/unit/cliproxy/model-routing-hints.test.ts +++ b/tests/unit/cliproxy/model-routing-hints.test.ts @@ -84,4 +84,28 @@ describe('cliproxy model routing hints', () => { unprefixedStatus: 'prefix-only', }); }); + + it('marks the managed pinned route as available when the live model list advertises it', () => { + const routing = buildCliproxyRoutingHints( + { + gemini: { + provider: 'gemini', + displayName: 'Gemini', + models: [{ id: 'gemini-3-flash-preview', name: 'Gemini Flash' }], + }, + }, + [{ id: 'gcli/gemini-3-flash-preview', owned_by: 'google', type: 'gemini-cli' }] + ); + + expect(routing.gemini?.models[0]).toMatchObject({ + pinnedModelId: 'gcli/gemini-3-flash-preview', + recommendedModelId: 'gcli/gemini-3-flash-preview', + pinnedAvailable: true, + unprefixedStatus: 'prefix-only', + effectiveProvider: null, + }); + expect(routing.gemini?.models[0]?.summary).toContain( + 'Use gcli/gemini-3-flash-preview to target Gemini.' + ); + }); }); From 29ef0c8e04a1c3935f62cd547b7a38ef84872691 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 10 Apr 2026 07:00:35 +0000 Subject: [PATCH 17/54] chore(release): 7.67.0-dev.3 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0b48264f..fc435501 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.67.0-dev.2", + "version": "7.67.0-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 63b67f1c1c270f3e4e6c1a676e1c9256f2577d99 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 05:49:38 -0400 Subject: [PATCH 18/54] feat(profiles): add local runtime readiness checks --- src/api/services/index.ts | 6 + src/api/services/local-runtime-readiness.ts | 121 +++++++++++++++ src/web-server/routes/profile-routes.ts | 9 ++ ...ile-routes-local-runtime-readiness.test.ts | 141 ++++++++++++++++++ ui/src/hooks/use-local-runtime-readiness.ts | 11 ++ ui/src/lib/api-client.ts | 19 +++ 6 files changed, 307 insertions(+) create mode 100644 src/api/services/local-runtime-readiness.ts create mode 100644 tests/unit/web-server/profile-routes-local-runtime-readiness.test.ts create mode 100644 ui/src/hooks/use-local-runtime-readiness.ts diff --git a/src/api/services/index.ts b/src/api/services/index.ts index fb6ad360..8b213e17 100644 --- a/src/api/services/index.ts +++ b/src/api/services/index.ts @@ -64,6 +64,12 @@ export { // OpenRouter catalog and picker export { isOpenRouterUrl, fetchOpenRouterModels, type OpenRouterModel } from './openrouter-catalog'; export { pickOpenRouterModel, type OpenRouterSelection } from './openrouter-picker'; +export { + getLocalRuntimeReadiness, + type LocalRuntimeId, + type LocalRuntimeReadiness, + type LocalRuntimeStatus, +} from './local-runtime-readiness'; // Provider presets for CLI export { diff --git a/src/api/services/local-runtime-readiness.ts b/src/api/services/local-runtime-readiness.ts new file mode 100644 index 00000000..0472bfc1 --- /dev/null +++ b/src/api/services/local-runtime-readiness.ts @@ -0,0 +1,121 @@ +export type LocalRuntimeId = 'ollama' | 'llamacpp'; +export type LocalRuntimeStatus = 'ready' | 'missing-model' | 'offline'; + +export interface LocalRuntimeReadiness { + id: LocalRuntimeId; + name: string; + endpoint: string; + status: LocalRuntimeStatus; + commandHint: string; + recommendedModel: string | null; + recommendedModelInstalled: boolean; + detectedModelCount: number; +} + +interface LocalRuntimeDefinition { + id: LocalRuntimeId; + name: string; + endpoint: string; + modelsUrl: string; + commandHint: string; + recommendedModel: string | null; + parseModelIds: (payload: unknown) => string[]; +} + +const LOCAL_RUNTIME_DEFINITIONS: LocalRuntimeDefinition[] = [ + { + id: 'ollama', + name: 'Ollama', + endpoint: 'http://127.0.0.1:11434', + modelsUrl: 'http://127.0.0.1:11434/api/tags', + commandHint: 'ollama serve', + recommendedModel: 'gemma4:e4b', + parseModelIds: (payload) => { + const models = (payload as { models?: Array<{ name?: string }> })?.models; + return Array.isArray(models) + ? models + .map((model) => (typeof model?.name === 'string' ? model.name.trim() : '')) + .filter(Boolean) + : []; + }, + }, + { + id: 'llamacpp', + name: 'llama.cpp', + endpoint: 'http://127.0.0.1:8080', + modelsUrl: 'http://127.0.0.1:8080/v1/models', + commandHint: './server --host 0.0.0.0 --port 8080 -m model.gguf', + recommendedModel: null, + parseModelIds: (payload) => { + const models = (payload as { data?: Array<{ id?: string }> })?.data; + return Array.isArray(models) + ? models + .map((model) => (typeof model?.id === 'string' ? model.id.trim() : '')) + .filter(Boolean) + : []; + }, + }, +]; + +async function fetchModelIds(definition: LocalRuntimeDefinition): Promise { + const response = await fetch(definition.modelsUrl, { + signal: AbortSignal.timeout(1500), + }); + + if (!response.ok) { + throw new Error(`${definition.id} readiness probe failed (${response.status})`); + } + + return definition.parseModelIds(await response.json()); +} + +function toReadiness( + definition: LocalRuntimeDefinition, + modelIds: string[] +): LocalRuntimeReadiness { + const recommendedModelInstalled = definition.recommendedModel + ? modelIds.some((modelId) => modelId === definition.recommendedModel) + : modelIds.length > 0; + + const status: LocalRuntimeStatus = + modelIds.length === 0 || !recommendedModelInstalled ? 'missing-model' : 'ready'; + + return { + id: definition.id, + name: definition.name, + endpoint: definition.endpoint, + status, + commandHint: + status === 'missing-model' && definition.recommendedModel + ? `ollama pull ${definition.recommendedModel}` + : definition.commandHint, + recommendedModel: definition.recommendedModel, + recommendedModelInstalled, + detectedModelCount: modelIds.length, + }; +} + +function toOffline(definition: LocalRuntimeDefinition): LocalRuntimeReadiness { + return { + id: definition.id, + name: definition.name, + endpoint: definition.endpoint, + status: 'offline', + commandHint: definition.commandHint, + recommendedModel: definition.recommendedModel, + recommendedModelInstalled: false, + detectedModelCount: 0, + }; +} + +export async function getLocalRuntimeReadiness(): Promise { + return Promise.all( + LOCAL_RUNTIME_DEFINITIONS.map(async (definition) => { + try { + return toReadiness(definition, await fetchModelIds(definition)); + } catch { + return toOffline(definition); + } + }) + ); +} diff --git a/src/web-server/routes/profile-routes.ts b/src/web-server/routes/profile-routes.ts index d640d04e..84752b1d 100644 --- a/src/web-server/routes/profile-routes.ts +++ b/src/web-server/routes/profile-routes.ts @@ -10,6 +10,7 @@ import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-na import { createApiProfile, createCliproxyBridgeProfile, + getLocalRuntimeReadiness, removeApiProfile, updateApiProfileTarget, discoverApiProfileOrphans, @@ -86,6 +87,14 @@ router.get('/cliproxy-bridge/providers', (_req: Request, res: Response): void => } }); +router.get('/local-runtime-readiness', async (_req: Request, res: Response): Promise => { + try { + res.json({ runtimes: await getLocalRuntimeReadiness() }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + router.post('/cliproxy-bridge', (req: Request, res: Response): void => { const shape = validatePayloadShape(req.body, ['provider', 'name', 'target']); if (!shape.ok) { diff --git a/tests/unit/web-server/profile-routes-local-runtime-readiness.test.ts b/tests/unit/web-server/profile-routes-local-runtime-readiness.test.ts new file mode 100644 index 00000000..46c89b25 --- /dev/null +++ b/tests/unit/web-server/profile-routes-local-runtime-readiness.test.ts @@ -0,0 +1,141 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; +import express from 'express'; +import type { Server } from 'http'; +import profileRoutes from '../../../src/web-server/routes/profile-routes'; + +describe('profile-routes local runtime readiness', () => { + let server: Server; + let baseUrl = ''; + const originalFetch = globalThis.fetch; + + beforeAll(async () => { + const app = express(); + app.use(express.json()); + app.use('/api/profiles', profileRoutes); + + await new Promise((resolve, reject) => { + server = app.listen(0, '127.0.0.1'); + const onError = (error: Error) => reject(error); + server.once('error', onError); + server.once('listening', () => { + server.off('error', onError); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + beforeEach(() => { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + + if (url.startsWith(baseUrl)) { + return originalFetch(input); + } + + if (url.includes('11434/api/tags')) { + return new Response( + JSON.stringify({ + models: [{ name: 'gemma4:e4b' }, { name: 'qwen3-coder:latest' }], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + if (url.includes('8080/v1/models')) { + return new Response( + JSON.stringify({ + data: [{ id: 'Qwen3-Coder-30B-A3B-Instruct-Q4_K_M.gguf' }], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + throw new Error(`Unexpected URL: ${url}`); + }) as typeof fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('reports local runtimes as ready when their endpoints respond with models', async () => { + const response = await fetch(`${baseUrl}/api/profiles/local-runtime-readiness`); + + expect(response.status).toBe(200); + const body = (await response.json()) as { + runtimes: Array<{ + id: string; + status: string; + recommendedModelInstalled: boolean; + }>; + }; + + expect(body.runtimes).toHaveLength(2); + expect(body.runtimes).toContainEqual( + expect.objectContaining({ + id: 'ollama', + status: 'ready', + recommendedModelInstalled: true, + }) + ); + expect(body.runtimes).toContainEqual( + expect.objectContaining({ + id: 'llamacpp', + status: 'ready', + }) + ); + }); + + it('reports setup guidance when local endpoints are unavailable', async () => { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith(baseUrl)) { + return originalFetch(input); + } + throw new Error('connect ECONNREFUSED'); + }) as typeof fetch; + + const response = await fetch(`${baseUrl}/api/profiles/local-runtime-readiness`); + + expect(response.status).toBe(200); + const body = (await response.json()) as { + runtimes: Array<{ + id: string; + status: string; + commandHint: string; + }>; + }; + + expect(body.runtimes).toContainEqual( + expect.objectContaining({ + id: 'ollama', + status: 'offline', + commandHint: 'ollama serve', + }) + ); + expect(body.runtimes).toContainEqual( + expect.objectContaining({ + id: 'llamacpp', + status: 'offline', + commandHint: './server --host 0.0.0.0 --port 8080 -m model.gguf', + }) + ); + }); +}); diff --git a/ui/src/hooks/use-local-runtime-readiness.ts b/ui/src/hooks/use-local-runtime-readiness.ts new file mode 100644 index 00000000..c207ae58 --- /dev/null +++ b/ui/src/hooks/use-local-runtime-readiness.ts @@ -0,0 +1,11 @@ +import { useQuery } from '@tanstack/react-query'; +import { api } from '@/lib/api-client'; + +export function useLocalRuntimeReadiness() { + return useQuery({ + queryKey: ['profiles', 'local-runtime-readiness'], + queryFn: () => api.profiles.getLocalRuntimeReadiness(), + staleTime: 15_000, + refetchOnWindowFocus: false, + }); +} diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index d8493a99..74a848ee 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -239,6 +239,23 @@ export interface Profile { cliproxyBridge?: CliproxyBridgeMetadata | null; } +export type LocalRuntimeStatus = 'ready' | 'missing-model' | 'offline'; + +export interface LocalRuntimeReadiness { + id: 'ollama' | 'llamacpp'; + name: string; + endpoint: string; + status: LocalRuntimeStatus; + commandHint: string; + recommendedModel: string | null; + recommendedModelInstalled: boolean; + detectedModelCount: number; +} + +export interface LocalRuntimeReadinessResponse { + runtimes: LocalRuntimeReadiness[]; +} + export interface CreateProfile { name: string; baseUrl: string; @@ -1024,6 +1041,8 @@ export interface CliproxyRestartResult { export const api = { profiles: { list: () => request<{ profiles: Profile[] }>('/profiles'), + getLocalRuntimeReadiness: () => + request('/profiles/local-runtime-readiness'), create: (data: CreateProfile) => request('/profiles', { method: 'POST', From ad70f93b1b95edb8d6f029a7613a44f3d2db83ab Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 05:50:26 -0400 Subject: [PATCH 19/54] feat(ui): reposition providers around quality and local lanes --- .../profiles/openrouter-quick-start.tsx | 339 +++++++++++++----- .../profiles/profile-create-dialog.tsx | 28 +- ui/src/lib/i18n.ts | 4 +- ui/src/pages/api.tsx | 25 +- .../profiles/openrouter-quick-start.test.tsx | 50 ++- .../profiles/profile-create-dialog.test.tsx | 27 +- 6 files changed, 352 insertions(+), 121 deletions(-) diff --git a/ui/src/components/profiles/openrouter-quick-start.tsx b/ui/src/components/profiles/openrouter-quick-start.tsx index 9fc4e746..4e273f6e 100644 --- a/ui/src/components/profiles/openrouter-quick-start.tsx +++ b/ui/src/components/profiles/openrouter-quick-start.tsx @@ -3,12 +3,16 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { useOpenRouterReady } from '@/hooks/use-openrouter-models'; +import { useLocalRuntimeReadiness } from '@/hooks/use-local-runtime-readiness'; import { cn } from '@/lib/utils'; import { ArrowRight, + BrainCircuit, CloudCog, ExternalLink, + HardDriveDownload, KeyRound, + Laptop, SlidersHorizontal, Sparkles, Zap, @@ -22,6 +26,8 @@ interface OpenRouterQuickStartProps { onAlibabaCodingPlanClick: () => void; onCliproxyClick: () => void; onCustomClick: () => void; + onOllamaClick: () => void; + onLlamacppClick: () => void; } interface QuickStartCardProps { @@ -38,6 +44,24 @@ interface QuickStartCardProps { footer?: ReactNode; } +type LocalRuntimeStatus = 'ready' | 'missing-model' | 'offline'; + +type LocalRuntimeView = { + id: 'ollama' | 'llamacpp'; + endpoint: string; + status: LocalRuntimeStatus; + commandHint: string; + detectedModelCount: number; +}; + +type LocalRuntimeCardState = { + badge: string; + badgeClassName: string; + actionLabel: string; + description: string; + footer: string; +}; + function QuickStartCard({ badge, badgeClassName, @@ -84,6 +108,52 @@ function QuickStartCard({ ); } +function getLocalRuntimeCardState( + runtime: LocalRuntimeView | undefined, + label: string +): LocalRuntimeCardState { + if (!runtime) { + return { + badge: 'Checking', + badgeClassName: 'bg-muted text-muted-foreground', + actionLabel: `Set up ${label}`, + description: 'Checking the local runtime status before showing setup guidance.', + footer: 'Checking the local runtime...', + }; + } + + if (runtime.status === 'ready') { + return { + badge: 'Ready', + badgeClassName: + 'bg-emerald-500/10 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200', + actionLabel: `Use ${label}`, + description: 'Best for private prompts, offline workflows, and cheaper batch transforms.', + footer: `Endpoint ready at ${runtime.endpoint}`, + }; + } + + if (runtime.status === 'missing-model') { + return { + badge: 'Needs model', + badgeClassName: 'bg-amber-500/10 text-amber-700 dark:bg-amber-500/20 dark:text-amber-200', + actionLabel: `Finish ${label} setup`, + description: + 'The runtime is up, but the recommended local model still needs to be pulled or loaded.', + footer: `Run \`${runtime.commandHint}\` to finish local setup.`, + }; + } + + return { + badge: 'Offline', + badgeClassName: 'bg-muted text-muted-foreground', + actionLabel: `Set up ${label}`, + description: + 'Use this lane for privacy-sensitive work, cheap local transforms, and offline sessions.', + footer: `Run \`${runtime.commandHint}\` to bring the local endpoint online.`, + }; +} + export function OpenRouterQuickStart({ hasProfiles, profileCount, @@ -91,31 +161,37 @@ export function OpenRouterQuickStart({ onAlibabaCodingPlanClick, onCliproxyClick, onCustomClick, + onOllamaClick, + onLlamacppClick, }: OpenRouterQuickStartProps) { const { t } = useTranslation(); const { modelCount, isLoading } = useOpenRouterReady(); + const { data: localRuntimeData } = useLocalRuntimeReadiness(); const modelCountLabel = isLoading ? '300+' : `${modelCount}+`; const profileSummaryLabel = hasProfiles - ? t('openrouterQuickStart.profileCount', { count: profileCount }) - : t('openrouterQuickStart.recommended'); + ? `${profileCount} ${profileCount === 1 ? 'profile' : 'profiles'}` + : 'Premium quality + local control'; const summaryTitle = hasProfiles - ? t('openrouterQuickStart.selectProfileTitle') - : t('apiProfiles.noProfilesYet'); + ? 'Choose a profile or add another lane' + : 'Choose your first profile lane'; const summaryDescription = hasProfiles - ? t('openrouterQuickStart.summaryDescriptionWithProfiles', { count: profileCount }) - : t('openrouterQuickStart.summaryDescriptionNoProfiles'); + ? `You already have ${profileCount} profile${profileCount === 1 ? '' : 's'} in this workspace. Keep premium-quality providers for serious coding and add local runtimes when privacy, cost, or offline work matters.` + : 'Pick the lane that matches the work. Premium providers stay the default for reliable coding. Local runtimes are best for privacy, cheaper transforms, and experimentation.'; + const ollamaRuntime = localRuntimeData?.runtimes.find((runtime) => runtime.id === 'ollama'); + const llamacppRuntime = localRuntimeData?.runtimes.find((runtime) => runtime.id === 'llamacpp'); + const ollamaCardState = getLocalRuntimeCardState(ollamaRuntime, 'Ollama'); + const llamacppCardState = getLocalRuntimeCardState(llamacppRuntime, 'llama.cpp'); return (
-
+
{profileSummaryLabel} - - {t('openrouterQuickStart.openrouterModelsBadge', { modelCountLabel })} - + Best quality by default + Local lane when task fit wins

{summaryTitle}

@@ -130,50 +206,171 @@ export function OpenRouterQuickStart({ -
- - OpenRouter -
- } - highlights={[ - { - icon: , - label: t('openrouterQuickStart.featureOneApi'), - }, - { - icon: , - label: t('openrouterQuickStart.featureTierMapping'), - }, - ]} - actionLabel={t('openrouterQuickStart.createOpenRouterProfile')} - actionClassName="w-full bg-accent text-white hover:bg-accent/90" - onAction={onOpenRouterClick} - footer={ - <> - {t('openrouterQuickStart.getApiKeyAt')}{' '} - - openrouter.ai/keys - - - - } - /> +
+
+ +

+ Best quality lanes +

+
+
+ + OpenRouter +
+ } + highlights={[ + { + icon: , + label: t('openrouterQuickStart.featureOneApi'), + }, + { + icon: , + label: 'Best default lane for high-stakes coding quality', + }, + ]} + actionLabel={t('openrouterQuickStart.createOpenRouterProfile')} + actionClassName="w-full bg-accent text-white hover:bg-accent/90" + onAction={onOpenRouterClick} + footer={ + <> + {t('openrouterQuickStart.getApiKeyAt')}{' '} + + openrouter.ai/keys + + + + } + /> + + Alibaba Coding Plan +
+ } + highlights={[ + { + icon: , + label: t('alibabaCodingPlanQuickStart.featureEndpoint'), + }, + { + icon: , + label: 'Good when you want premium quality with a dedicated endpoint', + }, + ]} + actionLabel={t('alibabaCodingPlanQuickStart.createAlibabaProfile')} + actionClassName="w-full bg-orange-600 text-white hover:bg-orange-600/90" + onAction={onAlibabaCodingPlanClick} + footer={ + <> + {t('alibabaCodingPlanQuickStart.readGuideAt')}{' '} + + Alibaba Cloud Model Studio + + + + } + /> +
+
+ +
+
+ +

+ Local runtimes +

+
+
+ + Ollama +
+ } + highlights={[ + { + icon: , + label: 'Best for private prompts, local cleanup, and cheap batch transforms', + }, + { + icon: , + label: + ollamaRuntime?.detectedModelCount && ollamaRuntime.detectedModelCount > 0 + ? `${ollamaRuntime.detectedModelCount} local model${ollamaRuntime.detectedModelCount === 1 ? '' : 's'} detected` + : 'No local models detected yet', + }, + ]} + actionLabel={ollamaCardState.actionLabel} + actionClassName="w-full bg-emerald-600 text-white hover:bg-emerald-600/90" + onAction={onOllamaClick} + footer={{ollamaCardState.footer}} + /> + + + llama.cpp +
+ } + highlights={[ + { + icon: , + label: 'Best for custom GGUF setups and advanced self-hosted local workflows', + }, + { + icon: , + label: + llamacppRuntime?.detectedModelCount && llamacppRuntime.detectedModelCount > 0 + ? `${llamacppRuntime.detectedModelCount} local model${llamacppRuntime.detectedModelCount === 1 ? '' : 's'} detected` + : 'Waiting for a local server and model list', + }, + ]} + actionLabel={llamacppCardState.actionLabel} + actionClassName="w-full bg-sky-600 text-white hover:bg-sky-600/90" + onAction={onLlamacppClick} + footer={{llamacppCardState.footer}} + /> +
+
+ +
@@ -194,50 +391,6 @@ export function OpenRouterQuickStart({ onAction={onCliproxyClick} footer={{t('openrouterQuickStart.runtimeProviderFooter')}} /> - - - Alibaba Coding Plan -
- } - highlights={[ - { - icon: , - label: t('alibabaCodingPlanQuickStart.featureEndpoint'), - }, - { - icon: , - label: t('alibabaCodingPlanQuickStart.featureKeyFormat'), - }, - ]} - actionLabel={t('alibabaCodingPlanQuickStart.createAlibabaProfile')} - actionClassName="w-full bg-orange-600 text-white hover:bg-orange-600/90" - onAction={onAlibabaCodingPlanClick} - footer={ - <> - {t('alibabaCodingPlanQuickStart.readGuideAt')}{' '} - - Alibaba Cloud Model Studio - - - - } - />
diff --git a/ui/src/components/profiles/profile-create-dialog.tsx b/ui/src/components/profiles/profile-create-dialog.tsx index 8a67a7cf..eabf55f6 100644 --- a/ui/src/components/profiles/profile-create-dialog.tsx +++ b/ui/src/components/profiles/profile-create-dialog.tsx @@ -80,13 +80,14 @@ interface ProfileCreateDialogProps { open: boolean; onOpenChange: (open: boolean) => void; onSuccess: (name: string) => void; - initialMode?: 'normal' | 'openrouter' | 'alibaba-coding-plan'; + initialMode?: ProviderPreset['id'] | 'normal'; } // Common URL mistakes to warn about const PROBLEMATIC_PATHS = ['/chat/completions', '/v1/messages', '/messages', '/completions']; const CUSTOM_PRESET_ID = 'custom'; const DEFAULT_PRESET_ID: ProviderPreset['id'] = 'openrouter'; +const LOCAL_PRESET_IDS = new Set(['ollama', 'llamacpp']); const EMPTY_FORM_VALUES: FormData = { name: '', @@ -100,6 +101,12 @@ const EMPTY_FORM_VALUES: FormData = { }; const RECOMMENDED_PRESETS = getPresetsByCategory('recommended'); +const FEATURED_PREMIUM_PRESETS = RECOMMENDED_PRESETS.filter( + (preset) => !LOCAL_PRESET_IDS.has(preset.id) +); +const LOCAL_RUNTIME_PRESETS = RECOMMENDED_PRESETS.filter((preset) => + LOCAL_PRESET_IDS.has(preset.id) +); const QUICK_TEMPLATE_PRESETS = PROVIDER_PRESETS.filter( (preset) => preset.category !== 'recommended' ); @@ -314,7 +321,7 @@ export function ProfileCreateDialog({
- {RECOMMENDED_PRESETS.map((preset) => ( + {FEATURED_PREMIUM_PRESETS.map((preset) => (
+ +
+ +
+ {LOCAL_RUNTIME_PRESETS.map((preset) => ( + handlePresetSelect(preset.id)} + density="compact" + /> + ))} +
+
diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 4d695cd7..e707c9da 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -19,7 +19,7 @@ const resources = { home: 'Home', analytics: 'Analytics', identityAccess: 'Identity & Access', - apiProfiles: 'API Profiles', + apiProfiles: 'Profiles', cliproxyPlus: 'CLIProxy Plus', cliproxyOverview: 'Overview', controlPanel: 'Control Panel', @@ -1017,7 +1017,7 @@ const resources = { tokensSubtitle: '{{value}} tokens', }, apiProfiles: { - title: 'API Profiles', + title: 'Profiles', new: 'New', searchPlaceholder: 'Search profiles...', loadingProfiles: 'Loading profiles...', diff --git a/ui/src/pages/api.tsx b/ui/src/pages/api.tsx index 2a653f87..425b1316 100644 --- a/ui/src/pages/api.tsx +++ b/ui/src/pages/api.tsx @@ -34,6 +34,7 @@ import { import { useOpenRouterModels } from '@/hooks/use-openrouter-models'; import { ConfirmDialog } from '@/components/shared/confirm-dialog'; import type { ApiProfileExportBundle, Profile } from '@/lib/api-client'; +import type { ProviderPreset } from '@/lib/provider-presets'; import { cn } from '@/lib/utils'; import { CopyButton } from '@/components/ui/copy-button'; import { useTranslation } from 'react-i18next'; @@ -53,9 +54,7 @@ export function ApiPage() { const [selectedProfile, setSelectedProfile] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [isCreateDialogOpen, setCreateDialogOpen] = useState(false); - const [createMode, setCreateMode] = useState<'normal' | 'openrouter' | 'alibaba-coding-plan'>( - 'normal' - ); + const [createMode, setCreateMode] = useState('normal'); const [deleteConfirm, setDeleteConfirm] = useState(null); const [editorHasChanges, setEditorHasChanges] = useState(false); const [pendingSwitch, setPendingSwitch] = useState(null); @@ -211,12 +210,14 @@ export function ApiPage() {
-
+
-

{t('apiProfiles.title')}

+
+

Profiles

+
-
+
+

+ Premium APIs, local runtimes, custom endpoints +

+
{ + setCreateMode('ollama'); + setCreateDialogOpen(true); + }} + onLlamacppClick={() => { + setCreateMode('llamacpp'); + setCreateDialogOpen(true); + }} onCustomClick={() => { setCreateMode('normal'); setCreateDialogOpen(true); diff --git a/ui/tests/unit/components/profiles/openrouter-quick-start.test.tsx b/ui/tests/unit/components/profiles/openrouter-quick-start.test.tsx index 2fdd1394..feb98f66 100644 --- a/ui/tests/unit/components/profiles/openrouter-quick-start.test.tsx +++ b/ui/tests/unit/components/profiles/openrouter-quick-start.test.tsx @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import i18n from '@/lib/i18n'; import { OpenRouterQuickStart } from '@/components/profiles/openrouter-quick-start'; -import { render, screen } from '@tests/setup/test-utils'; +import { render, screen, userEvent } from '@tests/setup/test-utils'; vi.mock('@/hooks/use-openrouter-models', () => ({ useOpenRouterReady: () => ({ @@ -10,12 +10,40 @@ vi.mock('@/hooks/use-openrouter-models', () => ({ }), })); +vi.mock('@/hooks/use-local-runtime-readiness', () => ({ + useLocalRuntimeReadiness: () => ({ + data: { + runtimes: [ + { + id: 'ollama', + status: 'offline', + endpoint: 'http://127.0.0.1:11434', + commandHint: 'ollama serve', + recommendedModel: 'gemma4:e4b', + recommendedModelInstalled: false, + }, + { + id: 'llamacpp', + status: 'ready', + endpoint: 'http://127.0.0.1:8080', + commandHint: './server --host 0.0.0.0 --port 8080 -m model.gguf', + recommendedModel: null, + recommendedModelInstalled: true, + }, + ], + }, + isLoading: false, + }), +})); + describe('OpenRouterQuickStart', () => { const props = { onOpenRouterClick: vi.fn(), onAlibabaCodingPlanClick: vi.fn(), onCliproxyClick: vi.fn(), onCustomClick: vi.fn(), + onOllamaClick: vi.fn(), + onLlamacppClick: vi.fn(), }; beforeEach(async () => { @@ -25,16 +53,28 @@ describe('OpenRouterQuickStart', () => { it('prompts the user to select an existing API profile instead of showing the empty-state copy', () => { render(); - expect(screen.getByRole('heading', { name: 'Select an API profile' })).toBeInTheDocument(); + expect( + screen.getByRole('heading', { name: 'Choose a profile or add another lane' }) + ).toBeInTheDocument(); expect(screen.getByText('1 profile')).toBeInTheDocument(); expect(screen.getByText(/You already have 1 profile in this workspace\./)).toBeInTheDocument(); expect(screen.queryByText('No API profiles yet')).not.toBeInTheDocument(); }); - it('keeps the original empty-state title when no API profiles exist', () => { + it('shows the new local lane with readiness-aware calls to action', async () => { render(); - expect(screen.getByRole('heading', { name: 'No API profiles yet' })).toBeInTheDocument(); - expect(screen.getAllByText('Recommended')).not.toHaveLength(0); + expect( + screen.getByRole('heading', { name: 'Choose your first profile lane' }) + ).toBeInTheDocument(); + expect(screen.getByText('Ollama + Gemma 4')).toBeInTheDocument(); + expect(screen.getByText('llama.cpp')).toBeInTheDocument(); + expect( + screen.getByText('Run `ollama serve` to bring the local endpoint online.') + ).toBeInTheDocument(); + expect(screen.getByText('Endpoint ready at http://127.0.0.1:8080')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: 'Set up Ollama' })); + expect(props.onOllamaClick).toHaveBeenCalledTimes(1); }); }); diff --git a/ui/tests/unit/components/profiles/profile-create-dialog.test.tsx b/ui/tests/unit/components/profiles/profile-create-dialog.test.tsx index 2a1fdd9e..2919fe84 100644 --- a/ui/tests/unit/components/profiles/profile-create-dialog.test.tsx +++ b/ui/tests/unit/components/profiles/profile-create-dialog.test.tsx @@ -33,7 +33,7 @@ describe('ProfileCreateDialog', () => { mutateAsync.mockReset(); }); - it('keeps advanced presets collapsed until explicitly opened and deselects custom after choosing a template', async () => { + it('keeps More Presets visible by default and deselects custom after choosing a template', async () => { render( { ); expect(screen.getByText('Featured Providers')).toBeInTheDocument(); + expect(screen.getByText('More Presets')).toBeInTheDocument(); + expect(screen.getByText('Local Runtimes')).toBeInTheDocument(); expect(screen.getByText('Alibaba Coding Plan')).toBeVisible(); - const morePresetsToggle = screen.getByRole('button', { name: /More Presets/i }); - expect(morePresetsToggle).toHaveAttribute('aria-expanded', 'false'); - expect(document.body.querySelectorAll('.overflow-x-auto')).toHaveLength(1); + expect(document.body.querySelectorAll('.overflow-x-auto')).toHaveLength(2); const customButton = screen.getByRole('button', { name: /Custom Endpoint/i }); await userEvent.click(customButton); - expect(morePresetsToggle).toHaveAttribute('aria-expanded', 'false'); - expect(document.body.querySelectorAll('.overflow-x-auto')).toHaveLength(1); - - await userEvent.click(morePresetsToggle); - - expect(morePresetsToggle).toHaveAttribute('aria-expanded', 'true'); - expect(document.body.querySelectorAll('.overflow-x-auto')).toHaveLength(2); - const glmButton = screen.getByText('GLM').closest('button'); expect(glmButton).not.toBeNull(); if (!glmButton) { @@ -67,10 +59,19 @@ describe('ProfileCreateDialog', () => { } await userEvent.click(glmButton); - expect(morePresetsToggle).toHaveAttribute('aria-expanded', 'true'); await waitFor(() => { expect(customButton).not.toHaveClass('border-primary'); }); expect(glmButton).toHaveClass('border-primary'); }); + + it('supports opening directly into the Ollama preset from the providers landing page', async () => { + render( + + ); + + expect(screen.getByDisplayValue('ollama')).toBeInTheDocument(); + expect(screen.getByDisplayValue('http://localhost:11434')).toBeInTheDocument(); + expect(screen.getByText('Local Runtimes')).toBeInTheDocument(); + }); }); From 5f9bafe9cde488aed26e3c03be8ff84852723e74 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 06:31:58 -0400 Subject: [PATCH 20/54] fix(ui): address provider lane review notes --- src/api/services/local-runtime-readiness.ts | 6 +- .../profiles/openrouter-quick-start.tsx | 103 ++++--- ui/src/lib/i18n.ts | 257 ++++++++++++++---- .../profiles/openrouter-quick-start.test.tsx | 4 + 4 files changed, 270 insertions(+), 100 deletions(-) diff --git a/src/api/services/local-runtime-readiness.ts b/src/api/services/local-runtime-readiness.ts index 0472bfc1..c8bc2350 100644 --- a/src/api/services/local-runtime-readiness.ts +++ b/src/api/services/local-runtime-readiness.ts @@ -18,6 +18,7 @@ interface LocalRuntimeDefinition { endpoint: string; modelsUrl: string; commandHint: string; + pullCommandHint?: string; recommendedModel: string | null; parseModelIds: (payload: unknown) => string[]; } @@ -29,6 +30,7 @@ const LOCAL_RUNTIME_DEFINITIONS: LocalRuntimeDefinition[] = [ endpoint: 'http://127.0.0.1:11434', modelsUrl: 'http://127.0.0.1:11434/api/tags', commandHint: 'ollama serve', + pullCommandHint: 'ollama pull', recommendedModel: 'gemma4:e4b', parseModelIds: (payload) => { const models = (payload as { models?: Array<{ name?: string }> })?.models; @@ -86,8 +88,8 @@ function toReadiness( endpoint: definition.endpoint, status, commandHint: - status === 'missing-model' && definition.recommendedModel - ? `ollama pull ${definition.recommendedModel}` + status === 'missing-model' && definition.recommendedModel && definition.pullCommandHint + ? `${definition.pullCommandHint} ${definition.recommendedModel}` : definition.commandHint, recommendedModel: definition.recommendedModel, recommendedModelInstalled, diff --git a/ui/src/components/profiles/openrouter-quick-start.tsx b/ui/src/components/profiles/openrouter-quick-start.tsx index 4e273f6e..a32c3506 100644 --- a/ui/src/components/profiles/openrouter-quick-start.tsx +++ b/ui/src/components/profiles/openrouter-quick-start.tsx @@ -110,47 +110,50 @@ function QuickStartCard({ function getLocalRuntimeCardState( runtime: LocalRuntimeView | undefined, - label: string + label: string, + t: (key: string, options?: Record) => string ): LocalRuntimeCardState { if (!runtime) { return { - badge: 'Checking', + badge: t('openrouterQuickStart.localRuntimeCheckingBadge'), badgeClassName: 'bg-muted text-muted-foreground', - actionLabel: `Set up ${label}`, - description: 'Checking the local runtime status before showing setup guidance.', - footer: 'Checking the local runtime...', + actionLabel: t('openrouterQuickStart.localRuntimeSetupAction', { label }), + description: t('openrouterQuickStart.localRuntimeCheckingDescription'), + footer: t('openrouterQuickStart.localRuntimeCheckingFooter'), }; } if (runtime.status === 'ready') { return { - badge: 'Ready', + badge: t('openrouterQuickStart.localRuntimeReadyBadge'), badgeClassName: 'bg-emerald-500/10 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200', - actionLabel: `Use ${label}`, - description: 'Best for private prompts, offline workflows, and cheaper batch transforms.', - footer: `Endpoint ready at ${runtime.endpoint}`, + actionLabel: t('openrouterQuickStart.localRuntimeUseAction', { label }), + description: t('openrouterQuickStart.localRuntimeReadyDescription'), + footer: t('openrouterQuickStart.localRuntimeReadyFooter', { endpoint: runtime.endpoint }), }; } if (runtime.status === 'missing-model') { return { - badge: 'Needs model', + badge: t('openrouterQuickStart.localRuntimeNeedsModelBadge'), badgeClassName: 'bg-amber-500/10 text-amber-700 dark:bg-amber-500/20 dark:text-amber-200', - actionLabel: `Finish ${label} setup`, - description: - 'The runtime is up, but the recommended local model still needs to be pulled or loaded.', - footer: `Run \`${runtime.commandHint}\` to finish local setup.`, + actionLabel: t('openrouterQuickStart.localRuntimeFinishAction', { label }), + description: t('openrouterQuickStart.localRuntimeNeedsModelDescription'), + footer: t('openrouterQuickStart.localRuntimeMissingModelFooter', { + command: runtime.commandHint, + }), }; } return { - badge: 'Offline', + badge: t('openrouterQuickStart.localRuntimeOfflineBadge'), badgeClassName: 'bg-muted text-muted-foreground', - actionLabel: `Set up ${label}`, - description: - 'Use this lane for privacy-sensitive work, cheap local transforms, and offline sessions.', - footer: `Run \`${runtime.commandHint}\` to bring the local endpoint online.`, + actionLabel: t('openrouterQuickStart.localRuntimeSetupAction', { label }), + description: t('openrouterQuickStart.localRuntimeOfflineDescription'), + footer: t('openrouterQuickStart.localRuntimeOfflineFooter', { + command: runtime.commandHint, + }), }; } @@ -169,18 +172,26 @@ export function OpenRouterQuickStart({ const { data: localRuntimeData } = useLocalRuntimeReadiness(); const modelCountLabel = isLoading ? '300+' : `${modelCount}+`; const profileSummaryLabel = hasProfiles - ? `${profileCount} ${profileCount === 1 ? 'profile' : 'profiles'}` - : 'Premium quality + local control'; + ? t('openrouterQuickStart.profileSummaryCount', { count: profileCount }) + : t('openrouterQuickStart.profileSummaryEmpty'); const summaryTitle = hasProfiles - ? 'Choose a profile or add another lane' - : 'Choose your first profile lane'; + ? t('openrouterQuickStart.summaryExistingTitle') + : t('openrouterQuickStart.summaryEmptyTitle'); const summaryDescription = hasProfiles - ? `You already have ${profileCount} profile${profileCount === 1 ? '' : 's'} in this workspace. Keep premium-quality providers for serious coding and add local runtimes when privacy, cost, or offline work matters.` - : 'Pick the lane that matches the work. Premium providers stay the default for reliable coding. Local runtimes are best for privacy, cheaper transforms, and experimentation.'; + ? t('openrouterQuickStart.summaryExistingDescription', { count: profileCount }) + : t('openrouterQuickStart.summaryEmptyDescription'); const ollamaRuntime = localRuntimeData?.runtimes.find((runtime) => runtime.id === 'ollama'); const llamacppRuntime = localRuntimeData?.runtimes.find((runtime) => runtime.id === 'llamacpp'); - const ollamaCardState = getLocalRuntimeCardState(ollamaRuntime, 'Ollama'); - const llamacppCardState = getLocalRuntimeCardState(llamacppRuntime, 'llama.cpp'); + const ollamaCardState = getLocalRuntimeCardState( + ollamaRuntime, + t('openrouterQuickStart.localOllamaLabel'), + t + ); + const llamacppCardState = getLocalRuntimeCardState( + llamacppRuntime, + t('openrouterQuickStart.localLlamacppLabel'), + t + ); return (
@@ -190,8 +201,10 @@ export function OpenRouterQuickStart({
{profileSummaryLabel} - Best quality by default - Local lane when task fit wins + + {t('openrouterQuickStart.summaryBadgeDefaultQuality')} + + {t('openrouterQuickStart.summaryBadgeLocalLane')}

{summaryTitle}

@@ -210,7 +223,7 @@ export function OpenRouterQuickStart({

- Best quality lanes + {t('openrouterQuickStart.qualityLanesTitle')}

@@ -230,7 +243,7 @@ export function OpenRouterQuickStart({ }, { icon: , - label: 'Best default lane for high-stakes coding quality', + label: t('openrouterQuickStart.qualityLaneHighlightDefaultQuality'), }, ]} actionLabel={t('openrouterQuickStart.createOpenRouterProfile')} @@ -256,7 +269,7 @@ export function OpenRouterQuickStart({ badge={t('alibabaCodingPlanQuickStart.recommended')} badgeClassName="bg-orange-500/10 text-orange-700 dark:bg-orange-500/20 dark:text-orange-200" title={t('alibabaCodingPlanQuickStart.title')} - description="Strong direct coding profile when you want a dedicated premium lane outside the OpenRouter catalog." + description={t('openrouterQuickStart.alibabaLaneDescription')} visual={
, - label: 'Good when you want premium quality with a dedicated endpoint', + label: t('openrouterQuickStart.alibabaLaneHighlightQuality'), }, ]} actionLabel={t('alibabaCodingPlanQuickStart.createAlibabaProfile')} @@ -301,14 +314,14 @@ export function OpenRouterQuickStart({

- Local runtimes + {t('openrouterQuickStart.localRuntimesTitle')}

@@ -318,14 +331,16 @@ export function OpenRouterQuickStart({ highlights={[ { icon: , - label: 'Best for private prompts, local cleanup, and cheap batch transforms', + label: t('openrouterQuickStart.localOllamaHighlight'), }, { icon: , label: ollamaRuntime?.detectedModelCount && ollamaRuntime.detectedModelCount > 0 - ? `${ollamaRuntime.detectedModelCount} local model${ollamaRuntime.detectedModelCount === 1 ? '' : 's'} detected` - : 'No local models detected yet', + ? t('openrouterQuickStart.localDetectedModels', { + count: ollamaRuntime.detectedModelCount, + }) + : t('openrouterQuickStart.localNoModelsDetected'), }, ]} actionLabel={ollamaCardState.actionLabel} @@ -337,7 +352,7 @@ export function OpenRouterQuickStart({ @@ -347,14 +362,16 @@ export function OpenRouterQuickStart({ highlights={[ { icon: , - label: 'Best for custom GGUF setups and advanced self-hosted local workflows', + label: t('openrouterQuickStart.localLlamacppHighlight'), }, { icon: , label: llamacppRuntime?.detectedModelCount && llamacppRuntime.detectedModelCount > 0 - ? `${llamacppRuntime.detectedModelCount} local model${llamacppRuntime.detectedModelCount === 1 ? '' : 's'} detected` - : 'Waiting for a local server and model list', + ? t('openrouterQuickStart.localDetectedModels', { + count: llamacppRuntime.detectedModelCount, + }) + : t('openrouterQuickStart.localWaitingServer'), }, ]} actionLabel={llamacppCardState.actionLabel} @@ -370,7 +387,7 @@ export function OpenRouterQuickStart({ badge={t('openrouterQuickStart.runtimeProviderBadge')} badgeClassName="bg-emerald-500/10 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200" title={t('openrouterQuickStart.runtimeProviderTitle')} - description="Use this when you need CLIProxy-managed connectors, provider secrets, and advanced routing controls." + description={t('openrouterQuickStart.runtimeProviderManagedDescription')} visual={
diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index e707c9da..0c2bee8b 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -269,21 +269,59 @@ const resources = { description: 'Access {{modelCountLabel}} models from OpenAI, Anthropic, Google, Meta and more - all through one API.', featureOneApi: 'One API, all providers', - featureTierMapping: 'Model tier mapping', - profileCount: '{{count}} profile', - profileCount_other: '{{count}} profiles', - selectProfileTitle: 'Select an API profile', - summaryDescriptionWithProfiles: - 'You already have {{count}} profile in this workspace. Select one from the left rail to edit it, or create another profile from here.', - summaryDescriptionWithProfiles_other: - 'You already have {{count}} profiles in this workspace. Select one from the left rail to edit it, or create another profile from here.', - summaryDescriptionNoProfiles: - 'Use API Profiles for Anthropic-compatible endpoints. Use AI Providers for Gemini, Codex, Claude, Vertex, and OpenAI-compatible connectors.', - openrouterModelsBadge: '{{modelCountLabel}} OpenRouter models', + profileSummaryCount: '{{count}} profile', + profileSummaryCount_other: '{{count}} profiles', + profileSummaryEmpty: 'Premium quality + local control', + summaryBadgeDefaultQuality: 'Best quality by default', + summaryBadgeLocalLane: 'Local lane when task fit wins', + summaryExistingTitle: 'Choose a profile or add another lane', + summaryEmptyTitle: 'Choose your first profile lane', + summaryExistingDescription: + 'You already have {{count}} profile in this workspace. Keep premium-quality providers for serious coding and add local runtimes when privacy, cost, or offline work matters.', + summaryExistingDescription_other: + 'You already have {{count}} profiles in this workspace. Keep premium-quality providers for serious coding and add local runtimes when privacy, cost, or offline work matters.', + summaryEmptyDescription: + 'Pick the lane that matches the work. Premium providers stay the default for reliable coding. Local runtimes are best for privacy, cheaper transforms, and experimentation.', + qualityLanesTitle: 'Best quality lanes', + qualityLaneHighlightDefaultQuality: 'Best default lane for high-stakes coding quality', + alibabaLaneDescription: + 'Strong direct coding profile when you want a dedicated premium lane outside the OpenRouter catalog.', + alibabaLaneHighlightQuality: 'Good when you want premium quality with a dedicated endpoint', + localRuntimesTitle: 'Local runtimes', + localOllamaTitle: 'Ollama + Gemma 4', + localOllamaLabel: 'Ollama', + localOllamaHighlight: 'Best for private prompts, local cleanup, and cheap batch transforms', + localLlamacppTitle: 'llama.cpp', + localLlamacppLabel: 'llama.cpp', + localLlamacppHighlight: + 'Best for custom GGUF setups and advanced self-hosted local workflows', + localDetectedModels: '{{count}} local model detected', + localDetectedModels_other: '{{count}} local models detected', + localNoModelsDetected: 'No local models detected yet', + localWaitingServer: 'Waiting for a local server and model list', + localRuntimeCheckingBadge: 'Checking', + localRuntimeReadyBadge: 'Ready', + localRuntimeNeedsModelBadge: 'Needs model', + localRuntimeOfflineBadge: 'Offline', + localRuntimeCheckingDescription: + 'Checking the local runtime status before showing setup guidance.', + localRuntimeReadyDescription: + 'Best for private prompts, offline workflows, and cheaper batch transforms.', + localRuntimeNeedsModelDescription: + 'The runtime is up, but the recommended local model still needs to be pulled or loaded.', + localRuntimeOfflineDescription: + 'Use this lane for privacy-sensitive work, cheap local transforms, and offline sessions.', + localRuntimeCheckingFooter: 'Checking the local runtime...', + localRuntimeReadyFooter: 'Endpoint ready at {{endpoint}}', + localRuntimeMissingModelFooter: 'Run `{{command}}` to finish local setup.', + localRuntimeOfflineFooter: 'Run `{{command}}` to bring the local endpoint online.', + localRuntimeSetupAction: 'Set up {{label}}', + localRuntimeUseAction: 'Use {{label}}', + localRuntimeFinishAction: 'Finish {{label}} setup', runtimeProviderBadge: 'Runtime provider setup', runtimeProviderTitle: 'Open AI Providers', - runtimeProviderDescription: - 'Manage shared provider keys and runtime connectors before creating standalone API Profiles.', + runtimeProviderManagedDescription: + 'Use this when you need CLIProxy-managed connectors, provider secrets, and advanced routing controls.', runtimeProviderFeatureConnectors: 'Gemini, Codex, Claude, Vertex, and OpenAI-compatible connectors', runtimeProviderFeatureSecrets: 'Shared secrets stay outside individual API profiles', @@ -291,7 +329,6 @@ const resources = { 'Best when multiple profiles should share one provider configuration.', createOpenRouterProfile: 'Create OpenRouter Profile', getApiKeyAt: 'Get your API key at', - or: 'or', createCustomProfile: 'Create Custom API Profile', }, alibabaCodingPlanQuickStart: { @@ -1628,26 +1665,59 @@ const resources = { description: '通过一个 API 即可访问来自 OpenAI、Anthropic、Google、Meta 等的 {{modelCountLabel}} 个模型。', featureOneApi: '一个 API,接入全部提供商', - featureTierMapping: '模型档位映射', - profileCount: '{{count}} 个 API 配置', - profileCount_other: '{{count}} 个 API 配置', - selectProfileTitle: '选择一个 API 配置', - summaryDescriptionWithProfiles: - '此工作区中已存在 {{count}} 个 API 配置。请从左侧栏选择一个进行编辑,或在此继续创建新的配置。', - summaryDescriptionWithProfiles_other: - '此工作区中已存在 {{count}} 个 API 配置。请从左侧栏选择一个进行编辑,或在此继续创建新的配置。', - summaryDescriptionNoProfiles: - 'API 配置适用于 Anthropic 兼容端点。AI Providers 适用于 Gemini、Codex、Claude、Vertex 以及 OpenAI 兼容连接器。', - openrouterModelsBadge: '{{modelCountLabel}} 个 OpenRouter 模型', + profileSummaryCount: '{{count}} 个配置', + profileSummaryCount_other: '{{count}} 个配置', + profileSummaryEmpty: '高级质量 + 本地控制', + summaryBadgeDefaultQuality: '默认最佳质量', + summaryBadgeLocalLane: '任务合适时使用本地通道', + summaryExistingTitle: '选择一个配置,或添加新的通道', + summaryEmptyTitle: '选择你的第一条配置通道', + summaryExistingDescription: + '此工作区中已存在 {{count}} 个配置。对于严肃编码工作请优先使用高质量提供商,而在隐私、成本或离线场景下再增加本地运行时。', + summaryExistingDescription_other: + '此工作区中已存在 {{count}} 个配置。对于严肃编码工作请优先使用高质量提供商,而在隐私、成本或离线场景下再增加本地运行时。', + summaryEmptyDescription: + '根据任务选择通道。高级提供商仍是可靠编码的默认选择;本地运行时更适合隐私、低成本转换和实验。', + qualityLanesTitle: '高质量通道', + qualityLaneHighlightDefaultQuality: '高风险编码任务的默认最佳通道', + alibabaLaneDescription: + '当你想要一个独立于 OpenRouter 目录之外的高级编码通道时,这是一个强力的直连配置。', + alibabaLaneHighlightQuality: '适合需要独立端点的高级质量场景', + localRuntimesTitle: '本地运行时', + localOllamaTitle: 'Ollama + Gemma 4', + localOllamaLabel: 'Ollama', + localOllamaHighlight: '最适合隐私敏感提示、本地清理和低成本批处理转换', + localLlamacppTitle: 'llama.cpp', + localLlamacppLabel: 'llama.cpp', + localLlamacppHighlight: '最适合自定义 GGUF 部署和高级自托管本地工作流', + localDetectedModels: '已检测到 {{count}} 个本地模型', + localDetectedModels_other: '已检测到 {{count}} 个本地模型', + localNoModelsDetected: '尚未检测到本地模型', + localWaitingServer: '正在等待本地服务和模型列表', + localRuntimeCheckingBadge: '检测中', + localRuntimeReadyBadge: '就绪', + localRuntimeNeedsModelBadge: '缺少模型', + localRuntimeOfflineBadge: '离线', + localRuntimeCheckingDescription: '正在检查本地运行时状态,然后再显示设置引导。', + localRuntimeReadyDescription: '最适合私有提示、离线工作流和更便宜的批量转换。', + localRuntimeNeedsModelDescription: '运行时已启动,但推荐的本地模型仍需要拉取或加载。', + localRuntimeOfflineDescription: '这个通道适合隐私敏感工作、低成本本地转换和离线会话。', + localRuntimeCheckingFooter: '正在检查本地运行时...', + localRuntimeReadyFooter: '端点已就绪:{{endpoint}}', + localRuntimeMissingModelFooter: '运行 `{{command}}` 完成本地设置。', + localRuntimeOfflineFooter: '运行 `{{command}}` 启动本地端点。', + localRuntimeSetupAction: '设置 {{label}}', + localRuntimeUseAction: '使用 {{label}}', + localRuntimeFinishAction: '完成 {{label}} 设置', runtimeProviderBadge: '运行时提供商设置', runtimeProviderTitle: '打开 AI Providers', - runtimeProviderDescription: '先管理共享的提供商密钥和运行时连接器,再创建独立的 API 配置。', + runtimeProviderManagedDescription: + '当你需要 CLIProxy 管理的连接器、提供商密钥和高级路由控制时使用这里。', runtimeProviderFeatureConnectors: 'Gemini、Codex、Claude、Vertex 以及 OpenAI 兼容连接器', runtimeProviderFeatureSecrets: '共享密钥与单个 API 配置分离存放', runtimeProviderFooter: '当多个配置需要共享同一提供商设置时最适合使用。', createOpenRouterProfile: '创建 OpenRouter 配置', getApiKeyAt: '在此获取 API Key:', - or: '或', createCustomProfile: '创建自定义 API 配置', }, alibabaCodingPlanQuickStart: { @@ -2940,21 +3010,60 @@ const resources = { description: 'Truy cập các mô hình {{modelCountLabel}} từ OpenAI, Anthropic, Google, Meta, v.v. - tất cả đều thông qua một API.', featureOneApi: 'Một API cho mọi nhà cung cấp', - featureTierMapping: 'Ánh xạ tầng mô hình', - profileCount: '{{count}} hồ sơ', - profileCount_other: '{{count}} hồ sơ', - selectProfileTitle: 'Chọn một hồ sơ API', - summaryDescriptionWithProfiles: - 'Bạn đã có {{count}} hồ sơ trong workspace này. Chọn một hồ sơ ở thanh bên trái để chỉnh sửa, hoặc tạo thêm hồ sơ mới tại đây.', - summaryDescriptionWithProfiles_other: - 'Bạn đã có {{count}} hồ sơ trong workspace này. Chọn một hồ sơ ở thanh bên trái để chỉnh sửa, hoặc tạo thêm hồ sơ mới tại đây.', - summaryDescriptionNoProfiles: - 'Dùng API Profiles cho các endpoint tương thích Anthropic. Dùng AI Providers cho Gemini, Codex, Claude, Vertex và các connector tương thích OpenAI.', - openrouterModelsBadge: '{{modelCountLabel}} mô hình OpenRouter', + profileSummaryCount: '{{count}} hồ sơ', + profileSummaryCount_other: '{{count}} hồ sơ', + profileSummaryEmpty: 'Chất lượng cao + kiểm soát cục bộ', + summaryBadgeDefaultQuality: 'Mặc định chất lượng tốt nhất', + summaryBadgeLocalLane: 'Dùng local khi đúng bài toán', + summaryExistingTitle: 'Chọn hồ sơ hoặc thêm một lane mới', + summaryEmptyTitle: 'Chọn lane hồ sơ đầu tiên của bạn', + summaryExistingDescription: + 'Bạn đã có {{count}} hồ sơ trong workspace này. Hãy giữ các nhà cung cấp chất lượng cao cho công việc code nghiêm túc, và chỉ thêm runtime local khi cần quyền riêng tư, chi phí thấp hoặc làm việc offline.', + summaryExistingDescription_other: + 'Bạn đã có {{count}} hồ sơ trong workspace này. Hãy giữ các nhà cung cấp chất lượng cao cho công việc code nghiêm túc, và chỉ thêm runtime local khi cần quyền riêng tư, chi phí thấp hoặc làm việc offline.', + summaryEmptyDescription: + 'Chọn lane phù hợp với công việc. Nhà cung cấp premium vẫn là mặc định cho độ tin cậy khi code; runtime local phù hợp hơn cho quyền riêng tư, batch rẻ và thử nghiệm.', + qualityLanesTitle: 'Lane chất lượng cao', + qualityLaneHighlightDefaultQuality: 'Lane mặc định tốt nhất cho các tác vụ code quan trọng', + alibabaLaneDescription: + 'Hồ sơ coding trực tiếp mạnh mẽ khi bạn muốn một lane premium riêng ngoài catalog OpenRouter.', + alibabaLaneHighlightQuality: 'Phù hợp khi bạn cần chất lượng premium với endpoint riêng', + localRuntimesTitle: 'Runtime local', + localOllamaTitle: 'Ollama + Gemma 4', + localOllamaLabel: 'Ollama', + localOllamaHighlight: + 'Tốt nhất cho prompt riêng tư, dọn dẹp local và batch transform chi phí thấp', + localLlamacppTitle: 'llama.cpp', + localLlamacppLabel: 'llama.cpp', + localLlamacppHighlight: + 'Tốt nhất cho thiết lập GGUF tùy biến và workflow local tự host nâng cao', + localDetectedModels: 'Đã phát hiện {{count}} mô hình local', + localDetectedModels_other: 'Đã phát hiện {{count}} mô hình local', + localNoModelsDetected: 'Chưa phát hiện mô hình local nào', + localWaitingServer: 'Đang chờ máy chủ local và danh sách mô hình', + localRuntimeCheckingBadge: 'Đang kiểm tra', + localRuntimeReadyBadge: 'Sẵn sàng', + localRuntimeNeedsModelBadge: 'Thiếu mô hình', + localRuntimeOfflineBadge: 'Ngoại tuyến', + localRuntimeCheckingDescription: + 'Đang kiểm tra trạng thái runtime local trước khi hiển thị hướng dẫn thiết lập.', + localRuntimeReadyDescription: + 'Phù hợp nhất cho prompt riêng tư, workflow offline và batch transform rẻ hơn.', + localRuntimeNeedsModelDescription: + 'Runtime đã chạy, nhưng mô hình local được khuyến nghị vẫn cần được tải hoặc nạp.', + localRuntimeOfflineDescription: + 'Lane này phù hợp cho công việc nhạy cảm về riêng tư, transform local giá rẻ và phiên offline.', + localRuntimeCheckingFooter: 'Đang kiểm tra runtime local...', + localRuntimeReadyFooter: 'Endpoint sẵn sàng tại {{endpoint}}', + localRuntimeMissingModelFooter: 'Chạy `{{command}}` để hoàn tất thiết lập local.', + localRuntimeOfflineFooter: 'Chạy `{{command}}` để đưa endpoint local lên.', + localRuntimeSetupAction: 'Thiết lập {{label}}', + localRuntimeUseAction: 'Dùng {{label}}', + localRuntimeFinishAction: 'Hoàn tất thiết lập {{label}}', runtimeProviderBadge: 'Thiết lập nhà cung cấp runtime', runtimeProviderTitle: 'Mở AI Providers', - runtimeProviderDescription: - 'Quản lý khóa nhà cung cấp dùng chung và các connector runtime trước khi tạo API Profiles độc lập.', + runtimeProviderManagedDescription: + 'Dùng mục này khi bạn cần connector do CLIProxy quản lý, secret nhà cung cấp và điều khiển routing nâng cao.', runtimeProviderFeatureConnectors: 'Gemini, Codex, Claude, Vertex và các connector tương thích OpenAI', runtimeProviderFeatureSecrets: 'Khóa dùng chung tách biệt khỏi từng API Profile', @@ -2962,7 +3071,6 @@ const resources = { 'Phù hợp nhất khi nhiều hồ sơ cần dùng chung một cấu hình nhà cung cấp.', createOpenRouterProfile: 'Tạo hồ sơ OpenRouter', getApiKeyAt: 'Nhận khóa API của bạn tại', - or: 'hoặc', createCustomProfile: 'Tạo hồ sơ API tùy chỉnh', }, alibabaCodingPlanQuickStart: { @@ -4329,21 +4437,61 @@ const resources = { description: 'OpenAI、Anthropic、Google、Meta などの {{modelCountLabel}} モデルを、1 つの API で利用できます。', featureOneApi: '1 つの API で全プロバイダー', - featureTierMapping: 'モデルティアマッピング', - profileCount: '{{count}} 個の API プロファイル', - profileCount_other: '{{count}} 個の API プロファイル', - selectProfileTitle: 'API プロファイルを選択', - summaryDescriptionWithProfiles: - 'このワークスペースには既に {{count}} 個の API プロファイルがあります。左側レールから 1 つ選んで編集するか、ここから新しいプロファイルを作成してください。', - summaryDescriptionWithProfiles_other: - 'このワークスペースには既に {{count}} 個の API プロファイルがあります。左側レールから 1 つ選んで編集するか、ここから新しいプロファイルを作成してください。', - summaryDescriptionNoProfiles: - 'API Profiles は Anthropic 互換エンドポイント向けです。AI Providers は Gemini、Codex、Claude、Vertex、OpenAI 互換コネクタ向けです。', - openrouterModelsBadge: '{{modelCountLabel}} 個の OpenRouter モデル', + profileSummaryCount: '{{count}} 件のプロファイル', + profileSummaryCount_other: '{{count}} 件のプロファイル', + profileSummaryEmpty: '高品質 + ローカル制御', + summaryBadgeDefaultQuality: '既定で最高品質', + summaryBadgeLocalLane: 'タスクに合うときだけ local を使う', + summaryExistingTitle: 'プロファイルを選ぶか、新しいレーンを追加', + summaryEmptyTitle: '最初のプロファイルレーンを選択', + summaryExistingDescription: + 'このワークスペースには既に {{count}} 件のプロファイルがあります。重要なコーディング作業には高品質プロバイダーを使い、プライバシー・コスト・オフライン要件があるときだけローカルランタイムを追加してください。', + summaryExistingDescription_other: + 'このワークスペースには既に {{count}} 件のプロファイルがあります。重要なコーディング作業には高品質プロバイダーを使い、プライバシー・コスト・オフライン要件があるときだけローカルランタイムを追加してください。', + summaryEmptyDescription: + '作業に合うレーンを選んでください。Premium プロバイダーは信頼性の高いコーディング向けの既定値で、ローカルランタイムはプライバシー、低コスト変換、実験向けです。', + qualityLanesTitle: '高品質レーン', + qualityLaneHighlightDefaultQuality: '重要なコーディング作業向けの既定ベストレーン', + alibabaLaneDescription: + 'OpenRouter カタログの外で、専用の premium コーディングレーンが欲しいときに強い直接接続プロファイルです。', + alibabaLaneHighlightQuality: '専用エンドポイントで premium 品質が必要なときに最適', + localRuntimesTitle: 'ローカルランタイム', + localOllamaTitle: 'Ollama + Gemma 4', + localOllamaLabel: 'Ollama', + localOllamaHighlight: 'プライベートなプロンプト、ローカル整理、低コストの一括変換に最適', + localLlamacppTitle: 'llama.cpp', + localLlamacppLabel: 'llama.cpp', + localLlamacppHighlight: + 'カスタム GGUF 構成や高度なセルフホスト型ローカルワークフローに最適', + localDetectedModels: '{{count}} 件のローカルモデルを検出', + localDetectedModels_other: '{{count}} 件のローカルモデルを検出', + localNoModelsDetected: 'ローカルモデルはまだ検出されていません', + localWaitingServer: 'ローカルサーバーとモデル一覧を待機中', + localRuntimeCheckingBadge: '確認中', + localRuntimeReadyBadge: '準備完了', + localRuntimeNeedsModelBadge: 'モデルが必要', + localRuntimeOfflineBadge: 'オフライン', + localRuntimeCheckingDescription: + 'セットアップ案内を表示する前に、ローカルランタイムの状態を確認しています。', + localRuntimeReadyDescription: + 'プライベートなプロンプト、オフライン作業、より安価な一括変換に適しています。', + localRuntimeNeedsModelDescription: + 'ランタイムは起動していますが、推奨ローカルモデルの取得または読み込みがまだ必要です。', + localRuntimeOfflineDescription: + 'このレーンは、プライバシー重視の作業、安価なローカル変換、オフラインセッションに向いています。', + localRuntimeCheckingFooter: 'ローカルランタイムを確認しています...', + localRuntimeReadyFooter: '{{endpoint}} でエンドポイント準備完了', + localRuntimeMissingModelFooter: + 'ローカルセットアップを完了するには `{{command}}` を実行してください。', + localRuntimeOfflineFooter: + 'ローカルエンドポイントを起動するには `{{command}}` を実行してください。', + localRuntimeSetupAction: '{{label}} をセットアップ', + localRuntimeUseAction: '{{label}} を使う', + localRuntimeFinishAction: '{{label}} のセットアップを完了', runtimeProviderBadge: 'ランタイムプロバイダー設定', runtimeProviderTitle: 'AI Providers を開く', - runtimeProviderDescription: - '個別の API プロファイルを作成する前に、共有プロバイダーキーとランタイムコネクタを管理します。', + runtimeProviderManagedDescription: + 'CLIProxy 管理のコネクタ、プロバイダーシークレット、高度なルーティング制御が必要なときに使います。', runtimeProviderFeatureConnectors: 'Gemini、Codex、Claude、Vertex、OpenAI 互換コネクタ', runtimeProviderFeatureSecrets: '共有シークレットを個別の API プロファイルから切り離して管理', @@ -4351,7 +4499,6 @@ const resources = { '複数のプロファイルで 1 つのプロバイダー設定を共有したい場合に最適です。', createOpenRouterProfile: 'OpenRouter プロファイルを作成', getApiKeyAt: 'API キー取得:', - or: 'または', createCustomProfile: 'カスタム API プロファイルを作成', }, alibabaCodingPlanQuickStart: { diff --git a/ui/tests/unit/components/profiles/openrouter-quick-start.test.tsx b/ui/tests/unit/components/profiles/openrouter-quick-start.test.tsx index feb98f66..4f925ad7 100644 --- a/ui/tests/unit/components/profiles/openrouter-quick-start.test.tsx +++ b/ui/tests/unit/components/profiles/openrouter-quick-start.test.tsx @@ -21,6 +21,7 @@ vi.mock('@/hooks/use-local-runtime-readiness', () => ({ commandHint: 'ollama serve', recommendedModel: 'gemma4:e4b', recommendedModelInstalled: false, + detectedModelCount: 0, }, { id: 'llamacpp', @@ -29,6 +30,7 @@ vi.mock('@/hooks/use-local-runtime-readiness', () => ({ commandHint: './server --host 0.0.0.0 --port 8080 -m model.gguf', recommendedModel: null, recommendedModelInstalled: true, + detectedModelCount: 1, }, ], }, @@ -73,6 +75,8 @@ describe('OpenRouterQuickStart', () => { screen.getByText('Run `ollama serve` to bring the local endpoint online.') ).toBeInTheDocument(); expect(screen.getByText('Endpoint ready at http://127.0.0.1:8080')).toBeInTheDocument(); + expect(screen.getByText('No local models detected yet')).toBeInTheDocument(); + expect(screen.getByText('1 local model detected')).toBeInTheDocument(); await userEvent.click(screen.getByRole('button', { name: 'Set up Ollama' })); expect(props.onOllamaClick).toHaveBeenCalledTimes(1); From 70051c2acbf13d05e826a17af1435a397b79d06c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 10 Apr 2026 12:10:20 +0000 Subject: [PATCH 21/54] chore(release): 7.67.0-dev.4 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fc435501..b876022c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.67.0-dev.3", + "version": "7.67.0-dev.4", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From bb331ff5d8a9ff8bc387599a491335ffe89e7a1c Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 13:17:50 -0400 Subject: [PATCH 22/54] feat(cliproxy): add entitlement evidence for gemini and agy --- src/cliproxy/gemini-cli-quota-normalizer.ts | 2 +- src/cliproxy/provider-entitlement-evidence.ts | 69 +++++++++++++++++ src/cliproxy/provider-entitlement-types.ts | 33 +++++++++ src/cliproxy/quota-fetcher-gemini-cli.ts | 74 ++++++++++++++++--- src/cliproxy/quota-fetcher.ts | 49 +++++++----- src/cliproxy/quota-types.ts | 4 + src/commands/cliproxy/quota-subcommand.ts | 13 +++- .../cliproxy/quota-fetcher-gemini-cli.test.ts | 54 ++++++++++++++ .../cliproxy-quota-subcommand.test.ts | 8 ++ .../account/shared/account-surface-card.tsx | 4 +- .../shared/quota-tooltip-content.tsx | 36 ++++++++- ui/src/lib/api-client.ts | 26 +++++++ ui/src/lib/utils.ts | 20 +++++ .../shared/quota-tooltip-content.test.tsx | 13 ++++ 14 files changed, 371 insertions(+), 34 deletions(-) create mode 100644 src/cliproxy/provider-entitlement-evidence.ts create mode 100644 src/cliproxy/provider-entitlement-types.ts diff --git a/src/cliproxy/gemini-cli-quota-normalizer.ts b/src/cliproxy/gemini-cli-quota-normalizer.ts index 1d5425b9..3fad0de9 100644 --- a/src/cliproxy/gemini-cli-quota-normalizer.ts +++ b/src/cliproxy/gemini-cli-quota-normalizer.ts @@ -20,7 +20,7 @@ const GEMINI_CLI_QUOTA_GROUPS: GeminiCliQuotaGroupDefinition[] = [ id: 'gemini-flash-lite-series', label: 'Gemini Flash Lite Series', preferredModelId: 'gemini-2.5-flash-lite', - modelIds: ['gemini-2.5-flash-lite'], + modelIds: ['gemini-2.5-flash-lite', 'gemini-3.1-flash-lite-preview'], }, { id: 'gemini-flash-series', diff --git a/src/cliproxy/provider-entitlement-evidence.ts b/src/cliproxy/provider-entitlement-evidence.ts new file mode 100644 index 00000000..9b36576e --- /dev/null +++ b/src/cliproxy/provider-entitlement-evidence.ts @@ -0,0 +1,69 @@ +import type { AccountTier } from './accounts/types'; +import type { + ProviderAccessState, + ProviderCapacityState, + ProviderEntitlementEvidence, + ProviderEntitlementSource, +} from './provider-entitlement-types'; + +const RAW_TIER_LABELS: Record = { + 'free-tier': 'Free', + 'legacy-tier': 'Legacy', + 'standard-tier': 'Standard', + 'g1-pro-tier': 'Pro', + 'g1-ultra-tier': 'Ultra', +}; + +export function normalizeProviderTierId(rawTierId: string | null | undefined): AccountTier { + if (!rawTierId) return 'unknown'; + const normalized = rawTierId.trim().toLowerCase(); + if (!normalized) return 'unknown'; + if (normalized.includes('ultra')) return 'ultra'; + if (normalized.includes('pro')) return 'pro'; + if (normalized.includes('free') || normalized.includes('legacy')) return 'free'; + return 'unknown'; +} + +export function getProviderTierLabel(rawTierId: string | null | undefined): string | null { + if (!rawTierId) return null; + const normalized = rawTierId.trim().toLowerCase(); + return normalized ? (RAW_TIER_LABELS[normalized] ?? rawTierId.trim()) : null; +} + +export function buildProviderEntitlementEvidence(input: { + normalizedTier: AccountTier; + rawTierId?: string | null; + rawTierLabel?: string | null; + source: ProviderEntitlementSource; + confidence: 'high' | 'medium' | 'low'; + accessState: ProviderAccessState; + capacityState: ProviderCapacityState; + notes?: string | null; + lastVerifiedAt?: number; +}): ProviderEntitlementEvidence { + const rawTierId = input.rawTierId?.trim() || null; + return { + normalizedTier: input.normalizedTier, + rawTierId, + rawTierLabel: input.rawTierLabel ?? getProviderTierLabel(rawTierId), + source: input.source, + confidence: input.confidence, + accessState: input.accessState, + capacityState: input.capacityState, + notes: input.notes ?? null, + lastVerifiedAt: input.lastVerifiedAt ?? Date.now(), + }; +} + +export function isModelCapacityExhausted( + message: string | null | undefined, + detail: string | null | undefined, + errorCode: string | null | undefined +): boolean { + const haystack = `${message || ''} ${detail || ''} ${errorCode || ''}`.toLowerCase(); + return ( + haystack.includes('model_capacity_exhausted') || + haystack.includes('no capacity available') || + haystack.includes('capacity exhausted') + ); +} diff --git a/src/cliproxy/provider-entitlement-types.ts b/src/cliproxy/provider-entitlement-types.ts new file mode 100644 index 00000000..ef07b8db --- /dev/null +++ b/src/cliproxy/provider-entitlement-types.ts @@ -0,0 +1,33 @@ +import type { AccountTier } from './accounts/types'; + +export type ProviderEntitlementSource = + | 'runtime_api' + | 'runtime_inference' + | 'registry_cache' + | 'official_docs'; + +export type ProviderAccessState = + | 'entitled' + | 'not_entitled' + | 'capacity_exhausted' + | 'temporarily_unavailable' + | 'unknown'; + +export type ProviderCapacityState = + | 'available' + | 'capacity_exhausted' + | 'rate_limited' + | 'temporarily_unavailable' + | 'unknown'; + +export interface ProviderEntitlementEvidence { + normalizedTier: AccountTier; + rawTierId: string | null; + rawTierLabel: string | null; + source: ProviderEntitlementSource; + confidence: 'high' | 'medium' | 'low'; + accessState: ProviderAccessState; + capacityState: ProviderCapacityState; + lastVerifiedAt: number; + notes?: string | null; +} diff --git a/src/cliproxy/quota-fetcher-gemini-cli.ts b/src/cliproxy/quota-fetcher-gemini-cli.ts index 1dc8fe7a..27d80d6a 100644 --- a/src/cliproxy/quota-fetcher-gemini-cli.ts +++ b/src/cliproxy/quota-fetcher-gemini-cli.ts @@ -8,7 +8,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { getAuthDir } from './config-generator'; -import { getProviderAccounts, getPausedDir } from './account-manager'; +import { getProviderAccounts, getPausedDir, setAccountTier } from './account-manager'; import { getTokenExpiryTimestamp, sanitizeEmail, isTokenExpired } from './auth-utils'; import { refreshGeminiToken } from './auth/gemini-token-refresh'; import { @@ -16,6 +16,13 @@ import { type GeminiCliParsedBucket, } from './gemini-cli-quota-normalizer'; import type { GeminiCliQuotaResult, GeminiCliBucket } from './quota-types'; +import { + buildProviderEntitlementEvidence, + getProviderTierLabel, + isModelCapacityExhausted, + normalizeProviderTierId, +} from './provider-entitlement-evidence'; +import type { ProviderEntitlementEvidence } from './provider-entitlement-types'; /** Google Cloud Code API endpoints */ const GEMINI_CLI_API_BASE = 'https://cloudcode-pa.googleapis.com'; @@ -25,13 +32,6 @@ const GEMINI_CLI_CODE_ASSIST_URL = `${GEMINI_CLI_API_BASE}/${GEMINI_CLI_API_VERS const GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH = 320; const GEMINI_CLI_ERROR_DETAIL_TRUNCATION_SUFFIX = '...[truncated]'; const GEMINI_CLI_G1_CREDIT_TYPE = 'GOOGLE_ONE_AI'; -const GEMINI_CLI_TIER_LABELS: Record = { - 'free-tier': 'Free', - 'legacy-tier': 'Legacy', - 'standard-tier': 'Standard', - 'g1-pro-tier': 'Pro', - 'g1-ultra-tier': 'Ultra', -}; /** Auth data extracted from Gemini CLI auth file */ interface GeminiCliAuthData { @@ -90,6 +90,7 @@ interface GeminiCliSupplementaryInfo { tierLabel: string | null; tierId: string | null; creditBalance: number | null; + normalizedTier: 'free' | 'pro' | 'ultra' | 'unknown'; } /** @@ -278,8 +279,7 @@ function resolveGeminiCliTierId(payload: GeminiCliCodeAssistResponse | null): st function resolveGeminiCliTierLabel(payload: GeminiCliCodeAssistResponse | null): string | null { const tierId = resolveGeminiCliTierId(payload); - if (!tierId) return null; - return GEMINI_CLI_TIER_LABELS[tierId] ?? tierId; + return getProviderTierLabel(tierId); } function resolveGeminiCliCreditBalance(payload: GeminiCliCodeAssistResponse | null): number | null { @@ -340,7 +340,7 @@ async function fetchGeminiCliSupplementary( if (verbose) { console.error(`[i] Gemini CLI supplementary metadata unavailable: HTTP ${response.status}`); } - return { tierLabel: null, tierId: null, creditBalance: null }; + return { tierLabel: null, tierId: null, creditBalance: null, normalizedTier: 'unknown' }; } const payload = (await response.json()) as GeminiCliCodeAssistResponse; @@ -348,6 +348,7 @@ async function fetchGeminiCliSupplementary( tierLabel: resolveGeminiCliTierLabel(payload), tierId: resolveGeminiCliTierId(payload), creditBalance: resolveGeminiCliCreditBalance(payload), + normalizedTier: normalizeProviderTierId(resolveGeminiCliTierId(payload)), }; } catch (error) { clearTimeout(timeoutId); @@ -355,7 +356,7 @@ async function fetchGeminiCliSupplementary( const message = error instanceof Error ? error.message : 'Unknown error'; console.error(`[i] Gemini CLI supplementary metadata skipped: ${message}`); } - return { tierLabel: null, tierId: null, creditBalance: null }; + return { tierLabel: null, tierId: null, creditBalance: null, normalizedTier: 'unknown' }; } } @@ -371,6 +372,7 @@ function buildGeminiCliFailureResult( retryable?: boolean; needsReauth?: boolean; isForbidden?: boolean; + entitlement?: ProviderEntitlementEvidence; } ): GeminiCliQuotaResult { return { @@ -390,6 +392,7 @@ function buildGeminiCliFailureResult( retryable: options.retryable, needsReauth: options.needsReauth, isForbidden: options.isForbidden, + entitlement: options.entitlement, }; } @@ -549,10 +552,37 @@ function buildGeminiCliHttpFailureResult( actionHint: buildGeminiCliForbiddenActionHint(parsed), isForbidden: true, retryable: false, + entitlement: buildProviderEntitlementEvidence({ + normalizedTier: 'unknown', + source: 'runtime_inference', + confidence: 'medium', + accessState: 'not_entitled', + capacityState: 'unknown', + }), }); } if (status === 429) { + if (isModelCapacityExhausted(parsed.message, parsed.errorDetail, parsed.errorCode)) { + return buildGeminiCliFailureResult(accountId, projectId, { + error: parsed.message || 'Model capacity exhausted for this account right now', + httpStatus: 429, + errorCode: 'capacity_exhausted', + errorDetail: parsed.errorDetail, + actionHint: + 'Retry later or switch to another Gemini model. This indicates temporary model capacity, not an authentication failure.', + retryable: true, + entitlement: buildProviderEntitlementEvidence({ + normalizedTier: 'unknown', + source: 'runtime_inference', + confidence: 'medium', + accessState: 'entitled', + capacityState: 'capacity_exhausted', + notes: 'Upstream returned MODEL_CAPACITY_EXHAUSTED for this model.', + }), + }); + } + return buildGeminiCliFailureResult(accountId, projectId, { error: parsed.message || 'Rate limited - try again later', httpStatus: 429, @@ -560,6 +590,13 @@ function buildGeminiCliHttpFailureResult( errorDetail: parsed.errorDetail, actionHint: 'Retry after a short delay.', retryable: true, + entitlement: buildProviderEntitlementEvidence({ + normalizedTier: 'unknown', + source: 'runtime_inference', + confidence: 'low', + accessState: 'unknown', + capacityState: 'rate_limited', + }), }); } @@ -683,6 +720,10 @@ async function fetchWithAuthData( if (verbose) console.error(`[i] Gemini CLI buckets found: ${buckets.length}`); + if (supplementary.normalizedTier !== 'unknown') { + setAccountTier('gemini', accountId, supplementary.normalizedTier); + } + return { success: true, buckets, @@ -690,6 +731,15 @@ async function fetchWithAuthData( tierLabel: supplementary.tierLabel, tierId: supplementary.tierId, creditBalance: supplementary.creditBalance, + entitlement: buildProviderEntitlementEvidence({ + normalizedTier: supplementary.normalizedTier, + rawTierId: supplementary.tierId, + rawTierLabel: supplementary.tierLabel, + source: supplementary.tierId ? 'runtime_api' : 'runtime_inference', + confidence: supplementary.tierId ? 'high' : 'medium', + accessState: 'entitled', + capacityState: 'available', + }), lastUpdated: Date.now(), accountId, }; diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index 1365ae42..06765c58 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -17,6 +17,12 @@ import { type AccountTier, } from './account-manager'; import { sanitizeEmail, isTokenExpired } from './auth-utils'; +import { + buildProviderEntitlementEvidence, + getProviderTierLabel, + normalizeProviderTierId, +} from './provider-entitlement-evidence'; +import type { ProviderEntitlementEvidence } from './provider-entitlement-types'; import { buildManagementHeaders, buildProxyUrl, getProxyTarget } from './proxy-target-resolver'; /** Individual model quota info */ @@ -67,6 +73,8 @@ export interface QuotaResult { projectId?: string; /** Detected account tier based on model access */ tier?: AccountTier; + /** Richer provider entitlement evidence derived from live/runtime signals */ + entitlement?: ProviderEntitlementEvidence; } /** Google Cloud Code API endpoints */ @@ -175,6 +183,8 @@ interface ManagedResponse { interface ProjectLookupResult { projectId: string | null; tier?: AccountTier; + rawTierId?: string | null; + rawTierLabel?: string | null; error?: string; errorCode?: string; errorDetail?: string; @@ -518,21 +528,6 @@ function readAuthData(provider: CLIProxyProvider, accountId: string): AuthData | * API returns: "g1-ultra-tier", "g1-pro-tier", "standard-tier", etc. * Priority: ultra > pro > free */ -function mapTierString(tierStr: string | undefined): AccountTier { - if (!tierStr) return 'unknown'; - const normalized = tierStr.toLowerCase(); - // Match "g1-ultra-tier" or "ultra" anywhere in string - if (normalized.includes('ultra')) return 'ultra'; - // Match "g1-pro-tier" or "pro" anywhere in string - if (normalized.includes('pro')) return 'pro'; - // Match free/legacy tiers - if (normalized.includes('free') || normalized.includes('legacy')) { - return 'free'; - } - // "standard-tier" and other unknown values = unknown - return 'unknown'; -} - /** * Get project ID and tier via loadCodeAssist endpoint * Uses paidTier.id for accurate tier detection (g1-ultra-tier, g1-pro-tier) @@ -590,10 +585,15 @@ async function getProjectId(accountId: string, accessToken: string): Promise { ]); }); + it('should keep Gemini 3.1 Flash Lite preview inside the Flash Lite family', () => { + const rawBuckets = [{ model_id: 'gemini-3.1-flash-lite-preview', remaining_fraction: 0.65 }]; + + const buckets = buildGeminiCliBuckets(rawBuckets); + + expect(buckets).toHaveLength(1); + expect(buckets[0].label).toBe('Gemini Flash Lite Series'); + expect(buckets[0].modelIds).toContain('gemini-3.1-flash-lite-preview'); + }); + it('should recognize Gemini 3.1 preview IDs during the rollout', () => { const rawBuckets = [ { model_id: 'gemini-3.1-flash-preview', remaining_fraction: 0.7 }, @@ -403,6 +413,13 @@ describe('Gemini CLI Quota Fetcher', () => { expect(result.tierLabel).toBe('Pro'); expect(result.tierId).toBe('g1-pro-tier'); expect(result.creditBalance).toBe(12); + expect(result.entitlement).toMatchObject({ + normalizedTier: 'pro', + rawTierId: 'g1-pro-tier', + rawTierLabel: 'Pro', + accessState: 'entitled', + capacityState: 'available', + }); expect(result.buckets.map((bucket) => bucket.label)).toEqual([ 'Gemini Flash Lite Series', 'Gemini Flash Series', @@ -822,6 +839,43 @@ describe('Gemini CLI Quota Fetcher', () => { globalThis.fetch = originalFetch; } }); + + it('classifies model capacity exhaustion separately from generic rate limits', async () => { + writeActiveGeminiAccount('capacity@example.com'); + + mockFetch([ + { + url: GEMINI_QUOTA_URL, + method: 'POST', + status: 429, + response: { + error: { + code: 429, + message: 'No capacity available for model gemini-3.1-pro-preview on the server', + status: 'RESOURCE_EXHAUSTED', + details: [ + { + '@type': 'type.googleapis.com/google.rpc.ErrorInfo', + reason: 'MODEL_CAPACITY_EXHAUSTED', + metadata: { model: 'gemini-3.1-pro-preview' }, + }, + ], + }, + }, + }, + ]); + + const result = await fetchGeminiCliQuota('capacity@example.com'); + + expect(result.success).toBe(false); + expect(result.httpStatus).toBe(429); + expect(result.errorCode).toBe('capacity_exhausted'); + expect(result.retryable).toBe(true); + expect(result.entitlement).toMatchObject({ + accessState: 'entitled', + capacityState: 'capacity_exhausted', + }); + }); }); describe('direct Gemini error helper coverage', () => { diff --git a/tests/unit/commands/cliproxy-quota-subcommand.test.ts b/tests/unit/commands/cliproxy-quota-subcommand.test.ts index 0b49cfef..f6bd033d 100644 --- a/tests/unit/commands/cliproxy-quota-subcommand.test.ts +++ b/tests/unit/commands/cliproxy-quota-subcommand.test.ts @@ -75,4 +75,12 @@ describe('cliproxy quota subcommand failure formatting', () => { }, ]); }); + + it('prefers live quota tier over stale account tier', async () => { + const { resolveDisplayedTier } = await loadQuotaCommandTestExports(); + + expect(resolveDisplayedTier('unknown', 'pro')).toBe('pro'); + expect(resolveDisplayedTier('pro', 'ultra')).toBe('ultra'); + expect(resolveDisplayedTier('pro', 'unknown')).toBe('pro'); + }); }); diff --git a/ui/src/components/account/shared/account-surface-card.tsx b/ui/src/components/account/shared/account-surface-card.tsx index 64a1795c..3d91d71f 100644 --- a/ui/src/components/account/shared/account-surface-card.tsx +++ b/ui/src/components/account/shared/account-surface-card.tsx @@ -86,7 +86,9 @@ export function AccountSurfaceCard({ const title = displayEmail || identity.email || accountId; const normalizedProvider = provider.toLowerCase(); const showTierBadge = - (normalizedProvider === 'agy' || normalizedProvider === 'antigravity') && + (normalizedProvider === 'agy' || + normalizedProvider === 'antigravity' || + normalizedProvider === 'gemini') && tier && tier !== 'unknown' && tier !== 'free'; diff --git a/ui/src/components/shared/quota-tooltip-content.tsx b/ui/src/components/shared/quota-tooltip-content.tsx index 5dcda088..bd1e14dd 100644 --- a/ui/src/components/shared/quota-tooltip-content.tsx +++ b/ui/src/components/shared/quota-tooltip-content.tsx @@ -21,6 +21,7 @@ import { type ModelTier, type UnifiedQuotaResult, } from '@/lib/utils'; +import type { ProviderEntitlementEvidence } from '@/lib/api-client'; interface QuotaTooltipContentProps { quota: UnifiedQuotaResult | null | undefined; @@ -77,6 +78,35 @@ function getClaudeWindowDisplayLabel(rateLimitType: string, fallback: string): s } } +function renderEntitlementRows(entitlement: ProviderEntitlementEvidence | undefined) { + if (!entitlement) return null; + + const rows: Array<{ label: string; value: string | null }> = []; + if (entitlement.rawTierLabel) { + rows.push({ label: 'Tier', value: entitlement.rawTierLabel }); + } else if (entitlement.normalizedTier !== 'unknown') { + rows.push({ label: 'Tier', value: entitlement.normalizedTier }); + } + if (entitlement.rawTierId) { + rows.push({ label: 'Tier ID', value: entitlement.rawTierId }); + } + if (entitlement.accessState !== 'entitled' || entitlement.capacityState !== 'available') { + rows.push({ + label: 'State', + value: `${entitlement.accessState.replaceAll('_', ' ')} / ${entitlement.capacityState.replaceAll('_', ' ')}`, + }); + } + + if (rows.length === 0) return null; + + return rows.map((row) => ( +
+ {row.label} + {row.value} +
+ )); +} + /** * Renders provider-specific quota tooltip content * Uses type guards for proper TypeScript narrowing @@ -132,6 +162,7 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro return (
+ {renderEntitlementRows(quota.entitlement)}

Model Quotas:

{tierOrder.map((tier, idx) => { const models = groups.get(tier); @@ -268,10 +299,13 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro // Gemini provider tooltip if (isGeminiQuotaResult(quota)) { const hasBucketResetTime = quota.buckets.some((bucket) => !!bucket.resetTime); + const hasEntitlementTier = + !!quota.entitlement?.rawTierLabel || quota.entitlement?.normalizedTier !== 'unknown'; return (
- {quota.tierLabel && ( + {renderEntitlementRows(quota.entitlement)} + {!hasEntitlementTier && quota.tierLabel && (
Tier {quota.tierLabel} diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 74a848ee..6a0e1bc4 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -562,6 +562,30 @@ export interface QuotaResult { retryable?: boolean; /** True if token is expired and needs re-authentication */ needsReauth?: boolean; + /** Richer provider entitlement evidence derived from live/runtime signals */ + entitlement?: ProviderEntitlementEvidence; +} + +export interface ProviderEntitlementEvidence { + normalizedTier: 'free' | 'pro' | 'ultra' | 'unknown'; + rawTierId: string | null; + rawTierLabel: string | null; + source: 'runtime_api' | 'runtime_inference' | 'registry_cache' | 'official_docs'; + confidence: 'high' | 'medium' | 'low'; + accessState: + | 'entitled' + | 'not_entitled' + | 'capacity_exhausted' + | 'temporarily_unavailable' + | 'unknown'; + capacityState: + | 'available' + | 'capacity_exhausted' + | 'rate_limited' + | 'temporarily_unavailable' + | 'unknown'; + lastVerifiedAt: number; + notes?: string | null; } /** Codex rate limit window */ @@ -725,6 +749,8 @@ export interface GeminiCliQuotaResult { tierId?: string | null; /** Available Google One AI credits when reported by the API */ creditBalance?: number | null; + /** Richer provider entitlement evidence derived from live/runtime signals */ + entitlement?: ProviderEntitlementEvidence; /** Timestamp of fetch */ lastUpdated: number; /** Upstream HTTP status when available */ diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 9db71545..0105d1e1 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -771,6 +771,25 @@ export function getQuotaFailureInfo( const technicalDetail = buildQuotaTechnicalDetail(quota); const rawDetail = buildQuotaRawDetail(quota, summary, technicalDetail); const lowerSummary = summary.toLowerCase(); + const entitlement = 'entitlement' in quota ? quota.entitlement : undefined; + + if ( + quota.errorCode === 'capacity_exhausted' || + entitlement?.capacityState === 'capacity_exhausted' || + lowerSummary.includes('no capacity available') || + lowerSummary.includes('capacity exhausted') + ) { + return { + label: 'Capacity', + summary, + actionHint: + actionHint || + 'Retry later or switch to another model. This is a temporary provider capacity issue.', + technicalDetail, + rawDetail, + tone: 'warning', + }; + } if ( quota.needsReauth || @@ -808,6 +827,7 @@ export function getQuotaFailureInfo( } if ( + entitlement?.accessState === 'not_entitled' || quota.isForbidden || quota.httpStatus === 403 || errorCode === 'quota_api_forbidden' || diff --git a/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx b/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx index bada72b1..790ec8a1 100644 --- a/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx +++ b/ui/tests/unit/ui/components/shared/quota-tooltip-content.test.tsx @@ -34,6 +34,17 @@ function createGeminiQuotaResult( tierLabel: 'Pro', tierId: 'g1-pro-tier', creditBalance: 12, + entitlement: { + normalizedTier: 'pro', + rawTierId: 'g1-pro-tier', + rawTierLabel: 'Pro', + source: 'runtime_api', + confidence: 'high', + accessState: 'entitled', + capacityState: 'available', + lastVerifiedAt: Date.now(), + notes: null, + }, lastUpdated: Date.now(), ...overrides, }; @@ -54,6 +65,8 @@ describe('QuotaTooltipContent', () => { expect(screen.getByText('Tier')).toBeInTheDocument(); expect(screen.getByText('Pro')).toBeInTheDocument(); + expect(screen.getByText('Tier ID')).toBeInTheDocument(); + expect(screen.getByText('g1-pro-tier')).toBeInTheDocument(); expect(screen.getByText('Credits')).toBeInTheDocument(); expect(screen.getByText('12')).toBeInTheDocument(); expect(screen.getByText('Gemini Flash Lite Series')).toBeInTheDocument(); From f9c61e0faceae2532798bcfe7ff87d458024c6a3 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 13:18:18 -0400 Subject: [PATCH 23/54] docs(cliproxy): clarify gemini and agy tier semantics --- docs/project-overview-pdr.md | 7 ++++--- docs/project-roadmap.md | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index d11bec1d..14c87e4e 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -126,9 +126,10 @@ CCS provides: - Keep `round-robin` as the default until the user explicitly changes it - Never infer routing strategy from account count, tier mix, or paused/default account state - Auto-failover when account exhausted -- Tier detection: free/paid/unknown +- Tier detection: free/pro/ultra/unknown +- Distinguish entitlement failures from temporary capacity exhaustion - Pre-flight quota checks before session start -- Dashboard UI with pause/resume toggles and tier badges +- Dashboard UI with pause/resume toggles, tier badges, and quota-detail guidance ### FR-010: Docker Deployment - Multi-stage Dockerfile with bun 1.2.21 and node:20-bookworm-slim @@ -279,7 +280,7 @@ CCS provides: ### v7.14 Release (Complete) - [x] Hybrid quota management with auto-failover - [x] `ccs cliproxy pause/resume/status` commands -- [x] API tier detection (free/paid/unknown) +- [x] API tier detection (free/pro/ultra/unknown) - [x] Dashboard pause/resume toggles and tier badges - [x] Pre-flight quota checks before session start diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 6ae4fc8e..82c63df6 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -1,6 +1,6 @@ # CCS Project Roadmap -Last Updated: 2026-04-09 +Last Updated: 2026-04-10 Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans. @@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes +- **2026-04-10**: **#945** CCS now normalizes Gemini CLI and Antigravity tier signals around an explicit `free / pro / ultra / unknown` model, preserves raw tier ids such as `g1-pro-tier`, enriches Gemini quota responses with provider entitlement evidence, classifies `MODEL_CAPACITY_EXHAUSTED` separately from auth/entitlement failures, fixes the Antigravity CLI quota table so live quota-derived tiers no longer collapse back to stale `unknown`, adds Gemini tier ids to CLI quota output, extends Gemini Flash Lite grouping to cover `gemini-3.1-flash-lite-preview`, and allows Gemini account surfaces to render the same tier badge semantics as Antigravity. - **2026-04-09**: **#938** Cliproxy model routing now exposes backend-pinned short prefixes for overlapping OAuth backends. CCS repairs managed OAuth auth-file prefixes for Gemini CLI (`gcli`) and Antigravity (`agy`), enriches `/api/cliproxy/catalog` with routing hints that show whether an unprefixed model is safe, shadowed, or prefix-only, upgrades `ccs cliproxy catalog` plus interactive variant model pickers to surface the pinned names, and updates the `ccs config` Cliproxy model selection UI so users can see the preferred call name and current effective backend before saving settings. - **2026-04-08**: **#931** `/cliproxy` model pickers now source their provider catalogs from CLIProxy management model definitions instead of treating the UI catalog file as the dropdown source of truth. CCS now refreshes live model definitions for Gemini, Codex, Claude, Antigravity, Qwen, iFlow, Kiro, GitHub Copilot, and Kimi through `/api/cliproxy/catalog`, overlays CCS-only preset/default metadata on top of those upstream models, keeps `/api/cliproxy/models` as the live availability feed, and falls back to cached/static catalogs when the proxy is unavailable so the dashboard never goes blank. - **2026-04-08**: **#929** Image Analysis hardening now makes the managed `ccs-image-analysis` MCP path authoritative on healthy Claude-target launches, suppresses stale CCS-managed image `Read` hooks instead of letting them compete with MCP, keeps the legacy hook available only as compatibility fallback when MCP provisioning fails, and extends self-heal to dashboard provisioning plus `ccs doctor --fix` so stale hook files and missing isolated MCP sync are repaired automatically. From 7330fdbf2830911bcac09826cabf5929cc741be8 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 13:24:00 -0400 Subject: [PATCH 24/54] fix(cliproxy): prefer live entitlement tier in account badges --- .../account/shared/account-surface-card.tsx | 28 ++++++++--- .../shared/account-surface-card.test.tsx | 49 +++++++++++++++++++ 2 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 ui/tests/unit/ui/components/account/shared/account-surface-card.test.tsx diff --git a/ui/src/components/account/shared/account-surface-card.tsx b/ui/src/components/account/shared/account-surface-card.tsx index 3d91d71f..783f3054 100644 --- a/ui/src/components/account/shared/account-surface-card.tsx +++ b/ui/src/components/account/shared/account-surface-card.tsx @@ -59,6 +59,19 @@ function getCompactAudienceBadgeLabel(audience: 'business' | 'personal' | 'unkno return '?'; } +function resolveEffectiveTier( + tier: AccountTier | undefined, + quota: UnifiedQuotaResult | undefined +): AccountTier | undefined { + if (quota && 'entitlement' in quota) { + const entitlementTier = quota.entitlement?.normalizedTier; + if (entitlementTier && entitlementTier !== 'unknown') { + return entitlementTier; + } + } + return tier; +} + export function AccountSurfaceCard({ mode, provider, @@ -85,13 +98,14 @@ export function AccountSurfaceCard({ const identity = getAccountIdentityPresentation(accountId, email, tokenFile); const title = displayEmail || identity.email || accountId; const normalizedProvider = provider.toLowerCase(); + const effectiveTier = resolveEffectiveTier(tier, quota); const showTierBadge = (normalizedProvider === 'agy' || normalizedProvider === 'antigravity' || normalizedProvider === 'gemini') && - tier && - tier !== 'unknown' && - tier !== 'free'; + effectiveTier && + effectiveTier !== 'unknown' && + effectiveTier !== 'free'; const isCompact = mode === 'compact'; const defaultCompactMetaBadges = ( <> @@ -99,10 +113,10 @@ export function AccountSurfaceCard({ - {tier} + {effectiveTier} )} {identity.audienceLabel && ( @@ -145,12 +159,12 @@ export function AccountSurfaceCard({ - {tier === 'ultra' ? 'U' : 'P'} + {effectiveTier === 'ultra' ? 'U' : 'P'} )}
diff --git a/ui/tests/unit/ui/components/account/shared/account-surface-card.test.tsx b/ui/tests/unit/ui/components/account/shared/account-surface-card.test.tsx new file mode 100644 index 00000000..6a0a823e --- /dev/null +++ b/ui/tests/unit/ui/components/account/shared/account-surface-card.test.tsx @@ -0,0 +1,49 @@ +import { render, screen } from '@tests/setup/test-utils'; +import { describe, expect, it } from 'vitest'; +import { AccountSurfaceCard } from '@/components/account/shared/account-surface-card'; +import type { GeminiCliQuotaResult } from '@/lib/api-client'; + +function createGeminiQuotaResult( + overrides: Partial = {} +): GeminiCliQuotaResult { + return { + success: true, + buckets: [], + projectId: 'project-123', + tierLabel: 'Pro', + tierId: 'g1-pro-tier', + creditBalance: 12, + entitlement: { + normalizedTier: 'pro', + rawTierId: 'g1-pro-tier', + rawTierLabel: 'Pro', + source: 'runtime_api', + confidence: 'high', + accessState: 'entitled', + capacityState: 'available', + lastVerifiedAt: Date.now(), + notes: null, + }, + lastUpdated: Date.now(), + ...overrides, + }; +} + +describe('AccountSurfaceCard', () => { + it('prefers live quota entitlement tier over a stale account tier for Gemini badges', () => { + render( + + ); + + expect(screen.getByText('pro')).toBeInTheDocument(); + }); +}); From 7c4545eb1b35914a20762e14fdbd7832828b1324 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 13:42:51 -0400 Subject: [PATCH 25/54] fix(cliproxy): attach entitlement evidence to agy failures --- src/cliproxy/quota-fetcher.ts | 71 ++++++++++++++++++- .../quota-fetcher-antigravity-failure.test.ts | 31 ++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 tests/unit/cliproxy/quota-fetcher-antigravity-failure.test.ts diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index 06765c58..551c0889 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -185,6 +185,7 @@ interface ProjectLookupResult { tier?: AccountTier; rawTierId?: string | null; rawTierLabel?: string | null; + entitlement?: ProviderEntitlementEvidence; error?: string; errorCode?: string; errorDetail?: string; @@ -219,7 +220,14 @@ function buildAntigravityFailure( bodyText?: string ): Pick< QuotaResult, - 'error' | 'errorCode' | 'errorDetail' | 'actionHint' | 'retryable' | 'httpStatus' | 'needsReauth' + | 'error' + | 'errorCode' + | 'errorDetail' + | 'actionHint' + | 'retryable' + | 'httpStatus' + | 'needsReauth' + | 'entitlement' > & { isForbidden?: boolean } { const detail = normalizeErrorDetail(bodyText || ''); @@ -232,6 +240,13 @@ function buildAntigravityFailure( 'Re-authenticate this account. If CLIProxy is running, retry after the proxy finishes refreshing the token.', needsReauth: true, errorDetail: detail, + entitlement: buildProviderEntitlementEvidence({ + normalizedTier: 'unknown', + source: 'runtime_inference', + confidence: 'medium', + accessState: 'unknown', + capacityState: 'unknown', + }), }; } @@ -243,6 +258,13 @@ function buildAntigravityFailure( actionHint: 'This account does not have Gemini Code Assist quota access.', isForbidden: true, errorDetail: detail, + entitlement: buildProviderEntitlementEvidence({ + normalizedTier: 'unknown', + source: 'runtime_inference', + confidence: 'medium', + accessState: 'not_entitled', + capacityState: 'unknown', + }), }; } @@ -254,6 +276,13 @@ function buildAntigravityFailure( actionHint: 'Retry later. This looks temporary.', retryable: true, errorDetail: detail, + entitlement: buildProviderEntitlementEvidence({ + normalizedTier: 'unknown', + source: 'runtime_inference', + confidence: 'low', + accessState: 'unknown', + capacityState: 'rate_limited', + }), }; } @@ -265,6 +294,13 @@ function buildAntigravityFailure( actionHint: 'Retry later. This looks temporary.', retryable: true, errorDetail: detail, + entitlement: buildProviderEntitlementEvidence({ + normalizedTier: 'unknown', + source: 'runtime_inference', + confidence: 'low', + accessState: 'unknown', + capacityState: 'temporarily_unavailable', + }), }; } @@ -276,6 +312,13 @@ function buildAntigravityFailure( actionHint: 'Retry later. The provider appears unavailable.', retryable: true, errorDetail: detail, + entitlement: buildProviderEntitlementEvidence({ + normalizedTier: 'unknown', + source: 'runtime_inference', + confidence: 'low', + accessState: 'unknown', + capacityState: 'temporarily_unavailable', + }), }; } @@ -285,6 +328,13 @@ function buildAntigravityFailure( error: `API error: ${status}`, errorCode: 'quota_request_failed', errorDetail: detail, + entitlement: buildProviderEntitlementEvidence({ + normalizedTier: 'unknown', + source: 'runtime_inference', + confidence: 'low', + accessState: 'unknown', + capacityState: 'unknown', + }), }; } @@ -292,6 +342,13 @@ function buildAntigravityFailure( error: 'Quota request failed', errorCode: 'quota_request_failed', errorDetail: detail, + entitlement: buildProviderEntitlementEvidence({ + normalizedTier: 'unknown', + source: 'runtime_inference', + confidence: 'low', + accessState: 'unknown', + capacityState: 'unknown', + }), }; } @@ -581,6 +638,14 @@ async function getProjectId(accountId: string, accessToken: string): Promise { + it('marks 403 failures as not entitled', async () => { + const { buildAntigravityFailure } = await loadAntigravityQuotaTestExports(); + + const result = buildAntigravityFailure(403, 'forbidden'); + + expect(result.entitlement).toMatchObject({ + accessState: 'not_entitled', + capacityState: 'unknown', + }); + }); + + it('marks 429 failures as rate limited', async () => { + const { buildAntigravityFailure } = await loadAntigravityQuotaTestExports(); + + const result = buildAntigravityFailure(429, 'rate limited'); + + expect(result.entitlement).toMatchObject({ + accessState: 'unknown', + capacityState: 'rate_limited', + }); + }); +}); From 506b61eca61381b8717d16b97be94b193e9fcbbf Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 14:19:02 -0400 Subject: [PATCH 26/54] fix(cliproxy): propagate agy entitlement failure metadata --- src/cliproxy/quota-fetcher.ts | 9 ++++ .../quota-fetcher-antigravity-failure.test.ts | 54 +++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index 551c0889..06e7c392 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -698,6 +698,14 @@ async function fetchAvailableModels( error: 'Invalid quota response from provider', errorCode: 'provider_unavailable', retryable: true, + entitlement: buildProviderEntitlementEvidence({ + normalizedTier: 'unknown', + source: 'runtime_inference', + confidence: 'low', + accessState: 'unknown', + capacityState: 'temporarily_unavailable', + notes: 'Provider returned a 2xx response with an empty or invalid quota payload.', + }), }; } @@ -814,6 +822,7 @@ export async function fetchAccountQuota( httpStatus: lastProjectResult.httpStatus, needsReauth: lastProjectResult.needsReauth, isUnprovisioned: lastProjectResult.isUnprovisioned, + entitlement: lastProjectResult.entitlement, isExpired: authData.isExpired, expiresAt: authData.expiresAt || undefined, }; diff --git a/tests/unit/cliproxy/quota-fetcher-antigravity-failure.test.ts b/tests/unit/cliproxy/quota-fetcher-antigravity-failure.test.ts index 6f79048a..817e7f3a 100644 --- a/tests/unit/cliproxy/quota-fetcher-antigravity-failure.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-antigravity-failure.test.ts @@ -28,4 +28,58 @@ describe('Antigravity quota failure metadata', () => { capacityState: 'rate_limited', }); }); + + it('preserves entitlement evidence when project lookup fails before quota fetch', async () => { + const moduleId = Date.now() + Math.random(); + const { fetchAccountQuota } = await import(`../../../src/cliproxy/quota-fetcher?agy-early=${moduleId}`); + const { getProviderAuthDir } = await import( + `../../../src/cliproxy/config-generator?agy-config=${moduleId}` + ); + const fs = await import('node:fs'); + const os = await import('node:os'); + const path = await import('node:path'); + + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-agy-failure-')); + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + + try { + const authDir = getProviderAuthDir('agy'); + fs.mkdirSync(authDir, { recursive: true }); + fs.writeFileSync( + path.join(authDir, 'antigravity-user@example.com.json'), + JSON.stringify({ + type: 'antigravity', + email: 'user@example.com', + project_id: 'project-x', + access_token: 'token', + }) + ); + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response(JSON.stringify({ error: { message: 'forbidden' } }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + })) as typeof fetch; + + try { + const result = await fetchAccountQuota('agy', 'user@example.com'); + expect(result.success).toBe(false); + expect(result.entitlement).toMatchObject({ + accessState: 'not_entitled', + capacityState: 'unknown', + }); + } finally { + globalThis.fetch = originalFetch; + } + } finally { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); }); From b56b58c0679d888baa7daa7526f8c06f2a49e16c Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 14:28:32 -0400 Subject: [PATCH 27/54] fix(cliproxy): preserve entitlement evidence on agy failures --- src/cliproxy/quota-fetcher.ts | 36 +++++ .../quota-fetcher-antigravity-failure.test.ts | 130 ++++++++++++++++++ ui/src/lib/api-client.ts | 24 +--- 3 files changed, 168 insertions(+), 22 deletions(-) diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index 06e7c392..dbe30a75 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -352,6 +352,28 @@ function buildAntigravityFailure( }; } +function mergeAntigravityTierEvidence( + entitlement: ProviderEntitlementEvidence | undefined, + tier: AccountTier, + rawTierId: string | null, + rawTierLabel: string | null +): ProviderEntitlementEvidence | undefined { + if (tier === 'unknown' && !entitlement) { + return undefined; + } + + return buildProviderEntitlementEvidence({ + normalizedTier: tier, + rawTierId, + rawTierLabel, + source: rawTierId ? 'runtime_api' : (entitlement?.source ?? 'runtime_inference'), + confidence: rawTierId ? 'high' : (entitlement?.confidence ?? 'medium'), + accessState: entitlement?.accessState ?? 'unknown', + capacityState: entitlement?.capacityState ?? 'unknown', + notes: entitlement?.notes ?? null, + }); +} + async function readManagedResponse( response: Response, viaManagement: boolean @@ -620,6 +642,14 @@ async function getProjectId(accountId: string, accessToken: string): Promise { fs.rmSync(tempHome, { recursive: true, force: true }); } }); + + it('attaches entitlement evidence when project lookup returns an invalid 2xx payload', async () => { + const moduleId = Date.now() + Math.random(); + const { fetchAccountQuota } = await import( + `../../../src/cliproxy/quota-fetcher?agy-invalid-project=${moduleId}` + ); + const { getProviderAuthDir } = await import( + `../../../src/cliproxy/config-generator?agy-config=${moduleId}` + ); + const fs = await import('node:fs'); + const os = await import('node:os'); + const path = await import('node:path'); + + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-agy-invalid-project-')); + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + + try { + const authDir = getProviderAuthDir('agy'); + fs.mkdirSync(authDir, { recursive: true }); + fs.writeFileSync( + path.join(authDir, 'antigravity-user@example.com.json'), + JSON.stringify({ + type: 'antigravity', + email: 'user@example.com', + access_token: 'token', + }) + ); + + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => + new Response('', { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) as typeof fetch; + + try { + const result = await fetchAccountQuota('agy', 'user@example.com'); + expect(result.success).toBe(false); + expect(result.errorCode).toBe('provider_unavailable'); + expect(result.entitlement).toMatchObject({ + accessState: 'unknown', + capacityState: 'temporarily_unavailable', + }); + } finally { + globalThis.fetch = originalFetch; + } + } finally { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('preserves live tier evidence when quota fetch fails after a successful project lookup', async () => { + const moduleId = Date.now() + Math.random(); + const { fetchAccountQuota } = await import( + `../../../src/cliproxy/quota-fetcher?agy-invalid-models=${moduleId}` + ); + const { getProviderAuthDir } = await import( + `../../../src/cliproxy/config-generator?agy-config=${moduleId}` + ); + const fs = await import('node:fs'); + const os = await import('node:os'); + const path = await import('node:path'); + + const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-agy-invalid-models-')); + const originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + + try { + const authDir = getProviderAuthDir('agy'); + fs.mkdirSync(authDir, { recursive: true }); + fs.writeFileSync( + path.join(authDir, 'antigravity-user@example.com.json'), + JSON.stringify({ + type: 'antigravity', + email: 'user@example.com', + access_token: 'token', + }) + ); + + const originalFetch = globalThis.fetch; + let requestCount = 0; + globalThis.fetch = (async () => { + requestCount += 1; + if (requestCount === 1) { + return new Response( + JSON.stringify({ + cloudaicompanionProject: { id: 'project-x' }, + paidTier: { id: 'g1-pro-tier' }, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ); + } + + return new Response('', { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as typeof fetch; + + try { + const result = await fetchAccountQuota('agy', 'user@example.com'); + expect(result.success).toBe(false); + expect(result.entitlement).toMatchObject({ + normalizedTier: 'pro', + rawTierId: 'g1-pro-tier', + rawTierLabel: 'Pro', + accessState: 'unknown', + capacityState: 'temporarily_unavailable', + }); + } finally { + globalThis.fetch = originalFetch; + } + } finally { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); }); diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 6a0e1bc4..a4fe2ed8 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -9,9 +9,11 @@ import type { ListAiProvidersResult, UpsertAiProviderEntryInput, } from '../../../src/cliproxy/ai-providers'; +import type { ProviderEntitlementEvidence } from '../../../src/cliproxy/provider-entitlement-types'; export const API_BASE_URL = '/api'; export const API_CONFLICT_ERROR_CODE = 'CONFLICT'; +export type { ProviderEntitlementEvidence }; export class ApiConflictError extends Error { readonly code = API_CONFLICT_ERROR_CODE; @@ -566,28 +568,6 @@ export interface QuotaResult { entitlement?: ProviderEntitlementEvidence; } -export interface ProviderEntitlementEvidence { - normalizedTier: 'free' | 'pro' | 'ultra' | 'unknown'; - rawTierId: string | null; - rawTierLabel: string | null; - source: 'runtime_api' | 'runtime_inference' | 'registry_cache' | 'official_docs'; - confidence: 'high' | 'medium' | 'low'; - accessState: - | 'entitled' - | 'not_entitled' - | 'capacity_exhausted' - | 'temporarily_unavailable' - | 'unknown'; - capacityState: - | 'available' - | 'capacity_exhausted' - | 'rate_limited' - | 'temporarily_unavailable' - | 'unknown'; - lastVerifiedAt: number; - notes?: string | null; -} - /** Codex rate limit window */ export interface CodexQuotaWindow { /** Window label: "Primary", "Secondary", "Code Review (Primary)", "Code Review (Secondary)" */ From 92a24786d674ca647caa44db9ee2ce638b843b55 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 14:32:45 -0400 Subject: [PATCH 28/54] fix(cliproxy): use type-only account provider import --- src/cliproxy/accounts/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cliproxy/accounts/types.ts b/src/cliproxy/accounts/types.ts index d93b20c4..3d098f54 100644 --- a/src/cliproxy/accounts/types.ts +++ b/src/cliproxy/accounts/types.ts @@ -2,7 +2,7 @@ * Shared types and constants for account management */ -import { CLIProxyProvider } from '../types'; +import type { CLIProxyProvider } from '../types'; /** Account tier for quota management: ultra > pro > free */ export type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown'; From 270810c06e1f3f3cc7f41c17c0bf74f5d37c8134 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 10 Apr 2026 18:52:54 +0000 Subject: [PATCH 29/54] chore(release): 7.67.0-dev.5 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b876022c..337094a5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.67.0-dev.4", + "version": "7.67.0-dev.5", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 2f02ddfb74062aefa4d006ca16bec9a94a2272c7 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 17:21:26 -0400 Subject: [PATCH 30/54] fix(image-analysis): tolerate missing auth init dependency --- docs/project-roadmap.md | 1 + .../hooks/image-analysis-runtime-status.ts | 4 +++- .../image-analysis-runtime-status.test.ts | 19 +++++++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 82c63df6..7a4ebde8 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes +- **2026-04-10**: **#944** Image Analysis auth readiness no longer collapses to native Read when a partial runtime-status dependency override omits `initializeAccounts`. CCS now treats account-registry warm-up as optional in that path, still reads token-backed auth status directly, and includes regression coverage for the missing-initializer case so Kimi-backed sessions do not fail on `deps.initializeAccounts is not a function`. - **2026-04-10**: **#945** CCS now normalizes Gemini CLI and Antigravity tier signals around an explicit `free / pro / ultra / unknown` model, preserves raw tier ids such as `g1-pro-tier`, enriches Gemini quota responses with provider entitlement evidence, classifies `MODEL_CAPACITY_EXHAUSTED` separately from auth/entitlement failures, fixes the Antigravity CLI quota table so live quota-derived tiers no longer collapse back to stale `unknown`, adds Gemini tier ids to CLI quota output, extends Gemini Flash Lite grouping to cover `gemini-3.1-flash-lite-preview`, and allows Gemini account surfaces to render the same tier badge semantics as Antigravity. - **2026-04-09**: **#938** Cliproxy model routing now exposes backend-pinned short prefixes for overlapping OAuth backends. CCS repairs managed OAuth auth-file prefixes for Gemini CLI (`gcli`) and Antigravity (`agy`), enriches `/api/cliproxy/catalog` with routing hints that show whether an unprefixed model is safe, shadowed, or prefix-only, upgrades `ccs cliproxy catalog` plus interactive variant model pickers to surface the pinned names, and updates the `ccs config` Cliproxy model selection UI so users can see the preferred call name and current effective backend before saving settings. - **2026-04-08**: **#931** `/cliproxy` model pickers now source their provider catalogs from CLIProxy management model definitions instead of treating the UI catalog file as the dropdown source of truth. CCS now refreshes live model definitions for Gemini, Codex, Claude, Antigravity, Qwen, iFlow, Kiro, GitHub Copilot, and Kimi through `/api/cliproxy/catalog`, overlays CCS-only preset/default metadata on top of those upstream models, keeps `/api/cliproxy/models` as the live availability feed, and falls back to cached/static catalogs when the proxy is unavailable so the dashboard never goes blank. diff --git a/src/utils/hooks/image-analysis-runtime-status.ts b/src/utils/hooks/image-analysis-runtime-status.ts index 9ec8a388..8d40532f 100644 --- a/src/utils/hooks/image-analysis-runtime-status.ts +++ b/src/utils/hooks/image-analysis-runtime-status.ts @@ -64,7 +64,9 @@ async function resolveAuthReadiness( (entry) => entry.provider === authProvider && entry.authenticated ); } else { - deps.initializeAccounts(); + if (typeof deps.initializeAccounts === 'function') { + deps.initializeAccounts(); + } authenticated = deps.getAuthStatus(authProvider).authenticated; } diff --git a/tests/unit/utils/hooks/image-analysis-runtime-status.test.ts b/tests/unit/utils/hooks/image-analysis-runtime-status.test.ts index ccb02d26..d314eb7a 100644 --- a/tests/unit/utils/hooks/image-analysis-runtime-status.test.ts +++ b/tests/unit/utils/hooks/image-analysis-runtime-status.test.ts @@ -88,6 +88,25 @@ describe('image-analysis-runtime-status', () => { expect(status.effectiveRuntimeMode).toBe('cliproxy-image-analysis'); }); + it('uses getAuthStatus when initializeAccounts is omitted from an override', async () => { + const status = await hydrateImageAnalysisRuntimeStatus(createStatus(), { + getProxyTarget: () => ({ host: '127.0.0.1', port: 8317, protocol: 'http', isRemote: false }), + initializeAccounts: undefined as unknown as () => void, + getAuthStatus: () => ({ + provider: 'ghcp', + authenticated: true, + tokenDir: '/tmp/auth', + tokenFiles: ['github-copilot-test.json'], + accounts: [], + defaultAccount: undefined, + }), + isCliproxyRunning: async () => true, + }); + + expect(status.authReadiness).toBe('ready'); + expect(status.effectiveRuntimeMode).toBe('cliproxy-image-analysis'); + }); + it('treats an unreachable remote proxy as unavailable', async () => { const status = await hydrateImageAnalysisRuntimeStatus(createStatus(), { getProxyTarget: () => ({ From 49c4338f28f5b22ea8834d103582eccd2ad54c62 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 17:28:18 -0400 Subject: [PATCH 31/54] fix(ui): preserve requested provider model aliases - keep requested=upstream semantics stable in the AI Providers editors - preview the client-visible ANTHROPIC_MODEL instead of the upstream target - add UI coverage for alias parsing and formatting helpers Refs #941 --- .../ai-providers/provider-entry-dialog.tsx | 27 +++------- ui/src/lib/provider-config.ts | 54 ++++++++++++++++++- ui/src/pages/cliproxy-ai-providers.tsx | 30 ++++------- ui/tests/unit/ui/lib/provider-config.test.ts | 36 +++++++++++++ 4 files changed, 105 insertions(+), 42 deletions(-) create mode 100644 ui/tests/unit/ui/lib/provider-config.test.ts diff --git a/ui/src/components/cliproxy/ai-providers/provider-entry-dialog.tsx b/ui/src/components/cliproxy/ai-providers/provider-entry-dialog.tsx index d9d602d7..e6b54c0b 100644 --- a/ui/src/components/cliproxy/ai-providers/provider-entry-dialog.tsx +++ b/ui/src/components/cliproxy/ai-providers/provider-entry-dialog.tsx @@ -1,6 +1,10 @@ import { useMemo, useState } from 'react'; import { ProviderLogo } from '@/components/cliproxy/provider-logo'; -import { getAiProviderFamilyVisual } from '@/lib/provider-config'; +import { + formatRequestedUpstreamModelRules, + getAiProviderFamilyVisual, + parseRequestedUpstreamModelRules, +} from '@/lib/provider-config'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; @@ -192,22 +196,7 @@ function parseKeyValueLines(value: string): Array<{ key: string; value: string } } function parseModelAliasLines(value: string) { - return value - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0) - .map((line) => { - const separatorIndex = line.indexOf('='); - if (separatorIndex === -1) { - return { name: line.trim(), alias: '' }; - } - - return { - name: line.slice(0, separatorIndex).trim(), - alias: line.slice(separatorIndex + 1).trim(), - }; - }) - .filter((item) => item.name.length > 0 || item.alias.length > 0); + return parseRequestedUpstreamModelRules(value); } function TextArea({ @@ -241,9 +230,7 @@ function formatExcludedModels(entry?: AiProviderEntryView | null): string { } function formatModelAliases(entry?: AiProviderEntryView | null): string { - return (entry?.models || []) - .map((item) => (item.alias.trim() ? `${item.name}=${item.alias}` : item.name)) - .join('\n'); + return formatRequestedUpstreamModelRules(entry?.models); } function ChecklistCard({ diff --git a/ui/src/lib/provider-config.ts b/ui/src/lib/provider-config.ts index 516b85e8..554441f7 100644 --- a/ui/src/lib/provider-config.ts +++ b/ui/src/lib/provider-config.ts @@ -9,7 +9,7 @@ import { PROVIDER_CAPABILITIES, getProvidersByOAuthFlow, } from '../../../src/cliproxy/provider-capabilities'; -import type { AiProviderFamilyId } from '../../../src/cliproxy/ai-providers'; +import type { AiProviderFamilyId, AiProviderModelAlias } from '../../../src/cliproxy/ai-providers'; // Monorepo contract: UI consumes provider capability constants directly from backend // to enforce one source of truth and prevent provider drift across surfaces. @@ -115,6 +115,58 @@ export function getAiProviderFamilyVisual(familyId: AiProviderFamilyId): Provide } } +/** + * Parse UI model rules that use the requested=upstream convention into the + * provider config shape where `name` is upstream and `alias` is client-visible. + */ +export function parseRequestedUpstreamModelRules(value: string): AiProviderModelAlias[] { + return value + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => { + const separatorIndex = line.indexOf('='); + if (separatorIndex === -1) { + return { name: line.trim(), alias: '' }; + } + + const requested = line.slice(0, separatorIndex).trim(); + const upstream = line.slice(separatorIndex + 1).trim(); + if (!upstream) { + return { name: requested, alias: '' }; + } + + return { + name: upstream, + alias: requested, + }; + }) + .filter((item) => item.name.length > 0 || item.alias.length > 0); +} + +/** + * Format stored provider config back into the UI-facing requested=upstream form. + */ +export function formatRequestedUpstreamModelRules( + models: AiProviderModelAlias[] | null | undefined +): string { + return (models || []) + .map((item) => { + const requested = item.alias.trim(); + const upstream = item.name.trim(); + return requested ? `${requested}=${upstream}` : upstream; + }) + .join('\n'); +} + +/** + * Return the client-visible model ID for previews and generated settings. + */ +export function getRequestedModelId(model: AiProviderModelAlias): string { + const requested = model.alias.trim(); + return requested || model.name.trim(); +} + export function getProviderLogoAsset(provider: unknown): string | undefined { const normalized = normalizeProviderInput(provider); if (!isProviderVisualId(normalized)) { diff --git a/ui/src/pages/cliproxy-ai-providers.tsx b/ui/src/pages/cliproxy-ai-providers.tsx index b7a8c79e..300e35bb 100644 --- a/ui/src/pages/cliproxy-ai-providers.tsx +++ b/ui/src/pages/cliproxy-ai-providers.tsx @@ -17,7 +17,12 @@ import { Input } from '@/components/ui/input'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Skeleton } from '@/components/ui/skeleton'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { getAiProviderFamilyVisual } from '@/lib/provider-config'; +import { + formatRequestedUpstreamModelRules, + getAiProviderFamilyVisual, + getRequestedModelId, + parseRequestedUpstreamModelRules, +} from '@/lib/provider-config'; import { cn } from '@/lib/utils'; import { FamilyRail, ProviderEntryDialog } from '@/components/cliproxy/ai-providers'; import { @@ -339,22 +344,7 @@ function parseKeyValueLines(value: string): Array<{ key: string; value: string } } function parseModelAliasLines(value: string) { - return value - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.length > 0) - .map((line) => { - const separatorIndex = line.indexOf('='); - if (separatorIndex === -1) { - return { name: line.trim(), alias: '' }; - } - - return { - name: line.slice(0, separatorIndex).trim(), - alias: line.slice(separatorIndex + 1).trim(), - }; - }) - .filter((item) => item.name.length > 0 || item.alias.length > 0); + return parseRequestedUpstreamModelRules(value); } function formatHeaders(entry?: AiProviderEntryView | null): string { @@ -366,9 +356,7 @@ function formatExcludedModels(entry?: AiProviderEntryView | null): string { } function formatModelAliases(entry?: AiProviderEntryView | null): string { - return (entry?.models || []) - .map((item) => (item.alias.trim() ? `${item.name}=${item.alias}` : item.name)) - .join('\n'); + return formatRequestedUpstreamModelRules(entry?.models); } function buildEntryEditorDraft(entry: AiProviderEntryView): EntryEditorDraft { @@ -579,7 +567,7 @@ function buildSettingsPreview( item.name.trim() ); if (primaryModel?.name) { - env.ANTHROPIC_MODEL = primaryModel.name; + env.ANTHROPIC_MODEL = getRequestedModelId(primaryModel); } return { env }; diff --git a/ui/tests/unit/ui/lib/provider-config.test.ts b/ui/tests/unit/ui/lib/provider-config.test.ts new file mode 100644 index 00000000..21514da1 --- /dev/null +++ b/ui/tests/unit/ui/lib/provider-config.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; + +import { + formatRequestedUpstreamModelRules, + getRequestedModelId, + parseRequestedUpstreamModelRules, +} from '@/lib/provider-config'; + +describe('provider model mapping helpers', () => { + it('parses requested=upstream rules into stored upstream+alias pairs', () => { + expect( + parseRequestedUpstreamModelRules('claude-sonnet-4-5=gpt-4.1\nminimax/minimax-m2.7') + ).toEqual([ + { name: 'gpt-4.1', alias: 'claude-sonnet-4-5' }, + { name: 'minimax/minimax-m2.7', alias: '' }, + ]); + }); + + it('formats stored model rules back into requested=upstream text', () => { + expect( + formatRequestedUpstreamModelRules([ + { name: 'gpt-4.1', alias: 'claude-sonnet-4-5' }, + { name: 'minimax/minimax-m2.7', alias: '' }, + ]) + ).toBe('claude-sonnet-4-5=gpt-4.1\nminimax/minimax-m2.7'); + }); + + it('prefers the requested alias for generated settings previews', () => { + expect(getRequestedModelId({ name: 'gpt-4.1', alias: 'claude-sonnet-4-5' })).toBe( + 'claude-sonnet-4-5' + ); + expect(getRequestedModelId({ name: 'minimax/minimax-m2.7', alias: '' })).toBe( + 'minimax/minimax-m2.7' + ); + }); +}); From 83255f477197c9f74b472b36d9a0086030c10713 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 17:29:25 -0400 Subject: [PATCH 32/54] feat(api): add Hugging Face provider preset - add the huggingface preset and hf alias to the shared preset catalog - default the preset to Factory Droid because the documented path is generic chat completions - cover the CLI and dialog behavior with preset metadata and UI tests --- src/commands/api-command/create-command.ts | 9 +++++-- src/commands/api-command/help.ts | 3 +++ src/shared/provider-preset-catalog.ts | 18 ++++++++++++++ tests/unit/api/provider-presets.test.ts | 19 +++++++++++++++ .../profiles/profile-create-dialog.tsx | 1 + .../profiles/profile-create-dialog.test.tsx | 24 +++++++++++++++++++ ui/tests/unit/ui/lib/provider-presets.test.ts | 7 ++++++ 7 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/commands/api-command/create-command.ts b/src/commands/api-command/create-command.ts index ccdbe5e6..063acf55 100644 --- a/src/commands/api-command/create-command.ts +++ b/src/commands/api-command/create-command.ts @@ -265,12 +265,17 @@ async function resolveModelConfiguration( } async function resolveDefaultTarget( + preset: ProviderPreset | null, providedTarget: TargetType | undefined, yes: boolean | undefined ): Promise { if (providedTarget) { return providedTarget; } + if (preset?.defaultTarget) { + console.log(info(`Using preset default target: ${preset.defaultTarget}`)); + return preset.defaultTarget; + } if (yes) { return 'claude'; } @@ -349,7 +354,7 @@ export async function handleApiCreateCommand(args: string[]): Promise { parsedArgs.name, parsedArgs.yes ); - const target = await resolveDefaultTarget(parsedArgs.target, parsedArgs.yes); + const target = await resolveDefaultTarget(null, parsedArgs.target, parsedArgs.yes); if (name && apiProfileExists(name) && !parsedArgs.force) { console.log(fail(`API '${name}' already exists`)); @@ -464,7 +469,7 @@ export async function handleApiCreateCommand(args: string[]): Promise { const finalModels = hasClaudeMappings ? applyClaudeExtendedContextPreference(models, shouldEnableClaudeLongContext) : models; - const target = await resolveDefaultTarget(parsedArgs.target, parsedArgs.yes); + const target = await resolveDefaultTarget(preset, parsedArgs.target, parsedArgs.yes); if (parsedArgs.extendedContext !== undefined && !hasClaudeMappings) { console.log(''); diff --git a/src/commands/api-command/help.ts b/src/commands/api-command/help.ts index 78c6dcc4..000a1840 100644 --- a/src/commands/api-command/help.ts +++ b/src/commands/api-command/help.ts @@ -91,6 +91,9 @@ export async function showApiCommandHelp(writeLine: HelpWriter = console.log): P writeLine(` ${color('ccs api create --preset openrouter', 'command')}`); writeLine(` ${color('ccs api create --preset alibaba-coding-plan', 'command')}`); writeLine(` ${color('ccs api create --preset alibaba', 'command')} ${dim('# alias')}`); + writeLine( + ` ${color('ccs api create hf-router --preset hf', 'command')} ${dim('# defaults to droid for generic chat completions')}` + ); writeLine(` ${color('ccs api create --preset glm', 'command')}`); writeLine(''); writeLine(subheader('Claude Long Context')); diff --git a/src/shared/provider-preset-catalog.ts b/src/shared/provider-preset-catalog.ts index 0baa2270..fa39b1e8 100644 --- a/src/shared/provider-preset-catalog.ts +++ b/src/shared/provider-preset-catalog.ts @@ -6,10 +6,12 @@ */ export type PresetCategory = 'recommended' | 'alternative'; +export type ProviderPresetTarget = 'claude' | 'droid' | 'codex'; export const PROVIDER_PRESET_IDS = [ 'openrouter', 'alibaba-coding-plan', + 'huggingface', 'ollama', 'llamacpp', 'anthropic', @@ -36,6 +38,7 @@ export interface ProviderPresetDefinition { apiKeyHint: string; category: PresetCategory; requiresApiKey: boolean; + defaultTarget?: ProviderPresetTarget; /** Additional env vars for thinking mode, etc. */ extraEnv?: Record; /** Enable always thinking mode. */ @@ -57,6 +60,7 @@ export const PROVIDER_PRESET_ALIASES: Readonly> kimi: 'km', alibaba: 'alibaba-coding-plan', acp: 'alibaba-coding-plan', + hf: 'huggingface', }); const RAW_PROVIDER_PRESET_DEFINITIONS: readonly ProviderPresetDefinition[] = [ @@ -135,6 +139,20 @@ const RAW_PROVIDER_PRESET_DEFINITIONS: readonly ProviderPresetDefinition[] = [ featured: true, icon: '/assets/providers/claude.svg', }, + { + id: 'huggingface', + name: 'Hugging Face', + description: 'Inference Providers router via OpenAI-compatible chat completions', + baseUrl: 'https://router.huggingface.co/v1', + defaultProfileName: 'hf', + defaultModel: 'openai/gpt-oss-120b:fastest', + apiKeyPlaceholder: 'hf_...', + apiKeyHint: 'Create a User Access Token at hf.co/settings/tokens', + category: 'alternative', + requiresApiKey: true, + defaultTarget: 'droid', + badge: 'Router', + }, { id: 'glm', name: 'GLM', diff --git a/tests/unit/api/provider-presets.test.ts b/tests/unit/api/provider-presets.test.ts index 7a233f27..e672b496 100644 --- a/tests/unit/api/provider-presets.test.ts +++ b/tests/unit/api/provider-presets.test.ts @@ -73,6 +73,25 @@ describe('provider-presets', () => { expect(preset?.defaultProfileName).toBe('qwen-api'); }); + it('resolves Hugging Face preset metadata', () => { + const preset = getPresetById('huggingface'); + expect(preset?.id).toBe('huggingface'); + expect(preset?.baseUrl).toBe('https://router.huggingface.co/v1'); + expect(preset?.defaultProfileName).toBe('hf'); + expect(preset?.defaultModel).toBe('openai/gpt-oss-120b:fastest'); + expect(preset?.defaultTarget).toBe('droid'); + expect(preset?.apiKeyPlaceholder).toBe('hf_...'); + }); + + it('resolves hf alias to the Hugging Face preset', () => { + const preset = getPresetById('hf'); + expect(preset?.id).toBe('huggingface'); + }); + + it('treats hf alias as a valid preset id', () => { + expect(isValidPresetId('hf')).toBe(true); + }); + it('keeps Anthropic direct last in the recommended preset order and reuses the Claude logo', () => { const recommendedPresetIds = PROVIDER_PRESETS.filter( (preset) => preset.category === 'recommended' diff --git a/ui/src/components/profiles/profile-create-dialog.tsx b/ui/src/components/profiles/profile-create-dialog.tsx index eabf55f6..1ae37568 100644 --- a/ui/src/components/profiles/profile-create-dialog.tsx +++ b/ui/src/components/profiles/profile-create-dialog.tsx @@ -157,6 +157,7 @@ export function ProfileCreateDialog({ opusModel: preset.defaultModel, sonnetModel: preset.defaultModel, haikuModel: preset.defaultModel, + target: preset.defaultTarget ?? 'claude', }); }, [reset] diff --git a/ui/tests/unit/components/profiles/profile-create-dialog.test.tsx b/ui/tests/unit/components/profiles/profile-create-dialog.test.tsx index 2919fe84..258df382 100644 --- a/ui/tests/unit/components/profiles/profile-create-dialog.test.tsx +++ b/ui/tests/unit/components/profiles/profile-create-dialog.test.tsx @@ -47,6 +47,7 @@ describe('ProfileCreateDialog', () => { expect(screen.getByText('More Presets')).toBeInTheDocument(); expect(screen.getByText('Local Runtimes')).toBeInTheDocument(); expect(screen.getByText('Alibaba Coding Plan')).toBeVisible(); + expect(screen.getByText('Hugging Face')).toBeVisible(); expect(document.body.querySelectorAll('.overflow-x-auto')).toHaveLength(2); const customButton = screen.getByRole('button', { name: /Custom Endpoint/i }); @@ -74,4 +75,27 @@ describe('ProfileCreateDialog', () => { expect(screen.getByDisplayValue('http://localhost:11434')).toBeInTheDocument(); expect(screen.getByText('Local Runtimes')).toBeInTheDocument(); }); + + it('steers the Hugging Face preset to the droid target by default', async () => { + render( + + ); + + const huggingFaceButton = screen.getByText('Hugging Face').closest('button'); + expect(huggingFaceButton).not.toBeNull(); + if (!huggingFaceButton) { + throw new Error('Hugging Face preset button not found'); + } + + await userEvent.click(huggingFaceButton); + + expect(screen.getByDisplayValue('hf')).toBeInTheDocument(); + expect(screen.getByDisplayValue('https://router.huggingface.co/v1')).toBeInTheDocument(); + expect(screen.getByRole('combobox')).toHaveTextContent('Factory Droid'); + }); }); diff --git a/ui/tests/unit/ui/lib/provider-presets.test.ts b/ui/tests/unit/ui/lib/provider-presets.test.ts index bac35a94..356155f5 100644 --- a/ui/tests/unit/ui/lib/provider-presets.test.ts +++ b/ui/tests/unit/ui/lib/provider-presets.test.ts @@ -32,6 +32,13 @@ describe('provider preset metadata', () => { expect(getPresetById('glmt')?.id).toBe('glm'); }); + it('maps hf alias to the Hugging Face preset', () => { + const preset = getPresetById('hf'); + expect(preset?.id).toBe('huggingface'); + expect(preset?.baseUrl).toBe('https://router.huggingface.co/v1'); + expect(preset?.defaultTarget).toBe('droid'); + }); + it('uses the llama.cpp provider logo asset for the local llama.cpp preset', () => { expect(getPresetById('llamacpp')?.icon).toBe('/assets/providers/llama-cpp.svg'); }); From 414e8c80f5ed5f4ed91766de85603abaa195bcbf Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 17:29:46 -0400 Subject: [PATCH 33/54] docs(roadmap): record Hugging Face preset support --- docs/project-roadmap.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 82c63df6..d70ae8e2 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes +- **2026-04-10**: **#765** `/providers` now includes a first-class Hugging Face preset for API Profiles. CCS exposes Hugging Face Inference Providers through the existing OpenAI-compatible profile flow with the official router endpoint `https://router.huggingface.co/v1`, a short `hf` default profile name, and `hf` preset alias support for both the dashboard chooser and `ccs api create --preset hf`. - **2026-04-10**: **#945** CCS now normalizes Gemini CLI and Antigravity tier signals around an explicit `free / pro / ultra / unknown` model, preserves raw tier ids such as `g1-pro-tier`, enriches Gemini quota responses with provider entitlement evidence, classifies `MODEL_CAPACITY_EXHAUSTED` separately from auth/entitlement failures, fixes the Antigravity CLI quota table so live quota-derived tiers no longer collapse back to stale `unknown`, adds Gemini tier ids to CLI quota output, extends Gemini Flash Lite grouping to cover `gemini-3.1-flash-lite-preview`, and allows Gemini account surfaces to render the same tier badge semantics as Antigravity. - **2026-04-09**: **#938** Cliproxy model routing now exposes backend-pinned short prefixes for overlapping OAuth backends. CCS repairs managed OAuth auth-file prefixes for Gemini CLI (`gcli`) and Antigravity (`agy`), enriches `/api/cliproxy/catalog` with routing hints that show whether an unprefixed model is safe, shadowed, or prefix-only, upgrades `ccs cliproxy catalog` plus interactive variant model pickers to surface the pinned names, and updates the `ccs config` Cliproxy model selection UI so users can see the preferred call name and current effective backend before saving settings. - **2026-04-08**: **#931** `/cliproxy` model pickers now source their provider catalogs from CLIProxy management model definitions instead of treating the UI catalog file as the dropdown source of truth. CCS now refreshes live model definitions for Gemini, Codex, Claude, Antigravity, Qwen, iFlow, Kiro, GitHub Copilot, and Kimi through `/api/cliproxy/catalog`, overlays CCS-only preset/default metadata on top of those upstream models, keeps `/api/cliproxy/models` as the live availability feed, and falls back to cached/static catalogs when the proxy is unavailable so the dashboard never goes blank. From e090c8b0d4e65eda3c9b4dcdd4ef0521c96a68b9 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 17:40:27 -0400 Subject: [PATCH 34/54] fix(image-analysis): preserve default runtime deps --- docs/project-roadmap.md | 2 +- .../hooks/image-analysis-runtime-status.ts | 13 +++-- .../image-analysis-runtime-status.test.ts | 49 +++++++++++++------ 3 files changed, 44 insertions(+), 20 deletions(-) diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 7a4ebde8..b25ed9af 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -41,7 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes -- **2026-04-10**: **#944** Image Analysis auth readiness no longer collapses to native Read when a partial runtime-status dependency override omits `initializeAccounts`. CCS now treats account-registry warm-up as optional in that path, still reads token-backed auth status directly, and includes regression coverage for the missing-initializer case so Kimi-backed sessions do not fail on `deps.initializeAccounts is not a function`. +- **2026-04-10**: **#944** Image Analysis auth readiness no longer collapses to native Read when merged runtime-status dependency overrides include a missing initializer value. CCS now preserves default dependency functions when override entries are `undefined`, still reads token-backed auth status directly in the local readiness path, and includes regression coverage for the missing-initializer case that previously surfaced as `deps.initializeAccounts is not a function`. - **2026-04-10**: **#945** CCS now normalizes Gemini CLI and Antigravity tier signals around an explicit `free / pro / ultra / unknown` model, preserves raw tier ids such as `g1-pro-tier`, enriches Gemini quota responses with provider entitlement evidence, classifies `MODEL_CAPACITY_EXHAUSTED` separately from auth/entitlement failures, fixes the Antigravity CLI quota table so live quota-derived tiers no longer collapse back to stale `unknown`, adds Gemini tier ids to CLI quota output, extends Gemini Flash Lite grouping to cover `gemini-3.1-flash-lite-preview`, and allows Gemini account surfaces to render the same tier badge semantics as Antigravity. - **2026-04-09**: **#938** Cliproxy model routing now exposes backend-pinned short prefixes for overlapping OAuth backends. CCS repairs managed OAuth auth-file prefixes for Gemini CLI (`gcli`) and Antigravity (`agy`), enriches `/api/cliproxy/catalog` with routing hints that show whether an unprefixed model is safe, shadowed, or prefix-only, upgrades `ccs cliproxy catalog` plus interactive variant model pickers to surface the pinned names, and updates the `ccs config` Cliproxy model selection UI so users can see the preferred call name and current effective backend before saving settings. - **2026-04-08**: **#931** `/cliproxy` model pickers now source their provider catalogs from CLIProxy management model definitions instead of treating the UI catalog file as the dropdown source of truth. CCS now refreshes live model definitions for Gemini, Codex, Claude, Antigravity, Qwen, iFlow, Kiro, GitHub Copilot, and Kimi through `/api/cliproxy/catalog`, overlays CCS-only preset/default metadata on top of those upstream models, keeps `/api/cliproxy/models` as the live availability feed, and falls back to cached/static catalogs when the proxy is unavailable so the dashboard never goes blank. diff --git a/src/utils/hooks/image-analysis-runtime-status.ts b/src/utils/hooks/image-analysis-runtime-status.ts index 8d40532f..4322b9e5 100644 --- a/src/utils/hooks/image-analysis-runtime-status.ts +++ b/src/utils/hooks/image-analysis-runtime-status.ts @@ -37,6 +37,13 @@ const defaultDeps: ImageAnalysisRuntimeStatusDeps = { isCliproxyRunning: () => isCliproxyRunning(), }; +function mergeDefinedDeps(defaults: T, overrides: Partial): T { + const definedOverrides = Object.fromEntries( + Object.entries(overrides as Record).filter(([, value]) => value !== undefined) + ) as Partial; + return { ...defaults, ...definedOverrides }; +} + async function resolveAuthReadiness( status: ImageAnalysisStatus, deps: ImageAnalysisRuntimeStatusDeps @@ -64,9 +71,7 @@ async function resolveAuthReadiness( (entry) => entry.provider === authProvider && entry.authenticated ); } else { - if (typeof deps.initializeAccounts === 'function') { - deps.initializeAccounts(); - } + deps.initializeAccounts(); authenticated = deps.getAuthStatus(authProvider).authenticated; } @@ -162,7 +167,7 @@ export async function hydrateImageAnalysisRuntimeStatus( baseStatus: ImageAnalysisStatus, deps: Partial = {} ): Promise { - const resolvedDeps = { ...defaultDeps, ...deps }; + const resolvedDeps = mergeDefinedDeps(defaultDeps, deps); const authStatus = await resolveAuthReadiness(baseStatus, resolvedDeps); const proxyStatus = await resolveProxyReadiness(baseStatus, resolvedDeps); const mergedStatus = { diff --git a/tests/unit/utils/hooks/image-analysis-runtime-status.test.ts b/tests/unit/utils/hooks/image-analysis-runtime-status.test.ts index d314eb7a..e94d34cc 100644 --- a/tests/unit/utils/hooks/image-analysis-runtime-status.test.ts +++ b/tests/unit/utils/hooks/image-analysis-runtime-status.test.ts @@ -1,3 +1,6 @@ +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; import { describe, expect, it } from 'bun:test'; import { hydrateImageAnalysisRuntimeStatus } from '../../../../src/utils/hooks/image-analysis-runtime-status'; import type { ImageAnalysisStatus } from '../../../../src/utils/hooks/image-analysis-backend-resolver'; @@ -89,22 +92,38 @@ describe('image-analysis-runtime-status', () => { }); it('uses getAuthStatus when initializeAccounts is omitted from an override', async () => { - const status = await hydrateImageAnalysisRuntimeStatus(createStatus(), { - getProxyTarget: () => ({ host: '127.0.0.1', port: 8317, protocol: 'http', isRemote: false }), - initializeAccounts: undefined as unknown as () => void, - getAuthStatus: () => ({ - provider: 'ghcp', - authenticated: true, - tokenDir: '/tmp/auth', - tokenFiles: ['github-copilot-test.json'], - accounts: [], - defaultAccount: undefined, - }), - isCliproxyRunning: async () => true, - }); + const tempCcsHome = mkdtempSync(join(tmpdir(), 'ccs-image-analysis-runtime-status-')); + const originalCcsHome = process.env.CCS_HOME; + const originalCcsDir = process.env.CCS_DIR; + delete process.env.CCS_DIR; + process.env.CCS_HOME = tempCcsHome; - expect(status.authReadiness).toBe('ready'); - expect(status.effectiveRuntimeMode).toBe('cliproxy-image-analysis'); + try { + const status = await hydrateImageAnalysisRuntimeStatus(createStatus(), { + getProxyTarget: () => ({ host: '127.0.0.1', port: 8317, protocol: 'http', isRemote: false }), + initializeAccounts: undefined as unknown as () => void, + getAuthStatus: () => ({ + provider: 'ghcp', + authenticated: true, + tokenDir: '/tmp/auth', + tokenFiles: ['github-copilot-test.json'], + accounts: [], + defaultAccount: undefined, + }), + isCliproxyRunning: async () => true, + }); + + expect(status.authReadiness).toBe('ready'); + expect(status.effectiveRuntimeMode).toBe('cliproxy-image-analysis'); + } finally { + if (originalCcsHome === undefined) delete process.env.CCS_HOME; + else process.env.CCS_HOME = originalCcsHome; + + if (originalCcsDir === undefined) delete process.env.CCS_DIR; + else process.env.CCS_DIR = originalCcsDir; + + rmSync(tempCcsHome, { recursive: true, force: true }); + } }); it('treats an unreachable remote proxy as unavailable', async () => { From 92a769d773a8ec63fd02744aff60d3691df7cf94 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 17:44:15 -0400 Subject: [PATCH 35/54] fix(ui): harden provider model alias editing - keep raw JSON model mappings aligned with requested=upstream semantics - block malformed mapping lines instead of coercing them on save - render saved mappings as requested to upstream and add regression coverage Refs #941 --- .../ai-providers/provider-entry-card.tsx | 11 +++-- .../ai-providers/provider-entry-dialog.tsx | 18 +++++++- ui/src/lib/provider-config.ts | 17 +++++++ ui/src/pages/cliproxy-ai-providers.tsx | 44 ++++++++++--------- .../cliproxy/provider-entry-card.test.tsx | 41 +++++++++++++++++ ui/tests/unit/ui/lib/provider-config.test.ts | 8 ++++ 6 files changed, 114 insertions(+), 25 deletions(-) create mode 100644 ui/tests/unit/components/cliproxy/provider-entry-card.test.tsx diff --git a/ui/src/components/cliproxy/ai-providers/provider-entry-card.tsx b/ui/src/components/cliproxy/ai-providers/provider-entry-card.tsx index 7457caae..941ac32b 100644 --- a/ui/src/components/cliproxy/ai-providers/provider-entry-card.tsx +++ b/ui/src/components/cliproxy/ai-providers/provider-entry-card.tsx @@ -1,5 +1,6 @@ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { getRequestedModelId } from '@/lib/provider-config'; import { cn } from '@/lib/utils'; import type { AiProviderEntryView, @@ -241,9 +242,13 @@ export function ProviderEntryCard({ key={`${model.name}:${model.alias}`} className="rounded-md border bg-muted/20 px-3 py-2" > - {model.name} - → - {model.alias} + {getRequestedModelId(model)} + {model.alias.trim() ? ( + <> + → + {model.name} + + ) : null}
))}
diff --git a/ui/src/components/cliproxy/ai-providers/provider-entry-dialog.tsx b/ui/src/components/cliproxy/ai-providers/provider-entry-dialog.tsx index e6b54c0b..d3dfc74c 100644 --- a/ui/src/components/cliproxy/ai-providers/provider-entry-dialog.tsx +++ b/ui/src/components/cliproxy/ai-providers/provider-entry-dialog.tsx @@ -3,6 +3,7 @@ import { ProviderLogo } from '@/components/cliproxy/provider-logo'; import { formatRequestedUpstreamModelRules, getAiProviderFamilyVisual, + getRequestedUpstreamModelRuleErrors, parseRequestedUpstreamModelRules, } from '@/lib/provider-config'; import { Badge } from '@/components/ui/badge'; @@ -284,6 +285,10 @@ export function ProviderEntryDialog({ const [headers, setHeaders] = useState(() => formatHeaders(entry)); const [excludedModels, setExcludedModels] = useState(() => formatExcludedModels(entry)); const [modelAliases, setModelAliases] = useState(() => formatModelAliases(entry)); + const modelRuleErrors = useMemo( + () => getRequestedUpstreamModelRuleErrors(modelAliases), + [modelAliases] + ); const [advancedOpen, setAdvancedOpen] = useState(() => Boolean( entry?.headers.length || entry?.excludedModels.length || entry?.proxyUrl || entry?.prefix @@ -298,6 +303,10 @@ export function ProviderEntryDialog({ }, [entry?.secretConfigured, isEditing, supportsOpenAiCompat]); const handleSubmit = async () => { + if (modelRuleErrors.length > 0) { + return; + } + const nextApiKey = apiKey.trim(); const nextApiKeys = parseDelimitedLines(apiKeys); const preserveSecrets = @@ -459,6 +468,9 @@ export function ProviderEntryDialog({ placeholder={guide.aliasesPlaceholder} />

{guide.aliasesHelper}

+ {modelRuleErrors.length > 0 ? ( +

{modelRuleErrors[0]}

+ ) : null}
@@ -560,7 +572,11 @@ export function ProviderEntryDialog({ -
diff --git a/ui/tests/unit/components/cliproxy/provider-entry-card.test.tsx b/ui/tests/unit/components/cliproxy/provider-entry-card.test.tsx new file mode 100644 index 00000000..b447edc1 --- /dev/null +++ b/ui/tests/unit/components/cliproxy/provider-entry-card.test.tsx @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { ProviderEntryCard } from '@/components/cliproxy/ai-providers/provider-entry-card'; +import { render, screen } from '../../../setup/test-utils'; + +describe('ProviderEntryCard', () => { + it('renders mapped models as requested to upstream', () => { + render( + + ); + + expect(screen.getByText('claude-sonnet-4-5')).toBeInTheDocument(); + expect(screen.getByText('gpt-5')).toBeInTheDocument(); + expect(screen.getByText('→')).toBeInTheDocument(); + }); +}); diff --git a/ui/tests/unit/ui/lib/provider-config.test.ts b/ui/tests/unit/ui/lib/provider-config.test.ts index 21514da1..6d48a838 100644 --- a/ui/tests/unit/ui/lib/provider-config.test.ts +++ b/ui/tests/unit/ui/lib/provider-config.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { formatRequestedUpstreamModelRules, + getRequestedUpstreamModelRuleErrors, getRequestedModelId, parseRequestedUpstreamModelRules, } from '@/lib/provider-config'; @@ -33,4 +34,11 @@ describe('provider model mapping helpers', () => { 'minimax/minimax-m2.7' ); }); + + it('rejects malformed requested=upstream lines instead of coercing them', () => { + expect(getRequestedUpstreamModelRuleErrors('claude-sonnet-4-5=\n=gpt-5\nqwen3-coder')).toEqual([ + 'Line 1: use requested=upstream or a plain model name.', + 'Line 2: use requested=upstream or a plain model name.', + ]); + }); }); From 52ac29e7a8a6f7450eb3c1daa357d84d0b03031f Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 17:44:25 -0400 Subject: [PATCH 36/54] fix(api): constrain preset target defaults - limit preset default targets to persisted API profile targets - add regression coverage for preset target resolution and profile target persistence --- src/commands/api-command/create-command.ts | 25 +++++++--- src/shared/provider-preset-catalog.ts | 2 +- .../unit/api/profile-writer-anthropic.test.ts | 46 +++++++++++++++++++ .../api-create-target-resolution.test.ts | 17 +++++++ 4 files changed, 83 insertions(+), 7 deletions(-) create mode 100644 tests/unit/commands/api-create-target-resolution.test.ts diff --git a/src/commands/api-command/create-command.ts b/src/commands/api-command/create-command.ts index 063acf55..1909c2dc 100644 --- a/src/commands/api-command/create-command.ts +++ b/src/commands/api-command/create-command.ts @@ -269,12 +269,12 @@ async function resolveDefaultTarget( providedTarget: TargetType | undefined, yes: boolean | undefined ): Promise { - if (providedTarget) { - return providedTarget; - } - if (preset?.defaultTarget) { - console.log(info(`Using preset default target: ${preset.defaultTarget}`)); - return preset.defaultTarget; + const resolvedTarget = resolvePresetDefaultTarget(preset, providedTarget); + if (resolvedTarget) { + if (preset?.defaultTarget && !providedTarget) { + console.log(info(`Using preset default target: ${preset.defaultTarget}`)); + } + return resolvedTarget; } if (yes) { return 'claude'; @@ -287,6 +287,19 @@ async function resolveDefaultTarget( return useDroidByDefault ? 'droid' : 'claude'; } +export function resolvePresetDefaultTarget( + preset: Pick | null, + providedTarget: TargetType | undefined +): TargetType | null { + if (providedTarget) { + return providedTarget; + } + if (preset?.defaultTarget) { + return preset.defaultTarget; + } + return null; +} + async function resolveClaudeLongContextPreference( models: ModelMapping, explicitPreference: boolean | undefined, diff --git a/src/shared/provider-preset-catalog.ts b/src/shared/provider-preset-catalog.ts index fa39b1e8..b94fe6e8 100644 --- a/src/shared/provider-preset-catalog.ts +++ b/src/shared/provider-preset-catalog.ts @@ -6,7 +6,7 @@ */ export type PresetCategory = 'recommended' | 'alternative'; -export type ProviderPresetTarget = 'claude' | 'droid' | 'codex'; +export type ProviderPresetTarget = 'claude' | 'droid'; export const PROVIDER_PRESET_IDS = [ 'openrouter', diff --git a/tests/unit/api/profile-writer-anthropic.test.ts b/tests/unit/api/profile-writer-anthropic.test.ts index 6eab981b..0416c321 100644 --- a/tests/unit/api/profile-writer-anthropic.test.ts +++ b/tests/unit/api/profile-writer-anthropic.test.ts @@ -85,6 +85,52 @@ describe('profile-writer Anthropic direct', () => { expect(settings.env.ANTHROPIC_API_KEY).toBeUndefined(); }); + it('persists droid as the saved target for generic API profiles', () => { + const result = createApiProfile( + 'hf-target', + 'https://router.huggingface.co/v1', + 'hf_testkey123', + { + default: 'openai/gpt-oss-120b:fastest', + opus: 'openai/gpt-oss-120b:fastest', + sonnet: 'openai/gpt-oss-120b:fastest', + haiku: 'openai/gpt-oss-120b:fastest', + }, + 'droid' + ); + + expect(result.success).toBe(true); + + const configPath = path.join(tempHome, '.ccs', 'config.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + + expect(config.profiles['hf-target']).toBe('~/.ccs/hf-target.settings.json'); + expect(config.profile_targets['hf-target']).toBe('droid'); + }); + + it('does not persist a non-default target entry when the target is claude', () => { + const result = createApiProfile( + 'hf-target-claude', + 'https://router.huggingface.co/v1', + 'hf_testkey123', + { + default: 'openai/gpt-oss-120b:fastest', + opus: 'openai/gpt-oss-120b:fastest', + sonnet: 'openai/gpt-oss-120b:fastest', + haiku: 'openai/gpt-oss-120b:fastest', + }, + 'claude' + ); + + expect(result.success).toBe(true); + + const configPath = path.join(tempHome, '.ccs', 'config.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + + expect(config.profiles['hf-target-claude']).toBe('~/.ccs/hf-target-claude.settings.json'); + expect(config.profile_targets?.['hf-target-claude']).toBeUndefined(); + }); + it('preserves OpenRouter ANTHROPIC_API_KEY blank behavior', () => { const result = createApiProfile( 'openrouter-test', diff --git a/tests/unit/commands/api-create-target-resolution.test.ts b/tests/unit/commands/api-create-target-resolution.test.ts new file mode 100644 index 00000000..eab0437f --- /dev/null +++ b/tests/unit/commands/api-create-target-resolution.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'bun:test'; + +import { resolvePresetDefaultTarget } from '../../../src/commands/api-command/create-command'; + +describe('api create target resolution', () => { + it('uses the preset default target when no explicit target is provided', () => { + expect(resolvePresetDefaultTarget({ defaultTarget: 'droid' }, undefined)).toBe('droid'); + }); + + it('lets an explicit target override the preset default target', () => { + expect(resolvePresetDefaultTarget({ defaultTarget: 'droid' }, 'claude')).toBe('claude'); + }); + + it('returns null when neither an explicit target nor a preset default exists', () => { + expect(resolvePresetDefaultTarget(null, undefined)).toBeNull(); + }); +}); From 42d5724f34c03c48e1d5d963a85f7cf0a164e9b0 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 17:46:18 -0400 Subject: [PATCH 37/54] fix(ui): accept partial raw model records - allow raw JSON model arrays with partial fields to format safely - keep the requested=upstream review fixes compatible with raw editor input Refs #941 --- ui/src/lib/provider-config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/lib/provider-config.ts b/ui/src/lib/provider-config.ts index 792f3f01..7276a996 100644 --- a/ui/src/lib/provider-config.ts +++ b/ui/src/lib/provider-config.ts @@ -165,7 +165,7 @@ export function getRequestedUpstreamModelRuleErrors(value: string): string[] { * Format stored provider config back into the UI-facing requested=upstream form. */ export function formatRequestedUpstreamModelRules( - models: AiProviderModelAlias[] | null | undefined + models: Array> | null | undefined ): string { return (models || []) .map((item) => { From 01c1193a994cc6efc80135590d5bacaf3b78deed Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 17:47:37 -0400 Subject: [PATCH 38/54] fix(ui): guard optional raw model fields - trim nullable alias and name fields safely in the raw model formatter - satisfy strict TypeScript checks for partial raw config records Refs #941 --- ui/src/lib/provider-config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/src/lib/provider-config.ts b/ui/src/lib/provider-config.ts index 7276a996..35e9822e 100644 --- a/ui/src/lib/provider-config.ts +++ b/ui/src/lib/provider-config.ts @@ -169,8 +169,8 @@ export function formatRequestedUpstreamModelRules( ): string { return (models || []) .map((item) => { - const requested = item.alias.trim(); - const upstream = item.name.trim(); + const requested = item.alias?.trim() || ''; + const upstream = item.name?.trim() || ''; return requested ? `${requested}=${upstream}` : upstream; }) .join('\n'); From cfdae7be9f14c476b6882f20d137f1294a9aec8d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 10 Apr 2026 22:06:06 +0000 Subject: [PATCH 39/54] chore(release): 7.67.0-dev.6 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 337094a5..a3888e4a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.67.0-dev.5", + "version": "7.67.0-dev.6", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 0e4677f558a2231b7eef7eb3de5c7f0336bbb041 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 17:24:46 -0400 Subject: [PATCH 40/54] fix: sanitize cliproxy provider model pickers --- src/cliproxy/model-catalog.ts | 41 +++++- tests/unit/cliproxy/model-catalog.test.js | 29 ++-- .../cliproxy/provider-model-selector.tsx | 30 ++-- ui/src/lib/model-catalogs.ts | 131 +++++++++++++++++- .../cliproxy/provider-model-selector.test.tsx | 90 ++++++++++++ ui/tests/unit/ui/lib/preset-utils.test.ts | 51 +++++-- 6 files changed, 327 insertions(+), 45 deletions(-) create mode 100644 ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 241c3b4e..3671eb6c 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -14,6 +14,21 @@ import { import { stripModelConfigurationSuffixes } from '../shared/extended-context-utils'; import { GEMINI_MINOR_VERSION_COMPATIBILITY_IDS } from '../shared/gemini-minor-version-compatibility'; +const AGY_GEMINI_PRO_HIGH_ID = 'gemini-3.1-pro-high'; +const AGY_GEMINI_PRO_LOW_ID = 'gemini-3.1-pro-low'; +const AGY_GEMINI_PRO_COMPATIBILITY_IDS = Object.freeze({ + 'gemini-3-pro-high': AGY_GEMINI_PRO_HIGH_ID, + 'gemini-3.1-pro-high': AGY_GEMINI_PRO_HIGH_ID, + 'gemini-3-pro-low': AGY_GEMINI_PRO_LOW_ID, + 'gemini-3.1-pro-low': AGY_GEMINI_PRO_LOW_ID, + 'gemini-3-pro-preview': AGY_GEMINI_PRO_HIGH_ID, + 'gemini-3-pro-preview-customtools': AGY_GEMINI_PRO_HIGH_ID, + 'gemini-3.1-pro-preview': AGY_GEMINI_PRO_HIGH_ID, + 'gemini-3.1-pro-preview-customtools': AGY_GEMINI_PRO_HIGH_ID, + 'gemini-3-1-pro-preview': AGY_GEMINI_PRO_HIGH_ID, + 'gemini-3-1-pro-preview-customtools': AGY_GEMINI_PRO_HIGH_ID, +} satisfies Record); + /** * Thinking support configuration for a model. * Defines how thinking/reasoning budget can be controlled. @@ -114,11 +129,19 @@ export const MODEL_CATALOG: Partial> = }, }, { - id: 'gemini-3.1-pro-preview', - name: 'Gemini 3.1 Pro', - description: 'Google latest Gemini Pro model via Antigravity', + id: AGY_GEMINI_PRO_HIGH_ID, + name: 'Gemini 3.1 Pro High', + description: 'Current Antigravity Gemini Pro route with higher reasoning budget', nativeImageInput: true, - thinking: { type: 'levels', levels: ['low', 'high'], dynamicAllowed: true }, + thinking: { type: 'none' }, + extendedContext: true, + }, + { + id: AGY_GEMINI_PRO_LOW_ID, + name: 'Gemini 3.1 Pro Low', + description: 'Current Antigravity Gemini Pro route with the lighter quota tier', + nativeImageInput: true, + thinking: { type: 'none' }, extendedContext: true, }, { @@ -465,6 +488,16 @@ export function findModel(provider: CLIProxyProvider, modelId: string): ModelEnt if (compatibilityId) { lookupCandidates.add(compatibilityId); } + + if (isAntigravityProvider(provider)) { + const agyCompatibilityId = + AGY_GEMINI_PRO_COMPATIBILITY_IDS[ + candidate as keyof typeof AGY_GEMINI_PRO_COMPATIBILITY_IDS + ]; + if (agyCompatibilityId) { + lookupCandidates.add(agyCompatibilityId); + } + } } return catalog.models.find((m) => lookupCandidates.has(m.id.toLowerCase())); diff --git a/tests/unit/cliproxy/model-catalog.test.js b/tests/unit/cliproxy/model-catalog.test.js index b379f5ab..17068475 100644 --- a/tests/unit/cliproxy/model-catalog.test.js +++ b/tests/unit/cliproxy/model-catalog.test.js @@ -91,11 +91,20 @@ describe('Model Catalog', () => { assert.strictEqual(ids.includes('claude-sonnet-4-5'), false); }); - it('includes Gemini 3.1 Pro (free via Antigravity)', () => { + it('includes Gemini 3.1 Pro High via Antigravity', () => { const { MODEL_CATALOG } = modelCatalog; - const gem3 = MODEL_CATALOG.agy.models.find((m) => m.id === 'gemini-3.1-pro-preview'); - assert(gem3, 'Should include Gemini 3.1 Pro'); - assert.strictEqual(gem3.name, 'Gemini 3.1 Pro'); + const gem3 = MODEL_CATALOG.agy.models.find((m) => m.id === 'gemini-3.1-pro-high'); + assert(gem3, 'Should include Gemini 3.1 Pro High'); + assert.strictEqual(gem3.name, 'Gemini 3.1 Pro High'); + // AGY models are all free - no paid tier + assert.strictEqual(gem3.tier, undefined, 'AGY models should not have paid tier'); + }); + + it('includes Gemini 3.1 Pro Low via Antigravity', () => { + const { MODEL_CATALOG } = modelCatalog; + const gem3 = MODEL_CATALOG.agy.models.find((m) => m.id === 'gemini-3.1-pro-low'); + assert(gem3, 'Should include Gemini 3.1 Pro Low'); + assert.strictEqual(gem3.name, 'Gemini 3.1 Pro Low'); // AGY models are all free - no paid tier assert.strictEqual(gem3.tier, undefined, 'AGY models should not have paid tier'); }); @@ -108,9 +117,9 @@ describe('Model Catalog', () => { assert.strictEqual(flash.tier, undefined, 'AGY models should not have paid tier'); }); - it('has 4 models total', () => { + it('has 5 models total', () => { const { MODEL_CATALOG } = modelCatalog; - assert.strictEqual(MODEL_CATALOG.agy.models.length, 4); + assert.strictEqual(MODEL_CATALOG.agy.models.length, 5); }); }); @@ -262,13 +271,15 @@ describe('Model Catalog', () => { assert.strictEqual(legacySonnet?.id, 'claude-sonnet-4-6'); }); - it('treats Gemini 3 and 3.1 preview IDs as the same catalog family', () => { + it('maps legacy Antigravity Gemini Pro aliases onto the current 3.1 high/low models', () => { const { findModel, getSuggestedReplacementModel } = modelCatalog; const legacyAgyGemini = findModel('agy', 'gemini-3-pro-preview'); + const legacyAgyGeminiLow = findModel('agy', 'gemini-3-pro-low'); const legacyGemini = findModel('gemini', 'gemini-3-pro-preview'); const currentGemini = findModel('gemini', 'gemini-3.1-pro-preview'); - assert.strictEqual(legacyAgyGemini?.id, 'gemini-3.1-pro-preview'); + assert.strictEqual(legacyAgyGemini?.id, 'gemini-3.1-pro-high'); + assert.strictEqual(legacyAgyGeminiLow?.id, 'gemini-3.1-pro-low'); assert.strictEqual(legacyGemini?.id, 'gemini-3.1-pro-preview'); assert.strictEqual(currentGemini?.id, 'gemini-3.1-pro-preview'); assert.strictEqual( @@ -371,7 +382,7 @@ describe('Model Catalog', () => { const sonnetThinkingIdx = models.findIndex((m) => m.id === 'claude-sonnet-4-6'); // Find indices of the remaining non-Claude model - const geminiIdx = models.findIndex((m) => m.id === 'gemini-3.1-pro-preview'); + const geminiIdx = models.findIndex((m) => m.id === 'gemini-3.1-pro-high'); // Primary Claude choices should appear ahead of Gemini fallback. assert(opusIdx < geminiIdx, 'Opus should be above Gemini'); diff --git a/ui/src/components/cliproxy/provider-model-selector.tsx b/ui/src/components/cliproxy/provider-model-selector.tsx index be7502bc..737915db 100644 --- a/ui/src/components/cliproxy/provider-model-selector.tsx +++ b/ui/src/components/cliproxy/provider-model-selector.tsx @@ -13,7 +13,7 @@ import { SearchableSelect } from '@/components/ui/searchable-select'; import { Skeleton } from '@/components/ui/skeleton'; import type { CliproxyProviderRoutingHints } from '@/lib/api-client'; import { getCodexEffortDisplay } from '@/lib/codex-effort'; -import { getResolvedCatalogModels } from '@/lib/model-catalogs'; +import { getResolvedCatalogModels, getSupplementalCatalogModels } from '@/lib/model-catalogs'; import { cn } from '@/lib/utils'; /** Model entry from catalog */ @@ -329,6 +329,10 @@ export function FlexibleModelSelector({ () => getResolvedCatalogModels(catalog, allModels), [allModels, catalog] ); + const supplementalModels = useMemo( + () => getSupplementalCatalogModels(catalog?.provider ?? '', catalog, allModels), + [allModels, catalog] + ); const catalogModelIds = new Set(resolvedCatalogModels.map((model) => model.id)); const routingHints = useMemo( () => @@ -388,7 +392,7 @@ export function FlexibleModelSelector({ ), })); - const allModelOptions = allModels + const allModelOptions = supplementalModels .filter((model) => !catalogModelIds.has(model.id)) .filter( (model) => @@ -495,14 +499,20 @@ export function FlexibleModelSelector({ {t('providerModelSelector.recommended')} ), }, - { - key: 'all', - label: ( - - {t('providerModelSelector.allModelsCount', { count: allModels.length })} - - ), - }, + ...(allModelOptions.length > 0 + ? [ + { + key: 'all', + label: ( + + {t('providerModelSelector.allModelsCount', { + count: allModelOptions.length, + })} + + ), + }, + ] + : []), ]} options={[ ...(selectedValueMissing && legacySelectedOption ? [legacySelectedOption] : []), diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 0401b405..2ccccf28 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -9,6 +9,21 @@ import { GEMINI_MINOR_VERSION_COMPATIBILITY_IDS } from '@shared/gemini-minor-ver const GEMINI_PREVIEW_MODEL_ID_PATTERN = /^gemini-(\d+(?:[.-]\d+)*)-(pro|flash)-preview(-customtools)?$/i; +const AGY_GEMINI_PRO_HIGH_ID = 'gemini-3.1-pro-high'; +const AGY_GEMINI_PRO_LOW_ID = 'gemini-3.1-pro-low'; +const AGY_GEMINI_PRO_COMPATIBILITY_IDS = new Map([ + ['gemini-3-pro-high', AGY_GEMINI_PRO_HIGH_ID], + ['gemini-3.1-pro-high', AGY_GEMINI_PRO_HIGH_ID], + ['gemini-3-pro-low', AGY_GEMINI_PRO_LOW_ID], + ['gemini-3.1-pro-low', AGY_GEMINI_PRO_LOW_ID], + ['gemini-3-pro-preview', AGY_GEMINI_PRO_HIGH_ID], + ['gemini-3-pro-preview-customtools', AGY_GEMINI_PRO_HIGH_ID], + ['gemini-3.1-pro-preview', AGY_GEMINI_PRO_HIGH_ID], + ['gemini-3.1-pro-preview-customtools', AGY_GEMINI_PRO_HIGH_ID], + ['gemini-3-1-pro-preview', AGY_GEMINI_PRO_HIGH_ID], + ['gemini-3-1-pro-preview-customtools', AGY_GEMINI_PRO_HIGH_ID], +]); +const MANAGED_MODEL_PREFIXES = ['agy/', 'gcli/'] as const; export type CatalogAvailableModel = { id: string; @@ -29,6 +44,27 @@ function normalizeModelId(modelId: string): string { return stripModelConfigurationSuffixes(modelId).toLowerCase(); } +function stripManagedModelPrefix(modelId: string): string { + const trimmedModelId = modelId.trim(); + const normalizedModelId = trimmedModelId.toLowerCase(); + + for (const prefix of MANAGED_MODEL_PREFIXES) { + if (normalizedModelId.startsWith(prefix)) { + return trimmedModelId.slice(prefix.length); + } + } + + return trimmedModelId; +} + +function stripCustomtoolsSuffix(modelId: string): string { + return modelId.replace(/-customtools$/i, ''); +} + +function getAgyGeminiProCompatibilityId(modelId: string): string | undefined { + return AGY_GEMINI_PRO_COMPATIBILITY_IDS.get(normalizeModelId(modelId)); +} + function parseGeminiPreviewModelId(modelId: string): GeminiPreviewModelInfo | null { const normalizedId = normalizeModelId(modelId); const match = normalizedId.match(GEMINI_PREVIEW_MODEL_ID_PATTERN); @@ -142,14 +178,26 @@ export const MODEL_CATALOGS: Record = { }, }, { - id: 'gemini-3.1-pro-preview', - name: 'Gemini Pro', - description: 'Resolves to the best advertised Gemini Pro preview via Antigravity', + id: AGY_GEMINI_PRO_HIGH_ID, + name: 'Gemini Pro High', + description: 'Current Antigravity Gemini Pro route with higher reasoning budget', extendedContext: true, presetMapping: { - default: 'gemini-3.1-pro-preview', - opus: 'gemini-3.1-pro-preview', - sonnet: 'gemini-3.1-pro-preview', + default: AGY_GEMINI_PRO_HIGH_ID, + opus: AGY_GEMINI_PRO_HIGH_ID, + sonnet: AGY_GEMINI_PRO_HIGH_ID, + haiku: 'gemini-3-1-flash-preview', + }, + }, + { + id: AGY_GEMINI_PRO_LOW_ID, + name: 'Gemini Pro Low', + description: 'Current Antigravity Gemini Pro route with the lighter quota tier', + extendedContext: true, + presetMapping: { + default: AGY_GEMINI_PRO_LOW_ID, + opus: AGY_GEMINI_PRO_LOW_ID, + sonnet: AGY_GEMINI_PRO_LOW_ID, haiku: 'gemini-3-1-flash-preview', }, }, @@ -684,6 +732,13 @@ function findCatalogModelInCatalog(catalog: ProviderCatalog | undefined, modelId if (!catalog) return undefined; const normalizedModelId = normalizeModelId(modelId); + if (catalog.provider === 'agy') { + const agyCompatibilityId = getAgyGeminiProCompatibilityId(normalizedModelId); + if (agyCompatibilityId) { + const compatibilityMatch = catalog.models.find((model) => model.id === agyCompatibilityId); + if (compatibilityMatch) return compatibilityMatch; + } + } const compatibilityModelId = GEMINI_MINOR_VERSION_COMPATIBILITY_IDS[ normalizedModelId.toLowerCase() as keyof typeof GEMINI_MINOR_VERSION_COMPATIBILITY_IDS @@ -835,18 +890,24 @@ export function getResolvedCatalogModels( ) { if (!catalog) return []; + const recommendedCatalog = MODEL_CATALOGS[catalog.provider.toLowerCase()] ?? catalog; const seenModelIds = new Set(); - return catalog.models + return recommendedCatalog.models .map((model) => { const resolvedModelId = resolveCatalogModelId(model.id, availableModels); const resolvedPresetModelMapping = model.presetMapping ? resolvePresetMapping(model.presetMapping, availableModels) : undefined; + const liveModelMatch = catalog.models.find( + (catalogModel) => normalizeModelId(catalogModel.id) === normalizeModelId(resolvedModelId) + ); return { ...model, id: resolvedModelId, + name: liveModelMatch?.name || model.name, + description: liveModelMatch?.description ?? model.description, presetMapping: resolvedPresetModelMapping, }; }) @@ -857,6 +918,62 @@ export function getResolvedCatalogModels( }); } +export function getSupplementalCatalogModels( + provider: string, + catalog: ProviderCatalog | undefined, + availableModels: CatalogAvailableModel[] = [] +) { + const normalizedProvider = provider.trim().toLowerCase(); + if (!catalog || !normalizedProvider) return []; + + const staticCatalog = MODEL_CATALOGS[normalizedProvider] ?? catalog; + const recommendedModels = getResolvedCatalogModels(catalog, availableModels); + const recommendedIds = new Set(recommendedModels.map((model) => normalizeModelId(model.id))); + const recommendedCanonicalIds = new Set( + recommendedModels + .map((model) => findCatalogModelInCatalog(staticCatalog, model.id)?.id) + .filter((modelId): modelId is string => Boolean(modelId)) + .map((modelId) => normalizeModelId(modelId)) + ); + const seenCanonicalIds = new Set(); + const normalizedAvailableIds = new Set( + availableModels.map((model) => normalizeModelId(stripManagedModelPrefix(model.id))) + ); + + return availableModels.filter((availableModel) => { + const strippedModelId = stripManagedModelPrefix(availableModel.id); + const baseModelId = stripCustomtoolsSuffix(strippedModelId); + const matchedModel = + findCatalogModelInCatalog(staticCatalog, strippedModelId) ?? + findCatalogModelInCatalog(staticCatalog, baseModelId); + + if (!matchedModel) return false; + + if (recommendedIds.has(normalizeModelId(strippedModelId))) { + return false; + } + + const canonicalId = normalizeModelId(matchedModel.id); + if (recommendedCanonicalIds.has(canonicalId)) { + return false; + } + if (seenCanonicalIds.has(canonicalId)) { + return false; + } + + const normalizedBaseModelId = normalizeModelId(baseModelId); + if ( + normalizedBaseModelId !== normalizeModelId(strippedModelId) && + normalizedAvailableIds.has(normalizedBaseModelId) + ) { + return false; + } + + seenCanonicalIds.add(canonicalId); + return true; + }); +} + export function supportsExtendedContext( provider: string, modelId: string, diff --git a/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx b/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx new file mode 100644 index 00000000..3b8b94b1 --- /dev/null +++ b/ui/tests/unit/components/cliproxy/provider-model-selector.test.tsx @@ -0,0 +1,90 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { FlexibleModelSelector } from '@/components/cliproxy/provider-model-selector'; +import { buildUiCatalogs } from '@/lib/model-catalogs'; +import { render, screen, userEvent } from '@tests/setup/test-utils'; + +const noisyAgyModels = [ + { id: 'agy/gemini-3.1-pro-high', owned_by: 'antigravity' }, + { id: 'gemini-3.1-pro-high', owned_by: 'antigravity' }, + { id: 'gemini-3.1-pro-low', owned_by: 'antigravity' }, + { id: 'gemini-3.1-pro-preview', owned_by: 'antigravity' }, + { id: 'gemini-3.1-pro-preview-customtools', owned_by: 'antigravity' }, + { id: 'gemini-3-pro-preview', owned_by: 'antigravity' }, + { id: 'gemini-3-1-flash-preview', owned_by: 'antigravity' }, + { id: 'gemini-3-1-flash-preview-customtools', owned_by: 'antigravity' }, + { id: 'gpt-oss-120b-medium', owned_by: 'antigravity' }, +]; + +const catalog = buildUiCatalogs({ + agy: { + provider: 'agy', + displayName: 'Antigravity', + defaultModel: 'claude-opus-4-6-thinking', + models: noisyAgyModels.map(({ id }) => ({ id, name: id })), + }, +}).agy; + +describe('FlexibleModelSelector', () => { + beforeEach(() => { + Object.defineProperty(HTMLElement.prototype, 'hasPointerCapture', { + configurable: true, + value: vi.fn(() => false), + }); + Object.defineProperty(HTMLElement.prototype, 'setPointerCapture', { + configurable: true, + value: vi.fn(), + }); + Object.defineProperty(HTMLElement.prototype, 'releasePointerCapture', { + configurable: true, + value: vi.fn(), + }); + Object.defineProperty(Element.prototype, 'scrollIntoView', { + configurable: true, + value: vi.fn(), + }); + }); + + it('keeps the picker focused on curated Antigravity live routes', async () => { + render( + + ); + + await userEvent.click(screen.getByRole('button', { name: /select model/i })); + + expect(screen.getByText('gemini-3.1-pro-high')).toBeInTheDocument(); + expect(screen.getByText('gemini-3.1-pro-low')).toBeInTheDocument(); + expect(screen.getByText('gemini-3-1-flash-preview')).toBeInTheDocument(); + + expect(screen.queryByText('gemini-3.1-pro-preview')).not.toBeInTheDocument(); + expect(screen.queryByText('gemini-3-pro-preview')).not.toBeInTheDocument(); + expect(screen.queryByText('agy/gemini-3.1-pro-high')).not.toBeInTheDocument(); + expect(screen.queryByText('gemini-3-1-flash-preview-customtools')).not.toBeInTheDocument(); + expect(screen.queryByText('gpt-oss-120b-medium')).not.toBeInTheDocument(); + expect(screen.queryByText(/All Models \(/i)).not.toBeInTheDocument(); + }); + + it('preserves a filtered legacy value under the current-value fallback group', async () => { + render( + + ); + + await userEvent.click(screen.getByRole('button', { name: /gemini-3\.1-pro-preview/i })); + + expect(screen.getByText('Current value')).toBeInTheDocument(); + expect(screen.getAllByText('gemini-3.1-pro-preview').length).toBeGreaterThan(0); + expect(screen.getByText('gemini-3.1-pro-high')).toBeInTheDocument(); + }); +}); diff --git a/ui/tests/unit/ui/lib/preset-utils.test.ts b/ui/tests/unit/ui/lib/preset-utils.test.ts index dbc852a2..a258523b 100644 --- a/ui/tests/unit/ui/lib/preset-utils.test.ts +++ b/ui/tests/unit/ui/lib/preset-utils.test.ts @@ -5,6 +5,7 @@ import { MODEL_CATALOGS, findCatalogModel, getResolvedCatalogModels, + getSupplementalCatalogModels, resolveCatalogModelId, } from '@/lib/model-catalogs'; import { applyDefaultPreset } from '@/lib/preset-utils'; @@ -126,29 +127,49 @@ describe('claude preset utils', () => { ); }); - it('resolves Gemini preview presets to the best live family match', () => { + it('maps legacy Antigravity Gemini Pro aliases to the current catalog entries', () => { + expect(findCatalogModel('agy', 'gemini-3.1-pro-preview')?.id).toBe('gemini-3.1-pro-high'); + expect(findCatalogModel('agy', 'gemini-3-pro-preview')?.id).toBe('gemini-3.1-pro-high'); + expect(findCatalogModel('agy', 'gemini-3-pro-low')?.id).toBe('gemini-3.1-pro-low'); + }); + + it('resolves Antigravity Gemini presets to the current live-safe model ids', () => { const availableModels = [ - { id: 'gemini-3.9-pro-preview-customtools', owned_by: 'antigravity' }, - { id: 'gemini-3.9-pro-preview', owned_by: 'antigravity' }, - { id: 'gemini-3-9-flash-preview-customtools', owned_by: 'antigravity' }, - { id: 'gemini-3-9-flash-preview', owned_by: 'antigravity' }, + { id: 'agy/gemini-3.1-pro-high', owned_by: 'antigravity' }, + { id: 'gemini-3.1-pro-high', owned_by: 'antigravity' }, + { id: 'gemini-3.1-pro-low', owned_by: 'antigravity' }, { id: 'gemini-3.1-pro-preview', owned_by: 'antigravity' }, + { id: 'gemini-3.1-pro-preview-customtools', owned_by: 'antigravity' }, + { id: 'gemini-3-pro-preview', owned_by: 'antigravity' }, + { id: 'gemini-3-1-flash-preview-customtools', owned_by: 'antigravity' }, + { id: 'gemini-3-1-flash-preview', owned_by: 'antigravity' }, + { id: 'gpt-oss-120b-medium', owned_by: 'antigravity' }, ]; - expect(resolveCatalogModelId('gemini-3.1-pro-preview', availableModels)).toBe( - 'gemini-3.9-pro-preview' - ); - expect(resolveCatalogModelId('gemini-3-flash-preview', availableModels)).toBe( - 'gemini-3-9-flash-preview' - ); - expect(findCatalogModel('agy', 'gemini-3.9-pro-preview')?.id).toBe('gemini-3.1-pro-preview'); + expect(findCatalogModel('agy', 'gemini-3.1-pro-preview')?.id).toBe('gemini-3.1-pro-high'); const resolvedAgyModels = getResolvedCatalogModels(MODEL_CATALOGS.agy, availableModels); - expect(resolvedAgyModels.find((model) => model.name === 'Gemini Pro')?.id).toBe( - 'gemini-3.9-pro-preview' + expect(resolvedAgyModels.find((model) => model.name === 'Gemini Pro High')?.id).toBe( + 'gemini-3.1-pro-high' + ); + expect(resolvedAgyModels.find((model) => model.name === 'Gemini Pro Low')?.id).toBe( + 'gemini-3.1-pro-low' ); expect(resolvedAgyModels.find((model) => model.name === 'Gemini Flash')?.id).toBe( - 'gemini-3-9-flash-preview' + 'gemini-3-1-flash-preview' + ); + + const supplementalAgyModels = getSupplementalCatalogModels( + 'agy', + MODEL_CATALOGS.agy, + availableModels + ); + expect(supplementalAgyModels.map((model) => model.id)).not.toContain('gpt-oss-120b-medium'); + expect(supplementalAgyModels.map((model) => model.id)).not.toContain('gemini-3-pro-preview'); + expect(supplementalAgyModels.map((model) => model.id)).not.toContain('gemini-3.1-pro-preview'); + expect(supplementalAgyModels.map((model) => model.id)).not.toContain('agy/gemini-3.1-pro-high'); + expect(supplementalAgyModels.map((model) => model.id)).not.toContain( + 'gemini-3-1-flash-preview-customtools' ); }); From 34d70442bf132c6772dc528875377e4d16a221ec Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 17:42:24 -0400 Subject: [PATCH 41/54] fix(cliproxy): keep new live provider models selectable --- ui/src/lib/model-catalogs.ts | 30 ++++++++++++++--------- ui/tests/unit/ui/lib/preset-utils.test.ts | 8 ++++++ 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 2ccccf28..ad809f3c 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -936,39 +936,47 @@ export function getSupplementalCatalogModels( .map((modelId) => normalizeModelId(modelId)) ); const seenCanonicalIds = new Set(); - const normalizedAvailableIds = new Set( - availableModels.map((model) => normalizeModelId(stripManagedModelPrefix(model.id))) - ); + const normalizedRawIds = new Set(availableModels.map((model) => normalizeModelId(model.id))); return availableModels.filter((availableModel) => { + const normalizedAvailableModelId = normalizeModelId(availableModel.id); const strippedModelId = stripManagedModelPrefix(availableModel.id); const baseModelId = stripCustomtoolsSuffix(strippedModelId); const matchedModel = findCatalogModelInCatalog(staticCatalog, strippedModelId) ?? findCatalogModelInCatalog(staticCatalog, baseModelId); - if (!matchedModel) return false; - if (recommendedIds.has(normalizeModelId(strippedModelId))) { return false; } - const canonicalId = normalizeModelId(matchedModel.id); - if (recommendedCanonicalIds.has(canonicalId)) { - return false; - } - if (seenCanonicalIds.has(canonicalId)) { + if ( + normalizedAvailableModelId !== normalizeModelId(strippedModelId) && + normalizedRawIds.has(normalizeModelId(strippedModelId)) + ) { return false; } const normalizedBaseModelId = normalizeModelId(baseModelId); if ( normalizedBaseModelId !== normalizeModelId(strippedModelId) && - normalizedAvailableIds.has(normalizedBaseModelId) + normalizedRawIds.has(normalizedBaseModelId) ) { return false; } + const canonicalId = matchedModel ? normalizeModelId(matchedModel.id) : normalizedBaseModelId; + if (matchedModel && recommendedCanonicalIds.has(canonicalId)) { + return false; + } + if (seenCanonicalIds.has(canonicalId)) { + return false; + } + + if (!matchedModel && normalizedProvider === 'agy') { + return false; + } + seenCanonicalIds.add(canonicalId); return true; }); diff --git a/ui/tests/unit/ui/lib/preset-utils.test.ts b/ui/tests/unit/ui/lib/preset-utils.test.ts index a258523b..2049860a 100644 --- a/ui/tests/unit/ui/lib/preset-utils.test.ts +++ b/ui/tests/unit/ui/lib/preset-utils.test.ts @@ -173,6 +173,14 @@ describe('claude preset utils', () => { ); }); + it('preserves newly advertised non-Antigravity models in the supplemental list', () => { + const supplementalCodexModels = getSupplementalCatalogModels('codex', MODEL_CATALOGS.codex, [ + { id: 'gpt-5.9-codex', owned_by: 'openai' }, + ]); + + expect(supplementalCodexModels).toEqual([{ id: 'gpt-5.9-codex', owned_by: 'openai' }]); + }); + it('does not silently swap Gemini Flash presets to flash-lite', () => { const availableModels = [{ id: 'gemini-3.1-flash-lite-preview', owned_by: 'google' }]; From 25dd9ab9e14d7c4131c0cace99222346bfb006bc Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 18:06:29 -0400 Subject: [PATCH 42/54] fix(cliproxy): align AGY preset aliases --- src/cliproxy/model-catalog.ts | 20 +++++------------ src/shared/agy-gemini-pro-compatibility.ts | 15 +++++++++++++ ui/src/lib/model-catalogs.ts | 25 +++++++--------------- ui/tests/unit/ui/lib/preset-utils.test.ts | 6 ++++++ 4 files changed, 34 insertions(+), 32 deletions(-) create mode 100644 src/shared/agy-gemini-pro-compatibility.ts diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 3671eb6c..8a4e4564 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -11,24 +11,14 @@ import { migrateDeniedAntigravityModelAliases, normalizeModelIdForProvider, } from './model-id-normalizer'; +import { + AGY_GEMINI_PRO_COMPATIBILITY_IDS, + AGY_GEMINI_PRO_HIGH_ID, + AGY_GEMINI_PRO_LOW_ID, +} from '../shared/agy-gemini-pro-compatibility'; import { stripModelConfigurationSuffixes } from '../shared/extended-context-utils'; import { GEMINI_MINOR_VERSION_COMPATIBILITY_IDS } from '../shared/gemini-minor-version-compatibility'; -const AGY_GEMINI_PRO_HIGH_ID = 'gemini-3.1-pro-high'; -const AGY_GEMINI_PRO_LOW_ID = 'gemini-3.1-pro-low'; -const AGY_GEMINI_PRO_COMPATIBILITY_IDS = Object.freeze({ - 'gemini-3-pro-high': AGY_GEMINI_PRO_HIGH_ID, - 'gemini-3.1-pro-high': AGY_GEMINI_PRO_HIGH_ID, - 'gemini-3-pro-low': AGY_GEMINI_PRO_LOW_ID, - 'gemini-3.1-pro-low': AGY_GEMINI_PRO_LOW_ID, - 'gemini-3-pro-preview': AGY_GEMINI_PRO_HIGH_ID, - 'gemini-3-pro-preview-customtools': AGY_GEMINI_PRO_HIGH_ID, - 'gemini-3.1-pro-preview': AGY_GEMINI_PRO_HIGH_ID, - 'gemini-3.1-pro-preview-customtools': AGY_GEMINI_PRO_HIGH_ID, - 'gemini-3-1-pro-preview': AGY_GEMINI_PRO_HIGH_ID, - 'gemini-3-1-pro-preview-customtools': AGY_GEMINI_PRO_HIGH_ID, -} satisfies Record); - /** * Thinking support configuration for a model. * Defines how thinking/reasoning budget can be controlled. diff --git a/src/shared/agy-gemini-pro-compatibility.ts b/src/shared/agy-gemini-pro-compatibility.ts new file mode 100644 index 00000000..88b4215d --- /dev/null +++ b/src/shared/agy-gemini-pro-compatibility.ts @@ -0,0 +1,15 @@ +export const AGY_GEMINI_PRO_HIGH_ID = 'gemini-3.1-pro-high'; +export const AGY_GEMINI_PRO_LOW_ID = 'gemini-3.1-pro-low'; + +export const AGY_GEMINI_PRO_COMPATIBILITY_IDS = Object.freeze({ + 'gemini-3-pro-high': AGY_GEMINI_PRO_HIGH_ID, + 'gemini-3.1-pro-high': AGY_GEMINI_PRO_HIGH_ID, + 'gemini-3-pro-low': AGY_GEMINI_PRO_LOW_ID, + 'gemini-3.1-pro-low': AGY_GEMINI_PRO_LOW_ID, + 'gemini-3-pro-preview': AGY_GEMINI_PRO_HIGH_ID, + 'gemini-3-pro-preview-customtools': AGY_GEMINI_PRO_HIGH_ID, + 'gemini-3.1-pro-preview': AGY_GEMINI_PRO_HIGH_ID, + 'gemini-3.1-pro-preview-customtools': AGY_GEMINI_PRO_HIGH_ID, + 'gemini-3-1-pro-preview': AGY_GEMINI_PRO_HIGH_ID, + 'gemini-3-1-pro-preview-customtools': AGY_GEMINI_PRO_HIGH_ID, +} satisfies Record); diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index ad809f3c..ab6425e8 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -5,24 +5,15 @@ import type { ModelEntry, ProviderCatalog } from '@/components/cliproxy/provider-model-selector'; import { stripModelConfigurationSuffixes } from '@/lib/extended-context-utils'; +import { + AGY_GEMINI_PRO_COMPATIBILITY_IDS, + AGY_GEMINI_PRO_HIGH_ID, + AGY_GEMINI_PRO_LOW_ID, +} from '@shared/agy-gemini-pro-compatibility'; import { GEMINI_MINOR_VERSION_COMPATIBILITY_IDS } from '@shared/gemini-minor-version-compatibility'; const GEMINI_PREVIEW_MODEL_ID_PATTERN = /^gemini-(\d+(?:[.-]\d+)*)-(pro|flash)-preview(-customtools)?$/i; -const AGY_GEMINI_PRO_HIGH_ID = 'gemini-3.1-pro-high'; -const AGY_GEMINI_PRO_LOW_ID = 'gemini-3.1-pro-low'; -const AGY_GEMINI_PRO_COMPATIBILITY_IDS = new Map([ - ['gemini-3-pro-high', AGY_GEMINI_PRO_HIGH_ID], - ['gemini-3.1-pro-high', AGY_GEMINI_PRO_HIGH_ID], - ['gemini-3-pro-low', AGY_GEMINI_PRO_LOW_ID], - ['gemini-3.1-pro-low', AGY_GEMINI_PRO_LOW_ID], - ['gemini-3-pro-preview', AGY_GEMINI_PRO_HIGH_ID], - ['gemini-3-pro-preview-customtools', AGY_GEMINI_PRO_HIGH_ID], - ['gemini-3.1-pro-preview', AGY_GEMINI_PRO_HIGH_ID], - ['gemini-3.1-pro-preview-customtools', AGY_GEMINI_PRO_HIGH_ID], - ['gemini-3-1-pro-preview', AGY_GEMINI_PRO_HIGH_ID], - ['gemini-3-1-pro-preview-customtools', AGY_GEMINI_PRO_HIGH_ID], -]); const MANAGED_MODEL_PREFIXES = ['agy/', 'gcli/'] as const; export type CatalogAvailableModel = { @@ -62,7 +53,7 @@ function stripCustomtoolsSuffix(modelId: string): string { } function getAgyGeminiProCompatibilityId(modelId: string): string | undefined { - return AGY_GEMINI_PRO_COMPATIBILITY_IDS.get(normalizeModelId(modelId)); + return AGY_GEMINI_PRO_COMPATIBILITY_IDS[normalizeModelId(modelId)]; } function parseGeminiPreviewModelId(modelId: string): GeminiPreviewModelInfo | null { @@ -208,8 +199,8 @@ export const MODEL_CATALOGS: Record = { extendedContext: true, presetMapping: { default: 'gemini-3-1-flash-preview', - opus: 'gemini-3.1-pro-preview', - sonnet: 'gemini-3.1-pro-preview', + opus: AGY_GEMINI_PRO_HIGH_ID, + sonnet: AGY_GEMINI_PRO_HIGH_ID, haiku: 'gemini-3-1-flash-preview', }, }, diff --git a/ui/tests/unit/ui/lib/preset-utils.test.ts b/ui/tests/unit/ui/lib/preset-utils.test.ts index 2049860a..b527c51d 100644 --- a/ui/tests/unit/ui/lib/preset-utils.test.ts +++ b/ui/tests/unit/ui/lib/preset-utils.test.ts @@ -158,6 +158,12 @@ describe('claude preset utils', () => { expect(resolvedAgyModels.find((model) => model.name === 'Gemini Flash')?.id).toBe( 'gemini-3-1-flash-preview' ); + expect(resolvedAgyModels.find((model) => model.name === 'Gemini Flash')?.presetMapping).toEqual( + expect.objectContaining({ + opus: 'gemini-3.1-pro-high', + sonnet: 'gemini-3.1-pro-high', + }) + ); const supplementalAgyModels = getSupplementalCatalogModels( 'agy', From c078a9633736faa2b5e45d6a7345d3d2883c6a7b Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 18:08:28 -0400 Subject: [PATCH 43/54] fix(ui): type shared AGY compatibility lookup --- ui/src/lib/model-catalogs.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index ab6425e8..a37ba349 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -53,7 +53,9 @@ function stripCustomtoolsSuffix(modelId: string): string { } function getAgyGeminiProCompatibilityId(modelId: string): string | undefined { - return AGY_GEMINI_PRO_COMPATIBILITY_IDS[normalizeModelId(modelId)]; + return AGY_GEMINI_PRO_COMPATIBILITY_IDS[ + normalizeModelId(modelId) as keyof typeof AGY_GEMINI_PRO_COMPATIBILITY_IDS + ]; } function parseGeminiPreviewModelId(modelId: string): GeminiPreviewModelInfo | null { From 031558f1a449a98c0c75c7328d2de1e610753a1d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 10 Apr 2026 22:21:19 +0000 Subject: [PATCH 44/54] chore(release): 7.67.0-dev.7 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a3888e4a..5343ab28 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.67.0-dev.6", + "version": "7.67.0-dev.7", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 451ecc11e91b7ceb0e19f402cb3c749aa26fb506 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 10 Apr 2026 22:29:28 +0000 Subject: [PATCH 45/54] chore(release): 7.67.0-dev.8 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5343ab28..ef9c79ab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.67.0-dev.7", + "version": "7.67.0-dev.8", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 033116b863bf62227a835b0a49440ba7cc328b87 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 11 Apr 2026 00:27:48 +0000 Subject: [PATCH 46/54] chore(release): 7.67.0-dev.9 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ef9c79ab..dbddd585 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.67.0-dev.8", + "version": "7.67.0-dev.9", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", From 2aaabb2deb03253493a19191690c001113a44040 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 21:39:50 -0400 Subject: [PATCH 47/54] fix(update): target the current CCS installation --- src/commands/update-command.ts | 170 ++++++-- src/utils/package-manager-detector.ts | 373 +++++++++++++++--- .../update-command-install-origin.test.ts | 185 +++++++++ .../update-command-current-install.test.ts | 283 +++++++++++++ .../utils/package-manager-detector.test.ts | 211 ++++++++++ 5 files changed, 1131 insertions(+), 91 deletions(-) create mode 100644 tests/integration/update-command-install-origin.test.ts create mode 100644 tests/unit/commands/update-command-current-install.test.ts create mode 100644 tests/unit/utils/package-manager-detector.test.ts diff --git a/src/commands/update-command.ts b/src/commands/update-command.ts index c78329c9..a85a38a3 100644 --- a/src/commands/update-command.ts +++ b/src/commands/update-command.ts @@ -7,7 +7,14 @@ import { spawn } from 'child_process'; import { initUI, header, ok, fail, warn, info, color } from '../utils/ui'; -import { detectPackageManager } from '../utils/package-manager-detector'; +import { + buildPackageManagerEnv, + detectCurrentInstall, + formatManualUpdateCommand, + readInstalledPackageState, + type CurrentInstall, + type InstalledPackageState, +} from '../utils/package-manager-detector'; import { compareVersionsWithPrerelease } from '../utils/update-checker'; import { getVersion } from '../utils/version'; @@ -22,6 +29,24 @@ export interface UpdateOptions { // Version (from centralized utility) const CCS_VERSION = getVersion(); +async function resolveTargetVersion( + currentVersion: string, + targetTag: 'latest' | 'dev' +): Promise { + const { checkForUpdates } = await import('../utils/update-checker'); + const result = await checkForUpdates(currentVersion, true, 'npm', targetTag); + + if (result.status === 'update_available' && result.latest) { + return result.latest; + } + + if (result.status === 'no_update') { + return currentVersion; + } + + return undefined; +} + /** * Handle the update command * Checks for updates and installs the latest version @@ -30,6 +55,7 @@ export async function handleUpdateCommand(options: UpdateOptions = {}): Promise< await initUI(); const { force = false, beta = false } = options; const targetTag = beta ? 'dev' : 'latest'; + const currentInstall = detectCurrentInstall(); console.log(''); console.log(header('Checking for updates...')); @@ -39,16 +65,16 @@ export async function handleUpdateCommand(options: UpdateOptions = {}): Promise< if (force) { console.log(info(`Force reinstall from @${targetTag} channel...`)); console.log(''); - await performNpmUpdate(targetTag, true); + const expectedVersion = await resolveTargetVersion(CCS_VERSION, targetTag); + await performNpmUpdate(currentInstall, targetTag, true, expectedVersion); return; } const { checkForUpdates } = await import('../utils/update-checker'); - const updateResult = await checkForUpdates(CCS_VERSION, true, 'npm', targetTag); if (updateResult.status === 'check_failed') { - handleCheckFailed(updateResult.message ?? 'Update check failed', targetTag); + handleCheckFailed(updateResult.message ?? 'Update check failed', targetTag, currentInstall); return; } @@ -89,13 +115,17 @@ export async function handleUpdateCommand(options: UpdateOptions = {}): Promise< console.log(''); } - await performNpmUpdate(targetTag); + await performNpmUpdate(currentInstall, targetTag, false, updateResult.latest); } /** * Handle failed update check */ -function handleCheckFailed(message: string, targetTag: string = 'latest'): void { +function handleCheckFailed( + message: string, + targetTag: string = 'latest', + currentInstall: CurrentInstall = detectCurrentInstall() +): void { console.log(fail(message)); console.log(''); console.log(warn('Possible causes:')); @@ -105,27 +135,7 @@ function handleCheckFailed(message: string, targetTag: string = 'latest'): void console.log(''); console.log('Try again later or update manually:'); - const packageManager = detectPackageManager(); - let manualCommand: string; - - switch (packageManager) { - case 'npm': - manualCommand = `npm install -g @kaitranntt/ccs@${targetTag}`; - break; - case 'yarn': - manualCommand = `yarn global add @kaitranntt/ccs@${targetTag}`; - break; - case 'pnpm': - manualCommand = `pnpm add -g @kaitranntt/ccs@${targetTag}`; - break; - case 'bun': - manualCommand = `bun add -g @kaitranntt/ccs@${targetTag}`; - break; - default: - manualCommand = `npm install -g @kaitranntt/ccs@${targetTag}`; - } - - console.log(color(` ${manualCommand}`, 'command')); + console.log(color(` ${formatManualUpdateCommand(targetTag, currentInstall)}`, 'command')); console.log(''); process.exit(1); } @@ -157,15 +167,94 @@ function handleNoUpdate(reason: string | undefined): void { /** * Perform update via npm/yarn/pnpm/bun */ -async function performNpmUpdate( - targetTag: string = 'latest', +function verifyCurrentInstallVersion( + currentInstall: CurrentInstall, + targetTag: string, + expectedVersion?: string, + previousState?: InstalledPackageState, isReinstall: boolean = false +): void { + const nextState = readInstalledPackageState(currentInstall); + const installedVersion = nextState.version; + if (!installedVersion) { + console.log(''); + console.log( + fail('Update finished, but CCS could not verify the current installation version.') + ); + console.log(''); + console.log('Current install remains ambiguous. Re-run manually:'); + console.log(color(` ${formatManualUpdateCommand(targetTag, currentInstall)}`, 'command')); + console.log(''); + process.exit(1); + } + + const installChanged = + previousState !== undefined && + (previousState.version !== nextState.version || + previousState.packageJsonMtimeMs !== nextState.packageJsonMtimeMs || + previousState.scriptMtimeMs !== nextState.scriptMtimeMs); + + if (expectedVersion && installedVersion !== expectedVersion) { + if (previousState?.version && installedVersion !== previousState.version) { + return; + } + + const comparison = compareVersionsWithPrerelease(installedVersion, expectedVersion); + if (comparison < 0) { + console.log(''); + console.log( + fail( + `Update completed outside the current installation. Current binary still reports ${installedVersion}; expected ${expectedVersion}.` + ) + ); + if (previousState?.version && previousState.version === installedVersion) { + console.log( + warn( + `The current install path did not change from ${previousState.version}; another package manager likely updated a different copy of CCS.` + ) + ); + } + console.log(''); + console.log('Re-run manually against the current install:'); + console.log(color(` ${formatManualUpdateCommand(targetTag, currentInstall)}`, 'command')); + console.log(''); + process.exit(1); + } + } + + if ( + isReinstall && + previousState?.version && + installedVersion === previousState.version && + !installChanged + ) { + console.log(''); + console.log( + fail( + `Reinstall completed, but CCS could not verify that the current installation changed from ${previousState.version}.` + ) + ); + console.log(''); + console.log('Re-run manually against the current install:'); + console.log(color(` ${formatManualUpdateCommand(targetTag, currentInstall)}`, 'command')); + console.log(''); + process.exit(1); + } +} + +async function performNpmUpdate( + currentInstall: CurrentInstall, + targetTag: string = 'latest', + isReinstall: boolean = false, + expectedVersion?: string ): Promise { - const packageManager = detectPackageManager(); + const packageManager = currentInstall.manager; let updateCommand: string; let updateArgs: string[]; let cacheCommand: string | null; let cacheArgs: string[] | null; + const childEnv = buildPackageManagerEnv(currentInstall); + const previousState = readInstalledPackageState(currentInstall); switch (packageManager) { case 'npm': @@ -214,9 +303,9 @@ async function performNpmUpdate( ? spawn(`${updateCommand} ${updateArgs.join(' ')}`, [], { stdio: ['inherit', 'inherit', 'pipe'], shell: true, - env: { ...process.env, NODE_NO_WARNINGS: '1' }, + env: { ...childEnv, NODE_NO_WARNINGS: '1' }, }) - : spawn(updateCommand, updateArgs, { stdio: 'inherit' }); + : spawn(updateCommand, updateArgs, { stdio: 'inherit', env: childEnv }); // On Windows, filter stderr to hide npm cleanup warnings (EPERM on bcrypt.node etc.) // These warnings are cosmetic - update succeeds despite file locking by antivirus/indexing @@ -244,6 +333,15 @@ async function performNpmUpdate( child.on('exit', (code) => { if (code === 0) { + if (expectedVersion || previousState?.version) { + verifyCurrentInstallVersion( + currentInstall, + targetTag, + expectedVersion, + previousState, + isReinstall + ); + } console.log(''); console.log(ok(`${isReinstall ? 'Reinstall' : 'Update'} successful!`)); console.log(''); @@ -255,7 +353,7 @@ async function performNpmUpdate( console.log(fail(`${isReinstall ? 'Reinstall' : 'Update'} failed`)); console.log(''); console.log('Try manually:'); - console.log(color(` ${updateCommand} ${updateArgs.join(' ')}`, 'command')); + console.log(color(` ${formatManualUpdateCommand(targetTag, currentInstall)}`, 'command')); console.log(''); } process.exit(code || 0); @@ -266,7 +364,7 @@ async function performNpmUpdate( console.log(fail(`Failed to run ${packageManager} ${isReinstall ? 'reinstall' : 'update'}`)); console.log(''); console.log('Try manually:'); - console.log(color(` ${updateCommand} ${updateArgs.join(' ')}`, 'command')); + console.log(color(` ${formatManualUpdateCommand(targetTag, currentInstall)}`, 'command')); console.log(''); process.exit(1); }); @@ -288,9 +386,9 @@ async function performNpmUpdate( ? spawn(`${cacheCommand} ${cacheArgs.join(' ')}`, [], { stdio: 'inherit', shell: true, - env: { ...process.env, NODE_NO_WARNINGS: '1' }, + env: { ...childEnv, NODE_NO_WARNINGS: '1' }, }) - : spawn(cacheCommand, cacheArgs, { stdio: 'inherit' }); + : spawn(cacheCommand, cacheArgs, { stdio: 'inherit', env: childEnv }); cacheChild.on('exit', (code) => { if (code !== 0) { diff --git a/src/utils/package-manager-detector.ts b/src/utils/package-manager-detector.ts index ac5840a0..4e5cea35 100644 --- a/src/utils/package-manager-detector.ts +++ b/src/utils/package-manager-detector.ts @@ -1,75 +1,338 @@ /** * Package Manager Detector Utilities * - * Cross-platform package manager detection utilities for CCS. - * Now only supports npm-based installation (npm/yarn/pnpm/bun). + * Detect the package manager and install root that own the CURRENT CCS binary. + * This is intentionally different from "which package manager has CCS installed + * somewhere on the machine" because self-update must target the current install. */ -import * as path from 'path'; import * as fs from 'fs'; -import { spawnSync } from 'child_process'; +import * as path from 'path'; + +export type PackageManager = 'npm' | 'yarn' | 'pnpm' | 'bun'; + +export interface CurrentInstall { + manager: PackageManager; + scriptPath: string; + resolvedScriptPath: string; + packageRoot: string | null; + prefix: string | null; + detectionSource: 'path' | 'package-root' | 'default'; +} + +export interface InstalledPackageState { + version: string | null; + packageJsonMtimeMs: number | null; + scriptMtimeMs: number | null; +} + +const CCS_PACKAGE_NAME = '@kaitranntt/ccs'; + +function resolveScriptPath(scriptPath: string): string { + if (path.win32.isAbsolute(scriptPath)) { + return scriptPath; + } + + try { + return fs.realpathSync(scriptPath); + } catch { + return path.resolve(scriptPath); + } +} + +function findPackageRoot(scriptPath: string): string | null { + let currentDir = path.dirname(scriptPath); + + for (let i = 0; i < 8; i++) { + const packageJsonPath = path.join(currentDir, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + try { + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as { + name?: string; + }; + if (packageJson.name === CCS_PACKAGE_NAME) { + return currentDir; + } + } catch { + // Ignore malformed package.json and keep walking upward. + } + } + + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + break; + } + currentDir = parentDir; + } + + return null; +} + +function getPrefixBeforeMarker(packageRoot: string, marker: string): string | null { + const index = packageRoot.lastIndexOf(marker); + return index >= 0 ? packageRoot.slice(0, index) || path.parse(packageRoot).root : null; +} + +function inferInstallFromPath( + targetPath: string +): Pick | null { + const normalizedPath = targetPath.split(path.sep).join('/'); + + if ( + normalizedPath.includes(`/install/global/node_modules/@kaitranntt/ccs`) || + normalizedPath.includes(`/.bun/install/global/node_modules/@kaitranntt/ccs`) + ) { + return { + manager: 'bun', + prefix: getPrefixBeforeMarker( + targetPath, + `${path.sep}install${path.sep}global${path.sep}node_modules` + ), + }; + } + + if (normalizedPath.includes(`/global/node_modules/@kaitranntt/ccs`)) { + return { + manager: 'yarn', + prefix: getPrefixBeforeMarker(targetPath, `${path.sep}global${path.sep}node_modules`), + }; + } + + if ( + normalizedPath.includes('/global/') && + normalizedPath.includes('/.pnpm/') && + normalizedPath.includes('/node_modules/@kaitranntt/ccs') + ) { + const pnpmVirtualStoreMatch = targetPath.match( + new RegExp( + `${path.sep.replace(/\\/g, '\\\\')}global${path.sep.replace(/\\/g, '\\\\')}[^${path.sep.replace( + /\\/g, + '\\\\' + )}]+${path.sep.replace(/\\/g, '\\\\')}\\.pnpm${path.sep.replace(/\\/g, '\\\\')}` + ) + ); + if (pnpmVirtualStoreMatch) { + return { + manager: 'pnpm', + prefix: getPrefixBeforeMarker(targetPath, `${path.sep}global${path.sep}`), + }; + } + } + + if ( + normalizedPath.includes('/global/') && + normalizedPath.includes('/node_modules/@kaitranntt/ccs') + ) { + return { + manager: 'pnpm', + prefix: getPrefixBeforeMarker(targetPath, `${path.sep}global${path.sep}`), + }; + } + + if ( + normalizedPath.includes('/.pnpm/') && + normalizedPath.includes('/node_modules/@kaitranntt/ccs') + ) { + return { + manager: 'pnpm', + prefix: null, + }; + } + + if (normalizedPath.includes('/lib/node_modules/@kaitranntt/ccs')) { + return { + manager: 'npm', + prefix: getPrefixBeforeMarker(targetPath, `${path.sep}lib${path.sep}node_modules`), + }; + } + + if ( + normalizedPath.includes('/node_modules/@kaitranntt/ccs') && + !normalizedPath.includes('/global/node_modules/@kaitranntt/ccs') && + !normalizedPath.includes('/install/global/node_modules/@kaitranntt/ccs') && + !normalizedPath.includes('/.pnpm/') + ) { + return { + manager: 'npm', + prefix: getPrefixBeforeMarker(targetPath, `${path.sep}node_modules`), + }; + } + + return null; +} /** - * Detect which package manager was used for installation + * Detect the current install owner from the path of the running script. + * Defaults to npm when the path is ambiguous because npm's global layout is + * the safest fallback for the existing manual remediation commands. */ -export function detectPackageManager(): 'npm' | 'yarn' | 'pnpm' | 'bun' { - const scriptPath = process.argv[1]; +export function detectCurrentInstall(scriptPath: string = process.argv[1] || ''): CurrentInstall { + const resolvedScriptPath = resolveScriptPath(scriptPath); + const pathMatch = inferInstallFromPath(resolvedScriptPath) ?? inferInstallFromPath(scriptPath); + const packageRoot = findPackageRoot(resolvedScriptPath); - // Check if script path contains package manager indicators - if (scriptPath.includes('.pnpm')) return 'pnpm'; - if (scriptPath.includes('yarn')) return 'yarn'; - if (scriptPath.includes('bun')) return 'bun'; - - // Check parent directories for lock files - const binDir = path.dirname(scriptPath); - - let checkDir = binDir; - for (let i = 0; i < 5; i++) { - if (fs.existsSync(path.join(checkDir, 'pnpm-lock.yaml'))) return 'pnpm'; - if (fs.existsSync(path.join(checkDir, 'yarn.lock'))) return 'yarn'; - if (fs.existsSync(path.join(checkDir, 'bun.lockb'))) return 'bun'; - checkDir = path.dirname(checkDir); + if (pathMatch) { + return { + manager: pathMatch.manager, + scriptPath, + resolvedScriptPath, + packageRoot, + prefix: pathMatch.prefix, + detectionSource: 'path', + }; } - // Check if package managers are available on the system - try { - const yarnResult = spawnSync('yarn', ['global', 'list', '--pattern', '@kaitranntt/ccs'], { - encoding: 'utf8', - shell: true, - timeout: 5000, - }); - if (yarnResult.status === 0 && yarnResult.stdout.includes('@kaitranntt/ccs')) { - return 'yarn'; + if (packageRoot) { + const packageRootMatch = inferInstallFromPath(packageRoot); + if (packageRootMatch) { + return { + manager: packageRootMatch.manager, + scriptPath, + resolvedScriptPath, + packageRoot, + prefix: packageRootMatch.prefix, + detectionSource: 'package-root', + }; } - } catch (_err) { - // Continue to next check } + return { + manager: 'npm', + scriptPath, + resolvedScriptPath, + packageRoot, + prefix: null, + detectionSource: 'default', + }; +} + +/** + * Backward-compatible helper for callers that only need the package manager. + */ +export function detectPackageManager(scriptPath: string = process.argv[1] || ''): PackageManager { + return detectCurrentInstall(scriptPath).manager; +} + +export function buildPackageManagerEnv( + install: CurrentInstall, + baseEnv: NodeJS.ProcessEnv = process.env +): NodeJS.ProcessEnv { + if (!install.prefix) { + return { ...baseEnv }; + } + + switch (install.manager) { + case 'npm': + return { + ...baseEnv, + npm_config_prefix: install.prefix, + NPM_CONFIG_PREFIX: install.prefix, + }; + case 'yarn': + return { + ...baseEnv, + YARN_GLOBAL_FOLDER: install.prefix, + }; + case 'pnpm': + return { + ...baseEnv, + PNPM_HOME: install.prefix, + }; + case 'bun': + return { + ...baseEnv, + BUN_INSTALL: install.prefix, + }; + default: + return { ...baseEnv }; + } +} + +export function readInstalledPackageVersion(install: CurrentInstall): string | null { + return readInstalledPackageState(install).version; +} + +function readFileMtimeMs(filePath: string): number | null { try { - const pnpmResult = spawnSync('pnpm', ['list', '-g', '--pattern', '@kaitranntt/ccs'], { - encoding: 'utf8', - shell: true, - timeout: 5000, - }); - if (pnpmResult.status === 0 && pnpmResult.stdout.includes('@kaitranntt/ccs')) { - return 'pnpm'; - } - } catch (_err) { - // Continue to next check + return fs.statSync(filePath).mtimeMs; + } catch { + return null; + } +} + +export function readInstalledPackageState(install: CurrentInstall): InstalledPackageState { + if (!install.packageRoot) { + return { + version: null, + packageJsonMtimeMs: null, + scriptMtimeMs: readFileMtimeMs(install.resolvedScriptPath), + }; } + const packageJsonPath = path.join(install.packageRoot, 'package.json'); + const packageJsonMtimeMs = readFileMtimeMs(packageJsonPath); + const scriptMtimeMs = readFileMtimeMs(install.resolvedScriptPath); + try { - const bunResult = spawnSync('bun', ['pm', 'ls', '-g', '--pattern', '@kaitranntt/ccs'], { - encoding: 'utf8', - shell: true, - timeout: 5000, - }); - if (bunResult.status === 0 && bunResult.stdout.includes('@kaitranntt/ccs')) { - return 'bun'; - } - } catch (_err) { - // Continue to default - } - - return 'npm'; + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as { + version?: string; + }; + + return { + version: typeof packageJson.version === 'string' ? packageJson.version : null, + packageJsonMtimeMs, + scriptMtimeMs, + }; + } catch { + return { + version: null, + packageJsonMtimeMs, + scriptMtimeMs, + }; + } +} + +function quoteForShell(value: string): string { + if (/^[A-Za-z0-9_./:-]+$/.test(value)) { + return value; + } + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function quoteForCmd(value: string): string { + return value.replace(/'/g, "''"); +} + +function formatWindowsEnvCommand(envVar: string, value: string, command: string): string { + return `powershell -NoProfile -Command "$env:${envVar}='${quoteForCmd(value)}'; ${command}"`; +} + +export function formatManualUpdateCommand( + targetTag: string, + install: CurrentInstall = detectCurrentInstall(), + platform: NodeJS.Platform = process.platform +): string { + const command = { + npm: `npm install -g @kaitranntt/ccs@${targetTag}`, + yarn: `yarn global add @kaitranntt/ccs@${targetTag}`, + pnpm: `pnpm add -g @kaitranntt/ccs@${targetTag}`, + bun: `bun add -g @kaitranntt/ccs@${targetTag}`, + }[install.manager]; + + if (!install.prefix) { + return command; + } + + const envVar = { + npm: 'NPM_CONFIG_PREFIX', + yarn: 'YARN_GLOBAL_FOLDER', + pnpm: 'PNPM_HOME', + bun: 'BUN_INSTALL', + }[install.manager]; + + if (platform === 'win32') { + return formatWindowsEnvCommand(envVar, install.prefix, command); + } + + return `${envVar}=${quoteForShell(install.prefix)} ${command}`; } diff --git a/tests/integration/update-command-install-origin.test.ts b/tests/integration/update-command-install-origin.test.ts new file mode 100644 index 00000000..ab10fabe --- /dev/null +++ b/tests/integration/update-command-install-origin.test.ts @@ -0,0 +1,185 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; +import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +let tempRoot = ''; +let currentPackageRoot = ''; +let currentPrefix = ''; +let bunPackageRoot = ''; +let fakeBinDir = ''; +let originalArgv1 = ''; +let originalPath = ''; +let originalConsoleLog: typeof console.log; +let originalProcessExit: typeof process.exit; +let logLines: string[] = []; +let exitCodes: number[] = []; + +function writePackage(root: string, version: string): void { + mkdirSync(root, { recursive: true }); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ name: '@kaitranntt/ccs', version }, null, 2) + ); +} + +function readPackageVersion(root: string): string { + return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version as string; +} + +function writeExecutable(filePath: string, contents: string): void { + writeFileSync(filePath, contents); + chmodSync(filePath, 0o755); +} + +async function loadHandleUpdateCommand() { + const mod = await import(`../../src/commands/update-command?test=${Date.now()}-${Math.random()}`); + return mod.handleUpdateCommand; +} + +beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), 'ccs-update-origin-')); + currentPrefix = join(tempRoot, 'prefix'); + currentPackageRoot = join(currentPrefix, 'lib', 'node_modules', '@kaitranntt', 'ccs'); + bunPackageRoot = join( + tempRoot, + '.bun', + 'install', + 'global', + 'node_modules', + '@kaitranntt', + 'ccs' + ); + fakeBinDir = join(tempRoot, 'bin'); + + mkdirSync(join(currentPackageRoot, 'dist'), { recursive: true }); + mkdirSync(fakeBinDir, { recursive: true }); + writePackage(currentPackageRoot, '7.67.0-dev.5'); + writePackage(bunPackageRoot, '0.0.0-stale'); + + originalArgv1 = process.argv[1] ?? ''; + process.argv[1] = join(currentPackageRoot, 'dist', 'ccs.js'); + + originalPath = process.env.PATH ?? ''; + process.env.PATH = `${fakeBinDir}:${originalPath}`; + + logLines = []; + exitCodes = []; + originalConsoleLog = console.log; + console.log = (...args: unknown[]) => { + logLines.push(args.map(String).join(' ')); + }; + + originalProcessExit = process.exit; + process.exit = ((code?: number) => { + exitCodes.push(code ?? 0); + }) as typeof process.exit; + + mock.module('../../src/utils/ui', () => ({ + initUI: async () => {}, + header: (value: string) => value, + ok: (value: string) => `[OK] ${value}`, + fail: (value: string) => `[X] ${value}`, + warn: (value: string) => `[!] ${value}`, + info: (value: string) => `[i] ${value}`, + color: (value: string) => value, + })); + + mock.module('../../src/utils/version', () => ({ + getVersion: () => '7.67.0-dev.5', + })); + + mock.module('../../src/utils/update-checker', () => ({ + compareVersionsWithPrerelease: (left: string, right: string) => left.localeCompare(right), + checkForUpdates: async () => ({ + status: 'update_available', + current: '7.67.0-dev.5', + latest: '7.67.0-dev.9', + }), + })); +}); + +afterEach(() => { + console.log = originalConsoleLog; + process.exit = originalProcessExit; + process.argv[1] = originalArgv1; + process.env.PATH = originalPath; + mock.restore(); + if (tempRoot) { + rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +async function waitForExitCode(expectedCode: number): Promise { + for (let attempt = 0; attempt < 50; attempt++) { + if (exitCodes.includes(expectedCode)) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`process.exit(${expectedCode}) was not observed`); +} + +describe('update-command install origin integration', () => { + it('updates the current npm-owned install instead of drifting to another manager', async () => { + writeExecutable( + join(fakeBinDir, 'npm'), + `#!/bin/sh +if [ "$npm_config_prefix" = "${currentPrefix}" ] || [ "$NPM_CONFIG_PREFIX" = "${currentPrefix}" ]; then + cat > "${join(currentPackageRoot, 'package.json')}" <<'EOF' +{"name":"@kaitranntt/ccs","version":"7.67.0-dev.9"} +EOF + exit 0 +fi +exit 13 +` + ); + + writeExecutable( + join(fakeBinDir, 'bun'), + `#!/bin/sh +cat > "${join(bunPackageRoot, 'package.json')}" <<'EOF' +{"name":"@kaitranntt/ccs","version":"7.67.0-dev.9"} +EOF +exit 0 +` + ); + + const handleUpdateCommand = await loadHandleUpdateCommand(); + + await handleUpdateCommand({ beta: true }); + await waitForExitCode(0); + + expect(readPackageVersion(currentPackageRoot)).toBe('7.67.0-dev.9'); + expect(readPackageVersion(bunPackageRoot)).toBe('0.0.0-stale'); + expect(logLines.join('\n')).toContain('Updating via npm'); + }); + + it('fails if the update exits 0 but the current install stays stale', async () => { + writeExecutable( + join(fakeBinDir, 'npm'), + `#!/bin/sh +exit 0 +` + ); + + writeExecutable( + join(fakeBinDir, 'bun'), + `#!/bin/sh +cat > "${join(bunPackageRoot, 'package.json')}" <<'EOF' +{"name":"@kaitranntt/ccs","version":"7.67.0-dev.9"} +EOF +exit 0 +` + ); + + const handleUpdateCommand = await loadHandleUpdateCommand(); + + await handleUpdateCommand({ beta: true }); + await waitForExitCode(1); + + expect(readPackageVersion(currentPackageRoot)).toBe('7.67.0-dev.5'); + expect(logLines.join('\n')).toContain('outside the current installation'); + expect(logLines.join('\n')).toContain('NPM_CONFIG_PREFIX='); + }); +}); diff --git a/tests/unit/commands/update-command-current-install.test.ts b/tests/unit/commands/update-command-current-install.test.ts new file mode 100644 index 00000000..33e7cf51 --- /dev/null +++ b/tests/unit/commands/update-command-current-install.test.ts @@ -0,0 +1,283 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; + +let logLines: string[] = []; +let spawnCalls: Array<{ command: string; args: string[]; env?: NodeJS.ProcessEnv }> = []; +let exitCodes: number[] = []; +let stateReads = 0; +let originalConsoleLog: typeof console.log; +let originalProcessExit: typeof process.exit; +let updateCheckResult: + | { status: 'update_available'; current: string; latest: string } + | { status: 'no_update' } + | { status: 'check_failed'; message: string }; +let currentInstallOverride = installDescriptor(); + +type Scenario = { + beforeState: { + version: string | null; + packageJsonMtimeMs: number | null; + scriptMtimeMs: number | null; + }; + afterState: { + version: string | null; + packageJsonMtimeMs: number | null; + scriptMtimeMs: number | null; + }; +}; + +let scenario: Scenario; + +function installDescriptor() { + return { + manager: 'npm' as const, + scriptPath: '/tmp/ccs-prefix/bin/ccs', + resolvedScriptPath: '/tmp/ccs-prefix/lib/node_modules/@kaitranntt/ccs/dist/ccs.js', + packageRoot: '/tmp/ccs-prefix/lib/node_modules/@kaitranntt/ccs', + prefix: '/tmp/ccs-prefix', + detectionSource: 'path' as const, + }; +} + +beforeEach(() => { + logLines = []; + spawnCalls = []; + exitCodes = []; + stateReads = 0; + scenario = { + beforeState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, + afterState: { version: '7.67.0-dev.9', packageJsonMtimeMs: 200, scriptMtimeMs: 200 }, + }; + updateCheckResult = { + status: 'update_available', + current: '7.67.0-dev.5', + latest: '7.67.0-dev.9', + }; + currentInstallOverride = installDescriptor(); + + originalConsoleLog = console.log; + originalProcessExit = process.exit; + + console.log = (...args: unknown[]) => { + logLines.push(args.map(String).join(' ')); + }; + + process.exit = ((code?: number) => { + exitCodes.push(code ?? 0); + throw new Error(`process.exit(${code ?? 0})`); + }) as typeof process.exit; + + mock.module('child_process', () => ({ + spawn: (command: string, args: string[], options?: { env?: NodeJS.ProcessEnv }) => { + spawnCalls.push({ command, args, env: options?.env }); + return { + on: (event: string, callback: (code?: number) => void) => { + if (event === 'exit') { + callback(0); + } + }, + }; + }, + })); + + mock.module('../../../src/utils/ui', () => ({ + initUI: async () => {}, + header: (value: string) => value, + ok: (value: string) => `[OK] ${value}`, + fail: (value: string) => `[X] ${value}`, + warn: (value: string) => `[!] ${value}`, + info: (value: string) => `[i] ${value}`, + color: (value: string) => value, + })); + + mock.module('../../../src/utils/version', () => ({ + getVersion: () => '7.67.0-dev.5', + })); + + mock.module('../../../src/utils/update-checker', () => ({ + compareVersionsWithPrerelease: (left: string, right: string) => left.localeCompare(right), + checkForUpdates: async () => updateCheckResult, + })); + + mock.module('../../../src/utils/package-manager-detector', () => ({ + detectCurrentInstall: () => currentInstallOverride, + buildPackageManagerEnv: () => { + if (currentInstallOverride.manager === 'npm') { + return { + PATH: '/usr/bin', + npm_config_prefix: '/tmp/ccs-prefix', + NPM_CONFIG_PREFIX: '/tmp/ccs-prefix', + }; + } + + if (currentInstallOverride.manager === 'bun') { + return { PATH: '/usr/bin', BUN_INSTALL: '/tmp/bun-prefix' }; + } + + if (currentInstallOverride.manager === 'yarn') { + return { PATH: '/usr/bin', YARN_GLOBAL_FOLDER: '/tmp/yarn-prefix' }; + } + + return { PATH: '/usr/bin', PNPM_HOME: '/tmp/pnpm-prefix' }; + }, + formatManualUpdateCommand: () => { + if (currentInstallOverride.manager === 'npm') { + return 'NPM_CONFIG_PREFIX=/tmp/ccs-prefix npm install -g @kaitranntt/ccs@dev'; + } + + if (currentInstallOverride.manager === 'bun') { + return 'BUN_INSTALL=/tmp/bun-prefix bun add -g @kaitranntt/ccs@dev'; + } + + if (currentInstallOverride.manager === 'yarn') { + return 'YARN_GLOBAL_FOLDER=/tmp/yarn-prefix yarn global add @kaitranntt/ccs@dev'; + } + + return 'PNPM_HOME=/tmp/pnpm-prefix pnpm add -g @kaitranntt/ccs@dev'; + }, + readInstalledPackageState: () => { + stateReads += 1; + return stateReads === 1 ? scenario.beforeState : scenario.afterState; + }, + })); +}); + +afterEach(() => { + console.log = originalConsoleLog; + process.exit = originalProcessExit; + mock.restore(); +}); + +async function loadHandleUpdateCommand() { + const mod = await import( + `../../../src/commands/update-command?test=${Date.now()}-${Math.random()}` + ); + return mod.handleUpdateCommand; +} + +describe('update-command current install handling', () => { + it('updates through the current install manager and prefix', async () => { + const handleUpdateCommand = await loadHandleUpdateCommand(); + + await expect(handleUpdateCommand({ beta: true })).rejects.toThrow('process.exit(0)'); + + const installCall = spawnCalls.find((call) => call.args.includes('install')); + + expect(installCall?.command).toBe('npm'); + expect(installCall?.args).toEqual(['install', '-g', '@kaitranntt/ccs@dev']); + expect(installCall?.env?.npm_config_prefix).toBe('/tmp/ccs-prefix'); + }); + + it('fails when another manager updated elsewhere but the current binary stayed stale', async () => { + scenario = { + beforeState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, + afterState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, + }; + const handleUpdateCommand = await loadHandleUpdateCommand(); + + await expect(handleUpdateCommand({ beta: true })).rejects.toThrow('process.exit(1)'); + + expect(logLines.join('\n')).toContain('outside the current installation'); + expect(logLines.join('\n')).toContain( + 'NPM_CONFIG_PREFIX=/tmp/ccs-prefix npm install -g @kaitranntt/ccs@dev' + ); + }); + + it('keeps force mode under exact target-version verification', async () => { + scenario = { + beforeState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, + afterState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, + }; + const handleUpdateCommand = await loadHandleUpdateCommand(); + + await expect(handleUpdateCommand({ force: true, beta: true })).rejects.toThrow( + 'process.exit(1)' + ); + + expect(logLines.join('\n')).toContain('outside the current installation'); + }); + + it('fails force mode when target resolution says no update and the current install stays unchanged', async () => { + scenario = { + beforeState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, + afterState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, + }; + updateCheckResult = { status: 'no_update' }; + const handleUpdateCommand = await loadHandleUpdateCommand(); + + await expect(handleUpdateCommand({ force: true, beta: true })).rejects.toThrow( + 'process.exit(1)' + ); + + expect(logLines.join('\n')).toContain('could not verify that the current installation changed'); + }); + + it('fails force mode when target version resolution fails and the current install stays unchanged', async () => { + scenario = { + beforeState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, + afterState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, + }; + updateCheckResult = { status: 'check_failed', message: 'network' }; + const handleUpdateCommand = await loadHandleUpdateCommand(); + + await expect(handleUpdateCommand({ force: true, beta: true })).rejects.toThrow( + 'process.exit(1)' + ); + + expect(logLines.join('\n')).toContain('could not verify that the current installation changed'); + }); + + it('accepts a newer installed version when the dist-tag moves during update', async () => { + scenario = { + beforeState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, + afterState: { version: '7.67.1-dev.0', packageJsonMtimeMs: 200, scriptMtimeMs: 200 }, + }; + const handleUpdateCommand = await loadHandleUpdateCommand(); + + await expect(handleUpdateCommand({ beta: true })).rejects.toThrow('process.exit(0)'); + + expect(logLines.join('\n')).not.toContain('outside the current installation'); + }); + + it('accepts force reinstall when the version stays the same but the current install files change', async () => { + scenario = { + beforeState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, + afterState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 200, scriptMtimeMs: 200 }, + }; + updateCheckResult = { status: 'no_update' }; + const handleUpdateCommand = await loadHandleUpdateCommand(); + + await expect(handleUpdateCommand({ force: true, beta: true })).rejects.toThrow( + 'process.exit(0)' + ); + + expect(logLines.join('\n')).not.toContain( + 'could not verify that the current installation changed' + ); + }); + + it.each([ + ['bun', 'add', 'BUN_INSTALL', '/tmp/bun-prefix'], + ['yarn', 'global', 'YARN_GLOBAL_FOLDER', '/tmp/yarn-prefix'], + ['pnpm', 'add', 'PNPM_HOME', '/tmp/pnpm-prefix'], + ])( + 'routes updates through the current %s install and env', + async (manager, expectedArg, envKey, envValue) => { + currentInstallOverride = { + ...installDescriptor(), + manager: manager as 'bun' | 'yarn' | 'pnpm', + prefix: envValue, + }; + + const handleUpdateCommand = await loadHandleUpdateCommand(); + + await expect(handleUpdateCommand({ beta: true })).rejects.toThrow('process.exit(0)'); + + const updateCall = spawnCalls.find( + (call) => + call.command === manager && call.args.some((arg) => arg.includes('@kaitranntt/ccs@dev')) + ); + expect(updateCall?.args).toContain(expectedArg); + expect(updateCall?.env?.[envKey]).toBe(envValue); + } + ); +}); diff --git a/tests/unit/utils/package-manager-detector.test.ts b/tests/unit/utils/package-manager-detector.test.ts new file mode 100644 index 00000000..693e0920 --- /dev/null +++ b/tests/unit/utils/package-manager-detector.test.ts @@ -0,0 +1,211 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { + buildPackageManagerEnv, + detectCurrentInstall, + formatManualUpdateCommand, + readInstalledPackageVersion, +} from '../../../src/utils/package-manager-detector'; + +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = mkdtempSync(join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function writePackage(root: string, version: string): void { + mkdirSync(root, { recursive: true }); + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ name: '@kaitranntt/ccs', version }, null, 2) + ); +} + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + } +}); + +describe('package-manager-detector', () => { + it('detects npm installs from the current binary path and keeps the custom prefix', () => { + const tempRoot = makeTempDir('ccs-install-detector-npm-'); + const packageRoot = join(tempRoot, 'prefix', 'lib', 'node_modules', '@kaitranntt', 'ccs'); + const scriptPath = join(packageRoot, 'dist', 'ccs.js'); + + writePackage(packageRoot, '7.67.0-dev.5'); + + const install = detectCurrentInstall(scriptPath); + + expect(install.manager).toBe('npm'); + expect(install.prefix).toBe(join(tempRoot, 'prefix')); + expect(install.packageRoot).toBe(packageRoot); + expect(readInstalledPackageVersion(install)).toBe('7.67.0-dev.5'); + }); + + it('detects bun installs from the resolved package path', () => { + const tempRoot = makeTempDir('ccs-install-detector-bun-'); + const packageRoot = join( + tempRoot, + '.bun', + 'install', + 'global', + 'node_modules', + '@kaitranntt', + 'ccs' + ); + const scriptPath = join(packageRoot, 'dist', 'ccs.js'); + + writePackage(packageRoot, '7.67.0-dev.9'); + + const install = detectCurrentInstall(scriptPath); + + expect(install.manager).toBe('bun'); + expect(install.prefix).toBe(join(tempRoot, '.bun')); + expect(readInstalledPackageVersion(install)).toBe('7.67.0-dev.9'); + }); + + it('detects custom bun install roots that still use install/global/node_modules', () => { + const tempRoot = makeTempDir('ccs-install-detector-custom-bun-'); + const packageRoot = join( + tempRoot, + 'custom-bun-root', + 'install', + 'global', + 'node_modules', + '@kaitranntt', + 'ccs' + ); + + writePackage(packageRoot, '7.67.0-dev.9'); + + const install = detectCurrentInstall(join(packageRoot, 'dist', 'ccs.js')); + + expect(install.manager).toBe('bun'); + expect(install.prefix).toBe(join(tempRoot, 'custom-bun-root')); + }); + + it('detects custom yarn global layouts', () => { + const tempRoot = makeTempDir('ccs-install-detector-custom-yarn-'); + const packageRoot = join( + tempRoot, + 'custom-yarn-root', + 'global', + 'node_modules', + '@kaitranntt', + 'ccs' + ); + + writePackage(packageRoot, '7.67.0-dev.9'); + + const install = detectCurrentInstall(join(packageRoot, 'dist', 'ccs.js')); + + expect(install.manager).toBe('yarn'); + expect(install.prefix).toBe(join(tempRoot, 'custom-yarn-root')); + }); + + it('detects custom pnpm global layouts that include a store version segment', () => { + const tempRoot = makeTempDir('ccs-install-detector-custom-pnpm-'); + const packageRoot = join( + tempRoot, + 'custom-pnpm-root', + 'global', + '5', + '.pnpm', + '@kaitranntt+ccs@7.67.0-dev.9', + 'node_modules', + '@kaitranntt', + 'ccs' + ); + + writePackage(packageRoot, '7.67.0-dev.9'); + + const install = detectCurrentInstall(join(packageRoot, 'dist', 'ccs.js')); + + expect(install.manager).toBe('pnpm'); + expect(install.prefix).toBe(join(tempRoot, 'custom-pnpm-root')); + }); + + it('detects pnpm global layouts without a visible .pnpm segment in the script path', () => { + const tempRoot = makeTempDir('ccs-install-detector-pnpm-global-flat-'); + const packageRoot = join( + tempRoot, + 'custom-pnpm-root', + 'global', + '5', + 'node_modules', + '@kaitranntt', + 'ccs' + ); + + writePackage(packageRoot, '7.67.0-dev.9'); + + const install = detectCurrentInstall(join(packageRoot, 'dist', 'ccs.js')); + + expect(install.manager).toBe('pnpm'); + expect(install.prefix).toBe(join(tempRoot, 'custom-pnpm-root')); + }); + + it('detects Windows npm globals without a lib directory', () => { + const install = detectCurrentInstall( + 'C:/Program Files/node-prefix/node_modules/@kaitranntt/ccs/dist/ccs.js' + ); + + expect(install.manager).toBe('npm'); + expect(install.prefix).toBe('C:/Program Files/node-prefix'); + }); + + it('formats manual npm update commands with the current prefix', () => { + const install = { + manager: 'npm' as const, + scriptPath: '/tmp/prefix/bin/ccs', + resolvedScriptPath: '/tmp/prefix/lib/node_modules/@kaitranntt/ccs/dist/ccs.js', + packageRoot: '/tmp/prefix/lib/node_modules/@kaitranntt/ccs', + prefix: '/tmp/prefix', + detectionSource: 'path' as const, + }; + + expect(formatManualUpdateCommand('dev', install)).toBe( + 'NPM_CONFIG_PREFIX=/tmp/prefix npm install -g @kaitranntt/ccs@dev' + ); + }); + + it('formats Windows-safe manual npm update commands for prefixes with spaces', () => { + const install = { + manager: 'npm' as const, + scriptPath: 'C:/Tools/CCS/ccs.cmd', + resolvedScriptPath: 'C:/Program Files/CCS/lib/node_modules/@kaitranntt/ccs/dist/ccs.js', + packageRoot: 'C:/Program Files/CCS/lib/node_modules/@kaitranntt/ccs', + prefix: 'C:/Program Files/CCS', + detectionSource: 'path' as const, + }; + + expect(formatManualUpdateCommand('dev', install, 'win32')).toBe( + `powershell -NoProfile -Command "$env:NPM_CONFIG_PREFIX='C:/Program Files/CCS'; npm install -g @kaitranntt/ccs@dev"` + ); + }); + + it('builds manager-specific env overrides for the current install', () => { + const install = { + manager: 'npm' as const, + scriptPath: '/tmp/prefix/bin/ccs', + resolvedScriptPath: '/tmp/prefix/lib/node_modules/@kaitranntt/ccs/dist/ccs.js', + packageRoot: '/tmp/prefix/lib/node_modules/@kaitranntt/ccs', + prefix: '/tmp/prefix', + detectionSource: 'path' as const, + }; + + const env = buildPackageManagerEnv(install, { PATH: '/usr/bin' }); + + expect(env.PATH).toBe('/usr/bin'); + expect(env.npm_config_prefix).toBe('/tmp/prefix'); + expect(env.NPM_CONFIG_PREFIX).toBe('/tmp/prefix'); + }); +}); From 59e04cb56f4d8c8cde6733bc15d3b8d9be94ab9f Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 22:27:07 -0400 Subject: [PATCH 48/54] fix(update): isolate updater test dependencies --- src/commands/update-command.ts | 157 +++++++++++---- .../update-command-current-install.test.ts | 182 ++++++++---------- 2 files changed, 197 insertions(+), 142 deletions(-) diff --git a/src/commands/update-command.ts b/src/commands/update-command.ts index a85a38a3..4346df7c 100644 --- a/src/commands/update-command.ts +++ b/src/commands/update-command.ts @@ -26,15 +26,81 @@ export interface UpdateOptions { beta?: boolean; } -// Version (from centralized utility) -const CCS_VERSION = getVersion(); +type TargetTag = 'latest' | 'dev'; + +type UpdateCheckResult = + | { + status: 'update_available'; + current?: string; + latest?: string; + message?: string; + reason?: string; + } + | { + status: 'check_failed'; + message?: string; + latest?: string; + current?: string; + reason?: string; + } + | { + status: 'no_update'; + reason?: string; + latest?: string; + current?: string; + message?: string; + }; + +export interface UpdateCommandDeps { + initUI: typeof initUI; + getVersion: typeof getVersion; + detectCurrentInstall: typeof detectCurrentInstall; + buildPackageManagerEnv: typeof buildPackageManagerEnv; + formatManualUpdateCommand: typeof formatManualUpdateCommand; + readInstalledPackageState: typeof readInstalledPackageState; + compareVersionsWithPrerelease: typeof compareVersionsWithPrerelease; + checkForUpdates: ( + currentVersion: string, + interactive: boolean, + channel: 'npm' | 'direct', + targetTag: TargetTag + ) => Promise; + spawn: typeof spawn; +} + +async function loadCheckForUpdates( + currentVersion: string, + interactive: boolean, + channel: 'npm' | 'direct', + targetTag: TargetTag +): Promise { + const { checkForUpdates } = await import('../utils/update-checker'); + return checkForUpdates( + currentVersion, + interactive, + channel, + targetTag + ) as Promise; +} + +const defaultDeps: UpdateCommandDeps = { + initUI, + getVersion, + detectCurrentInstall, + buildPackageManagerEnv, + formatManualUpdateCommand, + readInstalledPackageState, + compareVersionsWithPrerelease, + checkForUpdates: loadCheckForUpdates, + spawn, +}; async function resolveTargetVersion( currentVersion: string, - targetTag: 'latest' | 'dev' + targetTag: TargetTag, + deps: UpdateCommandDeps ): Promise { - const { checkForUpdates } = await import('../utils/update-checker'); - const result = await checkForUpdates(currentVersion, true, 'npm', targetTag); + const result = await deps.checkForUpdates(currentVersion, true, 'npm', targetTag); if (result.status === 'update_available' && result.latest) { return result.latest; @@ -47,15 +113,16 @@ async function resolveTargetVersion( return undefined; } -/** - * Handle the update command - * Checks for updates and installs the latest version - */ -export async function handleUpdateCommand(options: UpdateOptions = {}): Promise { - await initUI(); +export async function handleUpdateCommand( + options: UpdateOptions = {}, + injectedDeps: Partial = {} +): Promise { + const deps = { ...defaultDeps, ...injectedDeps }; + await deps.initUI(); const { force = false, beta = false } = options; - const targetTag = beta ? 'dev' : 'latest'; - const currentInstall = detectCurrentInstall(); + const targetTag: TargetTag = beta ? 'dev' : 'latest'; + const currentInstall = deps.detectCurrentInstall(); + const currentVersion = deps.getVersion(); console.log(''); console.log(header('Checking for updates...')); @@ -65,16 +132,20 @@ export async function handleUpdateCommand(options: UpdateOptions = {}): Promise< if (force) { console.log(info(`Force reinstall from @${targetTag} channel...`)); console.log(''); - const expectedVersion = await resolveTargetVersion(CCS_VERSION, targetTag); - await performNpmUpdate(currentInstall, targetTag, true, expectedVersion); + const expectedVersion = await resolveTargetVersion(currentVersion, targetTag, deps); + await performNpmUpdate(currentInstall, targetTag, true, expectedVersion, deps); return; } - const { checkForUpdates } = await import('../utils/update-checker'); - const updateResult = await checkForUpdates(CCS_VERSION, true, 'npm', targetTag); + const updateResult = await deps.checkForUpdates(currentVersion, true, 'npm', targetTag); if (updateResult.status === 'check_failed') { - handleCheckFailed(updateResult.message ?? 'Update check failed', targetTag, currentInstall); + handleCheckFailed( + updateResult.message ?? 'Update check failed', + targetTag, + currentInstall, + deps + ); return; } @@ -91,7 +162,7 @@ export async function handleUpdateCommand(options: UpdateOptions = {}): Promise< const isDowngrade = updateResult.latest && updateResult.current && - compareVersionsWithPrerelease(updateResult.latest, updateResult.current) < 0; + deps.compareVersionsWithPrerelease(updateResult.latest, updateResult.current) < 0; // This happens when stable user requests @dev but @dev base is older if (isDowngrade && beta) { @@ -115,7 +186,7 @@ export async function handleUpdateCommand(options: UpdateOptions = {}): Promise< console.log(''); } - await performNpmUpdate(currentInstall, targetTag, false, updateResult.latest); + await performNpmUpdate(currentInstall, targetTag, false, updateResult.latest, deps); } /** @@ -124,7 +195,8 @@ export async function handleUpdateCommand(options: UpdateOptions = {}): Promise< function handleCheckFailed( message: string, targetTag: string = 'latest', - currentInstall: CurrentInstall = detectCurrentInstall() + currentInstall: CurrentInstall = defaultDeps.detectCurrentInstall(), + deps: UpdateCommandDeps = defaultDeps ): void { console.log(fail(message)); console.log(''); @@ -135,7 +207,7 @@ function handleCheckFailed( console.log(''); console.log('Try again later or update manually:'); - console.log(color(` ${formatManualUpdateCommand(targetTag, currentInstall)}`, 'command')); + console.log(color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command')); console.log(''); process.exit(1); } @@ -172,9 +244,10 @@ function verifyCurrentInstallVersion( targetTag: string, expectedVersion?: string, previousState?: InstalledPackageState, - isReinstall: boolean = false + isReinstall: boolean = false, + deps: UpdateCommandDeps = defaultDeps ): void { - const nextState = readInstalledPackageState(currentInstall); + const nextState = deps.readInstalledPackageState(currentInstall); const installedVersion = nextState.version; if (!installedVersion) { console.log(''); @@ -183,7 +256,7 @@ function verifyCurrentInstallVersion( ); console.log(''); console.log('Current install remains ambiguous. Re-run manually:'); - console.log(color(` ${formatManualUpdateCommand(targetTag, currentInstall)}`, 'command')); + console.log(color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command')); console.log(''); process.exit(1); } @@ -199,7 +272,7 @@ function verifyCurrentInstallVersion( return; } - const comparison = compareVersionsWithPrerelease(installedVersion, expectedVersion); + const comparison = deps.compareVersionsWithPrerelease(installedVersion, expectedVersion); if (comparison < 0) { console.log(''); console.log( @@ -216,7 +289,9 @@ function verifyCurrentInstallVersion( } console.log(''); console.log('Re-run manually against the current install:'); - console.log(color(` ${formatManualUpdateCommand(targetTag, currentInstall)}`, 'command')); + console.log( + color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command') + ); console.log(''); process.exit(1); } @@ -236,7 +311,7 @@ function verifyCurrentInstallVersion( ); console.log(''); console.log('Re-run manually against the current install:'); - console.log(color(` ${formatManualUpdateCommand(targetTag, currentInstall)}`, 'command')); + console.log(color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command')); console.log(''); process.exit(1); } @@ -246,15 +321,16 @@ async function performNpmUpdate( currentInstall: CurrentInstall, targetTag: string = 'latest', isReinstall: boolean = false, - expectedVersion?: string + expectedVersion?: string, + deps: UpdateCommandDeps = defaultDeps ): Promise { const packageManager = currentInstall.manager; let updateCommand: string; let updateArgs: string[]; let cacheCommand: string | null; let cacheArgs: string[] | null; - const childEnv = buildPackageManagerEnv(currentInstall); - const previousState = readInstalledPackageState(currentInstall); + const childEnv = deps.buildPackageManagerEnv(currentInstall); + const previousState = deps.readInstalledPackageState(currentInstall); switch (packageManager) { case 'npm': @@ -300,12 +376,12 @@ async function performNpmUpdate( // Also suppress Node deprecation warnings that may come from package managers // Pipe stderr on Windows to filter npm cleanup warnings (EPERM on native modules) const child = isWindows - ? spawn(`${updateCommand} ${updateArgs.join(' ')}`, [], { + ? deps.spawn(`${updateCommand} ${updateArgs.join(' ')}`, [], { stdio: ['inherit', 'inherit', 'pipe'], shell: true, env: { ...childEnv, NODE_NO_WARNINGS: '1' }, }) - : spawn(updateCommand, updateArgs, { stdio: 'inherit', env: childEnv }); + : deps.spawn(updateCommand, updateArgs, { stdio: 'inherit', env: childEnv }); // On Windows, filter stderr to hide npm cleanup warnings (EPERM on bcrypt.node etc.) // These warnings are cosmetic - update succeeds despite file locking by antivirus/indexing @@ -339,7 +415,8 @@ async function performNpmUpdate( targetTag, expectedVersion, previousState, - isReinstall + isReinstall, + deps ); } console.log(''); @@ -353,7 +430,9 @@ async function performNpmUpdate( console.log(fail(`${isReinstall ? 'Reinstall' : 'Update'} failed`)); console.log(''); console.log('Try manually:'); - console.log(color(` ${formatManualUpdateCommand(targetTag, currentInstall)}`, 'command')); + console.log( + color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command') + ); console.log(''); } process.exit(code || 0); @@ -364,7 +443,9 @@ async function performNpmUpdate( console.log(fail(`Failed to run ${packageManager} ${isReinstall ? 'reinstall' : 'update'}`)); console.log(''); console.log('Try manually:'); - console.log(color(` ${formatManualUpdateCommand(targetTag, currentInstall)}`, 'command')); + console.log( + color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command') + ); console.log(''); process.exit(1); }); @@ -383,12 +464,12 @@ async function performNpmUpdate( console.log(info(stepMessage)); // On Windows, use shell with full command string to avoid deprecation warning const cacheChild = isWindows - ? spawn(`${cacheCommand} ${cacheArgs.join(' ')}`, [], { + ? deps.spawn(`${cacheCommand} ${cacheArgs.join(' ')}`, [], { stdio: 'inherit', shell: true, env: { ...childEnv, NODE_NO_WARNINGS: '1' }, }) - : spawn(cacheCommand, cacheArgs, { stdio: 'inherit', env: childEnv }); + : deps.spawn(cacheCommand, cacheArgs, { stdio: 'inherit', env: childEnv }); cacheChild.on('exit', (code) => { if (code !== 0) { diff --git a/tests/unit/commands/update-command-current-install.test.ts b/tests/unit/commands/update-command-current-install.test.ts index 33e7cf51..a0f44ad8 100644 --- a/tests/unit/commands/update-command-current-install.test.ts +++ b/tests/unit/commands/update-command-current-install.test.ts @@ -1,31 +1,31 @@ -import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { handleUpdateCommand, type UpdateCommandDeps } from '../../../src/commands/update-command'; let logLines: string[] = []; let spawnCalls: Array<{ command: string; args: string[]; env?: NodeJS.ProcessEnv }> = []; -let exitCodes: number[] = []; -let stateReads = 0; let originalConsoleLog: typeof console.log; let originalProcessExit: typeof process.exit; -let updateCheckResult: + +type InstalledState = { + version: string | null; + packageJsonMtimeMs: number | null; + scriptMtimeMs: number | null; +}; + +type Scenario = { + beforeState: InstalledState; + afterState: InstalledState; +}; + +type UpdateCheckResult = | { status: 'update_available'; current: string; latest: string } | { status: 'no_update' } | { status: 'check_failed'; message: string }; -let currentInstallOverride = installDescriptor(); - -type Scenario = { - beforeState: { - version: string | null; - packageJsonMtimeMs: number | null; - scriptMtimeMs: number | null; - }; - afterState: { - version: string | null; - packageJsonMtimeMs: number | null; - scriptMtimeMs: number | null; - }; -}; let scenario: Scenario; +let updateCheckResult: UpdateCheckResult; +let currentInstallOverride: ReturnType; +let stateReads = 0; function installDescriptor() { return { @@ -38,67 +38,10 @@ function installDescriptor() { }; } -beforeEach(() => { - logLines = []; - spawnCalls = []; - exitCodes = []; - stateReads = 0; - scenario = { - beforeState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, - afterState: { version: '7.67.0-dev.9', packageJsonMtimeMs: 200, scriptMtimeMs: 200 }, - }; - updateCheckResult = { - status: 'update_available', - current: '7.67.0-dev.5', - latest: '7.67.0-dev.9', - }; - currentInstallOverride = installDescriptor(); - - originalConsoleLog = console.log; - originalProcessExit = process.exit; - - console.log = (...args: unknown[]) => { - logLines.push(args.map(String).join(' ')); - }; - - process.exit = ((code?: number) => { - exitCodes.push(code ?? 0); - throw new Error(`process.exit(${code ?? 0})`); - }) as typeof process.exit; - - mock.module('child_process', () => ({ - spawn: (command: string, args: string[], options?: { env?: NodeJS.ProcessEnv }) => { - spawnCalls.push({ command, args, env: options?.env }); - return { - on: (event: string, callback: (code?: number) => void) => { - if (event === 'exit') { - callback(0); - } - }, - }; - }, - })); - - mock.module('../../../src/utils/ui', () => ({ +function createDeps(): UpdateCommandDeps { + return { initUI: async () => {}, - header: (value: string) => value, - ok: (value: string) => `[OK] ${value}`, - fail: (value: string) => `[X] ${value}`, - warn: (value: string) => `[!] ${value}`, - info: (value: string) => `[i] ${value}`, - color: (value: string) => value, - })); - - mock.module('../../../src/utils/version', () => ({ getVersion: () => '7.67.0-dev.5', - })); - - mock.module('../../../src/utils/update-checker', () => ({ - compareVersionsWithPrerelease: (left: string, right: string) => left.localeCompare(right), - checkForUpdates: async () => updateCheckResult, - })); - - mock.module('../../../src/utils/package-manager-detector', () => ({ detectCurrentInstall: () => currentInstallOverride, buildPackageManagerEnv: () => { if (currentInstallOverride.manager === 'npm') { @@ -138,27 +81,59 @@ beforeEach(() => { stateReads += 1; return stateReads === 1 ? scenario.beforeState : scenario.afterState; }, - })); + compareVersionsWithPrerelease: (left: string, right: string) => left.localeCompare(right), + checkForUpdates: async () => updateCheckResult, + spawn: ((command: string, args: string[], options?: { env?: NodeJS.ProcessEnv }) => { + spawnCalls.push({ command, args, env: options?.env }); + return { + stderr: undefined, + on: (event: string, callback: (code?: number) => void) => { + if (event === 'exit') { + callback(0); + } + }, + }; + }) as typeof UpdateCommandDeps.prototype.spawn, + }; +} + +beforeEach(() => { + logLines = []; + spawnCalls = []; + stateReads = 0; + scenario = { + beforeState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, + afterState: { version: '7.67.0-dev.9', packageJsonMtimeMs: 200, scriptMtimeMs: 200 }, + }; + updateCheckResult = { + status: 'update_available', + current: '7.67.0-dev.5', + latest: '7.67.0-dev.9', + }; + currentInstallOverride = installDescriptor(); + + originalConsoleLog = console.log; + originalProcessExit = process.exit; + + console.log = (...args: unknown[]) => { + logLines.push(args.map(String).join(' ')); + }; + + process.exit = ((code?: number) => { + throw new Error(`process.exit(${code ?? 0})`); + }) as typeof process.exit; }); afterEach(() => { console.log = originalConsoleLog; process.exit = originalProcessExit; - mock.restore(); }); -async function loadHandleUpdateCommand() { - const mod = await import( - `../../../src/commands/update-command?test=${Date.now()}-${Math.random()}` - ); - return mod.handleUpdateCommand; -} - describe('update-command current install handling', () => { it('updates through the current install manager and prefix', async () => { - const handleUpdateCommand = await loadHandleUpdateCommand(); - - await expect(handleUpdateCommand({ beta: true })).rejects.toThrow('process.exit(0)'); + await expect(handleUpdateCommand({ beta: true }, createDeps())).rejects.toThrow( + 'process.exit(0)' + ); const installCall = spawnCalls.find((call) => call.args.includes('install')); @@ -172,9 +147,10 @@ describe('update-command current install handling', () => { beforeState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, afterState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, }; - const handleUpdateCommand = await loadHandleUpdateCommand(); - await expect(handleUpdateCommand({ beta: true })).rejects.toThrow('process.exit(1)'); + await expect(handleUpdateCommand({ beta: true }, createDeps())).rejects.toThrow( + 'process.exit(1)' + ); expect(logLines.join('\n')).toContain('outside the current installation'); expect(logLines.join('\n')).toContain( @@ -187,9 +163,8 @@ describe('update-command current install handling', () => { beforeState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, afterState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, }; - const handleUpdateCommand = await loadHandleUpdateCommand(); - await expect(handleUpdateCommand({ force: true, beta: true })).rejects.toThrow( + await expect(handleUpdateCommand({ force: true, beta: true }, createDeps())).rejects.toThrow( 'process.exit(1)' ); @@ -202,9 +177,8 @@ describe('update-command current install handling', () => { afterState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, }; updateCheckResult = { status: 'no_update' }; - const handleUpdateCommand = await loadHandleUpdateCommand(); - await expect(handleUpdateCommand({ force: true, beta: true })).rejects.toThrow( + await expect(handleUpdateCommand({ force: true, beta: true }, createDeps())).rejects.toThrow( 'process.exit(1)' ); @@ -217,9 +191,8 @@ describe('update-command current install handling', () => { afterState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, }; updateCheckResult = { status: 'check_failed', message: 'network' }; - const handleUpdateCommand = await loadHandleUpdateCommand(); - await expect(handleUpdateCommand({ force: true, beta: true })).rejects.toThrow( + await expect(handleUpdateCommand({ force: true, beta: true }, createDeps())).rejects.toThrow( 'process.exit(1)' ); @@ -231,9 +204,10 @@ describe('update-command current install handling', () => { beforeState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, afterState: { version: '7.67.1-dev.0', packageJsonMtimeMs: 200, scriptMtimeMs: 200 }, }; - const handleUpdateCommand = await loadHandleUpdateCommand(); - await expect(handleUpdateCommand({ beta: true })).rejects.toThrow('process.exit(0)'); + await expect(handleUpdateCommand({ beta: true }, createDeps())).rejects.toThrow( + 'process.exit(0)' + ); expect(logLines.join('\n')).not.toContain('outside the current installation'); }); @@ -244,9 +218,8 @@ describe('update-command current install handling', () => { afterState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 200, scriptMtimeMs: 200 }, }; updateCheckResult = { status: 'no_update' }; - const handleUpdateCommand = await loadHandleUpdateCommand(); - await expect(handleUpdateCommand({ force: true, beta: true })).rejects.toThrow( + await expect(handleUpdateCommand({ force: true, beta: true }, createDeps())).rejects.toThrow( 'process.exit(0)' ); @@ -268,14 +241,15 @@ describe('update-command current install handling', () => { prefix: envValue, }; - const handleUpdateCommand = await loadHandleUpdateCommand(); - - await expect(handleUpdateCommand({ beta: true })).rejects.toThrow('process.exit(0)'); + await expect(handleUpdateCommand({ beta: true }, createDeps())).rejects.toThrow( + 'process.exit(0)' + ); const updateCall = spawnCalls.find( (call) => call.command === manager && call.args.some((arg) => arg.includes('@kaitranntt/ccs@dev')) ); + expect(updateCall?.args).toContain(expectedArg); expect(updateCall?.env?.[envKey]).toBe(envValue); } From a4496ff1d944fbe989d1ae18f7f78eb470846731 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 22:44:46 -0400 Subject: [PATCH 49/54] fix(update): harden install verification flow --- src/commands/update-command.ts | 230 +++++++++--------- src/utils/package-manager-detector.ts | 6 + .../update-command-current-install.test.ts | 52 ++-- 3 files changed, 149 insertions(+), 139 deletions(-) diff --git a/src/commands/update-command.ts b/src/commands/update-command.ts index 4346df7c..fd1c2ad4 100644 --- a/src/commands/update-command.ts +++ b/src/commands/update-command.ts @@ -237,16 +237,16 @@ function handleNoUpdate(reason: string | undefined): void { } /** - * Perform update via npm/yarn/pnpm/bun + * Perform update verification against the current install. */ -function verifyCurrentInstallVersion( +async function verifyCurrentInstallVersion( currentInstall: CurrentInstall, targetTag: string, expectedVersion?: string, previousState?: InstalledPackageState, isReinstall: boolean = false, deps: UpdateCommandDeps = defaultDeps -): void { +): Promise { const nextState = deps.readInstalledPackageState(currentInstall); const installedVersion = nextState.version; if (!installedVersion) { @@ -259,6 +259,7 @@ function verifyCurrentInstallVersion( console.log(color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command')); console.log(''); process.exit(1); + return; } const installChanged = @@ -268,12 +269,26 @@ function verifyCurrentInstallVersion( previousState.scriptMtimeMs !== nextState.scriptMtimeMs); if (expectedVersion && installedVersion !== expectedVersion) { - if (previousState?.version && installedVersion !== previousState.version) { + const postUpdateResult = await deps.checkForUpdates( + installedVersion, + true, + 'npm', + targetTag as TargetTag + ); + + if (postUpdateResult.status === 'no_update') { + return; + } + + if ( + postUpdateResult.status === 'update_available' && + postUpdateResult.latest === installedVersion + ) { return; } const comparison = deps.compareVersionsWithPrerelease(installedVersion, expectedVersion); - if (comparison < 0) { + if (comparison < 0 || installedVersion === previousState?.version) { console.log(''); console.log( fail( @@ -294,6 +309,7 @@ function verifyCurrentInstallVersion( ); console.log(''); process.exit(1); + return; } } @@ -305,18 +321,57 @@ function verifyCurrentInstallVersion( ) { console.log(''); console.log( - fail( - `Reinstall completed, but CCS could not verify that the current installation changed from ${previousState.version}.` + warn( + `Reinstall completed, but CCS could not prove that the current installation changed from ${previousState.version}. Verify the current binary manually if this reinstall was meant to repair a same-version install.` ) ); - console.log(''); - console.log('Re-run manually against the current install:'); - console.log(color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command')); - console.log(''); - process.exit(1); } } +function runChildProcess( + deps: UpdateCommandDeps, + command: string, + args: string[], + options: { + isWindows: boolean; + env: NodeJS.ProcessEnv; + filterCleanupWarnings?: boolean; + } +): Promise { + return new Promise((resolve, reject) => { + const { isWindows, env, filterCleanupWarnings = false } = options; + const child = isWindows + ? deps.spawn(`${command} ${args.join(' ')}`, [], { + stdio: ['inherit', 'inherit', 'pipe'], + shell: true, + env: { ...env, NODE_NO_WARNINGS: '1' }, + }) + : deps.spawn(command, args, { stdio: 'inherit', env }); + + if (isWindows && filterCleanupWarnings && child.stderr) { + let stderrBuffer = ''; + child.stderr.on('data', (data: Buffer) => { + stderrBuffer += data.toString(); + const lines = stderrBuffer.split('\n'); + stderrBuffer = lines.pop() || ''; + for (const line of lines) { + if (!/npm warn cleanup/i.test(line)) { + process.stderr.write(line + '\n'); + } + } + }); + child.stderr.on('close', () => { + if (stderrBuffer && !/npm warn cleanup/i.test(stderrBuffer)) { + process.stderr.write(stderrBuffer); + } + }); + } + + child.on('error', reject); + child.on('exit', (code) => resolve(code ?? 0)); + }); +} + async function performNpmUpdate( currentInstall: CurrentInstall, targetTag: string = 'latest', @@ -371,86 +426,6 @@ async function performNpmUpdate( const isWindows = process.platform === 'win32'; - const performUpdate = (): void => { - // On Windows, use shell with full command string to avoid deprecation warning - // Also suppress Node deprecation warnings that may come from package managers - // Pipe stderr on Windows to filter npm cleanup warnings (EPERM on native modules) - const child = isWindows - ? deps.spawn(`${updateCommand} ${updateArgs.join(' ')}`, [], { - stdio: ['inherit', 'inherit', 'pipe'], - shell: true, - env: { ...childEnv, NODE_NO_WARNINGS: '1' }, - }) - : deps.spawn(updateCommand, updateArgs, { stdio: 'inherit', env: childEnv }); - - // On Windows, filter stderr to hide npm cleanup warnings (EPERM on bcrypt.node etc.) - // These warnings are cosmetic - update succeeds despite file locking by antivirus/indexing - // Use line-buffering to handle chunk splitting (data events don't guarantee message boundaries) - if (isWindows && child.stderr) { - let stderrBuffer = ''; - child.stderr.on('data', (data: Buffer) => { - stderrBuffer += data.toString(); - const lines = stderrBuffer.split('\n'); - stderrBuffer = lines.pop() || ''; // Keep incomplete line in buffer - for (const line of lines) { - // Skip npm cleanup warnings (EPERM, ENOTEMPTY, EBUSY on native module prebuilds) - if (!/npm warn cleanup/i.test(line)) { - process.stderr.write(line + '\n'); - } - } - }); - child.stderr.on('close', () => { - // Flush remaining buffer on stream close - if (stderrBuffer && !/npm warn cleanup/i.test(stderrBuffer)) { - process.stderr.write(stderrBuffer); - } - }); - } - - child.on('exit', (code) => { - if (code === 0) { - if (expectedVersion || previousState?.version) { - verifyCurrentInstallVersion( - currentInstall, - targetTag, - expectedVersion, - previousState, - isReinstall, - deps - ); - } - console.log(''); - console.log(ok(`${isReinstall ? 'Reinstall' : 'Update'} successful!`)); - console.log(''); - console.log(`Run ${color('ccs --version', 'command')} to verify`); - console.log(info(`Tip: Use ${color('ccs config', 'command')} for web-based configuration`)); - console.log(''); - } else { - console.log(''); - console.log(fail(`${isReinstall ? 'Reinstall' : 'Update'} failed`)); - console.log(''); - console.log('Try manually:'); - console.log( - color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command') - ); - console.log(''); - } - process.exit(code || 0); - }); - - child.on('error', () => { - console.log(''); - console.log(fail(`Failed to run ${packageManager} ${isReinstall ? 'reinstall' : 'update'}`)); - console.log(''); - console.log('Try manually:'); - console.log( - color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command') - ); - console.log(''); - process.exit(1); - }); - }; - if (cacheCommand && cacheArgs) { // For bun on Windows, we pre-remove instead of cache clear const isBunPreRemove = packageManager === 'bun' && cacheArgs.includes('remove'); @@ -462,27 +437,62 @@ async function performNpmUpdate( : 'Cache clearing failed, proceeding anyway...'; console.log(info(stepMessage)); - // On Windows, use shell with full command string to avoid deprecation warning - const cacheChild = isWindows - ? deps.spawn(`${cacheCommand} ${cacheArgs.join(' ')}`, [], { - stdio: 'inherit', - shell: true, - env: { ...childEnv, NODE_NO_WARNINGS: '1' }, - }) - : deps.spawn(cacheCommand, cacheArgs, { stdio: 'inherit', env: childEnv }); - - cacheChild.on('exit', (code) => { - if (code !== 0) { + try { + const cacheCode = await runChildProcess(deps, cacheCommand, cacheArgs, { + isWindows, + env: childEnv, + }); + if (cacheCode !== 0) { console.log(warn(failMessage)); } - performUpdate(); + } catch { + console.log(warn(failMessage)); + } + } + + try { + const exitCode = await runChildProcess(deps, updateCommand, updateArgs, { + isWindows, + env: childEnv, + filterCleanupWarnings: true, }); - cacheChild.on('error', () => { - console.log(warn(failMessage)); - performUpdate(); - }); - } else { - performUpdate(); + if (exitCode === 0) { + if (expectedVersion || previousState?.version) { + await verifyCurrentInstallVersion( + currentInstall, + targetTag, + expectedVersion, + previousState, + isReinstall, + deps + ); + } + console.log(''); + console.log(ok(`${isReinstall ? 'Reinstall' : 'Update'} successful!`)); + console.log(''); + console.log(`Run ${color('ccs --version', 'command')} to verify`); + console.log(info(`Tip: Use ${color('ccs config', 'command')} for web-based configuration`)); + console.log(''); + } else { + console.log(''); + console.log(fail(`${isReinstall ? 'Reinstall' : 'Update'} failed`)); + console.log(''); + console.log('Try manually:'); + console.log( + color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command') + ); + console.log(''); + } + + process.exit(exitCode || 0); + } catch { + console.log(''); + console.log(fail(`Failed to run ${packageManager} ${isReinstall ? 'reinstall' : 'update'}`)); + console.log(''); + console.log('Try manually:'); + console.log(color(` ${deps.formatManualUpdateCommand(targetTag, currentInstall)}`, 'command')); + console.log(''); + process.exit(1); } } diff --git a/src/utils/package-manager-detector.ts b/src/utils/package-manager-detector.ts index 4e5cea35..606c58ed 100644 --- a/src/utils/package-manager-detector.ts +++ b/src/utils/package-manager-detector.ts @@ -123,6 +123,12 @@ function inferInstallFromPath( normalizedPath.includes('/global/') && normalizedPath.includes('/node_modules/@kaitranntt/ccs') ) { + const pnpmFlatMatch = normalizedPath.match(/\/global\/([^/]+)\/node_modules\/@kaitranntt\/ccs/); + + if (!pnpmFlatMatch || pnpmFlatMatch[1] === 'lib') { + return null; + } + return { manager: 'pnpm', prefix: getPrefixBeforeMarker(targetPath, `${path.sep}global${path.sep}`), diff --git a/tests/unit/commands/update-command-current-install.test.ts b/tests/unit/commands/update-command-current-install.test.ts index a0f44ad8..d65d6a51 100644 --- a/tests/unit/commands/update-command-current-install.test.ts +++ b/tests/unit/commands/update-command-current-install.test.ts @@ -3,6 +3,7 @@ import { handleUpdateCommand, type UpdateCommandDeps } from '../../../src/comman let logLines: string[] = []; let spawnCalls: Array<{ command: string; args: string[]; env?: NodeJS.ProcessEnv }> = []; +let exitCodes: number[] = []; let originalConsoleLog: typeof console.log; let originalProcessExit: typeof process.exit; @@ -100,6 +101,7 @@ function createDeps(): UpdateCommandDeps { beforeEach(() => { logLines = []; spawnCalls = []; + exitCodes = []; stateReads = 0; scenario = { beforeState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, @@ -120,7 +122,7 @@ beforeEach(() => { }; process.exit = ((code?: number) => { - throw new Error(`process.exit(${code ?? 0})`); + exitCodes.push(code ?? 0); }) as typeof process.exit; }); @@ -131,15 +133,14 @@ afterEach(() => { describe('update-command current install handling', () => { it('updates through the current install manager and prefix', async () => { - await expect(handleUpdateCommand({ beta: true }, createDeps())).rejects.toThrow( - 'process.exit(0)' - ); + await handleUpdateCommand({ beta: true }, createDeps()); const installCall = spawnCalls.find((call) => call.args.includes('install')); expect(installCall?.command).toBe('npm'); expect(installCall?.args).toEqual(['install', '-g', '@kaitranntt/ccs@dev']); expect(installCall?.env?.npm_config_prefix).toBe('/tmp/ccs-prefix'); + expect(exitCodes).toContain(0); }); it('fails when another manager updated elsewhere but the current binary stayed stale', async () => { @@ -148,14 +149,13 @@ describe('update-command current install handling', () => { afterState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, }; - await expect(handleUpdateCommand({ beta: true }, createDeps())).rejects.toThrow( - 'process.exit(1)' - ); + await handleUpdateCommand({ beta: true }, createDeps()); expect(logLines.join('\n')).toContain('outside the current installation'); expect(logLines.join('\n')).toContain( 'NPM_CONFIG_PREFIX=/tmp/ccs-prefix npm install -g @kaitranntt/ccs@dev' ); + expect(exitCodes).toContain(1); }); it('keeps force mode under exact target-version verification', async () => { @@ -164,39 +164,36 @@ describe('update-command current install handling', () => { afterState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, }; - await expect(handleUpdateCommand({ force: true, beta: true }, createDeps())).rejects.toThrow( - 'process.exit(1)' - ); + await handleUpdateCommand({ force: true, beta: true }, createDeps()); expect(logLines.join('\n')).toContain('outside the current installation'); + expect(exitCodes).toContain(1); }); - it('fails force mode when target resolution says no update and the current install stays unchanged', async () => { + it('warns but succeeds when target resolution says no update and the current install stays unchanged', async () => { scenario = { beforeState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, afterState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, }; updateCheckResult = { status: 'no_update' }; - await expect(handleUpdateCommand({ force: true, beta: true }, createDeps())).rejects.toThrow( - 'process.exit(1)' - ); + await handleUpdateCommand({ force: true, beta: true }, createDeps()); - expect(logLines.join('\n')).toContain('could not verify that the current installation changed'); + expect(logLines.join('\n')).toContain('could not prove that the current installation changed'); + expect(exitCodes).toContain(0); }); - it('fails force mode when target version resolution fails and the current install stays unchanged', async () => { + it('warns but succeeds when target version resolution fails and the current install stays unchanged', async () => { scenario = { beforeState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, afterState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, }; updateCheckResult = { status: 'check_failed', message: 'network' }; - await expect(handleUpdateCommand({ force: true, beta: true }, createDeps())).rejects.toThrow( - 'process.exit(1)' - ); + await handleUpdateCommand({ force: true, beta: true }, createDeps()); - expect(logLines.join('\n')).toContain('could not verify that the current installation changed'); + expect(logLines.join('\n')).toContain('could not prove that the current installation changed'); + expect(exitCodes).toContain(0); }); it('accepts a newer installed version when the dist-tag moves during update', async () => { @@ -205,11 +202,10 @@ describe('update-command current install handling', () => { afterState: { version: '7.67.1-dev.0', packageJsonMtimeMs: 200, scriptMtimeMs: 200 }, }; - await expect(handleUpdateCommand({ beta: true }, createDeps())).rejects.toThrow( - 'process.exit(0)' - ); + await handleUpdateCommand({ beta: true }, createDeps()); expect(logLines.join('\n')).not.toContain('outside the current installation'); + expect(exitCodes).toContain(0); }); it('accepts force reinstall when the version stays the same but the current install files change', async () => { @@ -219,13 +215,12 @@ describe('update-command current install handling', () => { }; updateCheckResult = { status: 'no_update' }; - await expect(handleUpdateCommand({ force: true, beta: true }, createDeps())).rejects.toThrow( - 'process.exit(0)' - ); + await handleUpdateCommand({ force: true, beta: true }, createDeps()); expect(logLines.join('\n')).not.toContain( 'could not verify that the current installation changed' ); + expect(exitCodes).toContain(0); }); it.each([ @@ -241,9 +236,7 @@ describe('update-command current install handling', () => { prefix: envValue, }; - await expect(handleUpdateCommand({ beta: true }, createDeps())).rejects.toThrow( - 'process.exit(0)' - ); + await handleUpdateCommand({ beta: true }, createDeps()); const updateCall = spawnCalls.find( (call) => @@ -252,6 +245,7 @@ describe('update-command current install handling', () => { expect(updateCall?.args).toContain(expectedArg); expect(updateCall?.env?.[envKey]).toBe(envValue); + expect(exitCodes).toContain(0); } ); }); From 94bcad2d1a7c843be5a75aba1140d997f7fcc0bd Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 23:06:37 -0400 Subject: [PATCH 50/54] fix(update): align no-update dependency injection --- src/commands/update-command.ts | 42 +++---------------- src/utils/update-checker.ts | 2 +- .../update-command-current-install.test.ts | 25 +++++++---- 3 files changed, 25 insertions(+), 44 deletions(-) diff --git a/src/commands/update-command.ts b/src/commands/update-command.ts index fd1c2ad4..a7354102 100644 --- a/src/commands/update-command.ts +++ b/src/commands/update-command.ts @@ -15,7 +15,7 @@ import { type CurrentInstall, type InstalledPackageState, } from '../utils/package-manager-detector'; -import { compareVersionsWithPrerelease } from '../utils/update-checker'; +import { compareVersionsWithPrerelease, type UpdateResult } from '../utils/update-checker'; import { getVersion } from '../utils/version'; /** @@ -28,29 +28,6 @@ export interface UpdateOptions { type TargetTag = 'latest' | 'dev'; -type UpdateCheckResult = - | { - status: 'update_available'; - current?: string; - latest?: string; - message?: string; - reason?: string; - } - | { - status: 'check_failed'; - message?: string; - latest?: string; - current?: string; - reason?: string; - } - | { - status: 'no_update'; - reason?: string; - latest?: string; - current?: string; - message?: string; - }; - export interface UpdateCommandDeps { initUI: typeof initUI; getVersion: typeof getVersion; @@ -64,7 +41,7 @@ export interface UpdateCommandDeps { interactive: boolean, channel: 'npm' | 'direct', targetTag: TargetTag - ) => Promise; + ) => Promise; spawn: typeof spawn; } @@ -73,14 +50,9 @@ async function loadCheckForUpdates( interactive: boolean, channel: 'npm' | 'direct', targetTag: TargetTag -): Promise { +): Promise { const { checkForUpdates } = await import('../utils/update-checker'); - return checkForUpdates( - currentVersion, - interactive, - channel, - targetTag - ) as Promise; + return checkForUpdates(currentVersion, interactive, channel, targetTag); } const defaultDeps: UpdateCommandDeps = { @@ -150,7 +122,7 @@ export async function handleUpdateCommand( } if (updateResult.status === 'no_update') { - handleNoUpdate(updateResult.reason); + handleNoUpdate(updateResult.reason, currentVersion); return; } @@ -215,9 +187,7 @@ function handleCheckFailed( /** * Handle no update available */ -function handleNoUpdate(reason: string | undefined): void { - const version = getVersion(); - +function handleNoUpdate(reason: string | undefined, version: string): void { let message = `You are already on the latest version (${version})`; switch (reason) { diff --git a/src/utils/update-checker.ts b/src/utils/update-checker.ts index 019e0c6e..002a17a5 100644 --- a/src/utils/update-checker.ts +++ b/src/utils/update-checker.ts @@ -21,7 +21,7 @@ interface UpdateCache { dismissed_version: string | null; } -interface UpdateResult { +export interface UpdateResult { status: 'update_available' | 'no_update' | 'check_failed'; reason?: string; latest?: string; diff --git a/tests/unit/commands/update-command-current-install.test.ts b/tests/unit/commands/update-command-current-install.test.ts index d65d6a51..ef540deb 100644 --- a/tests/unit/commands/update-command-current-install.test.ts +++ b/tests/unit/commands/update-command-current-install.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { handleUpdateCommand, type UpdateCommandDeps } from '../../../src/commands/update-command'; +import type { UpdateResult } from '../../../src/utils/update-checker'; let logLines: string[] = []; let spawnCalls: Array<{ command: string; args: string[]; env?: NodeJS.ProcessEnv }> = []; @@ -18,13 +19,8 @@ type Scenario = { afterState: InstalledState; }; -type UpdateCheckResult = - | { status: 'update_available'; current: string; latest: string } - | { status: 'no_update' } - | { status: 'check_failed'; message: string }; - let scenario: Scenario; -let updateCheckResult: UpdateCheckResult; +let updateCheckResult: UpdateResult; let currentInstallOverride: ReturnType; let stateReads = 0; @@ -39,7 +35,7 @@ function installDescriptor() { }; } -function createDeps(): UpdateCommandDeps { +function createDeps(overrides: Partial = {}): UpdateCommandDeps { return { initUI: async () => {}, getVersion: () => '7.67.0-dev.5', @@ -95,6 +91,7 @@ function createDeps(): UpdateCommandDeps { }, }; }) as typeof UpdateCommandDeps.prototype.spawn, + ...overrides, }; } @@ -196,6 +193,20 @@ describe('update-command current install handling', () => { expect(exitCodes).toContain(0); }); + it('uses the injected version in the no-update message', async () => { + updateCheckResult = { status: 'no_update' }; + + await handleUpdateCommand( + {}, + createDeps({ + getVersion: () => '9.9.9-test.1', + }) + ); + + expect(logLines.join('\n')).toContain('latest version (9.9.9-test.1)'); + expect(exitCodes).toContain(0); + }); + it('accepts a newer installed version when the dist-tag moves during update', async () => { scenario = { beforeState: { version: '7.67.0-dev.5', packageJsonMtimeMs: 100, scriptMtimeMs: 100 }, From 4b9fa4e45787950c73d6b9792d8e1bf2c25a7f5b Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 23:19:49 -0400 Subject: [PATCH 51/54] test(cliproxy): isolate session tracker config env --- .../unit/cliproxy/session-tracker-port.test.js | 17 ++++++++++++++++- tests/unit/cliproxy/session-tracker.test.js | 17 ++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/tests/unit/cliproxy/session-tracker-port.test.js b/tests/unit/cliproxy/session-tracker-port.test.js index 1826a2aa..e5a99d94 100644 --- a/tests/unit/cliproxy/session-tracker-port.test.js +++ b/tests/unit/cliproxy/session-tracker-port.test.js @@ -15,7 +15,10 @@ const testHome = path.join( os.tmpdir(), `ccs-test-session-port-${Date.now()}-${Math.random().toString(36).slice(2)}` ); +const originalCcsHome = process.env.CCS_HOME; +const originalCcsDir = process.env.CCS_DIR; process.env.CCS_HOME = testHome; +delete process.env.CCS_DIR; const { getExistingProxy, @@ -28,6 +31,7 @@ const { deleteSessionLockForPort, } = require('../../../dist/cliproxy/session-tracker'); const { CLIPROXY_DEFAULT_PORT } = require('../../../dist/cliproxy/config-generator'); +const { setGlobalConfigDir } = require('../../../dist/utils/config-manager'); describe('Session Tracker Port-Specific', function () { const variantPort1 = 8318; @@ -35,6 +39,11 @@ describe('Session Tracker Port-Specific', function () { let cliproxyDir; beforeEach(function () { + // Reassert test isolation because other files mutate CCS_DIR/CCS_HOME in the same Bun process. + process.env.CCS_HOME = testHome; + delete process.env.CCS_DIR; + setGlobalConfigDir(undefined); + // Create test directories cliproxyDir = path.join(testHome, '.ccs', 'cliproxy'); fs.mkdirSync(cliproxyDir, { recursive: true }); @@ -49,6 +58,8 @@ describe('Session Tracker Port-Specific', function () { }); afterEach(function () { + setGlobalConfigDir(undefined); + // Clean up session files try { const files = fs.readdirSync(cliproxyDir); @@ -69,7 +80,11 @@ describe('Session Tracker Port-Specific', function () { } catch { // Ignore cleanup errors } - delete process.env.CCS_HOME; + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + if (originalCcsDir !== undefined) process.env.CCS_DIR = originalCcsDir; + else delete process.env.CCS_DIR; + setGlobalConfigDir(undefined); }); describe('Session Lock Path', function () { diff --git a/tests/unit/cliproxy/session-tracker.test.js b/tests/unit/cliproxy/session-tracker.test.js index ac4d7de2..edc75d57 100644 --- a/tests/unit/cliproxy/session-tracker.test.js +++ b/tests/unit/cliproxy/session-tracker.test.js @@ -12,7 +12,10 @@ const os = require('os'); // Set test isolation environment before importing const testHome = path.join(os.tmpdir(), `ccs-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); +const originalCcsHome = process.env.CCS_HOME; +const originalCcsDir = process.env.CCS_DIR; process.env.CCS_HOME = testHome; +delete process.env.CCS_DIR; const { getExistingProxy, @@ -24,6 +27,7 @@ const { stopProxy, getProxyStatus, } = require('../../../dist/cliproxy/session-tracker'); +const { setGlobalConfigDir } = require('../../../dist/utils/config-manager'); describe('Session Tracker', function () { const testPort = 18317; @@ -31,6 +35,11 @@ describe('Session Tracker', function () { let cliproxyDir; beforeEach(function () { + // Reassert test isolation because other files mutate CCS_DIR/CCS_HOME in the same Bun process. + process.env.CCS_HOME = testHome; + delete process.env.CCS_DIR; + setGlobalConfigDir(undefined); + // Create test directories cliproxyDir = path.join(testHome, '.ccs', 'cliproxy'); fs.mkdirSync(cliproxyDir, { recursive: true }); @@ -51,6 +60,8 @@ describe('Session Tracker', function () { }); afterEach(function () { + setGlobalConfigDir(undefined); + // Clean up lock files try { const files = fs.readdirSync(cliproxyDir); @@ -71,7 +82,11 @@ describe('Session Tracker', function () { } catch { // Ignore cleanup errors } - delete process.env.CCS_HOME; + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + if (originalCcsDir !== undefined) process.env.CCS_DIR = originalCcsDir; + else delete process.env.CCS_DIR; + setGlobalConfigDir(undefined); }); describe('getExistingProxy', function () { From b8aa59a8f0d56e3a2589bf7714d54392a0325dd8 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 23:26:12 -0400 Subject: [PATCH 52/54] test(ci): isolate config env and cli entry fallbacks --- tests/npm/cli.test.js | 15 ++++++++++++-- tests/npm/special-commands.test.js | 33 ++++++++++++++++++++---------- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/tests/npm/cli.test.js b/tests/npm/cli.test.js index d32055b4..77c3a0a5 100644 --- a/tests/npm/cli.test.js +++ b/tests/npm/cli.test.js @@ -1,13 +1,24 @@ const assert = require('assert'); +const fs = require('fs'); const { execSync } = require('child_process'); const path = require('path'); const { createTestEnvironment } = require('../shared/fixtures/test-environment'); describe('npm CLI', () => { - const ccsPath = path.join(__dirname, '..', '..', 'dist', 'ccs.js'); + const distCcsPath = path.join(__dirname, '..', '..', 'dist', 'ccs.js'); + const srcCcsPath = path.join(__dirname, '..', '..', 'src', 'ccs.ts'); let testEnv; let testCcsHome; + function buildCliCommand(args = '') { + if (fs.existsSync(distCcsPath)) { + return `node "${distCcsPath}" ${args}`; + } + + // Some test files rebuild or clean dist during the same Bun process. + return `bun "${srcCcsPath}" ${args}`; + } + beforeAll(() => { // Create isolated test environment testEnv = createTestEnvironment(); @@ -30,7 +41,7 @@ describe('npm CLI', () => { // Helper to run CLI with test environment function runCli(args, options = {}) { - return execSync(`node "${ccsPath}" ${args}`, { + return execSync(buildCliCommand(args), { ...options, env: { ...process.env, CCS_HOME: testCcsHome } }); diff --git a/tests/npm/special-commands.test.js b/tests/npm/special-commands.test.js index 84f3f4d6..13726274 100644 --- a/tests/npm/special-commands.test.js +++ b/tests/npm/special-commands.test.js @@ -1,25 +1,36 @@ const assert = require('assert'); +const fs = require('fs'); const { execSync } = require('child_process'); const path = require('path'); describe('integration: special commands', () => { - const ccsPath = path.join(__dirname, '..', '..', 'dist', 'ccs.js'); + const distCcsPath = path.join(__dirname, '..', '..', 'dist', 'ccs.js'); + const srcCcsPath = path.join(__dirname, '..', '..', 'src', 'ccs.ts'); + + function buildCliCommand(args = '') { + if (fs.existsSync(distCcsPath)) { + return `node "${distCcsPath}" ${args}`; + } + + // Some tests rebuild or clean dist during the same Bun run. + return `bun "${srcCcsPath}" ${args}`; + } it('shows version with --version', () => { - const output = execSync(`node ${ccsPath} --version`, { encoding: 'utf8' }); + const output = execSync(buildCliCommand('--version'), { encoding: 'utf8' }); assert(output.includes('CCS (Claude Code Switch)')); assert(/v\d+\.\d+\.\d+/.test(output)); }); it('shows version with -v', () => { - const output = execSync(`node ${ccsPath} -v`, { encoding: 'utf8' }); + const output = execSync(buildCliCommand('-v'), { encoding: 'utf8' }); assert(/v\d+\.\d+\.\d+/.test(output)); }); it('shows help with --help', function() { // Note: Requires claude installation, so we just test that it doesn't crash try { - const output = execSync(`node ${ccsPath} --help`, { + const output = execSync(buildCliCommand('--help'), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); @@ -31,14 +42,14 @@ describe('integration: special commands', () => { }); it('handles --install command', () => { - const output = execSync(`node ${ccsPath} --install`, { encoding: 'utf8' }); + const output = execSync(buildCliCommand('--install'), { encoding: 'utf8' }); assert(output.includes('Feature not available')); assert(output.includes('under development')); assert(output.includes('.claude/ integration testing')); }); it('handles --uninstall command', () => { - const output = execSync(`node ${ccsPath} --uninstall`, { encoding: 'utf8' }); + const output = execSync(buildCliCommand('--uninstall'), { encoding: 'utf8' }); assert(output.includes('Uninstalling CCS')); assert(output.includes('[OK] Uninstall complete!') || output.includes('Nothing to uninstall')); }); @@ -47,7 +58,7 @@ describe('integration: special commands', () => { it.skip('parses --force flag without error', function() { // Skip: requires network/child process // Note: This will fail at update check (no network in test), but proves flag parsing works try { - execSync(`node ${ccsPath} update --force`, { + execSync(buildCliCommand('update --force'), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 @@ -62,7 +73,7 @@ describe('integration: special commands', () => { it.skip('parses --beta flag without error', function() { // Skip: requires network/child process try { - execSync(`node ${ccsPath} update --beta`, { + execSync(buildCliCommand('update --beta'), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 @@ -75,7 +86,7 @@ describe('integration: special commands', () => { it.skip('parses combined --force --beta flags', function() { // Skip: requires network/child process try { - execSync(`node ${ccsPath} update --force --beta`, { + execSync(buildCliCommand('update --force --beta'), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 @@ -89,7 +100,7 @@ describe('integration: special commands', () => { it.skip('shows appropriate error for direct install with --beta', function() { // Skip: requires network/child process // Test direct install rejection of --beta flag try { - execSync(`node ${ccsPath} update --beta`, { + execSync(buildCliCommand('update --beta'), { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 @@ -111,4 +122,4 @@ describe('integration: special commands', () => { } }); }); -}); \ No newline at end of file +}); From ef751723f5088ff39184e322cb69aa8186cf2053 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Fri, 10 Apr 2026 23:57:58 -0400 Subject: [PATCH 53/54] test(ci): stop leaking update-command module mocks --- .../update-command-install-origin.test.ts | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/tests/integration/update-command-install-origin.test.ts b/tests/integration/update-command-install-origin.test.ts index ab10fabe..294a45f3 100644 --- a/tests/integration/update-command-install-origin.test.ts +++ b/tests/integration/update-command-install-origin.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; @@ -74,29 +74,6 @@ beforeEach(() => { process.exit = ((code?: number) => { exitCodes.push(code ?? 0); }) as typeof process.exit; - - mock.module('../../src/utils/ui', () => ({ - initUI: async () => {}, - header: (value: string) => value, - ok: (value: string) => `[OK] ${value}`, - fail: (value: string) => `[X] ${value}`, - warn: (value: string) => `[!] ${value}`, - info: (value: string) => `[i] ${value}`, - color: (value: string) => value, - })); - - mock.module('../../src/utils/version', () => ({ - getVersion: () => '7.67.0-dev.5', - })); - - mock.module('../../src/utils/update-checker', () => ({ - compareVersionsWithPrerelease: (left: string, right: string) => left.localeCompare(right), - checkForUpdates: async () => ({ - status: 'update_available', - current: '7.67.0-dev.5', - latest: '7.67.0-dev.9', - }), - })); }); afterEach(() => { @@ -104,7 +81,6 @@ afterEach(() => { process.exit = originalProcessExit; process.argv[1] = originalArgv1; process.env.PATH = originalPath; - mock.restore(); if (tempRoot) { rmSync(tempRoot, { recursive: true, force: true }); } @@ -147,7 +123,19 @@ exit 0 const handleUpdateCommand = await loadHandleUpdateCommand(); - await handleUpdateCommand({ beta: true }); + await handleUpdateCommand( + { beta: true }, + { + initUI: async () => {}, + getVersion: () => '7.67.0-dev.5', + compareVersionsWithPrerelease: (left: string, right: string) => left.localeCompare(right), + checkForUpdates: async () => ({ + status: 'update_available', + current: '7.67.0-dev.5', + latest: '7.67.0-dev.9', + }), + } + ); await waitForExitCode(0); expect(readPackageVersion(currentPackageRoot)).toBe('7.67.0-dev.9'); @@ -175,7 +163,19 @@ exit 0 const handleUpdateCommand = await loadHandleUpdateCommand(); - await handleUpdateCommand({ beta: true }); + await handleUpdateCommand( + { beta: true }, + { + initUI: async () => {}, + getVersion: () => '7.67.0-dev.5', + compareVersionsWithPrerelease: (left: string, right: string) => left.localeCompare(right), + checkForUpdates: async () => ({ + status: 'update_available', + current: '7.67.0-dev.5', + latest: '7.67.0-dev.9', + }), + } + ); await waitForExitCode(1); expect(readPackageVersion(currentPackageRoot)).toBe('7.67.0-dev.5'); From e80bdb9116b92b8e8da2bd09e1ffc20160708cf1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 11 Apr 2026 04:04:05 +0000 Subject: [PATCH 54/54] chore(release): 7.67.0-dev.10 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index dbddd585..cc8f24d8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.67.0-dev.9", + "version": "7.67.0-dev.10", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli",