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 | gemini: | 287 | gemini: |
| 288 | # API endpoint version ("v1beta" or "v1alpha") | 288 | # API endpoint version ("v1beta" or "v1alpha") |
| 289 | apiVersion: 'v1beta' | 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 | # https://ai.google.dev/gemini-api/docs/imagen#imagen-configuration | 296 | # https://ai.google.dev/gemini-api/docs/imagen#imagen-configuration |
| 291 | image: | 297 | image: |
| 292 | # Leave empty to use the API-default value. | 298 | # Leave empty to use the API-default value. |
| @@ -45,7 +45,7 @@ import { | |||
| 45 | addAssistantPrefix, | 45 | addAssistantPrefix, |
| 46 | embedOpenRouterMedia, | 46 | embedOpenRouterMedia, |
| 47 | addReasoningContentToToolCalls, | 47 | addReasoningContentToToolCalls, |
| 48 | cachingSystemPromptForOpenRouterClaude, | 48 | cachingSystemPromptForOpenRouter, |
| 49 | addOpenRouterSignatures, | 49 | addOpenRouterSignatures, |
| 50 | } from '../../prompt-converters.js'; | 50 | } from '../../prompt-converters.js'; |
| 51 | 51 | ||
| @@ -84,6 +84,58 @@ const API_COMETAPI = 'https://api.cometapi.com/v1'; | |||
| 84 | const API_ZAI_COMMON = 'https://api.z.ai/api/paas/v4'; | 84 | const API_ZAI_COMMON = 'https://api.z.ai/api/paas/v4'; |
| 85 | const API_ZAI_CODING = 'https://api.z.ai/api/coding/paas/v4'; | 85 | const API_ZAI_CODING = 'https://api.z.ai/api/coding/paas/v4'; |
| 86 | const API_SILICONFLOW = 'https://api.siliconflow.com/v1'; | 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 | * Gets OpenRouter transforms based on the request. | 141 | * Gets OpenRouter transforms based on the request. |
| @@ -2051,22 +2103,30 @@ router.post('/generate', async function (request, response) { | |||
| 2051 | const cachingAtDepth = getConfigValue('claude.cachingAtDepth', -1, 'number'); | 2103 | const cachingAtDepth = getConfigValue('claude.cachingAtDepth', -1, 'number'); |
| 2052 | const isClaude3or4 = /anthropic\/claude-(3|opus-4|sonnet-4|haiku-4)/.test(request.body.model); | 2104 | const isClaude3or4 = /anthropic\/claude-(3|opus-4|sonnet-4|haiku-4)/.test(request.body.model); |
| 2053 | const cacheTTL = getConfigValue('claude.extendedTTL', false, 'boolean') ? '1h' : '5m'; | 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 | if (Array.isArray(request.body.messages)) { | 2111 | if (Array.isArray(request.body.messages)) { |
| 2055 | embedOpenRouterMedia(request.body.messages); | 2112 | embedOpenRouterMedia(request.body.messages); |
| 2056 | addOpenRouterSignatures(request.body.messages, request.body.model); | 2113 | addOpenRouterSignatures(request.body.messages, request.body.model); |
| 2057 | 2114 | ||
| 2058 | if (isClaude3or4) { | 2115 | if (isClaude3or4) { |
| 2059 | if (enableSystemPromptCache) { | 2116 | if (enableSystemPromptCache) { |
| 2060 | cachingSystemPromptForOpenRouterClaude(request.body.messages, cacheTTL); | 2117 | cachingSystemPromptForOpenRouter(request.body.messages, cacheTTL); |
| 2061 | } | 2118 | } |
| 2062 | 2119 | ||
| 2063 | if (Number.isInteger(cachingAtDepth) && cachingAtDepth >= 0) { | 2120 | if (Number.isInteger(cachingAtDepth) && cachingAtDepth >= 0) { |
| 2064 | cachingAtDepthForOpenRouterClaude(request.body.messages, cachingAtDepth, cacheTTL); | 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 | if (isGemini) { | 2130 | if (isGemini) { |
| 2071 | bodyParams['safety_settings'] = GEMINI_SAFETY; | 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 | * Adds cache_control to the system prompt for OpenRouter Claude requests. | 1060 | * Adds cache_control to the system prompt for OpenRouter requests. |
| 1061 | * | 1061 | * |
| 1062 | * @param {object[]} messages Array of messages | 1062 | * @param {object[]} messages Array of messages |
| 1063 | * @param {string} ttl TTL value | 1063 | * @param {string} [ttl] TTL value (optional) |
| 1064 | */ | 1064 | */ |
| 1065 | export function cachingSystemPromptForOpenRouterClaude(messages, ttl) { | 1065 | export function cachingSystemPromptForOpenRouter(messages, ttl = undefined) { |
| 1066 | if (!Array.isArray(messages) || messages.length === 0) { | 1066 | if (!Array.isArray(messages) || messages.length === 0) { |
| 1067 | return; | 1067 | return; |
| 1068 | } | 1068 | } |
| @@ -1078,6 +1078,10 @@ export function cachingSystemPromptForOpenRouterClaude(messages, ttl) { | |||
| 1078 | return; | 1078 | return; |
| 1079 | } | 1079 | } |
| 1080 | 1080 | ||
| 1081 | const cacheControl = ttl | ||
| 1082 | ? { type: 'ephemeral', ttl } | ||
| 1083 | : { type: 'ephemeral' }; | ||
| 1084 | |||
| 1081 | if (Array.isArray(systemMessage.content)) { | 1085 | if (Array.isArray(systemMessage.content)) { |
| 1082 | const hasExistingCacheControl = systemMessage.content.some(part => part?.cache_control); | 1086 | const hasExistingCacheControl = systemMessage.content.some(part => part?.cache_control); |
| 1083 | if (hasExistingCacheControl) { | 1087 | if (hasExistingCacheControl) { |
| @@ -1086,7 +1090,7 @@ export function cachingSystemPromptForOpenRouterClaude(messages, ttl) { | |||
| 1086 | 1090 | ||
| 1087 | for (let i = systemMessage.content.length - 1; i >= 0; i--) { | 1091 | for (let i = systemMessage.content.length - 1; i >= 0; i--) { |
| 1088 | if (systemMessage.content[i]?.type === 'text') { | 1092 | if (systemMessage.content[i]?.type === 'text') { |
| 1089 | systemMessage.content[i].cache_control = { type: 'ephemeral', ttl }; | 1093 | systemMessage.content[i].cache_control = cacheControl; |
| 1090 | return; | 1094 | return; |
| 1091 | } | 1095 | } |
| 1092 | } | 1096 | } |
| @@ -1095,7 +1099,7 @@ export function cachingSystemPromptForOpenRouterClaude(messages, ttl) { | |||
| 1095 | { | 1099 | { |
| 1096 | type: 'text', | 1100 | type: 'text', |
| 1097 | text: systemMessage.content, | 1101 | text: systemMessage.content, |
| 1098 | cache_control: { type: 'ephemeral', ttl }, | 1102 | cache_control: cacheControl, |
| 1099 | }, | 1103 | }, |
| 1100 | ]; | 1104 | ]; |
| 1101 | } | 1105 | } |