[BYOM] Support for Local, Private Endpoints (#26475)

* adds feature flag for private ip addresses

* Adds implementation, and updates tests

Moved some of the earlier endpoint tests into the newer param-based suite for better organization.

* Shows an error when attempting to use a model with an invalid endpoint

Current approach is to check the validity of the endpoint when the first human interaction takes place. If the model endpoint is invalid, an error is shown to the user. The displayed error invites the user to check their model's configuration.

* checks endpoint-validity on model activation

Rather than waiting for the user to interact with the model, this change proactively notifies the user of an invalid model endpoint when the model is selected for use.

* ui change; remove unnecessary gap in list

With the gap, there is an extra bit of padding above each model listing after the first. This gives the impression that the first model in the list is shorter (i.e., its bounding box size) than all that follow. Further, the gap causes asymmetry between the top and bottom padding on every model listing after the first.

* adds informative error message

We aim to provide an instructive error message to the user when they have provided an endpoint value that would only be valid with the enabling of optional private IPs.

* Enhanced model endpoint validation

This change introduces an alternative approach to endpoint validation. Some endpoints are only valid with the enabling of the optional brave-ai-chat-allow-private-ips flag. The new approach informs the user when their provided endpoint URL [would be] valid as a private IP address.

* presubmit fixes

* distinguish between endpoint error types

Upon saving a custom model, the URL may be deemed invalid. This change gives a more detailed message to the frontend regarding the endpoint validity, enabling us to present a more helpful error message to the user.

* clear orphaned error messages

Switching to a model with an invalid endpoint results in an error message being displayed. This change causes the error message to be cleared when switching to another model.

* fixes private endpoint validation logic

* Adds modal and warning label for private endpoints

If the user attempts to save a configuration with a private endpoint, and the optional flag has not been enabled, the user will be presented with a modal dialog informing them as much. If the optional flag has been enabled, and the user attempts to use a private endpoint, we will display a label warning them of the risk they're accepting.

* minor refactor of error-msg logic

Each condition checked `apiHasError`, so we can simplify by moving that to the top-most conditional, and turning the rest of the logic into a switch-case, dropping unnecessary parents around JSX items.

* presubmit fixes and cleanup

* removes unnecessary `async`

This method no longer queries the backend itself, and therefore no longer needs to be async.

* Improve string identifiers and content

To ease efforts for translators, more descriptive identifiers are provided.

* fixes typo

* tighten up expectations

Though not likely to happen, it's possible our method could be called with an invalid number and/or type of arguments. We'll make sure our expectations are clear, and that we reject early otherwise.

* supplemental comment(s) in mojom files

* presubmit fixes following rebase

* removes attempted-support of .local domains

Giving proper support to .local domains requires adequate address resolution, which can be somewhat tricky across platforms. For now, we will defer adding support to a later date. Track https://github.com/brave/brave-browser/issues/42367 for additional details and development.
This commit is contained in:
Sampson
2024-11-21 17:55:09 +00:00
committed by GitHub
parent a91a6cb6b5
commit 02ede1503e
22 changed files with 396 additions and 64 deletions
+12
View File
@@ -1413,6 +1413,18 @@ Leo AI. This action can't be undone.
<message name="IDS_SETTINGS_LEO_ASSISTANT_ENDPOINT_INVALID_ERROR" desc="An error message when the url is invalid">
You have entered an invalid URL. Please enter a valid URL.
</message>
<message name="IDS_SETTINGS_LEO_ASSISTANT_ENDPOINT_POTENTIALLY_UNSAFE_ERROR" desc="An error message when the url is potentially unsafe">
This endpoint is potentially unsafe.
</message>
<message name="IDS_SETTINGS_LEO_ASSISTANT_PRIVATE_IP_NOT_ALLOWED_TITLE" desc="Title for private IP not allowed dialog">
Private IPs Not Allowed
</message>
<message name="IDS_SETTINGS_LEO_ASSISTANT_PRIVATE_IP_NOT_ALLOWED_BODY" desc="Body text for private IP not allowed dialog">
The address you entered appears to be from a private network—like your router or a local server. Brave blocks these automatically to keep you safe, but you can enable them if you're confident it's secure.
</message>
<message name="IDS_SETTINGS_LEO_ASSISTANT_PRIVATE_IP_NOT_ALLOWED_INSTRUCTIONS" desc="Instructions on how to enable private IPs">
To proceed, visit brave://flags/, search for "brave-ai-chat-allow-private-ips," and enable the feature. Once enabled, you can use private network addresses in Leo settings.
</message>
<message name="IDS_SETTINGS_LEO_ASSISTANT_ADD_NEW_BUTTON_LABEL" desc="A label for a button that opens a panel with input forms for a user to add model">
Add new model
</message>
+7
View File
@@ -388,6 +388,13 @@
kOsDesktop | kOsAndroid, \
FEATURE_VALUE_TYPE(ai_chat::features::kPageContentRefine), \
}, \
{ \
"brave-ai-chat-allow-private-ips", \
"Private IP Addresses for Custom Model Endpoints", \
"Permits the use of private IP addresses as model endpoint URLs", \
kOsWin | kOsMac | kOsLinux | kOsAndroid, \
FEATURE_VALUE_TYPE(ai_chat::features::kAllowPrivateIPs), \
}, \
{ \
"brave-ai-chat-open-leo-from-brave-search", \
"Open Leo AI Chat from Brave Search", \
+9 -1
View File
@@ -172,7 +172,15 @@ void AIChatSettingsHelper::SaveCustomModel(uint32_t index,
ModelValidationResult result = ModelValidator::ValidateCustomModelOptions(
*model->options->get_custom_model_options());
if (result == ModelValidationResult::kInvalidUrl) {
std::move(callback).Run(mojom::OperationResult::InvalidUrl);
const auto endpoint = model->options->get_custom_model_options()->endpoint;
const bool valid_as_private_ip =
ModelValidator::IsValidEndpoint(endpoint, true);
// The URL is invalid, but may be valid as a private endpoint. Let's
// examine the value more closely, and notify the user.
std::move(callback).Run(
valid_as_private_ip ? mojom::OperationResult::UrlValidAsPrivateEndpoint
: mojom::OperationResult::InvalidUrl);
return;
}
@@ -63,8 +63,30 @@
.actions-container leo-button:first-of-type {
margin-right: 5px;
}
.unsafe-endpoint-label {
gap: 1em;
display: flex;
justify-content: center;
color: var(--leo-color-systemfeedback-error-icon);
}
</style>
<!-- warning message against potentially unsafe endpoints -->
<template is="dom-if" if="[[shouldShowUnsafeEndpointModal]]">
<cr-dialog show-close-button show-on-attach>
<div slot="title">
<span class="dialog-title">
$i18n{braveLeoAssistantEndpointValidAsPrivateIp_Title}
</span>
</div>
<div slot="body">
<p>$i18n{braveLeoAssistantEndpointValidAsPrivateIp_Body}</p>
<p>$i18n{braveLeoAssistantEndpointValidAsPrivateIp_Instructions}</p>
</div>
</cr-dialog>
</template>
<div class="settings-box">
<div class="container">
<div>
@@ -174,6 +196,14 @@
invalid="[[isUrlInvalid]]"
error-message="[[invalidUrlErrorMessage]]"
></cr-input>
<template is="dom-if" if="[[shouldShowUnsafeEndpointLabel]]">
<div class="unsafe-endpoint-label">
<leo-icon name="lock-open"></leo-icon>
<span class="label-text">
$i18n{braveLeoAssistantEndpointPotentiallyUnsafeError}
</span>
</div>
</template>
</div>
<div class="input-container">
@@ -6,6 +6,7 @@
import 'chrome://resources/cr_elements/cr_button/cr_button.js'
import 'chrome://resources/cr_elements/icons.html.js'
import { sendWithPromise } from 'chrome://resources/js/cr.js'
import type { CrInputElement } from 'chrome://resources/cr_elements/cr_input/cr_input.js'
import { PrefsMixin } from '/shared/settings/prefs/prefs_mixin.js'
import { I18nMixin } from 'chrome://resources/cr_elements/i18n_mixin.js'
@@ -59,6 +60,14 @@ export class ModelConfigUI extends ModelConfigUIBase {
type: Boolean,
value: false,
},
shouldShowUnsafeEndpointModal: {
type: Boolean,
value: false,
},
shouldShowUnsafeEndpointLabel: {
type: Boolean,
value: false,
},
invalidUrlErrorMessage: {
type: String,
value: ''
@@ -93,13 +102,28 @@ export class ModelConfigUI extends ModelConfigUIBase {
modelItem: mojom.Model | null
isEditing_: boolean
isUrlInvalid: boolean
shouldShowUnsafeEndpointLabel: boolean
isValidAsPrivateEndpoint: boolean
shouldShowUnsafeEndpointModal: boolean
invalidUrlErrorMessage: string
override ready() {
super.ready()
// If a user previously had --brave-ai-chat-allow-private-ips enabled, but
// now has it disabled, they should be notified of invalid endpoints
// immediately upon opening the config view of an impacted model. We should
// not wait for the user to make a change to the endpoint before informing
// them that the endpoint is no longer valid.
this.checkEndpointValidity_()
}
handleClick_() {
async handleClick_() {
// If the user is attempting to use a private endpoint, we should show a
// modal warning instructing them to enable the optional feature in order
// to proceed
this.shouldShowUnsafeEndpointModal =
this.isUrlInvalid && this.isValidAsPrivateEndpoint
if (!this.saveEnabled_()) {
return
}
@@ -158,19 +182,7 @@ export class ModelConfigUI extends ModelConfigUIBase {
onModelServerEndpointChange_(e: any) {
this.endpointUrl = e.target.value
// We need to check if the URL is valid because sending bad URL will cause
// renderer to crash. This is mainly due to mojo IPC not being able to
// handle bad URLs properly for |mojomUrl| type
try {
new URL(e.target.value)
this.isUrlInvalid = false
} catch {
this.isUrlInvalid = true
this.invalidUrlErrorMessage = this.i18n(
'braveLeoAssistantEndpointInvalidError'
)
}
this.checkEndpointValidity_()
}
onModelApiKeyChange_(e: any) {
@@ -201,6 +213,21 @@ export class ModelConfigUI extends ModelConfigUIBase {
return this.label && this.modelRequestName && this.endpointUrl && !this.isUrlInvalid
}
private checkEndpointValidity_() {
const url = this.endpointUrl.trim()
if (url !== '') {
sendWithPromise('validateModelEndpoint', { url })
.then((response: any) => {
this.isUrlInvalid = !response.isValid
this.isValidAsPrivateEndpoint = response.isValidAsPrivateEndpoint
this.shouldShowUnsafeEndpointLabel =
response.isValidDueToPrivateIPsFeature
this.invalidUrlErrorMessage =
this.i18n('braveLeoAssistantEndpointError')
})
}
}
private onModelItemChange_(newValue: mojom.Model | null) {
if (newValue?.options.customModelOptions) {
this.label = newValue.displayName
@@ -2,7 +2,6 @@
.list {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
border: 1px solid var(--leo-color-divider-subtle);
border-radius: var(--leo-radius-m);
@@ -16,7 +16,9 @@
#include "brave/browser/ui/sidebar/sidebar_service_factory.h"
#include "brave/components/ai_chat/core/browser/ai_chat_metrics.h"
#include "brave/components/ai_chat/core/browser/ai_chat_service.h"
#include "brave/components/ai_chat/core/browser/model_validator.h"
#include "brave/components/ai_chat/core/browser/utils.h"
#include "brave/components/ai_chat/core/common/features.h"
#include "brave/components/ai_chat/core/common/pref_names.h"
#include "brave/components/sidebar/browser/sidebar_item.h"
#include "brave/components/sidebar/browser/sidebar_service.h"
@@ -86,6 +88,11 @@ void BraveLeoAssistantHandler::RegisterMessages() {
"resetLeoData",
base::BindRepeating(&BraveLeoAssistantHandler::HandleResetLeoData,
base::Unretained(this)));
web_ui()->RegisterMessageCallback(
"validateModelEndpoint",
base::BindRepeating(
&BraveLeoAssistantHandler::HandleValidateModelEndpoint,
base::Unretained(this)));
}
void BraveLeoAssistantHandler::OnJavascriptAllowed() {
@@ -131,6 +138,35 @@ void BraveLeoAssistantHandler::HandleToggleLeoIcon(
}
}
void BraveLeoAssistantHandler::HandleValidateModelEndpoint(
const base::Value::List& args) {
AllowJavascript();
if (args.size() < 2 || !args[1].is_dict()) {
// Expect the appropriate number and type of arguments, or reject
RejectJavascriptCallback(args[0], base::Value("Invalid arguments"));
return;
}
const base::Value::Dict& dict = args[1].GetDict();
GURL endpoint(*dict.FindString("url"));
base::Value::Dict response;
const bool is_valid = ai_chat::ModelValidator::IsValidEndpoint(endpoint);
response.Set("isValid", is_valid);
response.Set("isValidAsPrivateEndpoint",
ai_chat::ModelValidator::IsValidEndpoint(
endpoint, std::optional<bool>(true)));
response.Set("isValidDueToPrivateIPsFeature",
is_valid && ai_chat::features::IsAllowPrivateIPsEnabled() &&
!ai_chat::ModelValidator::IsValidEndpoint(
endpoint, std::optional<bool>(false)));
ResolveJavascriptCallback(args[0], response);
}
void BraveLeoAssistantHandler::HandleGetLeoIconVisibility(
const base::Value::List& args) {
auto* service = sidebar::SidebarServiceFactory::GetForProfile(profile_);
@@ -43,6 +43,7 @@ class BraveLeoAssistantHandler : public settings::SettingsPageUIHandler,
void NotifyChatUiChanged(const bool& isLeoVisible);
void HandleValidateModelEndpoint(const base::Value::List& args);
void HandleToggleLeoIcon(const base::Value::List& args);
void HandleGetLeoIconVisibility(const base::Value::List& args);
void HandleResetLeoData(const base::Value::List& args);
@@ -495,6 +495,14 @@ void BraveAddCommonStrings(content::WebUIDataSource* html_source,
{"braveLeoModelSectionTitle", IDS_CHAT_UI_MENU_TITLE_MODELS},
{"braveLeoAssistantEndpointInvalidError",
IDS_SETTINGS_LEO_ASSISTANT_ENDPOINT_INVALID_ERROR},
{"braveLeoAssistantEndpointPotentiallyUnsafeError",
IDS_SETTINGS_LEO_ASSISTANT_ENDPOINT_POTENTIALLY_UNSAFE_ERROR},
{"braveLeoAssistantEndpointValidAsPrivateIp_Title",
IDS_SETTINGS_LEO_ASSISTANT_PRIVATE_IP_NOT_ALLOWED_TITLE},
{"braveLeoAssistantEndpointValidAsPrivateIp_Body",
IDS_SETTINGS_LEO_ASSISTANT_PRIVATE_IP_NOT_ALLOWED_BODY},
{"braveLeoAssistantEndpointValidAsPrivateIp_Instructions",
IDS_SETTINGS_LEO_ASSISTANT_PRIVATE_IP_NOT_ALLOWED_INSTRUCTIONS},
{"braveLeoAssistantAddModelButtonLabel",
IDS_SETTINGS_LEO_ASSISTANT_ADD_MODEL_BUTTON_LABEL},
{"braveLeoAssistantSaveModelButtonLabel",
@@ -31,6 +31,9 @@ base::span<const webui::LocalizedString> GetLocalizedStrings() {
{"aboutDescription_3", IDS_CHAT_UI_ABOUT_DESCRIPTION_3},
{"placeholderLabel", IDS_CHAT_UI_PLACEHOLDER_LABEL},
{"pageContentWarning", IDS_CHAT_UI_PAGE_CONTENT_WARNING},
{"customModelInvalidEndpoint", IDS_CUSTOM_MODEL_ENDPOINT_INVALID_ERROR},
{"customModelModifyConfigurationLabel",
IDS_CHAT_UI_MODIFY_CONFIGURATION_LABEL},
{"errorNetworkLabel", IDS_CHAT_UI_ERROR_NETWORK},
{"errorRateLimit", IDS_CHAT_UI_ERROR_RATE_LIMIT},
{"retryButtonLabel", IDS_CHAT_UI_RETRY_BUTTON_LABEL},
@@ -47,6 +47,7 @@
#include "brave/components/ai_chat/core/browser/associated_archive_content.h"
#include "brave/components/ai_chat/core/browser/local_models_updater.h"
#include "brave/components/ai_chat/core/browser/model_service.h"
#include "brave/components/ai_chat/core/browser/model_validator.h"
#include "brave/components/ai_chat/core/browser/types.h"
#include "brave/components/ai_chat/core/browser/utils.h"
#include "brave/components/ai_chat/core/common/features.h"
@@ -586,7 +587,22 @@ void ConversationHandler::ChangeModel(const std::string& model_key) {
auto* new_model = model_service_->GetModel(model_key);
if (new_model) {
model_key_ = new_model->key;
// Applies to Custom Models alone. Verify that the endpoint URL for this
// model is valid. Model endpoints may be valid in one session, but not in
// another. For example, if --allow-leo-private-ips is enabled, the endpoint
// does not need to use HTTPS.
if (new_model->options->is_custom_model_options()) {
const bool is_valid_endpoint = ModelValidator::IsValidEndpoint(
new_model->options->get_custom_model_options()->endpoint);
SetAPIError(is_valid_endpoint ? mojom::APIError::None
: mojom::APIError::InvalidEndpointURL);
} else {
// Non-custom model activated; clear any previous API error.
SetAPIError(mojom::APIError::None);
}
}
// Always call InitEngine, even with a bad key as we need a model
InitEngine();
}
@@ -5,14 +5,55 @@
#include "brave/components/ai_chat/core/browser/model_validator.h"
#include <string>
#include "base/numerics/safe_math.h"
#include "brave/components/ai_chat/core/common/features.h"
#include "brave/components/ai_chat/core/common/mojom/ai_chat.mojom.h"
#include "brave/net/base/url_util.h"
#include "net/base/ip_address.h"
class GURL;
namespace ai_chat {
namespace {
bool IsValidPrivateIPAddress(const GURL& endpoint) {
net::IPAddress ip_address;
// Extract the host
std::string host = endpoint.host();
// Parse the hostname to an IPAddress
if (!net::ParseURLHostnameToAddress(host, &ip_address) ||
!ip_address.IsValid()) {
return false;
}
// Allow loopback addresses
if (ip_address.IsLoopback()) {
return true;
}
// Allow link-local addresses
if (ip_address.IsLinkLocal()) {
return true;
}
// Allow unique local IPv6 addresses
if (ip_address.IsUniqueLocalIPv6()) {
return true;
}
// Allow private IPv4 addresses
if (ip_address.IsIPv4() && !ip_address.IsPubliclyRoutable()) {
return true;
}
// IP address is not in allowed ranges
return false;
}
} // namespace
// Static
bool ModelValidator::IsValidContextSize(const std::optional<int32_t>& size) {
if (!size.has_value()) {
@@ -33,8 +74,25 @@ bool ModelValidator::HasValidContextSize(
}
// Static
bool ModelValidator::IsValidEndpoint(const GURL& endpoint) {
return net::IsHTTPSOrLocalhostURL(endpoint);
bool ModelValidator::IsValidEndpoint(const GURL& endpoint,
std::optional<bool> check_as_private_ip) {
// HTTPS and localhost URLs are always allowed.
if (net::IsHTTPSOrLocalhostURL(endpoint)) {
return true;
}
// The following condition is only met when `true` is passed as
// check_as_private_ip or when the optional feature is enabled. Intentionally,
// it will not be met when `false` is passed as check_as_private_ip.
if (check_as_private_ip.value_or(
ai_chat::features::IsAllowPrivateIPsEnabled())) {
if (IsValidPrivateIPAddress(endpoint)) {
VLOG(2) << "Allowing private endpoint: " << endpoint.spec();
return true;
}
}
return false;
}
ModelValidationResult ModelValidator::ValidateCustomModelOptions(
@@ -43,7 +43,9 @@ class ModelValidator {
static bool IsValidContextSize(const std::optional<int32_t>& size);
static bool HasValidContextSize(const mojom::CustomModelOptions& options);
static bool IsValidEndpoint(const GURL& endpoint);
static bool IsValidEndpoint(
const GURL& endpoint,
std::optional<bool> check_as_private_ip = std::nullopt);
// Validates the custom model's properties, such as context size and endpoint
static ModelValidationResult ValidateCustomModelOptions(
@@ -12,6 +12,8 @@
#include "base/numerics/checked_math.h"
#include "base/strings/string_number_conversions.h"
#include "base/test/scoped_feature_list.h"
#include "brave/components/ai_chat/core/common/features.h"
#include "brave/components/ai_chat/core/common/mojom/ai_chat.mojom-forward.h"
#include "brave/components/ai_chat/core/common/mojom/ai_chat.mojom.h"
#include "mojo/public/cpp/bindings/struct_ptr.h"
@@ -20,6 +22,13 @@
namespace ai_chat {
namespace {
struct EndpointTestParams {
std::string url;
bool expected_with_private_ips_enabled;
bool expected_with_private_ips_disabled;
};
} // namespace
// Test fixture class for ModelValidator
class ModelValidatorUnitTest : public ::testing::Test {};
@@ -85,31 +94,6 @@ TEST_F(ModelValidatorUnitTest, HasValidContextSize) {
false);
}
// Test IsValidEndpoint with various URLs
TEST_F(ModelValidatorUnitTest, IsValidEndpoint) {
struct TestCase {
std::string url;
bool expected_result;
};
TestCase test_cases[] = {
{"https://valid-url.com", true}, // Valid HTTPS URL
{"http://invalid-url.com", false}, // HTTP URL (invalid)
{"https://localhost:8080", true}, // Valid localhost URL
{"invalid-url", false}, // Invalid URL string
{"https://", false}, // Incomplete URL
{"https://search.brave.com/search?q=foo",
true}, // Valid Brave search URL
};
for (const auto& test_case : test_cases) {
GURL endpoint(test_case.url);
bool actual = ModelValidator::IsValidEndpoint(endpoint);
EXPECT_EQ(actual, test_case.expected_result)
<< "Failed for URL: " << test_case.url;
}
}
// Test ValidateModel with valid and invalid models
TEST_F(ModelValidatorUnitTest, ValidateModel) {
// Valid custom model
@@ -158,4 +142,85 @@ TEST_F(ModelValidatorUnitTest, ValidateModel) {
ModelValidationResult::kInvalidUrl);
}
class ModelValidatorEndpointTest
: public ::testing::TestWithParam<EndpointTestParams> {};
TEST_P(ModelValidatorEndpointTest, IsValidEndpoint) {
const EndpointTestParams& params = GetParam();
// Test with private IPs enabled
{
base::test::ScopedFeatureList scoped_feature_list;
scoped_feature_list.InitAndEnableFeature(
ai_chat::features::kAllowPrivateIPs);
GURL endpoint(params.url);
bool actual = ModelValidator::IsValidEndpoint(endpoint);
EXPECT_EQ(actual, params.expected_with_private_ips_enabled)
<< "Failed for URL: " << params.url;
}
// Test with private IPs disabled
{
base::test::ScopedFeatureList scoped_feature_list;
scoped_feature_list.InitAndDisableFeature(
ai_chat::features::kAllowPrivateIPs);
GURL endpoint(params.url);
bool actual = ModelValidator::IsValidEndpoint(endpoint);
EXPECT_EQ(actual, params.expected_with_private_ips_disabled)
<< "Failed for URL: " << params.url;
}
}
INSTANTIATE_TEST_SUITE_P(
ModelValidatorEndpointTests,
ModelValidatorEndpointTest,
::testing::Values(
// Legacy scenarios
EndpointTestParams{"https://valid-url.com", true, true},
EndpointTestParams{"https://localhost:8080", true, true},
EndpointTestParams{"https://", false, false},
EndpointTestParams{"https://search.brave.com/search?q=foo", true, true},
// Localhost
EndpointTestParams{"http://localhost", true, true},
EndpointTestParams{"https://localhost", true, true},
EndpointTestParams{"http://127.0.0.1", true, true},
EndpointTestParams{"https://127.0.0.1", true, true},
EndpointTestParams{"http://[::1]", true, true},
EndpointTestParams{"https://[::1]", true, true},
// Private IPv4 Addresses
EndpointTestParams{"http://192.168.0.1", true, false},
EndpointTestParams{"http://172.16.0.1", true, false},
EndpointTestParams{"http://10.0.0.1", true, false},
EndpointTestParams{"http://169.254.0.1", true, false},
EndpointTestParams{"https://192.168.0.1", true, true},
EndpointTestParams{"https://172.16.0.1", true, true},
// Private IPv6 Addresses
EndpointTestParams{"http://[fe80::1]", true, false},
EndpointTestParams{"http://[fc00::1]", true, false},
EndpointTestParams{"https://[fe80::1]", true, true},
EndpointTestParams{"https://[fc00::1]", true, true},
// Public IP Addresses
EndpointTestParams{"http://8.8.8.8", false, false},
EndpointTestParams{"https://8.8.8.8", true, true},
EndpointTestParams{"http://1.2.3.4", false, false},
// Invalid Addresses
EndpointTestParams{"http://999.999.999.999", false, false},
EndpointTestParams{"http://invalid-url", false, false},
// Edge Cases - Boundary IPs
EndpointTestParams{"http://192.168.0.0", true, false},
EndpointTestParams{"http://192.168.255.255", true, false},
EndpointTestParams{"http://172.16.0.0", true, false},
EndpointTestParams{"http://172.31.255.255", true, false},
EndpointTestParams{"http://10.0.0.0", true, false},
EndpointTestParams{"http://10.255.255.255", true, false},
EndpointTestParams{"http://169.254.0.0", true, false},
EndpointTestParams{"http://169.254.255.255", true, false},
EndpointTestParams{"http://[fe80::]", true, false},
EndpointTestParams{"http://[febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff]",
true, false}
));
} // namespace ai_chat
@@ -56,6 +56,14 @@ bool IsPageContentRefineEnabled() {
return base::FeatureList::IsEnabled(features::kPageContentRefine);
}
BASE_FEATURE(kAllowPrivateIPs,
"AllowPrivateIPs",
base::FEATURE_DISABLED_BY_DEFAULT);
bool IsAllowPrivateIPsEnabled() {
return base::FeatureList::IsEnabled(features::kAllowPrivateIPs);
}
BASE_FEATURE(kOpenAIChatFromBraveSearch,
"OpenAIChatFromBraveSearch",
#if !BUILDFLAG(IS_ANDROID) && !BUILDFLAG(IS_IOS)
@@ -45,6 +45,10 @@ COMPONENT_EXPORT(AI_CHAT_COMMON) bool IsContextMenuRewriteInPlaceEnabled();
COMPONENT_EXPORT(AI_CHAT_COMMON) BASE_DECLARE_FEATURE(kPageContentRefine);
COMPONENT_EXPORT(AI_CHAT_COMMON) bool IsPageContentRefineEnabled();
COMPONENT_EXPORT(AI_CHAT_COMMON)
BASE_DECLARE_FEATURE(kAllowPrivateIPs);
COMPONENT_EXPORT(AI_CHAT_COMMON) bool IsAllowPrivateIPsEnabled();
COMPONENT_EXPORT(AI_CHAT_COMMON)
BASE_DECLARE_FEATURE(kOpenAIChatFromBraveSearch);
COMPONENT_EXPORT(AI_CHAT_COMMON) bool IsOpenAIChatFromBraveSearchEnabled();
@@ -23,7 +23,8 @@ enum APIError {
None,
ConnectionIssue,
RateLimitReached,
ContextLimitReached
ContextLimitReached,
InvalidEndpointURL
};
enum ModelEngineType {
@@ -260,6 +261,7 @@ struct CustomModelOptions {
uint32 long_conversation_warning_character_limit;
// a user-specified prompt to be used with the model
string? model_system_prompt;
// the endpoint could be a local network address or a remote server
url.mojom.Url endpoint;
string api_key;
};
@@ -11,6 +11,8 @@ enum OperationResult {
Success,
InvalidUrl,
InvalidContextSize,
// for use with the `brave-ai-chat-allow-private-ips` feature.
UrlValidAsPrivateEndpoint,
};
interface AIChatSettingsHelper {
@@ -0,0 +1,38 @@
/* Copyright (c) 2023 The Brave Authors. All rights reserved.
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at https://mozilla.org/MPL/2.0/. */
import * as React from 'react'
import Alert from '@brave/leo/react/alert'
import Button from '@brave/leo/react/button'
import { getLocale } from '$web-common/locale'
import { useAIChat } from '../../state/ai_chat_context'
import styles from './alerts.module.scss'
export default function ErrorInvalidEndpointURL () {
const aiChatContext = useAIChat()
const handleConfigureClick = () => {
aiChatContext.uiHandler?.openAIChatSettings()
}
return (
<div className={styles.alert}>
<Alert
mode='full'
type='error'
>
{getLocale('customModelInvalidEndpoint')}
<Button
slot='actions'
kind='filled'
onClick={handleConfigureClick}
>
{getLocale('customModelModifyConfigurationLabel')}
</Button>
</Alert>
</div>
)
}
@@ -15,6 +15,7 @@ import { useAIChat } from '../../state/ai_chat_context'
import { isLeoModel } from '../../model_utils'
import ErrorConnection from '../alerts/error_connection'
import ErrorConversationEnd from '../alerts/error_conversation_end'
import ErrorInvalidEndpointURL from '../alerts/error_invalid_endpoint_url'
import ErrorRateLimit from '../alerts/error_rate_limit'
import LongConversationInfo from '../alerts/long_conversation_info'
import WarningPremiumDisconnected from '../alerts/warning_premium_disconnected'
@@ -70,25 +71,22 @@ function Main() {
const scrollPos = React.useRef({ isAtBottom: true })
if (aiChatContext.hasAcceptedAgreement) {
if (conversationContext.apiHasError && conversationContext.currentError === mojom.APIError.ConnectionIssue) {
currentErrorElement = (
<ErrorConnection
onRetry={conversationContext.retryAPIRequest}
/>
)
}
if (conversationContext.apiHasError && conversationContext.currentError === mojom.APIError.RateLimitReached) {
currentErrorElement = (
<ErrorRateLimit />
)
}
if (conversationContext.apiHasError && conversationContext.currentError === mojom.APIError.ContextLimitReached) {
currentErrorElement = (
<ErrorConversationEnd />
)
// Determine which, if any, error message should be displayed
if (aiChatContext.hasAcceptedAgreement && conversationContext.apiHasError) {
switch (conversationContext.currentError) {
case mojom.APIError.ConnectionIssue:
currentErrorElement = <ErrorConnection
onRetry={conversationContext.retryAPIRequest} />
break
case mojom.APIError.RateLimitReached:
currentErrorElement = <ErrorRateLimit />
break
case mojom.APIError.ContextLimitReached:
currentErrorElement = <ErrorConversationEnd />
break
case mojom.APIError.InvalidEndpointURL:
currentErrorElement = <ErrorInvalidEndpointURL />
break
}
}
@@ -16,6 +16,8 @@ provideStrings({
pageContentWarning: 'Disconnect to stop sending this page content to Leo, and start a new conversation',
errorNetworkLabel: 'There was a network issue connecting to Leo, check your connection and try again.',
errorRateLimit: 'You\'ve reached the premium rate limit. Please try again in a few hours.',
braveLeoAssistantEndpointInvalidError: 'The endpoint URL is invalid. Please check the URL and try again.',
braveLeoAssistantEndpointValidAsPrivateIp: 'If you would like to use a private IP address, you must first enable "Private IP Addresses for Custom Model Enpoints" via brave://flags/#brave-ai-chat-allow-private-ips',
retryButtonLabel: 'Retry',
learnMore: 'Learn more',
dismissButtonLabel: 'Dismiss',
@@ -39,6 +39,9 @@
<message name="IDS_CHAT_UI_PAGE_CONTENT_WARNING" desc="Description about page content being sent to a remote LLM">
Disconnect to stop sending this page content to Leo, and start a new conversation
</message>
<message name="IDS_CUSTOM_MODEL_ENDPOINT_INVALID_ERROR" desc="An error presented when the model endpoint is invalid">
This model has an invalid endpoint. Please check your configuration and try again.
</message>
<message name="IDS_CHAT_UI_ERROR_NETWORK" desc="An error presented when there is a network issue in the UI">
There was a network issue connecting to Leo, check your connection and try again.
</message>
@@ -48,6 +51,9 @@
<message name="IDS_CHAT_UI_RETRY_BUTTON_LABEL" desc="A button label to retry API again">
Retry
</message>
<message name="IDS_CHAT_UI_MODIFY_CONFIGURATION_LABEL" desc="A button label to configure custom models">
Configure
</message>
<message name="IDS_CHAT_UI_INTRO_MESSAGE_CHAT_BASIC" desc="AI Chat intro message for the default model">
Hi, I'm Leo. I'm a fully hosted AI assistant by Brave. I'm powered by Llama 3.1 8B, a model created by Meta to be performant and applicable to many use cases. <ph name="LINK_BEFORE">$1</ph>Learn more<ph name="LINK_AFTER">$2</ph>
</message>