From 654cb89e16edb7b2a820ba7bf70c876b73849d41 Mon Sep 17 00:00:00 2001 From: Juraj Majerik Date: Thu, 16 Jul 2026 14:50:51 +0200 Subject: [PATCH 1/3] feat(ai): fetch prompts by label --- packages/ai/src/prompts.ts | 61 +++++++++++++++++++-------- packages/ai/src/types.ts | 9 ++++ packages/ai/tests/prompts.test.ts | 68 +++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 18 deletions(-) diff --git a/packages/ai/src/prompts.ts b/packages/ai/src/prompts.ts index 7b3debaa6b..b4b75cf84b 100644 --- a/packages/ai/src/prompts.ts +++ b/packages/ai/src/prompts.ts @@ -14,7 +14,9 @@ import type { const DEFAULT_CACHE_TTL_SECONDS = 300 // 5 minutes const DEFAULT_PROMPTS_HOST = 'https://us.posthog.com' -type PromptVersionCache = Map +// Keyed by version number, label string, or undefined for the latest version. +// Version and label keys can't collide: one is always a number, the other a string. +type PromptVersionCache = Map function normalizeApiKey(value?: unknown): string { return typeof value === 'string' ? value.trim() : '' @@ -70,6 +72,11 @@ function isPromptsWithPostHog(options: PromptsOptions): options is PromptsWithPo * version: 3, * }) * + * // Or fetch the version a label currently points to + * const prod = await prompts.get('support-system-prompt', { + * label: 'production', + * }) + * * // Compile with variables * const systemPrompt = prompts.compile(result.prompt, { * company: 'Acme Corp', @@ -114,8 +121,14 @@ export class Prompts { return promptVersions } - private getPromptLabel(name: string, version?: number): string { - return version === undefined ? `"${name}"` : `"${name}" version ${version}` + private getPromptReference(name: string, version?: number, label?: string): string { + if (version !== undefined) { + return `"${name}" version ${version}` + } + if (label !== undefined) { + return `"${name}" label "${label}"` + } + return `"${name}"` } /** @@ -125,20 +138,25 @@ export class Prompts { * `name`, and `version` metadata. Read `result.prompt` for the template string. */ async get(name: string, options?: GetPromptOptions): Promise { + if (options?.version !== undefined && options?.label !== undefined) { + throw new Error('[PostHog Prompts] Pass either version or label, not both.') + } + try { return await this.getInternal(name, options) } catch (error) { const fallback = options?.fallback if (fallback !== undefined) { - const promptLabel = this.getPromptLabel(name, options?.version) - console.warn(`[PostHog Prompts] Failed to fetch prompt ${promptLabel}, using fallback:`, error) + const promptReference = this.getPromptReference(name, options?.version, options?.label) + console.warn(`[PostHog Prompts] Failed to fetch prompt ${promptReference}, using fallback:`, error) return { source: 'code_fallback', prompt: fallback, name: undefined, version: undefined, + label: undefined, } satisfies PromptCodeFallbackResult } @@ -153,10 +171,12 @@ export class Prompts { private async getInternal(name: string, options?: GetPromptOptions): Promise { const cacheTtlSeconds = options?.cacheTtlSeconds ?? this.defaultCacheTtlSeconds const version = options?.version - const promptLabel = this.getPromptLabel(name, version) + const label = options?.label + const promptReference = this.getPromptReference(name, version, label) + const cacheEntryKey = version ?? label // Check cache first - const cached = this.getPromptCache(name)?.get(version) + const cached = this.getPromptCache(name)?.get(cacheEntryKey) const now = Date.now() if (cached) { @@ -170,17 +190,17 @@ export class Prompts { // Try to fetch from API try { - const fetched = await this.fetchPromptFromApi(name, version) + const fetched = await this.fetchPromptFromApi(name, version, label) // Update cache - this.getOrCreatePromptCache(name).set(version, { ...fetched, fetchedAt: Date.now() }) + this.getOrCreatePromptCache(name).set(cacheEntryKey, { ...fetched, fetchedAt: Date.now() }) return { source: 'api', ...fetched } } catch (error) { // Return stale cache (with warning) if (cached) { const { fetchedAt: _, ...cachedResult } = cached - console.warn(`[PostHog Prompts] Failed to fetch prompt ${promptLabel}, using stale cache:`, error) + console.warn(`[PostHog Prompts] Failed to fetch prompt ${promptReference}, using stale cache:`, error) return { source: 'stale_cache', ...cachedResult } } @@ -237,7 +257,11 @@ export class Prompts { } } - private async fetchPromptFromApi(name: string, version?: number): Promise> { + private async fetchPromptFromApi( + name: string, + version?: number, + label?: string + ): Promise> { if (!this.personalApiKey) { throw new Error( '[PostHog Prompts] personalApiKey is required to fetch prompts. ' + @@ -254,8 +278,9 @@ export class Prompts { const encodedPromptName = encodeURIComponent(name) const encodedProjectApiKey = encodeURIComponent(this.projectApiKey) 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 labelQuery = label === undefined ? '' : `&label=${encodeURIComponent(label)}` + const promptReference = this.getPromptReference(name, version, label) + const url = `${this.host}/api/environments/@current/llm_prompts/name/${encodedPromptName}/?token=${encodedProjectApiKey}${versionQuery}${labelQuery}` const response = await fetch(url, { method: 'GET', @@ -266,25 +291,25 @@ export class Prompts { if (!response.ok) { if (response.status === 404) { - throw new Error(`[PostHog Prompts] Prompt ${promptLabel} not found`) + throw new Error(`[PostHog Prompts] Prompt ${promptReference} not found`) } if (response.status === 403) { throw new Error( - `[PostHog Prompts] Access denied for prompt ${promptLabel}. ` + + `[PostHog Prompts] Access denied for prompt ${promptReference}. ` + 'Check that your personalApiKey has the correct permissions and the LLM prompts feature is enabled.' ) } - throw new Error(`[PostHog Prompts] Failed to fetch prompt ${promptLabel}: HTTP ${response.status}`) + throw new Error(`[PostHog Prompts] Failed to fetch prompt ${promptReference}: HTTP ${response.status}`) } const data: unknown = await response.json() if (!isPromptApiResponse(data)) { - throw new Error(`[PostHog Prompts] Invalid response format for prompt ${promptLabel}`) + throw new Error(`[PostHog Prompts] Invalid response format for prompt ${promptReference}`) } - return { prompt: data.prompt, name: data.name, version: data.version } + return { prompt: data.prompt, name: data.name, version: data.version, label: data.label } } } diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index f418a8a61b..43f3d2b7ba 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -105,7 +105,10 @@ export interface TokenUsage { export interface GetPromptOptions { cacheTtlSeconds?: number fallback?: string + /** Specific prompt version to fetch. Mutually exclusive with label. */ version?: number + /** Fetch the version this label currently points to, e.g. 'production'. Mutually exclusive with version. */ + label?: string } /** @@ -115,6 +118,7 @@ export interface CachedPrompt { prompt: string name: string version: number + label?: string fetchedAt: number } @@ -126,6 +130,8 @@ export interface PromptApiResponse { name: string prompt: string version: number + /** Present when the prompt was fetched by label. */ + label?: string created_by: string created_at: string updated_at: string @@ -140,6 +146,8 @@ export interface PromptRemoteResult { prompt: string name: string version: number + /** The label the prompt was fetched by, when fetching with the label option. */ + label?: string } /** @@ -152,6 +160,7 @@ export interface PromptCodeFallbackResult { prompt: string name: undefined version: undefined + label: undefined } /** diff --git a/packages/ai/tests/prompts.test.ts b/packages/ai/tests/prompts.test.ts index 97950079a1..58d81934fb 100644 --- a/packages/ai/tests/prompts.test.ts +++ b/packages/ai/tests/prompts.test.ts @@ -119,6 +119,74 @@ describe('Prompts', () => { ) }) + it('should fetch by label and surface label metadata', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => + Promise.resolve({ ...mockPromptResponse, version: 3, prompt: 'Production prompt', label: 'production' }), + }) + + const posthog = createMockPostHog() + const prompts = new Prompts({ posthog }) + + const result = await prompts.get('test-prompt', { label: 'production' }) + + expect(result.prompt).toBe('Production prompt') + expect(result.version).toBe(3) + expect(result.label).toBe('production') + expect(mockFetch).toHaveBeenCalledWith( + 'https://us.posthog.com/api/environments/@current/llm_prompts/name/test-prompt/?token=phc_test_key&label=production', + { + method: 'GET', + headers: { + Authorization: 'Bearer phx_test_key', + }, + } + ) + }) + + it('should reject version and label together', async () => { + const prompts = new Prompts({ posthog: createMockPostHog() }) + + await expect(prompts.get('test-prompt', { version: 1, label: 'production' })).rejects.toThrow( + 'either version or label' + ) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('should keep labeled and latest prompt caches separate', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ ...mockPromptResponse, version: 4, prompt: 'Latest prompt' }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => + Promise.resolve({ ...mockPromptResponse, version: 2, prompt: 'Production prompt', label: 'production' }), + }) + + const posthog = createMockPostHog() + const prompts = new Prompts({ posthog }) + + // A labeled fetch after a latest fetch must not be served from the + // latest cache entry — that would silently return the wrong version. + await expect(prompts.get('test-prompt')).resolves.toHaveProperty('prompt', 'Latest prompt') + await expect(prompts.get('test-prompt', { label: 'production' })).resolves.toHaveProperty( + 'prompt', + 'Production prompt' + ) + await expect(prompts.get('test-prompt')).resolves.toHaveProperty('prompt', 'Latest prompt') + await expect(prompts.get('test-prompt', { label: 'production' })).resolves.toHaveProperty( + 'prompt', + 'Production prompt' + ) + expect(mockFetch).toHaveBeenCalledTimes(2) + }) + it('should return cached prompt when fresh', async () => { mockFetch.mockResolvedValueOnce({ ok: true, From 6c4e6a21a3258630c5c54a87e23a1f73629061ae Mon Sep 17 00:00:00 2001 From: Juraj Majerik Date: Thu, 16 Jul 2026 14:57:00 +0200 Subject: [PATCH 2/3] feat(ai): validate label type in prompt api response guard --- packages/ai/src/prompts.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/ai/src/prompts.ts b/packages/ai/src/prompts.ts index b4b75cf84b..e0838f17a0 100644 --- a/packages/ai/src/prompts.ts +++ b/packages/ai/src/prompts.ts @@ -32,7 +32,12 @@ function isPromptApiResponse(data: unknown): data is PromptApiResponse { return false } const record = data as Record - return typeof record.prompt === 'string' && typeof record.name === 'string' && typeof record.version === 'number' + return ( + typeof record.prompt === 'string' && + typeof record.name === 'string' && + typeof record.version === 'number' && + (record.label === undefined || typeof record.label === 'string') + ) } export interface PromptsWithPostHogOptions { From b96fafa6958804d4b405d1eb97c82c8fbc025d51 Mon Sep 17 00:00:00 2001 From: Juraj Majerik Date: Fri, 17 Jul 2026 11:43:38 +0200 Subject: [PATCH 3/3] feat(ai): warn when server does not resolve requested label, add changeset --- .changeset/prompts-fetch-by-label.md | 5 +++++ packages/ai/src/prompts.ts | 10 ++++++++++ packages/ai/tests/prompts.test.ts | 15 +++++++++++++++ 3 files changed, 30 insertions(+) create mode 100644 .changeset/prompts-fetch-by-label.md diff --git a/.changeset/prompts-fetch-by-label.md b/.changeset/prompts-fetch-by-label.md new file mode 100644 index 0000000000..3bbdf73f4f --- /dev/null +++ b/.changeset/prompts-fetch-by-label.md @@ -0,0 +1,5 @@ +--- +'@posthog/ai': minor +--- + +feat(ai): add a `label` option to `Prompts.get()` to fetch the prompt version a label (e.g. `production`) currently points to. Labeled fetches are cached separately, results carry the resolved `label`, and a warning is logged when the server does not resolve the requested label (older PostHog versions ignore the parameter and return the latest version). diff --git a/packages/ai/src/prompts.ts b/packages/ai/src/prompts.ts index e0838f17a0..04cd78228f 100644 --- a/packages/ai/src/prompts.ts +++ b/packages/ai/src/prompts.ts @@ -197,6 +197,16 @@ export class Prompts { try { const fetched = await this.fetchPromptFromApi(name, version, label) + // An older PostHog server ignores the label param and returns the latest + // version with no label field — surface that instead of failing silently. + if (label !== undefined && fetched.label !== label) { + console.warn( + `[PostHog Prompts] Requested label "${label}" for prompt "${name}" but the server resolved ` + + `${fetched.label === undefined ? 'no label' : `"${fetched.label}"`}. It may not support prompt ` + + 'labels yet and returned the latest version instead.' + ) + } + // Update cache this.getOrCreatePromptCache(name).set(cacheEntryKey, { ...fetched, fetchedAt: Date.now() }) diff --git a/packages/ai/tests/prompts.test.ts b/packages/ai/tests/prompts.test.ts index 58d81934fb..5ce15b0f81 100644 --- a/packages/ai/tests/prompts.test.ts +++ b/packages/ai/tests/prompts.test.ts @@ -146,6 +146,21 @@ describe('Prompts', () => { ) }) + it('should warn when the server does not resolve the requested label', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve(mockPromptResponse), // no label field — old-server behavior + }) + + const prompts = new Prompts({ posthog: createMockPostHog() }) + const result = await prompts.get('test-prompt', { label: 'production' }) + + expect(result.prompt).toBe(mockPromptResponse.prompt) + expect(result.label).toBeUndefined() + expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('may not support prompt labels')) + }) + it('should reject version and label together', async () => { const prompts = new Prompts({ posthog: createMockPostHog() })