feat(llm-gateway): add free onboarding product for wizard cloud runs - #74095
Conversation
Setup wizard cloud runs are created with origin_product "onboarding", which the agent-server does not map to a gateway product, so they fall through to posthog_code and bill against the customer's PostHog Code credits. Onboarding is PostHog-funded acquisition spend, and it happens before the user has decided to buy anything. Adds an unbilled `onboarding` gateway product with a narrow model allowlist and an explicit cost ceiling, and pins wizard cloud runs to claude-sonnet-5 so they stop defaulting to the agent's premium model. `ai_stage` is stamped so the PR agent's generations are separable within the product. A free product needs a closed door. origin_product is caller-settable, so the task API now refuses `onboarding` from API callers the same way it already refuses image_builder and experiments. The agent-server side of the gate ships separately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This stack of pull requests is managed by Graphite. Learn more about stacking. |
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 2 · PR risk: 0/10 |
|
|
Note 🤖 stamphog reviewed Three independent reviewers (hex-security-app, veria-ai, greptile-apps) all flag the same unresolved, current-head security finding: the new unbilled onboarding product's server-credential requirement is only enforced when a flag that defaults to false is on, letting any ordinary PostHog Code OAuth token hit the free/unbilled endpoint; a second hex-security-app finding on model-pin mutability via PATCH is also unaddressed.
Gate mechanics and policy version
|
Two findings from the review bots on this PR.
check_product_access only enforced requires_server_credential when
posthog_code_model_gate_enabled was on, and that setting defaults to off, so the new unbilled
onboarding product accepted any PostHog Code OAuth token. The flag exists so products that already
shipped accepting marker-less Code tokens keep working until the Code billing cutover, which is a
reason that does not apply to a product introduced with the check already on it. Replaced the
custom_image_scans special case with UNCONDITIONAL_SERVER_CREDENTIAL_PRODUCTS so the distinction is
named rather than encoded as a growing chain of product comparisons, and put onboarding in it.
Every test in TestServerCredentialRequirement runs under an autouse fixture that forces the flag on,
which is why nothing caught this. Added flag-off coverage for both directions. Those tests name their
products literally instead of parameterizing over the set they are testing, because deriving the
cases from it would make dropping a product delete its own coverage instead of failing, with a
membership assertion tying the two lists together.
The wizard run's model pin lived in TaskRun.state, which PATCH /runs/{id} merges into after
filtering only _PROTECTED_RUN_STATE_KEYS, and model was not in that set. Onboarding tasks are
controllable by every team member, so one could repoint a queued run at claude-opus-4-8 or any
Bedrock model, all of which the onboarding product still allowlists, before the workflow read its
state. runtime_adapter, provider, model and reasoning_effort are now protected: they decide what a
run costs, every writer is server-side, and for a run routed to an unbilled product the pin is the
only thing holding the line. Extended the existing protected-key test rather than adding a new one.
Reported by hex-security-app, veria-ai and greptile-apps on PR #74095.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two findings from the review bots on this PR.
check_product_access only enforced requires_server_credential when
posthog_code_model_gate_enabled was on, and that setting defaults to off, so the new unbilled
onboarding product accepted any PostHog Code OAuth token. The flag exists so products that already
shipped accepting marker-less Code tokens keep working until the Code billing cutover, which is a
reason that does not apply to a product introduced with the check already on it. Replaced the
custom_image_scans special case with UNCONDITIONAL_SERVER_CREDENTIAL_PRODUCTS so the distinction is
named rather than encoded as a growing chain of product comparisons, and put onboarding in it.
Every test in TestServerCredentialRequirement runs under an autouse fixture that forces the flag on,
which is why nothing caught this. Added flag-off coverage for both directions. Those tests name their
products literally instead of parameterizing over the set they are testing, because deriving the
cases from it would make dropping a product delete its own coverage instead of failing, with a
membership assertion tying the two lists together.
The wizard run's model pin lived in TaskRun.state, which PATCH /runs/{id} merges into after
filtering only _PROTECTED_RUN_STATE_KEYS, and model was not in that set. Onboarding tasks are
controllable by every team member, so one could repoint a queued run at claude-opus-4-8 or any
Bedrock model, all of which the onboarding product still allowlists, before the workflow read its
state. runtime_adapter, provider, model and reasoning_effort are now protected: they decide what a
run costs, every writer is server-side, and for a run routed to an unbilled product the pin is the
only thing holding the line. Extended the existing protected-key test rather than adding a new one.
Reported by hex-security-app, veria-ai and greptile-apps on PR #74095.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
650cd0e to
a199363
Compare
richardsolomou
left a comment
There was a problem hiding this comment.
could we also cover removing the four new protected model keys via state_remove_keys? the current test covers overwrites, but deleting model or runtime_adapter could still trigger an unintended fallback
The state_remove_keys path is filtered by the same _PROTECTED_RUN_STATE_KEYS set as the merge path, but the test only exercised overwrites. Dropping a key is equally escalating: the processing context reads the model posture with .get(), so an absent key falls back to the runtime default rather than the pin the server chose. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Without an entry in DEFAULT_USER_COST_LIMITS the product fell back to the default $100/24h burst, which is a generous ceiling for a bucket nobody pays for. The route's server-credential marker proves a token was minted server-side, not that it belongs to a wizard run, so bounding per-user spend is what actually limits the damage if the marker is the only gate reached. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
a199363 to
392081b
Compare
|
@richardsolomou of course, see it in 376d1e3 |
The first pass sized this off an unmeasured guess that a setup wizard pass costs cents. It runs a coding agent, so the comparable product (background_agents, also unbilled and agentic) is set an order of magnitude higher. Cutting a user off partway through setup is worse than the unbilled spend, so the cap is sized to stay clear of real onboarding: $50/24h burst, $500/30d sustained, half of the default it would otherwise fall back to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Problem
Setup wizard cloud runs (the "run the wizard in the cloud" path from onboarding) create a task with
origin_product = "onboarding".The agent-server has no mapping for that origin, so it falls through to the
posthog_codedefault and every generation the run makes bills intoCreditBucket.POSTHOG_CODE_CREDITS.Two things wrong with that. We charge a customer's PostHog Code credits for onboarding, which is our acquisition spend and happens before they have decided to buy anything. And wizard traffic is indistinguishable from real Code tasks in
$ai_generation, so we cannot see what onboarding actually costs.ai_productcannot be fixed with a header. The gateway re-asserts it from the route in_apply_owned_event_propertiesprecisely so callers cannot spoof it. The route is the only lever, which means a new product.Changes
A new
onboardinggateway product, unbilled (credit_bucket=None), with an explicit cost ceiling so the spend gauge actually gets published for it.Wizard cloud runs now pin
claude-sonnet-5instead of inheriting the agent-server'sclaude-opus-4-8default, and stampai_stage="wizard_pr_agent"so the PR agent's generations stay separable inside the product. The pin is what makes the narrow model allowlist workable.A free product needs a closed door, and
origin_productis caller-settable: anytask:writeholder can POST a task claiming any origin except the three we already reject. So the task API now refusesonboardingtoo, the same way it refusesimage_builderandexperiments. Without that, this PR would ship a free LLM endpoint.That guard is one of two. The other lives in the agent-server: it only routes to the free product when the run also carries the
wizard_configstate key, which only the server-side wizard flow stamps and which the run PATCH allowlist drops. A task that merely claims the origin stays onposthog_code.Also fixed one adjacent lie in the gateway README, which still documented a
billable=Trueflag that was replaced bycredit_bucket.Important
This PR is the first half. The agent-server half is PostHog/code#3855, and it must land after this one.
The gateway deploys from its own path-triggered pipeline, so until
onboardingexists inPRODUCTSthere, any request to that path segment gets a 400 fromvalidate_product. Landing this first is inert, since nothing requests the slug until the agent change ships and the sandbox base image rebuilds.Note
Model allowlist includes
claude-opus-4-8on purpose. The Claude SDK sets it asfallbackModelfor refusal and overload rescue whenever it is not the primary, so a 403 there would fail the run instead of rescuing it. Bedrock ids are in for the same reason, matchingposthog_codeandbackground_agents.How did you test this code?
I (well, Claude) ran these locally, all green:
services/llm-gatewayfull suite, 1375 passed. The new product is picked up automatically by the derivedTestServerCredentialConfigInvariant, which is what forcesrequires_server_credential=Trueon anything sharing the Code OAuth app.products/tasks/backend/tests/test_api.pyplusposthog/api/wizard/, 660 passed.products/tasks/backend/tests/test_facade.py, 31 passed.New test coverage and the regression each one catches:
onboardingcase added totest_create_task_rejects_internal_origintask:writecaller. This is the security-relevant one.test_create_wizard_cloud_run_pins_its_modelonboardingadded to the twoTestServerCredentialRequirementlistsNot tested by me: the end to end run against a real sandbox. That needs both halves deployed plus a sandbox image rebuild, so it has to happen after the agent PR lands. What I would check then is that the run's
$ai_generationevents carryai_product = "onboarding",$ai_billable = false,ai_stage = "wizard_pr_agent", and sonnet-5 as the model.👉 Stay up-to-date with PostHog coding conventions for a smoother review.
Automatic notifications
Docs update
Updated
services/llm-gateway/README.mdin this PR: added the product to the registered products table and corrected thebillablereference tocredit_bucket. No user-facing docs affected.🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Claude Opus 5 in Claude Code, driven by me. Invoked
/writing-testsbefore adding tests.The interesting decision was how hard to lock the free route. The first proposal was a dedicated internal OAuth scope minted only for wizard runs and required by the product, mirroring
signal_scout_internal. I rejected it as too much machinery for the payoff. What we went with instead is the two-factor gate described above, which reuses markers that already exist and are already protected.Worth knowing while reviewing: the gateway's own
requires_server_credentialflag is set here for consistency and to satisfy the config invariant test, but it is not carrying the weight. It is OAuth-only, it is inert unlessposthog_code_model_gate_enabledis on, and every sandbox token already carriesinternal_run:readanyway, so it cannot tell a wizard run from any other task run. The API guard and thewizard_configcheck are what actually close the door.One related finding I deliberately left out of scope.
Task.internalis settable on create and mutable on PATCH, andisInternalmaps tobackground_agents, which is also unbilled. That looks like the same shape of hole, already open. It may be covered byrequires_server_credentialdepending on whatLLM_GATEWAY_POSTHOG_CODE_MODEL_GATE_ENABLEDis set to in production, which I could not verify from here. Worth a look, separately.