Remote model fetch and parsing (#35089)
* Remote model fetch and parsing. * Applied review suggestions * feat(ai_chat): update remote model parser to server capabilities format * feat(ai_chat): address review feedback on RemoteModelsFetcher
This commit is contained in:
@@ -75,6 +75,8 @@ static_library("browser") {
|
||||
"model_service.h",
|
||||
"model_validator.cc",
|
||||
"model_validator.h",
|
||||
"remote_models_fetcher.cc",
|
||||
"remote_models_fetcher.h",
|
||||
"skills_metrics.cc",
|
||||
"skills_metrics.h",
|
||||
"tab_tracker_service.cc",
|
||||
@@ -190,6 +192,7 @@ source_set("unit_tests") {
|
||||
"history_ui_handler_unittest.cc",
|
||||
"model_service_unittest.cc",
|
||||
"model_validator_unittest.cc",
|
||||
"remote_models_fetcher_unittest.cc",
|
||||
"skills_metrics_unittest.cc",
|
||||
"tab_tracker_service_unittest.cc",
|
||||
"tools/chart_code_plugin_unittest.cc",
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
// Copyright (c) 2026 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/.
|
||||
|
||||
#include "brave/components/ai_chat/core/browser/remote_models_fetcher.h"
|
||||
|
||||
#include <optional>
|
||||
#include <utility>
|
||||
|
||||
#include "base/functional/bind.h"
|
||||
#include "base/location.h"
|
||||
#include "base/logging.h"
|
||||
#include "base/numerics/safe_conversions.h"
|
||||
#include "base/task/sequenced_task_runner.h"
|
||||
#include "base/time/time.h"
|
||||
#include "base/values.h"
|
||||
#include "brave/components/ai_chat/core/common/mojom/ai_chat.mojom.h"
|
||||
#include "brave/components/api_request_helper/api_request_helper.h"
|
||||
#include "net/traffic_annotation/network_traffic_annotation.h"
|
||||
#include "services/network/public/cpp/shared_url_loader_factory.h"
|
||||
#include "url/gurl.h"
|
||||
|
||||
namespace ai_chat {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr size_t kMaxResponseSize = 5 * 1024 * 1024; // 5MB
|
||||
constexpr char kModelsKey[] = "models";
|
||||
|
||||
constexpr char kKeyField[] = "key";
|
||||
constexpr char kDisplayNameField[] = "display_name";
|
||||
constexpr char kCapabilitiesField[] = "capabilities";
|
||||
constexpr char kIsSuggestedModelField[] = "is_suggested_model";
|
||||
constexpr char kIsNearModelField[] = "is_near_model";
|
||||
constexpr char kOptionsField[] = "options";
|
||||
|
||||
constexpr char kTypeField[] = "type";
|
||||
constexpr char kNameField[] = "name";
|
||||
constexpr char kDisplayMakerField[] = "display_maker";
|
||||
constexpr char kDescriptionField[] = "description";
|
||||
constexpr char kAccessField[] = "access";
|
||||
constexpr char kMaxAssociatedContentLengthField[] =
|
||||
"max_associated_content_length";
|
||||
constexpr char kLongConversationWarningCharacterLimitField[] =
|
||||
"long_conversation_warning_character_limit";
|
||||
|
||||
constexpr net::NetworkTrafficAnnotationTag kTrafficAnnotation =
|
||||
net::DefineNetworkTrafficAnnotation("ai_chat_remote_models_fetcher", R"(
|
||||
semantics {
|
||||
sender: "AI Chat Remote Models Fetcher"
|
||||
description:
|
||||
"Fetches the list of available AI chat models from a remote "
|
||||
"endpoint. This allows dynamic model configuration without "
|
||||
"requiring browser updates."
|
||||
trigger:
|
||||
"Triggered when the user is opted in to AI Chat and the AI Chat "
|
||||
"panel is opened, if the model cache is empty or expired."
|
||||
data:
|
||||
"The Accept-Language header is included automatically by the "
|
||||
"network stack, indicating the user's preferred languages. This "
|
||||
"can serve as a fingerprinting signal."
|
||||
destination: BRAVE_OWNED_SERVICE
|
||||
internal {
|
||||
contacts {
|
||||
email: "support@brave.com"
|
||||
}
|
||||
}
|
||||
user_data {
|
||||
type: OTHER
|
||||
}
|
||||
last_reviewed: "2026-04-17"
|
||||
}
|
||||
policy {
|
||||
cookies_allowed: NO
|
||||
setting:
|
||||
"This feature can be disabled via the AIChatRemoteModelsConfig "
|
||||
"feature flag."
|
||||
})");
|
||||
|
||||
std::optional<mojom::ModelAccess> ParseAccess(const std::string& access_str) {
|
||||
if (access_str == "basic") {
|
||||
return mojom::ModelAccess::BASIC;
|
||||
}
|
||||
if (access_str == "premium") {
|
||||
return mojom::ModelAccess::PREMIUM;
|
||||
}
|
||||
if (access_str == "basic_and_premium") {
|
||||
return mojom::ModelAccess::BASIC_AND_PREMIUM;
|
||||
}
|
||||
DVLOG(1) << "Unknown model access: " << access_str;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<mojom::ConversationCapability> ParseCapability(
|
||||
const std::string& capability_str) {
|
||||
if (capability_str == "chat") {
|
||||
return mojom::ConversationCapability::CHAT;
|
||||
}
|
||||
if (capability_str == "content_agent") {
|
||||
return mojom::ConversationCapability::CONTENT_AGENT;
|
||||
}
|
||||
if (capability_str == "deep_research") {
|
||||
return mojom::ConversationCapability::DEEP_RESEARCH;
|
||||
}
|
||||
if (capability_str == "files") {
|
||||
return mojom::ConversationCapability::FILES;
|
||||
}
|
||||
if (capability_str == "summary") {
|
||||
return mojom::ConversationCapability::SUMMARY;
|
||||
}
|
||||
DVLOG(1) << "Unknown conversation capability: " << capability_str;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
struct ParsedCapabilities {
|
||||
std::vector<mojom::ConversationCapability> capabilities;
|
||||
mojom::ModelCategory category;
|
||||
};
|
||||
|
||||
// Parses the model's capability list and derives its category from the first
|
||||
// CHAT or SUMMARY capability in list order. Returns std::nullopt if the list
|
||||
// is missing or declares neither category capability, in which case the model
|
||||
// is rejected.
|
||||
std::optional<ParsedCapabilities> ParseCapabilities(
|
||||
const base::DictValue& model_dict) {
|
||||
const base::ListValue* capabilities_list =
|
||||
model_dict.FindList(kCapabilitiesField);
|
||||
if (!capabilities_list) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::vector<mojom::ConversationCapability> capabilities;
|
||||
for (const auto& capability_value : *capabilities_list) {
|
||||
if (!capability_value.is_string()) {
|
||||
continue;
|
||||
}
|
||||
if (auto capability = ParseCapability(capability_value.GetString())) {
|
||||
capabilities.push_back(*capability);
|
||||
}
|
||||
}
|
||||
|
||||
for (auto capability : capabilities) {
|
||||
if (capability == mojom::ConversationCapability::CHAT) {
|
||||
return ParsedCapabilities{std::move(capabilities),
|
||||
mojom::ModelCategory::CHAT};
|
||||
}
|
||||
if (capability == mojom::ConversationCapability::SUMMARY) {
|
||||
return ParsedCapabilities{std::move(capabilities),
|
||||
mojom::ModelCategory::SUMMARY};
|
||||
}
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
mojom::ModelPtr ParseModel(const base::DictValue& model_dict) {
|
||||
const std::string* key = model_dict.FindString(kKeyField);
|
||||
if (!key || key->empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const std::string* display_name = model_dict.FindString(kDisplayNameField);
|
||||
if (!display_name || display_name->empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const base::DictValue* options_dict = model_dict.FindDict(kOptionsField);
|
||||
if (!options_dict) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const std::string* type = options_dict->FindString(kTypeField);
|
||||
if (!type || *type != "leo") {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const std::string* name = options_dict->FindString(kNameField);
|
||||
if (!name || name->empty()) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
std::optional<int> max_content_length =
|
||||
options_dict->FindInt(kMaxAssociatedContentLengthField);
|
||||
if (max_content_length.has_value() && *max_content_length <= 0) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
std::optional<int> warning_limit =
|
||||
options_dict->FindInt(kLongConversationWarningCharacterLimitField);
|
||||
if (warning_limit.has_value() && *warning_limit <= 0) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const std::string* access = options_dict->FindString(kAccessField);
|
||||
std::optional<mojom::ModelAccess> parsed_access =
|
||||
access ? ParseAccess(*access) : std::nullopt;
|
||||
if (!parsed_access.has_value()) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
auto parsed_capabilities = ParseCapabilities(model_dict);
|
||||
if (!parsed_capabilities) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
auto model = mojom::Model::New();
|
||||
model->key = *key;
|
||||
model->display_name = *display_name;
|
||||
model->is_suggested_model =
|
||||
model_dict.FindBool(kIsSuggestedModelField).value_or(false);
|
||||
model->is_near_model = model_dict.FindBool(kIsNearModelField).value_or(false);
|
||||
model->supported_capabilities = std::move(parsed_capabilities->capabilities);
|
||||
|
||||
auto leo_opts = mojom::LeoModelOptions::New();
|
||||
leo_opts->name = *name;
|
||||
|
||||
const std::string* display_maker =
|
||||
options_dict->FindString(kDisplayMakerField);
|
||||
if (display_maker) {
|
||||
leo_opts->display_maker = *display_maker;
|
||||
}
|
||||
|
||||
const std::string* description = options_dict->FindString(kDescriptionField);
|
||||
leo_opts->description = description ? *description : "";
|
||||
|
||||
leo_opts->category = parsed_capabilities->category;
|
||||
|
||||
leo_opts->access = *parsed_access;
|
||||
|
||||
if (!max_content_length.has_value()) {
|
||||
max_content_length =
|
||||
(leo_opts->access == mojom::ModelAccess::PREMIUM) ? 90000 : 32000;
|
||||
}
|
||||
if (!warning_limit.has_value()) {
|
||||
warning_limit =
|
||||
(leo_opts->access == mojom::ModelAccess::PREMIUM) ? 160000 : 51200;
|
||||
}
|
||||
|
||||
leo_opts->max_associated_content_length =
|
||||
base::saturated_cast<uint32_t>(*max_content_length);
|
||||
leo_opts->long_conversation_warning_character_limit =
|
||||
base::saturated_cast<uint32_t>(*warning_limit);
|
||||
|
||||
model->options = mojom::ModelOptions::NewLeoModelOptions(std::move(leo_opts));
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
std::vector<mojom::ModelPtr> ParseModelsFromJSON(const base::Value& json) {
|
||||
const base::ListValue* models_list = nullptr;
|
||||
|
||||
if (json.is_dict()) {
|
||||
models_list = json.GetDict().FindList(kModelsKey);
|
||||
} else if (json.is_list()) {
|
||||
models_list = &json.GetList();
|
||||
}
|
||||
|
||||
if (!models_list) {
|
||||
return {};
|
||||
}
|
||||
|
||||
std::vector<mojom::ModelPtr> models;
|
||||
for (const auto& model_value : *models_list) {
|
||||
if (!model_value.is_dict()) {
|
||||
continue;
|
||||
}
|
||||
auto model = ParseModel(model_value.GetDict());
|
||||
if (model) {
|
||||
models.push_back(std::move(model));
|
||||
}
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
RemoteModelsFetcher::RemoteModelsFetcher(
|
||||
scoped_refptr<network::SharedURLLoaderFactory> url_loader_factory)
|
||||
: api_request_helper_(
|
||||
std::make_unique<api_request_helper::APIRequestHelper>(
|
||||
kTrafficAnnotation,
|
||||
url_loader_factory)) {}
|
||||
|
||||
RemoteModelsFetcher::~RemoteModelsFetcher() = default;
|
||||
|
||||
void RemoteModelsFetcher::FetchModels(const std::string& url,
|
||||
FetchModelsCallback callback) {
|
||||
const GURL endpoint_url(url);
|
||||
|
||||
if (!endpoint_url.is_valid() || !endpoint_url.SchemeIs(url::kHttpsScheme)) {
|
||||
base::SequencedTaskRunner::GetCurrentDefault()->PostTask(
|
||||
FROM_HERE,
|
||||
base::BindOnce(std::move(callback), std::vector<mojom::ModelPtr>{}));
|
||||
return;
|
||||
}
|
||||
|
||||
auto result_callback =
|
||||
base::BindOnce(&RemoteModelsFetcher::OnFetchComplete,
|
||||
weak_ptr_factory_.GetWeakPtr(), std::move(callback));
|
||||
|
||||
api_request_helper::APIRequestOptions options;
|
||||
options.max_body_size = kMaxResponseSize;
|
||||
options.timeout = base::Seconds(30);
|
||||
|
||||
api_request_helper_->Request(
|
||||
"GET", endpoint_url, "", "", std::move(result_callback),
|
||||
base::flat_map<std::string, std::string>(), options);
|
||||
}
|
||||
|
||||
void RemoteModelsFetcher::OnFetchComplete(
|
||||
FetchModelsCallback callback,
|
||||
api_request_helper::APIRequestResult result) {
|
||||
DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_);
|
||||
if (!result.Is2XXResponseCode()) {
|
||||
std::move(callback).Run({});
|
||||
return;
|
||||
}
|
||||
|
||||
std::move(callback).Run(ParseModelsFromJSON(result.value_body()));
|
||||
}
|
||||
|
||||
} // namespace ai_chat
|
||||
@@ -0,0 +1,62 @@
|
||||
// Copyright (c) 2026 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/.
|
||||
|
||||
#ifndef BRAVE_COMPONENTS_AI_CHAT_CORE_BROWSER_REMOTE_MODELS_FETCHER_H_
|
||||
#define BRAVE_COMPONENTS_AI_CHAT_CORE_BROWSER_REMOTE_MODELS_FETCHER_H_
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "base/functional/callback.h"
|
||||
#include "base/memory/scoped_refptr.h"
|
||||
#include "base/memory/weak_ptr.h"
|
||||
#include "base/sequence_checker.h"
|
||||
#include "brave/components/ai_chat/core/common/mojom/common.mojom-forward.h"
|
||||
|
||||
namespace network {
|
||||
class SharedURLLoaderFactory;
|
||||
}
|
||||
|
||||
namespace api_request_helper {
|
||||
class APIRequestHelper;
|
||||
class APIRequestResult;
|
||||
} // namespace api_request_helper
|
||||
|
||||
namespace ai_chat {
|
||||
|
||||
// Fetches AI chat models from a remote URL endpoint and parses the response.
|
||||
class RemoteModelsFetcher {
|
||||
public:
|
||||
using FetchModelsCallback =
|
||||
base::OnceCallback<void(std::vector<mojom::ModelPtr>)>;
|
||||
|
||||
explicit RemoteModelsFetcher(
|
||||
scoped_refptr<network::SharedURLLoaderFactory> url_loader_factory);
|
||||
~RemoteModelsFetcher();
|
||||
|
||||
RemoteModelsFetcher(const RemoteModelsFetcher&) = delete;
|
||||
RemoteModelsFetcher& operator=(const RemoteModelsFetcher&) = delete;
|
||||
|
||||
// Fetches and parses models from |url|, then invokes |callback| with the
|
||||
// results. |url| must be a valid HTTPS URL; non-HTTPS or malformed URLs
|
||||
// result in an empty callback. On network or parse failure, the callback
|
||||
// is invoked with an empty vector.
|
||||
void FetchModels(const std::string& url, FetchModelsCallback callback);
|
||||
|
||||
private:
|
||||
void OnFetchComplete(FetchModelsCallback callback,
|
||||
api_request_helper::APIRequestResult result);
|
||||
|
||||
std::unique_ptr<api_request_helper::APIRequestHelper> api_request_helper_;
|
||||
|
||||
SEQUENCE_CHECKER(sequence_checker_);
|
||||
|
||||
base::WeakPtrFactory<RemoteModelsFetcher> weak_ptr_factory_{this};
|
||||
};
|
||||
|
||||
} // namespace ai_chat
|
||||
|
||||
#endif // BRAVE_COMPONENTS_AI_CHAT_CORE_BROWSER_REMOTE_MODELS_FETCHER_H_
|
||||
@@ -0,0 +1,658 @@
|
||||
// Copyright (c) 2026 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/.
|
||||
|
||||
#include "brave/components/ai_chat/core/browser/remote_models_fetcher.h"
|
||||
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "base/strings/string_util.h"
|
||||
#include "base/test/bind.h"
|
||||
#include "base/test/task_environment.h"
|
||||
#include "base/test/test_future.h"
|
||||
#include "brave/components/ai_chat/core/common/mojom/ai_chat.mojom.h"
|
||||
#include "services/network/public/cpp/resource_request.h"
|
||||
#include "services/network/public/cpp/weak_wrapper_shared_url_loader_factory.h"
|
||||
#include "services/network/test/test_url_loader_factory.h"
|
||||
#include "testing/gtest/include/gtest/gtest.h"
|
||||
|
||||
namespace ai_chat {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr char kTestEndpoint[] = "https://example.com/models";
|
||||
|
||||
constexpr char kValidModelsJSON[] = R"({
|
||||
"models": [
|
||||
{
|
||||
"key": "test-model-1",
|
||||
"display_name": "Test Model 1",
|
||||
"is_suggested_model": true,
|
||||
"is_near_model": false,
|
||||
"capabilities": ["chat", "files"],
|
||||
"options": {
|
||||
"type": "leo",
|
||||
"name": "test-model-1-api",
|
||||
"display_maker": "Test Provider",
|
||||
"description": "A basic test model",
|
||||
"access": "basic",
|
||||
"max_associated_content_length": 100000,
|
||||
"long_conversation_warning_character_limit": 200000
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "test-model-2",
|
||||
"display_name": "Test Model 2",
|
||||
"is_suggested_model": false,
|
||||
"is_near_model": false,
|
||||
"capabilities": ["chat", "files", "content_agent"],
|
||||
"options": {
|
||||
"type": "leo",
|
||||
"name": "test-model-2-api",
|
||||
"display_maker": "Test Provider",
|
||||
"description": "A premium test model",
|
||||
"access": "premium",
|
||||
"max_associated_content_length": 150000,
|
||||
"long_conversation_warning_character_limit": 300000
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "test-model-3",
|
||||
"display_name": "Test Model 3",
|
||||
"is_suggested_model": false,
|
||||
"is_near_model": false,
|
||||
"capabilities": ["summary", "files"],
|
||||
"options": {
|
||||
"type": "leo",
|
||||
"name": "test-model-3-api",
|
||||
"display_maker": "Test Provider",
|
||||
"description": "A summary model",
|
||||
"access": "basic",
|
||||
"max_associated_content_length": 100000,
|
||||
"long_conversation_warning_character_limit": 200000
|
||||
}
|
||||
}
|
||||
]
|
||||
})";
|
||||
|
||||
constexpr char kInvalidJSON[] = "{ invalid json";
|
||||
|
||||
constexpr char kMissingKeyJSON[] = R"({
|
||||
"models": [
|
||||
{
|
||||
"display_name": "Test Model",
|
||||
"is_suggested_model": false,
|
||||
"is_near_model": false,
|
||||
"capabilities": ["chat", "files"],
|
||||
"options": {
|
||||
"type": "leo",
|
||||
"name": "test-model-api",
|
||||
"access": "basic",
|
||||
"max_associated_content_length": 100000,
|
||||
"long_conversation_warning_character_limit": 200000
|
||||
}
|
||||
}
|
||||
]
|
||||
})";
|
||||
|
||||
constexpr char kMissingDisplayNameJSON[] = R"({
|
||||
"models": [
|
||||
{
|
||||
"key": "test-model",
|
||||
"is_suggested_model": false,
|
||||
"is_near_model": false,
|
||||
"capabilities": ["chat", "files"],
|
||||
"options": {
|
||||
"type": "leo",
|
||||
"name": "test-model-api",
|
||||
"access": "basic",
|
||||
"max_associated_content_length": 100000,
|
||||
"long_conversation_warning_character_limit": 200000
|
||||
}
|
||||
}
|
||||
]
|
||||
})";
|
||||
|
||||
constexpr char kMissingOptionsJSON[] = R"({
|
||||
"models": [
|
||||
{
|
||||
"key": "test-model",
|
||||
"display_name": "Test Model",
|
||||
"is_suggested_model": false,
|
||||
"is_near_model": false,
|
||||
"capabilities": ["chat", "files"]
|
||||
}
|
||||
]
|
||||
})";
|
||||
|
||||
constexpr char kMissingNameJSON[] = R"({
|
||||
"models": [
|
||||
{
|
||||
"key": "test-model",
|
||||
"display_name": "Test Model",
|
||||
"is_suggested_model": false,
|
||||
"is_near_model": false,
|
||||
"capabilities": ["chat", "files"],
|
||||
"options": {
|
||||
"type": "leo",
|
||||
"access": "basic",
|
||||
"max_associated_content_length": 100000,
|
||||
"long_conversation_warning_character_limit": 200000
|
||||
}
|
||||
}
|
||||
]
|
||||
})";
|
||||
|
||||
constexpr char kMissingAccessJSON[] = R"({
|
||||
"models": [
|
||||
{
|
||||
"key": "test-model",
|
||||
"display_name": "Test Model",
|
||||
"is_suggested_model": false,
|
||||
"is_near_model": false,
|
||||
"capabilities": ["chat", "files"],
|
||||
"options": {
|
||||
"type": "leo",
|
||||
"name": "test-model-api",
|
||||
"max_associated_content_length": 100000,
|
||||
"long_conversation_warning_character_limit": 200000
|
||||
}
|
||||
}
|
||||
]
|
||||
})";
|
||||
|
||||
constexpr char kInvalidTypeJSON[] = R"({
|
||||
"models": [
|
||||
{
|
||||
"key": "invalid-type-model",
|
||||
"display_name": "Invalid Type Model",
|
||||
"is_suggested_model": true,
|
||||
"is_near_model": false,
|
||||
"capabilities": ["chat", "files"],
|
||||
"options": {
|
||||
"type": "custom",
|
||||
"name": "invalid-type-model",
|
||||
"access": "basic",
|
||||
"max_associated_content_length": 100000,
|
||||
"long_conversation_warning_character_limit": 200000
|
||||
}
|
||||
}
|
||||
]
|
||||
})";
|
||||
|
||||
} // namespace
|
||||
|
||||
class RemoteModelsFetcherTest : public testing::Test {
|
||||
public:
|
||||
RemoteModelsFetcherTest()
|
||||
: shared_url_loader_factory_(
|
||||
base::MakeRefCounted<network::WeakWrapperSharedURLLoaderFactory>(
|
||||
&test_url_loader_factory_)) {}
|
||||
|
||||
void SetUp() override {
|
||||
fetcher_ =
|
||||
std::make_unique<RemoteModelsFetcher>(shared_url_loader_factory_);
|
||||
}
|
||||
|
||||
void TearDown() override { fetcher_.reset(); }
|
||||
|
||||
protected:
|
||||
void SimulateSuccessfulFetch(const std::string& json_response,
|
||||
const std::string& base_url = kTestEndpoint) {
|
||||
test_url_loader_factory_.SetInterceptor(base::BindLambdaForTesting(
|
||||
[this, json_response,
|
||||
base_url](const network::ResourceRequest& request) {
|
||||
if (base::StartsWith(request.url.spec(), base_url)) {
|
||||
test_url_loader_factory_.AddResponse(request.url.spec(),
|
||||
json_response);
|
||||
} else {
|
||||
ADD_FAILURE() << "Unexpected request: " << request.url.spec();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
void SimulateHTTPError(int http_code,
|
||||
const std::string& base_url = kTestEndpoint) {
|
||||
test_url_loader_factory_.SetInterceptor(base::BindLambdaForTesting(
|
||||
[this, http_code, base_url](const network::ResourceRequest& request) {
|
||||
if (base::StartsWith(request.url.spec(), base_url)) {
|
||||
test_url_loader_factory_.AddResponse(
|
||||
request.url.spec(), "",
|
||||
static_cast<net::HttpStatusCode>(http_code));
|
||||
} else {
|
||||
ADD_FAILURE() << "Unexpected request: " << request.url.spec();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
void SimulateNetworkError(const std::string& base_url = kTestEndpoint) {
|
||||
test_url_loader_factory_.SetInterceptor(base::BindLambdaForTesting(
|
||||
[this, base_url](const network::ResourceRequest& request) {
|
||||
if (base::StartsWith(request.url.spec(), base_url)) {
|
||||
test_url_loader_factory_.AddResponse(
|
||||
request.url, network::mojom::URLResponseHead::New(), "",
|
||||
network::URLLoaderCompletionStatus(
|
||||
net::ERR_CONNECTION_REFUSED));
|
||||
} else {
|
||||
ADD_FAILURE() << "Unexpected request: " << request.url.spec();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
void ExpectEmptyResult(const std::string& json) {
|
||||
SimulateSuccessfulFetch(json);
|
||||
base::test::TestFuture<std::vector<mojom::ModelPtr>> future;
|
||||
fetcher_->FetchModels(kTestEndpoint, future.GetCallback());
|
||||
EXPECT_TRUE(future.Get().empty());
|
||||
}
|
||||
|
||||
base::test::TaskEnvironment task_environment_;
|
||||
network::TestURLLoaderFactory test_url_loader_factory_;
|
||||
scoped_refptr<network::SharedURLLoaderFactory> shared_url_loader_factory_;
|
||||
std::unique_ptr<RemoteModelsFetcher> fetcher_;
|
||||
};
|
||||
|
||||
TEST_F(RemoteModelsFetcherTest, SuccessfulFetch) {
|
||||
SimulateSuccessfulFetch(kValidModelsJSON);
|
||||
|
||||
base::test::TestFuture<std::vector<mojom::ModelPtr>> future;
|
||||
fetcher_->FetchModels(kTestEndpoint, future.GetCallback());
|
||||
const auto& fetched_models = future.Get();
|
||||
|
||||
ASSERT_EQ(3u, fetched_models.size());
|
||||
|
||||
EXPECT_EQ("test-model-1", fetched_models[0]->key);
|
||||
EXPECT_EQ("Test Model 1", fetched_models[0]->display_name);
|
||||
EXPECT_TRUE(fetched_models[0]->is_suggested_model);
|
||||
EXPECT_FALSE(fetched_models[0]->is_near_model);
|
||||
ASSERT_TRUE(fetched_models[0]->options->is_leo_model_options());
|
||||
auto& opts1 = fetched_models[0]->options->get_leo_model_options();
|
||||
EXPECT_EQ("test-model-1-api", opts1->name);
|
||||
EXPECT_EQ("Test Provider", opts1->display_maker);
|
||||
EXPECT_EQ("A basic test model", opts1->description);
|
||||
EXPECT_EQ(mojom::ModelCategory::CHAT, opts1->category);
|
||||
EXPECT_EQ(mojom::ModelAccess::BASIC, opts1->access);
|
||||
EXPECT_EQ(100000u, opts1->max_associated_content_length);
|
||||
EXPECT_EQ(200000u, opts1->long_conversation_warning_character_limit);
|
||||
ASSERT_EQ(2u, fetched_models[0]->supported_capabilities.size());
|
||||
EXPECT_EQ(mojom::ConversationCapability::CHAT,
|
||||
fetched_models[0]->supported_capabilities[0]);
|
||||
EXPECT_EQ(mojom::ConversationCapability::FILES,
|
||||
fetched_models[0]->supported_capabilities[1]);
|
||||
|
||||
EXPECT_EQ("test-model-2", fetched_models[1]->key);
|
||||
EXPECT_EQ("Test Model 2", fetched_models[1]->display_name);
|
||||
EXPECT_FALSE(fetched_models[1]->is_suggested_model);
|
||||
EXPECT_FALSE(fetched_models[1]->is_near_model);
|
||||
ASSERT_TRUE(fetched_models[1]->options->is_leo_model_options());
|
||||
auto& opts2 = fetched_models[1]->options->get_leo_model_options();
|
||||
EXPECT_EQ("test-model-2-api", opts2->name);
|
||||
EXPECT_EQ("Test Provider", opts2->display_maker);
|
||||
EXPECT_EQ("A premium test model", opts2->description);
|
||||
EXPECT_EQ(mojom::ModelCategory::CHAT, opts2->category);
|
||||
EXPECT_EQ(mojom::ModelAccess::PREMIUM, opts2->access);
|
||||
EXPECT_EQ(150000u, opts2->max_associated_content_length);
|
||||
EXPECT_EQ(300000u, opts2->long_conversation_warning_character_limit);
|
||||
ASSERT_EQ(3u, fetched_models[1]->supported_capabilities.size());
|
||||
EXPECT_EQ(mojom::ConversationCapability::CHAT,
|
||||
fetched_models[1]->supported_capabilities[0]);
|
||||
EXPECT_EQ(mojom::ConversationCapability::FILES,
|
||||
fetched_models[1]->supported_capabilities[1]);
|
||||
EXPECT_EQ(mojom::ConversationCapability::CONTENT_AGENT,
|
||||
fetched_models[1]->supported_capabilities[2]);
|
||||
|
||||
EXPECT_EQ("test-model-3", fetched_models[2]->key);
|
||||
EXPECT_EQ("Test Model 3", fetched_models[2]->display_name);
|
||||
ASSERT_TRUE(fetched_models[2]->options->is_leo_model_options());
|
||||
auto& opts3 = fetched_models[2]->options->get_leo_model_options();
|
||||
EXPECT_EQ(mojom::ModelCategory::SUMMARY, opts3->category);
|
||||
EXPECT_EQ(mojom::ModelAccess::BASIC, opts3->access);
|
||||
ASSERT_EQ(2u, fetched_models[2]->supported_capabilities.size());
|
||||
EXPECT_EQ(mojom::ConversationCapability::SUMMARY,
|
||||
fetched_models[2]->supported_capabilities[0]);
|
||||
EXPECT_EQ(mojom::ConversationCapability::FILES,
|
||||
fetched_models[2]->supported_capabilities[1]);
|
||||
}
|
||||
|
||||
TEST_F(RemoteModelsFetcherTest, HTTPError500) {
|
||||
SimulateHTTPError(500);
|
||||
|
||||
base::test::TestFuture<std::vector<mojom::ModelPtr>> future;
|
||||
fetcher_->FetchModels(kTestEndpoint, future.GetCallback());
|
||||
EXPECT_TRUE(future.Get().empty());
|
||||
}
|
||||
|
||||
TEST_F(RemoteModelsFetcherTest, NetworkError) {
|
||||
SimulateNetworkError();
|
||||
|
||||
base::test::TestFuture<std::vector<mojom::ModelPtr>> future;
|
||||
fetcher_->FetchModels(kTestEndpoint, future.GetCallback());
|
||||
EXPECT_TRUE(future.Get().empty());
|
||||
}
|
||||
|
||||
TEST_F(RemoteModelsFetcherTest, InvalidJSON) {
|
||||
SimulateSuccessfulFetch(kInvalidJSON);
|
||||
|
||||
base::test::TestFuture<std::vector<mojom::ModelPtr>> future;
|
||||
fetcher_->FetchModels(kTestEndpoint, future.GetCallback());
|
||||
EXPECT_TRUE(future.Get().empty());
|
||||
}
|
||||
|
||||
TEST_F(RemoteModelsFetcherTest, ValidModelsReturnedWhenSomeFail) {
|
||||
constexpr char kMixedModelsJSON[] = R"({
|
||||
"models": [
|
||||
{
|
||||
"key": "valid-model",
|
||||
"display_name": "Valid Model",
|
||||
"is_suggested_model": false,
|
||||
"is_near_model": false,
|
||||
"capabilities": ["chat", "files"],
|
||||
"options": {
|
||||
"type": "leo",
|
||||
"name": "valid-model-api",
|
||||
"display_maker": "Test Provider",
|
||||
"access": "basic",
|
||||
"max_associated_content_length": 100000,
|
||||
"long_conversation_warning_character_limit": 200000
|
||||
}
|
||||
},
|
||||
{
|
||||
"display_name": "Invalid Model - Missing Key",
|
||||
"is_suggested_model": false,
|
||||
"is_near_model": false,
|
||||
"capabilities": ["chat", "files"],
|
||||
"options": {
|
||||
"type": "leo",
|
||||
"name": "invalid-model-api",
|
||||
"access": "basic",
|
||||
"max_associated_content_length": 100000,
|
||||
"long_conversation_warning_character_limit": 200000
|
||||
}
|
||||
}
|
||||
]
|
||||
})";
|
||||
|
||||
SimulateSuccessfulFetch(kMixedModelsJSON);
|
||||
|
||||
base::test::TestFuture<std::vector<mojom::ModelPtr>> future;
|
||||
fetcher_->FetchModels(kTestEndpoint, future.GetCallback());
|
||||
const auto& fetched_models = future.Get();
|
||||
|
||||
ASSERT_EQ(1u, fetched_models.size());
|
||||
EXPECT_EQ("valid-model", fetched_models[0]->key);
|
||||
}
|
||||
|
||||
TEST_F(RemoteModelsFetcherTest, RequiredFieldsRejected) {
|
||||
const struct {
|
||||
const char* name;
|
||||
const char* json;
|
||||
} kCases[] = {
|
||||
{"MissingKey", kMissingKeyJSON},
|
||||
{"MissingDisplayName", kMissingDisplayNameJSON},
|
||||
{"MissingOptions", kMissingOptionsJSON},
|
||||
{"MissingName", kMissingNameJSON},
|
||||
{"MissingAccess", kMissingAccessJSON},
|
||||
};
|
||||
|
||||
for (const auto& test_case : kCases) {
|
||||
SCOPED_TRACE(test_case.name);
|
||||
ExpectEmptyResult(test_case.json);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(RemoteModelsFetcherTest, MissingCapabilities) {
|
||||
ExpectEmptyResult(R"({
|
||||
"models": [
|
||||
{
|
||||
"key": "test-model",
|
||||
"display_name": "Test Model",
|
||||
"is_suggested_model": false,
|
||||
"is_near_model": false,
|
||||
"options": {
|
||||
"type": "leo",
|
||||
"name": "test-model-api",
|
||||
"access": "basic",
|
||||
"max_associated_content_length": 100000,
|
||||
"long_conversation_warning_character_limit": 200000
|
||||
}
|
||||
}
|
||||
]
|
||||
})");
|
||||
}
|
||||
|
||||
TEST_F(RemoteModelsFetcherTest, NoCategoryCapability) {
|
||||
ExpectEmptyResult(R"({
|
||||
"models": [
|
||||
{
|
||||
"key": "test-model",
|
||||
"display_name": "Test Model",
|
||||
"is_suggested_model": false,
|
||||
"is_near_model": false,
|
||||
"capabilities": ["files"],
|
||||
"options": {
|
||||
"type": "leo",
|
||||
"name": "test-model-api",
|
||||
"access": "basic",
|
||||
"max_associated_content_length": 100000,
|
||||
"long_conversation_warning_character_limit": 200000
|
||||
}
|
||||
}
|
||||
]
|
||||
})");
|
||||
}
|
||||
|
||||
TEST_F(RemoteModelsFetcherTest, InvalidModelType) {
|
||||
ExpectEmptyResult(kInvalidTypeJSON);
|
||||
}
|
||||
|
||||
TEST_F(RemoteModelsFetcherTest, RejectsHTTPEndpoint) {
|
||||
base::test::TestFuture<std::vector<mojom::ModelPtr>> future;
|
||||
fetcher_->FetchModels("http://example.com/models", future.GetCallback());
|
||||
EXPECT_TRUE(future.Get().empty());
|
||||
}
|
||||
|
||||
TEST_F(RemoteModelsFetcherTest, RejectsHTTPForLocalhost) {
|
||||
base::test::TestFuture<std::vector<mojom::ModelPtr>> future;
|
||||
fetcher_->FetchModels("http://localhost:8080/models", future.GetCallback());
|
||||
EXPECT_TRUE(future.Get().empty());
|
||||
}
|
||||
|
||||
TEST_F(RemoteModelsFetcherTest, RejectsInvalidURL) {
|
||||
base::test::TestFuture<std::vector<mojom::ModelPtr>> future;
|
||||
fetcher_->FetchModels("not-a-valid-url", future.GetCallback());
|
||||
EXPECT_TRUE(future.Get().empty());
|
||||
}
|
||||
|
||||
TEST_F(RemoteModelsFetcherTest, EmptyResponse) {
|
||||
SimulateSuccessfulFetch(R"({"models": []})");
|
||||
|
||||
base::test::TestFuture<std::vector<mojom::ModelPtr>> future;
|
||||
fetcher_->FetchModels(kTestEndpoint, future.GetCallback());
|
||||
EXPECT_TRUE(future.Get().empty());
|
||||
}
|
||||
|
||||
TEST_F(RemoteModelsFetcherTest, RejectsUnrecognizedAccessLevel) {
|
||||
ExpectEmptyResult(R"({
|
||||
"models": [
|
||||
{
|
||||
"key": "unknown-access-model",
|
||||
"display_name": "Unknown Access Model",
|
||||
"is_suggested_model": false,
|
||||
"is_near_model": false,
|
||||
"capabilities": ["chat", "files"],
|
||||
"options": {
|
||||
"type": "leo",
|
||||
"name": "unknown-access-api",
|
||||
"display_maker": "Test Provider",
|
||||
"access": "enterprise",
|
||||
"max_associated_content_length": 100000,
|
||||
"long_conversation_warning_character_limit": 200000
|
||||
}
|
||||
}
|
||||
]
|
||||
})");
|
||||
}
|
||||
|
||||
TEST_F(RemoteModelsFetcherTest, MissingNumericFieldsGetTierDefaults) {
|
||||
constexpr char kMissingNumericFieldsJSON[] = R"({
|
||||
"models": [
|
||||
{
|
||||
"key": "basic-model",
|
||||
"display_name": "Basic Model",
|
||||
"is_suggested_model": false,
|
||||
"is_near_model": false,
|
||||
"capabilities": ["chat", "files"],
|
||||
"options": {
|
||||
"type": "leo",
|
||||
"name": "basic-model-api",
|
||||
"display_maker": "Test Provider",
|
||||
"access": "basic"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "premium-model",
|
||||
"display_name": "Premium Model",
|
||||
"is_suggested_model": false,
|
||||
"is_near_model": false,
|
||||
"capabilities": ["chat", "files"],
|
||||
"options": {
|
||||
"type": "leo",
|
||||
"name": "premium-model-api",
|
||||
"display_maker": "Test Provider",
|
||||
"access": "premium"
|
||||
}
|
||||
}
|
||||
]
|
||||
})";
|
||||
|
||||
SimulateSuccessfulFetch(kMissingNumericFieldsJSON);
|
||||
|
||||
base::test::TestFuture<std::vector<mojom::ModelPtr>> future;
|
||||
fetcher_->FetchModels(kTestEndpoint, future.GetCallback());
|
||||
const auto& fetched_models = future.Get();
|
||||
|
||||
ASSERT_EQ(2u, fetched_models.size());
|
||||
|
||||
ASSERT_TRUE(fetched_models[0]->options->is_leo_model_options());
|
||||
auto& basic_opts = fetched_models[0]->options->get_leo_model_options();
|
||||
EXPECT_EQ(basic_opts->max_associated_content_length, 32000u);
|
||||
EXPECT_EQ(basic_opts->long_conversation_warning_character_limit, 51200u);
|
||||
|
||||
ASSERT_TRUE(fetched_models[1]->options->is_leo_model_options());
|
||||
auto& premium_opts = fetched_models[1]->options->get_leo_model_options();
|
||||
EXPECT_EQ(premium_opts->max_associated_content_length, 90000u);
|
||||
EXPECT_EQ(premium_opts->long_conversation_warning_character_limit, 160000u);
|
||||
}
|
||||
|
||||
TEST_F(RemoteModelsFetcherTest, ParsesBareListResponse) {
|
||||
constexpr char kBareListJSON[] = R"([
|
||||
{
|
||||
"key": "test-model-1",
|
||||
"display_name": "Test Model 1",
|
||||
"is_suggested_model": true,
|
||||
"is_near_model": false,
|
||||
"capabilities": ["chat", "files"],
|
||||
"options": {
|
||||
"type": "leo",
|
||||
"name": "test-model-1-api",
|
||||
"display_maker": "Test Provider",
|
||||
"description": "A basic test model",
|
||||
"access": "basic",
|
||||
"max_associated_content_length": 100000,
|
||||
"long_conversation_warning_character_limit": 200000
|
||||
}
|
||||
}
|
||||
])";
|
||||
|
||||
SimulateSuccessfulFetch(kBareListJSON);
|
||||
|
||||
base::test::TestFuture<std::vector<mojom::ModelPtr>> future;
|
||||
fetcher_->FetchModels(kTestEndpoint, future.GetCallback());
|
||||
const auto& fetched_models = future.Get();
|
||||
|
||||
ASSERT_EQ(1u, fetched_models.size());
|
||||
EXPECT_EQ("test-model-1", fetched_models[0]->key);
|
||||
EXPECT_EQ("Test Model 1", fetched_models[0]->display_name);
|
||||
}
|
||||
|
||||
TEST_F(RemoteModelsFetcherTest, SkipsUnknownCapabilities) {
|
||||
constexpr char kUnknownCapabilityJSON[] = R"({
|
||||
"models": [
|
||||
{
|
||||
"key": "test-model",
|
||||
"display_name": "Test Model",
|
||||
"is_suggested_model": false,
|
||||
"is_near_model": false,
|
||||
"capabilities": ["chat", "unknown_capability"],
|
||||
"options": {
|
||||
"type": "leo",
|
||||
"name": "test-model-api",
|
||||
"display_maker": "Test Provider",
|
||||
"access": "basic",
|
||||
"max_associated_content_length": 100000,
|
||||
"long_conversation_warning_character_limit": 200000
|
||||
}
|
||||
}
|
||||
]
|
||||
})";
|
||||
|
||||
SimulateSuccessfulFetch(kUnknownCapabilityJSON);
|
||||
|
||||
base::test::TestFuture<std::vector<mojom::ModelPtr>> future;
|
||||
fetcher_->FetchModels(kTestEndpoint, future.GetCallback());
|
||||
const auto& fetched_models = future.Get();
|
||||
|
||||
ASSERT_EQ(1u, fetched_models.size());
|
||||
ASSERT_EQ(1u, fetched_models[0]->supported_capabilities.size());
|
||||
EXPECT_EQ(mojom::ConversationCapability::CHAT,
|
||||
fetched_models[0]->supported_capabilities[0]);
|
||||
}
|
||||
|
||||
TEST_F(RemoteModelsFetcherTest, RejectsNegativeMaxContentLength) {
|
||||
ExpectEmptyResult(R"({
|
||||
"models": [
|
||||
{
|
||||
"key": "bad-model",
|
||||
"display_name": "Bad Model",
|
||||
"is_suggested_model": false,
|
||||
"is_near_model": false,
|
||||
"capabilities": ["chat", "files"],
|
||||
"options": {
|
||||
"type": "leo",
|
||||
"name": "bad-model-api",
|
||||
"display_maker": "Test Provider",
|
||||
"access": "basic",
|
||||
"max_associated_content_length": -1,
|
||||
"long_conversation_warning_character_limit": 200000
|
||||
}
|
||||
}
|
||||
]
|
||||
})");
|
||||
}
|
||||
|
||||
TEST_F(RemoteModelsFetcherTest, RejectsNegativeWarningLimit) {
|
||||
ExpectEmptyResult(R"({
|
||||
"models": [
|
||||
{
|
||||
"key": "bad-model",
|
||||
"display_name": "Bad Model",
|
||||
"is_suggested_model": false,
|
||||
"is_near_model": false,
|
||||
"capabilities": ["chat", "files"],
|
||||
"options": {
|
||||
"type": "leo",
|
||||
"name": "bad-model-api",
|
||||
"display_maker": "Test Provider",
|
||||
"access": "basic",
|
||||
"max_associated_content_length": 100000,
|
||||
"long_conversation_warning_character_limit": -1
|
||||
}
|
||||
}
|
||||
]
|
||||
})");
|
||||
}
|
||||
|
||||
} // namespace ai_chat
|
||||
@@ -49,6 +49,9 @@ const base::FeatureParam<bool> kAutomaticModelSupportsTools{
|
||||
const base::FeatureParam<bool> kShouldIndentPageContentBlocks{
|
||||
&kAIChat, "should_indent_page_content_blocks", true};
|
||||
|
||||
// Enable remote model fetching from server endpoint
|
||||
BASE_FEATURE(kAIChatRemoteModelsConfig, base::FEATURE_DISABLED_BY_DEFAULT);
|
||||
|
||||
bool IsAIChatEnabled() {
|
||||
return base::FeatureList::IsEnabled(features::kAIChat);
|
||||
}
|
||||
|
||||
@@ -63,6 +63,9 @@ extern const base::FeatureParam<bool> kAutomaticModelSupportsTools;
|
||||
COMPONENT_EXPORT(AI_CHAT_COMMON)
|
||||
extern const base::FeatureParam<bool> kShouldIndentPageContentBlocks;
|
||||
|
||||
COMPONENT_EXPORT(AI_CHAT_COMMON)
|
||||
BASE_DECLARE_FEATURE(kAIChatRemoteModelsConfig);
|
||||
|
||||
COMPONENT_EXPORT(AI_CHAT_COMMON) bool IsAIChatEnabled();
|
||||
|
||||
COMPONENT_EXPORT(AI_CHAT_COMMON) BASE_DECLARE_FEATURE(kAIChatHistory);
|
||||
|
||||
@@ -59,7 +59,9 @@ enum CharacterType {
|
||||
enum ConversationCapability {
|
||||
CHAT,
|
||||
CONTENT_AGENT,
|
||||
DEEP_RESEARCH
|
||||
DEEP_RESEARCH,
|
||||
FILES,
|
||||
SUMMARY
|
||||
};
|
||||
|
||||
// Which action the user was taking for the entry. This can be used
|
||||
|
||||
Reference in New Issue
Block a user