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
What can feed the model picker on a function node, and how much does a live model list cost?
The constraint: inputs_schemachoice fields carry their choices array baked into the Hog template at build time (nodejs/src/cdp/schema/cyclotron.ts). There is no dynamic or async option source for a plain choice. The only dynamic input type is integration_field, which is coupled to an OAuth integration record.
Establish as fact:
What each candidate source offers. GET /api/llm_proxy/models/ (products/ai_observability/backend/api/proxy.py) is session-authenticated and readable by the web frontend, but its ModelInfo carries no reasoning-effort capability. The gateway's own registry (services/llm-gateway/.../model_registry.py) does carry supported_reasoning_levels, but sits behind the privileged llm_gateway:read scope.
Whether reasoning effort is uniform across models or varies per model. products/desktop/packages/agent/src/adapters/reasoning-effort.ts implies it varies.
What a custom input renderer actually costs: registering in CUSTOM_INPUT_RENDERERS plus adding the type string to four separate unions that are already drifting. Reference implementation: posthog_assignee in products/conversations/frontend/components/Assignee/.
Whether the existing LemonUI ModelPicker (products/ai_observability/frontend/ModelPicker.tsx) is reusable inside a Cyclotron input, or is too coupled to its own logic.
How other Hog templates handle a list that changes over time, and how often a baked list would actually go stale.
Report findings with file:line references. The picker's shape is decided in the ticket it blocks.
Facts only. Every claim carries a file:line. Where the repo cannot settle a question, it says so.
1. What each candidate source offers
GET /api/llm_proxy/models/ — session-readable, no reasoning data, stale default list.
Registered on the root router (not project-scoped) at products/ai_observability/backend/routes.py:38, mounted at posthog/urls.py:518. The frontend already calls it at products/ai_observability/frontend/modelPickerLogic.ts:216.
Auth, at products/ai_observability/backend/api/proxy.py:140-142:
Session-only. No personal API key, no project secret key, no scopes. The viewset carries no scope_object deliberately — the docstring at proxy.py:100-101 says it "would imply personal API key support that SessionAuthentication doesn't allow." PlaygroundAccessPermission (proxy.py:94-131) requires llm_playground access at viewer and a set user.current_team (proxy.py:118-121). The models action is exempt from throttling (proxy.py:146-149).
The payload is a bare JSON array (proxy.py:459-473) of ModelInfo, a TypedDict at products/ai_observability/backend/llm/__init__.py:15-20. Five fields, complete:
No reasoning field. The backend does know which models support thinking — SUPPORTED_MODELS_WITH_THINKING at products/ai_observability/backend/llm/__init__.py:10-12 — but it is only used to validate a completion request (proxy.py:264-266) and is never serialized into the models response. The playground UI consequently hardcodes a flat effort list with no per-model gating (products/ai_observability/frontend/playground/AIObservabilityPlaygroundScene.tsx:588-604).
Two branches (proxy.py:430-473):
No provider_key_id (the default a workflow node would hit): returns get_playground_models() (proxy.py:473), a static Python list assembled from per-provider constants — products/ai_observability/backend/llm/__init__.py:44-53, providers/openai.py:77-85, providers/gemini.py:57-63, providers/anthropic.py:75-95. No cache; it is an in-process constant. It is behind the frontier: the OpenAI playground subset tops out at gpt-5-mini / o4-mini (providers/openai.py:77-85), and no claude-opus-5, claude-sonnet-5, claude-fable-5, gpt-5.5, or gpt-5.6-* appears anywhere in this catalogue.
With provider_key_id (BYOK): live call to the customer's own provider (proxy.py:452), cached 60 s (MODELS_CACHE_TIMEOUT_SECONDS = 60, proxy.py:58, used at :456). Requires an LLMProviderKey row on the team (proxy.py:181-188), so it is not available to a team without BYOK configured.
There is a second, adjacent endpoint worth not confusing it with: LLMModelsViewSet at products/ai_observability/backend/api/models.py:29-34, project-scoped via routes.py:87, scope_object = "evaluation". It also returns provider-listed models with no reasoning data.
The gateway registry does not carry reasoning levels. The ticket's premise is wrong on this point.
No reasoning field. supported_reasoning_levels exists in exactly one line in the whole repo — services/llm-gateway/src/llm_gateway/api/models.py:44, on the API response model ModelObject, defaulted to []:
_build_response() (api/models.py:66-80) constructs each ModelObject from only id/slug/display_name/owned_by/context_window/supports_streaming/supports_vision. supported_reasoning_levels is never populated and always serializes as []. Nothing reads it. So the gateway catalog is not a source of reasoning-effort capability today, whatever its scope.
Two related corrections to the ticket's framing:
The registry is not static: it is derived from litellm's cost map, fetched over the network and cached 300 s (services/llm-gateway/src/llm_gateway/rate_limiting/model_cost_service.py:6-11, :17, :79-109), intersected with configured provider keys (model_registry.py:53-61) and a per-product allowlist (model_registry.py:118-177). The nearest fixed number is the 20-model _POSTHOG_CODE_AGENT_MODELS allowlist at services/llm-gateway/src/llm_gateway/products/config.py:70-93.
The models endpoints are not behind llm_gateway:read. GET /v1/models and GET /{product}/v1/models (services/llm-gateway/src/llm_gateway/api/models.py:104-126) declare no auth dependency at all, and create_app() installs no auth middleware (services/llm-gateway/src/llm_gateway/main.py:283-305). Tests call them with no Authorization header and assert 200 (services/llm-gateway/tests/test_models_api.py:149,182,192). The real barrier is network reachability of LLM_GATEWAY_URL, not a scope.
For completeness on the scope itself, since the ticket asks: llm_gateway is declared at posthog/scopes.py:74 and classified privileged at posthog/scopes.py:232 (PRIVILEGED_SCOPES = frozenset({"llm_gateway:read", "llm_gateway:write"})). It is filtered out of self-serve OAuth registration (posthog/scopes.py:314, :540), flag-gated on personal API keys (posthog/api/personal_api_key.py:49-54) and on project secret keys (posthog/api/project_secret_api_key.py:71-105). A browser session cannot obtain it — Django session auth carries no API scopes.
posthog/llm/gateway_client.py has no model-listing function. Verified by grep; the file's public surface is client construction. But listing is reachable through the returned OpenAI SDK client, whose base_url is f"{settings.LLM_GATEWAY_URL.rstrip('/')}/{product}/v1" (gateway_client.py:100), so .models.list() hits /{product}/v1/models. products/slack_app does exactly this at products/slack_app/backend/services/llm_models.py:47. Auth to the gateway is the shared settings.LLM_GATEWAY_API_KEY (gateway_client.py:98-105).
The one place in the repo that already joins a live model list to per-model reasoning efforts is products/slack_app/backend/services/model_catalogue.py:75-99 — available_model_choices() returns ModelChoice(runtime_adapter, model, label, supported_efforts) (:65-72), with efforts from the tasks facade (:96). It has no HTTP surface; it renders into Slack Block Kit. Its live list is cached 30 min (products/slack_app/backend/services/llm_models.py:24).
Other frontend-readable model lists, all static and hand-maintained:
Static Python, current, and the de-facto canonical map (below)
There is no generated TypeScript union of model names anywhere — grep -n "gpt-5\|claude-" over products/tasks/frontend/generated/api.schemas.ts returns nothing. The reasoning-effort enum is generated (api.schemas.ts:215-217); the model list is not.
2. Reasoning effort is per-model, not uniform
Established. It varies by model, by adapter, and by provider surface, and there is no single canonical registry — the closest thing lives in products/tasks, not in either gateway.
products/desktop/packages/agent/src/adapters/reasoning-effort.ts is a dispatch shim, not the data. getReasoningEffortOptions(adapter, modelId) at :18-28 returns ReasoningEffortOption[] | null, where null means the model has no reasoning-effort control at all.
The de-facto canonical table is CLAUDE_REASONING_EFFORTS_BY_MODEL at products/tasks/backend/temporal/process_task/utils.py:108-174:
Codex side (utils.py:176-190): base low, medium, high; +xhigh for {"gpt-5.5"}; +xhigh, +max for {"gpt-5.6-sol","gpt-5.6-terra","gpt-5.6-luna"}.
So the spread is real: one model accepts nothing, one accepts only high, max, others accept three or five or six levels. A single global effort dropdown would offer illegal combinations.
The repo states this itself, at posthog/temporal/ai/slack_app/activities/classifiers.py:324-340, which deliberately leaves reasoning_effort unconstrained in an LLM schema because "which efforts are valid depends on the model the run finally lands on, not on the catalogue-wide union."
There are at least five different effort vocabularies in the repo:
low, medium, high, xhigh, max — comment at :105 claims it mirrors the tasks enum; it does not
products/desktop/packages/api-client/src/generated.ts:4223 also drops ultracode while products/tasks/frontend/generated/api.zod.ts:90 — generated from the same Django schema — keeps it.
ai_observability is an island: its vocabulary, its boolean thinking flag, and its fixed budget_tokens=4096 (products/ai_observability/backend/llm/providers/anthropic.py:43, :273-274) share nothing with the tasks/desktop model, and its supported-model lists name an older model generation entirely (providers/anthropic.py:83-94, providers/openai.py:89-99).
Not establishable from this repo: whether the Go PostHog/ai-gateway defines per-model reasoning levels. It is not vendored here (referenced only as an external repo in AGENTS.md:188), and services/llm-gateway/PARITY.md contains zero occurrences of "reasoning", "thinking", or "effort".
3. What a custom input renderer actually costs
Registration itself is cheap.CUSTOM_INPUT_RENDERERS at frontend/src/lib/components/CyclotronJob/customInputRenderers.ts:17-38 is a flat Record<string, LazyExoticComponent>; adding an entry is one lazy import. The dispatch is a single default: branch at frontend/src/lib/components/CyclotronJob/CyclotronJobInputs.tsx:707, which falls through to Unknown field type "<type>" when no renderer matches. The component contract is five props (customInputRenderers.ts:8-14): schema, value, onChange, optional sampleGlobalsWithInputs.
The reference implementation products/conversations/frontend/components/Assignee/CyclotronJobInputAssignee.tsx is 43 lines — it mounts an existing kea logic (assigneeSelectLogic), wraps an existing dropdown, and calls onChange. That is the whole renderer.
Confirmed relevant: workflow function nodes render through this same shared component — products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.tsx:8,155 imports lib/components/CyclotronJob/CyclotronJobInputs. So a CUSTOM_INPUT_RENDERERS entry does reach a function node.
The cost is the type-string unions. There are five hand-maintained ones, not four.
The ~30 further occurrences in products/workflows/frontend/Workflows/workflowLogic.ts, hogflows/hogFlowEditorLogic.tsx, steps/stepDelayLogic.ts, and steps/stepWaitUntilTimeWindowLogic.ts are kea-typegen output (alphabetized, inlined) derived from #5. They regenerate; they are not hand-edited.
How badly they have drifted — measured:
type
cyclotron.ts
nodejs types.ts
frontend types.ts
validation.py
workflows types.ts
push_subscription
✅ :29
✅ :456
❌
❌
❌
non_failure_status_codes
❌
✅ :457
✅ :7122
✅ :320
✅ :91
all 16 others
✅
✅
✅
✅
✅
Only nodejs/src/cdp/types.ts is complete. Two of the eighteen types — 11 % — are missing from at least one union. nodejs/src/cdp/types.ts:440 carries the comment // Mostly copied from frontend types, which is the drift mechanism stated in the source.
The drift has real consequences today:
push_subscription passes the Node zod schema (cyclotron.ts:29) and is handled at runtime (nodejs/src/cdp/services/hog-executor.service.ts:377,389, hog-inputs.service.ts:133), but is rejected by the Django serializer (absent from validation.py:304-322) and would render as Unknown field type in the frontend (CyclotronJobInputs.tsx:722-724). It survives only because no shipped template uses it — grep for type: 'push_subscription' under nodejs/src/cdp/templates/ returns nothing; it appears only in tests (hog-inputs.service.test.ts:306, push-notification.service.test.ts:31).
non_failure_status_codes is missing from the Node zod schema while being fully implemented everywhere else, including a runtime read at nodejs/src/cdp/services/hog-executor-async.service.ts:425.
A second drift axis: the optional searchable flag exists in nodejs/src/cdp/types.ts, frontend/src/types.ts, and posthog/cdp/validation.py:328-329, but not in either zod schema (cyclotron.ts, workflows/.../steps/types.ts) — grep count is 0 in both.
Generated artifacts that must be regenerated after touching validation.py (12 files carry the enum):
5 MCP tool-schema snapshots under services/mcp/tests/unit/__snapshots__/tool-schemas/
hogli build:openapi covers the generated TS; the MCP snapshots update with the MCP test suite.
Backend value validation is a sixth touchpoint.posthog/cdp/validation.py validates the input value by type in a long elif chain (:390-436) and separately decides whether the value gets Hog-transpiled via a hardcoded list at :445-455. A new type that is a plain string needs no entry (it falls through), but one that is a dict or list does. posthog/cdp/test/test_validation.py:750-801 shows the expected test shape, including the regression note at :796-801 about the InputsItemSerializer opt-in.
Total for a new type, floor: 5 union edits + 1 registry entry + 1 component + 1 generated-types run + validation tests. The unions are one-line edits each; the work is in knowing all five exist, which is exactly the failure mode the current 11 % drift demonstrates.
4. Is ModelPicker reusable inside a Cyclotron input?
Yes. It is fully presentational and takes its data as a prop.
products/ai_observability/frontend/ModelPicker.tsx is 222 lines. Its props (:27-37) are model, selectedProviderKeyId, onSelect, groups: ProviderModelGroup[], loading, footerLink, placeholder, selectedModelName, data-attr. It mounts no kea logic — it holds two useState hooks for search and expand state (:77-78) and imports only LemonMenu/LemonButton/LemonInput/LemonSkeleton/Link, LLMProviderIcon, and two types. The only non-prop coupling is a settings link constant PROVIDER_SETTINGS_URL (:14), used solely through the optional footerLink prop — pass footerLink={null} and it drops out.
Three existing call sites already treat it this way, each supplying its own data: evaluations/AIObservabilityEvaluation.tsx:791, playground/AIObservabilityPlaygroundScene.tsx:501, tags/AIObservabilityTag.tsx:135.
The coupling lives in the logic, not the component. products/ai_observability/frontend/modelPickerLogic.ts:162-169connects llmProviderKeysLogic for BYOK keys, and its loader (:170-216) branches on provider keys before falling back to /api/llm_proxy/models/. A workflows renderer would not reuse that logic; it would build ProviderModelGroup[] itself. The helper buildPlaygroundProviderModelGroups(models) at modelPickerLogic.ts:34-59 does exactly that shaping from a flat ModelOption[] and is already exported — but note it drops any model whose provider does not map through toLLMProvider (:46-48), so a gateway-sourced list with providers outside LLMProvider would silently lose entries.
One caveat: ModelPicker has no affordance for reasoning effort. ModelOption (modelPickerLogic.ts:17-24) is id, name, provider, description, providerKeyId?, isRecommended?. A per-model effort control would be a sibling component, not a prop on this one — which is how the playground does it (AIObservabilityPlaygroundScene.tsx:588-604 renders effort separately) and how the composer does it (products/posthog_ai/frontend/components/composer/ComposerModelEffortPickers.tsx:15,65).
5. How other Hog templates handle a drifting list, and how stale a baked list gets
No Hog template anywhere in the repo lists LLM model names. Grepped nodejs/src/cdp/templates/ and posthog/cdp/templates/ for gpt-, claude-, gemini-, and key: 'model' — zero hits. There is no precedent to copy.
There are 22 type: 'choice' inputs across the TS templates and 13 across the Python ones. The vendor-owned examples:
All literal arrays. No runtime resolution of any kind.
The update path is: code edit → deploy → a startup task re-syncs the template row. Collection at posthog/management/commands/sync_hog_function_templates.py:74-121, write via sync_template_to_db (posthog/cdp/templates/hog_function_template.py:79-90), queued once per deploy behind a Redis setnx lock (products/cdp/backend/tasks/hog_functions.py:124-137, from posthog/apps.py:152-157). The frontend reads inputs_schema from the DB at request time via PublicHogFunctionTemplateViewSet (products/cdp/backend/api/hog_function_template.py:88-98, routed at products/cdp/backend/routes.py:64-69), called from frontend/src/lib/api.ts:705.
Template versioning exists but is vestigial.products/cdp/backend/models/hog_function_template.py:22-23,63 has template_id + sha with unique_together, and _generate_sha_from_content (:74-97) hashes inputs_schema. But sync_template_to_db (hog_function_template.py:82-88) passes the existing latest row as the serializer instance, so the sync mutates in place rather than appending a version. get_template accepts a sha (:99-128) but nothing passes one. The Node manager carries the comment "Currently this essentially loads the 'latest' template each time. We may need to swap this to using a specific version" (nodejs/src/cdp/services/managers/hog-function-template-manager.service.ts:41). No -v2-style template ids exist.
Already-configured functions are pinned and never receive updated choices.HogFunction has its own inputs_schema JSON column (products/cdp/backend/models/hog_functions/hog_function.py:129), copied from the template once at creation:
Nothing refreshes it. refresh_hog_functions.py only recompiles filters; resave_hog_functions.py only touches integration inputs; no code path re-reads template.inputs_schema into an existing row. The frontend confirms the pinning — the template loader short-circuits to null when editing by id (frontend/src/scenes/hog-functions/configuration/hogFunctionConfigurationLogic.tsx:980-982), and case 'choice' feeds the storedschema.choices ?? [] into the select (frontend/src/lib/components/CyclotronJob/CyclotronJobInputs.tsx:617-637).
The repo's actual remediation mechanism is a hand-written string-replace management command: posthog/management/commands/update_hog_function_code.py, a hardcoded replaceOptions dict of one-off migrations (LinkedIn 202409 → 202508 at :47-54, Meta v21.0 → v25.0 at :55-61, Google Ads v19–v23 → v24 at :71-77). Its own comment states the problem: "Destinations carry whatever version the template pinned at creation" (:64-70) and "functions created earlier keep their frozen code and reject the new URL" (:80-82). It rewrites hog code only, not inputs_schema — there is no precedent for backfilling a stale choices array into existing functions at all.
There is no dynamic choice.choices is a static z.array({value, label}) at nodejs/src/cdp/schema/cyclotron.ts:33-42, mirrored at frontend/src/types.ts:7112 and posthog/cdp/validation.py:328-329. No URL, no resolver, no callback.
integration_field escapes the choice system rather than extending it: the frontend resolves it through a hardcoded if-chain mapping field names to bespoke pickers (frontend/src/lib/components/CyclotronJobInputIntegrationField.tsx:101-180), each hitting a dedicated @action on the integration viewset (posthog/api/integration.py:1244, :1337, :1478, :1603, and ~20 more). It requires a credentialed integration row — it bails with "Configure X to continue" when none is selected (CyclotronJobInputIntegrationField.tsx:92-97). Nothing else piggybacks on it. CUSTOM_INPUT_RENDERERS is the only precedent for a dynamically-populated picker that needs no OAuth integration.
How stale a baked list actually gets — measured both ways.
Vendor choice arrays in this repo are written once and effectively never maintained:
posthog/cdp/templates/meta_ads/template_meta_ads.py — 18 commits to the file. git log -L 127,172 over the actionSource choices returns exactly one: 3208def7ab1, 2024-10-14. Untouched for ~22 months while the file was edited 17 more times.
nodejs/src/cdp/templates/_destinations/whatsapp/whatsapp.template.ts — 1 commit ever (c7840acc2d0, 2026-05-27). The 71-entry locale list has never been revised.
nodejs/src/cdp/templates/_destinations/snapchat_ads/snapchat.template.ts — 8 commits, none touching the choices.
Against that, PostHog's own model-and-effort list churns roughly once every ten days. git log -L 108,174 over CLAUDE_REASONING_EFFORTS_BY_MODEL in products/tasks/backend/temporal/process_task/utils.py returns 11 commits between 2026-04-15 and 2026-07-29:
2026-07-29 bd1eada9ae9 fix(tasks): register Kimi without effort levels (#74627)
2026-07-27 4e3773eb965 feat(tasks): Accept context window and fast mode on runs (#73803)
2026-07-24 5d8b336b652 feat(llm-gateway): add claude opus 5 (#73618)
2026-07-21 c82cafa4dab fix(tasks): allow GLM reasoning levels (#72549)
2026-07-14 a41c01928bd feat(self-driving): ReviewHog (#64651)
2026-07-02 465f56e0b00 fix(tasks): support claude-sonnet-5 reasoning effort (#67681)
2026-06-11 5a88ba43c04 fix(tasks): allow reasoning effort for claude-fable-5 runs (#62949)
2026-05-28 81bb14740e1 feat(llma): add claude-opus-4-8 to ai gateway (#60507)
2026-04-28 c39208134f2 feat(tasks): add xhigh reasoning effort for opus 4.6 and 4.7 (#56767)
2026-04-17 04b03a066be feat: add claude opus 4.7 reasoning efforts (#55129)
2026-04-15 dece1e9bd9d feat(cloud-agent): codex support (#54462)
That is the quantified answer: a baked model list would need ~11 template edits a year, and each edit would reach only newly-created nodes. Every workflow already configured keeps the list it was born with, permanently, with no backfill mechanism in the repo.
Facts that could not be established from this repo
Whether the Go PostHog/ai-gateway defines a per-model reasoning-level list, under what field name. Not vendored; PARITY.md does not mention reasoning at all.
The auth, scope, and payload of GET /api/projects/:team_id/agent_applications/models/. The agent_platform Django app is not in this repo — products/desktop/packages/api-client/src/posthog-client.ts:5610-5612 says so explicitly.
Whether the sha column on HogFunctionTemplate was ever used for real multi-version pinning historically. The model supports it; no caller passes one today.
Activity
Silthus commentedon Aug 6, 2026
Research: model list for the picker
Facts only. Every claim carries a
file:line. Where the repo cannot settle a question, it says so.1. What each candidate source offers
GET /api/llm_proxy/models/— session-readable, no reasoning data, stale default list.Registered on the root router (not project-scoped) at
products/ai_observability/backend/routes.py:38, mounted atposthog/urls.py:518. The frontend already calls it atproducts/ai_observability/frontend/modelPickerLogic.ts:216.Auth, at
products/ai_observability/backend/api/proxy.py:140-142:Session-only. No personal API key, no project secret key, no scopes. The viewset carries no
scope_objectdeliberately — the docstring atproxy.py:100-101says it "would imply personal API key support thatSessionAuthenticationdoesn't allow."PlaygroundAccessPermission(proxy.py:94-131) requiresllm_playgroundaccess atviewerand a setuser.current_team(proxy.py:118-121). Themodelsaction is exempt from throttling (proxy.py:146-149).The payload is a bare JSON array (
proxy.py:459-473) ofModelInfo, a TypedDict atproducts/ai_observability/backend/llm/__init__.py:15-20. Five fields, complete:No reasoning field. The backend does know which models support thinking —
SUPPORTED_MODELS_WITH_THINKINGatproducts/ai_observability/backend/llm/__init__.py:10-12— but it is only used to validate a completion request (proxy.py:264-266) and is never serialized into the models response. The playground UI consequently hardcodes a flat effort list with no per-model gating (products/ai_observability/frontend/playground/AIObservabilityPlaygroundScene.tsx:588-604).Two branches (
proxy.py:430-473):provider_key_id(the default a workflow node would hit): returnsget_playground_models()(proxy.py:473), a static Python list assembled from per-provider constants —products/ai_observability/backend/llm/__init__.py:44-53,providers/openai.py:77-85,providers/gemini.py:57-63,providers/anthropic.py:75-95. No cache; it is an in-process constant. It is behind the frontier: the OpenAI playground subset tops out atgpt-5-mini/o4-mini(providers/openai.py:77-85), and noclaude-opus-5,claude-sonnet-5,claude-fable-5,gpt-5.5, orgpt-5.6-*appears anywhere in this catalogue.provider_key_id(BYOK): live call to the customer's own provider (proxy.py:452), cached 60 s (MODELS_CACHE_TIMEOUT_SECONDS = 60,proxy.py:58, used at:456). Requires anLLMProviderKeyrow on the team (proxy.py:181-188), so it is not available to a team without BYOK configured.There is a second, adjacent endpoint worth not confusing it with:
LLMModelsViewSetatproducts/ai_observability/backend/api/models.py:29-34, project-scoped viaroutes.py:87,scope_object = "evaluation". It also returns provider-listed models with no reasoning data.The gateway registry does not carry reasoning levels. The ticket's premise is wrong on this point.
services/llm-gateway/src/llm_gateway/services/model_registry.py:30-36:No reasoning field.
supported_reasoning_levelsexists in exactly one line in the whole repo —services/llm-gateway/src/llm_gateway/api/models.py:44, on the API response modelModelObject, defaulted to[]:_build_response()(api/models.py:66-80) constructs eachModelObjectfrom onlyid/slug/display_name/owned_by/context_window/supports_streaming/supports_vision.supported_reasoning_levelsis never populated and always serializes as[]. Nothing reads it. So the gateway catalog is not a source of reasoning-effort capability today, whatever its scope.Two related corrections to the ticket's framing:
services/llm-gateway/src/llm_gateway/rate_limiting/model_cost_service.py:6-11,:17,:79-109), intersected with configured provider keys (model_registry.py:53-61) and a per-product allowlist (model_registry.py:118-177). The nearest fixed number is the 20-model_POSTHOG_CODE_AGENT_MODELSallowlist atservices/llm-gateway/src/llm_gateway/products/config.py:70-93.llm_gateway:read.GET /v1/modelsandGET /{product}/v1/models(services/llm-gateway/src/llm_gateway/api/models.py:104-126) declare no auth dependency at all, andcreate_app()installs no auth middleware (services/llm-gateway/src/llm_gateway/main.py:283-305). Tests call them with noAuthorizationheader and assert 200 (services/llm-gateway/tests/test_models_api.py:149,182,192). The real barrier is network reachability ofLLM_GATEWAY_URL, not a scope.For completeness on the scope itself, since the ticket asks:
llm_gatewayis declared atposthog/scopes.py:74and classified privileged atposthog/scopes.py:232(PRIVILEGED_SCOPES = frozenset({"llm_gateway:read", "llm_gateway:write"})). It is filtered out of self-serve OAuth registration (posthog/scopes.py:314,:540), flag-gated on personal API keys (posthog/api/personal_api_key.py:49-54) and on project secret keys (posthog/api/project_secret_api_key.py:71-105). A browser session cannot obtain it — Django session auth carries no API scopes.posthog/llm/gateway_client.pyhas no model-listing function. Verified by grep; the file's public surface is client construction. But listing is reachable through the returned OpenAI SDK client, whosebase_urlisf"{settings.LLM_GATEWAY_URL.rstrip('/')}/{product}/v1"(gateway_client.py:100), so.models.list()hits/{product}/v1/models.products/slack_appdoes exactly this atproducts/slack_app/backend/services/llm_models.py:47. Auth to the gateway is the sharedsettings.LLM_GATEWAY_API_KEY(gateway_client.py:98-105).The one place in the repo that already joins a live model list to per-model reasoning efforts is
products/slack_app/backend/services/model_catalogue.py:75-99—available_model_choices()returnsModelChoice(runtime_adapter, model, label, supported_efforts)(:65-72), with efforts from the tasks facade (:96). It has no HTTP surface; it renders into Slack Block Kit. Its live list is cached 30 min (products/slack_app/backend/services/llm_models.py:24).Other frontend-readable model lists, all static and hand-maintained:
products/posthog_ai/frontend/utils/composerModels.ts:15-19(COMPOSER_MODELS) +:35-60(EFFORTS_BY_MODEL):33-34says it mirrors the backend mapproducts/desktop/packages/ui/src/features/loops/loopModels.ts:40-61:92-101)products/desktop/packages/ui/src/features/agent-applications/hooks/useModelCatalog.ts:8-15gpt-5.5/gpt-5.6-*products/tasks/backend/temporal/process_task/utils.py:108-174There is no generated TypeScript union of model names anywhere —
grep -n "gpt-5\|claude-"overproducts/tasks/frontend/generated/api.schemas.tsreturns nothing. The reasoning-effort enum is generated (api.schemas.ts:215-217); the model list is not.2. Reasoning effort is per-model, not uniform
Established. It varies by model, by adapter, and by provider surface, and there is no single canonical registry — the closest thing lives in
products/tasks, not in either gateway.products/desktop/packages/agent/src/adapters/reasoning-effort.tsis a dispatch shim, not the data.getReasoningEffortOptions(adapter, modelId)at:18-28returnsReasoningEffortOption[] | null, wherenullmeans the model has no reasoning-effort control at all.The de-facto canonical table is
CLAUDE_REASONING_EFFORTS_BY_MODELatproducts/tasks/backend/temporal/process_task/utils.py:108-174:@cf/zai-org/glm-5.2high, max:112-115moonshotai/kimi-k3()— accepts none:116claude-opus-4-5low, medium, high:117-121claude-opus-4-6low, medium, high, xhigh, max:122-128claude-opus-4-7/4-8/claude-opus-5/claude-fable-5/claude-sonnet-5low, medium, high, xhigh, max, ultracode:129-168claude-sonnet-4-6low, medium, high:169-173Codex side (
utils.py:176-190): baselow, medium, high;+xhighfor{"gpt-5.5"};+xhigh, +maxfor{"gpt-5.6-sol","gpt-5.6-terra","gpt-5.6-luna"}.So the spread is real: one model accepts nothing, one accepts only
high, max, others accept three or five or six levels. A single global effort dropdown would offer illegal combinations.The repo states this itself, at
posthog/temporal/ai/slack_app/activities/classifiers.py:324-340, which deliberately leavesreasoning_effortunconstrained in an LLM schema because "which efforts are valid depends on the model the run finally lands on, not on the catalogue-wide union."There are at least five different effort vocabularies in the repo:
products/desktop/packages/shared/src/domain-types.ts:36-43low, medium, high, xhigh, max, ultracodeproducts/tasks/.../utils.py:78-86(ReasoningEffort)off, minimal, low, medium, high, xhigh, max, ultracodeproducts/tasks/backend/presentation/serializers.py:62(Pi)off, minimal, low, medium, high, xhigh, max— noultracodeproducts/ai_observability/frontend/playground/llmPlaygroundPromptsLogic.ts:35minimal, low, medium, high, null— plus a separatethinking: booleanproducts/signals/backend/scout_harness/model_selection.py:108low, medium, high, xhigh, max— comment at:105claims it mirrors the tasks enum; it does notproducts/desktop/packages/api-client/src/generated.ts:4223also dropsultracodewhileproducts/tasks/frontend/generated/api.zod.ts:90— generated from the same Django schema — keeps it.ai_observabilityis an island: its vocabulary, its booleanthinkingflag, and its fixedbudget_tokens=4096(products/ai_observability/backend/llm/providers/anthropic.py:43,:273-274) share nothing with the tasks/desktop model, and its supported-model lists name an older model generation entirely (providers/anthropic.py:83-94,providers/openai.py:89-99).Not establishable from this repo: whether the Go
PostHog/ai-gatewaydefines per-model reasoning levels. It is not vendored here (referenced only as an external repo inAGENTS.md:188), andservices/llm-gateway/PARITY.mdcontains zero occurrences of "reasoning", "thinking", or "effort".3. What a custom input renderer actually costs
Registration itself is cheap.
CUSTOM_INPUT_RENDERERSatfrontend/src/lib/components/CyclotronJob/customInputRenderers.ts:17-38is a flatRecord<string, LazyExoticComponent>; adding an entry is one lazy import. The dispatch is a singledefault:branch atfrontend/src/lib/components/CyclotronJob/CyclotronJobInputs.tsx:707, which falls through toUnknown field type "<type>"when no renderer matches. The component contract is five props (customInputRenderers.ts:8-14):schema,value,onChange, optionalsampleGlobalsWithInputs.The reference implementation
products/conversations/frontend/components/Assignee/CyclotronJobInputAssignee.tsxis 43 lines — it mounts an existing kea logic (assigneeSelectLogic), wraps an existing dropdown, and callsonChange. That is the whole renderer.Confirmed relevant: workflow function nodes render through this same shared component —
products/workflows/frontend/Workflows/hogflows/steps/components/HogFlowFunctionConfiguration.tsx:8,155importslib/components/CyclotronJob/CyclotronJobInputs. So aCUSTOM_INPUT_RENDERERSentry does reach afunctionnode.The cost is the type-string unions. There are five hand-maintained ones, not four.
nodejs/src/cdp/schema/cyclotron.ts:14-31(zod enum)nodejs/src/cdp/types.ts:441-459(TS union)frontend/src/types.ts:7106-7124(TS union)posthog/cdp/validation.py:304-322(DRFChoiceField)products/workflows/frontend/Workflows/hogflows/steps/types.ts:76-94(zod enum)The ~30 further occurrences in
products/workflows/frontend/Workflows/workflowLogic.ts,hogflows/hogFlowEditorLogic.tsx,steps/stepDelayLogic.ts, andsteps/stepWaitUntilTimeWindowLogic.tsare kea-typegen output (alphabetized, inlined) derived from #5. They regenerate; they are not hand-edited.How badly they have drifted — measured:
push_subscription:29:456non_failure_status_codes:457:7122:320:91Only
nodejs/src/cdp/types.tsis complete. Two of the eighteen types — 11 % — are missing from at least one union.nodejs/src/cdp/types.ts:440carries the comment// Mostly copied from frontend types, which is the drift mechanism stated in the source.The drift has real consequences today:
push_subscriptionpasses the Node zod schema (cyclotron.ts:29) and is handled at runtime (nodejs/src/cdp/services/hog-executor.service.ts:377,389,hog-inputs.service.ts:133), but is rejected by the Django serializer (absent fromvalidation.py:304-322) and would render asUnknown field typein the frontend (CyclotronJobInputs.tsx:722-724). It survives only because no shipped template uses it — grep fortype: 'push_subscription'undernodejs/src/cdp/templates/returns nothing; it appears only in tests (hog-inputs.service.test.ts:306,push-notification.service.test.ts:31).non_failure_status_codesis missing from the Node zod schema while being fully implemented everywhere else, including a runtime read atnodejs/src/cdp/services/hog-executor-async.service.ts:425.A second drift axis: the optional
searchableflag exists innodejs/src/cdp/types.ts,frontend/src/types.ts, andposthog/cdp/validation.py:328-329, but not in either zod schema (cyclotron.ts,workflows/.../steps/types.ts) — grep count is 0 in both.Generated artifacts that must be regenerated after touching
validation.py(12 files carry the enum):products/cdp/frontend/generated/api.schemas.ts,api.zod.tsproducts/desktop/packages/api-client/src/generated.tsservices/mcp/src/api/generated.ts,services/mcp/src/generated/cdp_functions/api.ts,services/mcp/src/generated/error_tracking_alerts/api.tsservices/mcp/tests/unit/__snapshots__/tool-schemas/hogli build:openapicovers the generated TS; the MCP snapshots update with the MCP test suite.Backend value validation is a sixth touchpoint.
posthog/cdp/validation.pyvalidates the input value by type in a longelifchain (:390-436) and separately decides whether the value gets Hog-transpiled via a hardcoded list at:445-455. A new type that is a plain string needs no entry (it falls through), but one that is a dict or list does.posthog/cdp/test/test_validation.py:750-801shows the expected test shape, including the regression note at:796-801about theInputsItemSerializeropt-in.Total for a new type, floor: 5 union edits + 1 registry entry + 1 component + 1 generated-types run + validation tests. The unions are one-line edits each; the work is in knowing all five exist, which is exactly the failure mode the current 11 % drift demonstrates.
4. Is
ModelPickerreusable inside a Cyclotron input?Yes. It is fully presentational and takes its data as a prop.
products/ai_observability/frontend/ModelPicker.tsxis 222 lines. Its props (:27-37) aremodel,selectedProviderKeyId,onSelect,groups: ProviderModelGroup[],loading,footerLink,placeholder,selectedModelName,data-attr. It mounts no kea logic — it holds twouseStatehooks for search and expand state (:77-78) and imports onlyLemonMenu/LemonButton/LemonInput/LemonSkeleton/Link,LLMProviderIcon, and two types. The only non-prop coupling is a settings link constantPROVIDER_SETTINGS_URL(:14), used solely through the optionalfooterLinkprop — passfooterLink={null}and it drops out.Three existing call sites already treat it this way, each supplying its own data:
evaluations/AIObservabilityEvaluation.tsx:791,playground/AIObservabilityPlaygroundScene.tsx:501,tags/AIObservabilityTag.tsx:135.The coupling lives in the logic, not the component.
products/ai_observability/frontend/modelPickerLogic.ts:162-169connectsllmProviderKeysLogicfor BYOK keys, and its loader (:170-216) branches on provider keys before falling back to/api/llm_proxy/models/. A workflows renderer would not reuse that logic; it would buildProviderModelGroup[]itself. The helperbuildPlaygroundProviderModelGroups(models)atmodelPickerLogic.ts:34-59does exactly that shaping from a flatModelOption[]and is already exported — but note it drops any model whose provider does not map throughtoLLMProvider(:46-48), so a gateway-sourced list with providers outsideLLMProviderwould silently lose entries.One caveat:
ModelPickerhas no affordance for reasoning effort.ModelOption(modelPickerLogic.ts:17-24) isid, name, provider, description, providerKeyId?, isRecommended?. A per-model effort control would be a sibling component, not a prop on this one — which is how the playground does it (AIObservabilityPlaygroundScene.tsx:588-604renders effort separately) and how the composer does it (products/posthog_ai/frontend/components/composer/ComposerModelEffortPickers.tsx:15,65).5. How other Hog templates handle a drifting list, and how stale a baked list gets
No Hog template anywhere in the repo lists LLM model names. Grepped
nodejs/src/cdp/templates/andposthog/cdp/templates/forgpt-,claude-,gemini-, andkey: 'model'— zero hits. There is no precedent to copy.There are 22
type: 'choice'inputs across the TS templates and 13 across the Python ones. The vendor-owned examples:nodejs/src/cdp/templates/_destinations/whatsapp/whatsapp.template.ts:156template_languagesearchable: trueposthog/cdp/templates/meta_ads/template_meta_ads.py:129actionSourcenodejs/src/cdp/templates/_destinations/openai_ads/openai.template.ts:58eventTypenodejs/src/cdp/templates/_destinations/snapchat_ads/snapchat.template.ts:36actionSourcenodejs/src/cdp/templates/_destinations/google_sheets/google_sheets.template.ts:79data_formatValueInputOptionAll literal arrays. No runtime resolution of any kind.
The update path is: code edit → deploy → a startup task re-syncs the template row. Collection at
posthog/management/commands/sync_hog_function_templates.py:74-121, write viasync_template_to_db(posthog/cdp/templates/hog_function_template.py:79-90), queued once per deploy behind a Redissetnxlock (products/cdp/backend/tasks/hog_functions.py:124-137, fromposthog/apps.py:152-157). The frontend readsinputs_schemafrom the DB at request time viaPublicHogFunctionTemplateViewSet(products/cdp/backend/api/hog_function_template.py:88-98, routed atproducts/cdp/backend/routes.py:64-69), called fromfrontend/src/lib/api.ts:705.Template versioning exists but is vestigial.
products/cdp/backend/models/hog_function_template.py:22-23,63hastemplate_id+shawithunique_together, and_generate_sha_from_content(:74-97) hashesinputs_schema. Butsync_template_to_db(hog_function_template.py:82-88) passes the existing latest row as the serializer instance, so the sync mutates in place rather than appending a version.get_templateaccepts asha(:99-128) but nothing passes one. The Node manager carries the comment "Currently this essentially loads the 'latest' template each time. We may need to swap this to using a specific version" (nodejs/src/cdp/services/managers/hog-function-template-manager.service.ts:41). No-v2-style template ids exist.Already-configured functions are pinned and never receive updated choices.
HogFunctionhas its owninputs_schemaJSON column (products/cdp/backend/models/hog_functions/hog_function.py:129), copied from the template once at creation:Nothing refreshes it.
refresh_hog_functions.pyonly recompiles filters;resave_hog_functions.pyonly touches integration inputs; no code path re-readstemplate.inputs_schemainto an existing row. The frontend confirms the pinning — the template loader short-circuits tonullwhen editing by id (frontend/src/scenes/hog-functions/configuration/hogFunctionConfigurationLogic.tsx:980-982), andcase 'choice'feeds the storedschema.choices ?? []into the select (frontend/src/lib/components/CyclotronJob/CyclotronJobInputs.tsx:617-637).The repo's actual remediation mechanism is a hand-written string-replace management command:
posthog/management/commands/update_hog_function_code.py, a hardcodedreplaceOptionsdict of one-off migrations (LinkedIn202409 → 202508at:47-54, Metav21.0 → v25.0at:55-61, Google Adsv19–v23 → v24at:71-77). Its own comment states the problem: "Destinations carry whatever version the template pinned at creation" (:64-70) and "functions created earlier keep their frozen code and reject the new URL" (:80-82). It rewriteshogcode only, notinputs_schema— there is no precedent for backfilling a stalechoicesarray into existing functions at all.There is no dynamic
choice.choicesis a staticz.array({value, label})atnodejs/src/cdp/schema/cyclotron.ts:33-42, mirrored atfrontend/src/types.ts:7112andposthog/cdp/validation.py:328-329. No URL, no resolver, no callback.integration_fieldescapes the choice system rather than extending it: the frontend resolves it through a hardcoded if-chain mapping field names to bespoke pickers (frontend/src/lib/components/CyclotronJobInputIntegrationField.tsx:101-180), each hitting a dedicated@actionon the integration viewset (posthog/api/integration.py:1244,:1337,:1478,:1603, and ~20 more). It requires a credentialed integration row — it bails with "Configure X to continue" when none is selected (CyclotronJobInputIntegrationField.tsx:92-97). Nothing else piggybacks on it.CUSTOM_INPUT_RENDERERSis the only precedent for a dynamically-populated picker that needs no OAuth integration.How stale a baked list actually gets — measured both ways.
Vendor choice arrays in this repo are written once and effectively never maintained:
posthog/cdp/templates/meta_ads/template_meta_ads.py— 18 commits to the file.git log -L 127,172over theactionSourcechoices returns exactly one:3208def7ab1, 2024-10-14. Untouched for ~22 months while the file was edited 17 more times.nodejs/src/cdp/templates/_destinations/whatsapp/whatsapp.template.ts— 1 commit ever (c7840acc2d0, 2026-05-27). The 71-entry locale list has never been revised.nodejs/src/cdp/templates/_destinations/snapchat_ads/snapchat.template.ts— 8 commits, none touching the choices.Against that, PostHog's own model-and-effort list churns roughly once every ten days.
git log -L 108,174overCLAUDE_REASONING_EFFORTS_BY_MODELinproducts/tasks/backend/temporal/process_task/utils.pyreturns 11 commits between 2026-04-15 and 2026-07-29:That is the quantified answer: a baked model list would need ~11 template edits a year, and each edit would reach only newly-created nodes. Every workflow already configured keeps the list it was born with, permanently, with no backfill mechanism in the repo.
Facts that could not be established from this repo
PostHog/ai-gatewaydefines a per-model reasoning-level list, under what field name. Not vendored;PARITY.mddoes not mention reasoning at all.GET /api/projects/:team_id/agent_applications/models/. Theagent_platformDjango app is not in this repo —products/desktop/packages/api-client/src/posthog-client.ts:5610-5612says so explicitly.shacolumn onHogFunctionTemplatewas ever used for real multi-version pinning historically. The model supports it; no caller passes one today.