Compare commits

..

3 Commits

Author SHA1 Message Date
Peter Steinberger
8a91af22a5 fix: clean up codex inline model api fallback (#39753) (thanks @justinhuangcode) 2026-03-08 13:51:18 +00:00
justinhuangcode
e4bfcff5a8 chore: update secrets baseline line numbers 2026-03-08 13:49:02 +00:00
justinhuangcode
c42dc2e8c2 fix(agents): let forward-compat resolve api when inline model omits it
When a user configures `models.providers.openai-codex` with a models
array but omits the `api` field, `buildInlineProviderModels` produces
an entry with `api: undefined`.  The inline-match early return then
hands this incomplete model straight to the caller, skipping the
forward-compat resolver that would supply the correct
`openai-codex-responses` api — causing a crash loop.

Let the inline match fall through to forward-compat when `api` is
absent so the resolver chain can fill it in.

Fixes #39682
2026-03-08 13:49:02 +00:00
20 changed files with 21 additions and 499 deletions

View File

@@ -11481,7 +11481,7 @@
"filename": "src/agents/models-config.e2e-harness.ts",
"hashed_secret": "7cf31e8b6cda49f70c31f1f25af05d46f924142d",
"is_verified": false,
"line_number": 131
"line_number": 130
}
],
"src/agents/models-config.fills-missing-provider-apikey-from-env-var.e2e.test.ts": [
@@ -13034,5 +13034,5 @@
}
]
},
"generated_at": "2026-03-08T13:52:40Z"
"generated_at": "2026-03-08T05:05:36Z"
}

View File

@@ -113,7 +113,6 @@
**Full maintainer PR workflow (optional):** If you want the repo's end-to-end maintainer workflow (triage order, quality bar, rebase rules, commit/changelog conventions, co-contributor policy, and the `review-pr` > `prepare-pr` > `merge-pr` pipeline), see `.agents/skills/PR_WORKFLOW.md`. Maintainers may use other workflows; when a maintainer specifies a workflow, follow that. If no workflow is specified, default to PR_WORKFLOW.
- `/landpr` lives in the global Codex prompts (`~/.codex/prompts/landpr.md`); when landing or merging any PR, always follow that `/landpr` process.
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
- Group related changes; avoid bundling unrelated refactors.

View File

@@ -7,7 +7,6 @@ Docs: https://docs.openclaw.ai
### Changes
- TUI: infer the active agent from the current workspace when launched inside a configured agent workspace, while preserving explicit `agent:` session targets. (#39591) thanks @arceus77-7.
- Tools/Brave web search: add opt-in `tools.web.search.brave.mode: "llm-context"` so `web_search` can call Brave's LLM Context endpoint and return extracted grounding snippets with source metadata, plus config/docs/test coverage. (#33383) Thanks @thirumaleshp.
### Fixes
@@ -15,9 +14,7 @@ Docs: https://docs.openclaw.ai
- Mattermost replies: keep `root_id` pinned to the existing thread root when an agent replies inside a thread, while still using reply-target threading for top-level posts. (#27744) thanks @hnykda.
- Agents/failover: detect Amazon Bedrock `Too many tokens per day` quota errors as rate limits across fallback, cron retry, and memory embeddings while keeping context-window `too many tokens per request` errors out of the rate-limit lane. (#39377) Thanks @gambletan.
- Android/Play distribution: remove self-update, background location, `screen.record`, and background mic capture from the Android app, narrow the foreground service to `dataSync` only, and clean up the legacy `location.enabledMode=always` preference migration. (#39660) Thanks @obviyus.
- macOS overlays: fix VoiceWake, Talk, and Notify overlay exclusivity crashes by removing shared `inout` visibility mutation from `OverlayPanelFactory.present`, and add a repeated Talk overlay smoke test. (#39275, #39321) Thanks @fellanH.
- macOS Talk Mode: set the speech recognition request `taskHint` to `.dictation` for mic capture, and add regression coverage for the request defaults. (#38445) Thanks @dmiv.
- macOS release packaging: default `scripts/package-mac-app.sh` to universal binaries for `BUILD_CONFIG=release`, and clarify that `scripts/package-mac-dist.sh` already produces the release zip + DMG. (#33891) Thanks @cgdusek.
- Agents/openai-codex model resolution: fall through from inline `openai-codex` model entries without an `api` so GPT-5.4 keeps the codex transport and still preserves configured `baseUrl` and headers. (#39753) Thanks @justinhuangcode.
## 2026.3.7

View File

@@ -22,7 +22,7 @@ enum HostEnvSecurityPolicy {
"PS4",
"GCONV_PATH",
"IFS",
"SSLKEYLOGFILE",
"SSLKEYLOGFILE"
]
static let blockedOverrideKeys: Set<String> = [
@@ -50,17 +50,17 @@ enum HostEnvSecurityPolicy {
"OPENSSL_ENGINES",
"PYTHONSTARTUP",
"WGETRC",
"CURL_HOME",
"CURL_HOME"
]
static let blockedOverridePrefixes: [String] = [
"GIT_CONFIG_",
"NPM_CONFIG_",
"NPM_CONFIG_"
]
static let blockedPrefixes: [String] = [
"DYLD_",
"LD_",
"BASH_FUNC_",
"BASH_FUNC_"
]
}

View File

@@ -70,11 +70,6 @@ actor TalkModeRuntime {
private let minSpeechRMS: Double = 1e-3
private let speechBoostFactor: Double = 6.0
static func configureRecognitionRequest(_ request: SFSpeechAudioBufferRecognitionRequest) {
request.shouldReportPartialResults = true
request.taskHint = .dictation
}
// MARK: - Lifecycle
func setEnabled(_ enabled: Bool) async {
@@ -181,9 +176,9 @@ actor TalkModeRuntime {
return
}
let request = SFSpeechAudioBufferRecognitionRequest()
Self.configureRecognitionRequest(request)
self.recognitionRequest = request
self.recognitionRequest = SFSpeechAudioBufferRecognitionRequest()
self.recognitionRequest?.shouldReportPartialResults = true
guard let request = self.recognitionRequest else { return }
if self.audioEngine == nil {
self.audioEngine = AVAudioEngine()

View File

@@ -66,15 +66,6 @@ struct LowCoverageViewSmokeTests {
try? await Task.sleep(nanoseconds: 250_000_000)
}
@Test func `talk overlay presents twice and dismisses`() async {
let controller = TalkOverlayController()
controller.present()
controller.updateLevel(0.4)
controller.present()
controller.dismiss()
try? await Task.sleep(nanoseconds: 250_000_000)
}
@Test func `visual effect view hosts in NS hosting view`() {
let hosting = NSHostingView(rootView: VisualEffectView(material: .sidebar))
_ = hosting.fittingSize

View File

@@ -1,14 +0,0 @@
import Speech
import Testing
@testable import OpenClaw
struct TalkModeRuntimeSpeechTests {
@Test func `speech request uses dictation defaults`() {
let request = SFSpeechAudioBufferRecognitionRequest()
TalkModeRuntime.configureRecognitionRequest(request)
#expect(request.shouldReportPartialResults)
#expect(request.taskHint == .dictation)
}
}

View File

@@ -29,27 +29,23 @@ Notes:
- `APP_BUILD` maps to `CFBundleVersion`/`sparkle:version`; keep it numeric + monotonic (no `-beta`), or Sparkle compares it as equal.
- If `APP_BUILD` is omitted, `scripts/package-mac-app.sh` derives a Sparkle-safe default from `APP_VERSION` (`YYYYMMDDNN`: stable defaults to `90`, prereleases use a suffix-derived lane) and uses the higher of that value and git commit count.
- You can still override `APP_BUILD` explicitly when release engineering needs a specific monotonic value.
- For `BUILD_CONFIG=release`, `scripts/package-mac-app.sh` now defaults to universal (`arm64 x86_64`) automatically. You can still override with `BUILD_ARCHS=arm64` or `BUILD_ARCHS=x86_64`. For local/dev builds (`BUILD_CONFIG=debug`), it defaults to the current architecture (`$(uname -m)`).
- Defaults to the current architecture (`$(uname -m)`). For release/universal builds, set `BUILD_ARCHS="arm64 x86_64"` (or `BUILD_ARCHS=all`).
- Use `scripts/package-mac-dist.sh` for release artifacts (zip + DMG + notarization). Use `scripts/package-mac-app.sh` for local/dev packaging.
```bash
# From repo root; set release IDs so Sparkle feed is enabled.
# This command builds release artifacts without notarization.
# APP_BUILD must be numeric + monotonic for Sparkle compare.
# Default is auto-derived from APP_VERSION when omitted.
SKIP_NOTARIZE=1 \
BUNDLE_ID=ai.openclaw.mac \
APP_VERSION=2026.3.8 \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-dist.sh
scripts/package-mac-app.sh
# `package-mac-dist.sh` already creates the zip + DMG.
# If you used `package-mac-app.sh` directly instead, create them manually:
# If you want notarization/stapling in this step, use the NOTARIZE command below.
# Zip for distribution (includes resource forks for Sparkle delta support)
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.3.8.zip
# Optional: build a styled DMG for humans (drag to /Applications)
# Optional: also build a styled DMG for humans (drag to /Applications)
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.3.8.dmg
# Recommended: build + notarize/staple zip + DMG

View File

@@ -111,29 +111,6 @@ Brave provides paid plans; check the Brave API portal for the current limits and
}
```
**Brave LLM Context mode:**
```json5
{
tools: {
web: {
search: {
enabled: true,
provider: "brave",
apiKey: "YOUR_BRAVE_API_KEY", // optional if BRAVE_API_KEY is set // pragma: allowlist secret
brave: {
mode: "llm-context",
},
},
},
},
}
```
`llm-context` returns extracted page chunks for grounding instead of standard Brave snippets.
In this mode, `country` and `language` / `search_lang` still work, but `ui_lang`,
`freshness`, `date_after`, and `date_before` are rejected.
## Using Gemini (Google Search grounding)
Gemini models support built-in [Google Search grounding](https://ai.google.dev/gemini-api/docs/grounding),
@@ -270,9 +247,6 @@ await web_search({
});
```
When Brave `llm-context` mode is enabled, `ui_lang`, `freshness`, `date_after`, and
`date_before` are not supported. Use Brave `web` mode for those filters.
## web_fetch
Fetch a URL and extract readable content.

View File

@@ -340,7 +340,6 @@
"@grammyjs/runner": "^2.0.3",
"@grammyjs/transformer-throttler": "^1.2.1",
"@homebridge/ciao": "^1.3.5",
"@larksuiteoapi/node-sdk": "^1.59.0",
"@line/bot-sdk": "^10.6.0",
"@lydell/node-pty": "1.2.0-beta.3",
"@mariozechner/pi-agent-core": "0.55.3",

3
pnpm-lock.yaml generated
View File

@@ -48,9 +48,6 @@ importers:
'@homebridge/ciao':
specifier: ^1.3.5
version: 1.3.5
'@larksuiteoapi/node-sdk':
specifier: ^1.59.0
version: 1.59.0
'@line/bot-sdk':
specifier: ^10.6.0
version: 10.6.0

View File

@@ -16,14 +16,7 @@ GIT_BUILD_NUMBER=$(cd "$ROOT_DIR" && git rev-list --count HEAD 2>/dev/null || ec
APP_VERSION="${APP_VERSION:-$PKG_VERSION}"
APP_BUILD="${APP_BUILD:-}"
BUILD_CONFIG="${BUILD_CONFIG:-debug}"
if [[ -n "${BUILD_ARCHS:-}" ]]; then
BUILD_ARCHS_VALUE="${BUILD_ARCHS}"
elif [[ "$BUILD_CONFIG" == "release" ]]; then
# Release packaging should be universal unless explicitly overridden.
BUILD_ARCHS_VALUE="all"
else
BUILD_ARCHS_VALUE="$(uname -m)"
fi
BUILD_ARCHS_VALUE="${BUILD_ARCHS:-$(uname -m)}"
if [[ "${BUILD_ARCHS_VALUE}" == "all" ]]; then
BUILD_ARCHS_VALUE="arm64 x86_64"
fi

View File

@@ -15,7 +15,6 @@ const {
resolveKimiModel,
resolveKimiBaseUrl,
extractKimiCitations,
resolveBraveMode,
} = __testing;
const kimiApiKeyEnv = ["KIMI_API", "KEY"].join("_");
@@ -277,25 +276,3 @@ describe("extractKimiCitations", () => {
).toEqual(["https://example.com/a", "https://example.com/b", "https://example.com/c"]);
});
});
describe("resolveBraveMode", () => {
it("defaults to 'web' when no config is provided", () => {
expect(resolveBraveMode({})).toBe("web");
});
it("defaults to 'web' when mode is undefined", () => {
expect(resolveBraveMode({ mode: undefined })).toBe("web");
});
it("returns 'llm-context' when configured", () => {
expect(resolveBraveMode({ mode: "llm-context" })).toBe("llm-context");
});
it("returns 'web' when mode is explicitly 'web'", () => {
expect(resolveBraveMode({ mode: "web" })).toBe("web");
});
it("falls back to 'web' for unrecognized mode values", () => {
expect(resolveBraveMode({ mode: "invalid" })).toBe("web");
});
});

View File

@@ -26,7 +26,6 @@ const DEFAULT_SEARCH_COUNT = 5;
const MAX_SEARCH_COUNT = 10;
const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search";
const BRAVE_LLM_CONTEXT_ENDPOINT = "https://api.search.brave.com/res/v1/llm/context";
const PERPLEXITY_SEARCH_ENDPOINT = "https://api.perplexity.ai/search";
const XAI_API_ENDPOINT = "https://api.x.ai/v1/responses";
@@ -248,17 +247,6 @@ type BraveSearchResponse = {
};
};
type BraveLlmContextSnippet = { text: string };
type BraveLlmContextResult = { url: string; title: string; snippets: BraveLlmContextSnippet[] };
type BraveLlmContextResponse = {
grounding: { generic?: BraveLlmContextResult[] };
sources?: { url?: string; hostname?: string; date?: string }[];
};
type BraveConfig = {
mode?: string;
};
type PerplexityConfig = {
apiKey?: string;
};
@@ -562,21 +550,6 @@ function resolveSearchProvider(search?: WebSearchConfig): (typeof SEARCH_PROVIDE
return "perplexity";
}
function resolveBraveConfig(search?: WebSearchConfig): BraveConfig {
if (!search || typeof search !== "object") {
return {};
}
const brave = "brave" in search ? search.brave : undefined;
if (!brave || typeof brave !== "object") {
return {};
}
return brave as BraveConfig;
}
function resolveBraveMode(brave: BraveConfig): "web" | "llm-context" {
return brave.mode === "llm-context" ? "llm-context" : "web";
}
function resolvePerplexityConfig(search?: WebSearchConfig): PerplexityConfig {
if (!search || typeof search !== "object") {
return {};
@@ -1240,67 +1213,6 @@ async function runKimiSearch(params: {
};
}
async function runBraveLlmContextSearch(params: {
query: string;
apiKey: string;
timeoutSeconds: number;
country?: string;
search_lang?: string;
freshness?: string;
}): Promise<{
results: Array<{
url: string;
title: string;
snippets: string[];
siteName?: string;
}>;
sources?: BraveLlmContextResponse["sources"];
}> {
const url = new URL(BRAVE_LLM_CONTEXT_ENDPOINT);
url.searchParams.set("q", params.query);
if (params.country) {
url.searchParams.set("country", params.country);
}
if (params.search_lang) {
url.searchParams.set("search_lang", params.search_lang);
}
if (params.freshness) {
url.searchParams.set("freshness", params.freshness);
}
return withTrustedWebSearchEndpoint(
{
url: url.toString(),
timeoutSeconds: params.timeoutSeconds,
init: {
method: "GET",
headers: {
Accept: "application/json",
"X-Subscription-Token": params.apiKey,
},
},
},
async (res) => {
if (!res.ok) {
const detailResult = await readResponseText(res, { maxBytes: 64_000 });
const detail = detailResult.text;
throw new Error(`Brave LLM Context API error (${res.status}): ${detail || res.statusText}`);
}
const data = (await res.json()) as BraveLlmContextResponse;
const genericResults = Array.isArray(data.grounding?.generic) ? data.grounding.generic : [];
const mapped = genericResults.map((entry) => ({
url: entry.url ?? "",
title: entry.title ?? "",
snippets: (entry.snippets ?? []).map((s) => s.text ?? "").filter(Boolean),
siteName: resolveSiteName(entry.url) || undefined,
}));
return { results: mapped, sources: data.sources };
},
);
}
async function runWebSearch(params: {
query: string;
count: number;
@@ -1323,9 +1235,7 @@ async function runWebSearch(params: {
geminiModel?: string;
kimiBaseUrl?: string;
kimiModel?: string;
braveMode?: "web" | "llm-context";
}): Promise<Record<string, unknown>> {
const effectiveBraveMode = params.braveMode ?? "web";
const providerSpecificKey =
params.provider === "grok"
? `${params.grokModel ?? DEFAULT_GROK_MODEL}:${String(params.grokInlineCitations ?? false)}`
@@ -1335,9 +1245,7 @@ async function runWebSearch(params: {
? `${params.kimiBaseUrl ?? DEFAULT_KIMI_BASE_URL}:${params.kimiModel ?? DEFAULT_KIMI_MODEL}`
: "";
const cacheKey = normalizeCacheKey(
params.provider === "brave" && effectiveBraveMode === "llm-context"
? `${params.provider}:llm-context:${params.query}:${params.country || "default"}:${params.search_lang || params.language || "default"}:${params.freshness || "default"}`
: `${params.provider}:${effectiveBraveMode}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || params.language || "default"}:${params.ui_lang || "default"}:${params.freshness || "default"}:${params.dateAfter || "default"}:${params.dateBefore || "default"}:${params.searchDomainFilter?.join(",") || "default"}:${params.maxTokens || "default"}:${params.maxTokensPerPage || "default"}:${providerSpecificKey}`,
`${params.provider}:${params.query}:${params.count}:${params.country || "default"}:${params.search_lang || params.language || "default"}:${params.ui_lang || "default"}:${params.freshness || "default"}:${params.dateAfter || "default"}:${params.dateBefore || "default"}:${params.searchDomainFilter?.join(",") || "default"}:${params.maxTokens || "default"}:${params.maxTokensPerPage || "default"}:${providerSpecificKey}`,
);
const cached = readCache(SEARCH_CACHE, cacheKey);
if (cached) {
@@ -1464,42 +1372,6 @@ async function runWebSearch(params: {
throw new Error("Unsupported web search provider.");
}
if (effectiveBraveMode === "llm-context") {
const { results: llmResults, sources } = await runBraveLlmContextSearch({
query: params.query,
apiKey: params.apiKey,
timeoutSeconds: params.timeoutSeconds,
country: params.country,
search_lang: params.search_lang,
freshness: params.freshness,
});
const mapped = llmResults.map((entry) => ({
title: entry.title ? wrapWebContent(entry.title, "web_search") : "",
url: entry.url,
snippets: entry.snippets.map((s) => wrapWebContent(s, "web_search")),
siteName: entry.siteName,
}));
const payload = {
query: params.query,
provider: params.provider,
mode: "llm-context" as const,
count: mapped.length,
tookMs: Date.now() - start,
externalContent: {
untrusted: true,
source: "web_search",
provider: params.provider,
wrapped: true,
},
results: mapped,
sources,
};
writeCache(SEARCH_CACHE, cacheKey, payload, params.cacheTtlMs);
return payload;
}
const url = new URL(BRAVE_SEARCH_ENDPOINT);
url.searchParams.set("q", params.query);
url.searchParams.set("count", String(params.count));
@@ -1593,8 +1465,6 @@ export function createWebSearchTool(options?: {
const grokConfig = resolveGrokConfig(search);
const geminiConfig = resolveGeminiConfig(search);
const kimiConfig = resolveKimiConfig(search);
const braveConfig = resolveBraveConfig(search);
const braveMode = resolveBraveMode(braveConfig);
const description =
provider === "perplexity"
@@ -1605,9 +1475,7 @@ export function createWebSearchTool(options?: {
? "Search the web using Kimi by Moonshot. Returns AI-synthesized answers with citations from native $web_search."
: provider === "gemini"
? "Search the web using Gemini with Google Search grounding. Returns AI-synthesized answers with citations from Google Search."
: braveMode === "llm-context"
? "Search the web using Brave Search LLM Context API. Returns pre-extracted page content (text chunks, tables, code blocks) optimized for LLM grounding."
: "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research.";
: "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research.";
return {
label: "Web Search",
@@ -1682,14 +1550,6 @@ export function createWebSearchTool(options?: {
}
const resolvedSearchLang = normalizedBraveLanguageParams.search_lang;
const resolvedUiLang = normalizedBraveLanguageParams.ui_lang;
if (resolvedUiLang && provider === "brave" && braveMode === "llm-context") {
return jsonResult({
error: "unsupported_ui_lang",
message:
"ui_lang is not supported by Brave llm-context mode. Remove ui_lang or use Brave web mode for locale-based UI hints.",
docs: "https://docs.openclaw.ai/tools/web",
});
}
const rawFreshness = readStringParam(params, "freshness");
if (rawFreshness && provider !== "brave" && provider !== "perplexity") {
return jsonResult({
@@ -1698,14 +1558,6 @@ export function createWebSearchTool(options?: {
docs: "https://docs.openclaw.ai/tools/web",
});
}
if (rawFreshness && provider === "brave" && braveMode === "llm-context") {
return jsonResult({
error: "unsupported_freshness",
message:
"freshness filtering is not supported by Brave llm-context mode. Remove freshness or use Brave web mode.",
docs: "https://docs.openclaw.ai/tools/web",
});
}
const freshness = rawFreshness ? normalizeFreshness(rawFreshness, provider) : undefined;
if (rawFreshness && !freshness) {
return jsonResult({
@@ -1731,14 +1583,6 @@ export function createWebSearchTool(options?: {
docs: "https://docs.openclaw.ai/tools/web",
});
}
if ((rawDateAfter || rawDateBefore) && provider === "brave" && braveMode === "llm-context") {
return jsonResult({
error: "unsupported_date_filter",
message:
"date_after/date_before filtering is not supported by Brave llm-context mode. Use Brave web mode for date filters.",
docs: "https://docs.openclaw.ai/tools/web",
});
}
const dateAfter = rawDateAfter ? normalizeToIsoDate(rawDateAfter) : undefined;
if (rawDateAfter && !dateAfter) {
return jsonResult({
@@ -1816,7 +1660,6 @@ export function createWebSearchTool(options?: {
geminiModel: resolveGeminiModel(geminiConfig),
kimiBaseUrl: resolveKimiBaseUrl(kimiConfig),
kimiModel: resolveKimiModel(kimiConfig),
braveMode,
});
return jsonResult(result);
},
@@ -1841,5 +1684,4 @@ export const __testing = {
resolveKimiBaseUrl,
extractKimiCitations,
resolveRedirectUrl: resolveCitationRedirectUrl,
resolveBraveMode,
} as const;

View File

@@ -31,23 +31,6 @@ function createPerplexitySearchTool(perplexityConfig?: { apiKey?: string }) {
});
}
function createBraveSearchTool(braveConfig?: { mode?: "web" | "llm-context" }) {
return createWebSearchTool({
config: {
tools: {
web: {
search: {
provider: "brave",
apiKey: "brave-config-test", // pragma: allowlist secret
...(braveConfig ? { brave: braveConfig } : {}),
},
},
},
},
sandboxed: true,
});
}
function createKimiSearchTool(kimiConfig?: { apiKey?: string; baseUrl?: string; model?: string }) {
return createWebSearchTool({
config: {
@@ -179,7 +162,7 @@ describe("web_search country and language parameters", () => {
}>,
) {
const mockFetch = installMockFetch({ web: { results: [] } });
const tool = createBraveSearchTool();
const tool = createWebSearchTool({ config: undefined, sandboxed: true });
expect(tool).not.toBeNull();
await tool?.execute?.("call-1", { query: "test", ...params });
expect(mockFetch).toHaveBeenCalled();
@@ -197,7 +180,7 @@ describe("web_search country and language parameters", () => {
it("should pass language parameter to Brave API as search_lang", async () => {
const mockFetch = installMockFetch({ web: { results: [] } });
const tool = createBraveSearchTool();
const tool = createWebSearchTool({ config: undefined, sandboxed: true });
await tool?.execute?.("call-1", { query: "test", language: "de" });
const url = new URL(mockFetch.mock.calls[0][0] as string);
@@ -221,7 +204,7 @@ describe("web_search country and language parameters", () => {
it("rejects unsupported Brave search_lang values before upstream request", async () => {
const mockFetch = installMockFetch({ web: { results: [] } });
const tool = createBraveSearchTool();
const tool = createWebSearchTool({ config: undefined, sandboxed: true });
const result = await tool?.execute?.("call-1", { query: "test", search_lang: "xx" });
expect(mockFetch).not.toHaveBeenCalled();
@@ -528,27 +511,8 @@ describe("web_search external content wrapping", () => {
return mock;
}
function installBraveLlmContextFetch(
result: Record<string, unknown>,
mock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
grounding: {
generic: [result],
},
sources: [{ url: "https://example.com/ctx", hostname: "example.com" }],
}),
} as Response),
),
) {
global.fetch = withFetchPreconnect(mock);
return mock;
}
async function executeBraveSearch(query: string) {
const tool = createBraveSearchTool();
const tool = createWebSearchTool({ config: undefined, sandboxed: true });
return tool?.execute?.("call-1", { query });
}
@@ -581,154 +545,6 @@ describe("web_search external content wrapping", () => {
});
});
it("uses Brave llm-context endpoint when mode is configured", async () => {
vi.stubEnv("BRAVE_API_KEY", "test-key");
const mockFetch = installBraveLlmContextFetch({
title: "Context title",
url: "https://example.com/ctx",
snippets: [{ text: "Context chunk one" }, { text: "Context chunk two" }],
});
const tool = createWebSearchTool({
config: {
tools: {
web: {
search: {
provider: "brave",
brave: {
mode: "llm-context",
},
},
},
},
},
sandboxed: true,
});
const result = await tool?.execute?.("call-1", {
query: "llm-context test",
country: "DE",
search_lang: "de",
});
const requestUrl = new URL(mockFetch.mock.calls[0]?.[0] as string);
expect(requestUrl.pathname).toBe("/res/v1/llm/context");
expect(requestUrl.searchParams.get("q")).toBe("llm-context test");
expect(requestUrl.searchParams.get("country")).toBe("DE");
expect(requestUrl.searchParams.get("search_lang")).toBe("de");
const details = result?.details as {
mode?: string;
results?: Array<{
title?: string;
url?: string;
snippets?: string[];
siteName?: string;
}>;
sources?: Array<{ hostname?: string }>;
};
expect(details.mode).toBe("llm-context");
expect(details.results?.[0]?.url).toBe("https://example.com/ctx");
expect(details.results?.[0]?.title).toContain("<<<EXTERNAL_UNTRUSTED_CONTENT");
expect(details.results?.[0]?.snippets?.[0]).toContain("<<<EXTERNAL_UNTRUSTED_CONTENT");
expect(details.results?.[0]?.snippets?.[0]).toContain("Context chunk one");
expect(details.results?.[0]?.siteName).toBe("example.com");
expect(details.sources?.[0]?.hostname).toBe("example.com");
});
it("rejects freshness in Brave llm-context mode", async () => {
vi.stubEnv("BRAVE_API_KEY", "test-key");
const mockFetch = installBraveLlmContextFetch({
title: "unused",
url: "https://example.com",
snippets: ["unused"],
});
const tool = createWebSearchTool({
config: {
tools: {
web: {
search: {
provider: "brave",
brave: {
mode: "llm-context",
},
},
},
},
},
sandboxed: true,
});
const result = await tool?.execute?.("call-1", { query: "test", freshness: "week" });
expect(result?.details).toMatchObject({ error: "unsupported_freshness" });
expect(mockFetch).not.toHaveBeenCalled();
});
it("rejects date_after/date_before in Brave llm-context mode", async () => {
vi.stubEnv("BRAVE_API_KEY", "test-key");
const mockFetch = installBraveLlmContextFetch({
title: "unused",
url: "https://example.com",
snippets: ["unused"],
});
const tool = createWebSearchTool({
config: {
tools: {
web: {
search: {
provider: "brave",
brave: {
mode: "llm-context",
},
},
},
},
},
sandboxed: true,
});
const result = await tool?.execute?.("call-1", {
query: "test",
date_after: "2025-01-01",
date_before: "2025-01-31",
});
expect(result?.details).toMatchObject({ error: "unsupported_date_filter" });
expect(mockFetch).not.toHaveBeenCalled();
});
it("rejects ui_lang in Brave llm-context mode", async () => {
vi.stubEnv("BRAVE_API_KEY", "test-key");
const mockFetch = installBraveLlmContextFetch({
title: "unused",
url: "https://example.com",
snippets: ["unused"],
});
const tool = createWebSearchTool({
config: {
tools: {
web: {
search: {
provider: "brave",
brave: {
mode: "llm-context",
},
},
},
},
},
sandboxed: true,
});
const result = await tool?.execute?.("call-1", {
query: "test",
ui_lang: "de-DE",
});
expect(result?.details).toMatchObject({ error: "unsupported_ui_lang" });
expect(mockFetch).not.toHaveBeenCalled();
});
it("does not wrap Brave result urls (raw for tool chaining)", async () => {
vi.stubEnv("BRAVE_API_KEY", "test-key");
const url = "https://example.com/some-page";

View File

@@ -48,32 +48,6 @@ describe("web search provider config", () => {
expect(res.ok).toBe(true);
});
it("accepts brave llm-context mode config", () => {
const res = validateConfigObject(
buildWebSearchProviderConfig({
provider: "brave",
providerConfig: {
mode: "llm-context",
},
}),
);
expect(res.ok).toBe(true);
});
it("rejects invalid brave mode config values", () => {
const res = validateConfigObject(
buildWebSearchProviderConfig({
provider: "brave",
providerConfig: {
mode: "invalid-mode",
},
}),
);
expect(res.ok).toBe(false);
});
});
describe("web search provider auto-detection", () => {

View File

@@ -666,8 +666,6 @@ export const FIELD_HELP: Record<string, string> = {
"Perplexity base URL override (default: https://openrouter.ai/api/v1 or https://api.perplexity.ai).",
"tools.web.search.perplexity.model":
'Perplexity model override (default: "perplexity/sonar-pro").',
"tools.web.search.brave.mode":
'Brave Search mode: "web" (URL results) or "llm-context" (pre-extracted page content for LLM grounding).',
"tools.web.fetch.enabled": "Enable the web_fetch tool (lightweight HTTP fetch).",
"tools.web.fetch.maxChars": "Max characters returned by web_fetch (truncated).",
"tools.web.fetch.maxCharsCap":

View File

@@ -224,7 +224,6 @@ export const FIELD_LABELS: Record<string, string> = {
"tools.web.search.gemini.model": "Gemini Search Model",
"tools.web.search.grok.apiKey": "Grok Search API Key", // pragma: allowlist secret
"tools.web.search.grok.model": "Grok Search Model",
"tools.web.search.brave.mode": "Brave Search Mode",
"tools.web.search.kimi.apiKey": "Kimi Search API Key", // pragma: allowlist secret
"tools.web.search.kimi.baseUrl": "Kimi Search Base URL",
"tools.web.search.kimi.model": "Kimi Search Model",

View File

@@ -485,11 +485,6 @@ export type ToolsConfig = {
/** Model to use (defaults to "moonshot-v1-128k"). */
model?: string;
};
/** Brave-specific configuration (used when provider="brave"). */
brave?: {
/** Brave Search mode: "web" (standard results) or "llm-context" (pre-extracted page content). Default: "web". */
mode?: "web" | "llm-context";
};
};
fetch?: {
/** Enable web fetch tool (default: true). */

View File

@@ -308,12 +308,6 @@ export const ToolsWebSearchSchema = z
})
.strict()
.optional(),
brave: z
.object({
mode: z.union([z.literal("web"), z.literal("llm-context")]).optional(),
})
.strict()
.optional(),
})
.strict()
.optional();