Add caching system prompt feature for OpenRouter Gemini (#4903) * feat: add caching system prompt for OpenRouter Gemini * fix: resolve reviews
Signed| @@ -287,6 +287,12 @@ claude: | ||
| 287 | 287 | gemini: |
| 288 | 288 | # API endpoint version ("v1beta" or "v1alpha") |
| 289 | 289 | apiVersion: 'v1beta' |
| 290 | + # Enables caching of the system prompt (if supported). Only for OpenRouter. | |
| 291 | + # -- IMPORTANT! -- | |
| 292 | + # Use only when the prompt before the chat history is static and doesn't change between requests | |
| 293 | + # (e.g {{random}} macro or lorebooks not as in-chat injections). | |
| 294 | + # Otherwise, you'll just waste money on cache misses. | |
| 295 | + enableSystemPromptCache: false | |
| 290 | 296 | # https://ai.google.dev/gemini-api/docs/imagen#imagen-configuration |
| 291 | 297 | image: |
| 292 | 298 | # Leave empty to use the API-default value. |
| @@ -45,7 +45,7 @@ import { | ||
| 45 | 45 | addAssistantPrefix, |
| 46 | 46 | embedOpenRouterMedia, |
| 47 | 47 | addReasoningContentToToolCalls, |
| 48 | 48 | cachingSystemPromptForOpenRouterClaudecachingSystemPromptForOpenRouter, |
| 49 | 49 | addOpenRouterSignatures, |
| 50 | 50 | } from '../../prompt-converters.js'; |
| 51 | 51 | |
| @@ -84,6 +84,58 @@ const API_COMETAPI = 'https://api.cometapi.com/v1'; | ||
| 84 | 84 | const API_ZAI_COMMON = 'https://api.z.ai/api/paas/v4'; |
| 85 | 85 | const API_ZAI_CODING = 'https://api.z.ai/api/coding/paas/v4'; |
| 86 | 86 | const API_SILICONFLOW = 'https://api.siliconflow.com/v1'; |
| 87 | +const API_OPENROUTER = 'https://openrouter.ai/api/v1'; | |
| 88 | + | |
| 89 | +/** | |
| 90 | + * Cache for cacheable (writing) OpenRouter model IDs. | |
| 91 | + * @type {string[]} | |
| 92 | + */ | |
| 93 | +const openRouterCacheableModels = []; | |
| 94 | + | |
| 95 | +/** | |
| 96 | + * Checks if an OpenRouter model supports prompt cache writing. | |
| 97 | + * Uses a cache to avoid repeated API calls. | |
| 98 | + * @param {string} modelId - The OpenRouter model ID | |
| 99 | + * @returns {Promise<boolean>} `true` if the model supports writing cache | |
| 100 | + */ | |
| 101 | +async function isOpenRouterModelCacheable(modelId) { | |
| 102 | + if (openRouterCacheableModels.includes(modelId)) { | |
| 103 | + return true; | |
| 104 | + } | |
| 105 | + | |
| 106 | + try { | |
| 107 | + const response = await fetch(`${API_OPENROUTER}/models`, { | |
| 108 | + method: 'GET', | |
| 109 | + headers: { 'Accept': 'application/json' }, | |
| 110 | + signal: AbortSignal.timeout(5000), | |
| 111 | + }); | |
| 112 | + | |
| 113 | + if (!response.ok) { | |
| 114 | + console.warn(`OpenRouter models API returned ${response.status}: ${response.statusText}`); | |
| 115 | + return false; | |
| 116 | + } | |
| 117 | + | |
| 118 | + /** @type {any} */ | |
| 119 | + const data = await response.json(); | |
| 120 | + | |
| 121 | + if (!Array.isArray(data?.data)) { | |
| 122 | + console.warn('OpenRouter API response format unexpected'); | |
| 123 | + return false; | |
| 124 | + } | |
| 125 | + | |
| 126 | + const model = data.data.find(m => m.id === modelId); | |
| 127 | + const supportsCache = model?.pricing?.input_cache_write != null; | |
| 128 | + | |
| 129 | + if (supportsCache) { | |
| 130 | + openRouterCacheableModels.push(modelId); | |
| 131 | + } | |
| 132 | + | |
| 133 | + return supportsCache; | |
| 134 | + } catch (error) { | |
| 135 | + console.warn(`Failed to check OpenRouter cache support for ${modelId}:`, error.message); | |
| 136 | + return false; | |
| 137 | + } | |
| 138 | +} | |
| 87 | 139 | |
| 88 | 140 | /** |
| 89 | 141 | * Gets OpenRouter transforms based on the request. |
| @@ -2051,22 +2103,30 @@ router.post('/generate', async function (request, response) { | ||
| 2051 | 2103 | const cachingAtDepth = getConfigValue('claude.cachingAtDepth', -1, 'number'); |
| 2052 | 2104 | const isClaude3or4 = /anthropic\/claude-(3|opus-4|sonnet-4|haiku-4)/.test(request.body.model); |
| 2053 | 2105 | const cacheTTL = getConfigValue('claude.extendedTTL', false, 'boolean') ? '1h' : '5m'; |
| 2106 | + | |
| 2107 | + const isGemini = /google\/gemini/.test(request.body.model); | |
| 2108 | + const isCacheableGemini = isGemini && await isOpenRouterModelCacheable(request.body.model); | |
| 2109 | + const enableGeminiSystemPromptCache = getConfigValue('gemini.enableSystemPromptCache', false, 'boolean'); | |
| 2110 | + | |
| 2054 | 2111 | if (Array.isArray(request.body.messages)) { |
| 2055 | 2112 | embedOpenRouterMedia(request.body.messages); |
| 2056 | 2113 | addOpenRouterSignatures(request.body.messages, request.body.model); |
| 2057 | 2114 | |
| 2058 | 2115 | if (isClaude3or4) { |
| 2059 | 2116 | if (enableSystemPromptCache) { |
| 2060 | 2117 | cachingSystemPromptForOpenRouterClaudecachingSystemPromptForOpenRouter(request.body.messages, cacheTTL); |
| 2061 | 2118 | } |
| 2062 | 2119 | |
| 2063 | 2120 | if (Number.isInteger(cachingAtDepth) && cachingAtDepth >= 0) { |
| 2064 | 2121 | cachingAtDepthForOpenRouterClaude(request.body.messages, cachingAtDepth, cacheTTL); |
| 2065 | 2122 | } |
| 2066 | 2123 | } |
| 2124 | + | |
| 2125 | + if (isCacheableGemini && enableGeminiSystemPromptCache) { | |
| 2126 | + cachingSystemPromptForOpenRouter(request.body.messages); | |
| 2127 | + } | |
| 2067 | 2128 | } |
| 2068 | 2129 | |
| 2069 | - const isGemini = /google\/gemini/.test(request.body.model); | |
| 2070 | 2130 | if (isGemini) { |
| 2071 | 2131 | bodyParams['safety_settings'] = GEMINI_SAFETY; |
| 2072 | 2132 | } |
| @@ -1057,12 +1057,12 @@ export function cachingAtDepthForOpenRouterClaude(messages, cachingAtDepth, ttl) | ||
| 1057 | 1057 | } |
| 1058 | 1058 | |
| 1059 | 1059 | /** |
| 1060 | 1060 | * Adds cache_control to the system prompt for OpenRouter Claude requests. |
| 1061 | 1061 | * |
| 1062 | 1062 | * @param {object[]} messages Array of messages |
| 1063 | 1063 | * @param {string} [ttl] TTL value (optional) |
| 1064 | 1064 | */ |
| 1065 | 1065 | export function cachingSystemPromptForOpenRouterClaudecachingSystemPromptForOpenRouter(messages, ttl = undefined) { |
| 1066 | 1066 | if (!Array.isArray(messages) || messages.length === 0) { |
| 1067 | 1067 | return; |
| 1068 | 1068 | } |
| @@ -1078,6 +1078,10 @@ export function cachingSystemPromptForOpenRouterClaude(messages, ttl) { | ||
| 1078 | 1078 | return; |
| 1079 | 1079 | } |
| 1080 | 1080 | |
| 1081 | + const cacheControl = ttl | |
| 1082 | + ? { type: 'ephemeral', ttl } | |
| 1083 | + : { type: 'ephemeral' }; | |
| 1084 | + | |
| 1081 | 1085 | if (Array.isArray(systemMessage.content)) { |
| 1082 | 1086 | const hasExistingCacheControl = systemMessage.content.some(part => part?.cache_control); |
| 1083 | 1087 | if (hasExistingCacheControl) { |
| @@ -1086,7 +1090,7 @@ export function cachingSystemPromptForOpenRouterClaude(messages, ttl) { | ||
| 1086 | 1090 | |
| 1087 | 1091 | for (let i = systemMessage.content.length - 1; i >= 0; i--) { |
| 1088 | 1092 | if (systemMessage.content[i]?.type === 'text') { |
| 1089 | 1093 | systemMessage.content[i].cache_control = { type: 'ephemeral', ttl }cacheControl; |
| 1090 | 1094 | return; |
| 1091 | 1095 | } |
| 1092 | 1096 | } |
| @@ -1095,7 +1099,7 @@ export function cachingSystemPromptForOpenRouterClaude(messages, ttl) { | ||
| 1095 | 1099 | { |
| 1096 | 1100 | type: 'text', |
| 1097 | 1101 | text: systemMessage.content, |
| 1098 | - cache_control: { type: 'ephemeral', ttl }, | |
| 1102 | + cache_control: cacheControl, | |
| 1099 | 1103 | }, |
| 1100 | 1104 | ]; |
| 1101 | 1105 | } |