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

`prompts.getAll()` now works without a label. It fetches the latest version of every prompt in one request and warms the cache for plain `prompts.get(name)` calls. Previously the label was required by the method's type, and the server treats any label value as a filter, so there was no way to batch-fetch unlabeled prompts.
55 changes: 38 additions & 17 deletions packages/ai/src/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,11 @@ function isSameOrigin(url: string, host: string): boolean {
}
}

/** Formats a batch-fetch reference for logs and errors. */
function promptListReference(label: string | undefined): string {
return label === undefined ? 'all prompts' : `prompts with label "${label}"`
}

/**
* Classify how a list row relates to the requested label, via its all_labels
* field.
Expand Down Expand Up @@ -163,6 +168,9 @@ function isPromptsWithPostHog(options: PromptsOptions): options is PromptsWithPo
* // Or fetch all prompts at a label in one request and warm the cache
* const prodPrompts = await prompts.getAll({ label: 'production' })
*
* // Or fetch the latest version of every prompt in one request
* const allPrompts = await prompts.getAll()
*
* // Compile with variables
* const systemPrompt = prompts.compile(result.prompt, {
* company: 'Acme Corp',
Expand Down Expand Up @@ -254,35 +262,42 @@ export class Prompts {
}

/**
* Fetch every prompt that carries a label, in one batch.
* Fetch every prompt in one batch.
*
* Returns an object mapping prompt name to `PromptRemoteResult`, with each
* prompt at the version the label points to. Prompts without the label are
* not included.
* Returns an object mapping prompt name to `PromptRemoteResult`. With a
* label, each prompt is at the version the label points to, and prompts
* without the label are not included. Without a label, every prompt is
* included at its latest version, matching what `get(name)` returns.
*
* Each fetched prompt is stored in the cache, so later
* `get(name, { label })` calls are served from cache within the TTL. An app
* with many prompts can call this once per cache cycle instead of making one
* `get()` request per prompt.
* `get(name, { label })` (or plain `get(name)`) calls are served from cache
* within the TTL. An app with many prompts can call this once per cache
* cycle instead of making one `get()` request per prompt.
*
* Unlike `get()`, there is no `fallback` option, so callers should handle
* the error themselves.
*
* @throws {Error} When the request fails, when the response is malformed,
* or when the server does not support fetching prompts by label on the list
* endpoint (PostHog releases from before September 2026).
* or when a label was passed and the server does not support fetching
* prompts by label on the list endpoint (PostHog releases from before
* September 2026).
*/
async getAll(options: { label: string }): Promise<Record<string, PromptRemoteResult>> {
const label = options.label
async getAll(options?: { label?: string }): Promise<Record<string, PromptRemoteResult>> {
const label = options?.label
const rows = await this.fetchPromptListFromApi(label)
const reference = promptListReference(label)

// Validate every row before caching any, so a rejected batch leaves the
// cache untouched.
const resolvedRows: PromptApiResponse[] = []
const skipped: string[] = []
for (const row of rows) {
if (!isPromptApiResponse(row)) {
throw new Error(`[PostHog Prompts] Invalid response format for prompts with label "${label}"`)
throw new Error(`[PostHog Prompts] Invalid response format for ${reference}`)
}
if (label === undefined) {
resolvedRows.push(row)
continue
}
const labelState = rowLabelState(row, label)
if (labelState === 'absent') {
Expand All @@ -303,7 +318,7 @@ export class Prompts {
resolvedRows.push(row)
}

if (rows.length > 0 && resolvedRows.length === 0) {
if (label !== undefined && rows.length > 0 && resolvedRows.length === 0) {
// Every returned row was skipped as moved. One moved label is a
// mid-request race, but all of them means the server most likely
// ignored the label param and served latest versions.
Expand Down Expand Up @@ -480,15 +495,21 @@ export class Prompts {
}

/**
* Fetch all prompts at a label from the paginated list endpoint.
* Fetch all prompts from the paginated list endpoint.
*
* Without a label the endpoint serves the latest version of every prompt;
* the param is then omitted entirely, since the server treats any value as
* a label name to filter by.
*
* Follows pagination links until the last page and returns the raw rows.
*/
private async fetchPromptListFromApi(label: string): Promise<unknown[]> {
private async fetchPromptListFromApi(label: string | undefined): Promise<unknown[]> {
this.requireCredentials()

const query = `token=${encodeURIComponent(this.projectApiKey)}&label=${encodeURIComponent(label)}&content=full`
const labelQuery = label === undefined ? '' : `&label=${encodeURIComponent(label)}`
const query = `token=${encodeURIComponent(this.projectApiKey)}${labelQuery}&content=full`
let url: string | undefined = `${this.host}/api/environments/@current/llm_prompts/?${query}`
const reference = `prompts with label "${label}"`
const reference = promptListReference(label)

const rows: unknown[] = []
let pages = 0
Expand Down
29 changes: 29 additions & 0 deletions packages/ai/tests/prompts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1330,6 +1330,35 @@ describe('Prompts', () => {
expect(cached.version).toBe(3)
})

it('omits the label param on an unlabeled fetch and seeds the cache', async () => {
// Without a label the param must be left off the URL entirely, since
// the server treats any value as a label name to filter by. Rows
// without any labels must be accepted, since no label was requested.
const unlabeled = { ...labeledRow('prompt-a', 2), all_labels: [] }
mockFetch.mockResolvedValueOnce(listResponse([unlabeled]))

const prompts = new Prompts({ posthog: createMockPostHog() })
const results = await prompts.getAll()

expect(mockFetch.mock.calls[0][0]).not.toContain('label')
expect(results).toEqual({
'prompt-a': {
source: 'api',
prompt: 'Prompt for prompt-a',
name: 'prompt-a',
version: 2,
label: undefined,
config: null,
},
})

// Later unlabeled get() calls are cache hits, not new requests.
const cached = await prompts.get('prompt-a')
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(cached.source).toBe('cache')
expect(cached.version).toBe(2)
})

it('throws when the server ignores the label', async () => {
// An old server ignores ?label= and returns latest versions of every
// prompt, including prompts without the label. Even when some labels
Expand Down