Merge pull request #3085 from honey-tree/claude-caching-at-depth Claude caching at depth
Signed| @@ -168,5 +168,13 @@ claude: | |||
| 168 | # (e.g {{random}} macro or lorebooks not as in-chat injections). | 168 | # (e.g {{random}} macro or lorebooks not as in-chat injections). |
| 169 | # Otherwise, you'll just waste money on cache misses. | 169 | # Otherwise, you'll just waste money on cache misses. |
| 170 | enableSystemPromptCache: false | 170 | 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 | ||
| 171 | # -- SERVER PLUGIN CONFIGURATION -- | 179 | # -- SERVER PLUGIN CONFIGURATION -- |
| 172 | enableServerPlugins: false | 180 | enableServerPlugins: false |
| @@ -26,6 +26,8 @@ import { | |||
| 26 | convertMistralMessages, | 26 | convertMistralMessages, |
| 27 | convertAI21Messages, | 27 | convertAI21Messages, |
| 28 | mergeMessages, | 28 | mergeMessages, |
| 29 | cachingAtDepthForOpenRouterClaude, | ||
| 30 | cachingAtDepthForClaude, | ||
| 29 | } from '../../prompt-converters.js'; | 31 | } from '../../prompt-converters.js'; |
| 30 | 32 | ||
| 31 | import { readSecret, SECRET_KEYS } from '../secrets.js'; | 33 | import { readSecret, SECRET_KEYS } from '../secrets.js'; |
| @@ -80,6 +82,11 @@ async function sendClaudeRequest(request, response) { | |||
| 80 | const apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.CLAUDE); | 82 | const apiKey = request.body.reverse_proxy ? request.body.proxy_password : readSecret(request.user.directories, SECRET_KEYS.CLAUDE); |
| 81 | const divider = '-'.repeat(process.stdout.columns); | 83 | const divider = '-'.repeat(process.stdout.columns); |
| 82 | const enableSystemPromptCache = getConfigValue('claude.enableSystemPromptCache', false) && request.body.model.startsWith('claude-3'); | 84 | 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 | } | ||
| 83 | 90 | ||
| 84 | if (!apiKey) { | 91 | if (!apiKey) { |
| 85 | console.log(color.red(`Claude API key is missing.\n${divider}`)); | 92 | console.log(color.red(`Claude API key is missing.\n${divider}`)); |
| @@ -138,9 +145,15 @@ async function sendClaudeRequest(request, response) { | |||
| 138 | requestBody.tools[requestBody.tools.length - 1]['cache_control'] = { type: 'ephemeral' }; | 145 | requestBody.tools[requestBody.tools.length - 1]['cache_control'] = { type: 'ephemeral' }; |
| 139 | } | 146 | } |
| 140 | } | 147 | } |
| 141 | if (enableSystemPromptCache) { | 148 | |
| 149 | if (cachingAtDepth !== -1) { | ||
| 150 | cachingAtDepthForClaude(convertedPrompt.messages, cachingAtDepth); | ||
| 151 | } | ||
| 152 | |||
| 153 | if (enableSystemPromptCache || cachingAtDepth !== -1) { | ||
| 142 | additionalHeaders['anthropic-beta'] = 'prompt-caching-2024-07-31'; | 154 | additionalHeaders['anthropic-beta'] = 'prompt-caching-2024-07-31'; |
| 143 | } | 155 | } |
| 156 | |||
| 144 | console.log('Claude request:', requestBody); | 157 | console.log('Claude request:', requestBody); |
| 145 | 158 | ||
| 146 | const generateResponse = await fetch(apiUrl + '/messages', { | 159 | const generateResponse = await fetch(apiUrl + '/messages', { |
| @@ -876,6 +889,11 @@ router.post('/generate', jsonParser, function (request, response) { | |||
| 876 | if (request.body.use_fallback) { | 889 | if (request.body.use_fallback) { |
| 877 | bodyParams['route'] = 'fallback'; | 890 | bodyParams['route'] = 'fallback'; |
| 878 | } | 891 | } |
| 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 | } | ||
| 879 | } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.CUSTOM) { | 897 | } else if (request.body.chat_completion_source === CHAT_COMPLETION_SOURCES.CUSTOM) { |
| 880 | apiUrl = request.body.custom_url; | 898 | apiUrl = request.body.custom_url; |
| 881 | apiKey = readSecret(request.user.directories, SECRET_KEYS.CUSTOM); | 899 | apiKey = readSecret(request.user.directories, SECRET_KEYS.CUSTOM); |
| @@ -718,3 +718,77 @@ export function convertTextCompletionPrompt(messages) { | |||
| 718 | }); | 718 | }); |
| 719 | return messageStrings.join('\n') + '\nassistant:'; | 719 | return messageStrings.join('\n') + '\nassistant:'; |
| 720 | } | 720 | } |
| 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 | } | ||