Skip to content

Commit 2d9eb60

Browse files
authored
feat(ai): add version-aware prompt cache clearing (#3210)
* feat(ai): add version-aware prompt cache clearing * chore(ai): add changeset for prompt cache clearing * test(ai): cover prompt cache cleanup path
1 parent 1fc147e commit 2d9eb60

File tree

3 files changed

+107
-22
lines changed

3 files changed

+107
-22
lines changed

.changeset/poor-lobsters-tease.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@posthog/ai': minor
3+
---
4+
5+
Add version-specific `Prompts.clearCache(name, version)` support and switch prompt caching to structured name/version entries instead of string-encoded cache keys.

packages/ai/src/prompts.ts

Lines changed: 38 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { PostHog } from 'posthog-node'
44
import type { CachedPrompt, GetPromptOptions, PromptApiResponse, PromptVariables, PromptsDirectOptions } from './types'
55

66
const DEFAULT_CACHE_TTL_SECONDS = 300 // 5 minutes
7+
type PromptVersionCache = Map<number | undefined, CachedPrompt>
78

89
function isPromptApiResponse(data: unknown): data is PromptApiResponse {
910
return (
@@ -63,7 +64,7 @@ export class Prompts {
6364
private projectApiKey: string
6465
private host: string
6566
private defaultCacheTtlSeconds: number
66-
private cache: Map<string, CachedPrompt> = new Map()
67+
private cache: Map<string, PromptVersionCache> = new Map()
6768

6869
constructor(options: PromptsOptions) {
6970
this.defaultCacheTtlSeconds = options.defaultCacheTtlSeconds ?? DEFAULT_CACHE_TTL_SECONDS
@@ -80,8 +81,19 @@ export class Prompts {
8081
}
8182
}
8283

83-
private getCacheKey(name: string, version?: number): string {
84-
return version === undefined ? `${name}::latest` : `${name}::version:${version}`
84+
private getPromptCache(name: string): PromptVersionCache | undefined {
85+
return this.cache.get(name)
86+
}
87+
88+
private getOrCreatePromptCache(name: string): PromptVersionCache {
89+
const cachedPromptVersions = this.cache.get(name)
90+
if (cachedPromptVersions) {
91+
return cachedPromptVersions
92+
}
93+
94+
const promptVersions: PromptVersionCache = new Map()
95+
this.cache.set(name, promptVersions)
96+
return promptVersions
8597
}
8698

8799
private getPromptLabel(name: string, version?: number): string {
@@ -100,11 +112,10 @@ export class Prompts {
100112
const cacheTtlSeconds = options?.cacheTtlSeconds ?? this.defaultCacheTtlSeconds
101113
const fallback = options?.fallback
102114
const version = options?.version
103-
const cacheKey = this.getCacheKey(name, version)
104115
const promptLabel = this.getPromptLabel(name, version)
105116

106117
// Check cache first
107-
const cached = this.cache.get(cacheKey)
118+
const cached = this.getPromptCache(name)?.get(version)
108119
const now = Date.now()
109120

110121
if (cached) {
@@ -121,7 +132,7 @@ export class Prompts {
121132
const fetchedAt = Date.now()
122133

123134
// Update cache
124-
this.cache.set(cacheKey, {
135+
this.getOrCreatePromptCache(name).set(version, {
125136
prompt,
126137
fetchedAt,
127138
})
@@ -169,24 +180,29 @@ export class Prompts {
169180
/**
170181
* Clear the cache for a specific prompt or all prompts
171182
*
172-
* @param name - Optional prompt name to clear. If provided, clears all cached versions for that prompt.
183+
* @param name - Optional prompt name to clear. If provided, clears all cached versions for that prompt unless a version is also provided.
184+
* @param version - Optional prompt version to clear. Requires a prompt name.
173185
*/
174-
clearCache(name?: string): void {
175-
if (name !== undefined) {
176-
const latestKey = this.getCacheKey(name)
177-
const versionPrefix = `${name}::version:`
178-
for (const key of this.cache.keys()) {
179-
if (key === latestKey) {
180-
this.cache.delete(key)
181-
continue
182-
}
183-
184-
if (key.startsWith(versionPrefix) && /^\d+$/.test(key.slice(versionPrefix.length))) {
185-
this.cache.delete(key)
186-
}
187-
}
188-
} else {
186+
clearCache(name?: string, version?: number): void {
187+
if (version !== undefined && name === undefined) {
188+
throw new Error("'version' requires 'name' to be provided")
189+
}
190+
191+
if (name === undefined) {
189192
this.cache.clear()
193+
return
194+
}
195+
196+
if (version === undefined) {
197+
this.cache.delete(name)
198+
return
199+
}
200+
201+
const promptVersions = this.getPromptCache(name)
202+
promptVersions?.delete(version)
203+
204+
if (promptVersions?.size === 0) {
205+
this.cache.delete(name)
190206
}
191207
}
192208

packages/ai/tests/prompts.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,13 @@ describe('Prompts', () => {
588588
})
589589

590590
describe('clearCache()', () => {
591+
it('should throw when clearing a specific version without a prompt name', () => {
592+
const posthog = createMockPostHog()
593+
const prompts = new Prompts({ posthog })
594+
595+
expect(() => prompts.clearCache(undefined, 1)).toThrow("'version' requires 'name' to be provided")
596+
})
597+
591598
it('should clear a specific prompt from cache', async () => {
592599
mockFetch
593600
.mockResolvedValueOnce({
@@ -703,6 +710,63 @@ describe('Prompts', () => {
703710
expect(mockFetch).toHaveBeenCalledTimes(4)
704711
})
705712

713+
it('should clear only the requested cached version when name and version are provided', async () => {
714+
mockFetch
715+
.mockResolvedValueOnce({
716+
ok: true,
717+
status: 200,
718+
json: () => Promise.resolve({ ...mockPromptResponse, version: 2, prompt: 'Latest prompt' }),
719+
})
720+
.mockResolvedValueOnce({
721+
ok: true,
722+
status: 200,
723+
json: () => Promise.resolve({ ...mockPromptResponse, version: 1, prompt: 'Version 1 prompt' }),
724+
})
725+
.mockResolvedValueOnce({
726+
ok: true,
727+
status: 200,
728+
json: () => Promise.resolve({ ...mockPromptResponse, version: 1, prompt: 'Version 1 prompt refreshed' }),
729+
})
730+
731+
const posthog = createMockPostHog()
732+
const prompts = new Prompts({ posthog })
733+
734+
await prompts.get('test-prompt')
735+
await prompts.get('test-prompt', { version: 1 })
736+
expect(mockFetch).toHaveBeenCalledTimes(2)
737+
738+
prompts.clearCache('test-prompt', 1)
739+
740+
await expect(prompts.get('test-prompt')).resolves.toBe('Latest prompt')
741+
await expect(prompts.get('test-prompt', { version: 1 })).resolves.toBe('Version 1 prompt refreshed')
742+
expect(mockFetch).toHaveBeenCalledTimes(3)
743+
})
744+
745+
it('should remove the outer cache entry when the last version is cleared', async () => {
746+
mockFetch
747+
.mockResolvedValueOnce({
748+
ok: true,
749+
status: 200,
750+
json: () => Promise.resolve({ ...mockPromptResponse, version: 1, prompt: 'Version 1 prompt' }),
751+
})
752+
.mockResolvedValueOnce({
753+
ok: true,
754+
status: 200,
755+
json: () => Promise.resolve({ ...mockPromptResponse, version: 1, prompt: 'Version 1 prompt refreshed' }),
756+
})
757+
758+
const posthog = createMockPostHog()
759+
const prompts = new Prompts({ posthog })
760+
761+
await prompts.get('test-prompt', { version: 1 })
762+
expect(mockFetch).toHaveBeenCalledTimes(1)
763+
764+
prompts.clearCache('test-prompt', 1)
765+
766+
await expect(prompts.get('test-prompt', { version: 1 })).resolves.toBe('Version 1 prompt refreshed')
767+
expect(mockFetch).toHaveBeenCalledTimes(2)
768+
})
769+
706770
it('should not clear cache entries for other prompt names that share the same prefix', async () => {
707771
mockFetch
708772
.mockResolvedValueOnce({

0 commit comments

Comments
 (0)