Skip to content
Open
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
1 change: 1 addition & 0 deletions apps/web/src/app/api/gateway/embedding-models/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export async function GET(): Promise<NextResponse> {
const policy = await resolveOrganizationMemberModelPolicy({
organizationId: auth.organizationId,
kiloUserId: auth.user.id,
consistency: 'eventual',
});
const models = [];
for (const model of KILO_EMBEDDING_MODEL_CATALOG.models) {
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/app/api/gateway/transcription-models/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export async function GET(): Promise<
const policy = await resolveOrganizationMemberModelPolicy({
organizationId: auth.organizationId,
kiloUserId: auth.user.id,
consistency: 'eventual',
});
const models = [];
for (const model of data.data) {
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 @@ -25,6 +25,7 @@ import {
import { gemma_4_26b_a4b_it_free_model } from '@/lib/ai-gateway/providers/google';
import { tencent_hy3_free_model } from '@/lib/ai-gateway/providers/tencent';
import { getEffectiveModelDecision } from '@/lib/organizations/effective-model-access.server';
import { getOrganizationGroupPolicyContext } from '@/lib/organizations/organization-group-policy-context.server';

jest.mock('next/server', () => {
return {
Expand Down Expand Up @@ -128,6 +129,7 @@ const mockedCheckFreeModelRateLimitByUser = jest.mocked(checkFreeModelRateLimitB
const mockedCheckPromotionLimit = jest.mocked(checkPromotionLimit);
const mockedLogFreeModelRequest = jest.mocked(logFreeModelRequest);
const mockedGetEffectiveModelDecision = jest.mocked(getEffectiveModelDecision);
const mockedGetOrganizationGroupPolicyContext = jest.mocked(getOrganizationGroupPolicyContext);

const provider = {
id: 'openrouter',
Expand Down Expand Up @@ -627,6 +629,11 @@ describe('kilo-auto/efficient classifier billing', () => {
const response = await POST(makeRequest(makeBody('kilo-auto/free')) as never);

expect(response.status).toBe(503);
expect(mockedGetOrganizationGroupPolicyContext).toHaveBeenCalledWith({
organizationId: 'org-1',
subject: { type: 'member', kiloUserId: 'user-123' },
consistency: 'eventual',
});
expect(mockedGetEffectiveModelDecision).toHaveBeenCalledWith(
expect.anything(),
'stepfun/step-3.7-flash:free'
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/app/api/openrouter/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
const context = await getOrganizationGroupPolicyContext({
organizationId: auth.organizationId,
subject: { type: 'member', kiloUserId: auth.user.id },
consistency: 'eventual',
});
return evaluateEffectiveModelAccessPolicy(context);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export async function GET() {
const policy = await resolveOrganizationMemberModelPolicy({
organizationId: auth.organizationId,
kiloUserId: auth.user.id,
consistency: 'eventual',
});
const providers = [];
for (const provider of result[0].data.providers) {
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/app/api/openrouter/models/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,11 @@ describe('GET /api/openrouter/models', () => {
const response = await GET(request());

expect(response.status).toBe(200);
expect(mockedGetAvailableModelsForOrganization).toHaveBeenCalledWith(
'org-1',
{ type: 'member', kiloUserId: 'user-id' },
{ consistency: 'eventual' }
);
await expect(response.json()).resolves.toEqual({
data: [
{
Expand Down
12 changes: 8 additions & 4 deletions apps/web/src/app/api/openrouter/models/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,14 @@ export async function GET(
try {
const result =
auth?.organizationId && auth.user
? await getAvailableModelsForOrganization(auth.organizationId, {
type: 'member',
kiloUserId: auth.user.id,
})
? await getAvailableModelsForOrganization(
auth.organizationId,
{
type: 'member',
kiloUserId: auth.user.id,
},
{ consistency: 'eventual' }
)
: null;
if (result) {
return NextResponse.json({
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/app/api/organizations/[id]/defaults/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export async function GET(
// deny list and provider allow-list driving these defaults are the ones the
// endpoint resolved rather than a second read of the same row.
organization,
consistency: 'eventual',
})
);
const isAllowed = async (modelId: string) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ describe('auto-routing-pool-validation', () => {
});

expect(mockedGetByokOrg).toHaveBeenCalledWith('org-1');
expect(mockedGetOrgModels).toHaveBeenCalledWith('org-1', { type: 'defaultAccess' });
expect(result).toEqual({
ok: false,
error: expect.objectContaining({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { db } from '@/lib/drizzle';
import {
getOrganizationGroupPolicyContext,
type OrganizationGroupPolicyContext,
type OrganizationPolicyConsistency,
} from '@/lib/organizations/organization-group-policy-context.server';

export type EffectiveOrganizationModelPolicy = {
Expand Down Expand Up @@ -177,11 +178,13 @@ export async function isModelRouteAllowed(
export async function resolveOrganizationMemberModelPolicy(params: {
organizationId: string;
kiloUserId: string;
consistency?: OrganizationPolicyConsistency;
}): Promise<EffectiveOrganizationModelPolicy> {
return evaluateEffectiveModelAccessPolicy(
await getOrganizationGroupPolicyContext({
organizationId: params.organizationId,
subject: { type: 'member', kiloUserId: params.kiloUserId },
consistency: params.consistency,
})
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { db, readDb } from '@/lib/drizzle';

jest.mock('@/lib/drizzle', () => ({
db: { transaction: jest.fn() },
readDb: { transaction: jest.fn() },
}));

import { getOrganizationGroupPolicyContext } from './organization-group-policy-context.server';

const mockedPrimaryTransaction = jest.mocked(db.transaction);
const mockedReplicaTransaction = jest.mocked(readDb.transaction);

describe('getOrganizationGroupPolicyContext', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('defaults to a repeatable-read snapshot on the primary', async () => {
const transactionError = new Error('stop after selecting the transaction client');
mockedPrimaryTransaction.mockRejectedValueOnce(transactionError);

await expect(
getOrganizationGroupPolicyContext({
organizationId: '00000000-0000-0000-0000-000000000001',
subject: { type: 'defaultAccess' },
})
).rejects.toBe(transactionError);

expect(mockedPrimaryTransaction).toHaveBeenCalledWith(expect.any(Function), {
isolationLevel: 'repeatable read',
accessMode: 'read only',
});
expect(mockedReplicaTransaction).not.toHaveBeenCalled();
});

it('uses the read replica when eventual consistency is requested', async () => {
const transactionError = new Error('stop after selecting the transaction client');
mockedReplicaTransaction.mockRejectedValueOnce(transactionError);

await expect(
getOrganizationGroupPolicyContext({
organizationId: '00000000-0000-0000-0000-000000000001',
subject: { type: 'defaultAccess' },
consistency: 'eventual',
})
).rejects.toBe(transactionError);

expect(mockedReplicaTransaction).toHaveBeenCalledWith(expect.any(Function), {
isolationLevel: 'repeatable read',
accessMode: 'read only',
});
expect(mockedPrimaryTransaction).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
import { and, eq, isNull } from 'drizzle-orm';
import { TRPCError } from '@trpc/server';
import { captureException } from '@sentry/nextjs';
import { db, type DrizzleTransaction } from '@/lib/drizzle';
import { db, readDb, type DrizzleTransaction } from '@/lib/drizzle';

export type OrganizationPolicySubject =
| {
Expand All @@ -40,6 +40,8 @@ export type OrganizationGroupPolicyContext = {
policyRevision: number;
};

export type OrganizationPolicyConsistency = 'strong' | 'eventual';

const DEFAULT_POLICIES = [
{ type: 'model_access', data: { mode: 'all' } },
] satisfies OrganizationGroupPolicies;
Expand Down Expand Up @@ -70,7 +72,7 @@ function parsePolicies(
* that could disagree with it.
*/
async function resolveOrganization(
client: typeof db | DrizzleTransaction,
client: DrizzleTransaction,
params: { organizationId: string; organization?: Organization }
): Promise<Organization> {
if (params.organization) {
Expand Down Expand Up @@ -102,15 +104,18 @@ export async function getOrganizationGroupPolicyContext(params: {
subject: OrganizationPolicySubject;
/** Organization row the caller already loaded; avoids re-reading it. */
organization?: Organization;
/** Use eventual consistency only for read-only presentation paths that tolerate replica lag. */
consistency?: OrganizationPolicyConsistency;
tx?: DrizzleTransaction;
}): Promise<OrganizationGroupPolicyContext> {
if (!params.tx) {
return await db.transaction(
const database = params.consistency === 'eventual' ? readDb : db;
return await database.transaction(
async tx => await getOrganizationGroupPolicyContext({ ...params, tx }),
{ isolationLevel: 'repeatable read', accessMode: 'read only' }
);
}
const client = params.tx ?? db;
const client = params.tx;
const organization = await resolveOrganization(client, params);

let isDirectMember = false;
Expand Down
10 changes: 8 additions & 2 deletions apps/web/src/lib/organizations/organization-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,20 @@ import { addUserByokAvailability, getOrganizationByokProviderIds } from '@/lib/a
import { readDb } from '@/lib/drizzle';
import {
getOrganizationGroupPolicyContext,
type OrganizationPolicyConsistency,
type OrganizationPolicySubject,
} from '@/lib/organizations/organization-group-policy-context.server';

export async function getAvailableModelsForOrganization(
organizationId: string,
subject: OrganizationPolicySubject
subject: OrganizationPolicySubject,
options: { consistency?: OrganizationPolicyConsistency } = {}
): Promise<OpenRouterModelsResponse | null> {
const context = await getOrganizationGroupPolicyContext({ organizationId, subject });
const context = await getOrganizationGroupPolicyContext({
organizationId,
subject,
consistency: options.consistency,
});
const organization = context.organization;
const policy = evaluateEffectiveModelAccessPolicy(context);

Expand Down
18 changes: 11 additions & 7 deletions apps/web/src/routers/model-preferences-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,17 @@ async function getAllowedModelIdsForOrg(
if (!organizationId) {
return null;
}
const response = await getAvailableModelsForOrganization(organizationId, {
type: 'member',
kiloUserId,
// Callers reach this after `ensureOrganizationAccess`, which also admits Kilo
// admins and parent-organization owners without a membership row.
allowNonMember: true,
});
const response = await getAvailableModelsForOrganization(
organizationId,
{
type: 'member',
kiloUserId,
// Callers reach this after `ensureOrganizationAccess`, which also admits Kilo
// admins and parent-organization owners without a membership row.
allowNonMember: true,
},
{ consistency: 'eventual' }
);
if (!response) {
return new Set();
}
Expand Down
20 changes: 12 additions & 8 deletions apps/web/src/routers/organizations/organization-settings-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,14 +320,18 @@ export const organizationsSettingsRouter = createTRPCRouter({
.query(async ({ input, ctx }) => {
const { organizationId } = input;

const result = await getAvailableModelsForOrganization(organizationId, {
type: 'member',
kiloUserId: ctx.user.id,
// `ensureOrganizationAccess` also admits Kilo admins and parent-organization
// owners, who hold no membership row and belong to no group; they resolve
// against organization-level policy instead of being rejected.
allowNonMember: true,
});
const result = await getAvailableModelsForOrganization(
organizationId,
{
type: 'member',
kiloUserId: ctx.user.id,
// `ensureOrganizationAccess` also admits Kilo admins and parent-organization
// owners, who hold no membership row and belong to no group; they resolve
// against organization-level policy instead of being rejected.
allowNonMember: true,
},
{ consistency: 'eventual' }
);
if (!result) {
throw new TRPCError({
code: 'NOT_FOUND',
Expand Down