Skip to content

feat(gateway): route and persist model experiments - #3325

Merged
markijbema merged 58 commits into
mainfrom
mark/experimental-models-gateway
May 25, 2026
Merged

markijbema merged 58 commits into
mainfrom
mark/experimental-models-gateway

Conversation

@markijbema

@markijbema markijbema commented May 19, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Implements backend model-experiment routing and attribution persistence for preview/provider-funded traffic.

  • Adds experiment routing for dedicated preview public IDs. getProvider checks a Redis-backed experimented-public-ID membership set, then loads the selected experiment, variants, and current variant versions from Postgres for preview traffic only.
  • Routes active experiments directly to the configured partner upstream via the shared buildDirectProvider path. Paused experiments return local model-unavailable behavior instead of falling through to default providers.
  • Keeps experiment API keys encrypted at rest. The chosen variant key is decrypted per request, immediately before building the direct upstream provider.
  • Captures the canonical transformed upstream request body for experimented requests, stores prompt bodies content-addressed in R2, and persists experiment attribution after the microdollar usage row is written.
  • Preserves billing correctness by making experiment attribution best-effort: prompt storage or attribution failures are reported but do not roll back usage accounting.
  • Requires BYOK_ENCRYPTION_KEY at config load and adds R2_EXPERIMENT_PROMPTS_BUCKET_NAME for prompt blob storage.

Verification

  • Sent two-variant experiment requests against a local instance and confirmed model_experiment_request rows land 1:1 with microdollar_usage.
  • Confirmed client_request_id is populated from x-kilo-request.
  • Confirmed experiment traffic uses the configured partner upstream.base_url rather than OpenRouter/Vercel.
  • Hot-swapped a variant version and confirmed subsequent routing resolves the new variant_version_id from Postgres while older attribution rows still point at the prior version.
  • Paused an experiment and confirmed requests to that public ID return local model-unavailable behavior.
  • Ran targeted tests for experiment picker and persistence.

Visual Changes

N/A

Reviewer Notes

  • Redis is only used as a fast membership pre-check for experimented public IDs. Admin mutations recompute EXPERIMENTED_PUBLIC_IDS_REDIS_KEY; if Redis is missing, corrupt, or unavailable, gateway treats that as “no preview IDs.”
  • Full experiment routing data is loaded from Postgres for preview traffic, matching the custom LLM pattern and avoiding a second Redis payload cache.
  • persistExperimentAttribution is intentionally best-effort and runs after the microdollar usage write. Billing remains the source of truth.
  • Prompt bodies are stored in R2 by sha256 hash. When prompt storage fails, the attribution row still lands with __failed__.
  • Recommended review order: routing entry points (route.ts, get-provider.ts); variant selection and fail-closed behavior (pick-variant.ts, pick-variant.types.ts); billing and policy guarantees (processUsage.ts, llm-proxy-helpers.ts); attribution persistence (persist.ts, experiment-prompts.ts); tests (pick-variant.test.ts, persist.test.ts, related provider/request tests).

Mark IJbema added 7 commits May 19, 2026 12:06
Phase 2 of the model-experiments rollout. Captures the per-message
client request id (used as the join key for PostHog feedback and for
`model_experiment_request.client_request_id` on experimented traffic)
and accepts `x-kilo-session` as a fallback session id when
`x-kilocode-taskid` is absent.

Also extends MicrodollarUsageContext with the experiment routing
fields (`modelExperimentVariantVersionId`,
`modelExperimentAllocationSubject`, `experimentPromptCapture`,
`clientRequestId`) so later phases can plumb them through without
touching every usage-context construction site.
Phase 3a of the model-experiments rollout. Pulls the kilo-internal/...
(custom_llm2) Provider construction into a shared
`buildDirectProvider` helper under
`apps/web/src/lib/ai-gateway/experiments/`. The experiment routing
path added in Phase 3b will consume the same helper, so direct-to-
upstream traffic for custom LLMs and experiments share one
implementation. Behavior is unchanged for custom_llm callers.
Phase 3b of the model-experiments rollout. Adds
`apps/web/src/lib/ai-gateway/experiments/pick-variant.ts` with:

- `isPublicIdExperimented(publicId)` — fast Redis-backed membership
  check with Postgres fallback, returning `unavailable` when both
  cache and DB are inaccessible so the gateway can fail closed.
- `getRoutingExperimentForPublicId(publicId)` — Redis-cached
  resolved experiment for a public id, including a single Postgres
  `DISTINCT ON (variant_id)` query that picks each variant's current
  `model_experiment_variant_version` and decrypts `encrypted_api_key`
  once at cache-build time. Returns `none` (no routing-relevant
  experiment), `experiment` (active or paused), or `unavailable`
  (config/decrypt/validation failure or BYOK_ENCRYPTION_KEY missing).
- `pickModelExperimentVariant(input)` — deterministic picker using
  `getRandomNumber`. Allocation subject precedence is user → machine
  → ip; missing all subjects returns `unavailable`. Variants are
  walked by id-asc cumulative weight so live label edits never
  rebucket users.

The `upstream` JSONB blob is validated by ExperimentUpstreamSchema
before use; partner-issued api keys live only in the encrypted column
and the cached payload, never in tRPC responses.
Phase 3c + 3d of the model-experiments rollout.

`getProvider` now returns a discriminated `GetProviderResult`
union (`provider` | `not-found` | `unavailable`) and adds a
new branch for experimented public ids that runs after the BYOK
checks and before kilo-internal/custom_llm and the
kiloExclusiveModels lookup. Active experiments produce a direct
provider built via `buildDirectProvider` plus experiment routing
metadata; paused experiments map to a local model-unavailable
response so traffic doesn't silently fall through to default
routing; cache/DB/config failures map to a 503
temporarily-unavailable response.

Experiment traffic uses three new flags on the provider result:
`skipBalanceCheck` (preview traffic is provider-funded),
`skipProviderPin` (the partner endpoint is selected by the
variant version, not by the org config), and
`skipKiloExclusiveModelSettings` (which
`applyProviderSpecificLogic` honors so the registry does not
override the variant's `internal_id`). Server-side organization
model/data-collection policy still runs against the public id.

Also excludes experimented preview public ids from `kilo-auto/free`
candidate sets so they are never silently selected on a user's
behalf — users only ever reach the experiment path by opting in
to the dedicated preview model id.

The route handler captures `x-kilocode-machineid` once into
`machineIdHeader` and threads it (plus the resolved client IP)
into `getProvider` so the variant picker can fall back from
userId to machineId to clientIp.
Phase 4a of the model-experiments rollout. New helper module
`apps/web/src/lib/r2/experiment-prompts.ts` exposes
`putPromptIfAbsent(content)` (HEAD-then-PUT under a sha256 hex key
for automatic dedup) and `getPromptByHash(sha)` (admin out-of-band
reads). The bucket comes from a new env var
`R2_EXPERIMENT_PROMPTS_BUCKET_NAME` exported from the existing R2
client module; per-environment buckets are
`kilo-experiment-prompts-dev` and `kilo-experiment-prompts-prod`.

The helper is wired up but not yet called from the request hot
path — Phase 4b will add the bounded prompt capture and
attribution writer that consumes it.
Phase 4b + 4c of the model-experiments rollout.

Adds `apps/web/src/lib/ai-gateway/experiments/persist.ts` with two
exports:

- `buildExperimentPromptCapture(request)` produces a bounded
  capture of the canonical post-`transformRequest` upstream body,
  splitting out the leading `role: 'system'` message for
  chat_completions and tail-truncating either side at 4 MB. The
  capture is stored on `MicrodollarUsageContext` so the async
  `after()` path never retains a reference to the full uncapped
  request body.
- `persistExperimentAttribution(input)` writes one row into
  `model_experiment_request` after running two parallel
  content-addressed R2 puts via `putPromptIfAbsent`, using
  `__absent__` / `__failed__` sentinels for missing/failed
  sides. Errors are reported to Sentry and swallowed — the billing
  write must succeed independently of analytics.

`processUsage.ts` now surfaces the inserted `{ usageId, createdAt }`
out of `logMicrodollarUsage` and `processTokenData` so the
`accountForMicrodollarUsage` after-hook can chain
`persistExperimentAttribution` on the same row. The route handler
calls `buildExperimentPromptCapture` after provider-specific
transforms and stashes the result on the usage context only for
experimented requests.
@markijbema
markijbema force-pushed the mark/experimental-models-gateway branch from 4864b01 to ebceefb Compare May 19, 2026 10:50
Mark IJbema added 7 commits May 19, 2026 12:53
Marks Phases 2 + 3 as merged-pending against PR #3325 and Phase 4
as in-progress with 4a/4b/4c on the same PR. 4d (the
`model_experiment_request_stats` view) is now explicitly deferred
until a real report consumer needs it. Also records the concrete
output of each phase as it landed.
…raffic

Review fix #1. `skipBalanceCheck` only skipped the preflight
zero-balance rejection; it did not mark the usage as free, so
`processTokenData` would still record full upstream cost into
`microdollar_usage.cost` and the insert CTE would increment
`kilocode_users.microdollars_used`. A zero-balance user could be
charged real upstream cost on a preview experiment and pushed
negative.

Adds an explicit `provider_funded` flag to
`MicrodollarUsageContext` and treats it the same as `user_byok`
in the cost-zeroing branch (zeroes `cost_mUsd` and
`cacheDiscount_mUsd` while preserving `market_cost` for
reporting). The route handler sets the flag for experimented
requests, alongside the existing `skipBalanceCheck`.
Captures the review-#4 finding from PR #3325: no TTL on the
EXPERIMENTED_PUBLIC_IDS_REDIS_KEY plus best-effort/swallowed
invalidation can leave a stale list indefinitely. Documents the
agreed fix (add a TTL so the cache self-heals via the existing
DB fallback path) and defers it to a follow-up PR.
…llection

Review fix #2. Direct experiment upstreams bypass the
gateway-pinned `data_collection` setting — that setting only
applies when the request is forwarded through OpenRouter or Vercel
with a pinned provider config. For experimented public ids the
upstream is selected by the variant version, so the org's
preference would be silently ignored while we still capture
prompts to R2 for partner evaluation.

When an org sets `data_collection: 'deny'` and the request
resolves to an experiment, return the existing
`dataCollectionRequiredResponse()` instead of routing. The
existing model deny list still runs first via
`checkOrganizationModelRestrictions`. Anonymous users and
non-org authenticated users have no settings and continue to
route normally.
Review fix #3. The Redis per-public-id experiment cache previously
held decrypted partner-issued api keys for up to 10 minutes. That
defeated the BYOK_ENCRYPTION_KEY fail-closed behavior: removing or
rotating the env var did nothing for warm caches because
`getRoutingExperimentForPublicId` returned the cached payload
straight to the picker. The PR verification claim ('disabling
BYOK_ENCRYPTION_KEY returns 503') only held on cold caches.

Refactors the cache to hold CIPHERTEXT exclusively. Decryption +
the missing-key check now run inside
`pickModelExperimentVariant`, on the chosen variant only, on
every request. Three knock-on benefits:

1. Key rotation is effective immediately on the next request;
   warm caches no longer serve stale plaintext keys.
2. We decrypt one variant per request instead of N at cache-build
   time.
3. A Redis dump no longer leaks usable api keys.

Per-request cost is one AES-256-GCM decrypt (microseconds), which
is negligible against an LLM round-trip.
Review fix #6. SYSTEM_PROMPT_CAP_BYTES / REQUEST_BODY_CAP_BYTES
were checked with `string.length` and trimmed with
`string.slice`, which both operate on UTF-16 code units. For
non-ASCII content the actual UTF-8 byte payload uploaded to R2
could be up to ~3× the intended cap (a single CJK char is 1 UTF-16
unit but 3 UTF-8 bytes; an emoji is 2 UTF-16 units but 4 UTF-8
bytes), so the cap was not bounding what the plan claimed it
bounded. Slicing UTF-16 code units could also split a surrogate
pair, leading the next UTF-8 encode to emit U+FFFD and breaking
the deterministic-prefix promise (truncated content would hash
differently across runtimes).

Adds `truncateToUtf8Bytes(s, maxBytes)` which encodes once, walks
back to a UTF-8 codepoint boundary using the 0b10xxxxxx
continuation-byte rule, and returns valid UTF-8 with bytes
<= maxBytes. Replaces the three call sites in
`buildExperimentPromptCapture` to measure with
`Buffer.byteLength` and truncate via the new helper.
Adds two test files covering the new experiment routing surface:

- `persist.test.ts` (14 cases) — `truncateToUtf8Bytes` boundary
  cases (multi-byte CJK, surrogate-pair emoji, exact alignment,
  zero cap, determinism); `buildExperimentPromptCapture`
  extraction (leading vs mid-conversation system message,
  serialization of structured content, UTF-8-byte cap enforcement
  for both system and body sides).
- `pick-variant.test.ts` (13 cases) — `isPublicIdExperimented`
  positive/negative + paused-also-counted; picker determinism for
  the same userId; api-key decryption returns the right partner
  secret per variant; allocation-subject precedence
  user > machine > ip; `unavailable` when no allocation subject;
  `not-found` for paused so traffic doesn't silently fall
  through; hot-swap preserves the variant_id slot while serving
  the new variant_version_id; weighted-distribution sanity (1:3
  split lands ~25%); historical attribution via the old version
  row survives a hot-swap; completed experiments stop routing.

Full suite: 373 passed (up from 371).
@markijbema

Copy link
Copy Markdown
Contributor Author

Addressed review feedback in the latest pushes:

  • refactor(bot): Extract platform-agnostic tool system #1 (billing not zeroed) — fixed. Added provider_funded flag on MicrodollarUsageContext; processTokenData now zeroes cost_mUsd / cacheDiscount_mUsd while preserving market_cost.
  • AppBuilder - Make entire project card clickable #2 (org data-collection bypass) — fixed. Refuse experiment routing with dataCollectionRequiredResponse() when the org has data_collection: 'deny'. Anonymous and non-org users continue to route normally.
  • App Builder - Add session-based message pagination #3 (warm cache plaintext keys) — fixed. Cache now stores ciphertext exclusively. Decryption + the BYOK_ENCRYPTION_KEY missing-key check moved into the per-pick path so warm caches no longer serve plaintext after key rotation/removal. Also: only the chosen variant's key is decrypted per request.
  • AppBuilder - Fix model selector not showing saved model on reload #4 (stale membership cache) — deferred to follow-up. Documented in .plans/experimental-models-1.md with the agreed fix (TTL on the membership key + rely on existing DB fallback).
  • Cloud Agent: prompt autocomplete (ghost text) #5 (PII erasure) — out of scope per plan. Plan defines an experiment-specific opt-in retention policy distinct from the default microdollar_usage_metadata soft-delete; the locking test lives in Phase 6 alongside the model-specific opt-in copy that's a prerequisite to the first real partner experiment.
  • Admin - Add App Builder project detail page with session traces link #6 (UTF-16 cap) — fixed. New truncateToUtf8Bytes walks back to a UTF-8 codepoint boundary; buildExperimentPromptCapture now measures with Buffer.byteLength and truncates by bytes.
  • App Builder - Unit and Integration tests #7 (tests) — added. Two new test files (27 cases) cover the picker, fail-closed paths (paused → not-found, missing allocation subject → unavailable, paused via cache invalidation), hot-swap preserving the slot but serving the new RC, weighted distribution, historical attribution via old version rows, and the UTF-8 truncation edge cases. Full pnpm --filter web test is now 373 suites / 5658 passing.

Each fix is a separate commit for review.

Follow-up to review fix #2. The previous commit only refused
experiment routing on the org-level setting
(`settings.data_collection === 'deny'`). A caller can also opt a
single request out of training/data-retention via OpenRouter's
request-body fields `provider.data_collection: 'deny'` or
`provider.zdr: true`. Direct experiment upstreams ignore those
fields entirely (we don't go through OpenRouter), but the
prompt-capture path was still uploading to R2 for partner
evaluation, violating the caller's stated intent.

Refuses experiment routing with `dataCollectionRequiredResponse()`
when `!isFreePromptTrainingAllowed(body.provider)` regardless of
org/anon/BYOK status. The check runs unconditionally right after
`getProvider` returns so it covers anonymous and non-org
authenticated users too.

Adds a unit test for `isFreePromptTrainingAllowed` covering all
opt-out shapes (`data_collection: 'deny'`, `zdr: true`,
`zdr: false`, both, neither, undefined) so a regression cannot
silently allow capture for users who explicitly opted out at the
request scope. Also tightens the `GatewayRequest` cast in
`persist.test.ts` per the test-file as-cast convention.
@markijbema

Copy link
Copy Markdown
Contributor Author

Addressed the request-level data-collection follow-up:

  • The previous fix AppBuilder - Make entire project card clickable #2 only blocked org-level settings.data_collection: 'deny'. A request-level opt-out via provider.data_collection: 'deny' or provider.zdr: true would still route to the experiment and capture the prompt to R2, violating the caller's stated intent.
  • New unconditional check right after getProvider returns: if (experiment && !isFreePromptTrainingAllowed(body.provider)) return dataCollectionRequiredResponse(). Covers anonymous, BYOK, and non-org authenticated users — anyone who set the field on the request body.
  • Reuses the existing isFreePromptTrainingAllowed predicate (already used by the kilo-exclusive free-model gate) so the opt-out semantics are consistent across the gateway.
  • Added a 7-case unit test for the predicate so future regressions can't silently allow capture for opted-out callers.

Commit: 33b3baf

@markijbema
markijbema marked this pull request as ready for review May 19, 2026 12:19
Plans are ephemeral and get removed when the work is done. Comments
that pointed readers at `.plans/experimental-models-1.md` or
referenced specific plan sections now stand on their own — the
relevant context (sentinel meanings, why the api key column is
separate, scope of preview routing) is inlined where it matters
instead of forwarded to a doc that won't exist long-term.

Touched:
- apps/web/src/lib/r2/experiment-prompts.ts (header)
- apps/web/src/lib/ai-gateway/experiments/persist.ts (sentinel docs)
- apps/web/src/lib/ai-gateway/experiments/upstream-schema.ts (api_key)
- apps/web/src/app/admin/model-experiments/ModelExperimentsContent.tsx
  (admin UI subtitle: dropped trailing 'See .plans/...' sentence)
- packages/db/src/schema.ts (three model_experiment* table headers)
Comment thread apps/web/src/lib/ai-gateway/experiments/persist.ts Outdated
Comment thread apps/web/src/lib/ai-gateway/auto-model/resolution.ts Outdated
@kilo-code-bot

kilo-code-bot Bot commented May 19, 2026 •

Copy link
Copy Markdown
Contributor

Code Review Summary

Status: 1 Suggestion | Recommendation: Merge

Executive Summary

No new issues introduced by the incremental commits. The docs commit (e4b1ca1d7) correctly documents the response model-id leak as a P1 followup, and the main-merge commits (stealth Opus model, Martian messages support, stepfun disable) are clean. One prior suggestion on membership.ts remains unresolved.

Overview

Severity Count
CRITICAL 0
WARNING 0
SUGGESTION 1
Issue Details (click to expand)

SUGGESTION

| File | Line | Issue |
|------|------|
-------|
| apps/web/src/lib/ai-gateway/experiments/membership.ts | 8 | JSDoc says module is imported from models.ts; actual importers are is-free-model.ts, get-provider.ts, and route.ts — models.ts does not import membership.ts |

New commits reviewed (since 1eefb73)
  • docs(experiments): note response model-id leak as v1 followup — adds "Followup: rewrite checkpoint identity in experiment responses" to .plans/experimental-models-1.md; correctly documents the P1 client-blinding gap and the proposed fix (widen or extend rewriteFreeModelResponse_* helpers to experiment traffic) ✅
  • Main merge from main — pulls in 3e609bae4 (stealth Claude Opus 4.7 model + requiresKiloDataCollection + Martian messages API support), 5d9e758a6 (stepfun disabled), 89e405f1d (Martian strips provider options from messages requests) ✅
Resolved since last review
  • isFreeModel made client-bundle-safe via module split ✅
  • provider_funded field removed; experiment cost-zeroing handled by isFreeModel ✅
  • isExperimentedModel local variable removed; isPublicIdExperimented called directly ✅
  • isOpenRouterStealthModel exported from models.ts ✅
  • Per-public-id Redis cache removed; decryption now per-pick only (api key never touches Redis) ✅
  • invalidateExperimentCaches refactored to refreshExperimentedPublicIdsCache (no more per-public-id redisDel calls) ✅
Files Reviewed
  • .plans/experimental-models-1.md — docs only, no issues ✅
  • apps/web/src/app/api/openrouter/[...path]/route.ts — no new issues ✅
  • apps/web/src/lib/ai-gateway/experiments/build-direct-provider.ts — no issues ✅
  • apps/web/src/lib/ai-gateway/experiments/membership.ts — 1 suggestion (stale JSDoc, unresolved)
  • apps/web/src/lib/ai-gateway/experiments/persist.test.ts — no issues ✅
  • apps/web/src/lib/ai-gateway/experiments/persist.ts — no issues ✅
  • apps/web/src/lib/ai-gateway/experiments/pick-variant.test.ts — no issues ✅
  • apps/web/src/lib/ai-gateway/experiments/pick-variant.ts — no issues ✅
  • apps/web/src/lib/ai-gateway/experiments/pick-variant.types.ts — no issues ✅
  • apps/web/src/lib/ai-gateway/is-free-model.ts — no issues ✅
  • apps/web/src/lib/ai-gateway/llm-proxy-helpers.ts — no issues ✅
  • apps/web/src/lib/ai-gateway/processUsage.types.ts — no issues ✅
  • apps/web/src/lib/ai-gateway/providers/apply-provider-specific-logic.ts — no issues ✅
  • apps/web/src/lib/ai-gateway/providers/get-provider.ts — no issues ✅
  • apps/web/src/lib/r2/client.ts — no issues ✅
  • apps/web/src/lib/r2/experiment-prompts.ts — no issues ✅
  • apps/web/src/lib/redis-keys.ts — modelExperimentRedisKey correctly removed (per-public-id Redis cache eliminated) ✅
  • apps/web/src/routers/admin/model-experiments-router.ts — no issues ✅

Fix these issues in Kilo Cloud


Reviewed by claude-sonnet-4.6 · 4,174,653 tokens

Review guidance: REVIEW.md from base branch main

The previous commit (b6b1ebc) cleaned plan references from five
files, three of which already exist on main from earlier merged
PRs (Phase 1 schema, Phase 5 admin UI). Touching cross-package
files unrelated to the gateway work blew up this PR's diff and
risked merge churn against PR #3329, which carries those same
three edits independently.

Restore the on-main copies of:
- packages/db/src/schema.ts
- apps/web/src/lib/ai-gateway/experiments/upstream-schema.ts
- apps/web/src/app/admin/model-experiments/ModelExperimentsContent.tsx

The branch-only files (persist.ts, experiment-prompts.ts) keep
their cleanups since they were authored on this branch and
land/ship together with the gateway work.
@markijbema

Copy link
Copy Markdown
Contributor Author

Extracted the buildDirectProvider refactor as a standalone prep PR: #3330.

Either order of merge works:

Refactor is pure and well-tested by the existing custom_llm coverage (83 tests). No behavioral change to the live kilo-internal/... path.

Mark IJbema added 2 commits May 19, 2026 15:39
Reverts the `filterOutExperimentedPublicIds` filter added in
`72dd51171` for `kilo-auto/free` candidate selection.

The safeguard added a per-candidate `isPublicIdExperimented`
Redis check on every `kilo-auto/free` request — material extra
latency on a hot path — to defend against an invariant that holds
by construction: `autoFreeModels` and the frontier preset list
are hand-curated and never include dedicated preview experiment
ids. The explicit-opt-in property of preview models is preserved
without any runtime guard.

`getProvider`'s experiment-membership check is unchanged —
explicit requests to a preview public id still resolve through the
experiment routing path.

Plan updated to reflect the no-guard decision and rationale.
…els-gateway

# Conflicts:
#	apps/web/src/lib/ai-gateway/providers/get-provider.ts
Comment thread apps/web/src/lib/organizations/organization-models.ts Outdated
Mark IJbema added 3 commits May 21, 2026 14:05
Resolve isPublicIdExperimented once per request and OR the result into the existing free-model gates: rate limit, anonymous-access, and balance check. Drop the now-redundant skipBalanceCheck flag from GetProviderProviderResult.
Comment thread apps/web/src/lib/ai-gateway/providers/openrouter/types.test.ts Outdated
Comment thread apps/web/src/lib/organizations/organization-models.ts Outdated
Comment thread apps/web/src/lib/redis-keys.ts Outdated
Mark IJbema added 7 commits May 21, 2026 15:50
…els-gateway

# Conflicts:
#	apps/web/src/app/api/openrouter/[...path]/route.ts
#	apps/web/src/lib/ai-gateway/processUsage.ts
Now that isFreeModel is async (PR #3393), let it answer for experimented public ids directly. Drops the route's local isExperimentedModel boolean from the auth and balance gates and removes the provider_funded flag — cost zeroing is handled by the isFreeModel check in processUsage.ts.
Enterprise provider allow-list is enforced via OpenRouter's body.provider.only field, which doesn't reach a direct partner upstream. Refuse experiment routing rather than silently bypassing the org's allow-list. Note Option 3 (unify direct-upstream routing abstraction) in the plan as the proper followup.
Experiment models were already added to restrictionCandidates and folded into the org-restrictions filter, then appended again unfiltered at the end. The duplicate append created two issues: catalog entries appeared twice, and Enterprise model_deny_list entries were silently re-exposed. Drop the duplicate append; the filter at line 32-42 covers experiments and matches the gateway's behavior (experiments do not set bypassAccessCheck and go through checkOrganizationModelRestrictions on the request path).
…els-gateway

# Conflicts:
#	.plans/experimental-models-1.md
@markijbema
markijbema force-pushed the mark/experimental-models-gateway branch from b2be6a9 to cb9f60d Compare May 21, 2026 14:52
models.ts is reachable from client bundles via constants like PRIMARY_DEFAULT_MODEL and preferredModels. Folding the experiment-membership check into isFreeModel inside models.ts dragged in the Redis client (and via pick-variant.ts the Postgres client), breaking the production build with 'Module not found' errors for net/tls/dns/promises and pg.

Split along two seams that already existed: pick-variant.ts now contains only the drizzle-using routing (getRoutingExperimentForPublicId, pickModelExperimentVariant), while a new membership.ts holds the Redis-only isPublicIdExperimented. The async isFreeModel itself moves to a new is-free-model.ts that imports the sync helpers from models.ts and the membership check from membership.ts. Net result: models.ts has no I/O dependencies, the fold is preserved for server callers, and the experiment-membership check still lives once in the codebase.
Comment thread apps/web/src/lib/ai-gateway/experiments/membership.ts
@markijbema
markijbema enabled auto-merge (squash) May 25, 2026 07:14
@markijbema
markijbema merged commit 3d67d31 into main May 25, 2026
17 checks passed
@markijbema
markijbema deleted the mark/experimental-models-gateway branch May 25, 2026 07:18
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