Skip to content

Confirm the LLM call path from Django #2

Closed

Description

@Silthus

Part of #1

Question

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.

Activity

  1. self-assigned this
    on Aug 6, 2026
  2. Silthus commented on Aug 6, 2026

    @Silthus
    OwnerAuthor

    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 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:181-182 — authentication_classes = [], permission_classes = [AllowAny].
    • 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:120-122 — EndpointViewSet declares authentication_classes = [ProjectSecretAPIKeyAuthentication], psak_allowed_actions = ["run"], scope_object = "endpoint".
    • 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.

    Inbound (caller to Django) Outbound (Django to gateway)
    Go gateway path whatever the viewset declares settings.AI_GATEWAY_API_KEY, posthog/settings/web.py:1221
    Python gateway path same settings.LLM_GATEWAY_API_KEY, ee/settings.py:99
    • 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

    • Defined at posthog/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.
    • There is no workflows value.
    • 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."
    • posthog/tasks/usage_report.py:1620 — POSTHOG_CODE_AI_PRODUCTS = ["posthog_code"].
    • The query filters on both: ai_billable = 1 (usage_report.py:1799) and ai_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/replay_vision/backend/temporal/vision_actions/synthesis.py:295 — logs vision_action.synthesis.over_credit_budget, returns SKIPPED_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-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:

    1. 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.
    2. 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.
    3. 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.
    4. 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.
    5. 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:

    1. 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)".
    2. 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.
    3. 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).

  3. Silthus commented on Aug 6, 2026

    @Silthus
    OwnerAuthor

    Amendment, from The endpoint contract

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions