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
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ export default async function OrganizationCustomModesPage({
return (
<OrganizationByPageLayout
params={params}
render={({ organization }) => <CustomModesLayout organizationId={organization.id} />}
render={({ organization, role, isGlobalAdmin }) => (
<CustomModesLayout
organizationId={organization.id}
role={role}
isGlobalAdmin={isGlobalAdmin}
/>
)}
/>
);
}
49 changes: 49 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 @@ -13,6 +13,7 @@ import type { Provider } from '@/lib/ai-gateway/providers/types';
import { fetchEfficientAutoDecision } from '@/lib/ai-gateway/auto-routing-decision';
import { logMicrodollarUsage } from '@/lib/ai-gateway/processUsage';
import { applyResolvedAutoModel } from '@/lib/ai-gateway/auto-model/resolution';
import { getDirectByokModel } from '@/lib/ai-gateway/providers/direct-byok';

jest.mock('next/server', () => {
return {
Expand Down Expand Up @@ -40,6 +41,9 @@ jest.mock('@/lib/ai-gateway/abuse-service', () => {
};
});
jest.mock('@/lib/ai-gateway/providers/get-provider');
jest.mock('@/lib/ai-gateway/providers/direct-byok', () => ({
getDirectByokModel: jest.fn(async () => ({ provider: null, model: null })),
}));
jest.mock('@/lib/ai-gateway/providers/upstream-request');
jest.mock('@/lib/ai-gateway/providers/gateway-models-cache');
jest.mock('@/lib/redis', () => ({
Expand Down Expand Up @@ -90,6 +94,7 @@ const mockedRedisSet = jest.mocked(redisClient.set);
const mockedFetchEfficientAutoDecision = jest.mocked(fetchEfficientAutoDecision);
const mockedLogMicrodollarUsage = jest.mocked(logMicrodollarUsage);
const mockedApplyResolvedAutoModel = jest.mocked(applyResolvedAutoModel);
const mockedGetDirectByokModel = jest.mocked(getDirectByokModel);

const provider = {
id: 'openrouter',
Expand Down Expand Up @@ -413,7 +418,9 @@ describe('POST /api/openrouter/v1/chat/completions rules-engine actions', () =>
describe('kilo-auto/efficient classifier billing', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedGetDirectByokModel.mockResolvedValue({ provider: null, model: null });
setUserAuth();

mockedGetProvider.mockResolvedValue({
kind: 'provider',
provider,
Expand Down Expand Up @@ -443,6 +450,48 @@ describe('kilo-auto/efficient classifier billing', () => {
});
});

it('rejects Organization Auto direct-BYOK routes when provider selection falls through', async () => {
mockedGetUserFromAuth.mockResolvedValue({
user: {
id: 'user-123',
google_user_email: 'test@example.com',
microdollars_used: 0,
} as User,
authFailedResponse: null,
organizationId: 'org-1',
});
mockedGetBalanceAndOrgSettings.mockResolvedValue({
balance: 1000,
settings: {
default_model: 'kilo-auto/org',
org_auto_model: { routes: {}, fallback_model: 'kilo-auto/balanced' },
},
plan: 'enterprise',
});
mockedApplyResolvedAutoModel.mockImplementation(async (_params, request) => {
request.body.model = 'martian/moonshotai/kimi-k2.6';
return {
kind: 'ok',
resolved: { model: 'martian/moonshotai/kimi-k2.6' },
routingTarget: 'martian/moonshotai/kimi-k2.6',
};
});
mockedGetDirectByokModel.mockResolvedValue({
provider: { id: 'martian' } as never,
model: {} as never,
});

const { POST } = await import('./route');
const response = await POST(makeRequest(makeBody('kilo-auto/org')) as never);

expect(response.status).toBe(400);
expect(await response.json()).toMatchObject({
error_type: 'organization_auto_configuration',
message: expect.stringContaining('does not have an enabled BYOK credential for martian'),
});
expect(mockedUpstreamRequest).not.toHaveBeenCalled();
});

it('bills classifier cost when cost > 0 and user is non-BYOK', async () => {
mockedFetchEfficientAutoDecision.mockResolvedValue({
decision: {
Expand Down
40 changes: 40 additions & 0 deletions apps/web/src/app/api/openrouter/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ 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 { getDirectByokModel } from '@/lib/ai-gateway/providers/direct-byok';
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';
Expand Down Expand Up @@ -48,6 +49,7 @@ import {
modelNotAllowedResponse,
extractHeaderAndLimitLength,
noFreeModelsAvailableResponse,
organizationAutoConfigurationResponse,
temporarilyUnavailableResponse,
usageLimitExceededResponse,
wrapInSafeNextResponse,
Expand Down Expand Up @@ -95,6 +97,7 @@ import {
isKiloAutoModel,
KILO_AUTO_FREE_MODEL,
KILO_AUTO_EFFICIENT_MODEL,
ORG_AUTO_MODEL,
} from '@/lib/ai-gateway/auto-model';
import { applyResolvedAutoModel } from '@/lib/ai-gateway/auto-model/resolution';
import { fetchEfficientAutoDecision } from '@/lib/ai-gateway/auto-routing-decision';
Expand Down Expand Up @@ -243,6 +246,13 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
? getBalanceAndOrgSettings(res.organizationId, res.user)
: { balance: 0, settings: undefined, plan: undefined }
);
const organizationContextPromise = Promise.all([authPromise, balanceAndSettingsPromise]).then(
([auth, balanceAndSettings]) => ({
organizationId: auth.organizationId,
settings: balanceAndSettings.settings,
plan: balanceAndSettings.plan,
})
);

// Extract IP early (needed for free model routing fallback and rate limiting)
const ipAddress = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim();
Expand Down Expand Up @@ -272,6 +282,7 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
}

let autoModel: string | null = null;
let routingTarget: string | null = null;
let classifierCostUsd = 0;
if (isKiloAutoModel(requestedModelLowerCased)) {
autoModel = requestedModelLowerCased;
Expand Down Expand Up @@ -312,6 +323,7 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
apiKind: requestBodyParsed.kind,
clientIp: ipAddress ?? null,
efficientDecision,
organizationContext: organizationContextPromise,
},
requestBodyParsed,
authPromise.then(res => res.user),
Expand All @@ -320,6 +332,10 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
if (autoResult.kind === 'no_free_models_available') {
return noFreeModelsAvailableResponse();
}
if (autoResult.kind === 'organization_auto_configuration_error') {
return organizationAutoConfigurationResponse(autoResult.message);
}
routingTarget = autoResult.routingTarget ?? null;
}

let effectiveModelIdLowerCased = requestBodyParsed.body.model.toLowerCase();
Expand Down Expand Up @@ -348,6 +364,7 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
const isRateLimitedFreeModelRequest =
isKiloExclusiveFreeModel(effectiveModelIdLowerCased) ||
autoModel === KILO_AUTO_FREE_MODEL.id ||
routingTarget === KILO_AUTO_FREE_MODEL.id ||
(await isPublicIdExperimented(effectiveModelIdLowerCased));
if (isRateLimitedFreeModelRequest) {
const rateLimit = await resolveRateLimit(feature, ipAddress, authPromise);
Expand Down Expand Up @@ -556,6 +573,29 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
}
let effectiveProviderContext = initialProviderResultForAbuseService;

if (autoModel === ORG_AUTO_MODEL.id && routingTarget) {
try {
const directByokTarget = await getDirectByokModel(routingTarget);
if (directByokTarget.provider && effectiveProviderContext.provider.id !== 'direct-byok') {
return organizationAutoConfigurationResponse(
`Organization Auto route target '${routingTarget}' is unavailable because this organization does not have an enabled BYOK credential for ${directByokTarget.provider.id}.`
);
}
} catch {
return organizationAutoConfigurationResponse(
'Organization Auto could not validate this route target against the current model catalog.'
);
}
}

// 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 (
(await hasBestEffortGuessDataCollectionRequirement(effectiveModelIdLowerCased)) &&
isDataCollectionExplicitlyDisallowed(requestBodyParsed.body.provider)
Expand Down
32 changes: 32 additions & 0 deletions apps/web/src/app/api/organizations/[id]/defaults/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,38 @@ describe('GET /api/organizations/[id]/defaults', () => {
expect(mockedGetEnhancedOpenRouterModels).not.toHaveBeenCalled();
});

test('returns Organization Auto when it is configured as the organization default', async () => {
const user = await insertTestUser();
const organization = await createOrganization('Test Org', user.id);

mockedGetEnhancedOpenRouterModels.mockRejectedValue(new Error('should not be called'));
mockedGetAuthorizedOrgContext.mockResolvedValue({
success: true,
data: {
user: { ...user, role: 'owner' },
organization: {
...organization,
plan: 'enterprise' as const,
settings: {
default_model: 'kilo-auto/org',
org_auto_model: {
routes: {},
fallback_model: 'kilo-auto/balanced',
},
},
},
},
});

const response = await GET(new NextRequest('http://localhost:3000'), {
params: Promise.resolve({ id: organization.id }),
});

expect(response.status).toBe(200);
const body = await response.json();
expect(body.defaultModel).toBe('kilo-auto/org');
});

test('returns 409 when all available models are blocked by policy', async () => {
const user = await insertTestUser();
const organization = await createOrganization('Test Org', user.id);
Expand Down
16 changes: 13 additions & 3 deletions apps/web/src/app/api/organizations/[id]/defaults/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ import {
hasActiveModelRestrictions,
} from '@/lib/model-allow.server';
import { getModelIdToProviderSlugsIndex } from '@/lib/ai-gateway/providers/openrouter/models-by-provider-index.server';
import { KILO_AUTO_FREE_MODEL } from '@/lib/ai-gateway/auto-model';
import { KILO_AUTO_FREE_MODEL, ORG_AUTO_MODEL } from '@/lib/ai-gateway/auto-model';
import { getEffectiveModelRestrictions } from '@/lib/organizations/model-restrictions';
import { isOrganizationAutoConfigured } from '@/lib/organizations/organization-auto-model';

type DefaultsResponse = {
defaultModel: string;
Expand Down Expand Up @@ -67,8 +68,17 @@ export async function GET(
return undefined;
};

// If organization has a default model set, validate it against allowed models
if (defaultModel && (defaultModel.endsWith('/*') || !(await isAllowed(defaultModel)))) {
// If organization has a default model set, validate it against allowed models.
// Organization Auto is a virtual organization-only default, so its eligibility
// is validated from persisted organization settings rather than provider policy.
if (defaultModel === ORG_AUTO_MODEL.id && !isOrganizationAutoConfigured(organization)) {
console.warn('organization_auto_invalid_default', { organizationId: organization.id });
defaultModel = undefined;
} else if (
defaultModel &&
defaultModel !== ORG_AUTO_MODEL.id &&
(defaultModel.endsWith('/*') || !(await isAllowed(defaultModel)))
) {
// Organization's configured default model is not permitted; fall back to a safe default.
defaultModel = undefined;
}
Expand Down
50 changes: 1 addition & 49 deletions apps/web/src/app/api/organizations/[id]/modes/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const mockedGetAuthorizedOrgContext = jest.mocked(getAuthorizedOrgContext);
const mockedGetAllOrganizationModes = jest.mocked(getAllOrganizationModes);

describe('GET /api/organizations/[id]/modes', () => {
test('returns defaultModel as part of the additive mode payload', async () => {
test('returns the direct mode payload without Organization Auto route projection', async () => {
mockedGetAuthorizedOrgContext.mockResolvedValue({
success: true,
data: {
Expand All @@ -30,7 +30,6 @@ describe('GET /api/organizations/[id]/modes', () => {
config: {
roleDefinition: 'You are a coding assistant',
groups: ['read'],
defaultModel: 'openai/gpt-4o',
},
},
]);
Expand All @@ -53,57 +52,10 @@ describe('GET /api/organizations/[id]/modes', () => {
config: {
roleDefinition: 'You are a coding assistant',
groups: ['read'],
defaultModel: 'openai/gpt-4o',
},
},
],
});
expect(mockedGetAllOrganizationModes).toHaveBeenCalledWith('org-1');
});

test('returns a legacy mode row without defaultModel unchanged', async () => {
mockedGetAuthorizedOrgContext.mockResolvedValue({
success: true,
data: {
organization: { id: 'org-1' },
},
} as never);
mockedGetAllOrganizationModes.mockResolvedValue([
{
id: 'mode-1',
organization_id: 'org-1',
name: 'Code',
slug: 'code',
created_by: 'user-1',
created_at: '2026-01-01T00:00:00.000Z',
updated_at: '2026-01-01T00:00:00.000Z',
config: {
roleDefinition: 'You are a coding assistant',
groups: ['read'],
},
},
]);

const response = await GET(new NextRequest('http://localhost:3000'), {
params: Promise.resolve({ id: 'org-1' }),
});

await expect(response.json()).resolves.toEqual({
modes: [
{
id: 'mode-1',
organization_id: 'org-1',
name: 'Code',
slug: 'code',
created_by: 'user-1',
created_at: '2026-01-01T00:00:00.000Z',
updated_at: '2026-01-01T00:00:00.000Z',
config: {
roleDefinition: 'You are a coding assistant',
groups: ['read'],
},
},
],
});
});
});
3 changes: 1 addition & 2 deletions apps/web/src/app/api/organizations/[id]/modes/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,8 @@ export async function GET(
}

const { organization } = data;
const modes = await getAllOrganizationModes(organization.id);

return NextResponse.json({
modes,
modes: await getAllOrganizationModes(organization.id),
});
}
Loading