Merge pull request #3085 from honey-tree/claude-caching-at-depth Claude caching at depth

54db4983f4663d77db79ec1246888a5791bdb619

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
3 files changed, +101 -1Ignore whitespace
default/config.yaml+8 -0
@@ -168,5 +168,13 @@ claude:
168168 # (e.g {{random}} macro or lorebooks not as in-chat injections).
169169 # Otherwise, you'll just waste money on cache misses.
170170 enableSystemPromptCache: false
171+ # Enables caching of the message history at depth (if supported).
172+ # https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching
173+ # -- IMPORTANT! --
174+ # Use with caution. Behavior may be unpredictable and no guarantees can or will be made.
175+ # Set to an integer to specify the desired depth. 0 (which does NOT include the prefill)
176+ # should be ideal for most use cases.
177+ # Any value other than a non-negative integer will be ignored and caching at depth will not be enabled.
178+ cachingAtDepth: -1
171179# -- SERVER PLUGIN CONFIGURATION --
172180enableServerPlugins: false
src/endpoints/backends/chat-completions.js+19 -1
@@ -26,6 +26,8 @@ import {
2626 convertMistralMessages,
2727 convertAI21Messages,
2828 mergeMessages,
29+ cachingAtDepthForOpenRouterClaude,
30+ cachingAtDepthForClaude,
2931} from '../../prompt-converters.js';
3032
3133import { readSecret, SECRET_KEYS } from '../secrets.js';
@@ -80,6 +82,11 @@ async function sendClaudeRequest(request, response) {
8082 const apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.CLAUDE);
8183 const divider = '-'.repeat(process.stdout.columns);
8284 const enableSystemPromptCache = getConfigValue('claude.enableSystemPromptCache', false) && request.body.model.startsWith('claude-3');
85+ let cachingAtDepth = getConfigValue('claude.cachingAtDepth', -1);
86+ // Disabled if not an integer or negative, or if the model doesn't support it
87+ if (!Number.isInteger(cachingAtDepth) || cachingAtDepth < 0 || !request.body.model.startsWith('claude-3')) {
88+ cachingAtDepth = -1;
89+ }
8390
8491 if (!apiKey) {
8592 console.log(color.red(`Claude API key is missing.\n${divider}`));
@@ -138,9 +145,15 @@ async function sendClaudeRequest(request, response) {
138145 requestBody.tools[requestBody.tools.length - 1]['cache_control'] = { type: 'ephemeral' };
139146 }
140147 }
141- if (enableSystemPromptCache) {
148+
149+ if (cachingAtDepth !== -1) {
150+ cachingAtDepthForClaude(convertedPrompt.messages, cachingAtDepth);
151+ }
152+
153+ if (enableSystemPromptCache || cachingAtDepth !== -1) {
142154 additionalHeaders['anthropic-beta'] = 'prompt-caching-2024-07-31';
143155 }
156+
144157 console.log('Claude request:', requestBody);
145158
146159 const generateResponse = await fetch(apiUrl + '/messages', {
@@ -876,6 +889,11 @@ router.post('/generate', jsonParser, function (request, response) {
876889 if (request.body.use_fallback) {
877890 bodyParams['route'] = 'fallback';
878891 }
892+
893+ let cachingAtDepth = getConfigValue('claude.cachingAtDepth', -1);
894+ if (Number.isInteger(cachingAtDepth) && cachingAtDepth >= 0 && request.body.model.startsWith('anthropic/claude-3')) {
895+ cachingAtDepthForOpenRouterClaude(request.body.messages, cachingAtDepth);
896+ }
879897 } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.CUSTOM) {
880898 apiUrl = request.body.custom_url;
881899 apiKey = readSecret(request.user.directories, SECRET_KEYS.CUSTOM);
src/prompt-converters.js+74 -0
@@ -718,3 +718,77 @@ export function convertTextCompletionPrompt(messages) {
718718 });
719719 return messageStrings.join('\n') + '\nassistant:';
720720}
721+
722+export function cachingAtDepthForClaude(messages, cachingAtDepth) {
723+ let passedThePrefill = false;
724+ let depth = 0;
725+ let previousRoleName = '';
726+
727+ for (let i = messages.length - 1; i >= 0; i--) {
728+ if (!passedThePrefill && messages[i].role === 'assistant') {
729+ continue;
730+ }
731+
732+ passedThePrefill = true;
733+
734+ if (messages[i].role !== previousRoleName) {
735+ if (depth === cachingAtDepth || depth === cachingAtDepth + 2) {
736+ const content = messages[i].content;
737+ content[content.length - 1].cache_control = { type: 'ephemeral' };
738+ }
739+
740+ if (depth === cachingAtDepth + 2) {
741+ break;
742+ }
743+
744+ depth += 1;
745+ previousRoleName = messages[i].role;
746+ }
747+ }
748+}
749+
750+/**
751+ * Append cache_control headers to an OpenRouter request at depth. Directly modifies the
752+ * messages array.
753+ * @param {object[]} messages Array of messages
754+ * @param {number} cachingAtDepth Depth at which caching is supposed to occur
755+ */
756+export function cachingAtDepthForOpenRouterClaude(messages, cachingAtDepth) {
757+ //caching the prefill is a terrible idea in general
758+ let passedThePrefill = false;
759+ //depth here is the number of message role switches
760+ let depth = 0;
761+ let previousRoleName = '';
762+ for (let i = messages.length - 1; i >= 0; i--) {
763+ if (!passedThePrefill && messages[i].role === 'assistant') {
764+ continue;
765+ }
766+
767+ passedThePrefill = true;
768+
769+ if (messages[i].role !== previousRoleName) {
770+ if (depth === cachingAtDepth || depth === cachingAtDepth + 2) {
771+ const content = messages[i].content;
772+ if (typeof content === 'string') {
773+ messages[i].content = [{
774+ type: 'text',
775+ text: content,
776+ cache_control: { type: 'ephemeral' },
777+ }];
778+ } else {
779+ const contentPartCount = content.length;
780+ content[contentPartCount - 1].cache_control = {
781+ type: 'ephemeral',
782+ };
783+ }
784+ }
785+
786+ if (depth === cachingAtDepth + 2) {
787+ break;
788+ }
789+
790+ depth += 1;
791+ previousRoleName = messages[i].role;
792+ }
793+ }
794+}