Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
709
extensions/google/web-search-provider.test.ts
Normal file
709
extensions/google/web-search-provider.test.ts
Normal file
@@ -0,0 +1,709 @@
|
||||
// Google tests cover web search provider plugin behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { withEnv, withEnvAsync, withFetchPreconnect } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { testing, createGeminiWebSearchProvider } from "./src/gemini-web-search-provider.js";
|
||||
|
||||
type TestModelProviderConfig = NonNullable<
|
||||
NonNullable<OpenClawConfig["models"]>["providers"]
|
||||
>[string];
|
||||
|
||||
function installGeminiFetch() {
|
||||
const mockFetch = vi.fn((_input?: RequestInfo | URL, _init?: RequestInit) =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
candidates: [
|
||||
{
|
||||
content: { parts: [{ text: "Grounded answer" }] },
|
||||
groundingMetadata: {
|
||||
groundingChunks: [{ web: { uri: "https://example.com", title: "Example" } }],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", withFetchPreconnect(mockFetch));
|
||||
return mockFetch;
|
||||
}
|
||||
|
||||
function createGoogleModelProviderConfig(
|
||||
overrides: Partial<TestModelProviderConfig>,
|
||||
): TestModelProviderConfig {
|
||||
return {
|
||||
baseUrl: "https://generativelanguage.googleapis.com/v1beta/",
|
||||
models: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function requireFirstGeminiFetchCall(
|
||||
mockFetch: ReturnType<typeof installGeminiFetch>,
|
||||
): [RequestInfo | URL | undefined, RequestInit | undefined] {
|
||||
const [call] = mockFetch.mock.calls;
|
||||
if (!call) {
|
||||
throw new Error("expected Gemini web search fetch call");
|
||||
}
|
||||
return call as [RequestInfo | URL | undefined, RequestInit | undefined];
|
||||
}
|
||||
|
||||
function getFetchHeaders(mockFetch: ReturnType<typeof installGeminiFetch>): Record<string, string> {
|
||||
const [, init] = requireFirstGeminiFetchCall(mockFetch);
|
||||
return (init?.headers as Record<string, string> | undefined) ?? {};
|
||||
}
|
||||
|
||||
function getGeminiFetchUrl(mockFetch: ReturnType<typeof installGeminiFetch>): string | undefined {
|
||||
const [input] = requireFirstGeminiFetchCall(mockFetch);
|
||||
if (typeof input === "string") {
|
||||
return input;
|
||||
}
|
||||
if (input instanceof URL) {
|
||||
return input.toString();
|
||||
}
|
||||
return input?.url;
|
||||
}
|
||||
|
||||
function parseGeminiFetchBody(mockFetch: ReturnType<typeof installGeminiFetch>): {
|
||||
contents?: Array<{ parts?: Array<{ text?: string }> }>;
|
||||
tools?: Array<{ google_search?: { timeRangeFilter?: unknown } }>;
|
||||
} {
|
||||
const [, init] = requireFirstGeminiFetchCall(mockFetch);
|
||||
const body = init?.body;
|
||||
if (typeof body !== "string") {
|
||||
throw new Error("Expected Gemini fetch body string");
|
||||
}
|
||||
return JSON.parse(body) as {
|
||||
contents?: Array<{ parts?: Array<{ text?: string }> }>;
|
||||
tools?: Array<{ google_search?: { timeRangeFilter?: unknown } }>;
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("google web search provider", () => {
|
||||
it("points missing-key users to fetch/browser alternatives", async () => {
|
||||
await withEnvAsync({ GEMINI_API_KEY: undefined }, async () => {
|
||||
const provider = createGeminiWebSearchProvider();
|
||||
const tool = provider.createTool({ config: {}, searchConfig: {} });
|
||||
if (!tool) {
|
||||
throw new Error("Expected tool definition");
|
||||
}
|
||||
|
||||
await expect(tool.execute({ query: "OpenClaw docs" })).resolves.toEqual({
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
error: "missing_gemini_api_key",
|
||||
message:
|
||||
"web_search (gemini) needs an API key. Set GEMINI_API_KEY in the Gateway environment, configure plugins.entries.google.config.webSearch.apiKey, or reuse models.providers.google.apiKey. If you do not want to configure a search API key, use web_fetch for a specific URL or the browser tool for interactive pages.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to GEMINI_API_KEY from the environment", () => {
|
||||
withEnv({ GEMINI_API_KEY: "AIza-env-test" }, () => {
|
||||
expect(testing.resolveGeminiApiKey()).toBe("AIza-env-test");
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers configured api keys over env fallbacks", () => {
|
||||
withEnv({ GEMINI_API_KEY: "AIza-env-test" }, () => {
|
||||
expect(testing.resolveGeminiApiKey({ apiKey: "AIza-configured-test" })).toBe(
|
||||
"AIza-configured-test",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("uses provider api keys only after env fallbacks", () => {
|
||||
withEnv({ GEMINI_API_KEY: "AIza-env-test" }, () => {
|
||||
expect(testing.resolveGeminiApiKey({ providerApiKey: "AIza-provider-test" })).toBe(
|
||||
"AIza-env-test",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("stores configured credentials at the canonical plugin config path", () => {
|
||||
const provider = createGeminiWebSearchProvider();
|
||||
const config = {} as OpenClawConfig;
|
||||
|
||||
provider.setConfiguredCredentialValue?.(config, "AIza-plugin-test");
|
||||
|
||||
expect(provider.credentialPath).toBe("plugins.entries.google.config.webSearch.apiKey");
|
||||
expect(provider.getConfiguredCredentialValue?.(config)).toBe("AIza-plugin-test");
|
||||
});
|
||||
|
||||
it("keeps model-provider fallback config runtime-only when Gemini config was injected", () => {
|
||||
const searchConfig = Object.defineProperty({ provider: "gemini" }, "gemini", {
|
||||
value: { apiKey: "AIza-plugin-test" },
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const merged = testing.withGoogleModelProviderFallbacks(searchConfig, {
|
||||
models: {
|
||||
providers: {
|
||||
google: createGoogleModelProviderConfig({
|
||||
apiKey: "AIza-provider-test",
|
||||
baseUrl: "https://generativelanguage.googleapis.com/proxy/v1beta/",
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(merged?.gemini).toEqual({
|
||||
apiKey: "AIza-plugin-test",
|
||||
providerApiKey: "AIza-provider-test",
|
||||
providerBaseUrl: "https://generativelanguage.googleapis.com/proxy/v1beta/",
|
||||
});
|
||||
expect(Object.keys(merged ?? {})).toEqual(["provider"]);
|
||||
expect(Object.getOwnPropertyDescriptor(merged, "gemini")?.enumerable).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults the Gemini web search model and trims explicit overrides", () => {
|
||||
expect(testing.resolveGeminiModel()).toBe("gemini-2.5-flash");
|
||||
expect(testing.resolveGeminiModel({ model: " gemini-2.5-pro " })).toBe("gemini-2.5-pro");
|
||||
});
|
||||
|
||||
it("routes Gemini web search through plugin webSearch.baseUrl", async () => {
|
||||
const mockFetch = installGeminiFetch();
|
||||
const provider = createGeminiWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
google: {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: "AIza-plugin-test",
|
||||
baseUrl: "https://generativelanguage.googleapis.com/proxy/v1beta/",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
searchConfig: { provider: "gemini" },
|
||||
});
|
||||
|
||||
await tool?.execute({ query: "OpenClaw docs" });
|
||||
|
||||
expect(getGeminiFetchUrl(mockFetch)).toBe(
|
||||
"https://generativelanguage.googleapis.com/proxy/v1beta/models/gemini-2.5-flash:generateContent",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts Gemini success JSON with empty grounding metadata", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
withFetchPreconnect(
|
||||
vi.fn(() =>
|
||||
Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
candidates: [
|
||||
{
|
||||
content: { parts: [{ text: "Today's date is Sunday, June 7, 2026." }] },
|
||||
groundingMetadata: {},
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
const provider = createGeminiWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
google: {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: "AIza-plugin-test",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
searchConfig: { provider: "gemini" },
|
||||
});
|
||||
|
||||
const result = await tool?.execute({ query: "current date today" });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
citations: [],
|
||||
model: "gemini-2.5-flash",
|
||||
provider: "gemini",
|
||||
});
|
||||
expect(String(result?.content)).toContain("Today's date is Sunday, June 7, 2026.");
|
||||
});
|
||||
|
||||
it("reports malformed Gemini API JSON with a stable provider error", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
withFetchPreconnect(vi.fn(() => Promise.resolve(new Response("{ nope")))),
|
||||
);
|
||||
const provider = createGeminiWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
google: {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: "AIza-plugin-test",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
searchConfig: { provider: "gemini" },
|
||||
});
|
||||
|
||||
await expect(tool?.execute({ query: "OpenClaw docs" })).rejects.toThrow(
|
||||
"Gemini API error: malformed JSON response",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects wrong-root Gemini success JSON with a stable provider error", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
withFetchPreconnect(vi.fn(() => Promise.resolve(new Response(JSON.stringify([]))))),
|
||||
);
|
||||
const provider = createGeminiWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
google: {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: "AIza-plugin-test",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
searchConfig: { provider: "gemini" },
|
||||
});
|
||||
|
||||
await expect(tool?.execute({ query: "OpenClaw docs" })).rejects.toThrow(
|
||||
"Gemini API error: malformed JSON response",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects Gemini success JSON without candidate text", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
withFetchPreconnect(
|
||||
vi.fn(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ candidates: [{ content: { parts: [] } }] })),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
const provider = createGeminiWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
google: {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: "AIza-plugin-test",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
searchConfig: { provider: "gemini" },
|
||||
});
|
||||
|
||||
await expect(tool?.execute({ query: "OpenClaw docs" })).rejects.toThrow(
|
||||
"Gemini API error: malformed JSON response",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes provider execution abort signals into the Gemini fetch", async () => {
|
||||
const mockFetch = installGeminiFetch();
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const provider = createGeminiWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
google: {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: "AIza-plugin-test",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
searchConfig: { provider: "gemini" },
|
||||
});
|
||||
|
||||
await tool?.execute({ query: "OpenClaw docs" }, { signal: controller.signal });
|
||||
|
||||
const [, init] = requireFirstGeminiFetchCall(mockFetch);
|
||||
expect(init?.signal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("reuses the Google model provider key when no web search key or env key is set", async () => {
|
||||
await withEnvAsync({ GEMINI_API_KEY: undefined }, async () => {
|
||||
const mockFetch = installGeminiFetch();
|
||||
const provider = createGeminiWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
google: createGoogleModelProviderConfig({
|
||||
apiKey: "AIza-provider-test",
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
searchConfig: { provider: "gemini" },
|
||||
});
|
||||
|
||||
await tool?.execute({ query: "OpenClaw provider key fallback" });
|
||||
|
||||
expect(getFetchHeaders(mockFetch)["x-goog-api-key"]).toBe("AIza-provider-test");
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps plugin web search keys ahead of env and provider keys", async () => {
|
||||
await withEnvAsync({ GEMINI_API_KEY: "AIza-env-test" }, async () => {
|
||||
const mockFetch = installGeminiFetch();
|
||||
const provider = createGeminiWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
google: {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: "AIza-plugin-test",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
google: createGoogleModelProviderConfig({
|
||||
apiKey: "AIza-provider-test",
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
searchConfig: { provider: "gemini" },
|
||||
});
|
||||
|
||||
await tool?.execute({ query: "OpenClaw plugin key precedence" });
|
||||
|
||||
expect(getFetchHeaders(mockFetch)["x-goog-api-key"]).toBe("AIza-plugin-test");
|
||||
});
|
||||
});
|
||||
|
||||
it("routes Gemini web search through provider-level google.baseUrl as a fallback", async () => {
|
||||
const mockFetch = installGeminiFetch();
|
||||
const provider = createGeminiWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
google: createGoogleModelProviderConfig({
|
||||
apiKey: "AIza-provider-test",
|
||||
baseUrl: "https://generativelanguage.googleapis.com/provider/v1beta/",
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
searchConfig: { provider: "gemini" },
|
||||
});
|
||||
|
||||
await tool?.execute({ query: "OpenClaw provider baseUrl fallback" });
|
||||
|
||||
expect(getGeminiFetchUrl(mockFetch)).toBe(
|
||||
"https://generativelanguage.googleapis.com/provider/v1beta/models/gemini-2.5-flash:generateContent",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps plugin webSearch.baseUrl ahead of provider-level google.baseUrl", async () => {
|
||||
const mockFetch = installGeminiFetch();
|
||||
const provider = createGeminiWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
google: {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: "AIza-plugin-test",
|
||||
baseUrl: "https://generativelanguage.googleapis.com/plugin/v1beta/",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
google: createGoogleModelProviderConfig({
|
||||
baseUrl: "https://generativelanguage.googleapis.com/provider/v1beta/",
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
searchConfig: { provider: "gemini" },
|
||||
});
|
||||
|
||||
await tool?.execute({ query: "OpenClaw plugin baseUrl precedence" });
|
||||
|
||||
expect(getGeminiFetchUrl(mockFetch)).toBe(
|
||||
"https://generativelanguage.googleapis.com/plugin/v1beta/models/gemini-2.5-flash:generateContent",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses a soft recency hint for Gemini day freshness shortcuts instead of a 24-hour range", async () => {
|
||||
const mockFetch = installGeminiFetch();
|
||||
const provider = createGeminiWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
google: {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: "AIza-plugin-test",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
searchConfig: { provider: "gemini" },
|
||||
});
|
||||
|
||||
await tool?.execute({ query: "latest ai news timestamp precision", freshness: "pd" });
|
||||
|
||||
const body = parseGeminiFetchBody(mockFetch);
|
||||
expect(body.tools?.[0]?.google_search?.timeRangeFilter).toBeUndefined();
|
||||
expect(body.contents?.[0]?.parts?.[0]?.text).toContain(
|
||||
"Prioritize web sources published in the last 24 hours.",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves hard Gemini time ranges for wider freshness values", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-04-15T12:00:00.123Z"));
|
||||
const mockFetch = installGeminiFetch();
|
||||
const provider = createGeminiWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
google: {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: "AIza-plugin-test",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
searchConfig: { provider: "gemini" },
|
||||
});
|
||||
|
||||
await tool?.execute({ query: "latest ai news timestamp precision", freshness: "week" });
|
||||
|
||||
const body = parseGeminiFetchBody(mockFetch);
|
||||
expect(body.contents?.[0]?.parts?.[0]?.text).toBe("latest ai news timestamp precision");
|
||||
expect(body.tools?.[0]?.google_search?.timeRangeFilter).toEqual({
|
||||
startTime: "2026-04-08T12:00:00Z",
|
||||
endTime: "2026-04-15T12:00:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("partitions Gemini cache entries for soft day freshness, hard week freshness, and no freshness", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(new Date("2026-04-15T12:00:00.123Z"));
|
||||
const mockFetch = installGeminiFetch();
|
||||
const provider = createGeminiWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
google: {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: "AIza-plugin-test",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
searchConfig: { provider: "gemini" },
|
||||
});
|
||||
|
||||
await tool?.execute({ query: "same query cache partition", freshness: "day" });
|
||||
await tool?.execute({ query: "same query cache partition", freshness: "week" });
|
||||
await tool?.execute({ query: "same query cache partition" });
|
||||
|
||||
const postCalls = mockFetch.mock.calls.filter(([, init]) => typeof init?.body === "string");
|
||||
expect(postCalls).toHaveLength(3);
|
||||
const parsePostedBody = (call: (typeof postCalls)[number] | undefined) => {
|
||||
const body = call?.[1]?.body;
|
||||
if (typeof body !== "string") {
|
||||
throw new Error("Expected Gemini fetch body to be a string");
|
||||
}
|
||||
return JSON.parse(body) as {
|
||||
contents?: Array<{ parts?: Array<{ text?: string }> }>;
|
||||
tools?: Array<{ google_search?: { timeRangeFilter?: unknown } }>;
|
||||
};
|
||||
};
|
||||
const firstBody = parsePostedBody(postCalls[0]);
|
||||
const secondBody = parsePostedBody(postCalls[1]);
|
||||
const thirdBody = parsePostedBody(postCalls[2]);
|
||||
expect(firstBody.tools?.[0]?.google_search?.timeRangeFilter).toBeUndefined();
|
||||
expect(firstBody.contents?.[0]?.parts?.[0]?.text).toContain(
|
||||
"Prioritize web sources published in the last 24 hours.",
|
||||
);
|
||||
expect(secondBody.tools?.[0]?.google_search?.timeRangeFilter).toEqual({
|
||||
startTime: "2026-04-08T12:00:00Z",
|
||||
endTime: "2026-04-15T12:00:00Z",
|
||||
});
|
||||
expect(secondBody.contents?.[0]?.parts?.[0]?.text).toBe("same query cache partition");
|
||||
expect(thirdBody.tools?.[0]?.google_search?.timeRangeFilter).toBeUndefined();
|
||||
expect(thirdBody.contents?.[0]?.parts?.[0]?.text).toBe("same query cache partition");
|
||||
});
|
||||
|
||||
it("strips sub-second precision from date-range timestamps so Gemini accepts them", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
// "now" with non-zero milliseconds. Without stripping, toISOString() emits
|
||||
// "2026-04-15T12:00:00.123Z", which Gemini's google_search.time_range_filter
|
||||
// rejects with "Granularity of nano is not supported".
|
||||
vi.setSystemTime(new Date("2026-04-15T12:00:00.123Z"));
|
||||
const mockFetch = installGeminiFetch();
|
||||
const provider = createGeminiWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
google: {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: "AIza-plugin-test",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
searchConfig: { provider: "gemini" },
|
||||
});
|
||||
|
||||
await tool?.execute({ query: "latest ai news", date_after: "2026-04-01" });
|
||||
|
||||
const body = parseGeminiFetchBody(mockFetch);
|
||||
const filter = body.tools?.[0]?.google_search?.timeRangeFilter as
|
||||
| { startTime: string; endTime: string }
|
||||
| undefined;
|
||||
expect(filter?.startTime).not.toMatch(/\.\d+Z$/);
|
||||
expect(filter?.endTime).not.toMatch(/\.\d+Z$/);
|
||||
expect(filter).toEqual({
|
||||
startTime: "2026-04-01T00:00:00Z",
|
||||
endTime: "2026-04-15T12:00:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes date ranges to Gemini Google Search grounding", async () => {
|
||||
const mockFetch = installGeminiFetch();
|
||||
const provider = createGeminiWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
google: {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: "AIza-plugin-test",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
searchConfig: { provider: "gemini" },
|
||||
});
|
||||
|
||||
await tool?.execute({
|
||||
query: "OpenClaw release notes",
|
||||
date_after: "2026-04-01",
|
||||
date_before: "2026-04-30",
|
||||
});
|
||||
|
||||
const body = parseGeminiFetchBody(mockFetch);
|
||||
expect(body.tools?.[0]?.google_search?.timeRangeFilter).toEqual({
|
||||
startTime: "2026-04-01T00:00:00Z",
|
||||
endTime: "2026-05-01T00:00:00Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns validation errors for invalid Gemini time filters before fetch", async () => {
|
||||
const mockFetch = installGeminiFetch();
|
||||
const provider = createGeminiWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {
|
||||
plugins: {
|
||||
entries: {
|
||||
google: {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: "AIza-plugin-test",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
searchConfig: { provider: "gemini" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
tool?.execute({
|
||||
query: "OpenClaw release notes",
|
||||
freshness: "week",
|
||||
date_after: "2026-04-01",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
error: "conflicting_time_filters",
|
||||
message:
|
||||
"freshness and date_after/date_before cannot be used together. Use either freshness (day/week/month/year) or a date range (date_after/date_before), not both.",
|
||||
});
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes Gemini shorthand base URLs", () => {
|
||||
expect(
|
||||
testing.resolveGeminiBaseUrl({ baseUrl: "https://generativelanguage.googleapis.com" }),
|
||||
).toBe("https://generativelanguage.googleapis.com/v1beta");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user