Repository navigation
feat(gateway): route and persist model experiments #3325
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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
5f346c7
refactor(gateway): extract buildDirectProvider used by custom_llm path
59a7924
feat(gateway): add experiment variant picker and routing-cache loader
72dd511
feat(gateway): route experimented public ids to direct upstream
b81a5b1
feat(r2): add experiment-prompts content-addressed storage helper
9bf9d4e
feat(gateway): persist experiment attribution after microdollar write
ebceefb
chore(experiments): drop unused MicrodollarUsageContext import
4cc8df6
docs(plan): update experimental-models-1 status for PR #3325
3f9519c
fix(experiments): zero billable cost for provider-funded experiment t…
b7a4afa
docs(plan): record stale membership cache as next-step follow-up
a354d37
fix(experiments): refuse experiment routing when org disables data co…
e911597
fix(experiments): cache encrypted upstream; decrypt only on pick
6757360
fix(experiments): cap prompt capture by UTF-8 bytes, not UTF-16 units
986fde9
test(experiments): cover picker, fail-closed paths, and UTF-8 truncation
33b3baf
fix(experiments): honor request-level data-collection opt-out
b6b1ebc
chore(experiments): drop plan references from code comments
810c76c
chore(experiments): leave shared-with-main files for separate chore PR
da9799e
revert(gateway): drop kilo-auto/free experimented-id safeguard
4d10fe0
Merge remote-tracking branch 'origin/main' into mark/experimental-mod…
0afa453
chore(experiments): drop unused barrel index.ts
afdbfba
Merge remote-tracking branch 'origin/main' into mark/experimental-mod…
f4f2c99
Merge remote-tracking branch 'origin/main' into mark/experimental-mod…
88de7c0
refactor(experiments): drop system-prompt split, plumb request_kind t…
454afb8
refactor(gateway): make balance <= 0 the primary condition on the usa…
9bdfbc2
fix(experiments): backfill the experimented-public-ids redis cache on…
0498dda
refactor(experiments): rename subjectPick → allocationSubject, fix ly…
e0fa122
fix(gateway): return usage identity for zero-cost inserts
0a54c02
fix(gateway): use JS-side identity values to avoid Postgres timestamp…
478473f
Merge remote-tracking branch 'origin/main' into mark/experimental-mod…
3f9e18c
refactor(gateway): extract experiment variant types
92012a2
refactor(gateway): narrow experiment attribution catch
e5ec33a
refactor(gateway): move prompt storage fallback
dd08383
refactor(gateway): simplify prompt hash fallback
8fb0a45
refactor(gateway): inline experiment attribution insert
5f3ba98
refactor(gateway): remove prompt truncation breadcrumb
09d6f77
refactor(gateway): inline prompt capture serialization
7107be8
refactor(gateway): simplify experiment attribution guard
0ea4021
fix(web): require BYOK encryption key
131a6a2
refactor(gateway): remove missing BYOK key branch
caf2002
refactor(gateway): cache experimented ids from Redis only
d404629
refactor(gateway): load experiment routing from Postgres
c6c5e08
test(gateway): avoid silent Redis membership skips
705bafa
feat(gateway): expose active experiment models
8a6bfc6
feat(admin): show experiment request log
5d728dc
fix(gateway): allow anonymous experiment models
4e3350a
Merge remote-tracking branch 'origin/main' into mark/experimental-mod…
a5b56b9
refactor(gateway): treat experiment models as free models
2d25fc8
chore(gateway): remove unrelated review changes
5502bc1
Merge remote-tracking branch 'origin/main' into mark/experimental-mod…
b0e2971
refactor(gateway): fold experiment check into isFreeModel
7098d88
chore(gateway): revert unnecessary comment edits
17b4afa
fix(gateway): refuse experiments under enterprise allow-list
cd82c5b
fix(gateway): remove duplicate unfiltered experiment models
cb9f60d
Merge remote-tracking branch 'origin/main' into mark/experimental-mod…
1eefb73
fix(gateway): split membership and isFreeModel out of models.ts
f931536
Merge remote-tracking branch 'origin/main' into mark/experimental-mod…
7bba29a
Merge branch 'main' into mark/experimental-models-gateway
markijbema e4b1ca1
docs(experiments): note response model-id leak as v1 followup
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 — | ||
| * 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
132
apps/web/src/lib/ai-gateway/experiments/persist.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.