Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 51 additions & 13 deletions inc/Main.php
Original file line number Diff line number Diff line change
Expand Up @@ -158,21 +158,22 @@ function ( $data ) use ( $settings, $post_types_for_js ) {
return array_merge(
$data,
[
'api' => $this->api->get_endpoint(),
'rest_url' => rest_url( $this->api->get_endpoint() ),
'postTypes' => $post_types_for_js,
'hasAPIKey' => isset( $settings['api_key'] ) && ! empty( $settings['api_key'] ),
'chunksLimit' => apply_filters( 'hyve_chunks_limit', 500 ),
'isQdrantActive' => Qdrant_API::is_active(),
'assets' => [
'api' => $this->api->get_endpoint(),
'rest_url' => rest_url( $this->api->get_endpoint() ),
'postTypes' => $post_types_for_js,
'hasAPIKey' => isset( $settings['api_key'] ) && ! empty( $settings['api_key'] ),
'isApiKeyConnected' => self::is_api_key_connected( $settings ),
'chunksLimit' => apply_filters( 'hyve_chunks_limit', 500 ),
'isQdrantActive' => Qdrant_API::is_active(),
'assets' => [
'images' => HYVE_LITE_URL . 'assets/images/',
],
'stats' => $this->get_stats(),
'docs' => 'https://docs.themeisle.com/article/2009-hyve-documentation',
'qdrant_docs' => 'https://docs.themeisle.com/article/2066-integrate-hyve-with-qdrant',
'pro' => 'https://themeisle.com/plugins/hyve/',
'chart' => $this->get_chart_data(),
'hasPro' => apply_filters( 'product_hyve_license_status', false ),
'stats' => $this->get_stats(),
'docs' => 'https://docs.themeisle.com/article/2009-hyve-documentation',
'qdrant_docs' => 'https://docs.themeisle.com/article/2066-integrate-hyve-with-qdrant',
'pro' => 'https://themeisle.com/plugins/hyve/',
'chart' => $this->get_chart_data(),
'hasPro' => apply_filters( 'product_hyve_license_status', false ),
]
);
},
Expand Down Expand Up @@ -641,6 +642,43 @@ function ( $date ) {
];
}

/**
* Determine whether the saved OpenAI API key is connected.
*
* The key is validated against OpenAI whenever it is saved, and any
* key-related failure during use is stored in the error option. The key is
* considered connected when it is set and the last stored error (if any) is
* not one that invalidates the key itself.
*
* @param array<string, mixed> $settings Plugin settings.
*
* @return bool
*/
public static function is_api_key_connected( $settings ) {
if ( empty( $settings['api_key'] ) ) {
return false;
}

$last_error = get_option( OpenAI::ERROR_OPTION_KEY, false );

if ( ! is_array( $last_error ) || empty( $last_error['code'] ) ) {
return true;
}

$key_error_codes = [
'invalid_api_key',
'invalid_authentication',
'account_deactivated',
'billing_not_active',
'organization_not_found',
'organization_deactivated',
'permission_denied',
'insufficient_quota',
];

return ! in_array( $last_error['code'], $key_error_codes, true );
}

/**
* Append services errors if they exists.
*
Expand Down
49 changes: 43 additions & 6 deletions src/backend/parts/settings/Advanced.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
BaseControl,
Button,
ExternalLink,
Icon,
Panel,
PanelRow,
TextControl,
Expand All @@ -20,6 +21,14 @@ import { useDispatch, useSelect } from '@wordpress/data';

import { applyFilters } from '@wordpress/hooks';

const getInitialApiStatus = () => {
if ( window.hyve?.isApiKeyConnected ) {
return 'connected';
}

return window.hyve?.hasAPIKey ? 'error' : 'none';
};

const Advanced = () => {
const settings = useSelect( ( select ) => select( 'hyve' ).getSettings() );

Expand All @@ -29,6 +38,8 @@ const Advanced = () => {

const [ isSaving, setIsSaving ] = useState( false );

const [ apiStatus, setApiStatus ] = useState( getInitialApiStatus );

const onSave = async () => {
setIsSaving( true );

Expand All @@ -47,15 +58,21 @@ const Advanced = () => {

if ( settings.api_key ) {
setHasAPI( true );
setApiStatus( 'connected' );
} else {
setHasAPI( false );
setApiStatus( 'none' );
}

createNotice( 'success', __( 'Settings saved.', 'hyve-lite' ), {
type: 'snackbar',
isDismissible: true,
} );
} catch ( error ) {
if ( settings.api_key ) {
setApiStatus( 'error' );
}

createNotice( 'error', error, {
type: 'snackbar',
isDismissible: true,
Expand Down Expand Up @@ -86,17 +103,37 @@ const Advanced = () => {
featureComponent: 'api-key',
featureValue: 'added',
} );
setApiStatus( 'editing' );
setSetting( 'api_key', newValue );
} }
/>
</BaseControl>

<ExternalLink
href="https://platform.openai.com/api-keys"
className="flex mb-2 items-centertext-sm text-blue-600"
>
{ __( 'Get an API key', 'hyve-lite' ) }
</ExternalLink>
{ 'connected' === apiStatus && (
<p className="mb-2 flex items-center text-green-700">
<Icon icon="yes" className="text-green-600" />
{ __( 'Connected', 'hyve-lite' ) }
</p>
) }

{ 'error' === apiStatus && (
<p className="mb-2 flex items-center text-red-700">
<Icon icon="warning" className="text-red-600" />
{ __(
'Not connected. Please check your API key.',
'hyve-lite'
) }
</p>
) }

{ 'connected' !== apiStatus && (
<ExternalLink
href="https://platform.openai.com/api-keys"
className="flex mb-2 items-centertext-sm text-blue-600"
>
{ __( 'Get an API key', 'hyve-lite' ) }
</ExternalLink>
) }

<Button
variant="primary"
Expand Down
82 changes: 82 additions & 0 deletions tests/php/unit/tests/test-api-key-connected.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php
/**
* Test_Api_Key_Connected class.
*
* @package Codeinwp/HyveLite
*/

use ThemeIsle\HyveLite\Main;
use ThemeIsle\HyveLite\OpenAI;

/**
* Class Test_Api_Key_Connected.
*/
class Test_Api_Key_Connected extends WP_UnitTestCase {
/**
* Clean up the stored error between tests.
*/
public function tear_down() {
delete_option( OpenAI::ERROR_OPTION_KEY );
parent::tear_down();
}

/**
* An empty key is never connected.
*/
public function testEmptyKeyIsNotConnected() {
$this->assertFalse( Main::is_api_key_connected( [] ) );
$this->assertFalse( Main::is_api_key_connected( [ 'api_key' => '' ] ) );
}

/**
* A key with no stored error is connected.
*/
public function testKeyWithoutErrorIsConnected() {
delete_option( OpenAI::ERROR_OPTION_KEY );

$this->assertTrue( Main::is_api_key_connected( [ 'api_key' => 'sk-test' ] ) );
}

/**
* A key-invalidating error reports as not connected.
*/
public function testKeyErrorIsNotConnected() {
$codes = [
'invalid_api_key',
'invalid_authentication',
'account_deactivated',
'billing_not_active',
'organization_not_found',
'organization_deactivated',
'permission_denied',
'insufficient_quota',
];

foreach ( $codes as $code ) {
update_option( OpenAI::ERROR_OPTION_KEY, [ 'code' => $code ] );

$this->assertFalse(
Main::is_api_key_connected( [ 'api_key' => 'sk-test' ] ),
"Code {$code} should mark the key as not connected."
);
}
}

/**
* A transient error (e.g. rate limiting) keeps the key connected.
*/
public function testTransientErrorStaysConnected() {
update_option( OpenAI::ERROR_OPTION_KEY, [ 'code' => 'rate_limit_exceeded' ] );

$this->assertTrue( Main::is_api_key_connected( [ 'api_key' => 'sk-test' ] ) );
}

/**
* A stored error without a code does not invalidate the key.
*/
public function testErrorWithoutCodeStaysConnected() {
update_option( OpenAI::ERROR_OPTION_KEY, [ 'message' => 'Something happened.' ] );

$this->assertTrue( Main::is_api_key_connected( [ 'api_key' => 'sk-test' ] ) );
}
}
Loading