diff --git a/CHANGELOG.md b/CHANGELOG.md index 62590acde9e..38c725617e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ Docs: https://docs.openclaw.ai - Auto-reply/prompt-cache: keep volatile inbound chat IDs out of the stable system prompt so task-scoped adapters can reuse prompt caches across runs, while preserving conversation metadata for the user turn and media-only messages. (#65071) Thanks @MonkeyLeeT. - BlueBubbles/inbound: restore inbound image attachment downloads on Node 22+ by stripping incompatible bundled-undici dispatchers from the non-SSRF fetch path, accept `updated-message` webhooks carrying attachments, use event-type-aware dedup keys so attachment follow-ups are not rejected as duplicates, and retry attachment fetch from the BB API when the initial webhook arrives with an empty array. (#64105, #61861, #65430, #67510) Thanks @omarshahine. - Agents/skills: sort prompt-facing `available_skills` entries by skill name after merging sources so `skills.load.extraDirs` order no longer changes prompt-cache prefixes. (#64198) Thanks @Bartok9. +- Agents/OpenAI Responses: add `models.providers.*.models.*.compat.supportsPromptCacheKey` so OpenAI-compatible proxies that forward `prompt_cache_key` can keep prompt caching enabled while incompatible endpoints can still force stripping. (#67427) Thanks @damselem. ## 2026.4.15-beta.1 diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 59b79e7409a..8207d58d0bf 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -4fec95c9ce02dddb4d3021812cf68df8b4cc92c5ba4db35778bb1bfe6fa63021 config-baseline.json -aafbb407e62908709e90f750ea0f8274016fcfcbd613394896ff984f967f236e config-baseline.core.json +8bbc7501da1e567f5e12b2bedb1c59e8592bcc5f003ddc1b7d584cc1b1ff8913 config-baseline.json +eeed6fe659078632d9f95b3350b27103b4aba282d050ff38d3b0953a456d242d config-baseline.core.json ef83a06633fc001b5b2535566939186ecb49d05cd1a90b40e54cc58d3e6e44e3 config-baseline.channel.json 5f5d4e850df6e9854a85b5d008236854ce185c707fdbb566efcf00f8c08b36e3 config-baseline.plugin.json diff --git a/src/agents/provider-attribution.test.ts b/src/agents/provider-attribution.test.ts index 6948f5b4537..c937ef7c5ab 100644 --- a/src/agents/provider-attribution.test.ts +++ b/src/agents/provider-attribution.test.ts @@ -620,6 +620,55 @@ describe("provider attribution", () => { }); }); + it("respects compat.supportsPromptCacheKey override on prompt cache stripping", () => { + // compat.supportsPromptCacheKey = true disables the strip even on a + // proxy-like endpoint that would otherwise trigger it. + expect( + resolveProviderRequestCapabilities({ + provider: "custom-proxy", + api: "openai-responses", + baseUrl: "https://proxy.example.com/v1", + capability: "llm", + transport: "stream", + compat: { supportsPromptCacheKey: true }, + }), + ).toMatchObject({ + endpointClass: "custom", + shouldStripResponsesPromptCache: false, + }); + + // compat.supportsPromptCacheKey = false forces the strip even on a + // native OpenAI endpoint that would otherwise forward the field. + expect( + resolveProviderRequestCapabilities({ + provider: "openai", + api: "openai-responses", + baseUrl: "https://api.openai.com/v1", + capability: "llm", + transport: "stream", + compat: { supportsPromptCacheKey: false }, + }), + ).toMatchObject({ + endpointClass: "openai-public", + shouldStripResponsesPromptCache: true, + }); + + // compat.supportsPromptCacheKey unset preserves the existing default + // (strip on proxy-like responses endpoints, preserving the fix from + // #48155 for providers that reject the field). + expect( + resolveProviderRequestCapabilities({ + provider: "custom-proxy", + api: "openai-responses", + baseUrl: "https://proxy.example.com/v1", + capability: "llm", + transport: "stream", + }), + ).toMatchObject({ + shouldStripResponsesPromptCache: true, + }); + }); + it("resolves shared compat families and native streaming-usage gates", () => { expect( resolveProviderRequestCapabilities({ diff --git a/src/agents/provider-attribution.ts b/src/agents/provider-attribution.ts index 5ddba81ab37..228be76fa15 100644 --- a/src/agents/provider-attribution.ts +++ b/src/agents/provider-attribution.ts @@ -92,6 +92,7 @@ export type ProviderRequestCapabilitiesInput = ProviderRequestPolicyInput & { modelId?: string | null; compat?: { supportsStore?: boolean; + supportsPromptCacheKey?: boolean; } | null; }; @@ -607,8 +608,20 @@ export function resolveProviderRequestCapabilities( OPENAI_RESPONSES_APIS.has(api) && OPENAI_RESPONSES_PROVIDERS.has(provider) && policy.usesKnownNativeOpenAIEndpoint, + // Default strip behavior (proxy-like endpoints with responses APIs) is + // preserved as a safety net for providers that reject prompt_cache_key — + // see #48155 (Volcano Engine DeepSeek). Operators running their payload + // through an OpenAI-compatible proxy known to forward the field + // (CLIProxy, LiteLLM, etc.) can opt out via compat.supportsPromptCacheKey + // to recover prompt caching; providers known to reject the field can + // force the strip with compat.supportsPromptCacheKey = false even on + // native endpoints. shouldStripResponsesPromptCache: - api !== undefined && OPENAI_RESPONSES_APIS.has(api) && policy.usesExplicitProxyLikeEndpoint, + input.compat?.supportsPromptCacheKey === true + ? false + : input.compat?.supportsPromptCacheKey === false + ? api !== undefined && OPENAI_RESPONSES_APIS.has(api) + : api !== undefined && OPENAI_RESPONSES_APIS.has(api) && policy.usesExplicitProxyLikeEndpoint, // Native endpoint class is the real signal here. Users can point a generic // provider key at Moonshot or DashScope and still need streaming usage. supportsNativeStreamingUsageCompat: diff --git a/src/agents/provider-request-config.ts b/src/agents/provider-request-config.ts index 624d3fd4909..38bb90d5637 100644 --- a/src/agents/provider-request-config.ts +++ b/src/agents/provider-request-config.ts @@ -162,6 +162,7 @@ type ResolveProviderRequestPolicyConfigParams = { authHeader?: boolean; compat?: { supportsStore?: boolean; + supportsPromptCacheKey?: boolean; } | null; modelId?: string | null; allowPrivateNetwork?: boolean; diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index a1f0e8e2111..f0f03e6dfde 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -2798,6 +2798,9 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { supportsStore: { type: "boolean", }, + supportsPromptCacheKey: { + type: "boolean", + }, supportsDeveloperRole: { type: "boolean", }, diff --git a/src/config/types.models.ts b/src/config/types.models.ts index 3497d7ffb25..5a5d52cfab5 100644 --- a/src/config/types.models.ts +++ b/src/config/types.models.ts @@ -37,6 +37,7 @@ type SupportedThinkingFormat = export type ModelCompatConfig = SupportedOpenAICompatFields & { thinkingFormat?: SupportedThinkingFormat; supportsTools?: boolean; + supportsPromptCacheKey?: boolean; requiresStringContent?: boolean; toolSchemaProfile?: string; unsupportedToolSchemaKeywords?: string[]; diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 20c21760412..68280d7c6ed 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -186,6 +186,7 @@ export const ModelApiSchema = z.enum(MODEL_APIS); export const ModelCompatSchema = z .object({ supportsStore: z.boolean().optional(), + supportsPromptCacheKey: z.boolean().optional(), supportsDeveloperRole: z.boolean().optional(), supportsReasoningEffort: z.boolean().optional(), supportsUsageInStreaming: z.boolean().optional(), diff --git a/test/scripts/bundle-a2ui.test.ts b/test/scripts/bundle-a2ui.test.ts index da658478980..8611625a317 100644 --- a/test/scripts/bundle-a2ui.test.ts +++ b/test/scripts/bundle-a2ui.test.ts @@ -64,17 +64,21 @@ describe("scripts/bundle-a2ui.mjs", () => { it("tracks only the resolved bundle dependency manifests from node_modules", () => { const repoRoot = process.cwd(); const dependencyPaths = getResolvedBundleDependencyPackageJsonPaths(repoRoot); + const relativeDependencyPaths = dependencyPaths.map((dependencyPath) => + path.relative(repoRoot, dependencyPath), + ); - expect(dependencyPaths).toContain(path.join(repoRoot, "node_modules", "lit", "package.json")); - expect(dependencyPaths).toContain( - path.join(repoRoot, "node_modules", "@lit/context", "package.json"), - ); - expect(dependencyPaths).toContain( - path.join(repoRoot, "node_modules", "@lit-labs/signals", "package.json"), - ); - expect(dependencyPaths).toContain( - path.join(repoRoot, "node_modules", "signal-utils", "package.json"), - ); + expect( + relativeDependencyPaths.map((relativePath) => relativePath.replace(/^ui\//u, "")), + ).toEqual([ + path.join("node_modules", "lit", "package.json"), + path.join("node_modules", "@lit/context", "package.json"), + path.join("node_modules", "@lit-labs/signals", "package.json"), + path.join("node_modules", "signal-utils", "package.json"), + ]); + expect( + relativeDependencyPaths.every((relativePath) => /^(ui\/)?node_modules\//u.test(relativePath)), + ).toBe(true); expect(getBundleHashInputPaths(repoRoot)).not.toContain(path.join(repoRoot, "package.json")); expect(getBundleHashInputPaths(repoRoot)).not.toContain(path.join(repoRoot, "pnpm-lock.yaml")); });