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
22 changes: 22 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 @@ -471,6 +471,28 @@ describe('POST /api/openrouter/v1/chat/completions bearer audiences', () => {
expect(mockedUpstreamRequest).not.toHaveBeenCalled();
});

it('skips the zero-balance 402 when the request is billed outside Kilo credits', async () => {
setUserAuth();
mockedGetBalanceAndOrgSettings.mockResolvedValue({
balance: 0,
settings: undefined,
plan: undefined,
});
mockedGetProvider.mockResolvedValue({
kind: 'provider',
provider,
userByok: null,
bypassAccessCheck: false,
skipBalanceCheck: true,
});

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

expect(response.status).toBe(200);
expect(mockedUpstreamRequest).toHaveBeenCalled();
});

it('returns a retryable response for a zero balance during an in-flight auto top-up', async () => {
setUserAuth();
mockedGetBalanceAndOrgSettings.mockResolvedValue({
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/app/api/openrouter/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -868,7 +868,8 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
if (
balance <= 0 &&
!(await isFreeModel(effectiveModelIdLowerCased)) &&
!effectiveProviderContext.userByok
!effectiveProviderContext.userByok &&
!effectiveProviderContext.skipBalanceCheck
) {
return await creditsBlockedResponse({
user,
Expand Down
37 changes: 37 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 @@ -19,6 +19,9 @@ jest.mock('@/lib/ai-gateway/byok', () => ({
addUserByokAvailability: jest.fn(),
getUserByokProviderIds: jest.fn(),
}));
jest.mock('@/lib/ai-gateway/openai-chatgpt/routing', () => ({
tagOpenAiChatGptByokModels: jest.fn((_userId: string, models: unknown[]) => models),
}));
jest.mock('@/lib/organizations/organization-models', () => ({
getAvailableModelsForOrganization: jest.fn(),
}));
Expand All @@ -38,6 +41,7 @@ const { listAvailableExperimentModels } = jest.requireMock(
);
const { addUserByokAvailability, getUserByokProviderIds } =
jest.requireMock('@/lib/ai-gateway/byok');
const { tagOpenAiChatGptByokModels } = jest.requireMock('@/lib/ai-gateway/openai-chatgpt/routing');
const { getAvailableModelsForOrganization } = jest.requireMock(
'@/lib/organizations/organization-models'
);
Expand All @@ -50,6 +54,7 @@ const mockedGetDirectByokModelsForUser = jest.mocked(getDirectByokModelsForUser)
const mockedListAvailableExperimentModels = jest.mocked(listAvailableExperimentModels);
const mockedAddUserByokAvailability = jest.mocked(addUserByokAvailability);
const mockedGetUserByokProviderIds = jest.mocked(getUserByokProviderIds);
const mockedTagOpenAiChatGptByokModels = jest.mocked(tagOpenAiChatGptByokModels);
const mockedGetAvailableModelsForOrganization = jest.mocked(getAvailableModelsForOrganization);
const mockedGetCachedRoutingTable = jest.mocked(getCachedRoutingTable);
const mockedGetAutoFreeCandidates = jest.mocked(getAutoFreeCandidates);
Expand Down Expand Up @@ -87,6 +92,9 @@ describe('GET /api/openrouter/models', () => {
mockedGetDirectByokModelsForUser.mockResolvedValue([]);
mockedListAvailableExperimentModels.mockResolvedValue([]);
mockedGetUserByokProviderIds.mockResolvedValue([]);
mockedTagOpenAiChatGptByokModels.mockImplementation(
(_userId: string, models: OpenRouterModel[]) => models
);
mockedGetAvailableModelsForOrganization.mockResolvedValue(null);
mockedGetCachedRoutingTable.mockResolvedValue(null);
mockedGetAutoFreeCandidates.mockResolvedValue([]);
Expand Down Expand Up @@ -131,6 +139,35 @@ describe('GET /api/openrouter/models', () => {
});
});

test('tags models an enabled ChatGPT connection can serve', async () => {
const openAiModel = makeModel('openai/gpt-5-nano');
const otherModel = makeModel('anthropic/claude');
mockedGetUserFromAuth.mockResolvedValue({
user: { id: 'user-id' },
organizationId: null,
authFailedResponse: null,
} as never);
mockedGetEnhancedOpenRouterModels.mockResolvedValue({ data: [openAiModel, otherModel] });
mockedAddUserByokAvailability.mockResolvedValue([openAiModel, otherModel]);
mockedTagOpenAiChatGptByokModels.mockImplementation(
(_userId: string, models: OpenRouterModel[]) =>
models.map(model =>
model.id === 'openai/gpt-5-nano' ? { ...model, hasUserByokAvailable: true } : model
)
);

const response = await GET(request());

expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
data: [{ ...openAiModel, hasUserByokAvailable: true }, otherModel],
});
expect(mockedTagOpenAiChatGptByokModels).toHaveBeenCalledWith('user-id', [
openAiModel,
otherModel,
]);
});

test('adds auto-routing models from routing sources', async () => {
const efficientModel = makeModel('kilo-auto/efficient');
const freeModel = makeModel('kilo-auto/free');
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/app/api/openrouter/models/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { getDirectByokModelsForUser } from '@/lib/ai-gateway/providers/direct-by
import { getAvailableModelsForOrganization } from '@/lib/organizations/organization-models';
import { listAvailableExperimentModels } from '@/lib/ai-gateway/experiments/list-available-experiment-models';
import { addUserByokAvailability, getUserByokProviderIds } from '@/lib/ai-gateway/byok';
import { tagOpenAiChatGptByokModels } from '@/lib/ai-gateway/openai-chatgpt/routing';
import { readDb } from '@/lib/drizzle';
import { addAutoRoutingModels } from '@/lib/ai-gateway/auto-routing-models';
import { appendLocalFakeDeterministicCatalogModels } from '@/lib/ai-gateway/local-fake-llm';
Expand Down Expand Up @@ -74,9 +75,9 @@ export async function GET(
listAvailableExperimentModels(),
getUserByokProviderIds(readDb, auth.user.id),
]);
const modelsWithByokAvailability = await addUserByokAvailability(
models,
enabledByokProviderIds
const modelsWithByokAvailability = await tagOpenAiChatGptByokModels(
auth.user.id,
await addUserByokAvailability(models, enabledByokProviderIds)
);
return await modelResponse({
data: appendLocalFakeDeterministicCatalogModels(
Expand Down
79 changes: 79 additions & 0 deletions apps/web/src/lib/ai-gateway/openai-chatgpt/routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,11 @@ import { EmptyFraudDetectionHeaders } from '@/lib/utils';
import {
buildOpenAiChatGptProvider,
checkOpenAiChatGptByok,
getOpenAiChatGptByokModelIds,
isOpenAiChatGptEligible,
OPENAI_CHATGPT_API_URL,
OPENAI_ON_BEHALF_OF_TOKEN_HEADER,
tagOpenAiChatGptByokModels,
type OpenAiChatGptRoutingInput,
} from './routing';
import type { OpenAiChatGptConnection } from './types';
Expand Down Expand Up @@ -243,6 +245,82 @@ describe('isOpenAiChatGptEligible', () => {
});
});

describe('getOpenAiChatGptByokModelIds', () => {
it('tags only the served OpenAI models among the candidates', async () => {
jest.mocked(isOpenAiModelServed).mockImplementation(async (_apiKey, modelId) => {
return modelId === 'gpt-5-nano';
});

await expect(
getOpenAiChatGptByokModelIds(USER_ID, [
'openai/gpt-5-nano',
'openai/gpt-5.6-luna-pro',
'anthropic/claude-sonnet-5',
])
).resolves.toEqual(new Set(['openai/gpt-5-nano']));
expect(getOpenAiChatGptStoredConnection).toHaveBeenCalledWith(USER_ID);
expect(isOpenAiModelServed).toHaveBeenCalledWith(PARTNER_KEY, 'gpt-5-nano');
expect(isOpenAiModelServed).toHaveBeenCalledWith(PARTNER_KEY, 'gpt-5.6-luna-pro');
});

it('returns null without a usable connection so the list stays untouched', async () => {
jest.mocked(getOpenAiChatGptStoredConnection).mockResolvedValue(null);
await expect(getOpenAiChatGptByokModelIds(USER_ID, ['openai/gpt-5-nano'])).resolves.toBeNull();

jest.mocked(getOpenAiChatGptStoredConnection).mockResolvedValue({
connection: connectedConnection(),
isEnabled: false,
});
await expect(getOpenAiChatGptByokModelIds(USER_ID, ['openai/gpt-5-nano'])).resolves.toBeNull();

jest.mocked(getOpenAiChatGptStoredConnection).mockResolvedValue({
connection: { ...connectedConnection(), status: 'error' },
isEnabled: true,
});
await expect(getOpenAiChatGptByokModelIds(USER_ID, ['openai/gpt-5-nano'])).resolves.toBeNull();
});

it('returns null without the deployment partner key', async () => {
process.env.OPENAI_CHATGPT_API_KEY = '';

await expect(getOpenAiChatGptByokModelIds(USER_ID, ['openai/gpt-5-nano'])).resolves.toBeNull();
});

it('never tags a gpt-oss or Kilo-exclusive model', async () => {
await expect(
getOpenAiChatGptByokModelIds(USER_ID, ['openai/gpt-oss-20b', 'openai/gpt-5.6-sol-discounted'])
).resolves.toEqual(new Set());
expect(isOpenAiModelServed).not.toHaveBeenCalled();
});
});

describe('tagOpenAiChatGptByokModels', () => {
it('marks the served models and leaves the rest untouched', async () => {
jest.mocked(isOpenAiModelServed).mockImplementation(async (_apiKey, modelId) => {
return modelId === 'gpt-5-nano';
});

await expect(
tagOpenAiChatGptByokModels(USER_ID, [
{ id: 'openai/gpt-5-nano' },
{ id: 'anthropic/claude-sonnet-5' },
{ id: 'openai/gpt-5-nano', hasUserByokAvailable: false },
])
).resolves.toEqual([
{ id: 'openai/gpt-5-nano', hasUserByokAvailable: true },
{ id: 'anthropic/claude-sonnet-5' },
{ id: 'openai/gpt-5-nano', hasUserByokAvailable: true },
]);
});

it('returns the same list when there is no usable connection', async () => {
jest.mocked(getOpenAiChatGptStoredConnection).mockResolvedValue(null);
const models = [{ id: 'openai/gpt-5-nano' }];

await expect(tagOpenAiChatGptByokModels(USER_ID, models)).resolves.toBe(models);
});
});

describe('checkOpenAiChatGptByok', () => {
it('builds a stateless responses provider carrying the delegated token', async () => {
const result = await checkOpenAiChatGptByok(routingInput());
Expand All @@ -251,6 +329,7 @@ describe('checkOpenAiChatGptByok', () => {
kind: 'provider',
userByok: null,
bypassAccessCheck: false,
skipBalanceCheck: true,
provider: {
id: 'openai-chatgpt',
apiUrl: OPENAI_CHATGPT_API_URL,
Expand Down
52 changes: 50 additions & 2 deletions apps/web/src/lib/ai-gateway/openai-chatgpt/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export type OpenAiChatGptRoutingResult =
provider: Provider;
userByok: null;
bypassAccessCheck: false;
skipBalanceCheck: true;
}
| {
/**
Expand Down Expand Up @@ -140,6 +141,51 @@ export async function isOpenAiChatGptEligible(input: OpenAiChatGptRoutingInput):
return stored?.isEnabled === true && stored.connection.status === 'connected';
}

/**
* The catalog model ids an enabled ChatGPT connection can serve, for the models
* list's BYOK tag. Returns null when the person has no usable connection, so the
* caller leaves the list untouched. The served-model gate is the same one
* routing uses, so a model is tagged only when the delegated route can carry it.
*/
export async function getOpenAiChatGptByokModelIds(
userId: string,
candidateModelIds: readonly string[]
): Promise<Set<string> | null> {
const stored = await getOpenAiChatGptStoredConnection(userId);
if (stored?.isEnabled !== true || stored.connection.status !== 'connected') return null;

const apiKey = getEnvVariable(OPENAI_CHATGPT_API_KEY_ENV);
if (apiKey.trim().length === 0) return null;

const tagged = await Promise.all(
candidateModelIds.filter(isOpenAiChatGptModel).map(async modelId => {
const upstreamModel = modelId.trim().replace(OPENAI_MODEL_PREFIX, '');
return (await isOpenAiModelServed(apiKey, upstreamModel)) ? modelId : null;
})
);
return new Set(tagged.filter((id): id is string => id !== null));
}

/**
* Returns `models` with the ChatGPT-served ones marked BYOK-available, so the
* model picker and the extension show the BYOK badge like pasted BYOK keys. The
* list is returned unchanged when the person has no usable connection.
*/
export async function tagOpenAiChatGptByokModels<
T extends { id: string; hasUserByokAvailable?: boolean },
>(userId: string, models: T[]): Promise<T[]> {
const byokModelIds = await getOpenAiChatGptByokModelIds(
userId,
models.map(model => model.id)
);
if (!byokModelIds) return models;
return models.map(model =>
model.hasUserByokAvailable === true || !byokModelIds.has(model.id)
? model
: { ...model, hasUserByokAvailable: true }
);
}

/**
* Builds the provider that serves the delegated request. Returns null when the
* partner key is missing: the deployment is misconfigured for token sharing, so
Expand Down Expand Up @@ -196,8 +242,9 @@ export function buildOpenAiChatGptProvider(apiKey: string, accessToken: string):
* null when the request is not eligible or cannot be served. A `reconnect`
* result is returned when the person's enabled credential is terminally dead:
* the request must fail readably instead of silently running on another billing
* path. Callers keep the standard balance and abuse checks
* (`bypassAccessCheck: false`), exactly like the Vercel BYOK path.
* path. Callers keep the standard abuse and organization policy checks
* (`bypassAccessCheck: false`), but skip the zero-balance paid-model block:
* the ChatGPT plan pays for the request, not Kilo credits.
*/
export async function checkOpenAiChatGptByok(
input: OpenAiChatGptRoutingInput
Expand All @@ -222,5 +269,6 @@ export async function checkOpenAiChatGptByok(
provider,
userByok: null,
bypassAccessCheck: false,
skipBalanceCheck: true,
};
}
4 changes: 4 additions & 0 deletions apps/web/src/lib/ai-gateway/providers/get-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ export type GetProviderProviderResult = {
* by direct-byok and custom_llm2 because both already require explicit
* admin opt-in. */
bypassAccessCheck: boolean;
/** Skip only the zero-balance paid-model block. Set when a user credential
* outside Kilo credits pays for the request, such as the ChatGPT
* subscription, while abuse and organization policy checks still apply. */
skipBalanceCheck?: boolean;
/** Present when this provider was resolved through a model experiment. */
experiment?: ExperimentRouting;
};
Expand Down
26 changes: 26 additions & 0 deletions apps/web/src/lib/organizations/organization-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const mockProviderIds = jest.fn(async (_db: unknown, _organizationId: string) =>
const mockAvailability = jest.fn(async (models: OpenRouterModel[], _providers: string[]) =>
models.map(model => ({ ...model, hasUserByokAvailable: model.id === 'provider/allowed' }))
);
const mockTagChatGpt = jest.fn(async (_userId: string, models: OpenRouterModel[]) => models);

jest.mock('@/lib/config.server', () => ({
get ENKRYPT_PUBLICATION_ENABLED() {
Expand Down Expand Up @@ -76,6 +77,9 @@ jest.mock('@/lib/ai-gateway/byok', () => ({
addUserByokAvailability: mockAvailability,
getOrganizationByokProviderIds: mockProviderIds,
}));
jest.mock('@/lib/ai-gateway/openai-chatgpt/routing', () => ({
tagOpenAiChatGptByokModels: mockTagChatGpt,
}));

const checkedAt = '2026-09-01T00:00:00.000Z';
const benchmark: EnkryptBenchmark = {
Expand Down Expand Up @@ -196,6 +200,7 @@ beforeEach(async () => {
.mockReset()
.mockResolvedValue([catalogModel('kilo-internal/private', { mayTrainOnYourPrompts: true })]);
mockExperiments.mockReset().mockResolvedValue([catalogModel('partner/experiment')]);
mockTagChatGpt.mockReset().mockImplementation(async (_userId, models) => models);
[producer, cache, enkrypt] = await Promise.all([
import('./organization-models'),
import('@/lib/model-stats/model-stats-cache'),
Expand Down Expand Up @@ -398,4 +403,25 @@ describe('organization model producer publication', () => {
});
expect(mockReadRows).toHaveBeenCalledTimes(2);
});

it('tags ChatGPT-served models for a member and skips the default-access subject', async () => {
mockTagChatGpt.mockImplementation(async (_userId: string, models: OpenRouterModel[]) =>
models.map(model =>
model.id === 'provider/training' ? { ...model, hasUserByokAvailable: true } : model
)
);

const memberResult = await resultFor();
expect(mockTagChatGpt).toHaveBeenCalledWith('fixture-user', expect.any(Array));
expect(
memberResult.data.find(model => model.id === 'provider/training')?.hasUserByokAvailable
).toBe(true);

mockTagChatGpt.mockClear();
const defaultResult = await resultFor({ type: 'defaultAccess' });
expect(mockTagChatGpt).not.toHaveBeenCalled();
expect(
defaultResult.data.find(model => model.id === 'provider/training')?.hasUserByokAvailable
).toBe(false);
});
});
5 changes: 5 additions & 0 deletions apps/web/src/lib/organizations/organization-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { ORG_AUTO_MODEL } from '@/lib/ai-gateway/auto-model';
import { isOrganizationAutoEnabled } from '@/lib/organizations/organization-auto-model';
import { addUserByokAvailability, getOrganizationByokProviderIds } from '@/lib/ai-gateway/byok';
import { appendLocalFakeDeterministicCatalogModels } from '@/lib/ai-gateway/local-fake-llm';
import { tagOpenAiChatGptByokModels } from '@/lib/ai-gateway/openai-chatgpt/routing';
import { readDb } from '@/lib/drizzle';
import { getEnkryptBenchmarks, publishEnkryptModels } from '@/lib/model-stats/enkrypt';
import {
Expand Down Expand Up @@ -59,6 +60,10 @@ export async function getAvailableModelsForOrganization(
availableModels.push(...(await getDirectByokModelsForOrganization(organizationId)));
availableModels.push(...(await listAvailableCustomLlms(organizationId, context.groupIds)));

if (subject.type === 'member') {
availableModels = await tagOpenAiChatGptByokModels(subject.kiloUserId, availableModels);
}

const snapshot = await getEnkryptBenchmarks();
return {
...responseData,
Expand Down
Loading