Skip to content

Commit 43a81c5

Browse files
authored
feat(ai): support versioned prompts (#3206)
* feat(ai): support versioned prompts * fix(ai): avoid prefix collisions when clearing prompt cache
1 parent 6f426db commit 43a81c5

File tree

4 files changed

+184
-14
lines changed

4 files changed

+184
-14
lines changed

.changeset/tough-apes-judge.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-aware prompt fetching to the Prompts SDK so callers can request a specific published prompt version without colliding with the latest prompt cache entry.

packages/ai/src/prompts.ts

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ function isPromptsWithPostHog(options: PromptsOptions): options is PromptsWithPo
4646
* fallback: 'You are a helpful assistant.',
4747
* })
4848
*
49+
* // Or fetch an exact published version
50+
* const v3Template = await prompts.get('support-system-prompt', {
51+
* version: 3,
52+
* })
53+
*
4954
* // Compile with variables
5055
* const systemPrompt = prompts.compile(template, {
5156
* company: 'Acme Corp',
@@ -75,20 +80,31 @@ export class Prompts {
7580
}
7681
}
7782

83+
private getCacheKey(name: string, version?: number): string {
84+
return version === undefined ? `${name}::latest` : `${name}::version:${version}`
85+
}
86+
87+
private getPromptLabel(name: string, version?: number): string {
88+
return version === undefined ? `"${name}"` : `"${name}" version ${version}`
89+
}
90+
7891
/**
7992
* Fetch a prompt by name from the PostHog API
8093
*
8194
* @param name - The name of the prompt to fetch
82-
* @param options - Optional settings for caching and fallback
95+
* @param options - Optional settings for caching, fallback, and exact version selection
8396
* @returns The prompt string
8497
* @throws Error if the prompt cannot be fetched and no fallback is provided
8598
*/
8699
async get(name: string, options?: GetPromptOptions): Promise<string> {
87100
const cacheTtlSeconds = options?.cacheTtlSeconds ?? this.defaultCacheTtlSeconds
88101
const fallback = options?.fallback
102+
const version = options?.version
103+
const cacheKey = this.getCacheKey(name, version)
104+
const promptLabel = this.getPromptLabel(name, version)
89105

90106
// Check cache first
91-
const cached = this.cache.get(name)
107+
const cached = this.cache.get(cacheKey)
92108
const now = Date.now()
93109

94110
if (cached) {
@@ -101,11 +117,11 @@ export class Prompts {
101117

102118
// Try to fetch from API
103119
try {
104-
const prompt = await this.fetchPromptFromApi(name)
120+
const prompt = await this.fetchPromptFromApi(name, version)
105121
const fetchedAt = Date.now()
106122

107123
// Update cache
108-
this.cache.set(name, {
124+
this.cache.set(cacheKey, {
109125
prompt,
110126
fetchedAt,
111127
})
@@ -115,13 +131,13 @@ export class Prompts {
115131
// Fallback order:
116132
// 1. Return stale cache (with warning)
117133
if (cached) {
118-
console.warn(`[PostHog Prompts] Failed to fetch prompt "${name}", using stale cache:`, error)
134+
console.warn(`[PostHog Prompts] Failed to fetch prompt ${promptLabel}, using stale cache:`, error)
119135
return cached.prompt
120136
}
121137

122138
// 2. Return fallback (with warning)
123139
if (fallback !== undefined) {
124-
console.warn(`[PostHog Prompts] Failed to fetch prompt "${name}", using fallback:`, error)
140+
console.warn(`[PostHog Prompts] Failed to fetch prompt ${promptLabel}, using fallback:`, error)
125141
return fallback
126142
}
127143

@@ -153,17 +169,28 @@ export class Prompts {
153169
/**
154170
* Clear the cache for a specific prompt or all prompts
155171
*
156-
* @param name - Optional prompt name to clear. If not provided, clears all cached prompts.
172+
* @param name - Optional prompt name to clear. If provided, clears all cached versions for that prompt.
157173
*/
158174
clearCache(name?: string): void {
159175
if (name !== undefined) {
160-
this.cache.delete(name)
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+
}
161188
} else {
162189
this.cache.clear()
163190
}
164191
}
165192

166-
private async fetchPromptFromApi(name: string): Promise<string> {
193+
private async fetchPromptFromApi(name: string, version?: number): Promise<string> {
167194
if (!this.personalApiKey) {
168195
throw new Error(
169196
'[PostHog Prompts] personalApiKey is required to fetch prompts. ' +
@@ -179,7 +206,9 @@ export class Prompts {
179206

180207
const encodedPromptName = encodeURIComponent(name)
181208
const encodedProjectApiKey = encodeURIComponent(this.projectApiKey)
182-
const url = `${this.host}/api/environments/@current/llm_prompts/name/${encodedPromptName}/?token=${encodedProjectApiKey}`
209+
const versionQuery = version === undefined ? '' : `&version=${encodeURIComponent(String(version))}`
210+
const promptLabel = this.getPromptLabel(name, version)
211+
const url = `${this.host}/api/environments/@current/llm_prompts/name/${encodedPromptName}/?token=${encodedProjectApiKey}${versionQuery}`
183212

184213
const response = await fetch(url, {
185214
method: 'GET',
@@ -190,23 +219,23 @@ export class Prompts {
190219

191220
if (!response.ok) {
192221
if (response.status === 404) {
193-
throw new Error(`[PostHog Prompts] Prompt "${name}" not found`)
222+
throw new Error(`[PostHog Prompts] Prompt ${promptLabel} not found`)
194223
}
195224

196225
if (response.status === 403) {
197226
throw new Error(
198-
`[PostHog Prompts] Access denied for prompt "${name}". ` +
227+
`[PostHog Prompts] Access denied for prompt ${promptLabel}. ` +
199228
'Check that your personalApiKey has the correct permissions and the LLM prompts feature is enabled.'
200229
)
201230
}
202231

203-
throw new Error(`[PostHog Prompts] Failed to fetch prompt "${name}": HTTP ${response.status}`)
232+
throw new Error(`[PostHog Prompts] Failed to fetch prompt ${promptLabel}: HTTP ${response.status}`)
204233
}
205234

206235
const data: unknown = await response.json()
207236

208237
if (!isPromptApiResponse(data)) {
209-
throw new Error(`[PostHog Prompts] Invalid response format for prompt "${name}"`)
238+
throw new Error(`[PostHog Prompts] Invalid response format for prompt ${promptLabel}`)
210239
}
211240

212241
return data.prompt

packages/ai/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ export interface TokenUsage {
101101
export interface GetPromptOptions {
102102
cacheTtlSeconds?: number
103103
fallback?: string
104+
version?: number
104105
}
105106

106107
/**

packages/ai/tests/prompts.test.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,30 @@ describe('Prompts', () => {
6868
)
6969
})
7070

71+
it('should fetch a specific prompt version when requested', async () => {
72+
mockFetch.mockResolvedValueOnce({
73+
ok: true,
74+
status: 200,
75+
json: () => Promise.resolve({ ...mockPromptResponse, version: 2, prompt: 'Version 2 prompt' }),
76+
})
77+
78+
const posthog = createMockPostHog()
79+
const prompts = new Prompts({ posthog })
80+
81+
const result = await prompts.get('test-prompt', { version: 2 })
82+
83+
expect(result).toBe('Version 2 prompt')
84+
expect(mockFetch).toHaveBeenCalledWith(
85+
'https://us.posthog.com/api/environments/@current/llm_prompts/name/test-prompt/?token=phc_test_key&version=2',
86+
{
87+
method: 'GET',
88+
headers: {
89+
Authorization: 'Bearer phx_test_key',
90+
},
91+
}
92+
)
93+
})
94+
7195
it('should return cached prompt when fresh', async () => {
7296
mockFetch.mockResolvedValueOnce({
7397
ok: true,
@@ -92,6 +116,34 @@ describe('Prompts', () => {
92116
expect(mockFetch).toHaveBeenCalledTimes(1) // No additional fetch
93117
})
94118

119+
it('should keep latest and versioned prompt caches separate', async () => {
120+
mockFetch
121+
.mockResolvedValueOnce({
122+
ok: true,
123+
status: 200,
124+
json: () => Promise.resolve({ ...mockPromptResponse, version: 2, prompt: 'Latest prompt' }),
125+
})
126+
.mockResolvedValueOnce({
127+
ok: true,
128+
status: 200,
129+
json: () => Promise.resolve({ ...mockPromptResponse, version: 1, prompt: 'Version 1 prompt' }),
130+
})
131+
132+
const posthog = createMockPostHog()
133+
const prompts = new Prompts({ posthog })
134+
135+
const latestResult = await prompts.get('test-prompt', { cacheTtlSeconds: 300 })
136+
const versionedResult = await prompts.get('test-prompt', { cacheTtlSeconds: 300, version: 1 })
137+
138+
expect(latestResult).toBe('Latest prompt')
139+
expect(versionedResult).toBe('Version 1 prompt')
140+
expect(mockFetch).toHaveBeenCalledTimes(2)
141+
142+
await expect(prompts.get('test-prompt', { cacheTtlSeconds: 300 })).resolves.toBe('Latest prompt')
143+
await expect(prompts.get('test-prompt', { cacheTtlSeconds: 300, version: 1 })).resolves.toBe('Version 1 prompt')
144+
expect(mockFetch).toHaveBeenCalledTimes(2)
145+
})
146+
95147
it('should refetch when cache is stale', async () => {
96148
const updatedPromptResponse = {
97149
...mockPromptResponse,
@@ -180,6 +232,20 @@ describe('Prompts', () => {
180232
await expect(prompts.get('test-prompt')).rejects.toThrow('Network error')
181233
})
182234

235+
it('should include the requested version in versioned fetch errors', async () => {
236+
mockFetch.mockResolvedValueOnce({
237+
ok: false,
238+
status: 404,
239+
})
240+
241+
const posthog = createMockPostHog()
242+
const prompts = new Prompts({ posthog })
243+
244+
await expect(prompts.get('nonexistent-prompt', { version: 7 })).rejects.toThrow(
245+
'[PostHog Prompts] Prompt "nonexistent-prompt" version 7 not found'
246+
)
247+
})
248+
183249
it('should handle 404 response', async () => {
184250
mockFetch.mockResolvedValueOnce({
185251
ok: false,
@@ -599,5 +665,74 @@ describe('Prompts', () => {
599665
await prompts.get('other-prompt')
600666
expect(mockFetch).toHaveBeenCalledTimes(4)
601667
})
668+
669+
it('should clear all cached versions for a prompt when a name is provided', async () => {
670+
mockFetch
671+
.mockResolvedValueOnce({
672+
ok: true,
673+
status: 200,
674+
json: () => Promise.resolve({ ...mockPromptResponse, version: 2, prompt: 'Latest prompt' }),
675+
})
676+
.mockResolvedValueOnce({
677+
ok: true,
678+
status: 200,
679+
json: () => Promise.resolve({ ...mockPromptResponse, version: 1, prompt: 'Version 1 prompt' }),
680+
})
681+
.mockResolvedValueOnce({
682+
ok: true,
683+
status: 200,
684+
json: () => Promise.resolve({ ...mockPromptResponse, version: 2, prompt: 'Latest prompt refreshed' }),
685+
})
686+
.mockResolvedValueOnce({
687+
ok: true,
688+
status: 200,
689+
json: () => Promise.resolve({ ...mockPromptResponse, version: 1, prompt: 'Version 1 prompt refreshed' }),
690+
})
691+
692+
const posthog = createMockPostHog()
693+
const prompts = new Prompts({ posthog })
694+
695+
await prompts.get('test-prompt')
696+
await prompts.get('test-prompt', { version: 1 })
697+
expect(mockFetch).toHaveBeenCalledTimes(2)
698+
699+
prompts.clearCache('test-prompt')
700+
701+
await expect(prompts.get('test-prompt')).resolves.toBe('Latest prompt refreshed')
702+
await expect(prompts.get('test-prompt', { version: 1 })).resolves.toBe('Version 1 prompt refreshed')
703+
expect(mockFetch).toHaveBeenCalledTimes(4)
704+
})
705+
706+
it('should not clear cache entries for other prompt names that share the same prefix', async () => {
707+
mockFetch
708+
.mockResolvedValueOnce({
709+
ok: true,
710+
status: 200,
711+
json: () => Promise.resolve({ ...mockPromptResponse, name: 'foo', prompt: 'Foo latest' }),
712+
})
713+
.mockResolvedValueOnce({
714+
ok: true,
715+
status: 200,
716+
json: () => Promise.resolve({ ...mockPromptResponse, name: 'foo::bar', prompt: 'Foo bar latest' }),
717+
})
718+
.mockResolvedValueOnce({
719+
ok: true,
720+
status: 200,
721+
json: () => Promise.resolve({ ...mockPromptResponse, name: 'foo', prompt: 'Foo latest refreshed' }),
722+
})
723+
724+
const posthog = createMockPostHog()
725+
const prompts = new Prompts({ posthog })
726+
727+
await prompts.get('foo')
728+
await prompts.get('foo::bar')
729+
expect(mockFetch).toHaveBeenCalledTimes(2)
730+
731+
prompts.clearCache('foo')
732+
733+
await expect(prompts.get('foo')).resolves.toBe('Foo latest refreshed')
734+
await expect(prompts.get('foo::bar')).resolves.toBe('Foo bar latest')
735+
expect(mockFetch).toHaveBeenCalledTimes(3)
736+
})
602737
})
603738
})

0 commit comments

Comments
 (0)