diff --git a/.sampo/changesets/prompts-get-all-without-label.md b/.sampo/changesets/prompts-get-all-without-label.md new file mode 100644 index 000000000..a523038c9 --- /dev/null +++ b/.sampo/changesets/prompts-get-all-without-label.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: minor +--- + +`Prompts.get_all()` now works without a label: it fetches the latest version of every prompt in one request and caches each one under the key `get(name)` reads. Apps that do not use labels no longer need one request per prompt per cache cycle. diff --git a/posthog/ai/prompts.py b/posthog/ai/prompts.py index 531477154..f9e4e96c7 100644 --- a/posthog/ai/prompts.py +++ b/posthog/ai/prompts.py @@ -96,6 +96,13 @@ def _prompt_reference( return reference +def _prompt_list_reference(label: Optional[str]) -> str: + """Format a prompt list reference for logs and errors.""" + if label is None: + return "all prompts" + return f'prompts with label "{label}"' + + def _extract_config(data: Dict[str, Any]) -> Optional[Dict[str, Any]]: """Read config from an API response, tolerating servers that don't send it.""" config = data.get("config") @@ -201,6 +208,9 @@ class Prompts: # Fetch all prompts at a label in one request and warm the cache prod_prompts = prompts.get_all(label='production') + # Or fetch the latest version of every prompt in one request + all_prompts = prompts.get_all() + # Compile with variables system_prompt = prompts.compile(template, { 'company': 'Acme Corp', @@ -361,29 +371,30 @@ def get( return fallback raise - def get_all(self, *, label: str) -> Dict[str, PromptResult]: + def get_all(self, *, label: Optional[str] = None) -> Dict[str, PromptResult]: """ - Fetch every prompt that carries a label, in one batch. + Fetch every prompt in one batch. - Returns a dict mapping prompt name to :class:`PromptResult`, with each - prompt at the version the label points to. Prompts without the label - are not included. + With ``label``, each prompt comes back at the version the label points + to, and prompts without the label are not included. Without ``label``, + every prompt comes back at its latest version. - Each fetched prompt is stored in the cache, so later - ``get(name, label=...)`` calls are served from cache within the TTL. + Each fetched prompt is stored in the cache under the key the matching + ``get()`` call reads, so that call is 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. Args: - label: The label to resolve, e.g. 'production'. + label: The label to resolve, e.g. 'production'. Omit it to fetch + the latest version of every prompt. Returns: Dict of prompt name to PromptResult. Raises: - Exception: If the request fails, or the server does not support - fetching prompts by label on the list endpoint (PostHog - releases from before September 2026). + Exception: If the request fails, or a label was requested and the + server does not support fetching prompts by label on the list + endpoint (PostHog releases from before September 2026). """ try: rows = self._fetch_prompt_list_from_api(label) @@ -398,34 +409,39 @@ def get_all(self, *, label: str) -> Dict[str, PromptResult]: for row in rows: if not _is_prompt_api_response(row): invalid_error = Exception( - f'[PostHog Prompts] Invalid response format for prompts with label "{label}"' + "[PostHog Prompts] Invalid response format for " + f"{_prompt_list_reference(label)}" ) self._maybe_capture_error( invalid_error, name="*", version=None, label=label ) raise invalid_error - label_state = _row_label_state(row, label) - if label_state == "absent": - # Even one unlabeled row proves the server did not filter, and - # then rows that look resolved are only labels that happen to - # point at the latest version. A partial result here would hide - # the rest, so fail loudly instead. - compat_error = Exception( - f'[PostHog Prompts] The server returned a prompt that does not carry label "{label}". ' - "It may not support fetching prompts by label on the list endpoint yet. " - "Upgrade PostHog, or fetch prompts one by one with get()." - ) - self._maybe_capture_error( - compat_error, name="*", version=None, label=label - ) - raise compat_error - if label_state == "moved": - skipped.append(row["name"]) - continue + # Without a label the endpoint returns latest versions, so there + # is no label to resolve and no old-server behavior to detect. + if label is not None: + label_state = _row_label_state(row, label) + if label_state == "absent": + # Even one unlabeled row proves the server did not filter, + # and then rows that look resolved are only labels that + # happen to point at the latest version. A partial result + # here would hide the rest, so fail loudly instead. + compat_error = Exception( + f'[PostHog Prompts] The server returned a prompt that does not carry label "{label}". ' + "It may not support fetching prompts by label on the list endpoint yet. " + "Upgrade PostHog, or fetch prompts one by one with get()." + ) + self._maybe_capture_error( + compat_error, name="*", version=None, label=label + ) + raise compat_error + if label_state == "moved": + skipped.append(row["name"]) + continue + resolved_rows.append(row) - if rows and not resolved_rows: + if label is not None and rows and not resolved_rows: # 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. @@ -655,26 +671,31 @@ def _require_credentials(self) -> None: "Please provide it when initializing the Prompts instance." ) - def _fetch_prompt_list_from_api(self, label: str) -> List[Dict[str, Any]]: + def _fetch_prompt_list_from_api( + self, label: Optional[str] = None + ) -> List[Dict[str, Any]]: """ - Fetch all prompts at a label from the paginated list endpoint. + Fetch prompts from the paginated list endpoint. Endpoint: {host}/api/environments/@current/llm_prompts/ - ?token={encoded_project_api_key}&label={label}&content=full + ?token={encoded_project_api_key}[&label={label}]&content=full Auth: Bearer {personal_api_key} - Follows pagination links until the last page. Returns the raw rows. + Without a label the endpoint returns the latest version of every + prompt. Follows pagination links until the last page. Returns the raw + rows. """ self._require_credentials() - query = urllib.parse.urlencode( - {"token": self._project_api_key, "label": label, "content": "full"} - ) + params = {"token": self._project_api_key, "content": "full"} + if label is not None: + params["label"] = label + query = urllib.parse.urlencode(params) url: Optional[str] = ( f"{self._host}/api/environments/@current/llm_prompts/?{query}" ) - reference = f'prompts with label "{label}"' + reference = _prompt_list_reference(label) headers = { "Authorization": f"Bearer {self._personal_api_key}", "User-Agent": USER_AGENT, diff --git a/posthog/test/ai/test_prompts.py b/posthog/test/ai/test_prompts.py index ba682dad0..7637be581 100644 --- a/posthog/test/ai/test_prompts.py +++ b/posthog/test/ai/test_prompts.py @@ -1426,6 +1426,62 @@ def test_fetches_all_pages_and_seeds_the_cache(self, mock_get_session): self.assertEqual(cached.source, "cache") self.assertEqual(cached.version, 3) + @patch("posthog.ai.prompts._get_session") + def test_without_a_label_returns_latest_versions_and_seeds_the_cache( + self, mock_get_session + ): + # No label means no label to resolve: a prompt that carries no label, + # and one whose label points at an earlier version, both belong in the + # result at their latest version. + mock_get = mock_get_session.return_value.get + unlabeled = {**self.labeled_row("prompt-a", version=2), "all_labels": []} + labeled_elsewhere = { + **self.labeled_row("prompt-b", version=3), + "all_labels": [{"name": "production", "version": 1}], + } + mock_get.return_value = self.list_response([unlabeled, labeled_elsewhere]) + + prompts = Prompts(self.create_mock_posthog()) + results = prompts.get_all() + + self.assertNotIn("label", mock_get.call_args.args[0]) + self.assertEqual( + results, + { + "prompt-a": PromptResult( + source="api", + prompt="Prompt for prompt-a", + name="prompt-a", + version=2, + ), + "prompt-b": PromptResult( + source="api", + prompt="Prompt for prompt-b", + name="prompt-b", + version=3, + ), + }, + ) + + # Later unlabeled get() calls are cache hits, not new requests. + cached = prompts.get("prompt-a", with_metadata=True) + self.assertEqual(mock_get.call_count, 1) + self.assertEqual(cached.source, "cache") + self.assertEqual(cached.version, 2) + self.assertIsNone(cached.label) + + @patch("posthog.ai.prompts._get_session") + def test_without_a_label_names_all_prompts_in_an_error(self, mock_get_session): + mock_get = mock_get_session.return_value.get + malformed = {**self.labeled_row("prompt-a"), "version": "1"} + mock_get.return_value = self.list_response([malformed]) + + prompts = Prompts(self.create_mock_posthog()) + + with self.assertRaises(Exception) as ctx: + prompts.get_all() + self.assertIn("Invalid response format for all prompts", str(ctx.exception)) + @patch("posthog.ai.prompts._get_session") def test_raises_when_the_server_ignores_the_label(self, mock_get_session): # An old server ignores ?label= and returns latest versions of every diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 36b28e434..6599ea487 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -1348,7 +1348,7 @@ method posthog.ai.otel.processor.PostHogSpanProcessor.shutdown() -> None method posthog.ai.prompts.Prompts.clear_cache(name: Optional[str] = None, *, version: Optional[int] = None) -> None method posthog.ai.prompts.Prompts.compile(prompt: str, variables: PromptVariables) -> str method posthog.ai.prompts.Prompts.get(name: str, *, with_metadata: Optional[bool] = None, cache_ttl_seconds: Optional[int] = None, fallback: Optional[str] = None, version: Optional[int] = None, label: Optional[str] = None) -> Union[str, PromptResult] -method posthog.ai.prompts.Prompts.get_all(*, label: str) -> Dict[str, PromptResult] +method posthog.ai.prompts.Prompts.get_all(*, label: Optional[str] = None) -> Dict[str, PromptResult] method posthog.ai.stream.AsyncStreamWrapper.aclose() -> None method posthog.ai.stream.AsyncStreamWrapper.close() -> None method posthog.async_client.AsyncClient.alias(previous_id: ID_TYPES, distinct_id: Optional[str], timestamp: Optional[Union[datetime, str]] = None, uuid: Optional[str] = None, disable_geoip: Optional[bool] = None) -> Optional[str]