Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions products/tasks/backend/facade/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,13 @@

WIZARD_PR_READY_EMAIL_FEATURE_FLAG = "wizard-cloud-run-pr-ready-email-enabled"

# Runtime posture for a setup-wizard cloud run, applied in create_wizard_cloud_run. The model is
# pinned because these runs route to the unbilled `onboarding` gateway product, which allowlists a
# narrow model set; the string form avoids pulling the temporal RuntimeAdapter enum onto this path.
WIZARD_CLOUD_RUN_RUNTIME_ADAPTER = "claude"
WIZARD_CLOUD_RUN_MODEL = "claude-sonnet-5"
WIZARD_CLOUD_RUN_AI_STAGE = "wizard_pr_agent"

__all__ = [
"CODE_INVITE_INVALID_CODE",
"CODE_INVITE_NOT_REDEEMABLE",
Expand Down Expand Up @@ -900,6 +907,10 @@ def create_wizard_cloud_run(
The PR head branch is generated here (not by the agent) so the GitHub PR webhook can bind the
opened PR back to this run by branch + repository — wizard PRs are bot-authored, which the
agent-side PR attribution cannot match.

The model is pinned rather than left to the agent's default because these runs bill to nobody:
they route to the unbilled ``onboarding`` gateway product, whose model allowlist is narrow, and
PostHog absorbs the cost. Keep the pin inside that allowlist or the run fails at the gateway.
"""
head_branch = generate_wizard_head_branch()
prompt = build_wizard_pr_agent_prompt(head_branch)
Expand All @@ -916,6 +927,9 @@ def create_wizard_cloud_run(
wizard_config={},
wizard_head_branch=head_branch,
posthog_mcp_scopes="read_only",
runtime_adapter=WIZARD_CLOUD_RUN_RUNTIME_ADAPTER,
model=WIZARD_CLOUD_RUN_MODEL,
Comment thread
rafaeelaudibert marked this conversation as resolved.
ai_stage=WIZARD_CLOUD_RUN_AI_STAGE,
# The agent server boots idle; this is the message that actually kicks it off once ready
# (delivered by forward_pending_user_message). Without it the run stalls after "Started agent".
pending_user_message=prompt,
Expand Down Expand Up @@ -1769,6 +1783,16 @@ def _sync_automation_schedule(automation: TaskAutomation) -> None:
"loop_trigger_id",
"trigger_context",
"config_snapshot",
# The run's model posture, chosen at creation by the server-owned caller and read back out
# of state when the run dispatches. It decides what the run costs, and for a run routed to
# an unbilled gateway product (create_wizard_cloud_run pins claude-sonnet-5 for the
# `onboarding` product) it is the only thing keeping the run off the more expensive models
# that product still allowlists. Every writer is server-side, so nothing legitimate PATCHes
# these.
"runtime_adapter",
"provider",
"model",
"reasoning_effort",
}
)

Expand Down
5 changes: 5 additions & 0 deletions products/tasks/backend/presentation/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,11 @@ def validate_origin_product(self, value):
# would route the task's run logs into PostHog's internal Logs project
# (run_log_mirror) and inherit scout visibility semantics.
raise serializers.ValidationError("origin_product 'signals_scout' is reserved for signals scout runs")
if value == tasks_facade.TaskOriginProduct.ONBOARDING:
# This origin routes the run's LLM traffic to the unbilled `onboarding` gateway
# product, so a forged one would be free model access. Only create_wizard_cloud_run
# sets it, behind its own rate limits and daily cap.
raise serializers.ValidationError("origin_product 'onboarding' is reserved for setup wizard cloud runs")
return value

def validate_repository(self, value):
Expand Down
27 changes: 26 additions & 1 deletion products/tasks/backend/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1267,6 +1267,7 @@ def test_create_task_with_hogdesk_origin_product(self):
[
("image_builder",),
("experiments",),
("onboarding",),
]
)
def test_create_task_rejects_internal_origin(self, origin: str):
Expand Down Expand Up @@ -4385,6 +4386,10 @@ def test_patch_cannot_mutate_protected_credential_state_keys(self, _mock_publish
"pending_dispatch": {"workflow_id_prefix": "review-real", "create_pr": True},
"pending_external_followups": pending_external_followups,
"pending_external_followups_generation": 7,
"runtime_adapter": "claude",
"provider": "anthropic",
"model": "claude-sonnet-5",
"reasoning_effort": "low",
},
)

Expand All @@ -4394,7 +4399,8 @@ def test_patch_cannot_mutate_protected_credential_state_keys(self, _mock_publish
# (which would mint a write-scoped wizard token into the sandbox), change rollout
# decisions, change Modal resume snapshot metadata, repoint the run at another
# team's Temporal workflow, or steer an orphan re-dispatch (workflow ID prefix / MCP
# scopes) via pending_dispatch. Non-protected keys still merge.
# scopes) via pending_dispatch, or repoint the run at a costlier model (which for a run
# routed to an unbilled gateway product is free spend). Non-protected keys still merge.
response = self.client.patch(
f"/api/projects/@current/tasks/{task.id}/runs/{run.id}/",
{
Expand Down Expand Up @@ -4424,6 +4430,10 @@ def test_patch_cannot_mutate_protected_credential_state_keys(self, _mock_publish
}
],
"pending_external_followups_generation": 999,
"runtime_adapter": "codex",
"provider": "openai",
"model": "claude-opus-4-8",
"reasoning_effort": "high",
"scratch": "ok",
}
},
Expand All @@ -4450,6 +4460,10 @@ def test_patch_cannot_mutate_protected_credential_state_keys(self, _mock_publish
assert run.state["pending_dispatch"] == {"workflow_id_prefix": "review-real", "create_pr": True}
assert run.state["pending_external_followups"] == pending_external_followups
assert run.state["pending_external_followups_generation"] == 7
assert run.state["runtime_adapter"] == "claude"
assert run.state["provider"] == "anthropic"
assert run.state["model"] == "claude-sonnet-5"
assert run.state["reasoning_effort"] == "low"
assert run.state["scratch"] == "ok" # non-protected keys still merge

# Nor can a caller remove a protected key to force a fallback or unguarded path.
Expand All @@ -4470,6 +4484,10 @@ def test_patch_cannot_mutate_protected_credential_state_keys(self, _mock_publish
"pending_dispatch",
"pending_external_followups",
"pending_external_followups_generation",
"runtime_adapter",
"provider",
"model",
"reasoning_effort",
"scratch",
],
},
Expand All @@ -4489,6 +4507,13 @@ def test_patch_cannot_mutate_protected_credential_state_keys(self, _mock_publish
assert run.state["pending_dispatch"] == {"workflow_id_prefix": "review-real", "create_pr": True}
assert run.state["pending_external_followups"] == pending_external_followups
assert run.state["pending_external_followups_generation"] == 7
# Dropping the model posture is as good as repointing it: the processing context reads these
# back with .get(), so an absent key silently falls back to the runtime's default rather than
# the pin the server chose.
assert run.state["runtime_adapter"] == "claude" # protected key survives removal
assert run.state["provider"] == "anthropic" # protected key survives removal
assert run.state["model"] == "claude-sonnet-5" # protected key survives removal
assert run.state["reasoning_effort"] == "low" # protected key survives removal
assert "scratch" not in run.state # non-protected key removed

@patch("products.tasks.backend.facade.api.signal_workflow_completion")
Expand Down
17 changes: 17 additions & 0 deletions products/tasks/backend/tests/test_facade.py
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,23 @@ def test_create_wizard_cloud_run_seeds_pending_user_message(self, _mock_workflow
# and the run never opens a PR. Wizard runs must pin the overlap boot off.
self.assertIs(run.state.get("overlap_clone_boot_enabled"), False)

@patch("products.tasks.backend.temporal.client.execute_task_processing_workflow")
def test_create_wizard_cloud_run_pins_its_model(self, _mock_workflow):
Integration.objects.create(team=self.team, kind="github", config={})
created = facade.create_wizard_cloud_run(
team=self.team,
user_id=self.user.id,
repository="acme-co/web",
)
run = TaskRun.objects.get(task_id=created.task_id)
# Wizard runs route to the unbilled `onboarding` gateway product, which allowlists only
# these models. Dropping the pin puts the run back on the agent-server's premium default,
# which that product rejects, so every wizard cloud run would 403 at the gateway. Changing
# the pin means changing the allowlist in services/llm-gateway too.
self.assertEqual(run.state.get("runtime_adapter"), "claude")
self.assertEqual(run.state.get("model"), "claude-sonnet-5")
self.assertEqual(run.state.get("ai_stage"), "wizard_pr_agent")


class TestRecentWizardCloudRunTimes(TestCase):
organization: ClassVar[Organization]
Expand Down
6 changes: 4 additions & 2 deletions services/llm-gateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ OAuth access is permitted only for products with an explicit `allowed_applicatio
| `ci` | API key only | All | CI / e2e test runs |
| `posthog_code` | OAuth only | Restricted set | Desktop coding agent |
| `background_agents` | OAuth only | Restricted set | Cloud background agents |
| `onboarding` | OAuth only | claude-sonnet-5 | Unbilled setup wizard cloud run |
| `wizard` | API key + OAuth | All | Max AI assistant |
| `django` | API key only | All | Server-side Django calls |
| `growth` | API key only | All | Growth team |
Expand Down Expand Up @@ -334,5 +335,6 @@ response = client.chat.completions.create(
```

`ai_product` and `$ai_billable` are derived from the product config (`products/config.py`):
the route sets `ai_product` from the `product` arg, and `$ai_billable` from that product's
`billable` flag. Set `billable=True` on the product config to bill its generations.
the route sets `ai_product` from the `product` arg, and `$ai_billable` from whether that
product has a `credit_bucket`. Set `credit_bucket` on the product config to bill its
generations into that bucket; leave it `None` to keep them unbilled.
14 changes: 14 additions & 0 deletions services/llm-gateway/src/llm_gateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class UserCostLimit(BaseModel, frozen=True):
"wizard": ProductCostLimit(limit_usd=10000.0, window_seconds=86400),
"posthog_code": ProductCostLimit(limit_usd=5000.0, window_seconds=3600),
"background_agents": ProductCostLimit(limit_usd=1000.0, window_seconds=3600),
"onboarding": ProductCostLimit(limit_usd=1000.0, window_seconds=3600),
"django": ProductCostLimit(limit_usd=5000.0, window_seconds=86400),
"custom_image_scans": ProductCostLimit(limit_usd=1000.0, window_seconds=86400),
"signals": ProductCostLimit(limit_usd=25000.0, window_seconds=86400),
Expand Down Expand Up @@ -61,6 +62,19 @@ class UserCostLimit(BaseModel, frozen=True):
sustained_limit_usd=10000.0,
sustained_window_seconds=2592000,
),
# Nobody is billed for onboarding (credit_bucket=None), so this bounds blast radius rather than
# spend: the route's server-credential marker proves a token was minted server-side, not that it
# belongs to a wizard run, and INTERNAL_SCOPES in posthog/temporal/oauth.py grants that marker to
# every task run. Sized to stay clear of real onboarding rather than to be tight, since cutting a
# user off mid-setup is worse than the unbilled spend: half of DEFAULT_USER_COST_LIMIT, and well
# under the comparable agentic product (background_agents, $500/week burst). Staff bypass this
# entirely via is_usage_unlimited, so internal runs are never capped by it.
"onboarding": UserCostLimit(
burst_limit_usd=50.0,
burst_window_seconds=86400,
sustained_limit_usd=500.0,
sustained_window_seconds=2592000,
),
}

FREE_PLAN_COST_LIMIT = UserCostLimit(
Expand Down
32 changes: 29 additions & 3 deletions services/llm-gateway/src/llm_gateway/products/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,18 @@ class ProductConfig:
}
)

# Products whose requires_server_credential applies right away rather than waiting for
# posthog_code_model_gate_enabled. The flag exists so products that already shipped accepting plain
# Code OAuth tokens keep working until the Code billing cutover. A product that never had such a
# permissive period has nothing to stay compatible with, and leaving it flag-gated would ship an
# unbilled route open to any Code OAuth token for as long as the flag is off.
UNCONDITIONAL_SERVER_CREDENTIAL_PRODUCTS: Final[frozenset[str]] = frozenset(
{
"custom_image_scans",
"onboarding",
}
)

PRODUCTS: Final[dict[str, ProductConfig]] = {
"llm_gateway": ProductConfig(
allowed_application_ids=None,
Expand Down Expand Up @@ -139,6 +151,19 @@ class ProductConfig:
credit_bucket=None,
requires_server_credential=True,
),
# The setup wizard's cloud run (Task.OriginProduct.ONBOARDING). Unbilled like
# background_agents, and this one runs before the user has decided to buy anything.
# Two gates keep the free route shut: Django refuses `onboarding` as a caller-supplied
# task origin, and the agent-server only routes here for a run carrying the protected
# `wizard_config` state key. Models stay narrow because a free bucket shouldn't reach
# the whole fleet; claude-opus-4-8 is only the SDK's fallback for the pinned sonnet.
"onboarding": ProductConfig(
Comment thread
rafaeelaudibert marked this conversation as resolved.
allowed_application_ids=frozenset({POSTHOG_CODE_US_APP_ID, POSTHOG_CODE_EU_APP_ID, POSTHOG_CODE_DEV_APP_ID}),
Comment thread
veria-ai[bot] marked this conversation as resolved.
allowed_models=frozenset({"claude-sonnet-5", "claude-opus-4-8"}) | BEDROCK_MODELS,
allow_api_keys=False,
credit_bucket=None,
requires_server_credential=True,
Comment thread
veria-ai[bot] marked this conversation as resolved.
Comment thread
rafaeelaudibert marked this conversation as resolved.
),
"slack_app": ProductConfig(
allowed_application_ids=frozenset({POSTHOG_CODE_US_APP_ID, POSTHOG_CODE_EU_APP_ID, POSTHOG_CODE_DEV_APP_ID}),
allowed_models=_POSTHOG_CODE_AGENT_MODELS | BEDROCK_MODELS,
Expand Down Expand Up @@ -418,13 +443,14 @@ def check_product_access(
# and route around the posthog_code free-tier model gate. Require the internal marker that
# only server-minted tokens carry. OAuth-only: personal API keys reach the gateway with an
# explicit, feature-gated llm_gateway:read scope (a `*` PAK is rejected at auth), so the
# shared server-side gateway key still works here. Gated behind the same flag as the
# free-tier gate so it stays inert until the Code billing cutover.
# shared server-side gateway key still works here. Products that shipped before this check
# existed stay behind the free-tier flag so they keep working until the Code billing cutover;
# the rest enforce it now, per UNCONDITIONAL_SERVER_CREDENTIAL_PRODUCTS.
if (
config.requires_server_credential
and is_oauth
and INTERNAL_RUN_SCOPE not in (scopes or [])
and (settings.posthog_code_model_gate_enabled or resolved_product == "custom_image_scans")
and (settings.posthog_code_model_gate_enabled or resolved_product in UNCONDITIONAL_SERVER_CREDENTIAL_PRODUCTS)
):
return False, f"Product '{product}' requires a server-minted credential"

Expand Down
Loading
Loading