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
10 changes: 10 additions & 0 deletions apps/web/src/app/(app)/components/OrganizationAppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
ChevronLeft,
ChevronRight,
ChartLine,
UsersRound,
} from 'lucide-react';
import { usePathname } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
Expand Down Expand Up @@ -291,6 +292,15 @@ export default function OrganizationAppSidebar({
url: string;
className?: string;
}> = [
...(currentOrg?.plan === 'enterprise'
? [
{
title: 'Groups',
icon: UsersRound,
url: `/organizations/${organizationId}/groups`,
},
]
: []),
...(hasOwnerLevelAccess
? [
{
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/app/(app)/organizations/[id]/groups/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { redirect } from 'next/navigation';
import { OrganizationByPageLayout } from '@/components/organizations/OrganizationByPageLayout';
import { OrganizationGroupsPage } from '@/components/organizations/groups/OrganizationGroupsPage';

export default async function GroupsPage({ params }: { params: Promise<{ id: string }> }) {
return (
<OrganizationByPageLayout
params={params}
render={({ role, organization }) => {
if (organization.plan !== 'enterprise') redirect(`/organizations/${organization.id}`);
return <OrganizationGroupsPage organizationId={organization.id} role={role} />;
}}
/>
);
}
13 changes: 13 additions & 0 deletions apps/web/src/app/api/edit/completions/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
MicrodollarUsageContext,
MicrodollarUsageStats,
} from '@/lib/ai-gateway/processUsage.types';
import { resolveOrganizationMemberModelDecision } from '@/lib/organizations/effective-model-access.server';

let mockInceptionPromoRunning = true;

Expand All @@ -25,6 +26,11 @@ jest.mock('@/lib/config.server', () => ({
jest.mock('@/lib/user/server');
jest.mock('@/lib/organizations/organization-usage');
jest.mock('@/lib/ai-gateway/byok');
jest.mock('@/lib/organizations/effective-model-access.server', () => ({
resolveOrganizationMemberModelDecision: jest.fn().mockResolvedValue({
decision: { allowed: true },
}),
}));
jest.mock('@/lib/redis', () => ({
redisClient: {
get: jest.fn().mockResolvedValue(null),
Expand Down Expand Up @@ -62,6 +68,9 @@ const mockedGetUserFromAuth = jest.mocked(getUserFromAuth);
const mockedGetBalanceAndOrgSettings = jest.mocked(getBalanceAndOrgSettings);
const mockedGetBYOKforOrganization = jest.mocked(getBYOKforOrganization);
const mockedGetBYOKforUser = jest.mocked(getBYOKforUser);
const mockedResolveOrganizationMemberModelDecision = jest.mocked(
resolveOrganizationMemberModelDecision
);
const mockedFetch = jest.fn() as jest.MockedFunction<typeof globalThis.fetch>;
const originalFetch = globalThis.fetch;

Expand Down Expand Up @@ -155,6 +164,10 @@ async function flushAfter() {
describe('POST /api/edit/completions', () => {
beforeEach(() => {
jest.resetAllMocks();
mockedResolveOrganizationMemberModelDecision.mockResolvedValue({
policy: {} as never,
decision: { allowed: true },
});
mockInceptionPromoRunning = true;
globalThis.fetch = mockedFetch;
mockedLogMicrodollarUsage.mockResolvedValue(null);
Expand Down
16 changes: 16 additions & 0 deletions apps/web/src/app/api/edit/completions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
wrapInSafeNextResponse,
captureProxyError,
extractHeaderAndLimitLength,
modelNotAllowedResponse,
} from '@/lib/ai-gateway/llm-proxy-helpers';
import { ProxyErrorType } from '@/lib/proxy-error-types';
import { getBalanceAndOrgSettings } from '@/lib/organizations/organization-usage';
Expand All @@ -28,6 +29,7 @@ import { debugSaveProxyRequest } from '@/lib/debugUtils';
import { sentryLogger } from '@/lib/utils.server';
import { getBYOKforOrganization, getBYOKforUser } from '@/lib/ai-gateway/byok';
import type { UserByokProviderId } from '@/lib/ai-gateway/providers/openrouter/inference-provider-id';
import { resolveOrganizationMemberModelDecision } from '@/lib/organizations/effective-model-access.server';

// Inception's edit endpoint mirrors a chat completion shape but is hosted at
// a separate path. It accepts a single `role: "user"` message; the system prompt
Expand Down Expand Up @@ -208,6 +210,20 @@ export async function POST(request: NextRequest) {
});
if (modelRestrictionError) return modelRestrictionError;

if (organizationId) {
const { decision } = await resolveOrganizationMemberModelDecision({
organizationId,
kiloUserId: user.id,
modelId: requestBody.model,
});
if (
!decision.allowed ||
(decision.eligibleProviderRoutes && !decision.eligibleProviderRoutes.has(editProvider))
) {
return modelNotAllowedResponse();
}
}

// Org-level "do not collect my data" opt-out. The OpenRouter/Vercel paths
// honor this by setting `provider.data_collection = 'deny'` on the upstream
// request body, which causes the gateway to route to a sub-provider with a
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/app/api/fim/completions/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
MicrodollarUsageContext,
MicrodollarUsageStats,
} from '@/lib/ai-gateway/processUsage.types';
import { resolveOrganizationMemberModelDecision } from '@/lib/organizations/effective-model-access.server';

let mockInceptionPromoRunning = true;

Expand All @@ -25,6 +26,11 @@ jest.mock('@/lib/config.server', () => ({
jest.mock('@/lib/user/server');
jest.mock('@/lib/organizations/organization-usage');
jest.mock('@/lib/ai-gateway/byok');
jest.mock('@/lib/organizations/effective-model-access.server', () => ({
resolveOrganizationMemberModelDecision: jest.fn().mockResolvedValue({
decision: { allowed: true },
}),
}));
jest.mock('@/lib/redis', () => ({
redisClient: {
get: jest.fn().mockResolvedValue(null),
Expand Down Expand Up @@ -58,6 +64,9 @@ const mockedGetUserFromAuth = jest.mocked(getUserFromAuth);
const mockedGetBalanceAndOrgSettings = jest.mocked(getBalanceAndOrgSettings);
const mockedGetBYOKforOrganization = jest.mocked(getBYOKforOrganization);
const mockedGetBYOKforUser = jest.mocked(getBYOKforUser);
const mockedResolveOrganizationMemberModelDecision = jest.mocked(
resolveOrganizationMemberModelDecision
);
const mockedFetch = jest.fn() as jest.MockedFunction<typeof globalThis.fetch>;
const originalFetch = globalThis.fetch;

Expand Down Expand Up @@ -122,6 +131,10 @@ describe('POST /api/fim/completions', () => {
mockInceptionPromoRunning = true;
globalThis.fetch = mockedFetch;
mockedLogMicrodollarUsage.mockResolvedValue(null);
mockedResolveOrganizationMemberModelDecision.mockResolvedValue({
policy: {} as never,
decision: { allowed: true },
});
});

afterAll(() => {
Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/app/api/fim/completions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
wrapInSafeNextResponse,
captureProxyError,
extractHeaderAndLimitLength,
modelNotAllowedResponse,
} from '@/lib/ai-gateway/llm-proxy-helpers';
import { ProxyErrorType } from '@/lib/proxy-error-types';
import { getBalanceAndOrgSettings } from '@/lib/organizations/organization-usage';
Expand All @@ -27,6 +28,7 @@ import { debugSaveProxyRequest } from '@/lib/debugUtils';
import { sentryLogger } from '@/lib/utils.server';
import { getBYOKforOrganization, getBYOKforUser } from '@/lib/ai-gateway/byok';
import type { UserByokProviderId } from '@/lib/ai-gateway/providers/openrouter/inference-provider-id';
import { resolveOrganizationMemberModelDecision } from '@/lib/organizations/effective-model-access.server';

// Mistral exposes FIM on two separate, key-incompatible endpoints:
// - https://api.mistral.ai (La Plateforme, paid tier keys)
Expand Down Expand Up @@ -219,6 +221,18 @@ export async function POST(request: NextRequest) {
});
if (modelRestrictionError) return modelRestrictionError;

if (organizationId) {
const { decision } = await resolveOrganizationMemberModelDecision({
organizationId,
kiloUserId: user.id,
modelId: requestBody.model,
});
if (!decision.allowed) return modelNotAllowedResponse();
if (decision.eligibleProviderRoutes && !decision.eligibleProviderRoutes.has(fimProvider)) {
return modelNotAllowedResponse();
}
}

// FIM routes directly to providers, so enforce the resolved provider name here.
if (providerConfig?.only && !providerConfig.only.includes(fimProvider)) {
return NextResponse.json(
Expand Down
38 changes: 38 additions & 0 deletions apps/web/src/app/api/gateway/embedding-models/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,44 @@
import { NextResponse } from 'next/server';
import { KILO_EMBEDDING_MODEL_CATALOG } from '@/lib/ai-gateway/embeddings/kilo-embedding-models';
import { getUserFromAuth } from '@/lib/user/server';
import {
getEffectiveModelDecision,
resolveOrganizationMemberModelPolicy,
} from '@/lib/organizations/effective-model-access.server';

export async function GET(): Promise<NextResponse> {
const auth = await getUserFromAuth({ adminOnly: false }).catch(() => null);
if (auth?.organizationId && auth.user) {
// Resolve the member's policy once, then evaluate each catalog model
// against it — the policy context is a transaction + several queries.
const policy = await resolveOrganizationMemberModelPolicy({
organizationId: auth.organizationId,
kiloUserId: auth.user.id,
});
const models = [];
for (const model of KILO_EMBEDDING_MODEL_CATALOG.models) {
if ((await getEffectiveModelDecision(policy, model.id)).allowed) models.push(model);
}
if (models.length === 0) {
return NextResponse.json(
{ error: 'No embedding models are available through your organization groups.' },
{ status: 409 }
);
}
const modelIds = new Set(models.map(model => model.id));
const aliases = Object.fromEntries(
Object.entries(KILO_EMBEDDING_MODEL_CATALOG.aliases).filter(([, modelId]) =>
modelIds.has(modelId)
)
);
return NextResponse.json({
...KILO_EMBEDDING_MODEL_CATALOG,
defaultModel: modelIds.has(KILO_EMBEDDING_MODEL_CATALOG.defaultModel)
? KILO_EMBEDDING_MODEL_CATALOG.defaultModel
: models[0].id,
models,
aliases,
});
}
return NextResponse.json(KILO_EMBEDDING_MODEL_CATALOG);
}
18 changes: 18 additions & 0 deletions apps/web/src/app/api/gateway/transcription-models/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import { NextResponse } from 'next/server';
import { captureException } from '@sentry/nextjs';
import type { OpenRouterModelsResponse } from '@/lib/organizations/organization-types';
import { getOpenRouterTranscriptionModels } from '@/lib/ai-gateway/providers/openrouter';
import { getUserFromAuth } from '@/lib/user/server';
import {
getEffectiveModelDecision,
resolveOrganizationMemberModelPolicy,
} from '@/lib/organizations/effective-model-access.server';

/**
* Test using:
Expand All @@ -12,6 +17,19 @@ export async function GET(): Promise<
> {
try {
const data = await getOpenRouterTranscriptionModels();
const auth = await getUserFromAuth({ adminOnly: false }).catch(() => null);
if (auth?.organizationId && auth.user && Array.isArray(data.data)) {
// Resolve the member's policy once, then evaluate each catalog model.
const policy = await resolveOrganizationMemberModelPolicy({
organizationId: auth.organizationId,
kiloUserId: auth.user.id,
});
const models = [];
for (const model of data.data) {
if ((await getEffectiveModelDecision(policy, model.id)).allowed) models.push(model);
}
return NextResponse.json({ ...data, data: models });
}
return NextResponse.json(data);
} catch (error) {
captureException(error, {
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/app/api/openrouter/[...path]/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ jest.mock('@sentry/nextjs', () => ({
jest.mock('@/lib/user/server');
jest.mock('@/lib/organizations/organization-usage');
jest.mock('@/lib/drizzle', () => ({ readDb: {} }));
jest.mock('@/lib/organizations/organization-group-policy-context.server', () => ({
getOrganizationGroupPolicyContext: jest.fn().mockResolvedValue({}),
}));
jest.mock('@/lib/organizations/effective-model-access.server', () => ({
evaluateEffectiveModelAccessPolicy: jest.fn().mockReturnValue({}),
getEffectiveModelDecision: jest.fn().mockResolvedValue({ allowed: true }),
}));
jest.mock('@/lib/ai-gateway/abuse-service', () => {
const actual = jest.requireActual('@/lib/ai-gateway/abuse-service');
return {
Expand Down
48 changes: 46 additions & 2 deletions apps/web/src/app/api/openrouter/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,11 @@ import {
import { redactProviderHints } from '@kilocode/auto-routing-contracts';
import { logExceptInTest } from '@/lib/utils.server';
import { readDb } from '@/lib/drizzle';
import { getOrganizationGroupPolicyContext } from '@/lib/organizations/organization-group-policy-context.server';
import {
evaluateEffectiveModelAccessPolicy,
getEffectiveModelDecision,
} from '@/lib/organizations/effective-model-access.server';

export const maxDuration = 800;

Expand Down Expand Up @@ -456,6 +461,25 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
user = maybeUser;
}

// Enterprise group model-access policy is enforced on the hottest request
// path, so start its DB read now — concurrently with provider resolution,
// abuse checks, and the balance await below — rather than sequentially just
// before routing. The evaluated policy and per-model decision are then
// in-memory (the provider index is cached), so awaiting this promise later
// adds no extra round-trip. Only authenticated org requests need it.
const organizationGroupPolicyPromise =
Comment thread
jrf0110 marked this conversation as resolved.
organizationId && !isAnonymousContext(user)
? getOrganizationGroupPolicyContext({
organizationId,
subject: { type: 'member', kiloUserId: user.id },
}).then(evaluateEffectiveModelAccessPolicy)
: null;
// The prefetch is awaited only on the authorized, non-bypassed path below, and
// roughly a dozen earlier returns can skip it entirely. Attach a no-op handler
// so a policy-context failure can never surface as an unhandled rejection; the
// await below still receives the original error.
void organizationGroupPolicyPromise?.catch(() => {});

// Fraud/project headers are pure header parsing; resolve them here so the
// classifier-overhead billing below can be scheduled before any downstream
// rejection path runs.
Expand Down Expand Up @@ -792,6 +816,26 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
});
if (modelRestrictionError) return modelRestrictionError;

let effectiveProviderConfig = providerConfig;
if (organizationGroupPolicyPromise) {
// Started right after auth so the DB read overlapped the work above; the
// decision itself is in-memory against the cached provider index.
const groupPolicy = await organizationGroupPolicyPromise;
const groupDecision = await getEffectiveModelDecision(
groupPolicy,
effectiveModelIdLowerCased
);
if (!groupDecision.allowed) return modelNotAllowedResponse();
if (groupDecision.eligibleProviderRoutes) {
const currentOnly = providerConfig?.only;
const only = currentOnly
? currentOnly.filter(provider => groupDecision.eligibleProviderRoutes?.has(provider))
: [...groupDecision.eligibleProviderRoutes];
if (only.length === 0) return modelNotAllowedResponse();
effectiveProviderConfig = { ...providerConfig, only };
}
}

Comment thread
jrf0110 marked this conversation as resolved.
// 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
Expand All @@ -812,8 +856,8 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
// 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 && !effectiveProviderContext.experiment) {
requestBodyParsed.body.provider = providerConfig;
if (effectiveProviderConfig && !effectiveProviderContext.experiment) {
requestBodyParsed.body.provider = effectiveProviderConfig;
}
}

Expand Down
Loading