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/ai-prompt-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@posthog/ai': minor
---

`Prompts.get()` results now include `config`, the JSON object of model parameters or agent configuration stored with the prompt version in PostHog prompt management (`null` when the version has none). Config is carried through the client-side cache and the stale-cache fallback, and each result gets its own copy so mutating `result.config` cannot pollute later cache hits. The hardcoded `fallback` string has no config, so use defensive access like `(result.config ?? {}).temperature`.
32 changes: 27 additions & 5 deletions packages/ai/src/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,19 @@ function normalizeHost(value?: unknown): string {
return (normalizedHost || DEFAULT_PROMPTS_HOST).replace(/\/+$/, '')
}

/** Reads config from an API response, tolerating servers that don't send it. */
function extractConfig(value: unknown): Record<string, unknown> | null {
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
return value as Record<string, unknown>
}
return null
}

/** Copied so a caller mutating result.config can't pollute the cache entry later reads are served from. */
function cloneConfig(config: Record<string, unknown> | null): Record<string, unknown> | null {
return config === null ? null : structuredClone(config)
}

function isPromptApiResponse(data: unknown): data is PromptApiResponse {
if (typeof data !== 'object' || data === null) {
return false
Expand Down Expand Up @@ -140,7 +153,9 @@ export class Prompts {
* Fetch a prompt by name from the PostHog API.
*
* Returns a `PromptResult` object carrying the prompt text alongside `source`,
* `name`, and `version` metadata. Read `result.prompt` for the template string.
* `name`, `version`, and `config` metadata. Read `result.prompt` for the
* template string and `result.config ?? {}` for model parameters or agent
* configuration stored with the version.
*/
async get(name: string, options?: GetPromptOptions): Promise<PromptResult> {
if (options?.version !== undefined && options?.label !== undefined) {
Expand All @@ -162,6 +177,7 @@ export class Prompts {
name: undefined,
version: undefined,
label: undefined,
config: undefined,
} satisfies PromptCodeFallbackResult
}

Expand Down Expand Up @@ -189,7 +205,7 @@ export class Prompts {

if (isFresh) {
const { fetchedAt: _, ...cachedResult } = cached
return { source: 'cache', ...cachedResult }
return { source: 'cache', ...cachedResult, config: cloneConfig(cached.config) }
}
}

Expand All @@ -210,13 +226,13 @@ export class Prompts {
// Update cache
this.getOrCreatePromptCache(name).set(cacheEntryKey, { ...fetched, fetchedAt: Date.now() })

return { source: 'api', ...fetched }
return { source: 'api', ...fetched, config: cloneConfig(fetched.config) }
} catch (error) {
// Return stale cache (with warning)
if (cached) {
const { fetchedAt: _, ...cachedResult } = cached
console.warn(`[PostHog Prompts] Failed to fetch prompt ${promptReference}, using stale cache:`, error)
return { source: 'stale_cache', ...cachedResult }
return { source: 'stale_cache', ...cachedResult, config: cloneConfig(cached.config) }
}

throw error
Expand Down Expand Up @@ -325,6 +341,12 @@ export class Prompts {
throw new Error(`[PostHog Prompts] Invalid response format for prompt ${promptReference}`)
}

return { prompt: data.prompt, name: data.name, version: data.version, label: data.label }
return {
prompt: data.prompt,
name: data.name,
version: data.version,
label: data.label,
config: extractConfig(data.config),
}
}
}
10 changes: 10 additions & 0 deletions packages/ai/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ export interface CachedPrompt {
name: string
version: number
label?: string
config: Record<string, unknown> | null
fetchedAt: number
}

Expand All @@ -132,6 +133,8 @@ export interface PromptApiResponse {
version: number
/** Present when the prompt was fetched by label. */
label?: string
/** Model parameters or agent configuration stored with the version. Absent on older servers. */
config?: unknown
created_by: string
created_at: string
updated_at: string
Expand All @@ -148,6 +151,12 @@ export interface PromptRemoteResult {
version: number
/** The label the prompt was fetched by, when fetching with the label option. */
label?: string
/**
* JSON object of model parameters or agent configuration stored with the
* prompt version, or null when the version has none. Use defensive access,
* e.g. `result.config ?? {}` — fallback results carry no config.
*/
config: Record<string, unknown> | null
}

/**
Expand All @@ -161,6 +170,7 @@ export interface PromptCodeFallbackResult {
name: undefined
version: undefined
label: undefined
config: undefined
}

/**
Expand Down
86 changes: 86 additions & 0 deletions packages/ai/tests/prompts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,7 @@ describe('Prompts', () => {
prompt: mockPromptResponse.prompt,
name: 'test-prompt',
version: 1,
config: null,
})
})

Expand All @@ -643,6 +644,7 @@ describe('Prompts', () => {
prompt: mockPromptResponse.prompt,
name: 'test-prompt',
version: 1,
config: null,
})
expect(mockFetch).toHaveBeenCalledTimes(1)
})
Expand Down Expand Up @@ -673,6 +675,7 @@ describe('Prompts', () => {
prompt: mockPromptResponse.prompt,
name: 'test-prompt',
version: 1,
config: null,
})
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('using stale cache'), expect.any(Error))
})
Expand Down Expand Up @@ -721,10 +724,93 @@ describe('Prompts', () => {
prompt: 'Version 3 prompt',
name: 'test-prompt',
version: 3,
config: null,
})
})
})

describe('get() config', () => {
const mockConfig = { model: 'gpt-4o', temperature: 0.2 }

it('should carry config through api and cache-hit results', async () => {
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ ...mockPromptResponse, config: mockConfig }),
})

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

const apiResult = await prompts.get('test-prompt')
const cachedResult = await prompts.get('test-prompt')

expect(apiResult.source).toBe('api')
expect(apiResult.config).toEqual(mockConfig)
expect(cachedResult.source).toBe('cache')
expect(cachedResult.config).toEqual(mockConfig)
expect(mockFetch).toHaveBeenCalledTimes(1)
})

it('should keep config on stale-cache results', async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({ ...mockPromptResponse, config: mockConfig }),
})
.mockRejectedValueOnce(new Error('Network error'))

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

await prompts.get('test-prompt', { cacheTtlSeconds: 60 })
jest.advanceTimersByTime(61 * 1000)
const result = await prompts.get('test-prompt', { cacheTtlSeconds: 60 })

expect(result.source).toBe('stale_cache')
expect(result.config).toEqual(mockConfig)
})

it('should not let a caller mutating result.config pollute later cache hits', async () => {
// Nested values included: a shallow copy would pass the top-level mutations
// below but leak the nested one into the cache.
const nestedConfig = { model: 'gpt-4o', tools: [{ name: 'search', parameters: { depth: 1 } }] }
mockFetch.mockResolvedValue({
ok: true,
status: 200,
json: () => Promise.resolve({ ...mockPromptResponse, config: nestedConfig }),
})

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

const first = await prompts.get('test-prompt')
first.config!.temperature = 0.9
delete first.config!.model
;(first.config!.tools as { parameters: { depth: number } }[])[0].parameters.depth = 99

const second = await prompts.get('test-prompt')

expect(second.source).toBe('cache')
expect(second.config).toEqual({ model: 'gpt-4o', tools: [{ name: 'search', parameters: { depth: 1 } }] })
})

it.each([
['absent', {}],
['null', { config: null }],
['non-object', { config: 'gpt-4o' }],
])('should read %s config as null', async (_scenario, extra) => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({ ...mockPromptResponse, ...extra }),
})

const prompts = new Prompts({ posthog: createMockPostHog() })
const result = await prompts.get('test-prompt')

expect(result.config).toBeNull()
})
})

describe('compile()', () => {
it('should replace a single variable', () => {
const posthog = createMockPostHog()
Expand Down
Loading