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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/web/src/app/admin/api/model-experiments/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ export function useModelExperiment(id: string | null) {
});
}

export function useModelExperimentRequests() {
const trpc = useTRPC();
return useQuery(trpc.admin.modelExperiments.listRequests.queryOptions());
}

function useInvalidate() {
const trpc = useTRPC();
const qc = useQueryClient();
Expand Down
15 changes: 14 additions & 1 deletion apps/web/src/app/admin/gateway/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,21 @@ import { SyncProvidersContent } from '@/app/admin/sync-providers/SyncProvidersCo
import { CustomLlmsContent } from '@/app/admin/custom-llms/CustomLlmsContent';
import { RoutingContent } from '@/app/admin/gateway/RoutingContent';
import { ModelExperimentsContent } from '@/app/admin/model-experiments/ModelExperimentsContent';
import { ModelExperimentRequestsContent } from '@/app/admin/model-experiments/ModelExperimentRequestsContent';

const VALID_TABS: readonly string[] = [
'sync-providers',
'custom-llms',
'routing',
'model-experiments',
'experiment-requests',
];
type Tab = 'sync-providers' | 'custom-llms' | 'routing' | 'model-experiments';
type Tab =
| 'sync-providers'
| 'custom-llms'
| 'routing'
| 'model-experiments'
| 'experiment-requests';
const isValidTab = (value: string | null): value is Tab =>
value !== null && VALID_TABS.includes(value);

Expand Down Expand Up @@ -76,6 +83,9 @@ export default function AdminGatewayPage() {
<TabsTrigger value="model-experiments" className={tabTriggerClass}>
Model Experiments
</TabsTrigger>
<TabsTrigger value="experiment-requests" className={tabTriggerClass}>
Experiment Requests
</TabsTrigger>
</TabsList>
<TabsContent value="sync-providers" className="mt-4">
<SyncProvidersContent />
Expand All @@ -89,6 +99,9 @@ export default function AdminGatewayPage() {
<TabsContent value="model-experiments" className="mt-4">
<ModelExperimentsContent />
</TabsContent>
<TabsContent value="experiment-requests" className="mt-4">
<ModelExperimentRequestsContent />
</TabsContent>
</Tabs>
</div>
</AdminPage>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
'use client';

import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { useModelExperimentRequests } from '@/app/admin/api/model-experiments/hooks';

function formatDate(value: string): string {
return new Date(value).toLocaleString();
}

function formatNullable(value: string | null): string {
return value && value.length > 0 ? value : '—';
}

function formatTokens(value: number): string {
return value.toLocaleString();
}

function formatMicrodollars(value: number | null): string {
if (value === null) return '—';
return value.toLocaleString();
}

function HashCell({ value }: { value: string }) {
const display = value.length > 18 ? `${value.slice(0, 18)}…` : value;
return (
<span className="font-mono text-xs" title={value}>
{display}
</span>
);
}

export function ModelExperimentRequestsContent() {
const { data, isLoading } = useModelExperimentRequests();
const rows = data?.items ?? [];

return (
<div className="flex w-full flex-col gap-y-4">
<div className="flex flex-col gap-1">
<h2 className="text-2xl font-bold">Experiment Requests</h2>
<p className="text-muted-foreground text-sm">
Last 100 requests attributed to model experiments. Prompt bodies stay in R2; this table
shows attribution, routing, and usage metadata only.
</p>
</div>

{isLoading ? (
Comment thread
markijbema marked this conversation as resolved.
<div className="text-muted-foreground text-sm">Loading…</div>
) : rows.length === 0 ? (
<div className="border-border bg-card text-muted-foreground rounded-lg border p-6 text-sm">
No experiment requests recorded yet.
</div>
) : (
<div className="border-border overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Created</TableHead>
<TableHead>Experiment</TableHead>
<TableHead>Variant</TableHead>
<TableHead>Request</TableHead>
<TableHead>Usage</TableHead>
<TableHead>Prompt hash</TableHead>
<TableHead>User / org</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map(row => (
<TableRow key={row.usageId}>
<TableCell className="whitespace-nowrap align-top text-sm">
{formatDate(row.createdAt)}
</TableCell>
<TableCell className="min-w-56 align-top">
<div className="font-medium">{row.experimentName}</div>
<div className="text-muted-foreground font-mono text-xs">
{row.publicModelId}
</div>
<div className="text-muted-foreground mt-1 font-mono text-xs">
{row.experimentId}
</div>
</TableCell>
<TableCell className="min-w-48 align-top">
<div className="font-medium">{row.variantLabel}</div>
<div className="text-muted-foreground font-mono text-xs">
{row.variantVersionId}
</div>
</TableCell>
<TableCell className="min-w-48 align-top">
<div className="flex flex-wrap items-center gap-1">
<Badge variant="outline">{row.requestKind}</Badge>
<Badge variant="secondary">{row.allocationSubject}</Badge>
{row.wasTruncated && <Badge variant="destructive">truncated</Badge>}
</div>
<div className="text-muted-foreground mt-1 text-xs">
Client: {formatNullable(row.clientRequestId)}
</div>
<div className="text-muted-foreground font-mono text-xs">{row.usageId}</div>
</TableCell>
<TableCell className="min-w-48 align-top text-sm">
<div>
Tokens: {formatTokens(row.inputTokens)} in / {formatTokens(row.outputTokens)}{' '}
out
</div>
<div className="text-muted-foreground text-xs">
Cache: {formatTokens(row.cacheWriteTokens)} write /{' '}
{formatTokens(row.cacheHitTokens)} hit
</div>
<div className="text-muted-foreground text-xs">
Cost: {formatMicrodollars(row.costMicrodollars)} µ$ · discount:{' '}
{formatMicrodollars(row.cacheDiscountMicrodollars)} µ$
</div>
{row.hasError && <Badge variant="destructive">error</Badge>}
</TableCell>
<TableCell className="min-w-44 align-top">
<HashCell value={row.requestBodySha256} />
</TableCell>
<TableCell className="min-w-56 align-top text-xs">
<div className="font-mono">{row.userId}</div>
<div className="text-muted-foreground font-mono">
{formatNullable(row.organizationId)}
</div>
<div className="text-muted-foreground mt-1">
{formatNullable(row.provider)} / {formatNullable(row.inferenceProvider)}
</div>
<div className="text-muted-foreground font-mono">
{formatNullable(row.requestedModel)} → {formatNullable(row.upstreamModel)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
);
}
64 changes: 64 additions & 0 deletions apps/web/src/routers/admin/model-experiments-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@ import { cleanupDbForTest, db } from '@/lib/drizzle';
import { insertTestUser } from '@/tests/helpers/user.helper';
import { createCallerForUser } from '@/routers/test-utils';
import {
microdollar_usage,
model_experiment,
model_experiment_request,
model_experiment_variant,
model_experiment_variant_version,
} from '@kilocode/db/schema';
import { decryptApiKey } from '@/lib/ai-gateway/byok/encryption';
import { BYOK_ENCRYPTION_KEY } from '@/lib/config.server';
import { eq } from 'drizzle-orm';
import type { User } from '@kilocode/db/schema';
import { randomUUID } from 'crypto';

let admin: User;

Expand Down Expand Up @@ -93,6 +96,67 @@ describe('admin.modelExperiments — basic CRUD', () => {
expect(withArchived.items.map(i => i.id).sort()).toEqual([a.id, b.id].sort());
});

it('lists the last experiment attribution requests with usage and variant metadata', async () => {
const { caller, experimentId, variantA } =
await makeDraftWithTwoVariants('kilo/preview-requests');
await caller.admin.modelExperiments.activate({ id: experimentId });
const [version] = await db
.select({ id: model_experiment_variant_version.id })
.from(model_experiment_variant_version)
.where(eq(model_experiment_variant_version.variant_id, variantA.id))
.limit(1);
expect(version).toBeDefined();

const usageId = randomUUID();
await db.insert(microdollar_usage).values({
id: usageId,
kilo_user_id: 'request-user',
cost: 0,
input_tokens: 123,
output_tokens: 45,
cache_write_tokens: 6,
cache_hit_tokens: 7,
provider: 'custom',
model: 'partner-checkpoint-rc1',
requested_model: 'kilo/preview-requests',
inference_provider: 'partner',
organization_id: null,
});
await db.insert(model_experiment_request).values({
usage_id: usageId,
variant_version_id: version.id,
allocation_subject: 'user',
client_request_id: 'client-request-123',
request_kind: 'chat_completions',
request_body_sha256: '__failed__',
was_truncated: true,
});

const requests = await caller.admin.modelExperiments.listRequests();

expect(requests.items[0]).toEqual(
expect.objectContaining({
usageId,
experimentId,
experimentName: 'exp-kilo/preview-requests',
publicModelId: 'kilo/preview-requests',
variantId: variantA.id,
variantLabel: 'control',
variantVersionId: version.id,
allocationSubject: 'user',
clientRequestId: 'client-request-123',
requestKind: 'chat_completions',
requestBodySha256: '__failed__',
wasTruncated: true,
userId: 'request-user',
requestedModel: 'kilo/preview-requests',
upstreamModel: 'partner-checkpoint-rc1',
inputTokens: 123,
outputTokens: 45,
})
);
});

it('rejects delete unless status is draft', async () => {
const { caller, experimentId } = await makeDraftWithTwoVariants('kilo/preview-delete');
await caller.admin.modelExperiments.activate({ id: experimentId });
Expand Down
49 changes: 49 additions & 0 deletions apps/web/src/routers/admin/model-experiments-router.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { adminProcedure, createTRPCRouter } from '@/lib/trpc/init';
import { db } from '@/lib/drizzle';
import {
microdollar_usage,
model_experiment,
model_experiment_request,
model_experiment_variant,
model_experiment_variant_version,
} from '@kilocode/db/schema';
Expand Down Expand Up @@ -287,6 +289,53 @@ export const adminModelExperimentsRouter = createTRPCRouter({
return { items: rows };
}),

listRequests: adminProcedure.query(async () => {
const rows = await db
.select({
usageId: model_experiment_request.usage_id,
createdAt: model_experiment_request.created_at,
Comment thread
markijbema marked this conversation as resolved.
experimentId: model_experiment.id,
experimentName: model_experiment.name,
publicModelId: model_experiment.public_model_id,
variantId: model_experiment_variant.id,
variantLabel: model_experiment_variant.label,
variantVersionId: model_experiment_variant_version.id,
allocationSubject: model_experiment_request.allocation_subject,
clientRequestId: model_experiment_request.client_request_id,
requestKind: model_experiment_request.request_kind,
requestBodySha256: model_experiment_request.request_body_sha256,
wasTruncated: model_experiment_request.was_truncated,
userId: microdollar_usage.kilo_user_id,
organizationId: microdollar_usage.organization_id,
requestedModel: microdollar_usage.requested_model,
upstreamModel: microdollar_usage.model,
provider: microdollar_usage.provider,
inferenceProvider: microdollar_usage.inference_provider,
inputTokens: microdollar_usage.input_tokens,
outputTokens: microdollar_usage.output_tokens,
cacheWriteTokens: microdollar_usage.cache_write_tokens,
cacheHitTokens: microdollar_usage.cache_hit_tokens,
costMicrodollars: microdollar_usage.cost,
cacheDiscountMicrodollars: microdollar_usage.cache_discount,
hasError: microdollar_usage.has_error,
})
.from(model_experiment_request)
.innerJoin(microdollar_usage, eq(model_experiment_request.usage_id, microdollar_usage.id))
.innerJoin(
model_experiment_variant_version,
eq(model_experiment_request.variant_version_id, model_experiment_variant_version.id)
)
.innerJoin(
model_experiment_variant,
eq(model_experiment_variant_version.variant_id, model_experiment_variant.id)
)
.innerJoin(model_experiment, eq(model_experiment_variant.experiment_id, model_experiment.id))
.orderBy(desc(model_experiment_request.created_at))
.limit(100);

return { items: rows };
}),

get: adminProcedure.input(idSchema).query(async ({ input }) => {
const experiment = await loadExperimentOrThrow(input.id);
const variants = await listVariantsWithCurrentVersion(input.id);
Expand Down