From 6c8392fa45e66c5dbd6db51164e4ebf8b3879919 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:41:09 -0300 Subject: [PATCH] feat(providers): let custom connections opt into prompt-cache capability (#6880) (#7257) Add a per-connection cache capability override (supportsPromptCaching, cacheControlPassthrough) stored in provider_specific_data.cache, consulted first by providerSupportsCaching() / providerHonorsOpenAIFormatCacheControl() before falling back to the hardcoded CACHING_PROVIDERS name sets. Unblocks prompt_cache_key injection, the compression cache-aware guard, and cache_control passthrough for openai-compatible-chat--style custom connections that can never match the hardcoded provider-name sets. Default (no override) is byte-identical to current behavior. --- .../6880-connection-cache-override.md | 1 + open-sse/handlers/chatCore.ts | 17 +- .../chatCore/compressionCacheStats.ts | 4 + open-sse/handlers/chatCore/upstreamBody.ts | 19 +- open-sse/services/compression/cachingAware.ts | 8 +- open-sse/translator/index.ts | 13 +- open-sse/utils/cacheControlPolicy.ts | 52 ++++- src/lib/providers/requestDefaults.ts | 57 ++++- src/shared/validation/providerSpecificData.ts | 40 ++++ .../connection-cache-override-6880.test.ts | 207 ++++++++++++++++++ 10 files changed, 393 insertions(+), 25 deletions(-) create mode 100644 changelog.d/features/6880-connection-cache-override.md create mode 100644 tests/unit/connection-cache-override-6880.test.ts diff --git a/changelog.d/features/6880-connection-cache-override.md b/changelog.d/features/6880-connection-cache-override.md new file mode 100644 index 0000000000..0f013bb59f --- /dev/null +++ b/changelog.d/features/6880-connection-cache-override.md @@ -0,0 +1 @@ +- **feat(providers):** let a custom/openai-compatible connection opt into prompt-cache behavior via a per-connection `cache` capability override, unblocking `prompt_cache_key` injection, the compression cache-aware guard, and `cache_control` passthrough for `openai-compatible-chat-`-style connections. (thanks @andrea-kingautomation) diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index f7c8774e66..0ddc173a07 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -232,7 +232,10 @@ import { getProviderCredentials, extractSessionAffinityKey } from "@/sse/service import { deleteSessionAccountAffinity } from "@/lib/db/sessionAccountAffinity"; import { getCacheControlSettings } from "@/lib/cacheControlSettings"; import { guardrailRegistry } from "@/lib/guardrails"; -import { shouldPreserveCacheControl } from "../utils/cacheControlPolicy.ts"; +import { + shouldPreserveCacheControl, + resolveConnectionCacheOverride, +} from "../utils/cacheControlPolicy.ts"; import { getCachedSettings } from "@/lib/db/readCache"; import { applyCodexGlobalFastServiceTier } from "@/lib/providers/codexFastTier"; import { buildUpstreamHeadersForExecute as buildUpstreamHeadersForExecuteFor } from "./chatCore/upstreamExecuteHeaders.ts"; @@ -1179,12 +1182,15 @@ export async function handleChatCore({ if (compressionHeader) { log?.debug?.("COMPRESSION", `x-omniroute-compression header: ${compressionHeader}`); } + const connectionCacheOverride = resolveConnectionCacheOverride( + credentials?.providerSpecificData + ); const modeBeforeOutputTransform = selectCompressionStrategy( config, compressionComboKey, estimatedTokens, body as Record, - { provider, targetFormat, model: effectiveModel }, + { provider, targetFormat, model: effectiveModel, connectionCacheOverride }, namedCombos, compressionHeader ); @@ -1283,7 +1289,7 @@ export async function handleChatCore({ compressionComboKey, estimatedTokens, compressionInputBody, - { provider, targetFormat, model: effectiveModel }, + { provider, targetFormat, model: effectiveModel, connectionCacheOverride }, namedCombos, compressionHeader, { @@ -1322,7 +1328,7 @@ export async function handleChatCore({ // #3890: in a caching context, never compress the system prompt (cacheable prefix) // even if the operator disabled preserveSystemPrompt — honors the cache-aware flag // that selectCompressionStrategy can only partially apply via the mode string. - const cacheCtx = { provider, targetFormat, model: effectiveModel }; + const cacheCtx = { provider, targetFormat, model: effectiveModel, connectionCacheOverride }; const compressionConfig = resolveCacheAwareConfig(config, compressionInputBody, cacheCtx); const result = await applyCompressionAsync(compressionInputBody, mode, { model: effectiveModel, @@ -1463,6 +1469,7 @@ export async function handleChatCore({ effectiveModel, mode, stats: result.stats, + connectionCacheOverride, log, }); log?.info?.( @@ -1658,6 +1665,7 @@ export async function handleChatCore({ // Determine if we should preserve client-side cache_control headers // Fetch settings from DB to get user preference const cacheControlMode = await getCacheControlSettings().catch(() => "auto" as const); + const connectionCacheOverride = resolveConnectionCacheOverride(credentials?.providerSpecificData); const preserveCacheControl = shouldPreserveCacheControl({ userAgent, isCombo, @@ -1665,6 +1673,7 @@ export async function handleChatCore({ targetProvider: provider, targetFormat, settings: { alwaysPreserveClientCache: cacheControlMode }, + connectionCacheOverride, }); if (preserveCacheControl) { diff --git a/open-sse/handlers/chatCore/compressionCacheStats.ts b/open-sse/handlers/chatCore/compressionCacheStats.ts index 06bca10837..445f7f9c7b 100644 --- a/open-sse/handlers/chatCore/compressionCacheStats.ts +++ b/open-sse/handlers/chatCore/compressionCacheStats.ts @@ -8,6 +8,8 @@ * affects the request. Behaviour is byte-identical to the previous inline block. */ +import type { ConnectionCacheOverride } from "../../utils/cacheControlPolicy.ts"; + type LoggerLike = { debug?: (...args: unknown[]) => void } | null | undefined; export function recordCompressionCacheStats(args: { @@ -17,6 +19,7 @@ export function recordCompressionCacheStats(args: { effectiveModel: string | null | undefined; mode: string; stats: { originalTokens: number; compressedTokens: number }; + connectionCacheOverride?: ConnectionCacheOverride | null; log?: LoggerLike; }): void { void (async () => { @@ -27,6 +30,7 @@ export function recordCompressionCacheStats(args: { provider: args.provider, targetFormat: args.targetFormat, model: args.effectiveModel, + connectionCacheOverride: args.connectionCacheOverride ?? null, }); const tokensSavedCompression = Math.max( 0, diff --git a/open-sse/handlers/chatCore/upstreamBody.ts b/open-sse/handlers/chatCore/upstreamBody.ts index c426b240a7..d108e5c2bf 100644 --- a/open-sse/handlers/chatCore/upstreamBody.ts +++ b/open-sse/handlers/chatCore/upstreamBody.ts @@ -15,12 +15,19 @@ import { resolvePayloadRuleProtocols, } from "../../services/payloadRules.ts"; import { getEffectiveToolLimit, getKnownToolLimit } from "../../services/toolLimitDetector.ts"; -import { providerSupportsCaching } from "../../utils/cacheControlPolicy.ts"; +import { + providerSupportsCaching, + resolveConnectionCacheOverride, + type ConnectionCacheOverride, +} from "../../utils/cacheControlPolicy.ts"; import { FORMATS } from "../../translator/formats.ts"; type LoggerLike = { debug?: (...args: unknown[]) => void } | null | undefined; type Body = Record; -type CredentialsLike = { apiKey?: unknown; accessToken?: unknown } | null | undefined; +type CredentialsLike = + | { apiKey?: unknown; accessToken?: unknown; providerSpecificData?: Record | null } + | null + | undefined; function buildAppliedRulesSummary( applied: Array<{ type: string; path: string; value?: unknown }> @@ -100,11 +107,12 @@ function backfillQwenOAuthUser( async function injectPromptCacheKey( bodyToSend: Body, provider: string | null | undefined, - targetFormat: string + targetFormat: string, + connectionCacheOverride: ConnectionCacheOverride | null ): Promise { if ( targetFormat === FORMATS.OPENAI && - providerSupportsCaching(provider) && + providerSupportsCaching(provider, undefined, connectionCacheOverride) && !bodyToSend.prompt_cache_key && Array.isArray(bodyToSend.messages) && !["nvidia", "codex", "xai"].includes(provider) @@ -162,7 +170,8 @@ export async function prepareUpstreamBody(opts: { bodyToSend = truncateToolList(bodyToSend, provider, bypassDefaultToolLimit ?? false, log); bodyToSend = backfillQwenOAuthUser(bodyToSend, provider, credentials, log); - bodyToSend = await injectPromptCacheKey(bodyToSend, provider, targetFormat); + const connectionCacheOverride = resolveConnectionCacheOverride(credentials?.providerSpecificData); + bodyToSend = await injectPromptCacheKey(bodyToSend, provider, targetFormat, connectionCacheOverride); return bodyToSend; } diff --git a/open-sse/services/compression/cachingAware.ts b/open-sse/services/compression/cachingAware.ts index 54a72ed638..dc48339005 100644 --- a/open-sse/services/compression/cachingAware.ts +++ b/open-sse/services/compression/cachingAware.ts @@ -6,7 +6,10 @@ * @exports CachingContext, CacheAwareStrategy, detectCachingContext, getCacheAwareStrategy */ -import { providerSupportsCaching } from "../../utils/cacheControlPolicy.ts"; +import { + providerSupportsCaching, + type ConnectionCacheOverride, +} from "../../utils/cacheControlPolicy.ts"; type JsonRecord = Record; @@ -14,6 +17,7 @@ export interface CachingDetectionContext { provider?: string | null; targetFormat?: string | null; model?: string | null; + connectionCacheOverride?: ConnectionCacheOverride | null; } export interface CachingContext { @@ -94,7 +98,7 @@ export function detectCachingContext( hasCacheControl: hasCacheControl(body), provider, targetFormat, - isCachingProvider: providerSupportsCaching(provider, targetFormat), + isCachingProvider: providerSupportsCaching(provider, targetFormat, context.connectionCacheOverride), }; } diff --git a/open-sse/translator/index.ts b/open-sse/translator/index.ts index d2e2b5038d..cc31aca671 100644 --- a/open-sse/translator/index.ts +++ b/open-sse/translator/index.ts @@ -9,7 +9,10 @@ import { prepareClaudeRequest, } from "./helpers/claudeHelper.ts"; import { filterToOpenAIFormat } from "./helpers/openaiHelper.ts"; -import { providerHonorsOpenAIFormatCacheControl } from "../utils/cacheControlPolicy.ts"; +import { + providerHonorsOpenAIFormatCacheControl, + resolveConnectionCacheOverride, +} from "../utils/cacheControlPolicy.ts"; import { coerceToolSchemas, injectEmptyReasoningContentForToolCalls, @@ -171,6 +174,9 @@ export function translateRequest( let result = body; const use9CharId = options?.normalizeToolCallId === true; const preserveDeveloperRole = options?.preserveDeveloperRole; + const connectionCacheOverride = resolveConnectionCacheOverride( + (credentials as { providerSpecificData?: unknown } | null)?.providerSpecificData + ); // Phase 2: Apply thinking budget control before normalization result = applyThinkingBudget(result); @@ -246,7 +252,7 @@ export function translateRequest( // stripped. const preserveCacheControl = options?.preserveCacheControl === true && - providerHonorsOpenAIFormatCacheControl(provider); + providerHonorsOpenAIFormatCacheControl(provider, connectionCacheOverride); const step1Credentials = options?.copilotClient || hasTargetHint || preserveCacheControl ? { @@ -313,7 +319,8 @@ export function translateRequest( // requested upstream; generic/implicit-cache OpenAI providers stay stripped. result = filterToOpenAIFormat(result, { preserveCacheControl: - options?.preserveCacheControl === true && providerHonorsOpenAIFormatCacheControl(provider), + options?.preserveCacheControl === true && + providerHonorsOpenAIFormatCacheControl(provider, connectionCacheOverride), // #4849 regression guard: keep client reasoning_content for replay providers. preserveReasoningContent: isReasoner, }); diff --git a/open-sse/utils/cacheControlPolicy.ts b/open-sse/utils/cacheControlPolicy.ts index e886bd3e63..69177154e4 100644 --- a/open-sse/utils/cacheControlPolicy.ts +++ b/open-sse/utils/cacheControlPolicy.ts @@ -123,14 +123,54 @@ const OPENAI_FORMAT_CACHE_CONTROL_PROVIDERS = new Set([ "xiaomi-mimo", ]); +/** + * Per-connection override for cache behavior, resolved from the connection's + * `provider_specific_data.cache` JSON sub-object (see `resolveConnectionCacheOverride`). + * Lets an operator opt a custom/openai-compatible connection into prompt-cache + * behavior that the hardcoded provider-name sets above can never match (#6880). + */ +export interface ConnectionCacheOverride { + supportsPromptCaching?: boolean; + cacheControlPassthrough?: "strip" | "openai-format" | "claude-format"; +} + +/** + * Extract and validate a `ConnectionCacheOverride` from a connection's + * `providerSpecificData` bag. Returns `null` when absent/malformed so every + * call site can safely pass the result straight through. + */ +export function resolveConnectionCacheOverride( + providerSpecificData: unknown +): ConnectionCacheOverride | null { + if (!providerSpecificData || typeof providerSpecificData !== "object") return null; + const cache = (providerSpecificData as Record).cache; + if (!cache || typeof cache !== "object" || Array.isArray(cache)) return null; + const record = cache as Record; + const result: ConnectionCacheOverride = {}; + if (typeof record.supportsPromptCaching === "boolean") { + result.supportsPromptCaching = record.supportsPromptCaching; + } + if ( + record.cacheControlPassthrough === "strip" || + record.cacheControlPassthrough === "openai-format" || + record.cacheControlPassthrough === "claude-format" + ) { + result.cacheControlPassthrough = record.cacheControlPassthrough; + } + return Object.keys(result).length > 0 ? result : null; +} + /** * Whether `cache_control` markers should be PASSED THROUGH the OpenAI-format * translation for this provider (vs. stripped). Used to gate the request-side * passthrough so generic / implicit-cache OpenAI providers keep getting cleaned. */ export function providerHonorsOpenAIFormatCacheControl( - provider: string | null | undefined + provider: string | null | undefined, + connectionCacheOverride?: ConnectionCacheOverride | null ): boolean { + if (connectionCacheOverride?.cacheControlPassthrough === "openai-format") return true; + if (connectionCacheOverride?.cacheControlPassthrough === "strip") return false; if (!provider) return false; return OPENAI_FORMAT_CACHE_CONTROL_PROVIDERS.has(provider.toLowerCase()); } @@ -159,8 +199,12 @@ export function isClaudeCodeClient(userAgent: string | null | undefined): boolea */ export function providerSupportsCaching( provider: string | null | undefined, - targetFormat?: string | null + targetFormat?: string | null, + connectionCacheOverride?: ConnectionCacheOverride | null ): boolean { + if (typeof connectionCacheOverride?.supportsPromptCaching === "boolean") { + return connectionCacheOverride.supportsPromptCaching; + } if (!provider) return false; if (CACHING_PROVIDERS.has(provider.toLowerCase())) return true; // All Claude-protocol providers support prompt caching @@ -195,6 +239,7 @@ export function shouldPreserveCacheControl({ targetProvider, targetFormat, settings, + connectionCacheOverride, }: { userAgent: string | null | undefined; isCombo: boolean; @@ -202,6 +247,7 @@ export function shouldPreserveCacheControl({ targetProvider: string | null | undefined; targetFormat?: string | null; settings?: CacheControlSettings; + connectionCacheOverride?: ConnectionCacheOverride | null; }): boolean { // User override takes precedence if (settings?.alwaysPreserveClientCache === "always") { @@ -218,7 +264,7 @@ export function shouldPreserveCacheControl({ } // Target provider must support caching - if (!providerSupportsCaching(targetProvider, targetFormat)) { + if (!providerSupportsCaching(targetProvider, targetFormat, connectionCacheOverride)) { return false; } diff --git a/src/lib/providers/requestDefaults.ts b/src/lib/providers/requestDefaults.ts index ae6c2a5449..84a9bcd666 100644 --- a/src/lib/providers/requestDefaults.ts +++ b/src/lib/providers/requestDefaults.ts @@ -129,6 +129,54 @@ export function normalizeRequestDefaults( return Object.keys(normalized).length > 0 ? normalized : undefined; } +const CACHE_PASSTHROUGH_VALUES = new Set(["strip", "openai-format", "claude-format"]); + +// #6880 — per-connection prompt-cache capability override: strip unknown keys / invalid +// types, drop the sub-object entirely when nothing valid survives. +export function normalizeCacheOverride(value: unknown): JsonRecord | undefined { + const record = asRecord(value); + if (Object.keys(record).length === 0) return undefined; + + const normalized: JsonRecord = {}; + if (typeof record.supportsPromptCaching === "boolean") { + normalized.supportsPromptCaching = record.supportsPromptCaching; + } + if ( + typeof record.cacheControlPassthrough === "string" && + CACHE_PASSTHROUGH_VALUES.has(record.cacheControlPassthrough) + ) { + normalized.cacheControlPassthrough = record.cacheControlPassthrough; + } + + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + +// #6880 — extracted so normalizeProviderSpecificData() stays under the +// max-lines-per-function gate: normalizes the two nested-object sub-fields +// (requestDefaults, cache) in one pass. +function normalizeNestedSubObjects( + provider: string | null | undefined, + normalized: JsonRecord +): void { + if ("requestDefaults" in normalized) { + const requestDefaults = normalizeRequestDefaults(provider, normalized.requestDefaults); + if (requestDefaults) { + normalized.requestDefaults = requestDefaults; + } else { + delete normalized.requestDefaults; + } + } + + if ("cache" in normalized) { + const cache = normalizeCacheOverride(normalized.cache); + if (cache) { + normalized.cache = cache; + } else { + delete normalized.cache; + } + } +} + export function normalizeProviderSpecificData( provider: string | null | undefined, value: unknown @@ -138,14 +186,7 @@ export function normalizeProviderSpecificData( const normalized: JsonRecord = { ...record }; - if ("requestDefaults" in normalized) { - const requestDefaults = normalizeRequestDefaults(provider, normalized.requestDefaults); - if (requestDefaults) { - normalized.requestDefaults = requestDefaults; - } else { - delete normalized.requestDefaults; - } - } + normalizeNestedSubObjects(provider, normalized); if ("openaiStoreEnabled" in normalized && typeof normalized.openaiStoreEnabled !== "boolean") { delete normalized.openaiStoreEnabled; diff --git a/src/shared/validation/providerSpecificData.ts b/src/shared/validation/providerSpecificData.ts index c07aceb52e..864b013161 100644 --- a/src/shared/validation/providerSpecificData.ts +++ b/src/shared/validation/providerSpecificData.ts @@ -15,6 +15,44 @@ function isHttpUrl(value: string): boolean { const CODEX_REASONING_EFFORT_VALUES = new Set(["none", "low", "medium", "high", "xhigh", "max"]); const REQUEST_DEFAULT_SERVICE_TIER_VALUES = new Set(["default", "priority", "fast", "flex"]); +const CACHE_PASSTHROUGH_VALUES = new Set(["strip", "openai-format", "claude-format"]); + +// #6880 — per-connection prompt-cache capability override, extracted so +// validateProviderSpecificData() stays under the complexity gate. +function validateCacheBlock(data: Record, ctx: z.RefinementCtx): void { + const cache = data.cache; + if (cache === undefined) return; + if (!cache || typeof cache !== "object" || Array.isArray(cache)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "providerSpecificData.cache must be an object", + path: ["cache"], + }); + return; + } + const cacheRecord = cache as Record; + const supportsPromptCaching = cacheRecord.supportsPromptCaching; + if (supportsPromptCaching !== undefined && typeof supportsPromptCaching !== "boolean") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "providerSpecificData.cache.supportsPromptCaching must be a boolean", + path: ["cache", "supportsPromptCaching"], + }); + } + const cacheControlPassthrough = cacheRecord.cacheControlPassthrough; + if ( + cacheControlPassthrough !== undefined && + (typeof cacheControlPassthrough !== "string" || + !CACHE_PASSTHROUGH_VALUES.has(cacheControlPassthrough)) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'providerSpecificData.cache.cacheControlPassthrough must be one of "strip", "openai-format", "claude-format"', + path: ["cache", "cacheControlPassthrough"], + }); + } +} export function validateProviderSpecificData( data: Record | undefined, @@ -163,6 +201,8 @@ export function validateProviderSpecificData( } } + validateCacheBlock(data, ctx); + const consoleApiKey = data.consoleApiKey; if (consoleApiKey !== undefined && consoleApiKey !== null && typeof consoleApiKey !== "string") { ctx.addIssue({ diff --git a/tests/unit/connection-cache-override-6880.test.ts b/tests/unit/connection-cache-override-6880.test.ts new file mode 100644 index 0000000000..c8748ab674 --- /dev/null +++ b/tests/unit/connection-cache-override-6880.test.ts @@ -0,0 +1,207 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import type { z } from "zod"; +import { + providerSupportsCaching, + providerHonorsOpenAIFormatCacheControl, + resolveConnectionCacheOverride, + shouldPreserveCacheControl, +} from "../../open-sse/utils/cacheControlPolicy.ts"; +import { + detectCachingContext, + getCacheAwareStrategy, +} from "../../open-sse/services/compression/cachingAware.ts"; +import { validateProviderSpecificData } from "../../src/shared/validation/providerSpecificData.ts"; +import { normalizeProviderSpecificData } from "../../src/lib/providers/requestDefaults.ts"; + +// Regression for #6880: a custom/openai-compatible connection (provider id like +// `openai-compatible-chat-`) can never match the hardcoded CACHING_PROVIDERS / +// OPENAI_FORMAT_CACHE_CONTROL_PROVIDERS name sets in cacheControlPolicy.ts, so cache +// behaviors (prompt_cache_key injection, the compression cache-aware guard, and +// cache_control passthrough) are permanently disabled for that class of connections with +// no way to opt in. This adds a per-connection `cache` capability override consulted +// first by the policy functions, defaulting to today's hardcoded-set behavior. + +function collectIssues(): { ctx: z.RefinementCtx; issues: Array<{ path: (string | number)[]; message: string }> } { + const issues: Array<{ path: (string | number)[]; message: string }> = []; + const ctx = { + addIssue: (issue: { path?: (string | number)[]; message: string }) => { + issues.push({ path: issue.path ?? [], message: issue.message }); + }, + } as unknown as z.RefinementCtx; + return { ctx, issues }; +} + +describe("#6880 resolveConnectionCacheOverride", () => { + test("returns null for undefined/non-object/empty cache", () => { + assert.equal(resolveConnectionCacheOverride(undefined), null); + assert.equal(resolveConnectionCacheOverride(null), null); + assert.equal(resolveConnectionCacheOverride("nope"), null); + assert.equal(resolveConnectionCacheOverride({}), null); + assert.equal(resolveConnectionCacheOverride({ cache: null }), null); + assert.equal(resolveConnectionCacheOverride({ cache: [] }), null); + assert.equal(resolveConnectionCacheOverride({ cache: {} }), null); + }); + + test("extracts valid fields and drops invalid/unknown values", () => { + const result = resolveConnectionCacheOverride({ + cache: { + supportsPromptCaching: true, + cacheControlPassthrough: "openai-format", + unknownField: "ignored", + }, + }); + assert.deepEqual(result, { + supportsPromptCaching: true, + cacheControlPassthrough: "openai-format", + }); + + const invalid = resolveConnectionCacheOverride({ + cache: { supportsPromptCaching: "yes", cacheControlPassthrough: "bogus" }, + }); + assert.equal(invalid, null); + }); +}); + +describe("#6880 providerSupportsCaching override", () => { + test("unblocks a custom openai-compatible connection when the override opts in", () => { + assert.equal( + providerSupportsCaching("openai-compatible-chat-abc123", undefined, { + supportsPromptCaching: true, + }), + true + ); + }); + + test("no override -> default hardcoded-set behavior is unchanged", () => { + assert.equal(providerSupportsCaching("openai-compatible-chat-abc123"), false); + }); + + test("explicit opt-out overrides the hardcoded set", () => { + assert.equal(providerSupportsCaching("claude", undefined, { supportsPromptCaching: false }), false); + }); +}); + +describe("#6880 providerHonorsOpenAIFormatCacheControl override", () => { + test("openai-format override enables passthrough for a non-hardcoded provider", () => { + assert.equal( + providerHonorsOpenAIFormatCacheControl("grok-custom", { cacheControlPassthrough: "openai-format" }), + true + ); + }); + + test("strip override disables passthrough", () => { + assert.equal( + providerHonorsOpenAIFormatCacheControl("grok-custom", { cacheControlPassthrough: "strip" }), + false + ); + }); + + test("no override -> default hardcoded-set behavior is unchanged", () => { + assert.equal(providerHonorsOpenAIFormatCacheControl("grok-custom"), false); + assert.equal(providerHonorsOpenAIFormatCacheControl("alibaba"), true); + }); +}); + +describe("#6880 shouldPreserveCacheControl override", () => { + test("preserves cache_control for a non-hardcoded provider when override opts in", () => { + const result = shouldPreserveCacheControl({ + userAgent: "claude-code/1.0", + isCombo: false, + targetProvider: "openai-compatible-chat-abc123", + targetFormat: "openai", + connectionCacheOverride: { supportsPromptCaching: true }, + }); + assert.equal(result, true); + }); + + test("no override -> non-hardcoded provider still not preserved", () => { + const result = shouldPreserveCacheControl({ + userAgent: "claude-code/1.0", + isCombo: false, + targetProvider: "openai-compatible-chat-abc123", + targetFormat: "openai", + }); + assert.equal(result, false); + }); +}); + +describe("#6880 compression cache-aware guard", () => { + test("detectCachingContext reports isCachingProvider=true when the override opts in", () => { + const ctx = detectCachingContext( + { messages: [{ role: "user", content: "hi" }] }, + { + provider: "openai-compatible-chat-xyz", + targetFormat: "openai", + connectionCacheOverride: { supportsPromptCaching: true }, + } + ); + assert.equal(ctx.isCachingProvider, true); + }); + + test("detectCachingContext without override keeps default (non-caching) behavior", () => { + const ctx = detectCachingContext( + { messages: [{ role: "user", content: "hi" }] }, + { provider: "openai-compatible-chat-xyz", targetFormat: "openai" } + ); + assert.equal(ctx.isCachingProvider, false); + }); + + test("getCacheAwareStrategy protects the cacheable prefix for an overridden context", () => { + const ctx = detectCachingContext( + { messages: [{ role: "user", content: "hi" }] }, + { + provider: "openai-compatible-chat-xyz", + targetFormat: "openai", + connectionCacheOverride: { supportsPromptCaching: true }, + } + ); + const strategy = getCacheAwareStrategy("aggressive", ctx); + assert.equal(strategy.skipSystemPrompt, true); + assert.equal(strategy.deterministicOnly, true); + }); +}); + +describe("#6880 validateProviderSpecificData cache block", () => { + test("accepts a well-formed cache block", () => { + const { ctx, issues } = collectIssues(); + validateProviderSpecificData( + { cache: { supportsPromptCaching: true, cacheControlPassthrough: "openai-format" } }, + ctx + ); + assert.deepEqual(issues, []); + }); + + test("rejects a non-object cache", () => { + const { ctx, issues } = collectIssues(); + validateProviderSpecificData({ cache: "nope" }, ctx); + assert.equal(issues.length, 1); + assert.deepEqual(issues[0]?.path, ["cache"]); + }); + + test("rejects an invalid cacheControlPassthrough value", () => { + const { ctx, issues } = collectIssues(); + validateProviderSpecificData({ cache: { cacheControlPassthrough: "bogus" } }, ctx); + assert.equal(issues.length, 1); + assert.deepEqual(issues[0]?.path, ["cache", "cacheControlPassthrough"]); + }); +}); + +describe("#6880 normalizeProviderSpecificData cache block", () => { + test("strips an invalid cache sub-object down to nothing (key deleted)", () => { + const normalized = normalizeProviderSpecificData("openai-compatible-chat-xyz", { + cache: { supportsPromptCaching: "yes", cacheControlPassthrough: "bogus" }, + }); + assert.equal(normalized?.cache, undefined); + }); + + test("preserves a valid cache sub-object", () => { + const normalized = normalizeProviderSpecificData("openai-compatible-chat-xyz", { + cache: { supportsPromptCaching: true, cacheControlPassthrough: "openai-format", junk: 1 }, + }); + assert.deepEqual(normalized?.cache, { + supportsPromptCaching: true, + cacheControlPassthrough: "openai-format", + }); + }); +});