feat(acp+): models/providers in desktop on ACP+ (#9987)
This commit is contained in:
@@ -3,23 +3,19 @@ import { render, screen, waitFor, type RenderOptions } from '@testing-library/re
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import AuthSettingsSection from './AuthSettingsSection';
|
||||
import {
|
||||
configureProviderOauth,
|
||||
deleteProviderSecret,
|
||||
listProviderSecrets,
|
||||
ProviderSecret,
|
||||
} from '../../../api';
|
||||
acpAuthenticateProvider,
|
||||
acpDeleteProviderSecret,
|
||||
acpListProviderSecrets,
|
||||
type ProviderSecretDto,
|
||||
} from '../../../acp/providers';
|
||||
import { IntlTestWrapper } from '../../../i18n/test-utils';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
vi.mock('../../../api', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../api')>('../../../api');
|
||||
return {
|
||||
...actual,
|
||||
configureProviderOauth: vi.fn(),
|
||||
listProviderSecrets: vi.fn(),
|
||||
deleteProviderSecret: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock('../../../acp/providers', () => ({
|
||||
acpAuthenticateProvider: vi.fn(),
|
||||
acpListProviderSecrets: vi.fn(),
|
||||
acpDeleteProviderSecret: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../ModelAndProviderContext', () => ({
|
||||
useModelAndProvider: () => ({
|
||||
@@ -34,41 +30,35 @@ vi.mock('react-toastify', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedListProviderSecrets = vi.mocked(listProviderSecrets);
|
||||
const mockedDeleteProviderSecret = vi.mocked(deleteProviderSecret);
|
||||
const mockedConfigureProviderOauth = vi.mocked(configureProviderOauth);
|
||||
const mockedListProviderSecrets = vi.mocked(acpListProviderSecrets);
|
||||
const mockedDeleteProviderSecret = vi.mocked(acpDeleteProviderSecret);
|
||||
const mockedAcpAuthenticateProvider = vi.mocked(acpAuthenticateProvider);
|
||||
const mockedToast = vi.mocked(toast);
|
||||
|
||||
const renderWithIntl = (ui: React.ReactElement, options?: RenderOptions) =>
|
||||
render(ui, { wrapper: IntlTestWrapper, ...options });
|
||||
|
||||
const providerSecret: ProviderSecret = {
|
||||
const providerSecret: ProviderSecretDto = {
|
||||
id: 'secret_store:openai:OPENAI_API_KEY',
|
||||
provider: 'openai',
|
||||
provider_display_name: 'OpenAI',
|
||||
providerDisplayName: 'OpenAI',
|
||||
name: 'OPENAI_API_KEY',
|
||||
storage: 'secret_store',
|
||||
expires_at: null,
|
||||
expiresAt: null,
|
||||
status: 'unknown',
|
||||
configured: true,
|
||||
has_secret: true,
|
||||
can_delete: true,
|
||||
can_configure: false,
|
||||
configure_provider: null,
|
||||
hasSecret: true,
|
||||
canDelete: true,
|
||||
canConfigure: false,
|
||||
configureProvider: null,
|
||||
};
|
||||
|
||||
const apiResult = <T,>(data: T) => ({
|
||||
data,
|
||||
request: {} as never,
|
||||
response: {} as never,
|
||||
});
|
||||
|
||||
describe('AuthSettingsSection', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockedListProviderSecrets.mockResolvedValue(apiResult({ secrets: [] }));
|
||||
mockedDeleteProviderSecret.mockResolvedValue(apiResult('ok'));
|
||||
mockedConfigureProviderOauth.mockResolvedValue(apiResult('ok'));
|
||||
mockedListProviderSecrets.mockResolvedValue([]);
|
||||
mockedDeleteProviderSecret.mockResolvedValue(undefined);
|
||||
mockedAcpAuthenticateProvider.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('renders an empty state when no credentials are stored', async () => {
|
||||
@@ -79,17 +69,13 @@ describe('AuthSettingsSection', () => {
|
||||
});
|
||||
|
||||
it('renders provider credentials with storage and expiry status', async () => {
|
||||
mockedListProviderSecrets.mockResolvedValue(
|
||||
apiResult({
|
||||
secrets: [
|
||||
{
|
||||
...providerSecret,
|
||||
expires_at: '2027-01-01T12:00:00Z',
|
||||
status: 'valid',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
mockedListProviderSecrets.mockResolvedValue([
|
||||
{
|
||||
...providerSecret,
|
||||
expiresAt: '2027-01-01T12:00:00Z',
|
||||
status: 'valid',
|
||||
},
|
||||
]);
|
||||
|
||||
renderWithIntl(<AuthSettingsSection />);
|
||||
|
||||
@@ -100,7 +86,7 @@ describe('AuthSettingsSection', () => {
|
||||
});
|
||||
|
||||
it('does not render an expiry badge when expiry is unknown', async () => {
|
||||
mockedListProviderSecrets.mockResolvedValue(apiResult({ secrets: [providerSecret] }));
|
||||
mockedListProviderSecrets.mockResolvedValue([providerSecret]);
|
||||
|
||||
renderWithIntl(<AuthSettingsSection />);
|
||||
|
||||
@@ -113,8 +99,8 @@ describe('AuthSettingsSection', () => {
|
||||
it('deletes a credential after confirmation and refreshes the list', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockedListProviderSecrets
|
||||
.mockResolvedValueOnce(apiResult({ secrets: [providerSecret] }))
|
||||
.mockResolvedValueOnce(apiResult({ secrets: [] }));
|
||||
.mockResolvedValueOnce([providerSecret])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
renderWithIntl(<AuthSettingsSection />);
|
||||
|
||||
@@ -132,10 +118,7 @@ describe('AuthSettingsSection', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedDeleteProviderSecret).toHaveBeenCalledWith({
|
||||
path: { id: 'secret_store:openai:OPENAI_API_KEY' },
|
||||
throwOnError: true,
|
||||
});
|
||||
expect(mockedDeleteProviderSecret).toHaveBeenCalledWith('secret_store:openai:OPENAI_API_KEY');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockedToast.success).toHaveBeenCalledWith('Credential deleted');
|
||||
@@ -145,35 +128,31 @@ describe('AuthSettingsSection', () => {
|
||||
|
||||
it('configures the permanent Hugging Face credential row', async () => {
|
||||
const user = userEvent.setup();
|
||||
const huggingFaceSecret: ProviderSecret = {
|
||||
const huggingFaceSecret: ProviderSecretDto = {
|
||||
id: 'provider_cache:huggingface',
|
||||
provider: 'huggingface',
|
||||
provider_display_name: 'Hugging Face',
|
||||
providerDisplayName: 'Hugging Face',
|
||||
name: 'OAuth token',
|
||||
storage: 'provider_cache',
|
||||
expires_at: null,
|
||||
expiresAt: null,
|
||||
status: 'unknown',
|
||||
configured: false,
|
||||
has_secret: false,
|
||||
can_delete: false,
|
||||
can_configure: true,
|
||||
configure_provider: 'huggingface',
|
||||
hasSecret: false,
|
||||
canDelete: false,
|
||||
canConfigure: true,
|
||||
configureProvider: 'huggingface',
|
||||
};
|
||||
|
||||
mockedListProviderSecrets
|
||||
.mockResolvedValueOnce(apiResult({ secrets: [huggingFaceSecret] }))
|
||||
.mockResolvedValueOnce(
|
||||
apiResult({
|
||||
secrets: [
|
||||
{
|
||||
...huggingFaceSecret,
|
||||
configured: true,
|
||||
has_secret: true,
|
||||
can_delete: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
.mockResolvedValueOnce([huggingFaceSecret])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
...huggingFaceSecret,
|
||||
configured: true,
|
||||
hasSecret: true,
|
||||
canDelete: true,
|
||||
},
|
||||
]);
|
||||
|
||||
renderWithIntl(<AuthSettingsSection />);
|
||||
|
||||
@@ -182,10 +161,7 @@ describe('AuthSettingsSection', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'Sign in' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedConfigureProviderOauth).toHaveBeenCalledWith({
|
||||
path: { name: 'huggingface' },
|
||||
throwOnError: true,
|
||||
});
|
||||
expect(mockedAcpAuthenticateProvider).toHaveBeenCalledWith('huggingface');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockedToast.success).toHaveBeenCalledWith('Credential configured');
|
||||
|
||||
@@ -2,11 +2,11 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import { KeyRound, Loader2, LogIn, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import {
|
||||
configureProviderOauth,
|
||||
deleteProviderSecret,
|
||||
listProviderSecrets,
|
||||
ProviderSecret,
|
||||
} from '../../../api';
|
||||
acpAuthenticateProvider,
|
||||
acpDeleteProviderSecret,
|
||||
acpListProviderSecrets,
|
||||
type ProviderSecretDto,
|
||||
} from '../../../acp/providers';
|
||||
import { errorMessage } from '../../../utils/conversionUtils';
|
||||
import { useModelAndProvider } from '../../ModelAndProviderContext';
|
||||
import { Button } from '../../ui/button';
|
||||
@@ -97,26 +97,26 @@ const i18n = defineMessages({
|
||||
},
|
||||
});
|
||||
|
||||
function storageLabel(secret: ProviderSecret, intl: ReturnType<typeof useIntl>) {
|
||||
function storageLabel(secret: ProviderSecretDto, intl: ReturnType<typeof useIntl>) {
|
||||
if (secret.storage === 'provider_cache') {
|
||||
return intl.formatMessage(i18n.storageProviderCache);
|
||||
}
|
||||
return intl.formatMessage(i18n.storageSecretStore);
|
||||
}
|
||||
|
||||
function expiryLabel(secret: ProviderSecret, intl: ReturnType<typeof useIntl>) {
|
||||
if (!secret.expires_at) {
|
||||
function expiryLabel(secret: ProviderSecretDto, intl: ReturnType<typeof useIntl>) {
|
||||
if (!secret.expiresAt) {
|
||||
return null;
|
||||
}
|
||||
return intl.formatMessage(i18n.expiresAt, {
|
||||
date: intl.formatDate(new Date(secret.expires_at), {
|
||||
date: intl.formatDate(new Date(secret.expiresAt), {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function expiryClass(secret: ProviderSecret) {
|
||||
function expiryClass(secret: ProviderSecretDto) {
|
||||
if (secret.status === 'expired') {
|
||||
return 'border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300';
|
||||
}
|
||||
@@ -126,17 +126,17 @@ function expiryClass(secret: ProviderSecret) {
|
||||
export default function AuthSettingsSection() {
|
||||
const intl = useIntl();
|
||||
const { currentProvider } = useModelAndProvider();
|
||||
const [secrets, setSecrets] = useState<ProviderSecret[]>([]);
|
||||
const [secrets, setSecrets] = useState<ProviderSecretDto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [configuringId, setConfiguringId] = useState<string | null>(null);
|
||||
const [secretToDelete, setSecretToDelete] = useState<ProviderSecret | null>(null);
|
||||
const [secretToDelete, setSecretToDelete] = useState<ProviderSecretDto | null>(null);
|
||||
|
||||
const loadSecrets = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await listProviderSecrets({ throwOnError: true });
|
||||
setSecrets(response.data?.secrets ?? []);
|
||||
const secrets = await acpListProviderSecrets();
|
||||
setSecrets(secrets);
|
||||
} catch {
|
||||
toast.error(intl.formatMessage(i18n.failedToLoad));
|
||||
setSecrets([]);
|
||||
@@ -156,10 +156,7 @@ export default function AuthSettingsSection() {
|
||||
|
||||
setDeletingId(secretToDelete.id);
|
||||
try {
|
||||
await deleteProviderSecret({
|
||||
path: { id: secretToDelete.id },
|
||||
throwOnError: true,
|
||||
});
|
||||
await acpDeleteProviderSecret(secretToDelete.id);
|
||||
toast.success(intl.formatMessage(i18n.deleted));
|
||||
setSecretToDelete(null);
|
||||
await loadSecrets();
|
||||
@@ -174,17 +171,14 @@ export default function AuthSettingsSection() {
|
||||
}
|
||||
};
|
||||
|
||||
const configureSecret = async (secret: ProviderSecret) => {
|
||||
if (!secret.configure_provider) {
|
||||
const configureSecret = async (secret: ProviderSecretDto) => {
|
||||
if (!secret.configureProvider) {
|
||||
return;
|
||||
}
|
||||
|
||||
setConfiguringId(secret.id);
|
||||
try {
|
||||
await configureProviderOauth({
|
||||
path: { name: secret.configure_provider },
|
||||
throwOnError: true,
|
||||
});
|
||||
await acpAuthenticateProvider(secret.configureProvider);
|
||||
toast.success(intl.formatMessage(i18n.signedIn));
|
||||
await loadSecrets();
|
||||
} catch (error) {
|
||||
@@ -229,7 +223,7 @@ export default function AuthSettingsSection() {
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-medium text-text-primary">
|
||||
{secret.provider_display_name}
|
||||
{secret.providerDisplayName}
|
||||
</h3>
|
||||
<span className="rounded border border-border-primary bg-background-secondary px-2 py-0.5 text-xs text-text-secondary">
|
||||
{storageLabel(secret, intl)}
|
||||
@@ -247,7 +241,7 @@ export default function AuthSettingsSection() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-start sm:self-auto">
|
||||
{secret.can_configure && secret.configure_provider && (
|
||||
{secret.canConfigure && secret.configureProvider && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -257,17 +251,17 @@ export default function AuthSettingsSection() {
|
||||
>
|
||||
{configuringId === secret.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : secret.has_secret || secret.configured ? (
|
||||
) : secret.hasSecret || secret.configured ? (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
) : (
|
||||
<LogIn className="h-4 w-4" />
|
||||
)}
|
||||
{secret.has_secret || secret.configured
|
||||
{secret.hasSecret || secret.configured
|
||||
? intl.formatMessage(i18n.reauthorize)
|
||||
: intl.formatMessage(i18n.signIn)}
|
||||
</Button>
|
||||
)}
|
||||
{secret.can_delete && (
|
||||
{secret.canDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -300,7 +294,7 @@ export default function AuthSettingsSection() {
|
||||
secretToDelete
|
||||
? intl.formatMessage(i18n.deleteMessage, {
|
||||
name: secretToDelete.name,
|
||||
provider: secretToDelete.provider_display_name,
|
||||
provider: secretToDelete.providerDisplayName,
|
||||
})
|
||||
: ''
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Loader2, LogIn } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { configureProviderOauth, listProviderSecrets } from '../../../api';
|
||||
import { acpAuthenticateProvider, acpListProviderSecrets } from '../../../acp/providers';
|
||||
import { errorMessage } from '../../../utils/conversionUtils';
|
||||
import { defineMessages, useIntl } from '../../../i18n';
|
||||
import { Button } from '../../ui/button';
|
||||
@@ -51,11 +51,11 @@ export default function HuggingFaceSignInPrompt({
|
||||
const loadStatus = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await listProviderSecrets({ throwOnError: true });
|
||||
const huggingFaceSecret = response.data?.secrets.find(
|
||||
const secrets = await acpListProviderSecrets();
|
||||
const huggingFaceSecret = secrets.find(
|
||||
(secret) => secret.id === HUGGINGFACE_OAUTH_SECRET_ID
|
||||
);
|
||||
setLoggedIn(Boolean(huggingFaceSecret?.has_secret && huggingFaceSecret.status !== 'expired'));
|
||||
setLoggedIn(Boolean(huggingFaceSecret?.hasSecret && huggingFaceSecret.status !== 'expired'));
|
||||
} catch {
|
||||
setLoggedIn(false);
|
||||
} finally {
|
||||
@@ -70,10 +70,7 @@ export default function HuggingFaceSignInPrompt({
|
||||
const signIn = async () => {
|
||||
setSigningIn(true);
|
||||
try {
|
||||
await configureProviderOauth({
|
||||
path: { name: HUGGINGFACE_PROVIDER },
|
||||
throwOnError: true,
|
||||
});
|
||||
await acpAuthenticateProvider(HUGGINGFACE_PROVIDER);
|
||||
toast.success(intl.formatMessage(i18n.signedIn));
|
||||
setLoggedIn(true);
|
||||
onSignedIn?.();
|
||||
|
||||
Reference in New Issue
Block a user