mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 14:16:43 +00:00
fix(cliproxy): harden pinned routing follow-up
This commit is contained in:
@@ -20,7 +20,7 @@ export interface CatalogRoutingSnapshot {
|
||||
|
||||
export async function getCatalogRoutingSnapshot(): Promise<CatalogRoutingSnapshot> {
|
||||
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 {
|
||||
|
||||
@@ -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<ManagementAuthFileRecord[]> {
|
||||
async function fetchManagementEndpoint(path: string, init: RequestInit = {}): Promise<Response> {
|
||||
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<string, string> | undefined),
|
||||
signal: controller.signal,
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async function listAuthFiles(): Promise<ManagementAuthFileRecord[]> {
|
||||
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<ManagementAuthFileRecord[]> {
|
||||
}
|
||||
|
||||
async function patchAuthFilePrefix(name: string, prefix: string): Promise<void> {
|
||||
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<void>
|
||||
}
|
||||
|
||||
async function readAuthFileMetadata(name: string): Promise<AuthFileMetadata> {
|
||||
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<AuthFileMetadata> {
|
||||
export async function ensureManagedModelPrefixes(
|
||||
providers?: CLIProxyProvider[]
|
||||
): Promise<ManagedPrefixSyncResult> {
|
||||
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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1017,7 +1017,6 @@ router.get('/:provider/status', async (req: Request, res: Response): Promise<voi
|
||||
return;
|
||||
}
|
||||
|
||||
pendingManualAuthState.delete(state);
|
||||
try {
|
||||
await ensureManagedModelPrefixes([account.provider]);
|
||||
} catch {
|
||||
@@ -1033,6 +1032,7 @@ router.get('/:provider/status', async (req: Request, res: Response): Promise<voi
|
||||
isDefault: account.isDefault,
|
||||
},
|
||||
});
|
||||
pendingManualAuthState.delete(state);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1167,7 +1167,11 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P
|
||||
}
|
||||
|
||||
if (parsed.state) {
|
||||
pendingManualAuthState.delete(parsed.state);
|
||||
try {
|
||||
await ensureManagedModelPrefixes([account.provider]);
|
||||
} catch {
|
||||
// Keep manual callback success path non-fatal when prefix repair cannot run.
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
@@ -1180,6 +1184,9 @@ router.post('/:provider/submit-callback', async (req: Request, res: Response): P
|
||||
isDefault: account.isDefault,
|
||||
},
|
||||
});
|
||||
if (parsed.state) {
|
||||
pendingManualAuthState.delete(parsed.state);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -111,6 +111,16 @@ describe('ensureManagedModelPrefixes', () => {
|
||||
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: [
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<typeof useQueryClient>): void {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-catalog'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-models'] });
|
||||
}
|
||||
|
||||
async function parseResponseBody(response: Response): Promise<Record<string, unknown>> {
|
||||
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 {
|
||||
|
||||
@@ -14,6 +14,17 @@ import {
|
||||
} from '@/lib/api-client';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
function invalidateCliproxyRoutingQueries(queryClient: ReturnType<typeof useQueryClient>): void {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-catalog'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-models'] });
|
||||
}
|
||||
|
||||
function invalidateCliproxyAccountQueries(queryClient: ReturnType<typeof useQueryClient>): 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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user