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
6 changes: 6 additions & 0 deletions ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ Manage shared web env var additions and rotations with `pnpm web:env set <VARIAB

- `ANACONDA_CLIENT_ID` - Anaconda OAuth app client ID. `[PUBLIC]`
- `ANACONDA_CLIENT_SECRET` - Anaconda OAuth app client secret. `[SECRET]`
- `OPENAI_CLIENT_ID` - OpenAI (Sign in with ChatGPT) OAuth client ID. Read only from the environment; the literal must never appear in source. `[SECRET]`
- `OPENAI_CLIENT_SECRET` - OpenAI (Sign in with ChatGPT) OAuth client secret; server-side only, sent only in the token endpoint's HTTP Basic authorization header. `[SECRET]`
- `OPENAI_DISCOVERY_URL` - Optional override for the OpenAI OpenID Connect discovery document; defaults to the production issuer's document. [SERVER]
- `OPENAI_TOKEN_ENDPOINT` - Optional override for the OpenAI token endpoint; defaults to the production issuer's endpoint. [SERVER]
- `OPENAI_CHATGPT_API_URL` - Optional override for the base URL that token-sharing requests use with a "Sign in with ChatGPT" connection; defaults to `https://api.openai.com/v1`. Set it wherever `OPENAI_DISCOVERY_URL`/`OPENAI_TOKEN_ENDPOINT` are overridden, so delegated inference stays in the same environment as the token issuer. [SERVER]
- `GITHUB_CLIENT_ID` - GitHub OAuth app client ID. `[PUBLIC]`
- `GITHUB_CLIENT_SECRET` - GitHub OAuth app client secret. `[SECRET]`
- `GITHUB_APP_ID` - GitHub App ID; used in integration adapter and tests. `[SECRET]`
Expand Down Expand Up @@ -269,6 +274,7 @@ Manage shared web env var additions and rotations with `pnpm web:env set <VARIAB

- `OPENROUTER_API_KEY` - Primary OpenRouter API key for model inference through the AI gateway; provider definition in `apps/web/src/lib/ai-gateway/providers/definitions/openrouter.ts` pointing to `https://openrouter.ai/api/v1`. `[SECRET]`
- `OPENAI_API_KEY` - OpenAI API key supplied as a managed BYOK credential when managed inference requests route through the Vercel AI Gateway and permit the OpenAI provider. `[SECRET]`
- `OPENAI_CHATGPT_API_KEY` - Partner project key for the delegated "Sign in with ChatGPT" route (`apps/web/src/lib/ai-gateway/openai-chatgpt/routing.ts`); sent as `Authorization: Bearer` alongside the user's `OpenAI-On-Behalf-Of-Token`. OpenAI requires this key to come from the project that owns the OAuth client (`oaiapp_Abz1xcqSQAvvIwtxyemZbXBJ`); a key from another project makes every delegated call fail with an opaque `400 Bad Request`. `[SECRET]`
- `MISTRAL_API_KEY` - Mistral API key; used in `apps/web/src/lib/ai-gateway/embeddings/embedding-providers.ts` for `codestral-embed-2505` and `mistral-embed` embeddings, in the FIM completions proxy at `apps/web/src/app/api/fim/completions/route.ts` (routes Mistral Codestral vs. La Plateforme keys), and as a provider config in `apps/web/src/lib/config.server.ts`. `[SECRET]`
- `LONGCAT_API_KEY` - LongCat API key for model inference through the AI gateway; provider definition in `apps/web/src/lib/ai-gateway/providers/definitions/longcat.ts`. `[SECRET]`
- `STREAMLAKE_API_KEY` - StreamLake API key for model inference through the AI gateway; provider definition in `apps/web/src/lib/ai-gateway/providers/definitions/streamlake.ts`. `[SECRET]`
Expand Down
59 changes: 59 additions & 0 deletions apps/web/src/app/(app)/byok/page.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires -- Jest mocks must be registered before loading the page. */
// The personal BYOK page renders the ChatGPT connection card only when the
// PostHog flag allows it. The card's own behaviour is covered in
// `OpenAiChatGptCard.test.ts`; this locks the flag gate.
import { jest } from '@jest/globals';
import React, { createElement } from 'react';
import type { ReactElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';

(globalThis as typeof globalThis & { React: typeof React }).React = React;

let mockFlagEnabled: boolean | undefined = false;

jest.mock('posthog-js/react', () => ({
useFeatureFlagEnabled: () => mockFlagEnabled,
}));

jest.mock('@/components/PageLayout', () => ({
PageLayout: ({ children }: { children: ReactElement }) => children,
}));

jest.mock('@/components/organizations/byok/BYOKKeysManager', () => ({
BYOKKeysManager: () => createElement('div', null, 'BYOKKeysManager'),
}));

jest.mock('@/components/organizations/byok/OpenAiChatGptCard', () => ({
OpenAiChatGptCard: () => createElement('div', null, 'card'),
OpenAiChatGptCardView: () => createElement('div', null, 'card-skeleton'),
}));

const { default: PersonalBYOKPage } = require('./page') as {
default: () => ReactElement;
};

describe('PersonalBYOKPage ChatGPT flag gate', () => {
it('hides the ChatGPT card when the flag is off', () => {
mockFlagEnabled = false;
const html = renderToStaticMarkup(createElement(PersonalBYOKPage));

expect(html).not.toContain('card');
expect(html).toContain('BYOKKeysManager');
});

it('holds the card height while the flag is still loading', () => {
mockFlagEnabled = undefined;
const html = renderToStaticMarkup(createElement(PersonalBYOKPage));

expect(html).toContain('card-skeleton');
expect(html).not.toMatch(/>card</);
});

it('shows the ChatGPT card when the flag is on', () => {
mockFlagEnabled = true;
const html = renderToStaticMarkup(createElement(PersonalBYOKPage));

expect(html).toMatch(/>card</);
expect(html).toContain('BYOKKeysManager');
});
});
21 changes: 20 additions & 1 deletion apps/web/src/app/(app)/byok/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,31 @@
'use client';

import { useFeatureFlagEnabled } from 'posthog-js/react';
import { PageLayout } from '@/components/PageLayout';
import { BYOKKeysManager } from '@/components/organizations/byok/BYOKKeysManager';
import {
OpenAiChatGptCard,
OpenAiChatGptCardView,
} from '@/components/organizations/byok/OpenAiChatGptCard';
import { CHATGPT_ACCESS_FLAG } from '@/lib/auth/openai/access';

export default function PersonalBYOKPage() {
// The flag's release condition matches the `email` person property against the
// approved domains, so a person outside the list never sees the card.
const chatGptEnabled = useFeatureFlagEnabled(CHATGPT_ACCESS_FLAG);

return (
<PageLayout title="Bring Your Own Key">
<BYOKKeysManager />
<div className="space-y-4">
{chatGptEnabled === true ? (
<OpenAiChatGptCard />
) : chatGptEnabled === undefined ? (
// Hold the card's reserved height until the flags resolve, so the key
// list does not shift when the card appears.
<OpenAiChatGptCardView status={undefined} />
) : null}
<BYOKKeysManager />
</div>
</PageLayout>
);
}
48 changes: 48 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 @@ -1020,6 +1020,54 @@ describe('POST /api/openrouter/v1/chat/completions rules-engine actions', () =>
expect(mockedUpstreamRequest).not.toHaveBeenCalled();
});

it('returns the reconnect error instead of serving another billing path when the ChatGPT connection is dead', async () => {
mockedGetProvider.mockResolvedValue({
kind: 'chatgpt-reconnect',
message: 'Your ChatGPT connection has expired. Reconnect to continue.',
});

const { POST } = await import('./route');
const response = await POST(makeRequest(makeBody()) as never);
const body = (await response.json()) as { error: string; error_type: string };

expect(response.status).toBe(400);
expect(body.error_type).toBe('byok_error');
expect(body.error).toContain('Your ChatGPT connection has expired. Reconnect to continue.');
expect(body.error).toContain('/byok');
expect(mockedUpstreamRequest).not.toHaveBeenCalled();
expect(mockedClassifyAbuse).not.toHaveBeenCalled();
});

it('applies delay before returning the reconnect error when quarantine-3 re-resolution is dead', async () => {
jest.useFakeTimers();
mockedRedisGet.mockResolvedValue(cachedRulesEngineAction('quarantine-3'));
mockedClassifyAbuse.mockResolvedValue(classifyResult('quarantine-3'));
mockedGetProvider
.mockResolvedValueOnce({
kind: 'provider',
provider,
userByok: null,
bypassAccessCheck: false,
})
.mockResolvedValueOnce({
kind: 'chatgpt-reconnect',
message: 'Your ChatGPT connection has expired. Reconnect to continue.',
});

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

await jest.advanceTimersByTimeAsync(5999);
expect(mockedUpstreamRequest).not.toHaveBeenCalled();

await jest.advanceTimersByTimeAsync(1);
const response = await responsePromise;

expect(response.status).toBe(400);
expect(mockedGetProvider).toHaveBeenCalledTimes(2);
expect(mockedUpstreamRequest).not.toHaveBeenCalled();
});

it('applies delay before returning error when quarantine-3 override API kind is unsupported', async () => {
jest.useFakeTimers();
mockedRedisGet.mockResolvedValue(cachedRulesEngineAction('quarantine-3'));
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/app/api/openrouter/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
apiKindNotSupportedResponse,
checkExclusiveModelProviderAllowed,
modelDoesNotExistOnOpenRouterResponse,
chatGptReconnectResponse,
} from '@/lib/ai-gateway/llm-proxy-helpers';
import { ProxyErrorType } from '@/lib/proxy-error-types';
import { getBalanceAndOrgSettings } from '@/lib/organizations/organization-usage';
Expand Down Expand Up @@ -686,6 +687,11 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
if (initialProviderResultForAbuseService.kind === 'unavailable') {
return temporarilyUnavailableResponse();
}
if (initialProviderResultForAbuseService.kind === 'chatgpt-reconnect') {
// The person's enabled ChatGPT connection is terminally dead. Fail readably
// instead of silently serving the request through another billing path.
return chatGptReconnectResponse(initialProviderResultForAbuseService.message);
}
let effectiveProviderContext = initialProviderResultForAbuseService;

if (autoModel === ORG_AUTO_MODEL.id && routingTarget) {
Expand Down Expand Up @@ -815,6 +821,12 @@ export async function POST(request: NextRequest): Promise<NextResponseType<unkno
}
return temporarilyUnavailableResponse();
}
if (quarantineProviderResult.kind === 'chatgpt-reconnect') {
if (rulesEngineDecision.delayMs > 0) {
await sleepForRulesEngineAction(rulesEngineDecision.delayMs);
}
return chatGptReconnectResponse(quarantineProviderResult.message);
}

effectiveProviderContext = quarantineProviderResult;

Expand Down
196 changes: 196 additions & 0 deletions apps/web/src/app/auth/openai/callback/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
const mockNextAuthHttpHandler = jest.fn<Promise<Response>, [NextRequest, unknown]>();
const mockGetUserFromSession = jest.fn<Promise<{ id: string } | null>, []>();

jest.mock('@/lib/user/server', () => ({
nextAuthHttpHandler: (...args: [NextRequest, unknown]) => mockNextAuthHttpHandler(...args),
getUserFromSession: () => mockGetUserFromSession(),
}));

import { NextRequest } from 'next/server';
import { GET } from './route';

const CALLBACK_URL = 'https://app.kilo.ai/auth/openai/callback';

beforeEach(() => {
jest.clearAllMocks();
mockNextAuthHttpHandler.mockResolvedValue(new Response(null, { status: 200 }));
});

function forwardedRequest(): NextRequest | undefined {
return mockNextAuthHttpHandler.mock.calls[0]?.[0];
}

function forwardedContext(): { params: Promise<{ nextauth: string[] }> } | undefined {
return mockNextAuthHttpHandler.mock.calls[0]?.[1] as
| { params: Promise<{ nextauth: string[] }> }
| undefined;
}

function redirectResponse(location: string): Response {
return new Response(null, { status: 302, headers: { location } });
}

describe('GET /auth/openai/callback', () => {
test('rewrites the registered callback path to the next-auth openai callback', async () => {
mockGetUserFromSession.mockResolvedValue(null);
const request = new NextRequest(`${CALLBACK_URL}?code=the-code&state=the-state`);

await GET(request, {});

expect(mockNextAuthHttpHandler).toHaveBeenCalledTimes(1);
expect(forwardedRequest()?.nextUrl.pathname).toBe('/api/auth/callback/openai');
expect(forwardedRequest()?.nextUrl.searchParams.get('code')).toBe('the-code');
expect(forwardedRequest()?.nextUrl.searchParams.get('state')).toBe('the-state');
// NextAuth's App Router adapter picks the action and provider from
// `context.params.nextauth`; without them the delegated call takes its
// legacy API-handler path, which cannot read a NextRequest at all.
await expect(forwardedContext()?.params).resolves.toEqual({ nextauth: ['callback', 'openai'] });
});

test('redirects a callback error to BYOK when a session exists', async () => {
mockGetUserFromSession.mockResolvedValue({ id: 'user-1' });
const request = new NextRequest(`${CALLBACK_URL}?error=access_denied&state=the-state`);

const response = await GET(request, {});

expect(mockNextAuthHttpHandler).not.toHaveBeenCalled();
const location = new URL(response.headers.get('location') ?? '');
expect(location.pathname).toBe('/byok');
expect(location.searchParams.get('openai_error')).toBe('access_denied');
});

test('falls through to next-auth on a callback error without a session', async () => {
mockGetUserFromSession.mockResolvedValue(null);
const request = new NextRequest(`${CALLBACK_URL}?error=access_denied&state=the-state`);

await GET(request, {});

expect(mockNextAuthHttpHandler).toHaveBeenCalledTimes(1);
expect(forwardedRequest()?.nextUrl.pathname).toBe('/api/auth/callback/openai');
expect(forwardedRequest()?.nextUrl.searchParams.get('error')).toBe('access_denied');
});

test('redirects an internal callback failure to BYOK when a session exists', async () => {
mockGetUserFromSession.mockResolvedValue({ id: 'user-1' });
mockNextAuthHttpHandler.mockResolvedValue(
redirectResponse('https://app.kilo.ai/api/auth/error?error=OAuthCallback')
);
const request = new NextRequest(`${CALLBACK_URL}?code=the-code&state=the-state`);

const response = await GET(request, {});

expect(mockNextAuthHttpHandler).toHaveBeenCalledTimes(1);
const location = new URL(response.headers.get('location') ?? '');
expect(location.pathname).toBe('/byok');
expect(location.searchParams.get('openai_error')).toBe('OAuthCallback');
});

test('redirects the bare sign-in hop next-auth emits when the callback has no usable profile', async () => {
mockGetUserFromSession.mockResolvedValue({ id: 'user-1' });
// `routes/callback.js` returns `${url}/signin` with no error parameter when
// the exchange yields no profile/account: the only sign-in hop the callback
// action itself emits. The error hop above is followed by the browser, not
// by this route, so this shape is what reaches us from the token exchange.
mockNextAuthHttpHandler.mockResolvedValue(
redirectResponse('http://localhost:3000/api/auth/signin')
);
const request = new NextRequest(`${CALLBACK_URL}?code=the-code&state=the-state`);

const response = await GET(request, {});

const location = new URL(response.headers.get('location') ?? '');
expect(location.pathname).toBe('/byok');
expect(location.searchParams.get('openai_error')).toBe('OAuthCallback');
});

test('keeps the error code a sign-in hop carries', async () => {
mockGetUserFromSession.mockResolvedValue({ id: 'user-1' });
mockNextAuthHttpHandler.mockResolvedValue(
redirectResponse('https://app.kilo.ai/api/auth/signin?error=OAuthAccountNotLinked')
);
const request = new NextRequest(`${CALLBACK_URL}?code=the-code&state=the-state`);

const response = await GET(request, {});

const location = new URL(response.headers.get('location') ?? '');
expect(location.pathname).toBe('/byok');
expect(location.searchParams.get('openai_error')).toBe('OAuthAccountNotLinked');
});

test('leaves an error hop without a failure code unchanged', async () => {
mockGetUserFromSession.mockResolvedValue({ id: 'user-1' });
const nextAuthResponse = redirectResponse('https://app.kilo.ai/api/auth/error');
mockNextAuthHttpHandler.mockResolvedValue(nextAuthResponse);
const request = new NextRequest(`${CALLBACK_URL}?code=the-code&state=the-state`);

const response = await GET(request, {});

expect(response).toBe(nextAuthResponse);
expect(mockGetUserFromSession).not.toHaveBeenCalled();
});

test('leaves the internal callback failure on the sign-in path without a session', async () => {
mockGetUserFromSession.mockResolvedValue(null);
const nextAuthResponse = redirectResponse(
'https://app.kilo.ai/api/auth/error?error=OAuthCallback'
);
mockNextAuthHttpHandler.mockResolvedValue(nextAuthResponse);
const request = new NextRequest(`${CALLBACK_URL}?code=the-code&state=the-state`);

const response = await GET(request, {});

expect(response).toBe(nextAuthResponse);
expect(response.headers.get('location')).toBe(
'https://app.kilo.ai/api/auth/error?error=OAuthCallback'
);
});

test('leaves the bare sign-in hop on the sign-in path without a session', async () => {
mockGetUserFromSession.mockResolvedValue(null);
const nextAuthResponse = redirectResponse('http://localhost:3000/api/auth/signin');
mockNextAuthHttpHandler.mockResolvedValue(nextAuthResponse);
const request = new NextRequest(`${CALLBACK_URL}?code=the-code&state=the-state`);

const response = await GET(request, {});

expect(response).toBe(nextAuthResponse);
expect(response.headers.get('location')).toBe('http://localhost:3000/api/auth/signin');
});

test('returns a successful callback redirect unchanged', async () => {
mockGetUserFromSession.mockResolvedValue({ id: 'user-1' });
const nextAuthResponse = redirectResponse('https://app.kilo.ai/byok');
mockNextAuthHttpHandler.mockResolvedValue(nextAuthResponse);
const request = new NextRequest(`${CALLBACK_URL}?code=the-code&state=the-state`);

const response = await GET(request, {});

expect(response).toBe(nextAuthResponse);
expect(mockGetUserFromSession).not.toHaveBeenCalled();
});

test('returns a non-redirect callback response unchanged', async () => {
mockGetUserFromSession.mockResolvedValue({ id: 'user-1' });
const nextAuthResponse = new Response(null, { status: 200 });
mockNextAuthHttpHandler.mockResolvedValue(nextAuthResponse);
const request = new NextRequest(`${CALLBACK_URL}?code=the-code&state=the-state`);

const response = await GET(request, {});

expect(response).toBe(nextAuthResponse);
});

test('does not rewrite an auth error response for a request without a code', async () => {
mockGetUserFromSession.mockResolvedValue({ id: 'user-1' });
const nextAuthResponse = redirectResponse(
'https://app.kilo.ai/api/auth/error?error=OAuthCallback'
);
mockNextAuthHttpHandler.mockResolvedValue(nextAuthResponse);
const request = new NextRequest(`${CALLBACK_URL}?state=the-state`);

const response = await GET(request, {});

expect(response).toBe(nextAuthResponse);
expect(mockGetUserFromSession).not.toHaveBeenCalled();
});
});
Loading
Loading