Add Hugging Face OAuth support, add auth tab to settings (#9552)

Signed-off-by: jh-block <jhugo@block.xyz>
This commit is contained in:
jh-block
2026-06-03 15:30:33 +02:00
committed by GitHub
parent 9626b4c3c1
commit 30034b9b32
28 changed files with 3894 additions and 75 deletions
+145
View File
@@ -1312,6 +1312,66 @@
}
}
},
"/config/provider-secrets": {
"get": {
"tags": [
"super::routes::config_management"
],
"operationId": "list_provider_secrets",
"responses": {
"200": {
"description": "Provider secrets retrieved successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProviderSecretsResponse"
}
}
}
},
"500": {
"description": "Internal server error"
}
}
}
},
"/config/provider-secrets/{id}": {
"delete": {
"tags": [
"super::routes::config_management"
],
"operationId": "delete_provider_secret",
"parameters": [
{
"name": "id",
"in": "path",
"description": "Provider secret identifier",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Provider secret deleted successfully",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
},
"400": {
"description": "Invalid provider secret identifier"
},
"500": {
"description": "Internal server error"
}
}
}
},
"/config/providers": {
"get": {
"tags": [
@@ -7154,6 +7214,91 @@
}
}
},
"ProviderSecret": {
"type": "object",
"required": [
"id",
"provider",
"provider_display_name",
"name",
"storage",
"status",
"configured",
"has_secret",
"can_delete",
"can_configure"
],
"properties": {
"can_configure": {
"type": "boolean"
},
"can_delete": {
"type": "boolean"
},
"configure_provider": {
"type": "string",
"nullable": true
},
"configured": {
"type": "boolean"
},
"expires_at": {
"type": "string",
"format": "date-time",
"nullable": true
},
"has_secret": {
"type": "boolean"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"provider": {
"type": "string"
},
"provider_display_name": {
"type": "string"
},
"status": {
"$ref": "#/components/schemas/ProviderSecretStatus"
},
"storage": {
"$ref": "#/components/schemas/ProviderSecretStorage"
}
}
},
"ProviderSecretStatus": {
"type": "string",
"enum": [
"valid",
"expired",
"unknown"
]
},
"ProviderSecretStorage": {
"type": "string",
"enum": [
"secret_store",
"provider_cache"
]
},
"ProviderSecretsResponse": {
"type": "object",
"required": [
"secrets"
],
"properties": {
"secrets": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ProviderSecret"
}
}
}
},
"ProviderTemplate": {
"type": "object",
"required": [
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+78
View File
@@ -1030,6 +1030,29 @@ export type ProviderModelInfoQuery = {
model: string;
};
export type ProviderSecret = {
can_configure: boolean;
can_delete: boolean;
configure_provider?: string | null;
configured: boolean;
expires_at?: string | null;
has_secret: boolean;
id: string;
name: string;
provider: string;
provider_display_name: string;
status: ProviderSecretStatus;
storage: ProviderSecretStorage;
};
export type ProviderSecretStatus = 'valid' | 'expired' | 'unknown';
export type ProviderSecretStorage = 'secret_store' | 'provider_cache';
export type ProviderSecretsResponse = {
secrets: Array<ProviderSecret>;
};
export type ProviderTemplate = {
api_url: string;
doc_url: string;
@@ -2718,6 +2741,61 @@ export type GetProviderCatalogTemplateResponses = {
export type GetProviderCatalogTemplateResponse = GetProviderCatalogTemplateResponses[keyof GetProviderCatalogTemplateResponses];
export type ListProviderSecretsData = {
body?: never;
path?: never;
query?: never;
url: '/config/provider-secrets';
};
export type ListProviderSecretsErrors = {
/**
* Internal server error
*/
500: unknown;
};
export type ListProviderSecretsResponses = {
/**
* Provider secrets retrieved successfully
*/
200: ProviderSecretsResponse;
};
export type ListProviderSecretsResponse = ListProviderSecretsResponses[keyof ListProviderSecretsResponses];
export type DeleteProviderSecretData = {
body?: never;
path: {
/**
* Provider secret identifier
*/
id: string;
};
query?: never;
url: '/config/provider-secrets/{id}';
};
export type DeleteProviderSecretErrors = {
/**
* Invalid provider secret identifier
*/
400: unknown;
/**
* Internal server error
*/
500: unknown;
};
export type DeleteProviderSecretResponses = {
/**
* Provider secret deleted successfully
*/
200: string;
};
export type DeleteProviderSecretResponse = DeleteProviderSecretResponses[keyof DeleteProviderSecretResponses];
export type ProvidersData = {
body?: never;
path?: never;
@@ -18,6 +18,7 @@ import {
Keyboard,
HardDrive,
Network,
KeyRound,
} from 'lucide-react';
import { useState, useEffect, useRef } from 'react';
import TunnelSection from './tunnel/TunnelSection';
@@ -25,6 +26,7 @@ import GatewaySettingsSection from './gateways/GatewaySettingsSection';
import { getTunnelStatus } from '../../api/sdk.gen';
import ChatSettingsSection from './chat/ChatSettingsSection';
import KeyboardShortcutsSection from './keyboard/KeyboardShortcutsSection';
import AuthSettingsSection from './auth/AuthSettingsSection';
import LocalInferenceSection from './localInference/LocalInferenceSection';
import MeshSection from './mesh/MeshSection';
import { CONFIGURATION_ENABLED } from '../../updates';
@@ -61,6 +63,10 @@ const i18n = defineMessages({
id: 'settingsView.tabKeyboard',
defaultMessage: 'Keyboard',
},
tabAuth: {
id: 'settingsView.tabAuth',
defaultMessage: 'Auth',
},
tabApp: {
id: 'settingsView.tabApp',
defaultMessage: 'App',
@@ -109,6 +115,7 @@ export default function SettingsView({
chat: 'chat',
prompts: 'prompts',
keyboard: 'keyboard',
auth: 'auth',
gateway: 'sharing',
'local-inference': 'local-inference',
mesh: 'mesh',
@@ -242,6 +249,10 @@ export default function SettingsView({
<Keyboard className="h-4 w-4" />
{intl.formatMessage(i18n.tabKeyboard)}
</TabsTrigger>
<TabsTrigger value="auth" className="flex gap-2" data-testid="settings-auth-tab">
<KeyRound className="h-4 w-4" />
{intl.formatMessage(i18n.tabAuth)}
</TabsTrigger>
<TabsTrigger value="app" className="flex gap-2" data-testid="settings-app-tab">
<Monitor className="h-4 w-4" />
{intl.formatMessage(i18n.tabApp)}
@@ -312,6 +323,13 @@ export default function SettingsView({
<KeyboardShortcutsSection />
</TabsContent>
<TabsContent
value="auth"
className="mt-0 focus-visible:outline-none focus-visible:ring-0"
>
<AuthSettingsSection />
</TabsContent>
<TabsContent
value="app"
className="mt-0 focus-visible:outline-none focus-visible:ring-0"
@@ -0,0 +1,194 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, type RenderOptions } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import AuthSettingsSection from './AuthSettingsSection';
import {
configureProviderOauth,
deleteProviderSecret,
listProviderSecrets,
ProviderSecret,
} from '../../../api';
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('../../ModelAndProviderContext', () => ({
useModelAndProvider: () => ({
currentProvider: 'openai',
}),
}));
vi.mock('react-toastify', () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
const mockedListProviderSecrets = vi.mocked(listProviderSecrets);
const mockedDeleteProviderSecret = vi.mocked(deleteProviderSecret);
const mockedConfigureProviderOauth = vi.mocked(configureProviderOauth);
const mockedToast = vi.mocked(toast);
const renderWithIntl = (ui: React.ReactElement, options?: RenderOptions) =>
render(ui, { wrapper: IntlTestWrapper, ...options });
const providerSecret: ProviderSecret = {
id: 'secret_store:openai:OPENAI_API_KEY',
provider: 'openai',
provider_display_name: 'OpenAI',
name: 'OPENAI_API_KEY',
storage: 'secret_store',
expires_at: null,
status: 'unknown',
configured: true,
has_secret: true,
can_delete: true,
can_configure: false,
configure_provider: 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'));
});
it('renders an empty state when no credentials are stored', async () => {
renderWithIntl(<AuthSettingsSection />);
expect(screen.getByText('Loading credentials...')).toBeInTheDocument();
expect(await screen.findByText('No locally stored provider credentials were found.')).toBeInTheDocument();
});
it('renders provider credentials with storage and expiry status', async () => {
mockedListProviderSecrets.mockResolvedValue(
apiResult({
secrets: [
{
...providerSecret,
expires_at: '2027-01-01T12:00:00Z',
status: 'valid',
},
],
})
);
renderWithIntl(<AuthSettingsSection />);
expect(await screen.findByText('OpenAI')).toBeInTheDocument();
expect(screen.getByText('OPENAI_API_KEY')).toBeInTheDocument();
expect(screen.getByText('Secret store')).toBeInTheDocument();
expect(screen.getByText(/Expires/)).toBeInTheDocument();
});
it('does not render an expiry badge when expiry is unknown', async () => {
mockedListProviderSecrets.mockResolvedValue(apiResult({ secrets: [providerSecret] }));
renderWithIntl(<AuthSettingsSection />);
expect(await screen.findByText('OpenAI')).toBeInTheDocument();
expect(screen.getByText('Secret store')).toBeInTheDocument();
expect(screen.queryByText('Expiry unknown')).not.toBeInTheDocument();
expect(screen.queryByText(/Expires/)).not.toBeInTheDocument();
});
it('deletes a credential after confirmation and refreshes the list', async () => {
const user = userEvent.setup();
mockedListProviderSecrets
.mockResolvedValueOnce(apiResult({ secrets: [providerSecret] }))
.mockResolvedValueOnce(apiResult({ secrets: [] }));
renderWithIntl(<AuthSettingsSection />);
expect(await screen.findByText('OpenAI')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Delete credential' }));
expect(screen.getByText('Delete the OPENAI_API_KEY credential for OpenAI?')).toBeInTheDocument();
expect(
screen.getByText(
'This is the active provider. New requests may fail until you configure another credential.'
)
).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Delete' }));
await waitFor(() => {
expect(mockedDeleteProviderSecret).toHaveBeenCalledWith({
path: { id: 'secret_store:openai:OPENAI_API_KEY' },
throwOnError: true,
});
});
await waitFor(() => {
expect(mockedToast.success).toHaveBeenCalledWith('Credential deleted');
});
expect(await screen.findByText('No locally stored provider credentials were found.')).toBeInTheDocument();
});
it('configures the permanent Hugging Face credential row', async () => {
const user = userEvent.setup();
const huggingFaceSecret: ProviderSecret = {
id: 'provider_cache:huggingface',
provider: 'huggingface',
provider_display_name: 'Hugging Face',
name: 'OAuth token',
storage: 'provider_cache',
expires_at: null,
status: 'unknown',
configured: false,
has_secret: false,
can_delete: false,
can_configure: true,
configure_provider: 'huggingface',
};
mockedListProviderSecrets
.mockResolvedValueOnce(apiResult({ secrets: [huggingFaceSecret] }))
.mockResolvedValueOnce(
apiResult({
secrets: [
{
...huggingFaceSecret,
configured: true,
has_secret: true,
can_delete: true,
},
],
})
);
renderWithIntl(<AuthSettingsSection />);
expect(await screen.findByText('Hugging Face')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Delete credential' })).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Sign in' }));
await waitFor(() => {
expect(mockedConfigureProviderOauth).toHaveBeenCalledWith({
path: { name: 'huggingface' },
throwOnError: true,
});
});
await waitFor(() => {
expect(mockedToast.success).toHaveBeenCalledWith('Credential configured');
});
});
});
@@ -0,0 +1,317 @@
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';
import { errorMessage } from '../../../utils/conversionUtils';
import { useModelAndProvider } from '../../ModelAndProviderContext';
import { Button } from '../../ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
import { ConfirmationModal } from '../../ui/ConfirmationModal';
import { defineMessages, useIntl } from '../../../i18n';
const i18n = defineMessages({
title: {
id: 'authSettings.title',
defaultMessage: 'Provider Credentials',
},
description: {
id: 'authSettings.description',
defaultMessage: 'Manage provider credentials stored locally by goose.',
},
loading: {
id: 'authSettings.loading',
defaultMessage: 'Loading credentials...',
},
empty: {
id: 'authSettings.empty',
defaultMessage: 'No locally stored provider credentials were found.',
},
failedToLoad: {
id: 'authSettings.failedToLoad',
defaultMessage: 'Failed to load provider credentials',
},
deleteTitle: {
id: 'authSettings.deleteTitle',
defaultMessage: 'Delete credential',
},
deleteMessage: {
id: 'authSettings.deleteMessage',
defaultMessage: 'Delete the {name} credential for {provider}?',
},
activeProviderWarning: {
id: 'authSettings.activeProviderWarning',
defaultMessage: 'This is the active provider. New requests may fail until you configure another credential.',
},
delete: {
id: 'authSettings.delete',
defaultMessage: 'Delete',
},
cancel: {
id: 'authSettings.cancel',
defaultMessage: 'Cancel',
},
deleted: {
id: 'authSettings.deleted',
defaultMessage: 'Credential deleted',
},
failedToDelete: {
id: 'authSettings.failedToDelete',
defaultMessage: 'Failed to delete credential: {error}',
},
storageSecretStore: {
id: 'authSettings.storageSecretStore',
defaultMessage: 'Secret store',
},
storageProviderCache: {
id: 'authSettings.storageProviderCache',
defaultMessage: 'Provider cache',
},
expiresAt: {
id: 'authSettings.expiresAt',
defaultMessage: 'Expires {date}',
},
deleteCredential: {
id: 'authSettings.deleteCredential',
defaultMessage: 'Delete credential',
},
signIn: {
id: 'authSettings.signIn',
defaultMessage: 'Sign in',
},
reauthorize: {
id: 'authSettings.reauthorize',
defaultMessage: 'Reauthorize',
},
signedIn: {
id: 'authSettings.signedIn',
defaultMessage: 'Credential configured',
},
failedToConfigure: {
id: 'authSettings.failedToConfigure',
defaultMessage: 'Failed to configure credential: {error}',
},
});
function storageLabel(secret: ProviderSecret, 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) {
return null;
}
return intl.formatMessage(i18n.expiresAt, {
date: intl.formatDate(new Date(secret.expires_at), {
dateStyle: 'medium',
timeStyle: 'short',
}),
});
}
function expiryClass(secret: ProviderSecret) {
if (secret.status === 'expired') {
return 'border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300';
}
return 'border-green-500/30 bg-green-500/10 text-green-700 dark:text-green-300';
}
export default function AuthSettingsSection() {
const intl = useIntl();
const { currentProvider } = useModelAndProvider();
const [secrets, setSecrets] = useState<ProviderSecret[]>([]);
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 loadSecrets = useCallback(async () => {
setLoading(true);
try {
const response = await listProviderSecrets({ throwOnError: true });
setSecrets(response.data?.secrets ?? []);
} catch {
toast.error(intl.formatMessage(i18n.failedToLoad));
setSecrets([]);
} finally {
setLoading(false);
}
}, [intl]);
useEffect(() => {
loadSecrets();
}, [loadSecrets]);
const confirmDelete = async () => {
if (!secretToDelete) {
return;
}
setDeletingId(secretToDelete.id);
try {
await deleteProviderSecret({
path: { id: secretToDelete.id },
throwOnError: true,
});
toast.success(intl.formatMessage(i18n.deleted));
setSecretToDelete(null);
await loadSecrets();
} catch (error) {
toast.error(
intl.formatMessage(i18n.failedToDelete, {
error: errorMessage(error, 'Unknown error'),
})
);
} finally {
setDeletingId(null);
}
};
const configureSecret = async (secret: ProviderSecret) => {
if (!secret.configure_provider) {
return;
}
setConfiguringId(secret.id);
try {
await configureProviderOauth({
path: { name: secret.configure_provider },
throwOnError: true,
});
toast.success(intl.formatMessage(i18n.signedIn));
await loadSecrets();
} catch (error) {
toast.error(
intl.formatMessage(i18n.failedToConfigure, {
error: errorMessage(error, 'Unknown error'),
})
);
} finally {
setConfiguringId(null);
}
};
const isActiveProvider = secretToDelete?.provider === currentProvider;
return (
<section id="auth" className="space-y-4 pr-4 mt-1">
<Card className="pb-2">
<CardHeader className="pb-0">
<CardTitle className="flex items-center gap-2">
<KeyRound className="h-4 w-4" />
{intl.formatMessage(i18n.title)}
</CardTitle>
<CardDescription>{intl.formatMessage(i18n.description)}</CardDescription>
</CardHeader>
<CardContent className="px-4 py-2">
{loading ? (
<div className="flex items-center gap-2 py-6 text-sm text-text-secondary">
<Loader2 className="h-4 w-4 animate-spin" />
{intl.formatMessage(i18n.loading)}
</div>
) : secrets.length === 0 ? (
<div className="py-6 text-sm text-text-secondary">{intl.formatMessage(i18n.empty)}</div>
) : (
<div className="divide-y divide-border-primary">
{secrets.map((secret) => (
<div
key={secret.id}
className="flex flex-col gap-3 py-3 sm:flex-row sm:items-center sm:justify-between"
data-testid="auth-secret-row"
>
<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}
</h3>
<span className="rounded border border-border-primary bg-background-secondary px-2 py-0.5 text-xs text-text-secondary">
{storageLabel(secret, intl)}
</span>
{expiryLabel(secret, intl) && (
<span
className={`rounded border px-2 py-0.5 text-xs ${expiryClass(secret)}`}
>
{expiryLabel(secret, intl)}
</span>
)}
</div>
<p className="mt-1 break-all font-mono text-xs text-text-secondary">
{secret.name}
</p>
</div>
<div className="flex items-center gap-2 self-start sm:self-auto">
{secret.can_configure && secret.configure_provider && (
<Button
variant="outline"
size="sm"
className="gap-2"
disabled={configuringId === secret.id}
onClick={() => configureSecret(secret)}
>
{configuringId === secret.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : secret.has_secret || secret.configured ? (
<RefreshCw className="h-4 w-4" />
) : (
<LogIn className="h-4 w-4" />
)}
{secret.has_secret || secret.configured
? intl.formatMessage(i18n.reauthorize)
: intl.formatMessage(i18n.signIn)}
</Button>
)}
{secret.can_delete && (
<Button
variant="ghost"
size="sm"
shape="round"
className="text-text-secondary hover:text-text-primary"
disabled={deletingId === secret.id}
onClick={() => setSecretToDelete(secret)}
aria-label={intl.formatMessage(i18n.deleteCredential)}
title={intl.formatMessage(i18n.deleteCredential)}
>
{deletingId === secret.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
<ConfirmationModal
isOpen={!!secretToDelete}
title={intl.formatMessage(i18n.deleteTitle)}
message={
secretToDelete
? intl.formatMessage(i18n.deleteMessage, {
name: secretToDelete.name,
provider: secretToDelete.provider_display_name,
})
: ''
}
detail={isActiveProvider ? intl.formatMessage(i18n.activeProviderWarning) : undefined}
onConfirm={confirmDelete}
onCancel={() => setSecretToDelete(null)}
confirmLabel={intl.formatMessage(i18n.delete)}
cancelLabel={intl.formatMessage(i18n.cancel)}
confirmVariant="destructive"
isSubmitting={!!deletingId}
/>
</section>
);
}
@@ -0,0 +1,116 @@
import { useCallback, useEffect, useState } from 'react';
import { Loader2, LogIn } from 'lucide-react';
import { toast } from 'react-toastify';
import { configureProviderOauth, listProviderSecrets } from '../../../api';
import { errorMessage } from '../../../utils/conversionUtils';
import { defineMessages, useIntl } from '../../../i18n';
import { Button } from '../../ui/button';
const HUGGINGFACE_PROVIDER = 'huggingface';
const HUGGINGFACE_OAUTH_SECRET_ID = 'provider_cache:huggingface';
const i18n = defineMessages({
title: {
id: 'huggingFaceSignInPrompt.title',
defaultMessage: 'Hugging Face',
},
signIn: {
id: 'huggingFaceSignInPrompt.signIn',
defaultMessage: 'Sign in',
},
signingIn: {
id: 'huggingFaceSignInPrompt.signingIn',
defaultMessage: 'Signing in...',
},
signedIn: {
id: 'huggingFaceSignInPrompt.signedIn',
defaultMessage: 'Hugging Face signed in',
},
failedToConfigure: {
id: 'huggingFaceSignInPrompt.failedToConfigure',
defaultMessage: 'Failed to sign in to Hugging Face: {error}',
},
});
interface HuggingFaceSignInPromptProps {
description: string;
className?: string;
onSignedIn?: () => void;
}
export default function HuggingFaceSignInPrompt({
description,
className,
onSignedIn,
}: HuggingFaceSignInPromptProps) {
const intl = useIntl();
const [loading, setLoading] = useState(true);
const [loggedIn, setLoggedIn] = useState(false);
const [signingIn, setSigningIn] = useState(false);
const loadStatus = useCallback(async () => {
setLoading(true);
try {
const response = await listProviderSecrets({ throwOnError: true });
const huggingFaceSecret = response.data?.secrets.find(
(secret) => secret.id === HUGGINGFACE_OAUTH_SECRET_ID
);
setLoggedIn(Boolean(huggingFaceSecret?.has_secret && huggingFaceSecret.status !== 'expired'));
} catch {
setLoggedIn(false);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadStatus();
}, [loadStatus]);
const signIn = async () => {
setSigningIn(true);
try {
await configureProviderOauth({
path: { name: HUGGINGFACE_PROVIDER },
throwOnError: true,
});
toast.success(intl.formatMessage(i18n.signedIn));
setLoggedIn(true);
onSignedIn?.();
} catch (error) {
toast.error(
intl.formatMessage(i18n.failedToConfigure, {
error: errorMessage(error, 'Unknown error'),
})
);
await loadStatus();
} finally {
setSigningIn(false);
}
};
if (loading || loggedIn) {
return null;
}
return (
<div
className={`flex flex-col gap-3 rounded-lg border border-border-subtle bg-background-default p-3 sm:flex-row sm:items-center sm:justify-between ${className ?? ''}`}
>
<div className="min-w-0">
<h4 className="text-sm font-medium text-text-default">{intl.formatMessage(i18n.title)}</h4>
<p className="mt-1 text-xs text-text-muted">{description}</p>
</div>
<Button
variant="outline"
size="sm"
className="gap-2 self-start sm:self-auto"
disabled={signingIn}
onClick={signIn}
>
{signingIn ? <Loader2 className="h-4 w-4 animate-spin" /> : <LogIn className="h-4 w-4" />}
{signingIn ? intl.formatMessage(i18n.signingIn) : intl.formatMessage(i18n.signIn)}
</Button>
</div>
);
}
@@ -17,6 +17,7 @@ import {
import { HuggingFaceModelSearch } from './HuggingFaceModelSearch';
import { ModelSettingsPanel } from './ModelSettingsPanel';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../ui/dialog';
import HuggingFaceSignInPrompt from '../auth/HuggingFaceSignInPrompt';
const i18n = defineMessages({
title: {
@@ -96,6 +97,11 @@ const i18n = defineMessages({
id: 'localInferenceSettings.visionEncoderNotDownloaded',
defaultMessage: 'Vision encoder not downloaded',
},
huggingFaceSignInNote: {
id: 'localInferenceSettings.huggingFaceSignInNote',
defaultMessage:
'Sign in to increase rate limits when searching and downloading models, and to access private or gated Hugging Face repositories.',
},
});
const VisionBadge = ({
@@ -328,6 +334,8 @@ export const LocalInferenceSettings = () => {
</p>
</div>
<HuggingFaceSignInPrompt description={intl.formatMessage(i18n.huggingFaceSignInNote)} />
{/* Active Downloads */}
{downloads.size > 0 && (
<div ref={downloadSectionRef}>
@@ -26,6 +26,7 @@ import {
import { Button } from '../../../../components/ui/button';
import { errorMessage } from '../../../../utils/conversionUtils';
import { defineMessages, useIntl } from '../../../../i18n';
import HuggingFaceSignInPrompt from '../../auth/HuggingFaceSignInPrompt';
const i18n = defineMessages({
deleteConfigHeader: {
@@ -114,6 +115,11 @@ const i18n = defineMessages({
id: 'providerConfigurationModal.close',
defaultMessage: 'Close',
},
huggingFaceOAuthDescription: {
id: 'providerConfigurationModal.huggingFaceOAuthDescription',
defaultMessage:
'Sign in to use Hugging Face Inference Providers without manually entering an API token.',
},
});
/** Render a setup step string, turning `backtick` spans into <code> and newlines into <br/>. */
@@ -176,6 +182,7 @@ export default function ProviderConfigurationModal({
const hasOAuth = provider.metadata.config_keys.some((key) => key.oauth_flow);
const hasConfig = configKeys.length > 0;
const hasDeviceCodeFlow = provider.metadata.config_keys.some((key) => key.device_code_flow);
const isHuggingFaceProvider = provider.name === 'huggingface';
const isConfigured = provider.is_configured;
const headerText = showDeleteConfirmation
@@ -422,6 +429,20 @@ export default function ProviderConfigurationModal({
/>
)}
{isHuggingFaceProvider && !hasOAuth && (
<HuggingFaceSignInPrompt
className="mb-4"
description={intl.formatMessage(i18n.huggingFaceOAuthDescription)}
onSignedIn={() => {
if (onConfigured) {
onConfigured(provider);
} else {
onClose();
}
}}
/>
)}
{isExternalSetup && (
<div className="space-y-3">
<p className="text-sm text-text-secondary">
+84
View File
@@ -44,6 +44,66 @@
"appsView.title": {
"defaultMessage": "Apps"
},
"authSettings.activeProviderWarning": {
"defaultMessage": "This is the active provider. New requests may fail until you configure another credential."
},
"authSettings.cancel": {
"defaultMessage": "Cancel"
},
"authSettings.delete": {
"defaultMessage": "Delete"
},
"authSettings.deleteCredential": {
"defaultMessage": "Delete credential"
},
"authSettings.deleteMessage": {
"defaultMessage": "Delete the {name} credential for {provider}?"
},
"authSettings.deleteTitle": {
"defaultMessage": "Delete credential"
},
"authSettings.deleted": {
"defaultMessage": "Credential deleted"
},
"authSettings.description": {
"defaultMessage": "Manage provider credentials stored locally by goose."
},
"authSettings.empty": {
"defaultMessage": "No locally stored provider credentials were found."
},
"authSettings.expiresAt": {
"defaultMessage": "Expires {date}"
},
"authSettings.failedToConfigure": {
"defaultMessage": "Failed to configure credential: {error}"
},
"authSettings.failedToDelete": {
"defaultMessage": "Failed to delete credential: {error}"
},
"authSettings.failedToLoad": {
"defaultMessage": "Failed to load provider credentials"
},
"authSettings.loading": {
"defaultMessage": "Loading credentials..."
},
"authSettings.reauthorize": {
"defaultMessage": "Reauthorize"
},
"authSettings.signIn": {
"defaultMessage": "Sign in"
},
"authSettings.signedIn": {
"defaultMessage": "Credential configured"
},
"authSettings.storageProviderCache": {
"defaultMessage": "Provider cache"
},
"authSettings.storageSecretStore": {
"defaultMessage": "Secret store"
},
"authSettings.title": {
"defaultMessage": "Provider Credentials"
},
"backButton.back": {
"defaultMessage": "Back"
},
@@ -1466,6 +1526,21 @@
"huggingFaceModelSearch.tooLarge": {
"defaultMessage": "May not fit in memory ({size} model, {available} available)"
},
"huggingFaceSignInPrompt.failedToConfigure": {
"defaultMessage": "Failed to sign in to Hugging Face: {error}"
},
"huggingFaceSignInPrompt.signIn": {
"defaultMessage": "Sign in"
},
"huggingFaceSignInPrompt.signedIn": {
"defaultMessage": "Hugging Face signed in"
},
"huggingFaceSignInPrompt.signingIn": {
"defaultMessage": "Signing in..."
},
"huggingFaceSignInPrompt.title": {
"defaultMessage": "Hugging Face"
},
"imagePreview.altText": {
"defaultMessage": "goose image"
},
@@ -1805,6 +1880,9 @@
"localInferenceSettings.featuredModels": {
"defaultMessage": "Featured Models"
},
"localInferenceSettings.huggingFaceSignInNote": {
"defaultMessage": "Sign in to increase rate limits when searching and downloading models, and to access private or gated Hugging Face repositories."
},
"localInferenceSettings.modelSettings": {
"defaultMessage": "Model Settings"
},
@@ -2801,6 +2879,9 @@
"providerConfigurationModal.goBack": {
"defaultMessage": "Go Back"
},
"providerConfigurationModal.huggingFaceOAuthDescription": {
"defaultMessage": "Sign in to use Hugging Face Inference Providers without manually entering an API token."
},
"providerConfigurationModal.oauthLoginFailed": {
"defaultMessage": "OAuth login failed: {error}"
},
@@ -4055,6 +4136,9 @@
"settingsView.tabApp": {
"defaultMessage": "App"
},
"settingsView.tabAuth": {
"defaultMessage": "Auth"
},
"settingsView.tabChat": {
"defaultMessage": "Chat"
},