fix(brave-search): clarify ui_lang and search_lang format requirements (#25130)

* fix(brave-search): swap ui_lang and search_lang formats (#23826)

* fix(web-search): normalize Brave ui_lang/search_lang params

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Glucksberg 2026-02-25 00:59:38 -04:00 committed by GitHub
parent b564b72dc9
commit 6e97470515
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 109 additions and 4 deletions

View File

@ -7,6 +7,7 @@ const {
resolvePerplexityBaseUrl,
isDirectPerplexityBaseUrl,
resolvePerplexityRequestModel,
normalizeBraveLanguageParams,
normalizeFreshness,
freshnessToPerplexityRecency,
resolveGrokApiKey,
@ -93,6 +94,28 @@ describe("web_search perplexity model normalization", () => {
});
});
describe("web_search brave language param normalization", () => {
it("normalizes and auto-corrects swapped Brave language params", () => {
expect(normalizeBraveLanguageParams({ search_lang: "tr-TR", ui_lang: "tr" })).toEqual({
search_lang: "tr",
ui_lang: "tr-TR",
});
expect(normalizeBraveLanguageParams({ search_lang: "EN", ui_lang: "en-us" })).toEqual({
search_lang: "en",
ui_lang: "en-US",
});
});
it("flags invalid Brave language formats", () => {
expect(normalizeBraveLanguageParams({ search_lang: "en-US" })).toEqual({
invalidField: "search_lang",
});
expect(normalizeBraveLanguageParams({ ui_lang: "en" })).toEqual({
invalidField: "ui_lang",
});
});
});
describe("web_search freshness normalization", () => {
it("accepts Brave shortcut values", () => {
expect(normalizeFreshness("pd")).toBe("pd");

View File

@ -43,6 +43,8 @@ const KIMI_WEB_SEARCH_TOOL = {
const SEARCH_CACHE = new Map<string, CacheEntry<Record<string, unknown>>>();
const BRAVE_FRESHNESS_SHORTCUTS = new Set(["pd", "pw", "pm", "py"]);
const BRAVE_FRESHNESS_RANGE = /^(\d{4}-\d{2}-\d{2})to(\d{4}-\d{2}-\d{2})$/;
const BRAVE_SEARCH_LANG_CODE = /^[a-z]{2}$/i;
const BRAVE_UI_LANG_LOCALE = /^([a-z]{2})-([a-z]{2})$/i;
const TRUSTED_NETWORK_SSRF_POLICY = { dangerouslyAllowPrivateNetwork: true } as const;
const WebSearchSchema = Type.Object({
@ -62,12 +64,14 @@ const WebSearchSchema = Type.Object({
),
search_lang: Type.Optional(
Type.String({
description: "ISO language code for search results (e.g., 'de', 'en', 'fr').",
description:
"Short ISO language code for search results (e.g., 'de', 'en', 'fr', 'tr'). Must be a 2-letter code, NOT a locale.",
}),
),
ui_lang: Type.Optional(
Type.String({
description: "ISO language code for UI elements.",
description:
"Locale code for UI elements in language-region format (e.g., 'en-US', 'de-DE', 'fr-FR', 'tr-TR'). Must include region subtag.",
}),
),
freshness: Type.Optional(
@ -705,6 +709,62 @@ function resolveSearchCount(value: unknown, fallback: number): number {
return clamped;
}
function normalizeBraveSearchLang(value: string | undefined): string | undefined {
if (!value) {
return undefined;
}
const trimmed = value.trim();
if (!trimmed || !BRAVE_SEARCH_LANG_CODE.test(trimmed)) {
return undefined;
}
return trimmed.toLowerCase();
}
function normalizeBraveUiLang(value: string | undefined): string | undefined {
if (!value) {
return undefined;
}
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
const match = trimmed.match(BRAVE_UI_LANG_LOCALE);
if (!match) {
return undefined;
}
const [, language, region] = match;
return `${language.toLowerCase()}-${region.toUpperCase()}`;
}
function normalizeBraveLanguageParams(params: { search_lang?: string; ui_lang?: string }): {
search_lang?: string;
ui_lang?: string;
invalidField?: "search_lang" | "ui_lang";
} {
const rawSearchLang = params.search_lang?.trim() || undefined;
const rawUiLang = params.ui_lang?.trim() || undefined;
let searchLangCandidate = rawSearchLang;
let uiLangCandidate = rawUiLang;
// Recover common LLM mix-up: locale in search_lang + short code in ui_lang.
if (normalizeBraveUiLang(rawSearchLang) && normalizeBraveSearchLang(rawUiLang)) {
searchLangCandidate = rawUiLang;
uiLangCandidate = rawSearchLang;
}
const search_lang = normalizeBraveSearchLang(searchLangCandidate);
if (searchLangCandidate && !search_lang) {
return { invalidField: "search_lang" };
}
const ui_lang = normalizeBraveUiLang(uiLangCandidate);
if (uiLangCandidate && !ui_lang) {
return { invalidField: "ui_lang" };
}
return { search_lang, ui_lang };
}
function normalizeFreshness(value: string | undefined): string | undefined {
if (!value) {
return undefined;
@ -1289,8 +1349,29 @@ export function createWebSearchTool(options?: {
const count =
readNumberParam(params, "count", { integer: true }) ?? search?.maxResults ?? undefined;
const country = readStringParam(params, "country");
const search_lang = readStringParam(params, "search_lang");
const ui_lang = readStringParam(params, "ui_lang");
const rawSearchLang = readStringParam(params, "search_lang");
const rawUiLang = readStringParam(params, "ui_lang");
const normalizedBraveLanguageParams =
provider === "brave"
? normalizeBraveLanguageParams({ search_lang: rawSearchLang, ui_lang: rawUiLang })
: { search_lang: rawSearchLang, ui_lang: rawUiLang };
if (normalizedBraveLanguageParams.invalidField === "search_lang") {
return jsonResult({
error: "invalid_search_lang",
message:
"search_lang must be a 2-letter ISO language code like 'en' (not a locale like 'en-US').",
docs: "https://docs.openclaw.ai/tools/web",
});
}
if (normalizedBraveLanguageParams.invalidField === "ui_lang") {
return jsonResult({
error: "invalid_ui_lang",
message: "ui_lang must be a language-region locale like 'en-US'.",
docs: "https://docs.openclaw.ai/tools/web",
});
}
const search_lang = normalizedBraveLanguageParams.search_lang;
const ui_lang = normalizedBraveLanguageParams.ui_lang;
const rawFreshness = readStringParam(params, "freshness");
if (rawFreshness && provider !== "brave" && provider !== "perplexity") {
return jsonResult({
@ -1342,6 +1423,7 @@ export const __testing = {
resolvePerplexityBaseUrl,
isDirectPerplexityBaseUrl,
resolvePerplexityRequestModel,
normalizeBraveLanguageParams,
normalizeFreshness,
freshnessToPerplexityRecency,
resolveGrokApiKey,