[AIChat] Add NEAR attestation verification (#32370)

* [AIChat] Add NEAR attestation verification

* Remove `NEARVerifier`, get verification status from response header

* Update to use `near_verification_status`

* Address follow-ups
This commit is contained in:
Darnell Andries
2025-11-19 02:07:08 +01:00
committed by GitHub
parent 586b221ae9
commit cc4a901ae3
13 changed files with 280 additions and 105 deletions
@@ -1247,80 +1247,93 @@ void ConversationHandler::UpdateOrCreateLastAssistantEntry(
auto& entry = chat_history_.back();
auto& event = result.event;
if (event->is_completion_event()) {
if (!engine_->SupportsDeltaTextResponses() || entry->events->size() == 0 ||
!entry->events->back()->is_completion_event()) {
// The start of completion responses needs whitespace trim
// TODO(petemill): This should happen server-side?
event->get_completion_event()->completion = base::TrimWhitespaceASCII(
event->get_completion_event()->completion, base::TRIM_LEADING);
// Only update if verification status is pending, or did not already fail.
if (result.is_near_verified.has_value() &&
(!entry->near_verification_status ||
entry->near_verification_status->verified)) {
entry->near_verification_status =
mojom::NEARVerificationStatus::New(*result.is_near_verified);
}
if (event) {
if (event->is_completion_event()) {
if (!engine_->SupportsDeltaTextResponses() ||
entry->events->size() == 0 ||
!entry->events->back()->is_completion_event()) {
// The start of completion responses needs whitespace trim
// TODO(petemill): This should happen server-side?
event->get_completion_event()->completion = base::TrimWhitespaceASCII(
event->get_completion_event()->completion, base::TRIM_LEADING);
}
// Optimize by merging with previous completion events if delta updates
// are supported or otherwise replacing the previous event.
if (entry->events->size() > 0) {
auto& last_event = entry->events->back();
if (last_event->is_completion_event()) {
// Merge completion events
if (engine_->SupportsDeltaTextResponses()) {
event->get_completion_event()->completion =
base::StrCat({last_event->get_completion_event()->completion,
event->get_completion_event()->completion});
}
// Remove the last event because we'll replace in both delta and
// non-delta cases
entry->events->pop_back();
}
}
// TODO(petemill): Remove ConversationTurn.text backwards compatibility
// when all UI is updated to instead use ConversationEntryEvent items.
entry->text = event->get_completion_event()->completion;
}
// Optimize by merging with previous completion events if delta updates
// are supported or otherwise replacing the previous event.
if (entry->events->size() > 0) {
if (event->is_tool_use_event() && entry->events->size() > 0) {
// Tool use events can be partial and may need to be combined with the
// previous event.
auto& last_event = entry->events->back();
if (last_event->is_completion_event()) {
// Merge completion events
if (engine_->SupportsDeltaTextResponses()) {
event->get_completion_event()->completion =
base::StrCat({last_event->get_completion_event()->completion,
event->get_completion_event()->completion});
}
// Remove the last event because we'll replace in both delta and
// non-delta cases
entry->events->pop_back();
auto& tool_use_event = event->get_tool_use_event();
DVLOG(2) << __func__
<< " Got event for tool use: " << tool_use_event->tool_name
<< " is empty? " << tool_use_event->tool_name.empty()
<< " with input: " << tool_use_event->arguments_json;
if (last_event->is_tool_use_event() &&
tool_use_event->tool_name.empty()) {
last_event->get_tool_use_event()->arguments_json =
base::StrCat({last_event->get_tool_use_event()->arguments_json,
tool_use_event->arguments_json});
// TODO(petemill): Don't clone
OnHistoryUpdate(entry.Clone());
return;
}
}
// TODO(petemill): Remove ConversationTurn.text backwards compatibility when
// all UI is updated to instead use ConversationEntryEvent items.
entry->text = event->get_completion_event()->completion;
}
if (event->is_tool_use_event() && entry->events->size() > 0) {
// Tool use events can be partial and may need to be combined with the
// previous event.
auto& last_event = entry->events->back();
auto& tool_use_event = event->get_tool_use_event();
DVLOG(2) << __func__
<< " Got event for tool use: " << tool_use_event->tool_name
<< " is empty? " << tool_use_event->tool_name.empty()
<< " with input: " << tool_use_event->arguments_json;
if (last_event->is_tool_use_event() && tool_use_event->tool_name.empty()) {
last_event->get_tool_use_event()->arguments_json =
base::StrCat({last_event->get_tool_use_event()->arguments_json,
tool_use_event->arguments_json});
// TODO(petemill): Don't clone
OnHistoryUpdate(entry.Clone());
if (event->is_conversation_title_event()) {
OnConversationTitleChanged(event->get_conversation_title_event()->title);
// Don't add this event to history
return;
}
}
if (event->is_conversation_title_event()) {
OnConversationTitleChanged(event->get_conversation_title_event()->title);
// Don't add this event to history
return;
}
if (event->is_selected_language_event()) {
OnSelectedLanguageChanged(
event->get_selected_language_event()->selected_language);
// Don't add this event to history
return;
}
if (event->is_selected_language_event()) {
OnSelectedLanguageChanged(
event->get_selected_language_event()->selected_language);
// Don't add this event to history
return;
}
if (event->is_content_receipt_event()) {
OnConversationTokenInfoChanged(
event->get_content_receipt_event()->total_tokens,
event->get_content_receipt_event()->trimmed_tokens);
// Don't add this event to history
return;
}
if (event->is_content_receipt_event()) {
OnConversationTokenInfoChanged(
event->get_content_receipt_event()->total_tokens,
event->get_content_receipt_event()->trimmed_tokens);
// Don't add this event to history
return;
entry->events->push_back(std::move(event));
}
entry->events->push_back(std::move(event));
// Update clients for partial entries but not observers, who will get notified
// when we know this is a complete entry.
OnHistoryUpdate(entry.Clone());
@@ -1573,8 +1586,9 @@ void ConversationHandler::OnEngineCompletionComplete(
// Handle success, which might mean do nothing much since all data was passed
// in the streaming "received" callback.
DVLOG(2) << __func__ << ": With value";
if (result->event && result->event->is_completion_event() &&
!result->event->get_completion_event()->completion.empty()) {
if ((result->event && result->event->is_completion_event() &&
!result->event->get_completion_event()->completion.empty()) ||
result->is_near_verified.has_value()) {
UpdateOrCreateLastAssistantEntry(std::move(*result));
} else {
// This is a workaround for any occasions where the engine returns
@@ -542,6 +542,72 @@ TEST_F(ConversationHandlerUnitTest, SubmitSelectedText) {
ExpectConversationHistoryEquals(FROM_HERE, history, expected_history, false);
}
TEST_F(ConversationHandlerUnitTest, SubmitSelectedText_WithNEARVerification) {
MockEngineConsumer* engine = static_cast<MockEngineConsumer*>(
conversation_handler_->GetEngineForTesting());
std::string selected_text = "Verified content.";
std::string expected_turn_text =
l10n_util::GetStringUTF8(IDS_AI_CHAT_QUESTION_SUMMARIZE_SELECTED_TEXT);
const std::string expected_response = "This is verified.";
EXPECT_CALL(*engine, GenerateAssistantResponse(
_, LastTurnHasSelectedText(selected_text), StrEq(""),
_, _, _, _, _, _))
.WillOnce(::testing::DoAll(
base::test::RunOnceCallback<7>(EngineConsumer::GenerationResultData(
mojom::ConversationEntryEvent::NewCompletionEvent(
mojom::CompletionEvent::New(expected_response)),
std::nullopt /* model_key */, true /* is_near_verified */)),
base::test::RunOnceCallback<8>(
base::ok(EngineConsumer::GenerationResultData(
nullptr, std::nullopt /* model_key */,
true /* is_near_verified */)))));
conversation_handler_->associated_content_manager()->ClearContent();
std::vector<mojom::ConversationTurnPtr> expected_history;
expected_history.push_back(mojom::ConversationTurn::New(
std::nullopt, mojom::CharacterType::HUMAN,
mojom::ActionType::SUMMARIZE_SELECTED_TEXT, expected_turn_text,
std::nullopt, selected_text, std::nullopt, base::Time::Now(),
std::nullopt, std::nullopt, nullptr /* skill */, false,
std::nullopt /* model_key */, nullptr /* near_verification_status */));
std::vector<mojom::ConversationEntryEventPtr> response_events;
response_events.push_back(mojom::ConversationEntryEvent::NewCompletionEvent(
mojom::CompletionEvent::New(expected_response)));
expected_history.push_back(mojom::ConversationTurn::New(
std::nullopt, mojom::CharacterType::ASSISTANT,
mojom::ActionType::RESPONSE, expected_response, std::nullopt,
std::nullopt, std::move(response_events), base::Time::Now(), std::nullopt,
std::nullopt, nullptr /* skill */, false, std::nullopt /* model_key */,
mojom::NEARVerificationStatus::New(true)));
NiceMock<MockConversationHandlerClient> client(conversation_handler_.get());
EXPECT_CALL(client, OnAPIRequestInProgress(true)).Times(1);
EXPECT_CALL(client, OnConversationHistoryUpdate(
TurnEq(mojom::ConversationTurnPtr().get())))
.Times(1);
EXPECT_CALL(client,
OnConversationHistoryUpdate(TurnEq(expected_history[1].get())))
.Times(3);
EXPECT_CALL(client, OnAPIRequestInProgress(false)).Times(1);
EXPECT_CALL(*engine, SanitizeInput(StrEq(selected_text)));
EXPECT_CALL(*engine, SanitizeInput(StrEq(expected_turn_text)));
conversation_handler_->SubmitSelectedText(
selected_text, mojom::ActionType::SUMMARIZE_SELECTED_TEXT);
task_environment_.RunUntilIdle();
testing::Mock::VerifyAndClearExpectations(&client);
EXPECT_TRUE(conversation_handler_->HasAnyHistory());
const auto& history = conversation_handler_->GetConversationHistory();
ExpectConversationHistoryEquals(FROM_HERE, history, expected_history, false);
}
TEST_F(ConversationHandlerUnitTest, SubmitSelectedText_WithAssociatedContent) {
// Test with page contents.
MockEngineConsumer* engine = static_cast<MockEngineConsumer*>(
@@ -15,10 +15,10 @@
#include <vector>
#include "base/check.h"
#include "base/command_line.h"
#include "base/containers/checked_iterators.h"
#include "base/containers/fixed_flat_map.h"
#include "base/containers/flat_map.h"
#include "base/containers/map_util.h"
#include "base/functional/bind.h"
#include "base/json/json_writer.h"
#include "base/logging.h"
@@ -31,11 +31,11 @@
#include "base/strings/string_util.h"
#include "base/types/expected.h"
#include "base/values.h"
#include "brave/brave_domains/service_domains.h"
#include "brave/components/ai_chat/core/browser/ai_chat_credential_manager.h"
#include "brave/components/ai_chat/core/browser/engine/conversation_api_parsing.h"
#include "brave/components/ai_chat/core/browser/engine/oai_parsing.h"
#include "brave/components/ai_chat/core/browser/model_service.h"
#include "brave/components/ai_chat/core/browser/utils.h"
#include "brave/components/ai_chat/core/common/buildflags/buildflags.h"
#include "brave/components/ai_chat/core/common/features.h"
#include "brave/components/ai_chat/core/common/mojom/ai_chat.mojom.h"
@@ -63,10 +63,6 @@ constexpr char kRemotePath[] = "v1/conversation";
constexpr char kAllowedWebSourceFaviconHost[] = "imgs.search.brave.com";
#if !defined(OFFICIAL_BUILD)
constexpr char kAIChatServerUrl[] = "ai-chat-server-url";
#endif
net::NetworkTrafficAnnotationTag GetNetworkTrafficAnnotationTag() {
return net::DefineNetworkTrafficAnnotation("ai_chat", R"(
semantics {
@@ -192,33 +188,6 @@ base::Value::List ConversationEventsToList(
return events;
}
GURL GetEndpointUrl(bool premium, const std::string& path) {
CHECK(!path.starts_with("/"));
#if !defined(OFFICIAL_BUILD)
// If a runtime AI Chat URL is provided, use it.
std::string ai_chat_url =
base::CommandLine::ForCurrentProcess()->GetSwitchValueASCII(
kAIChatServerUrl);
if (!ai_chat_url.empty()) {
GURL url = GURL(base::StrCat({ai_chat_url, "/", path}));
CHECK(url.is_valid()) << "Invalid API Url: " << url.spec();
return url;
}
#endif
auto* prefix = premium ? "ai-chat-premium.bsg" : "ai-chat.bsg";
auto hostname = brave_domains::GetServicesDomain(
prefix, brave_domains::ServicesEnvironment::DEV);
GURL url{base::StrCat(
{url::kHttpsScheme, url::kStandardSchemeSeparator, hostname, "/", path})};
CHECK(url.is_valid()) << "Invalid API Url: " << url.spec();
return url;
}
} // namespace
ConversationAPIClient::ConversationEvent::ConversationEvent(
@@ -409,6 +378,7 @@ void ConversationAPIClient::OnQueryCompleted(
if (success) {
std::string completion = "";
std::optional<std::string> model_key = std::nullopt;
std::optional<bool> is_near_verified = std::nullopt;
mojom::ConversationEntryEventPtr completion_event = nullptr;
// We're checking for a value body in case for non-streaming API results.
// TODO(petemill): server should provide parseable history events even for
@@ -428,10 +398,16 @@ void ConversationAPIClient::OnQueryCompleted(
}
}
const auto& headers = result.headers();
if (const auto* header_value =
base::FindOrNull(headers, kBraveNearVerifiedHeader)) {
is_near_verified = *header_value == "true";
}
completion_event = mojom::ConversationEntryEvent::NewCompletionEvent(
mojom::CompletionEvent::New(completion));
GenerationResultData data(std::move(completion_event),
std::move(model_key));
GenerationResultData data(std::move(completion_event), std::move(model_key),
is_near_verified);
std::move(callback).Run(base::ok(std::move(data)));
return;
}
@@ -39,6 +39,8 @@ namespace ai_chat {
class AIChatCredentialManager;
struct CredentialCacheEntry;
inline constexpr char kBraveNearVerifiedHeader[] = "brave-near-verified";
// Performs remote request to the remote HTTP Brave Conversation API.
class ConversationAPIClient {
public:
@@ -582,6 +582,7 @@ TEST_F(ConversationAPIUnitTest, PerformRequest_PremiumHeaders) {
mojom::ConversationEntryEvent::NewCompletionEvent(
mojom::CompletionEvent::New("")),
std::nullopt));
EXPECT_FALSE(result->is_near_verified.has_value());
});
// Begin request
@@ -1348,6 +1349,70 @@ TEST_F(ConversationAPIUnitTest,
testing::Mock::VerifyAndClearExpectations(mock_request_helper);
}
TEST_F(ConversationAPIUnitTest, PerformRequest_NEARVerification) {
std::string expected_completion_response = "Verified response";
auto events_and_body = GetMockEventsAndExpectedEventsBody();
std::vector<ConversationAPIClient::ConversationEvent> events =
std::move(events_and_body.first);
MockAPIRequestHelper* mock_request_helper =
client_->GetMockAPIRequestHelper();
testing::StrictMock<MockCallbacks> mock_callbacks;
base::RunLoop run_loop;
EXPECT_CALL(*mock_request_helper, RequestSSE(_, _, _, _, _, _, _, _))
.WillOnce([&](const std::string& method, const GURL& url,
const std::string& body, const std::string& content_type,
DataReceivedCallback data_received_callback,
ResultCallback result_callback,
const base::flat_map<std::string, std::string>& headers,
const api_request_helper::APIRequestOptions& options) {
base::Value result(base::Value::Type::DICT);
result.GetDict().Set("type", "completion");
result.GetDict().Set("model", "llama-3-8b-instruct");
result.GetDict().Set("completion", expected_completion_response);
data_received_callback.Run(base::ok(std::move(result)));
base::flat_map<std::string, std::string> response_headers;
response_headers[kBraveNearVerifiedHeader] = "true";
std::move(result_callback)
.Run(api_request_helper::APIRequestResult(200, {}, response_headers,
net::OK, GURL()));
run_loop.Quit();
return Ticket();
});
EXPECT_CALL(mock_callbacks, OnDataReceived(_))
.WillOnce([&](EngineConsumer::GenerationResultData result) {
ASSERT_TRUE(result.event);
EXPECT_TRUE(result.event->is_completion_event());
EXPECT_EQ(result.event->get_completion_event()->completion,
expected_completion_response);
EXPECT_FALSE(result.is_near_verified);
});
EXPECT_CALL(mock_callbacks, OnCompleted(_))
.WillOnce([](EngineConsumer::GenerationResult result) {
ASSERT_TRUE(result.has_value());
EXPECT_TRUE(result.value().is_near_verified.has_value());
EXPECT_TRUE(result.value().is_near_verified.value());
});
client_->PerformRequest(
std::move(events), "" /* selected_language */,
std::nullopt, /* oai_tool_definitions */
std::nullopt, /* preferred_tool_name */
mojom::ConversationCapability::CONTENT_AGENT,
base::BindRepeating(&MockCallbacks::OnDataReceived,
base::Unretained(&mock_callbacks)),
base::BindOnce(&MockCallbacks::OnCompleted,
base::Unretained(&mock_callbacks)));
run_loop.Run();
testing::Mock::VerifyAndClearExpectations(client_.get());
testing::Mock::VerifyAndClearExpectations(mock_request_helper);
}
TEST_F(ConversationAPIUnitTest, FailNoConversationEvents) {
// Tests handling invalid request parameters
std::vector<ConversationAPIClient::ConversationEvent> events;
@@ -17,8 +17,11 @@ namespace ai_chat {
EngineConsumer::GenerationResultData::GenerationResultData(
mojom::ConversationEntryEventPtr event,
std::optional<std::string>&& model_key)
: event(std::move(event)), model_key(std::move(model_key)) {}
std::optional<std::string>&& model_key,
std::optional<bool> is_near_verified)
: event(std::move(event)),
model_key(std::move(model_key)),
is_near_verified(is_near_verified) {}
EngineConsumer::GenerationResultData::GenerationResultData(
GenerationResultData&& other) = default;
@@ -8,6 +8,7 @@
#include <cstdint>
#include <memory>
#include <optional>
#include <string>
#include <utility>
#include <vector>
@@ -43,7 +44,8 @@ class EngineConsumer {
struct GenerationResultData {
GenerationResultData(mojom::ConversationEntryEventPtr event,
std::optional<std::string>&& model_key);
std::optional<std::string>&& model_key,
std::optional<bool> is_near_verified = std::nullopt);
~GenerationResultData();
GenerationResultData(GenerationResultData&&);
@@ -55,6 +57,7 @@ class EngineConsumer {
mojom::ConversationEntryEventPtr event;
std::optional<std::string> model_key;
std::optional<bool> is_near_verified;
};
using GenerationResult =
+29
View File
@@ -11,9 +11,11 @@
#include <vector>
#include "base/check.h"
#include "base/command_line.h"
#include "base/containers/flat_map.h"
#include "base/functional/bind.h"
#include "base/no_destructor.h"
#include "base/strings/strcat.h"
#include "base/strings/string_util.h"
#include "base/time/time.h"
#include "brave/brave_domains/service_domains.h"
@@ -253,4 +255,31 @@ SkBitmap ScaleDownBitmap(const SkBitmap& bitmap) {
return scaled_bitmap;
}
GURL GetEndpointUrl(bool premium, const std::string& path) {
CHECK(!path.starts_with("/"));
#if !defined(OFFICIAL_BUILD)
// If a runtime AI Chat URL is provided, use it.
std::string ai_chat_url =
base::CommandLine::ForCurrentProcess()->GetSwitchValueASCII(
"ai-chat-server-url");
if (!ai_chat_url.empty()) {
GURL url = GURL(base::StrCat({ai_chat_url, "/", path}));
CHECK(url.is_valid()) << "Invalid API Url: " << url.spec();
return url;
}
#endif
auto* prefix = premium ? "ai-chat-premium.bsg" : "ai-chat.bsg";
auto hostname = brave_domains::GetServicesDomain(
prefix, brave_domains::ServicesEnvironment::DEV);
GURL url{base::StrCat(
{url::kHttpsScheme, url::kStandardSchemeSeparator, hostname, "/", path})};
CHECK(url.is_valid()) << "Invalid API Url: " << url.spec();
return url;
}
} // namespace ai_chat
+2
View File
@@ -48,6 +48,8 @@ EngineConsumer::GenerationDataCallback BindParseRewriteReceivedData(
// 1024x768
SkBitmap ScaleDownBitmap(const SkBitmap& bitmap);
GURL GetEndpointUrl(bool premium, const std::string& path);
} // namespace ai_chat
#endif // BRAVE_COMPONENTS_AI_CHAT_CORE_BROWSER_UTILS_H_
@@ -20,6 +20,7 @@ type RatingStatus = (typeof statuses)[number]
interface ContextActionsAssistantProps {
turnUuid?: string
turnModelKey?: string
turnNEARVerified?: boolean
onEditAnswerClicked?: () => void
onCopyTextClicked?: () => void
}
@@ -121,6 +122,7 @@ export default function ContextActionsAssistant(
onRegenerate={handleRegenerateAnswer}
leoModels={leoModels}
turnModelKey={props.turnModelKey}
turnNEARVerified={props.turnNEARVerified}
/>
)}
</div>
@@ -379,6 +379,9 @@ function ConversationEntries() {
<ContextActionsAssistant
turnUuid={firstEntryEdit.uuid}
turnModelKey={turnModelKey}
turnNEARVerified={
group.at(-1)?.nearVerificationStatus?.verified
}
onEditAnswerClicked={
canEditEntry
? () => setEditInputId(index)
@@ -13,6 +13,7 @@ import * as Mojom from '../../../common/mojom'
import {
ModelMenuItem, //
} from '../../../page/components/model_menu_item/model_menu_item'
import { NearLabel } from '../../../page/components/near_label/near_label'
import styles from './style.module.scss'
interface Props {
@@ -22,11 +23,19 @@ interface Props {
onRegenerate: (selectedModelKey: string) => void
leoModels: Mojom.Model[]
turnModelKey: string
turnNEARVerified?: boolean
}
export function RegenerateAnswerMenu(props: Props) {
const { isOpen, onOpen, onClose, onRegenerate, leoModels, turnModelKey } =
props
const {
isOpen,
onOpen,
onClose,
onRegenerate,
leoModels,
turnModelKey,
turnNEARVerified,
} = props
const modelDisplayName =
leoModels.find((model) => model.key === turnModelKey)?.displayName ?? ''
@@ -68,6 +77,7 @@ export function RegenerateAnswerMenu(props: Props) {
>
<div className={styles.anchorButtonContent}>
<span className={styles.anchorButtonText}>{modelDisplayName}</span>
{turnNEARVerified && <NearLabel />}
<Icon
name='carat-down'
className={classnames({
@@ -57,7 +57,7 @@
.anchorButtonContent {
display: grid;
grid-template-columns: 1fr auto;
grid-template-columns: 1fr auto auto;
align-items: center;
gap: var(--leo-spacing-s);
overflow: hidden;