feat(acp+): models/providers in desktop on ACP+ (#9987)

This commit is contained in:
Alex Hancock
2026-06-26 13:04:24 -04:00
committed by GitHub
parent d1d2166190
commit 2fda760641
49 changed files with 2734 additions and 1687 deletions
@@ -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?.();