Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tough-apes-judge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@posthog/ai': minor
---

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.
57 changes: 43 additions & 14 deletions packages/ai/src/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ function isPromptsWithPostHog(options: PromptsOptions): options is PromptsWithPo
* fallback: 'You are a helpful assistant.',
* })
*
* // Or fetch an exact published version
* const v3Template = await prompts.get('support-system-prompt', {
* version: 3,
* })
*
* // Compile with variables
* const systemPrompt = prompts.compile(template, {
* company: 'Acme Corp',
Expand Down Expand Up @@ -75,20 +80,31 @@ export class Prompts {
}
}

private getCacheKey(name: string, version?: number): string {
return version === undefined ? `${name}::latest` : `${name}::version:${version}`
}
Comment on lines 82 to +85
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would document that we enforce cache naming in the backend that prevents using :::

Suggested change
private getCacheKey(name: string, version?: number): string {
return version === undefined ? `${name}::latest` : `${name}::version:${version}`
}
// :: is restricted from use in cache names in the backend, so no chance of accidental collision
private getCacheKey(name: string, version?: number): string {
return version === undefined ? `${name}::latest` : `${name}::version:${version}`
}

Still a bit brittle since we could change that in the backend without realising this code exists, the ideal solution would be to use a Map<string, Map<number | undefined, CachedPrompt>> as the cache, mimicking the Python approach of a tuple as a key.


private getPromptLabel(name: string, version?: number): string {
return version === undefined ? `"${name}"` : `"${name}" version ${version}`
}

/**
* Fetch a prompt by name from the PostHog API
*
* @param name - The name of the prompt to fetch
* @param options - Optional settings for caching and fallback
* @param options - Optional settings for caching, fallback, and exact version selection
* @returns The prompt string
* @throws Error if the prompt cannot be fetched and no fallback is provided
*/
async get(name: string, options?: GetPromptOptions): Promise<string> {
const cacheTtlSeconds = options?.cacheTtlSeconds ?? this.defaultCacheTtlSeconds
const fallback = options?.fallback
const version = options?.version
const cacheKey = this.getCacheKey(name, version)
const promptLabel = this.getPromptLabel(name, version)

// Check cache first
const cached = this.cache.get(name)
const cached = this.cache.get(cacheKey)
const now = Date.now()

if (cached) {
Expand All @@ -101,11 +117,11 @@ export class Prompts {

// Try to fetch from API
try {
const prompt = await this.fetchPromptFromApi(name)
const prompt = await this.fetchPromptFromApi(name, version)
const fetchedAt = Date.now()

// Update cache
this.cache.set(name, {
this.cache.set(cacheKey, {
prompt,
fetchedAt,
})
Expand All @@ -115,13 +131,13 @@ export class Prompts {
// Fallback order:
// 1. Return stale cache (with warning)
if (cached) {
console.warn(`[PostHog Prompts] Failed to fetch prompt "${name}", using stale cache:`, error)
console.warn(`[PostHog Prompts] Failed to fetch prompt ${promptLabel}, using stale cache:`, error)
return cached.prompt
}

// 2. Return fallback (with warning)
if (fallback !== undefined) {
console.warn(`[PostHog Prompts] Failed to fetch prompt "${name}", using fallback:`, error)
console.warn(`[PostHog Prompts] Failed to fetch prompt ${promptLabel}, using fallback:`, error)
return fallback
}

Expand Down Expand Up @@ -153,17 +169,28 @@ export class Prompts {
/**
* Clear the cache for a specific prompt or all prompts
*
* @param name - Optional prompt name to clear. If not provided, clears all cached prompts.
* @param name - Optional prompt name to clear. If provided, clears all cached versions for that prompt.
*/
clearCache(name?: string): void {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be clearCache(name?: string, version?: number): void for parity with Python's API?

if (name !== undefined) {
this.cache.delete(name)
const latestKey = this.getCacheKey(name)
const versionPrefix = `${name}::version:`
for (const key of this.cache.keys()) {
if (key === latestKey) {
this.cache.delete(key)
continue
}
Comment on lines +176 to +182
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The approach described in my previous comment would also make clearing all versions for a prompt a one liner without string manipulation.


if (key.startsWith(versionPrefix) && /^\d+$/.test(key.slice(versionPrefix.length))) {
this.cache.delete(key)
}
}
} else {
this.cache.clear()
}
}

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

const encodedPromptName = encodeURIComponent(name)
const encodedProjectApiKey = encodeURIComponent(this.projectApiKey)
const url = `${this.host}/api/environments/@current/llm_prompts/name/${encodedPromptName}/?token=${encodedProjectApiKey}`
const versionQuery = version === undefined ? '' : `&version=${encodeURIComponent(String(version))}`
const promptLabel = this.getPromptLabel(name, version)
const url = `${this.host}/api/environments/@current/llm_prompts/name/${encodedPromptName}/?token=${encodedProjectApiKey}${versionQuery}`

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

if (!response.ok) {
if (response.status === 404) {
throw new Error(`[PostHog Prompts] Prompt "${name}" not found`)
throw new Error(`[PostHog Prompts] Prompt ${promptLabel} not found`)
}

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

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

const data: unknown = await response.json()

if (!isPromptApiResponse(data)) {
throw new Error(`[PostHog Prompts] Invalid response format for prompt "${name}"`)
throw new Error(`[PostHog Prompts] Invalid response format for prompt ${promptLabel}`)
}

return data.prompt
Expand Down
1 change: 1 addition & 0 deletions packages/ai/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export interface TokenUsage {
export interface GetPromptOptions {
cacheTtlSeconds?: number
fallback?: string
version?: number
}

/**
Expand Down
135 changes: 135 additions & 0 deletions packages/ai/tests/prompts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,30 @@ describe('Prompts', () => {
)
})

it('should fetch a specific prompt version when requested', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({ ...mockPromptResponse, version: 2, prompt: 'Version 2 prompt' }),
})

const posthog = createMockPostHog()
const prompts = new Prompts({ posthog })

const result = await prompts.get('test-prompt', { version: 2 })

expect(result).toBe('Version 2 prompt')
expect(mockFetch).toHaveBeenCalledWith(
'https://us.posthog.com/api/environments/@current/llm_prompts/name/test-prompt/?token=phc_test_key&version=2',
{
method: 'GET',
headers: {
Authorization: 'Bearer phx_test_key',
},
}
)
})

it('should return cached prompt when fresh', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
Expand All @@ -92,6 +116,34 @@ describe('Prompts', () => {
expect(mockFetch).toHaveBeenCalledTimes(1) // No additional fetch
})

it('should keep latest and versioned prompt caches separate', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({ ...mockPromptResponse, version: 2, prompt: 'Latest prompt' }),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({ ...mockPromptResponse, version: 1, prompt: 'Version 1 prompt' }),
})

const posthog = createMockPostHog()
const prompts = new Prompts({ posthog })

const latestResult = await prompts.get('test-prompt', { cacheTtlSeconds: 300 })
const versionedResult = await prompts.get('test-prompt', { cacheTtlSeconds: 300, version: 1 })

expect(latestResult).toBe('Latest prompt')
expect(versionedResult).toBe('Version 1 prompt')
expect(mockFetch).toHaveBeenCalledTimes(2)

await expect(prompts.get('test-prompt', { cacheTtlSeconds: 300 })).resolves.toBe('Latest prompt')
await expect(prompts.get('test-prompt', { cacheTtlSeconds: 300, version: 1 })).resolves.toBe('Version 1 prompt')
expect(mockFetch).toHaveBeenCalledTimes(2)
})

it('should refetch when cache is stale', async () => {
const updatedPromptResponse = {
...mockPromptResponse,
Expand Down Expand Up @@ -180,6 +232,20 @@ describe('Prompts', () => {
await expect(prompts.get('test-prompt')).rejects.toThrow('Network error')
})

it('should include the requested version in versioned fetch errors', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
})

const posthog = createMockPostHog()
const prompts = new Prompts({ posthog })

await expect(prompts.get('nonexistent-prompt', { version: 7 })).rejects.toThrow(
'[PostHog Prompts] Prompt "nonexistent-prompt" version 7 not found'
)
})

it('should handle 404 response', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
Expand Down Expand Up @@ -599,5 +665,74 @@ describe('Prompts', () => {
await prompts.get('other-prompt')
expect(mockFetch).toHaveBeenCalledTimes(4)
})

it('should clear all cached versions for a prompt when a name is provided', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({ ...mockPromptResponse, version: 2, prompt: 'Latest prompt' }),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({ ...mockPromptResponse, version: 1, prompt: 'Version 1 prompt' }),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({ ...mockPromptResponse, version: 2, prompt: 'Latest prompt refreshed' }),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({ ...mockPromptResponse, version: 1, prompt: 'Version 1 prompt refreshed' }),
})

const posthog = createMockPostHog()
const prompts = new Prompts({ posthog })

await prompts.get('test-prompt')
await prompts.get('test-prompt', { version: 1 })
expect(mockFetch).toHaveBeenCalledTimes(2)

prompts.clearCache('test-prompt')

await expect(prompts.get('test-prompt')).resolves.toBe('Latest prompt refreshed')
await expect(prompts.get('test-prompt', { version: 1 })).resolves.toBe('Version 1 prompt refreshed')
expect(mockFetch).toHaveBeenCalledTimes(4)
})

it('should not clear cache entries for other prompt names that share the same prefix', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({ ...mockPromptResponse, name: 'foo', prompt: 'Foo latest' }),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({ ...mockPromptResponse, name: 'foo::bar', prompt: 'Foo bar latest' }),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({ ...mockPromptResponse, name: 'foo', prompt: 'Foo latest refreshed' }),
})

const posthog = createMockPostHog()
const prompts = new Prompts({ posthog })

await prompts.get('foo')
await prompts.get('foo::bar')
expect(mockFetch).toHaveBeenCalledTimes(2)

prompts.clearCache('foo')

await expect(prompts.get('foo')).resolves.toBe('Foo latest refreshed')
await expect(prompts.get('foo::bar')).resolves.toBe('Foo bar latest')
expect(mockFetch).toHaveBeenCalledTimes(3)
})
})
})
Loading