fix(google): add direct cachedContent support (#60757)

* fix(google): restore gemini cache reporting

* fix(google): split cli parsing into separate PR

* fix(google): drop remaining cli overlap

* fix(google): honor cachedContent alias precedence
This commit is contained in:
Vincent Koc 2026-04-04 20:07:13 +09:00 committed by GitHub
parent b9e3c1a02e
commit d766465e38
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 133 additions and 0 deletions

View File

@ -75,6 +75,7 @@ Docs: https://docs.openclaw.ai
- Plugins/marketplace: block remote marketplace symlink escapes without breaking ordinary local marketplace install paths. (#60556) Thanks @eleqtrizit.
- Plugins/install: preserve unsafe override flags across linked plugin and hook-pack probes so local `--link` installs honor the documented override behavior. (#60624) Thanks @JerrettDavis.
- Config/All Settings: keep the raw config view intact when sensitive fields are blank instead of corrupting or dropping the rendered snapshot. (#28214) Thanks @solodmd.
- Google/cache: pass explicit `cachedContent` handles through direct Google transport params and agent extra params so prebuilt Gemini context caches can be targeted again. Thanks @vincentkoc.
- Security: preserve restrictive plugin-only tool allowlists, require owner access for `/allowlist add` and `/allowlist remove`, fail closed when `before_tool_call` hooks crash, block browser SSRF redirect bypasses earlier, and keep non-interactive auth-choice inference scoped to bundled and already-trusted plugins. (#58476, #59836, #59822, #58771, #59120)
- Exec approvals: reuse durable exact-command `allow-always` approvals in allowlist mode so identical reruns stop prompting, and tighten Windows interpreter/path approval handling so wrapper and malformed-path cases fail closed more consistently. (#59880, #59780, #58040, #59182)
- Agents/runtime: make default subagent allowlists, inherited skills/workspaces, and duplicate session-id resolution behave more predictably, and include value-shape hints in missing-parameter tool errors. (#59944, #59992, #59858, #55317)

View File

@ -112,6 +112,7 @@ describe("google transport stream", () => {
} as unknown as Parameters<typeof streamFn>[1],
{
apiKey: "gemini-api-key",
cachedContent: "cachedContents/request-cache",
reasoning: "medium",
toolChoice: "auto",
} as Parameters<typeof streamFn>[2],
@ -142,6 +143,7 @@ describe("google transport stream", () => {
expect(payload.systemInstruction).toEqual({
parts: [{ text: "Follow policy." }],
});
expect(payload.cachedContent).toBe("cachedContents/request-cache");
expect(payload.generationConfig).toMatchObject({
thinkingConfig: { includeThoughts: true, thinkingLevel: "HIGH" },
});
@ -246,4 +248,31 @@ describe("google transport stream", () => {
thinkingConfig: { thinkingBudget: -1 },
});
});
it("includes cachedContent in direct Gemini payloads when requested", () => {
const model = {
id: "gemini-2.5-pro",
name: "Gemini 2.5 Pro",
api: "google-generative-ai",
provider: "google",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 8192,
} satisfies Model<"google-generative-ai">;
const params = buildGoogleGenerativeAiParams(
model,
{
messages: [{ role: "user", content: "hello", timestamp: 0 }],
} as never,
{
cachedContent: "cachedContents/prebuilt-context",
},
);
expect(params.cachedContent).toBe("cachedContents/prebuilt-context");
});
});

View File

@ -30,6 +30,7 @@ type GoogleTransportModel = Model<"google-generative-ai"> & {
type GoogleThinkingLevel = "MINIMAL" | "LOW" | "MEDIUM" | "HIGH";
type GoogleTransportOptions = SimpleStreamOptions & {
cachedContent?: string;
toolChoice?:
| "auto"
| "none"
@ -49,6 +50,7 @@ type GoogleTransportOptions = SimpleStreamOptions & {
};
type GoogleGenerateContentRequest = {
cachedContent?: string;
contents: Array<Record<string, unknown>>;
generationConfig?: Record<string, unknown>;
systemInstruction?: Record<string, unknown>;
@ -441,6 +443,9 @@ export function buildGoogleGenerativeAiParams(
const params: GoogleGenerateContentRequest = {
contents: convertGoogleMessages(model, context),
};
if (typeof options?.cachedContent === "string" && options.cachedContent.trim()) {
params.cachedContent = options.cachedContent.trim();
}
if (Object.keys(generationConfig).length > 0) {
params.generationConfig = generationConfig;
}

View File

@ -37,4 +37,71 @@ describe("extra-params: Google thinking payload compatibility", () => {
expect(payload.config?.thinkingConfig?.thinkingBudget).toBeUndefined();
expect(payload.config?.thinkingConfig?.thinkingLevel).toBe("HIGH");
});
it("passes cachedContent through Google extra params", () => {
const { options } = runExtraParamsCase({
cfg: {
agents: {
defaults: {
models: {
"google/gemini-2.5-pro": {
params: {
cachedContent: "cachedContents/test-cache",
},
},
},
},
},
} as never,
applyProvider: "google",
applyModelId: "gemini-2.5-pro",
model: {
api: "google-generative-ai",
provider: "google",
id: "gemini-2.5-pro",
} as unknown as Model<"openai-completions">,
payload: {
contents: [],
},
});
expect((options as { cachedContent?: string } | undefined)?.cachedContent).toBe(
"cachedContents/test-cache",
);
});
it("lets higher-precedence cachedContent override lower-precedence cached_content", () => {
const { options } = runExtraParamsCase({
cfg: {
agents: {
defaults: {
params: {
cached_content: "cachedContents/default-cache",
},
models: {
"google/gemini-2.5-pro": {
params: {
cachedContent: "cachedContents/model-cache",
},
},
},
},
},
} as never,
applyProvider: "google",
applyModelId: "gemini-2.5-pro",
model: {
api: "google-generative-ai",
provider: "google",
id: "gemini-2.5-pro",
} as unknown as Model<"openai-completions">,
payload: {
contents: [],
},
});
expect((options as { cachedContent?: string } | undefined)?.cachedContent).toBe(
"cachedContents/model-cache",
);
});
});

View File

@ -6,6 +6,7 @@ import { applyExtraParamsToAgent } from "./extra-params.js";
export type ExtraParamsCapture<TPayload extends Record<string, unknown>> = {
headers?: Record<string, string>;
options?: SimpleStreamOptions;
payload: TPayload;
};
@ -45,6 +46,7 @@ export function runExtraParamsCase<
const baseStreamFn: StreamFn = (model, _context, options) => {
captured.headers = options?.headers;
captured.options = options;
options?.onPayload?.(params.payload, model);
return createMockStream();
};

View File

@ -91,11 +91,22 @@ export function resolveExtraParams(params: {
delete merged.textVerbosity;
}
const resolvedCachedContent = resolveAliasedParamValue(
[defaultParams, globalParams, agentParams],
"cached_content",
"cachedContent",
);
if (resolvedCachedContent !== undefined) {
merged.cachedContent = resolvedCachedContent;
delete merged.cached_content;
}
return merged;
}
type CacheRetentionStreamOptions = Partial<SimpleStreamOptions> & {
cacheRetention?: "none" | "short" | "long";
cachedContent?: string;
openaiWsWarmup?: boolean;
};
type SupportedTransport = Exclude<CacheRetentionStreamOptions["transport"], undefined>;
@ -137,6 +148,15 @@ export function resolvePreparedExtraParams(params: {
...sanitizeExtraParamsRecord(resolvedExtraParams),
...override,
};
const resolvedCachedContent = resolveAliasedParamValue(
[resolvedExtraParams, override],
"cached_content",
"cachedContent",
);
if (resolvedCachedContent !== undefined) {
merged.cachedContent = resolvedCachedContent;
delete merged.cached_content;
}
return (
providerRuntimeDeps.prepareProviderExtraParams({
provider: params.provider,
@ -207,6 +227,15 @@ function createStreamFnWithExtraParams(
if (typeof extraParams.openaiWsWarmup === "boolean") {
streamParams.openaiWsWarmup = extraParams.openaiWsWarmup;
}
const cachedContent =
typeof extraParams.cachedContent === "string"
? extraParams.cachedContent
: typeof extraParams.cached_content === "string"
? extraParams.cached_content
: undefined;
if (typeof cachedContent === "string" && cachedContent.trim()) {
streamParams.cachedContent = cachedContent.trim();
}
const initialCacheRetention = resolveCacheRetention(
extraParams,
provider,