diff --git a/.changeset/ai-prompt-config.md b/.changeset/ai-prompt-config.md new file mode 100644 index 0000000000..85df878aa2 --- /dev/null +++ b/.changeset/ai-prompt-config.md @@ -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`. diff --git a/packages/ai/src/prompts.ts b/packages/ai/src/prompts.ts index 04cd78228f..63cc9be39c 100644 --- a/packages/ai/src/prompts.ts +++ b/packages/ai/src/prompts.ts @@ -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 | null { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + return value as Record + } + return null +} + +/** Copied so a caller mutating result.config can't pollute the cache entry later reads are served from. */ +function cloneConfig(config: Record | null): Record | null { + return config === null ? null : structuredClone(config) +} + function isPromptApiResponse(data: unknown): data is PromptApiResponse { if (typeof data !== 'object' || data === null) { return false @@ -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 { if (options?.version !== undefined && options?.label !== undefined) { @@ -162,6 +177,7 @@ export class Prompts { name: undefined, version: undefined, label: undefined, + config: undefined, } satisfies PromptCodeFallbackResult } @@ -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) } } } @@ -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 @@ -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), + } } } diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 43f3d2b7ba..33b8bd98bf 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -119,6 +119,7 @@ export interface CachedPrompt { name: string version: number label?: string + config: Record | null fetchedAt: number } @@ -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 @@ -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 | null } /** @@ -161,6 +170,7 @@ export interface PromptCodeFallbackResult { name: undefined version: undefined label: undefined + config: undefined } /** diff --git a/packages/ai/tests/prompts.test.ts b/packages/ai/tests/prompts.test.ts index 5ce15b0f81..bb8d7c2c25 100644 --- a/packages/ai/tests/prompts.test.ts +++ b/packages/ai/tests/prompts.test.ts @@ -619,6 +619,7 @@ describe('Prompts', () => { prompt: mockPromptResponse.prompt, name: 'test-prompt', version: 1, + config: null, }) }) @@ -643,6 +644,7 @@ describe('Prompts', () => { prompt: mockPromptResponse.prompt, name: 'test-prompt', version: 1, + config: null, }) expect(mockFetch).toHaveBeenCalledTimes(1) }) @@ -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)) }) @@ -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()