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:
jeremytieman
2026-05-27 16:37:46 -07:00
committed by GitHub
parent 4c5405a5f4
commit e15973a4d9
7 changed files with 1054 additions and 1 deletions
+3
View File
@@ -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