Add caching system prompt feature for OpenRouter Gemini (#4903) * feat: add caching system prompt for OpenRouter Gemini * fix: resolve reviews

ca4379679566a29561d0502488667edfaf7e85db

Chanho Chung <hello@chungchan.dev>

Signed
3 files changed, +78 -8Showing whitespace changes
default/config.yaml+6 -0
@@ -287,6 +287,12 @@ claude:
287gemini:287gemini:
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-configuration296 # 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.
src/endpoints/backends/chat-completions.js+63 -3
@@ -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';
5151
@@ -84,6 +84,58 @@ const API_COMETAPI = 'https://api.cometapi.com/v1';
84const API_ZAI_COMMON = 'https://api.z.ai/api/paas/v4';84const API_ZAI_COMMON = 'https://api.z.ai/api/paas/v4';
85const API_ZAI_CODING = 'https://api.z.ai/api/coding/paas/v4';85const API_ZAI_CODING = 'https://api.z.ai/api/coding/paas/v4';
86const API_SILICONFLOW = 'https://api.siliconflow.com/v1';86const API_SILICONFLOW = 'https://api.siliconflow.com/v1';
87const API_OPENROUTER = 'https://openrouter.ai/api/v1';
88
89/**
90 * Cache for cacheable (writing) OpenRouter model IDs.
91 * @type {string[]}
92 */
93const 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 */
101async 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}
87139
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);
20572114
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 }
20622119
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 }
20682129
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 }
src/prompt-converters.js+9 -5
@@ -1057,12 +1057,12 @@ export function cachingAtDepthForOpenRouterClaude(messages, cachingAtDepth, ttl)
1057}1057}
10581058
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 messages1062 * @param {object[]} messages Array of messages
1063 * @param {string} ttl TTL value1063 * @param {string} [ttl] TTL value (optional)
1064 */1064 */
1065export function cachingSystemPromptForOpenRouterClaude(messages, ttl) {1065export 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 }
10801080
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) {
10861090
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 }