Skip to content

feat(llm-gateway): add free onboarding product for wizard cloud runs - #74095

Merged
rafaeelaudibert merged 5 commits into
masterfrom
rafa/free-onboarding-llm-gateway-product
Jul 28, 2026
Merged

feat(llm-gateway): add free onboarding product for wizard cloud runs#74095
rafaeelaudibert merged 5 commits into
masterfrom
rafa/free-onboarding-llm-gateway-product

Conversation

@rafaeelaudibert

@rafaeelaudibert rafaeelaudibert commented Jul 28, 2026

Copy link
Copy Markdown
Member

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_code default and every generation the run makes bills into CreditBucket.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_product cannot be fixed with a header. The gateway re-asserts it from the route in _apply_owned_event_properties precisely so callers cannot spoof it. The route is the only lever, which means a new product.

Changes

A new onboarding gateway 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-5 instead of inheriting the agent-server's claude-opus-4-8 default, and stamp ai_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_product is caller-settable: any task:write holder can POST a task claiming any origin except the three we already reject. So the task API now refuses onboarding too, the same way it refuses image_builder and experiments. 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_config state key, which only the server-side wizard flow stamps and which the run PATCH allowlist drops. A task that merely claims the origin stays on posthog_code.

Also fixed one adjacent lie in the gateway README, which still documented a billable=True flag that was replaced by credit_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 onboarding exists in PRODUCTS there, any request to that path segment gets a 400 from validate_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-8 on purpose. The Claude SDK sets it as fallbackModel for 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, matching posthog_code and background_agents.

How did you test this code?

I (well, Claude) ran these locally, all green:

  • services/llm-gateway full suite, 1375 passed. The new product is picked up automatically by the derived TestServerCredentialConfigInvariant, which is what forces requires_server_credential=True on anything sharing the Code OAuth app.
  • products/tasks/backend/tests/test_api.py plus posthog/api/wizard/, 660 passed.
  • products/tasks/backend/tests/test_facade.py, 31 passed.

New test coverage and the regression each one catches:

Test Catches
onboarding case added to test_create_task_rejects_internal_origin Someone drops the API guard and the free route becomes forgeable by any task:write caller. This is the security-relevant one.
test_create_wizard_cloud_run_pins_its_model The model pin gets dropped, runs go back to the premium default, and every wizard cloud run then 403s at the gateway because that model is not in the product's allowlist.
onboarding added to the two TestServerCredentialRequirement lists The server-credential marker stops being enforced for this product. Those lists are hand-enumerated, so a new product is not covered without it.

Not 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_generation events carry ai_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

  • Publish to changelog?
  • Alert Sales and Marketing teams?

Docs update

Updated services/llm-gateway/README.md in this PR: added the product to the registered products table and corrected the billable reference to credit_bucket. No user-facing docs affected.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Claude Opus 5 in Claude Code, driven by me. Invoked /writing-tests before 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_credential flag 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 unless posthog_code_model_gate_enabled is on, and every sandbox token already carries internal_run:read anyway, so it cannot tell a wizard run from any other task run. The API guard and the wizard_config check are what actually close the door.

One related finding I deliberately left out of scope. Task.internal is settable on create and mutable on PATCH, and isInternal maps to background_agents, which is also unbilled. That looks like the same shape of hole, already open. It may be covered by requires_server_credential depending on what LLM_GATEWAY_POSTHOG_CODE_MODEL_GATE_ENABLED is set to in production, which I could not verify from here. Worth a look, separately.

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>
@rafaeelaudibert
rafaeelaudibert requested review from a team, MattBro and fercgomes and removed request for a team July 28, 2026 03:47
@graphite-app graphite-app Bot added the stamphog Request AI approval (no full review) label Jul 28, 2026

Copy link
Copy Markdown
Member Author

This stack of pull requests is managed by Graphite. Learn more about stacking.

@pr-assigner-resolver-posthog
pr-assigner-resolver-posthog Bot requested review from a team July 28, 2026 03:48
Comment thread services/llm-gateway/src/llm_gateway/products/config.py
Comment thread products/tasks/backend/facade/api.py
Comment thread services/llm-gateway/src/llm_gateway/products/config.py
@veria-ai

veria-ai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 2 · PR risk: 0/10

@greptile-apps

greptile-apps Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Security Review

The onboarding product's server-credential restriction is ineffective while the global model gate is disabled, allowing a matching desktop OAuth token to access unbilled inference. How this was verified: The onboarding route accepts shared PostHog Code application IDs, while the scope check is conditional on a setting that defaults to false.

Prompt To Fix All With AI
### Issue 1
services/llm-gateway/src/llm_gateway/products/config.py:153
**Server credential gate stays disabled**

When `posthog_code_model_gate_enabled` retains its default false value, `requires_server_credential=True` is not enforced for this product, so an ordinary PostHog Code OAuth token from an allowlisted application can send unbilled onboarding inference requests without the required `internal_run:read` scope. **How this was verified:** The onboarding route accepts the shared PostHog Code application IDs, while the server-credential scope check is conditional on a setting that defaults to false.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "feat(llm-gateway): add free onboarding p..." | Re-trigger Greptile

Comment thread services/llm-gateway/src/llm_gateway/products/config.py
@stamphog

stamphog Bot commented Jul 28, 2026

Copy link
Copy Markdown

Note

🤖 stamphog reviewed 650cd0ebba1a9159ef2dd4b02e3bfd28f2ca715d — verdict: REFUSED

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.

  • Author wrote 0% of the modified lines and has 6 merged PRs in these paths (familiarity MODERATE).
  • greptile-apps[bot], hex-security-app[bot], veria-ai[bot] reviewed the current head.
  • Unresolved high-severity finding (hex-security-app, corroborated by veria-ai and greptile-apps) on services/llm-gateway/src/llm_gateway/products/config.py: requires_server_credential for the onboarding product is only enforced when posthog_code_model_gate_enabled is true, which defaults to false, so a normal PostHog Code OAuth token can call the unbilled onboarding endpoint directly.
  • Unresolved medium-severity finding (hex-security-app) on products/tasks/backend/facade/api.py: the onboarding model pin lives in mutable TaskRun.state and PATCH does not protect the 'model' key, so any team member could switch a queued wizard run to a costlier allowlisted model before it's processed.
  • Change is in billing/auth-sensitive territory (new unbilled credit bucket, task-origin authorization) with cross-team authorship and only MODERATE familiarity — assurance from reviewers exists but points at real unfixed gaps rather than clearing the change.
Gate mechanics and policy version
Gate Result
prerequisites all clear
deny-list no deny categories matched
size 33L, 4F substantive, 67L/8F incl. docs/generated/snapshots — within ceiling
tier T1-agent / T1c-medium (67L, 8F, two-areas, feat)
stamphog 2.0.0b3 .stamphog/policy.yml @ 953cac2 · reviewed head 650cd0e

@stamphog stamphog Bot removed the stamphog Request AI approval (no full review) label Jul 28, 2026
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>
rafaeelaudibert added a commit that referenced this pull request Jul 28, 2026
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>
@rafaeelaudibert
rafaeelaudibert force-pushed the rafa/free-onboarding-llm-gateway-product branch from 650cd0e to a199363 Compare July 28, 2026 06:21
Comment thread services/llm-gateway/src/llm_gateway/products/config.py

@richardsolomou richardsolomou left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

rafaeelaudibert and others added 2 commits July 28, 2026 09:18
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>
@rafaeelaudibert
rafaeelaudibert force-pushed the rafa/free-onboarding-llm-gateway-product branch from a199363 to 392081b Compare July 28, 2026 12:19
@rafaeelaudibert

Copy link
Copy Markdown
Member Author

@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>
@rafaeelaudibert
rafaeelaudibert merged commit a06c4d3 into master Jul 28, 2026
238 checks passed
@rafaeelaudibert
rafaeelaudibert deleted the rafa/free-onboarding-llm-gateway-product branch July 28, 2026 19:12
@deployment-status-posthog

deployment-status-posthog Bot commented Jul 28, 2026

Copy link
Copy Markdown

Deploy status

Environment Status Deployed At Workflow
dev ✅ Deployed 2026-07-28 19:56 UTC Run
prod-us ✅ Deployed 2026-07-28 20:12 UTC Run
prod-eu ✅ Deployed 2026-07-28 20:12 UTC Run

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants