Skip to content

Commit a457742

Browse files
committed
Easy link with openrouter #951
1 parent 8185a8b commit a457742

File tree

6 files changed

+188
-12
lines changed

6 files changed

+188
-12
lines changed

browser/data-browser/src/components/AI/AISettings.tsx

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
import * as React from 'react';
2-
import { Column } from '../Row';
2+
import { Column, Row } from '../Row';
33
import { Checkbox, CheckboxLabel } from '../forms/Checkbox';
44
import { InputStyled, InputWrapper } from '../forms/InputStyles';
55
import { MCPServersManager } from '../MCPServersManager';
66
import styled from 'styled-components';
77
import { transition } from '../../helpers/transition';
88
import { useSettings } from '../../helpers/AppSettings';
99
import { useEffect, useState } from 'react';
10+
import { OpenRouterLoginButton } from './OpenRouterLoginButton';
1011

1112
interface CreditUsage {
1213
total: number;
1314
used: number;
1415
}
1516

17+
const CREDITS_ENDPOINT = 'https://openrouter.ai/api/v1/credits';
18+
1619
const AISettings: React.FC = () => {
1720
const {
1821
enableAI,
@@ -29,10 +32,12 @@ const AISettings: React.FC = () => {
2932

3033
useEffect(() => {
3134
if (!openRouterApiKey) {
35+
setCreditUsage(undefined);
36+
3237
return;
3338
}
3439

35-
fetch('https://openrouter.ai/api/v1/credits', {
40+
fetch(CREDITS_ENDPOINT, {
3641
headers: {
3742
Authorization: `Bearer ${openRouterApiKey}`,
3843
},
@@ -57,20 +62,40 @@ const AISettings: React.FC = () => {
5762
<label htmlFor='openrouter-api-key'>
5863
<Column gap='0.5rem'>
5964
OpenRouter API Key
60-
<InputWrapper>
61-
<InputStyled
62-
id='openrouter-api-key'
63-
type='password'
64-
value={openRouterApiKey || ''}
65-
onChange={e => setOpenRouterApiKey(e.target.value || undefined)}
66-
placeholder='Enter your OpenRouter API key'
67-
/>
68-
</InputWrapper>
65+
<Row center>
66+
{!openRouterApiKey && (
67+
<>
68+
<OpenRouterLoginButton />
69+
or
70+
</>
71+
)}
72+
<InputWrapper>
73+
<InputStyled
74+
id='openrouter-api-key'
75+
type='password'
76+
value={openRouterApiKey || ''}
77+
onChange={e =>
78+
setOpenRouterApiKey(e.target.value || undefined)
79+
}
80+
placeholder='Enter your OpenRouter API key'
81+
/>
82+
</InputWrapper>
83+
</Row>
6984
{creditUsage && (
7085
<CreditUsage>
7186
Credits used: {creditUsage.used} / Total: {creditUsage.total}
7287
</CreditUsage>
7388
)}
89+
{!openRouterApiKey && (
90+
<CreditUsage>
91+
<p>
92+
OpenRouter provides a unified API that gives you access to
93+
hundreds of AI models from all major vendors, while
94+
automatically handling fallbacks and selecting the most
95+
cost-effective options.
96+
</p>
97+
</CreditUsage>
98+
)}
7499
</Column>
75100
</label>
76101
<CheckboxLabel>
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { useEffect, useState } from 'react';
2+
import { ButtonLink } from '../ButtonLink';
3+
import { paths } from '../../routes/paths';
4+
5+
const TEXT = 'Login with OpenRouter';
6+
const AUTH_ENDPOINT = 'https://openrouter.ai/auth';
7+
8+
async function createSHA256CodeChallenge(input: string): Promise<string> {
9+
const encoder = new TextEncoder();
10+
const data = encoder.encode(input);
11+
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
12+
13+
// Convert ArrayBuffer to base64url string
14+
const byteArray = new Uint8Array(hashBuffer);
15+
const base64String = btoa(String.fromCharCode(...byteArray));
16+
17+
// Convert base64 to base64url
18+
return base64String
19+
.replace(/\+/g, '-')
20+
.replace(/\//g, '_')
21+
.replace(/=+$/, '');
22+
}
23+
24+
const buildUrl = (challenge: string) => {
25+
const url = new URL(AUTH_ENDPOINT);
26+
27+
url.searchParams.set(
28+
'callback_url',
29+
`${location.origin}${paths.linkOpenRouter}`,
30+
);
31+
url.searchParams.set('code_challenge', challenge);
32+
url.searchParams.set('code_challenge_method', 'S256');
33+
34+
return url.toString();
35+
};
36+
37+
export const OpenRouterLoginButton = () => {
38+
const [challenge, setChallenge] = useState<string | null>(null);
39+
40+
useEffect(() => {
41+
const randomString = crypto.randomUUID();
42+
createSHA256CodeChallenge(randomString).then(generatedChallenge => {
43+
setChallenge(generatedChallenge);
44+
sessionStorage.setItem(
45+
'atomic.ai.openrouter-code-verifier',
46+
randomString,
47+
);
48+
});
49+
}, []);
50+
51+
if (!challenge) {
52+
return <ButtonLink href='#'>{TEXT}</ButtonLink>;
53+
}
54+
55+
return <ButtonLink href={buildUrl(challenge)}>{TEXT}</ButtonLink>;
56+
};

browser/data-browser/src/helpers/AppSettings.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export const AppSettingsContextProvider = (
3232
);
3333
const [openRouterApiKey, setOpenRouterApiKey] = useLocalStorage<
3434
string | undefined
35-
>('openRouterApiKey', undefined);
35+
>('atomic.ai.openrouter-api-key', undefined);
3636
const [mcpServers, setMcpServers] = useLocalStorage<MCPServer[]>(
3737
'atomic.ai.mcpServers',
3838
[],
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { createRoute } from '@tanstack/react-router';
2+
import { pathNames, paths } from './paths';
3+
import { appRoute } from './RootRoutes';
4+
import { useEffect, useState } from 'react';
5+
import { useNavigateWithTransition } from '../hooks/useNavigateWithTransition';
6+
import styled from 'styled-components';
7+
import { useSettings } from '../helpers/AppSettings';
8+
9+
export type LinkOpenRouterSearch = {
10+
code: string;
11+
};
12+
13+
const ENDPOINT = 'https://openrouter.ai/api/v1/auth/keys';
14+
15+
export const LinkOpenRouter = createRoute({
16+
path: pathNames.linkOpenRouter,
17+
component: () => <LinkOpenRouterPage />,
18+
getParentRoute: () => appRoute,
19+
validateSearch: (search): LinkOpenRouterSearch => ({
20+
code: (search.code as string) ?? '',
21+
}),
22+
});
23+
24+
function LinkOpenRouterPage() {
25+
const [error, setError] = useState<string>();
26+
const { setOpenRouterApiKey } = useSettings();
27+
const { code } = LinkOpenRouter.useSearch();
28+
const navigate = useNavigateWithTransition();
29+
30+
useEffect(() => {
31+
const codeVerifier = sessionStorage.getItem(
32+
'atomic.ai.openrouter-code-verifier',
33+
);
34+
35+
if (!codeVerifier) {
36+
setError('No code verifier found');
37+
38+
return;
39+
}
40+
41+
fetch(ENDPOINT, {
42+
method: 'POST',
43+
headers: {
44+
'Content-Type': 'application/json',
45+
},
46+
body: JSON.stringify({
47+
code: code,
48+
code_verifier: codeVerifier,
49+
code_challenge_method: 'S256',
50+
}),
51+
})
52+
.then(res => res.json())
53+
.then(({ key }) => {
54+
setOpenRouterApiKey(key);
55+
sessionStorage.removeItem('atomic.ai.openrouter-code-verifier');
56+
sessionStorage.removeItem('atomic.ai.openrouter-code-challenge');
57+
58+
navigate({ to: paths.appSettings });
59+
})
60+
.catch(err => {
61+
setError(err.message);
62+
});
63+
}, [code]);
64+
65+
if (error) {
66+
return (
67+
<Center>
68+
<div>
69+
<h1>Error</h1>
70+
<p>{error}</p>
71+
</div>
72+
</Center>
73+
);
74+
}
75+
76+
return (
77+
<Center>
78+
<div>
79+
<h1>Linking OpenRouter</h1>
80+
<p>Please wait while we link your OpenRouter account...</p>
81+
</div>
82+
</Center>
83+
);
84+
}
85+
86+
const Center = styled.div`
87+
display: grid;
88+
height: 100%;
89+
width: 100%;
90+
place-items: center;
91+
`;

browser/data-browser/src/routes/Router.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { rootRoute, topRoute, appRoute } from './RootRoutes';
1717
import { unavailableLazyRoute } from './UnavailableLazyRoute';
1818
import { ImportRoute } from './ImportRoute';
1919
import { HistoryRoute } from './History/HistoryRoute';
20+
import { LinkOpenRouter } from './LinkOpenRouter';
2021

2122
const PruneTestsRoute = createRoute({
2223
getParentRoute: () => appRoute,
@@ -58,6 +59,7 @@ const routeTree = rootRoute.addChildren({
5859
NewRoute,
5960
PruneTestsRoute,
6061
SandboxRoute,
62+
LinkOpenRouter,
6163
}),
6264
topRoute,
6365
});

browser/data-browser/src/routes/paths.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const pathNames = {
2020
sandbox: '/sandbox',
2121
fetchBookmark: '/fetch-bookmark',
2222
pruneTests: '/prunetests',
23+
linkOpenRouter: '/link-openrouter',
2324
} as const;
2425
export const paths = {
2526
agentSettings: `${pathNames.app}${pathNames.agentSettings}`,
@@ -40,4 +41,5 @@ export const paths = {
4041
sandbox: `${pathNames.app}${pathNames.sandbox}`,
4142
fetchBookmark: pathNames.fetchBookmark,
4243
pruneTests: `${pathNames.app}${pathNames.pruneTests}`,
44+
linkOpenRouter: `${pathNames.app}${pathNames.linkOpenRouter}`,
4345
} as const;

0 commit comments

Comments
 (0)