Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
eff9e05
feat(gateway): capture x-kilo-request and x-kilo-session headers
May 19, 2026
5f346c7
refactor(gateway): extract buildDirectProvider used by custom_llm path
May 19, 2026
59a7924
feat(gateway): add experiment variant picker and routing-cache loader
May 19, 2026
72dd511
feat(gateway): route experimented public ids to direct upstream
May 19, 2026
b81a5b1
feat(r2): add experiment-prompts content-addressed storage helper
May 19, 2026
9bf9d4e
feat(gateway): persist experiment attribution after microdollar write
May 19, 2026
ebceefb
chore(experiments): drop unused MicrodollarUsageContext import
May 19, 2026
4cc8df6
docs(plan): update experimental-models-1 status for PR #3325
May 19, 2026
3f9519c
fix(experiments): zero billable cost for provider-funded experiment t…
May 19, 2026
b7a4afa
docs(plan): record stale membership cache as next-step follow-up
May 19, 2026
a354d37
fix(experiments): refuse experiment routing when org disables data co…
May 19, 2026
e911597
fix(experiments): cache encrypted upstream; decrypt only on pick
May 19, 2026
6757360
fix(experiments): cap prompt capture by UTF-8 bytes, not UTF-16 units
May 19, 2026
986fde9
test(experiments): cover picker, fail-closed paths, and UTF-8 truncation
May 19, 2026
33b3baf
fix(experiments): honor request-level data-collection opt-out
May 19, 2026
b6b1ebc
chore(experiments): drop plan references from code comments
May 19, 2026
810c76c
chore(experiments): leave shared-with-main files for separate chore PR
May 19, 2026
da9799e
revert(gateway): drop kilo-auto/free experimented-id safeguard
May 19, 2026
4d10fe0
Merge remote-tracking branch 'origin/main' into mark/experimental-mod…
May 19, 2026
0afa453
chore(experiments): drop unused barrel index.ts
May 19, 2026
afdbfba
Merge remote-tracking branch 'origin/main' into mark/experimental-mod…
May 19, 2026
f4f2c99
Merge remote-tracking branch 'origin/main' into mark/experimental-mod…
May 20, 2026
88de7c0
refactor(experiments): drop system-prompt split, plumb request_kind t…
May 20, 2026
454afb8
refactor(gateway): make balance <= 0 the primary condition on the usa…
May 20, 2026
9bdfbc2
fix(experiments): backfill the experimented-public-ids redis cache on…
May 20, 2026
0498dda
refactor(experiments): rename subjectPick → allocationSubject, fix ly…
May 20, 2026
e0fa122
fix(gateway): return usage identity for zero-cost inserts
May 20, 2026
0a54c02
fix(gateway): use JS-side identity values to avoid Postgres timestamp…
May 20, 2026
478473f
Merge remote-tracking branch 'origin/main' into mark/experimental-mod…
May 20, 2026
3f9e18c
refactor(gateway): extract experiment variant types
May 20, 2026
92012a2
refactor(gateway): narrow experiment attribution catch
May 20, 2026
e5ec33a
refactor(gateway): move prompt storage fallback
May 20, 2026
dd08383
refactor(gateway): simplify prompt hash fallback
May 20, 2026
8fb0a45
refactor(gateway): inline experiment attribution insert
May 20, 2026
5f3ba98
refactor(gateway): remove prompt truncation breadcrumb
May 20, 2026
09d6f77
refactor(gateway): inline prompt capture serialization
May 20, 2026
7107be8
refactor(gateway): simplify experiment attribution guard
May 20, 2026
0ea4021
fix(web): require BYOK encryption key
May 20, 2026
131a6a2
refactor(gateway): remove missing BYOK key branch
May 20, 2026
caf2002
refactor(gateway): cache experimented ids from Redis only
May 20, 2026
d404629
refactor(gateway): load experiment routing from Postgres
May 21, 2026
c6c5e08
test(gateway): avoid silent Redis membership skips
May 21, 2026
705bafa
feat(gateway): expose active experiment models
May 21, 2026
8a6bfc6
feat(admin): show experiment request log
May 21, 2026
5d728dc
fix(gateway): allow anonymous experiment models
May 21, 2026
4e3350a
Merge remote-tracking branch 'origin/main' into mark/experimental-mod…
May 21, 2026
a5b56b9
refactor(gateway): treat experiment models as free models
May 21, 2026
2d25fc8
chore(gateway): remove unrelated review changes
May 21, 2026
5502bc1
Merge remote-tracking branch 'origin/main' into mark/experimental-mod…
May 21, 2026
b0e2971
refactor(gateway): fold experiment check into isFreeModel
May 21, 2026
7098d88
chore(gateway): revert unnecessary comment edits
May 21, 2026
17b4afa
fix(gateway): refuse experiments under enterprise allow-list
May 21, 2026
cd82c5b
fix(gateway): remove duplicate unfiltered experiment models
May 21, 2026
cb9f60d
Merge remote-tracking branch 'origin/main' into mark/experimental-mod…
May 21, 2026
1eefb73
fix(gateway): split membership and isFreeModel out of models.ts
May 21, 2026
f931536
Merge remote-tracking branch 'origin/main' into mark/experimental-mod…
May 21, 2026
7bba29a
Merge branch 'main' into mark/experimental-models-gateway
markijbema May 25, 2026
e4b1ca1
docs(experiments): note response model-id leak as v1 followup
May 25, 2026
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
109 changes: 98 additions & 11 deletions .plans/experimental-models-1.md

Large diffs are not rendered by default.

73 changes: 69 additions & 4 deletions apps/web/src/app/api/openrouter/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import type {
} from '@/lib/ai-gateway/providers/openrouter/types';
import { applyProviderSpecificLogic } from '@/lib/ai-gateway/providers/apply-provider-specific-logic';
import { getProvider } from '@/lib/ai-gateway/providers/get-provider';
import { buildExperimentPromptCapture } from '@/lib/ai-gateway/experiments/persist';
import { isPublicIdExperimented } from '@/lib/ai-gateway/experiments/membership';
import { upstreamRequest } from '@/lib/ai-gateway/providers/upstream-request';
import { debugSaveProxyRequest } from '@/lib/debugUtils';
import { setTag, startInactiveSpan } from '@sentry/nextjs';
Expand All @@ -43,6 +45,7 @@ import {
malformedJsonResponse,
makeErrorReadable,
modelDoesNotExistResponse,
modelNotAllowedResponse,
extractHeaderAndLimitLength,
noFreeModelsAvailableResponse,
temporarilyUnavailableResponse,
Expand Down Expand Up @@ -276,7 +279,9 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
// per user when the request comes from Cloudflare IPs (Kilo infrastructure).
// All other products rate-limit per IP (fast pre-auth path).
const isRateLimitedFreeModelRequest =
isKiloExclusiveFreeModel(originalModelIdLowerCased) || autoModel === KILO_AUTO_FREE_MODEL.id;
isKiloExclusiveFreeModel(originalModelIdLowerCased) ||
autoModel === KILO_AUTO_FREE_MODEL.id ||
(await isPublicIdExperimented(originalModelIdLowerCased));
if (isRateLimitedFreeModelRequest) {
const rateLimit = await resolveRateLimit(feature, ipAddress, authPromise);
if (rateLimit instanceof NextResponse) return rateLimit;
Expand Down Expand Up @@ -386,14 +391,38 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
user,
organizationId,
taskId,
clientIp: ipAddress ?? null,
machineId: machineIdHeader,
});
if (providerResult.kind === 'not-found') {
// Paused experiment for this public id — return a local model-unavailable
// response instead of silently falling through to default routing.
return modelDoesNotExistResponse();
}
if (providerResult.kind === 'unavailable') {
return temporarilyUnavailableResponse();
}
const { provider, userByok, bypassAccessCheck } = providerResult;
const {
provider,
userByok,
bypassAccessCheck,
skipProviderPin,
skipKiloExclusiveModelSettings,
experiment,
} = providerResult;

// Request-level data-collection opt-out: a caller can set
// `provider.data_collection: 'deny'` or `provider.zdr: true` on any
// request to opt that single request out of training/data-retention.
// Direct experiment upstreams ignore those OpenRouter/Vercel flags
// (we never reach OpenRouter), but we still capture the prompt to R2
// for partner evaluation — which violates the caller's stated
// intent. Refuse here regardless of org settings, anon/BYOK status,
// or the org-level check below.
if (experiment && !isFreePromptTrainingAllowed(requestBodyParsed.body.provider)) {
return dataCollectionRequiredResponse();
}

if (!provider.supportedChatApis.includes(requestBodyParsed.kind)) {
return apiKindNotSupportedResponse(
requestBodyParsed.kind,
Expand Down Expand Up @@ -483,11 +512,38 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
});
if (modelRestrictionError) return modelRestrictionError;

if (providerConfig) {
// Experiment traffic captures prompts to R2 for partner evaluation, which
// is a form of data collection that the gateway-pinned `data_collection`
// setting cannot enforce on a direct partner upstream. If the org has
// explicitly disabled data collection, refuse the experimented public id
// here rather than routing through and silently capturing prompts.
if (experiment && settings?.data_collection === 'deny') {
return dataCollectionRequiredResponse();
}

// Enterprise `provider_allow_list` is enforced via OpenRouter's
// `body.provider.only` field, which doesn't reach a direct partner
// upstream. Refuse the experimented public id rather than routing
// around the org's allow-list.
if (experiment && providerConfig?.only !== undefined) {
return modelNotAllowedResponse();
}

// Direct experiment upstreams must not have a Vercel/OpenRouter
// provider config pinned onto them — the partner endpoint is selected
// by the variant version.
if (providerConfig && !skipProviderPin) {
requestBodyParsed.body.provider = providerConfig;
}
}

if (experiment) {
usageContext.modelExperimentVariantVersionId = experiment.variantVersionId;
usageContext.modelExperimentAllocationSubject = experiment.allocationSubject;
// Cost zeroing for experiment traffic is handled by `isFreeModel`, which
// returns true for experimented public ids.
}

sentryRootSpan()?.setAttribute(
'openrouter.time_to_request_start_ms',
performance.now() - requestStartedAt
Expand Down Expand Up @@ -535,9 +591,18 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
requestBodyParsed,
extraHeaders,
userByok,
fraudHeaders
fraudHeaders,
{ skipKiloExclusiveModelSettings: skipKiloExclusiveModelSettings === true }
);

// Capture the bounded prompt for experimented requests AFTER provider
// transforms have produced the canonical upstream body. Stored on the
// usage context so the async `after()` hook can persist it without
// retaining a reference to the full uncapped body.
if (experiment) {
usageContext.experimentPromptCapture = buildExperimentPromptCapture(requestBodyParsed);
}

const response = await upstreamRequest({
path,
search: url.search,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,10 @@ export function inferSupportedChatApis(
* Plain in-memory shape: an `ExperimentUpstream` (no key) merged with the
* decrypted partner-issued api key.
*
* The cache loader (`getRoutingExperimentForPublicId`) decrypts
* `model_experiment_variant_version.encrypted_api_key` once and stores the
* resulting plaintext alongside the rest of the upstream blob in this shape
* for hot-path use. The plaintext NEVER touches Postgres or any tRPC
* response — only this in-memory record and the per-public-id Redis cache.
* `pickModelExperimentVariant` decrypts the chosen
* `model_experiment_variant_version.encrypted_api_key` and merges the
* plaintext with the upstream blob for the outbound provider request. The
* plaintext NEVER touches Postgres, Redis, or any tRPC response.
*/
export type ResolvedExperimentUpstream = ExperimentUpstream & { api_key: string };

Expand Down
51 changes: 51 additions & 0 deletions apps/web/src/lib/ai-gateway/experiments/membership.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { redisGet } from '@/lib/redis';
import { EXPERIMENTED_PUBLIC_IDS_REDIS_KEY } from '@/lib/redis-keys';
import { createCachedFetch } from '@/lib/cached-fetch';

/**
* Membership pre-check for model-experiment routing. Lives in its own module
* (separate from `pick-variant.ts`) so it can be imported from
* `lib/ai-gateway/models.ts` — which is reachable from client bundles —
Comment thread
markijbema marked this conversation as resolved.
* without dragging in `pick-variant.ts`'s drizzle dependency.
*
* Touches only Redis + an in-process cache. Admin writes are responsible for
* maintaining the Redis membership set; if Redis is empty, corrupt, or
* unavailable, treat it as "no experimented public ids".
*/
const EXPERIMENTED_PUBLIC_IDS_LOCAL_CACHE_TTL_MS = process.env.NODE_ENV === 'test' ? 0 : 60_000;

const getExperimentedPublicIds = createCachedFetch<string[]>(
async () => {
try {
const cached = await redisGet(EXPERIMENTED_PUBLIC_IDS_REDIS_KEY);
if (cached === null) return [];
return parseStringArray(cached) ?? [];
} catch {
// captureException already invoked by the redis helper
return [];
}
},
EXPERIMENTED_PUBLIC_IDS_LOCAL_CACHE_TTL_MS,
[]
);

/**
* Fast pre-check: does any active|paused experiment target this public id?
*
* Reads from a short-lived in-process cache backed by Redis.
*/
export async function isPublicIdExperimented(publicId: string): Promise<boolean> {
const ids = await getExperimentedPublicIds();
return ids.includes(publicId);
}

function parseStringArray(raw: string): string[] | null {
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return null;
if (parsed.some(v => typeof v !== 'string')) return null;
return parsed as string[];
} catch {
return null;
}
}
132 changes: 132 additions & 0 deletions apps/web/src/lib/ai-gateway/experiments/persist.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { describe, expect, it } from '@jest/globals';
import {
buildExperimentPromptCapture,
REQUEST_BODY_CAP_BYTES,
truncateToUtf8Bytes,
} from './persist';
import type { GatewayRequest } from '@/lib/ai-gateway/providers/openrouter/types';

describe('truncateToUtf8Bytes', () => {
it('returns the input unchanged when already within the cap', () => {
expect(truncateToUtf8Bytes('hello', 100)).toBe('hello');
});

it('returns valid UTF-8 with length <= maxBytes for ASCII', () => {
const out = truncateToUtf8Bytes('a'.repeat(1000), 16);
expect(Buffer.byteLength(out, 'utf8')).toBe(16);
expect(out).toBe('a'.repeat(16));
});

it('does not split a multi-byte UTF-8 codepoint', () => {
// '日' is 3 bytes in UTF-8 (0xE6 0x97 0xA5). With maxBytes=4 the cut
// would otherwise land mid-codepoint and produce invalid UTF-8.
const input = '日日日'; // 9 UTF-8 bytes
const out = truncateToUtf8Bytes(input, 4);
// Result must be valid UTF-8 and <= 4 bytes. The only valid prefixes
// here are '' (0 bytes) and '日' (3 bytes).
expect(Buffer.byteLength(out, 'utf8')).toBeLessThanOrEqual(4);
expect(out).toBe('日');
});

it('handles 4-byte emoji codepoints correctly', () => {
// '😀' is 4 bytes in UTF-8 (a surrogate pair in UTF-16).
const input = '😀😀😀';
const out = truncateToUtf8Bytes(input, 6);
// Only '😀' (4 bytes) fits cleanly; cutting at byte 6 would split
// the second emoji.
expect(out).toBe('😀');
expect(Buffer.byteLength(out, 'utf8')).toBe(4);
});

it('produces deterministic output across calls', () => {
const input = 'hello 日本語 world 🎉 こんにちは';
const a = truncateToUtf8Bytes(input, 12);
const b = truncateToUtf8Bytes(input, 12);
expect(a).toBe(b);
expect(Buffer.byteLength(a, 'utf8')).toBeLessThanOrEqual(12);
});

it('cuts cleanly when the cap exactly aligns with a codepoint boundary', () => {
const input = '日日'; // 6 bytes
const out = truncateToUtf8Bytes(input, 3);
expect(out).toBe('日');
});

it('handles maxBytes of 0', () => {
expect(truncateToUtf8Bytes('hello', 0)).toBe('');
});
});

describe('buildExperimentPromptCapture', () => {
function chatRequest(body: Record<string, unknown>): GatewayRequest {
// Tests may pass synthetic message shapes that don't fully match
// the production OpenAI types; double-cast through unknown is the
// pragmatic test-only escape hatch.
return { kind: 'chat_completions', body } as unknown as GatewayRequest;
}

it('captures the full serialized body and records the request kind', () => {
const cap = buildExperimentPromptCapture(
chatRequest({
model: 'kilo/preview-foo',
messages: [
{ role: 'system', content: 'you are a helpful assistant' },
{ role: 'user', content: 'hi' },
],
})
);
expect(cap.requestKind).toBe('chat_completions');
expect(cap.requestBodyContent).toContain('"role":"system"');
expect(cap.requestBodyContent).toContain('"role":"user"');
expect(cap.wasTruncated).toBe(false);
});

it('records `messages` for an Anthropic-shape request', () => {
const cap = buildExperimentPromptCapture({
kind: 'messages',
body: {
model: 'kilo/preview-foo',
system: 'you are a helpful assistant',
messages: [{ role: 'user', content: 'hi' }],
},
} as unknown as GatewayRequest);
expect(cap.requestKind).toBe('messages');
expect(cap.requestBodyContent).toContain('"system":"you are a helpful assistant"');
});

it('records `responses` for a Responses-API request', () => {
const cap = buildExperimentPromptCapture({
kind: 'responses',
body: { model: 'kilo/preview-foo', input: 'hi' },
} as unknown as GatewayRequest);
expect(cap.requestKind).toBe('responses');
expect(cap.requestBodyContent).toContain('"input":"hi"');
});

it('truncates the body by UTF-8 bytes and marks wasTruncated', () => {
const huge = 'a'.repeat(REQUEST_BODY_CAP_BYTES + 1024);
const cap = buildExperimentPromptCapture(
chatRequest({
model: 'kilo/preview-foo',
messages: [{ role: 'user', content: huge }],
})
);
expect(cap.wasTruncated).toBe(true);
expect(Buffer.byteLength(cap.requestBodyContent, 'utf8')).toBeLessThanOrEqual(
REQUEST_BODY_CAP_BYTES
);
});

it('produces deterministic output for identical inputs', () => {
// Content addressing on top of this capture relies on byte-for-byte
// determinism; a JSON.stringify implementation that reordered keys
// would silently break dedup.
const body = {
model: 'kilo/preview-foo',
messages: [{ role: 'user', content: 'hi' }],
};
const a = buildExperimentPromptCapture(chatRequest({ ...body }));
const b = buildExperimentPromptCapture(chatRequest({ ...body }));
expect(a.requestBodyContent).toBe(b.requestBodyContent);
});
});
Loading