You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Can a Django endpoint authenticated with a team's project secret API key reach the AI gateway, and what quota and throttle gates should a workflow-triggered generation carry?
Establish as fact:
Whether posthog/llm/gateway_client.py's builders (build_openai_client, build_async_openai_client) can be called from a request handler authenticated by a project secret API key (phs_), the credential the six existing CDP async functions already send (nodejs/src/cdp/async-functions/conversations.ts). Note that the gateway's own auth is a separate credential from the caller's — confirm how the two relate and where the gateway credential comes from in a web process.
Which product and ai_product identifiers exist, which one a workflow generation should declare, and what that choice controls (the gateway owns $ai_billable from its product config).
What attribution metadata the Go gateway requires: X-PostHog-Properties, X-PostHog-Trace-Id, X-PostHog-Distinct-Id.
The quota gate. ee/billing/quota_limiting.py exposes QuotaResource.AI_CREDITS and is_team_limited. Establish whether a workflow generation should check it, and what a limited team should see.
The throttle precedent. products/ai_observability/backend/api/proxy.py requires organization.customer_id (402 otherwise), enforces a model allowlist when PostHog pays (403), and selects among six throttle classes. Establish which of those apply to a server-side workflow call, where there is no interactive user to show an error to.
Whether project secret API key auth is the right credential at all, or whether a different auth path fits better. .agents/skills/adding-project-secret-api-key-auth/ documents the pattern.
Report findings with file:line references. This ticket establishes facts; the endpoint's shape is decided in the ticket it blocks.
Facts only. Every claim has a file:line. Where the repo does not answer a question, this says so.
Read this first: one premise in the ticket is wrong, and it changes two of the six bullets. The six CDP async functions do not send a project secret API key. See section 0.
0. Correction: the CDP async functions send Team.secret_api_token, not a PSAK
The ticket says the phs_ credential the six existing async functions send is "the team's project secret API key". It is not. It is the legacy per-team secret_api_token column.
nodejs/src/cdp/async-functions/conversations.ts:21-29 reads team.secret_api_token and sends Authorization: Bearer <token>.
The token comes from context.teamManager.getTeam(...) (conversations.ts:17). TeamManager loads t.secret_api_token straight from the posthog_team row in its cache query, nodejs/src/common/utils/team-manager.ts:126, typed at nodejs/src/types.ts:277.
The column is Team.secret_api_token, posthog/models/team/team.py:347.
Both credentials share the phs_ prefix, which is why they look identical. posthog/models/utils.py:166 says so in as many words: SECRET_API_TOKEN_PREFIX = "phs_" # "s" standing for "secret"; team secret tokens and project secret API keys.
The receiving Django views do not use ProjectSecretAPIKeyAuthentication either. They opt out of DRF auth completely and hand-roll it:
products/conversations/backend/api/external.py:59-79 — _authenticate_team strips Bearer , then Team.objects.get(Q(secret_api_token=...) | Q(secret_api_token_backup=...), conversations_enabled=True). No scope check.
Same pattern in products/customer_analytics/backend/presentation/views/external.py:327-328 and :141-163.
Two further corrections of detail:
The six functions are split across two files. Two are in conversations.ts (postHogGetTicket at :8, postHogUpdateTicket at :77). Four are in nodejs/src/cdp/async-functions/customer_analytics.ts (postHogGetAccount:29, postHogUpdateAccount:88, postHogSetAccountProperties:138, postHogCreateAccount:188).
The transport is Cyclotron fetch, as the map assumes. Each function only sets result.invocation.queueParameters to a parsed CyclotronInvocationQueueParametersFetchSchema (conversations.ts:25). The async loop at nodejs/src/cdp/services/hog-executor-async.service.ts:102-131 then dispatches it, and executeFetch (:282) makes the HTTP call with retry and backoff.
The CDP runtime has no PSAK. Nothing in nodejs/ loads a ProjectSecretAPIKey row. To send one from an async function, a new field must be added to the TeamManager query (nodejs/src/common/utils/team-manager.ts:118-140) and to RawTeam/Team in nodejs/src/types.ts.
1. Can the builders be called from a request handler authenticated by a project secret API key?
Yes. Nothing in the builders reads the request, the user, or the caller's credential.
build_openai_client (posthog/llm/gateway_client.py:256-274) and build_async_openai_client (:277-288) take two arguments, product and ai_product. They read only Django settings. They have no dependency on request context, so they work in a web worker, a Celery task, or a Temporal activity without change.
There is a direct precedent for a synchronous gateway call inside a web request handler, on a viewset that also declares PSAK auth:
products/endpoints/backend/presentation/views/api.py:713 — the materialization_suggestion action calls suggest_materialization_fix(...) inline.
products/endpoints/backend/logic/ai_materialization_fix.py:304 — that function calls get_llm_client(product=..., team_id=team_id) and blocks the worker until the model replies.
Note the precedent's action is not in psak_allowed_actions, so today it is session/personal-key only. The point it proves is mechanical: the same class can carry both a PSAK-allowed action and a gateway-calling action. No code today combines the two.
Gates the precedent applies, for reference (products/endpoints/backend/presentation/views/api.py:681-728): feature flag off gives 404; self.team.organization.is_ai_data_processing_approved is not True gives 403; APIConnectionError gives 503; any other exception gives 502. There is also a wall-clock budget in the logic layer (ai_materialization_fix.py:54-58). There is no AI-credits check and no bespoke throttle.
The two credentials and how they relate
They are unrelated. The caller's credential terminates at Django. The gateway credential is a process-level secret read from Django settings.
Go path: resolve_ai_gateway_config() (gateway_client.py:182-197) returns (AI_GATEWAY_URL, AI_GATEWAY_API_KEY) from posthog/settings/web.py:1220-1221, or None. None when neither var is set, and also when the pair is half-applied or the URL lacks a /v1 path — that logs ai_gateway_misconfigured_falling_back and falls back rather than failing the call (:186-196).
Python fallback path: get_llm_client uses settings.LLM_GATEWAY_URL and settings.LLM_GATEWAY_API_KEY (gateway_client.py:98-108). Both are defined in ee/settings.py:98-99. LLM_GATEWAY_API_KEY reads the env var LLM_GATEWAY_PERSONAL_API_KEY, so on this path the gateway credential is a personal API key (phx_).
On the Go path the gateway credential is itself a phs_ secret. services/llm-gateway/PARITY.md:63 states Go accepts "phs_ project secrets and pha_ OAuth credentials with the literal llm_gateway:read scope; credential resolves team and revocation state." So the outbound phs_ belongs to whichever team funds the traffic, not to the customer team that triggered it.
The Go gateway derives the owning team from its own bearer, not from anything the caller sends. gateway_client.py:261 states it plainly, and :306-307 repeats it: team_id "does not change the event's owning project, which the gateway derives from the phs_ bearer."
Which gateway serves any given call is a runtime fact, decided by whether AI_GATEWAY_URL/AI_GATEWAY_API_KEY are set. No in-repo config pins it.
One gap worth naming
Neither OpenAI builder forwards team_id. build_openai_client and build_async_openai_client set default_headers=ai_product_headers(ai_product) and nothing else (gateway_client.py:271, :285). Only build_async_anthropic_client carries team_id, ai_stage, and the trace id (:319-329). An OpenAI-shaped workflow call that needs per-team attribution has to pass headers per call, or the builder needs a new argument.
2. product and ai_product
These are two different identifiers with different types, different owners, and different effects.
product — closed literal, selects the Python gateway route
The comment at :40 requires any new value to be added to services/llm-gateway/src/llm_gateway/products/config.py too.
It builds the Python gateway URL path, gateway_client.py:102: f"{LLM_GATEWAY_URL}/{product}/v1".
The Go gateway is slugless and ignores it (gateway_client.py:261).
What the Python gateway's ProductConfig controls (services/llm-gateway/src/llm_gateway/products/config.py:28-50):
Field
Effect
allowed_application_ids
which OAuth apps may use the route; empty set (default) or None means none authorized
allowed_models
model allowlist; None means all
exact_model_match
exact match instead of prefix match
allow_api_keys
whether API-key auth is accepted
credit_bucket
which customer credit bucket the generations bill into
requires_server_credential
OAuth callers must present a server-minted internal_run:read token
credit_bucket is the $ai_billable owner the ticket refers to. config.py:37-42: None means the emitted $ai_generation events are tagged $ai_billable=false and the usage reporter ignores them. A bucket value tags them billable and blocks every caller of the product when the bucket is exhausted. The enum has two members, AI_CREDITS = "ai_credits" and POSTHOG_CODE_CREDITS = "posthog_code_credits" (config.py:24-25), and the docstring at :14-22 says the values match the Django QuotaResource keys on purpose.
Only four products carry a bucket: posthog_code (config.py:136), slack_app (:187), slack_app_routing (:220), posthog_ai (:326). Every other product is credit_bucket=None, including conversations (:305), signals (:256), web_analytics (:332), and warehouse_semantic_enrichment (:312).
ai_product — free-form string, an event property
ai_product_headers(ai_product: str | None) at gateway_client.py:214-220. The type is str | None. There is no enum and no closed set. Existing values are written inline at each call site: signals_safety, signals_grouping, signals_eval, aio_summarization, aio_eval_summary, aio_clustering, aio_eval_reports, aio_stamphog, signals_scout, alert_investigation_agent.
It rides inside the X-PostHog-Properties JSON blob (gateway_client.py:200-211, :220).
PARITY.md:40 states it is telemetry only: "An ai_product event property is not an authorization or billing boundary." .agents/skills/migrating-llm-gateway-callers/SKILL.md:47 repeats it.
What ai_product does control
Billing rollup in the usage report, which is a separate mechanism from the gateway's $ai_billable flag.
posthog/tasks/usage_report.py:1609-1617 — POSTHOG_AI_PRODUCTS = ["posthog_ai", "slack_app", "subscriptions", "alert_investigation_agent", "product_analytics", "surveys", "replay_vision"], with the comment "ai_product values that roll into PostHog AI (Max) billing credits."
The query filters on both: ai_billable = 1 (usage_report.py:1799) andai_product IN %(ai_products)s (:1796).
So a generation is billed to AI credits only when the gateway product config marks it billable and its ai_product string appears in POSTHOG_AI_PRODUCTS. A value not in that list is not billed, whatever the gateway says.
Which one a workflow generation should declare
The ticket asks for facts, so: there is no workflows value in either identifier today. A new product value would need entries in gateway_client.py:15-40 and in services/llm-gateway/.../products/config.py. A new ai_product value needs no registration at all, but stays unbilled unless added to POSTHOG_AI_PRODUCTS in usage_report.py:1609. The choice between reusing an existing product and adding one belongs to the deciding ticket.
3. Attribution metadata the Go gateway requires
None of the three are strictly required. Each has a documented fallback. They are required only to get attribution that is worth having.
X-PostHog-Properties
Built by _ai_property_headers(**labels) at gateway_client.py:200-211. One JSON object. Unset labels are dropped; the function returns None when nothing is set, so no header is sent.
The docstring at :203-205 states the constraint: the Go gateway reads labels only from this blob, not from the x-posthog-property-<key> per-header form the Python gateway accepts. Keys must not carry an $ai_ prefix, because the gateway strips those as reserved.
PARITY.md:74 lists the same split: Go takes "One X-PostHog-Properties JSON object plus dedicated distinct ID, trace ID, and provider headers"; X-POSTHOG-PROPERTY-* and X-POSTHOG-FLAG-* are Python-only.
PARITY.md:54: "Caller-supplied properties are not trusted policy."
X-PostHog-Trace-Id
_ai_trace_headers(team_id) at gateway_client.py:239-242, from team_trace_id(team_id) at :228-236.
The id is deterministic per team: uuid5(_TEAM_TRACE_ID_NAMESPACE, f"team-{team_id}"). The namespace UUID is at :225, with the comment that it mirrors services/llm-gateway/src/llm_gateway/callbacks/posthog.py and both must stay equal or a team's trace id changes with whichever gateway serves it.
Without the header, Go stamps a fresh id per request, so every generation sits in its own trace (:231-233).
Grouping is per team, not per pipeline run (:233). A workflow run that wants its own trace cannot get one from this helper.
X-PostHog-Distinct-Id
Named only in services/llm-gateway/PARITY.md:54 and :65. :65: "Distinct ID comes from X-PostHog-Distinct-Id or falls back to team ID."
PARITY.md:54 adds the trap: "Go does not derive the event distinct ID from OpenAI user, Anthropic metadata.user_id, or the OAuth user." So the user= argument that works on the Python gateway (gateway_client.py:73-74) is dropped by Go.
There is no helper for this header in posthog/llm/gateway_client.py, and no caller in this repo sets it. Grep across *.py/*.ts/*.md finds it only in PARITY.md and in unrelated frontend/proxy CORS code (frontend/src/lib/api.ts:2141, services/agent-proxy/src/lib/constants.ts:81).
What the builders actually send today
Builder
X-PostHog-Properties
X-PostHog-Trace-Id
X-PostHog-Distinct-Id
team_id
build_openai_client (:256)
ai_product only
no
no
no
build_async_openai_client (:277)
ai_product only
no
no
no
build_async_anthropic_client (:291)
ai_product, ai_stage, team_id
yes (:328)
no
yes
4. The quota gate
The primitives
QuotaResource.AI_CREDITS = "ai_credits" — ee/billing/quota_limiting.py:90. The comment at :77-78 says the enum values match the billing default_plans_config.yml usage keys.
No overage buffer: OVERAGE_BUFFER[QuotaResource.AI_CREDITS] = 0, ee/billing/quota_limiting.py:116.
No grace period: AI_CREDITS is in GRACE_PERIOD_EXEMPT_RESOURCES, ee/billing/quota_limiting.py:128.
is_team_limited(team_api_token: str, resource: QuotaResource, cache_key: QuotaLimitingCaches) -> bool — ee/billing/quota_limiting.py:226-228. It takes the team API token (team.api_token), not a team id.
Backing store is a Redis sorted set read through list_limited_team_attributes (:203-213), key @posthog/quota-limits/ai_credits (:100), members team_api_token -> expiry. That lookup is memoized for 30 seconds per worker process (@cache_for(timedelta(seconds=30), background_refresh=True), :202).
is_team_over_ai_credit_budget(team_api_token) -> bool at :266-270 is the shared one-line wrapper. Its docstring notes the Redis read is synchronous, so async callers must wrap it in sync_to_async.
get_fresh_team_limited_resources(team_api_token) at :231-245 is the uncached variant.
Who enforces it
No single owner. Established from the repo:
The Python gateway enforces, but only for bucketed products.services/llm-gateway/src/llm_gateway/dependencies.py:170-196 only calls the quota resolver when credit_bucket is not None; otherwise it returns QuotaResourceStatus(limited=False) and skips. The resolver (services/llm-gateway/src/llm_gateway/services/quota_resolver.py:104, :184) calls GET /api/projects/{team_id}/quota_limits/ and caches per team and resource. A denial surfaces as HTTP 429 with code: "billable_credits" (services/llm-gateway/src/llm_gateway/rate_limiting/throttles.py:59, message at billable_credits_throttle.py:8-11).
The Go gateway does not.PARITY.md:41 lists "Product or user budget enforcement" as a blocking Python-only contract: "Python supports product and user cost limits, plan checks, quota buckets, and requests that debit no wallet. Go currently admits against the credential owner's team wallet and does not branch settlement on credential billing mode." PARITY.md:66 puts "quota buckets, free tiers, unbilled products" on the Python-only side.
posthog/llm/gateway_client.py does not. No quota or credit reference anywhere in the file.
Consequence, stated as fact: on the Go path, or on the Python path with a credit_bucket=None product, nothing checks AI credits unless the Django caller does.
The Go gateway's source is not in this tree. Its behavior here is established only from services/llm-gateway/PARITY.md. No document under docs/ names an owner for the AI-credits check.
What a limited team sees, in existing callers
Two distinct precedents, split by whether a person is waiting.
Interactive DRF paths raise and return 402.
ee/api/conversation.py:454-456 — is_team_limited(self.team.api_token, QuotaResource.AI_CREDITS, QuotaLimitingCaches.QUOTA_LIMITER_CACHE_KEY) then raise QuotaLimitExceeded("Your organization reached its AI credit usage limit. Increase the limits in Billing settings, or ask an org admin to do so."). QuotaLimitExceeded is HTTP 402 with code quota_limit_exceeded (posthog/exceptions.py:26-29).
ee/api/conversation.py:708 — same check and same message on the sandbox open endpoint.
ee/api/subscription.py:563 — same, during serializer validation when a summary turns active.
products/tasks/backend/logic/services/warm.py:52 — _ai_credits_checker, registered at :78. The contract is exception-based because the caller cannot return a Response (comment at :73-76). The registry is fail-closed: an unregistered origin raises PermissionDenied (:100).
Non-interactive paths skip silently, and fail open when the lookup itself errors.
products/exports/backend/temporal/subscriptions/snapshot_activities.py:449 — drops only the AI summary, still delivers the rest; fails open on lookup error (:452-460).
products/exports/backend/temporal/subscriptions/ai_subscription/activities.py:245 — skips delivery and reschedules; fails open (:248-258). The comment at :239-244 is the clearest in-repo statement of the split: the interactive Max path enforces in ee/api/conversation.py, and "scheduled reports need their own check."
posthog/temporal/ai/slack_app/helpers.py:34-38 — block_if_team_over_quota posts a Slack denial instead of an HTTP status. Message text at products/slack_app/backend/api.py:255-258.
There is no shared decorator or middleware. Every caller writes its own check.
Callers that do not check at all, whose gateway product is also credit_bucket=None, so nothing gates them anywhere: the conversations AI-reply Temporal activities, products/warehouse_sources/.../enrich_table_semantics.py:246, products/warehouse_sources/.../custom/ai_builder.py:330, products/review_hog/.../direct_llm.py:57, products/web_analytics/.../path_cleaning_suggestions/service.py:126, products/endpoints/backend/logic/ai_materialization_fix.py:304, products/stamphog/backend/logic/digest.py:147.
A workflow generation is both shapes at once. It is a server-side call with no interactive user, but it is reached through an HTTP endpoint, so a 402 is returnable to the Cyclotron fetch and would surface through the node's existing on_error. Which of the two precedents applies is a decision, not a fact, and belongs to the ticket this one blocks.
5. The throttle precedent in products/ai_observability/backend/api/proxy.py
Read in full. It is registered on the root router, not a project-nested one: products/ai_observability/backend/routes.py:38 gives GET /api/llm_proxy/models and POST /api/llm_proxy/completion.
Gate
Where
Behavior
organization.customer_id
proxy.py:249-255
402, body {"error": "The playground requires a valid payment method on file to prevent abuse."}. A plain Response, not an exception. Guards completion only; models has no such check.
Model allowlist
proxy.py:281-288
403, body {"error": "Model '<x>' is not available on the PostHog-funded playground. Please add your own API key to use this model."}. Applies only when PostHog pays (provider_key is None). BYOK bypasses it entirely. PLAYGROUND_MODEL_IDS at products/ai_observability/backend/llm/__init__.py:56.
Throttles
proxy.py:144-169
models gets [], no throttling. completion with a resolvable provider_key_id gets the BYOK trio. Otherwise the PostHog-funded trio.
AI credits
—
None. No quota, credit, or billing reference in the file.
Auth: authentication_classes = [SessionAuthentication], permission_classes = [IsAuthenticated, PlaygroundAccessPermission] (proxy.py:140-142). The comment at proxy.py:97-98 says it deliberately declares no scope_object, because that "would imply personal API key support that SessionAuthentication doesn't allow". So no key-based credential can reach this endpoint at all, PSAK included.
An interactive user is assumed throughout: user.current_team in PlaygroundAccessPermission (proxy.py:113-116, message "Open a project before using the playground.") and in _get_provider_key (:176-178); distinct_id = request.user.email (:295); report_user_action(request.user, ...) at :307, :317, :327, :390.
It also does not use posthog/llm/gateway_client.py. It uses products.ai_observability.backend.llm.Client (proxy.py:288), so it never reaches the gateway's own BillableCreditThrottle.
The six throttle classes
All in posthog/rate_limit.py, all subclassing DRF's UserRateThrottle:
Class
Line
Scope
Rate
LLMProxyBurstRateThrottle
:672-674
llm_proxy_burst
2/minute
LLMProxySustainedRateThrottle
:677-679
llm_proxy_sustained
10/hour
LLMProxyDailyRateThrottle
:682-684
llm_proxy_daily
20/day
LLMProxyBYOKBurstRateThrottle
:687-689
llm_proxy_byok_burst
30/minute
LLMProxyBYOKSustainedRateThrottle
:692-694
llm_proxy_byok_sustained
500/hour
LLMProxyBYOKDailyRateThrottle
:697-699
llm_proxy_byok_daily
2000/day
None of them overrides get_cache_key, so all six key on request.user.pk. None keys on team or organization.
Which of these apply to a server-side workflow call
Established facts, not recommendations:
The 402 as written is unreachable under PSAK. It reads request.user.organization (proxy.py:249). A PSAK request's request.user is a ProjectSecretAPIKeyUser (posthog/auth.py:417-431), a SyntheticUser (posthog/synthetic_user.py:6) which defines no organization attribute, so the access raises AttributeError. The equivalent check that does work is team.organization.customer_id — Team.organization is an ordinary FK, and a live PSAK handler already uses it: products/customer_analytics/backend/presentation/views/external.py:559 passes organization_id=team.organization_id from the PSAK-resolved team.
All six throttles degrade to a single shared bucket under PSAK.SyntheticUser sets pk = -1 (posthog/synthetic_user.py:27) and id = None (:28). Every PSAK request in the fleet would therefore land in the same UserRateThrottle bucket. At 2/minute on the PostHog-funded scope, that is one bucket for all customers.
The PSAK-aware throttles exist and are the ones that key correctly.PersonalOrProjectSecretApiKeyRateThrottle (posthog/rate_limit.py:429-447) keys on psak:{key_id}. ProjectSecretApiKeyTeamRateThrottle (:449-467) keys on psak-team:{team_id} and returns True, a no-op, for any non-PSAK auth. _TeamBucketRateThrottle (:506-524) gives one bucket per project regardless of auth method. products/customer_analytics/.../external.py:517-522 stacks the first two at 60/minute and 600/hour.
A plain PersonalApiKeyRateThrottle subclass silently lets PSAK through. PSAK requests carry no personal key, so they slip past the personal_api_key_only gate. This is stated in the docstring at posthog/rate_limit.py:436-439.
The model allowlist has a direct analogue in the Python gateway's own ProductConfig.allowed_models (services/llm-gateway/.../products/config.py:33), which is enforced per product route without any endpoint code. The Go gateway has its own catalog (PARITY.md:72). So a workflow endpoint on the Go path would need its own allowlist if it wants one.
6. Is PSAK the right credential?
Facts on each side. The choice is not established here.
Facts that argue against PSAK as-is:
The CDP cannot send one. Section 0. TeamManager loads secret_api_token, not any PSAK row. Plumbing a PSAK into the CDP means new fields in nodejs/src/common/utils/team-manager.ts:118-140 and nodejs/src/types.ts.
llm_gateway:read is deliberately excluded from the PSAK scope allowlist.posthog/scopes.py:191-192: "llm_gateway:read is omitted on purpose: it's alpha/privileged and granted only behind the ai-gateway flag in ProjectSecretAPIKeySerializer, not unconditionally like the entries here." The allowlist PROJECT_SECRET_API_KEY_ALLOWED_API_SCOPE_ACTION (posthog/scopes.py:193-206) has four entries: ("endpoint","read"), ("feature_flag","read"), ("account","read"), ("loop","write"). It is enforced at key-creation time in posthog/api/project_secret_api_key.py:70. A workflow scope would need a fifth entry.
PSAK is project-wide. A leaked key reaches every object of its resource type in the project. posthog/scopes.py:202-205 documents this for loop:write.
No request.user.organization. Section 5, point 1.
Facts that argue for it:
It is default-deny per action. psak_allowed_actions defaults to an empty list (posthog/permissions.py:569), enforced at :692-696 with "This action does not support project secret API key access".
It binds to one team. APIScopePermission._check_project_secret_api_key_team (posthog/permissions.py:735-743) denies when view.team.id != psak.team_id.
It is revocable and rotatable per key, unlike Team.secret_api_token which is one shared value per project readable by any project member. products/customer_analytics/backend/presentation/views/external.py chose PSAK over secret_api_token for exactly that reason, per the comment at posthog/scopes.py:199-201.
team.organization is reachable, so a billing check is possible.
The Go gateway itself accepts phs_ credentials with llm_gateway:read (PARITY.md:63), so the credential type is not foreign to this path — though the gateway's own bearer is a separate, PostHog-owned credential.
The alternatives that exist in this repo:
Scoped JWT — the repo's stated preference..agents/security.md:25 (reached from AGENTS.md:169) says: do not extend INTERNAL_API_SECRET to new service-to-service calls, it is a single fleet-wide shared secret already trusted across Django, Node, and Rust, and it is being actively retired edge by edge. The preferred replacement is a scoped JWT with a dedicated per-audience signing key, never INTERNAL_API_SECRET, SECRET_KEY, or JWT_SIGNING_KEY, with claims pinning the token to its team and operation. Canonical example named there: RECORDING_API_JWT_SECRET, minted in posthog/session_recordings/recordings/recording_api_jwt.py, verified in nodejs/src/session-replay/recording-api/auth.ts. There is already a live CDP-originated instance of this pattern: WORKFLOWS_RESCHEDULE_JWT_SECRET, nodejs/src/cdp/config.ts:178-181, comma-separated keys where the newest signs and all verify, with an in-code comment saying it is "Deliberately NOT the fleet-wide INTERNAL_API_SECRET (see .agents/security.md)".
INTERNAL_API_SECRET.InternalAPIAuthentication at posthog/auth.py:1004, header X-Internal-Api-Secret, principal InternalAPIUser at posthog/auth.py:985 which does carry current_organization_id and current_team_id derived from the route's team_id kwarg (:1028-1039). The workflows product already has one: InternalHogFlowViewSet, products/workflows/backend/api/hog_flow.py:4163-4173, authentication_classes = [InternalAPIAuthentication], scope_object = "INTERNAL", docstring "not exposed to Contour ingress". Node reaches it via InternalFetchService (nodejs/src/cdp/services/hogflow-schedule/hogflow-schedule.service.ts:63). Reusing this viewset is arguably not a "new caller", but the security doc's direction is away from it.
Team.secret_api_token. What the six functions use today, via hand-rolled _authenticate_team. TeamSecretTokenAuthentication (posthog/auth.py:372) is the DRF class for it, though the conversations and customer-analytics external views do not use it.
What could not be established from the repo
The Go gateway's actual behavior.PostHog/ai-gateway is not vendored here. Everything in sections 1, 3, and 4 about Go comes from services/llm-gateway/PARITY.md, which is a decision record, not the implementation.
Which gateway serves production traffic. It depends on whether AI_GATEWAY_URL and AI_GATEWAY_API_KEY are set at runtime (posthog/llm/gateway_client.py:182-197). No in-repo config pins it, so both paths must be assumed live.
Who owns the AI-credits check as policy. No document under docs/ or services/llm-gateway/ states an owner. PARITY.md states a capability gap. The only other statements are docstrings in ee/billing/quota_limiting.py:266-269, ee/api/quota_limits.py:1-9, and products/exports/.../ai_subscription/activities.py:239-244.
Whether X-PostHog-Distinct-Id has a required format. It is named in PARITY.md:54 and :65 only. No helper, no caller, no test in this repo sets it.
Whether report_user_action drops synthetic users. Asserted by .agents/skills/adding-project-secret-api-key-auth/SKILL.md:69; not verified in source during this pass.
One stale doc worth flagging
.agents/skills/adding-project-secret-api-key-auth/SKILL.md:20 says "the machinery is shipped but nothing is wired to it yet — the first planned consumer is the endpoints run action." There are at least four live PSAK consumers now: endpoints (products/endpoints/backend/presentation/views/api.py:120-122), feature flags (products/feature_flags/backend/api/feature_flag.py:2844), tasks/loops (products/tasks/backend/presentation/views/loops.py:137-152), and the customer analytics account list (products/customer_analytics/backend/presentation/views/external.py:515-523).
The workflows endpoint does not use the posthog/llm/gateway_client.py builders, so section 1's "yes, the builders are callable from a request handler" is true but not the route taken.
The deciding constraint is billing. The standard AI credits path is caller-side, not gateway-side: the PostHog-instrumented SDK emits the $ai_generation event from the client, carrying $ai_billable, team_id, and ai_product as properties, and the ai_product string is then listed in POSTHOG_AI_PRODUCTS (posthog/tasks/usage_report.py:1609).
Because the event is emitted client-side, $ai_billable never travels as a header, so the reserved-$ai_-prefix stripping documented at gateway_client.py:203-205 never applies.
Live examples: products/replay_vision/backend/temporal/vision_actions/synthesis.py:586-608, ee/hogai/llm.py:175-178 (Max), products/surveys/backend/llm/client.py:62, products/exports/backend/temporal/subscriptions/llm_change_summary.py:427, and the helper at products/product_analytics/backend/api/ai_billing.py.
All of them point base_url at the gateway; they differ from the builders only in who emits the generation event.
So the client is OpenAI(posthog_client=posthoganalytics.setup(), base_url=settings.OPENAI_BASE_URL).
This narrows section 2's conclusion rather than contradicting it.
The credit_bucket mechanism this ticket documented is real, but it is the rarer of the two, used by four products, and it cannot bill on the Go path at all.
Adding a workflows value to the product Literal is therefore unnecessary.
Everything section 3 records about attribution headers still holds for callers that do use the builders.
Activity
Silthus commentedon Aug 6, 2026
Research: the LLM call path from Django
Facts only. Every claim has a
file:line. Where the repo does not answer a question, this says so.Read this first: one premise in the ticket is wrong, and it changes two of the six bullets. The six CDP async functions do not send a project secret API key. See section 0.
0. Correction: the CDP async functions send
Team.secret_api_token, not a PSAKThe ticket says the
phs_credential the six existing async functions send is "the team's project secret API key". It is not. It is the legacy per-teamsecret_api_tokencolumn.nodejs/src/cdp/async-functions/conversations.ts:21-29readsteam.secret_api_tokenand sendsAuthorization: Bearer <token>.context.teamManager.getTeam(...)(conversations.ts:17).TeamManagerloadst.secret_api_tokenstraight from theposthog_teamrow in its cache query,nodejs/src/common/utils/team-manager.ts:126, typed atnodejs/src/types.ts:277.Team.secret_api_token,posthog/models/team/team.py:347.phs_prefix, which is why they look identical.posthog/models/utils.py:166says so in as many words:SECRET_API_TOKEN_PREFIX = "phs_" # "s" standing for "secret"; team secret tokens and project secret API keys.The receiving Django views do not use
ProjectSecretAPIKeyAuthenticationeither. They opt out of DRF auth completely and hand-roll it:products/conversations/backend/api/external.py:181-182—authentication_classes = [],permission_classes = [AllowAny].products/conversations/backend/api/external.py:59-79—_authenticate_teamstripsBearer, thenTeam.objects.get(Q(secret_api_token=...) | Q(secret_api_token_backup=...), conversations_enabled=True). No scope check.products/customer_analytics/backend/presentation/views/external.py:327-328and:141-163.Two further corrections of detail:
conversations.ts(postHogGetTicketat:8,postHogUpdateTicketat:77). Four are innodejs/src/cdp/async-functions/customer_analytics.ts(postHogGetAccount:29,postHogUpdateAccount:88,postHogSetAccountProperties:138,postHogCreateAccount:188).result.invocation.queueParametersto a parsedCyclotronInvocationQueueParametersFetchSchema(conversations.ts:25). The async loop atnodejs/src/cdp/services/hog-executor-async.service.ts:102-131then dispatches it, andexecuteFetch(:282) makes the HTTP call with retry and backoff.The CDP runtime has no PSAK. Nothing in
nodejs/loads aProjectSecretAPIKeyrow. To send one from an async function, a new field must be added to theTeamManagerquery (nodejs/src/common/utils/team-manager.ts:118-140) and toRawTeam/Teaminnodejs/src/types.ts.1. Can the builders be called from a request handler authenticated by a project secret API key?
Yes. Nothing in the builders reads the request, the user, or the caller's credential.
build_openai_client(posthog/llm/gateway_client.py:256-274) andbuild_async_openai_client(:277-288) take two arguments,productandai_product. They read only Django settings. They have no dependency on request context, so they work in a web worker, a Celery task, or a Temporal activity without change.There is a direct precedent for a synchronous gateway call inside a web request handler, on a viewset that also declares PSAK auth:
products/endpoints/backend/presentation/views/api.py:120-122—EndpointViewSetdeclaresauthentication_classes = [ProjectSecretAPIKeyAuthentication],psak_allowed_actions = ["run"],scope_object = "endpoint".products/endpoints/backend/presentation/views/api.py:713— thematerialization_suggestionaction callssuggest_materialization_fix(...)inline.products/endpoints/backend/logic/ai_materialization_fix.py:304— that function callsget_llm_client(product=..., team_id=team_id)and blocks the worker until the model replies.Note the precedent's action is not in
psak_allowed_actions, so today it is session/personal-key only. The point it proves is mechanical: the same class can carry both a PSAK-allowed action and a gateway-calling action. No code today combines the two.Gates the precedent applies, for reference (
products/endpoints/backend/presentation/views/api.py:681-728): feature flag off gives 404;self.team.organization.is_ai_data_processing_approved is not Truegives 403;APIConnectionErrorgives 503; any other exception gives 502. There is also a wall-clock budget in the logic layer (ai_materialization_fix.py:54-58). There is no AI-credits check and no bespoke throttle.The two credentials and how they relate
They are unrelated. The caller's credential terminates at Django. The gateway credential is a process-level secret read from Django settings.
settings.AI_GATEWAY_API_KEY,posthog/settings/web.py:1221settings.LLM_GATEWAY_API_KEY,ee/settings.py:99resolve_ai_gateway_config()(gateway_client.py:182-197) returns(AI_GATEWAY_URL, AI_GATEWAY_API_KEY)fromposthog/settings/web.py:1220-1221, orNone.Nonewhen neither var is set, and also when the pair is half-applied or the URL lacks a/v1path — that logsai_gateway_misconfigured_falling_backand falls back rather than failing the call (:186-196).get_llm_clientusessettings.LLM_GATEWAY_URLandsettings.LLM_GATEWAY_API_KEY(gateway_client.py:98-108). Both are defined inee/settings.py:98-99.LLM_GATEWAY_API_KEYreads the env varLLM_GATEWAY_PERSONAL_API_KEY, so on this path the gateway credential is a personal API key (phx_).phs_secret.services/llm-gateway/PARITY.md:63states Go accepts "phs_project secrets andpha_OAuth credentials with the literalllm_gateway:readscope; credential resolves team and revocation state." So the outboundphs_belongs to whichever team funds the traffic, not to the customer team that triggered it.gateway_client.py:261states it plainly, and:306-307repeats it:team_id"does not change the event's owning project, which the gateway derives from thephs_bearer."Which gateway serves any given call is a runtime fact, decided by whether
AI_GATEWAY_URL/AI_GATEWAY_API_KEYare set. No in-repo config pins it.One gap worth naming
Neither OpenAI builder forwards
team_id.build_openai_clientandbuild_async_openai_clientsetdefault_headers=ai_product_headers(ai_product)and nothing else (gateway_client.py:271,:285). Onlybuild_async_anthropic_clientcarriesteam_id,ai_stage, and the trace id (:319-329). An OpenAI-shaped workflow call that needs per-team attribution has to pass headers per call, or the builder needs a new argument.2.
productandai_productThese are two different identifiers with different types, different owners, and different effects.
product— closed literal, selects the Python gateway routeposthog/llm/gateway_client.py:15-40. 24 values today:llm_gateway,ci,posthog_code,background_agents,slack_app,slack_app_routing,wizard,django,growth,llma_translation,llma_summarization,llma_eval_summary,slack-twig,customer_archetype_classification,product_analytics,subscriptions,signals,review_hog,custom_image_scans,conversations,warehouse_semantic_enrichment,warehouse_custom_source_builder,web_analytics,stamphog.workflowsvalue.:40requires any new value to be added toservices/llm-gateway/src/llm_gateway/products/config.pytoo.gateway_client.py:102:f"{LLM_GATEWAY_URL}/{product}/v1".gateway_client.py:261).What the Python gateway's
ProductConfigcontrols (services/llm-gateway/src/llm_gateway/products/config.py:28-50):allowed_application_idsNonemeans none authorizedallowed_modelsNonemeans allexact_model_matchallow_api_keyscredit_bucketrequires_server_credentialinternal_run:readtokencredit_bucketis the$ai_billableowner the ticket refers to.config.py:37-42:Nonemeans the emitted$ai_generationevents are tagged$ai_billable=falseand the usage reporter ignores them. A bucket value tags them billable and blocks every caller of the product when the bucket is exhausted. The enum has two members,AI_CREDITS = "ai_credits"andPOSTHOG_CODE_CREDITS = "posthog_code_credits"(config.py:24-25), and the docstring at:14-22says the values match the DjangoQuotaResourcekeys on purpose.Only four products carry a bucket:
posthog_code(config.py:136),slack_app(:187),slack_app_routing(:220),posthog_ai(:326). Every other product iscredit_bucket=None, includingconversations(:305),signals(:256),web_analytics(:332), andwarehouse_semantic_enrichment(:312).ai_product— free-form string, an event propertyai_product_headers(ai_product: str | None)atgateway_client.py:214-220. The type isstr | None. There is no enum and no closed set. Existing values are written inline at each call site:signals_safety,signals_grouping,signals_eval,aio_summarization,aio_eval_summary,aio_clustering,aio_eval_reports,aio_stamphog,signals_scout,alert_investigation_agent.X-PostHog-PropertiesJSON blob (gateway_client.py:200-211,:220).PARITY.md:40states it is telemetry only: "Anai_productevent property is not an authorization or billing boundary.".agents/skills/migrating-llm-gateway-callers/SKILL.md:47repeats it.What
ai_productdoes controlBilling rollup in the usage report, which is a separate mechanism from the gateway's
$ai_billableflag.posthog/tasks/usage_report.py:1609-1617—POSTHOG_AI_PRODUCTS = ["posthog_ai", "slack_app", "subscriptions", "alert_investigation_agent", "product_analytics", "surveys", "replay_vision"], with the comment "ai_product values that roll into PostHog AI (Max) billing credits."posthog/tasks/usage_report.py:1620—POSTHOG_CODE_AI_PRODUCTS = ["posthog_code"].ai_billable = 1(usage_report.py:1799) andai_product IN %(ai_products)s(:1796).So a generation is billed to AI credits only when the gateway product config marks it billable and its
ai_productstring appears inPOSTHOG_AI_PRODUCTS. A value not in that list is not billed, whatever the gateway says.Which one a workflow generation should declare
The ticket asks for facts, so: there is no
workflowsvalue in either identifier today. A newproductvalue would need entries ingateway_client.py:15-40and inservices/llm-gateway/.../products/config.py. A newai_productvalue needs no registration at all, but stays unbilled unless added toPOSTHOG_AI_PRODUCTSinusage_report.py:1609. The choice between reusing an existingproductand adding one belongs to the deciding ticket.3. Attribution metadata the Go gateway requires
None of the three are strictly required. Each has a documented fallback. They are required only to get attribution that is worth having.
X-PostHog-Properties_ai_property_headers(**labels)atgateway_client.py:200-211. One JSON object. Unset labels are dropped; the function returnsNonewhen nothing is set, so no header is sent.:203-205states the constraint: the Go gateway reads labels only from this blob, not from thex-posthog-property-<key>per-header form the Python gateway accepts. Keys must not carry an$ai_prefix, because the gateway strips those as reserved.PARITY.md:74lists the same split: Go takes "OneX-PostHog-PropertiesJSON object plus dedicated distinct ID, trace ID, and provider headers";X-POSTHOG-PROPERTY-*andX-POSTHOG-FLAG-*are Python-only.PARITY.md:54: "Caller-supplied properties are not trusted policy."X-PostHog-Trace-Id_ai_trace_headers(team_id)atgateway_client.py:239-242, fromteam_trace_id(team_id)at:228-236.uuid5(_TEAM_TRACE_ID_NAMESPACE, f"team-{team_id}"). The namespace UUID is at:225, with the comment that it mirrorsservices/llm-gateway/src/llm_gateway/callbacks/posthog.pyand both must stay equal or a team's trace id changes with whichever gateway serves it.:231-233).:233). A workflow run that wants its own trace cannot get one from this helper.X-PostHog-Distinct-Idservices/llm-gateway/PARITY.md:54and:65.:65: "Distinct ID comes fromX-PostHog-Distinct-Idor falls back to team ID."PARITY.md:54adds the trap: "Go does not derive the event distinct ID from OpenAIuser, Anthropicmetadata.user_id, or the OAuth user." So theuser=argument that works on the Python gateway (gateway_client.py:73-74) is dropped by Go.posthog/llm/gateway_client.py, and no caller in this repo sets it. Grep across*.py/*.ts/*.mdfinds it only inPARITY.mdand in unrelated frontend/proxy CORS code (frontend/src/lib/api.ts:2141,services/agent-proxy/src/lib/constants.ts:81).What the builders actually send today
X-PostHog-PropertiesX-PostHog-Trace-IdX-PostHog-Distinct-Idbuild_openai_client(:256)ai_productonlybuild_async_openai_client(:277)ai_productonlybuild_async_anthropic_client(:291)ai_product,ai_stage,team_id:328)4. The quota gate
The primitives
QuotaResource.AI_CREDITS = "ai_credits"—ee/billing/quota_limiting.py:90. The comment at:77-78says the enum values match the billingdefault_plans_config.ymlusage keys.OVERAGE_BUFFER[QuotaResource.AI_CREDITS] = 0,ee/billing/quota_limiting.py:116.AI_CREDITSis inGRACE_PERIOD_EXEMPT_RESOURCES,ee/billing/quota_limiting.py:128.is_team_limited(team_api_token: str, resource: QuotaResource, cache_key: QuotaLimitingCaches) -> bool—ee/billing/quota_limiting.py:226-228. It takes the team API token (team.api_token), not a team id.list_limited_team_attributes(:203-213), key@posthog/quota-limits/ai_credits(:100), membersteam_api_token -> expiry. That lookup is memoized for 30 seconds per worker process (@cache_for(timedelta(seconds=30), background_refresh=True),:202).is_team_over_ai_credit_budget(team_api_token) -> boolat:266-270is the shared one-line wrapper. Its docstring notes the Redis read is synchronous, so async callers must wrap it insync_to_async.get_fresh_team_limited_resources(team_api_token)at:231-245is the uncached variant.Who enforces it
No single owner. Established from the repo:
services/llm-gateway/src/llm_gateway/dependencies.py:170-196only calls the quota resolver whencredit_bucket is not None; otherwise it returnsQuotaResourceStatus(limited=False)and skips. The resolver (services/llm-gateway/src/llm_gateway/services/quota_resolver.py:104,:184) callsGET /api/projects/{team_id}/quota_limits/and caches per team and resource. A denial surfaces as HTTP 429 withcode: "billable_credits"(services/llm-gateway/src/llm_gateway/rate_limiting/throttles.py:59, message atbillable_credits_throttle.py:8-11).PARITY.md:41lists "Product or user budget enforcement" as a blocking Python-only contract: "Python supports product and user cost limits, plan checks, quota buckets, and requests that debit no wallet. Go currently admits against the credential owner's team wallet and does not branch settlement on credential billing mode."PARITY.md:66puts "quota buckets, free tiers, unbilled products" on the Python-only side.posthog/llm/gateway_client.pydoes not. No quota or credit reference anywhere in the file.Consequence, stated as fact: on the Go path, or on the Python path with a
credit_bucket=Noneproduct, nothing checks AI credits unless the Django caller does.The Go gateway's source is not in this tree. Its behavior here is established only from
services/llm-gateway/PARITY.md. No document underdocs/names an owner for the AI-credits check.What a limited team sees, in existing callers
Two distinct precedents, split by whether a person is waiting.
Interactive DRF paths raise and return 402.
ee/api/conversation.py:454-456—is_team_limited(self.team.api_token, QuotaResource.AI_CREDITS, QuotaLimitingCaches.QUOTA_LIMITER_CACHE_KEY)thenraise QuotaLimitExceeded("Your organization reached its AI credit usage limit. Increase the limits in Billing settings, or ask an org admin to do so.").QuotaLimitExceededis HTTP 402 with codequota_limit_exceeded(posthog/exceptions.py:26-29).ee/api/conversation.py:708— same check and same message on the sandbox open endpoint.ee/api/subscription.py:563— same, during serializer validation when a summary turns active.products/tasks/backend/logic/services/warm.py:52—_ai_credits_checker, registered at:78. The contract is exception-based because the caller cannot return aResponse(comment at:73-76). The registry is fail-closed: an unregistered origin raisesPermissionDenied(:100).Non-interactive paths skip silently, and fail open when the lookup itself errors.
products/replay_vision/backend/temporal/vision_actions/synthesis.py:295— logsvision_action.synthesis.over_credit_budget, returnsSKIPPED_OVER_BUDGET.products/exports/backend/temporal/subscriptions/snapshot_activities.py:449— drops only the AI summary, still delivers the rest; fails open on lookup error (:452-460).products/exports/backend/temporal/subscriptions/ai_subscription/activities.py:245— skips delivery and reschedules; fails open (:248-258). The comment at:239-244is the clearest in-repo statement of the split: the interactive Max path enforces inee/api/conversation.py, and "scheduled reports need their own check."posthog/temporal/ai/slack_app/helpers.py:34-38—block_if_team_over_quotaposts a Slack denial instead of an HTTP status. Message text atproducts/slack_app/backend/api.py:255-258.There is no shared decorator or middleware. Every caller writes its own check.
Callers that do not check at all, whose gateway product is also
credit_bucket=None, so nothing gates them anywhere: the conversations AI-reply Temporal activities,products/warehouse_sources/.../enrich_table_semantics.py:246,products/warehouse_sources/.../custom/ai_builder.py:330,products/review_hog/.../direct_llm.py:57,products/web_analytics/.../path_cleaning_suggestions/service.py:126,products/endpoints/backend/logic/ai_materialization_fix.py:304,products/stamphog/backend/logic/digest.py:147.A workflow generation is both shapes at once. It is a server-side call with no interactive user, but it is reached through an HTTP endpoint, so a 402 is returnable to the Cyclotron fetch and would surface through the node's existing
on_error. Which of the two precedents applies is a decision, not a fact, and belongs to the ticket this one blocks.5. The throttle precedent in
products/ai_observability/backend/api/proxy.pyRead in full. It is registered on the root router, not a project-nested one:
products/ai_observability/backend/routes.py:38givesGET /api/llm_proxy/modelsandPOST /api/llm_proxy/completion.organization.customer_idproxy.py:249-255{"error": "The playground requires a valid payment method on file to prevent abuse."}. A plainResponse, not an exception. Guardscompletiononly;modelshas no such check.proxy.py:281-288{"error": "Model '<x>' is not available on the PostHog-funded playground. Please add your own API key to use this model."}. Applies only when PostHog pays (provider_key is None). BYOK bypasses it entirely.PLAYGROUND_MODEL_IDSatproducts/ai_observability/backend/llm/__init__.py:56.proxy.py:144-169modelsgets[], no throttling.completionwith a resolvableprovider_key_idgets the BYOK trio. Otherwise the PostHog-funded trio.quota,credit, orbillingreference in the file.Auth:
authentication_classes = [SessionAuthentication],permission_classes = [IsAuthenticated, PlaygroundAccessPermission](proxy.py:140-142). The comment atproxy.py:97-98says it deliberately declares noscope_object, because that "would imply personal API key support thatSessionAuthenticationdoesn't allow". So no key-based credential can reach this endpoint at all, PSAK included.An interactive user is assumed throughout:
user.current_teaminPlaygroundAccessPermission(proxy.py:113-116, message "Open a project before using the playground.") and in_get_provider_key(:176-178);distinct_id = request.user.email(:295);report_user_action(request.user, ...)at:307,:317,:327,:390.It also does not use
posthog/llm/gateway_client.py. It usesproducts.ai_observability.backend.llm.Client(proxy.py:288), so it never reaches the gateway's ownBillableCreditThrottle.The six throttle classes
All in
posthog/rate_limit.py, all subclassing DRF'sUserRateThrottle:LLMProxyBurstRateThrottle:672-674llm_proxy_burstLLMProxySustainedRateThrottle:677-679llm_proxy_sustainedLLMProxyDailyRateThrottle:682-684llm_proxy_dailyLLMProxyBYOKBurstRateThrottle:687-689llm_proxy_byok_burstLLMProxyBYOKSustainedRateThrottle:692-694llm_proxy_byok_sustainedLLMProxyBYOKDailyRateThrottle:697-699llm_proxy_byok_dailyNone of them overrides
get_cache_key, so all six key onrequest.user.pk. None keys on team or organization.Which of these apply to a server-side workflow call
Established facts, not recommendations:
request.user.organization(proxy.py:249). A PSAK request'srequest.useris aProjectSecretAPIKeyUser(posthog/auth.py:417-431), aSyntheticUser(posthog/synthetic_user.py:6) which defines noorganizationattribute, so the access raisesAttributeError. The equivalent check that does work isteam.organization.customer_id—Team.organizationis an ordinary FK, and a live PSAK handler already uses it:products/customer_analytics/backend/presentation/views/external.py:559passesorganization_id=team.organization_idfrom the PSAK-resolved team.SyntheticUsersetspk = -1(posthog/synthetic_user.py:27) andid = None(:28). Every PSAK request in the fleet would therefore land in the sameUserRateThrottlebucket. At 2/minute on the PostHog-funded scope, that is one bucket for all customers.PersonalOrProjectSecretApiKeyRateThrottle(posthog/rate_limit.py:429-447) keys onpsak:{key_id}.ProjectSecretApiKeyTeamRateThrottle(:449-467) keys onpsak-team:{team_id}and returnsTrue, a no-op, for any non-PSAK auth._TeamBucketRateThrottle(:506-524) gives one bucket per project regardless of auth method.products/customer_analytics/.../external.py:517-522stacks the first two at 60/minute and 600/hour.PersonalApiKeyRateThrottlesubclass silently lets PSAK through. PSAK requests carry no personal key, so they slip past thepersonal_api_key_onlygate. This is stated in the docstring atposthog/rate_limit.py:436-439.ProductConfig.allowed_models(services/llm-gateway/.../products/config.py:33), which is enforced per product route without any endpoint code. The Go gateway has its own catalog (PARITY.md:72). So a workflow endpoint on the Go path would need its own allowlist if it wants one.6. Is PSAK the right credential?
Facts on each side. The choice is not established here.
Facts that argue against PSAK as-is:
TeamManagerloadssecret_api_token, not any PSAK row. Plumbing a PSAK into the CDP means new fields innodejs/src/common/utils/team-manager.ts:118-140andnodejs/src/types.ts.llm_gateway:readis deliberately excluded from the PSAK scope allowlist.posthog/scopes.py:191-192: "llm_gateway:read is omitted on purpose: it's alpha/privileged and granted only behind the ai-gateway flag in ProjectSecretAPIKeySerializer, not unconditionally like the entries here." The allowlistPROJECT_SECRET_API_KEY_ALLOWED_API_SCOPE_ACTION(posthog/scopes.py:193-206) has four entries:("endpoint","read"),("feature_flag","read"),("account","read"),("loop","write"). It is enforced at key-creation time inposthog/api/project_secret_api_key.py:70. A workflow scope would need a fifth entry.posthog/scopes.py:202-205documents this forloop:write.request.user.organization. Section 5, point 1.Facts that argue for it:
psak_allowed_actionsdefaults to an empty list (posthog/permissions.py:569), enforced at:692-696with "This action does not support project secret API key access".APIScopePermission._check_project_secret_api_key_team(posthog/permissions.py:735-743) denies whenview.team.id != psak.team_id.Team.secret_api_tokenwhich is one shared value per project readable by any project member.products/customer_analytics/backend/presentation/views/external.pychose PSAK oversecret_api_tokenfor exactly that reason, per the comment atposthog/scopes.py:199-201.team.organizationis reachable, so a billing check is possible.phs_credentials withllm_gateway:read(PARITY.md:63), so the credential type is not foreign to this path — though the gateway's own bearer is a separate, PostHog-owned credential.The alternatives that exist in this repo:
.agents/security.md:25(reached fromAGENTS.md:169) says: do not extendINTERNAL_API_SECRETto new service-to-service calls, it is a single fleet-wide shared secret already trusted across Django, Node, and Rust, and it is being actively retired edge by edge. The preferred replacement is a scoped JWT with a dedicated per-audience signing key, neverINTERNAL_API_SECRET,SECRET_KEY, orJWT_SIGNING_KEY, with claims pinning the token to its team and operation. Canonical example named there:RECORDING_API_JWT_SECRET, minted inposthog/session_recordings/recordings/recording_api_jwt.py, verified innodejs/src/session-replay/recording-api/auth.ts.There is already a live CDP-originated instance of this pattern:
WORKFLOWS_RESCHEDULE_JWT_SECRET,nodejs/src/cdp/config.ts:178-181, comma-separated keys where the newest signs and all verify, with an in-code comment saying it is "Deliberately NOT the fleet-wide INTERNAL_API_SECRET (see .agents/security.md)".INTERNAL_API_SECRET.InternalAPIAuthenticationatposthog/auth.py:1004, headerX-Internal-Api-Secret, principalInternalAPIUseratposthog/auth.py:985which does carrycurrent_organization_idandcurrent_team_idderived from the route'steam_idkwarg (:1028-1039). The workflows product already has one:InternalHogFlowViewSet,products/workflows/backend/api/hog_flow.py:4163-4173,authentication_classes = [InternalAPIAuthentication],scope_object = "INTERNAL", docstring "not exposed to Contour ingress". Node reaches it viaInternalFetchService(nodejs/src/cdp/services/hogflow-schedule/hogflow-schedule.service.ts:63). Reusing this viewset is arguably not a "new caller", but the security doc's direction is away from it.Team.secret_api_token. What the six functions use today, via hand-rolled_authenticate_team.TeamSecretTokenAuthentication(posthog/auth.py:372) is the DRF class for it, though the conversations and customer-analytics external views do not use it.What could not be established from the repo
PostHog/ai-gatewayis not vendored here. Everything in sections 1, 3, and 4 about Go comes fromservices/llm-gateway/PARITY.md, which is a decision record, not the implementation.AI_GATEWAY_URLandAI_GATEWAY_API_KEYare set at runtime (posthog/llm/gateway_client.py:182-197). No in-repo config pins it, so both paths must be assumed live.docs/orservices/llm-gateway/states an owner.PARITY.mdstates a capability gap. The only other statements are docstrings inee/billing/quota_limiting.py:266-269,ee/api/quota_limits.py:1-9, andproducts/exports/.../ai_subscription/activities.py:239-244.X-PostHog-Distinct-Idhas a required format. It is named inPARITY.md:54and:65only. No helper, no caller, no test in this repo sets it.report_user_actiondrops synthetic users. Asserted by.agents/skills/adding-project-secret-api-key-auth/SKILL.md:69; not verified in source during this pass.One stale doc worth flagging
.agents/skills/adding-project-secret-api-key-auth/SKILL.md:20says "the machinery is shipped but nothing is wired to it yet — the first planned consumer is the endpointsrunaction." There are at least four live PSAK consumers now: endpoints (products/endpoints/backend/presentation/views/api.py:120-122), feature flags (products/feature_flags/backend/api/feature_flag.py:2844), tasks/loops (products/tasks/backend/presentation/views/loops.py:137-152), and the customer analytics account list (products/customer_analytics/backend/presentation/views/external.py:515-523).Silthus commentedon Aug 6, 2026
Amendment, from The endpoint contract
The workflows endpoint does not use the
posthog/llm/gateway_client.pybuilders, so section 1's "yes, the builders are callable from a request handler" is true but not the route taken.The deciding constraint is billing. The standard AI credits path is caller-side, not gateway-side: the PostHog-instrumented SDK emits the
$ai_generationevent from the client, carrying$ai_billable,team_id, andai_productas properties, and theai_productstring is then listed inPOSTHOG_AI_PRODUCTS(posthog/tasks/usage_report.py:1609).Because the event is emitted client-side,
$ai_billablenever travels as a header, so the reserved-$ai_-prefix stripping documented atgateway_client.py:203-205never applies.Live examples:
products/replay_vision/backend/temporal/vision_actions/synthesis.py:586-608,ee/hogai/llm.py:175-178(Max),products/surveys/backend/llm/client.py:62,products/exports/backend/temporal/subscriptions/llm_change_summary.py:427, and the helper atproducts/product_analytics/backend/api/ai_billing.py.All of them point
base_urlat the gateway; they differ from the builders only in who emits the generation event.So the client is
OpenAI(posthog_client=posthoganalytics.setup(), base_url=settings.OPENAI_BASE_URL).This narrows section 2's conclusion rather than contradicting it.
The
credit_bucketmechanism this ticket documented is real, but it is the rarer of the two, used by four products, and it cannot bill on the Go path at all.Adding a
workflowsvalue to theproductLiteral is therefore unnecessary.Everything section 3 records about attribution headers still holds for callers that do use the builders.