Skip to content
Closed
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
3 changes: 3 additions & 0 deletions examples/nextjs-example/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts

# Override for local developtment of OAuth functionality
!src/lib/oauth.ts
651 changes: 302 additions & 349 deletions examples/nextjs-example/package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion examples/nextjs-example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/node": "^24",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,37 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { OAUTH_CALLBACK_URL, OPENROUTER_CODE_VERIFIER_KEY } from "@/lib/config";
import { useOpenRouter } from "@/lib/hooks/use-openrouter-client";
import {
OAUTH_CALLBACK_URL,
OPENROUTER_CODE_VERIFIER_KEY,
OPENROUTER_STATE_LOCALSTORAGE_KEY,
} from "@/lib/config";
import {
createAuthorizationUrl,
createSHA256CodeChallenge,
generateOAuthState,
} from "@/lib/oauth";
import { ExternalLink } from "lucide-react";

interface NotConnectedDialogProps {
open: boolean;
}

export function NotConnectedDialog({ open }: NotConnectedDialogProps) {
const { client } = useOpenRouter();

const handleGotoOAuth = async () => {
const { codeChallenge, codeVerifier } =
await client.oAuth.createSHA256CodeChallenge();
await createSHA256CodeChallenge();
const state = generateOAuthState();

const url = await client.oAuth.createAuthorizationUrl({
const url = await createAuthorizationUrl({
codeChallenge,
callbackUrl: OAUTH_CALLBACK_URL,
codeChallengeMethod: "S256",
state,
});

localStorage.setItem(OPENROUTER_CODE_VERIFIER_KEY, codeVerifier);
localStorage.setItem(OPENROUTER_STATE_LOCALSTORAGE_KEY, state);
window.location.href = url;
};

Expand Down
65 changes: 63 additions & 2 deletions examples/nextjs-example/src/app/(app)/chat/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ import {
} from "@/components/ui/select";
import { useApiKey } from "@/lib/hooks/use-api-key";
import { useOpenRouter } from "@/lib/hooks/use-openrouter-client";
import { Message as OpenRouterMessageRequest } from "@openrouter/sdk/models";
import {
ChatMessageContentItem,
ChatResponse,
Message as OpenRouterMessageRequest,
} from "@openrouter/sdk/models";
import type { SendChatCompletionRequestResponse } from "@openrouter/sdk/models/operations";
import { Bot, MessageSquare, Send, Settings, User } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { NotConnectedDialog } from "./_dialogs/not-connected";
Expand All @@ -24,6 +29,48 @@ type Message = OpenRouterMessageRequest & {
created?: number;
};

type ChatSendResult = SendChatCompletionRequestResponse;

function isChatResponse(result: ChatSendResult): result is ChatResponse {
return (
!!result
&& typeof result === "object"
&& Array.isArray((result as ChatResponse).choices)
);
}

function formatContentItem(item: ChatMessageContentItem | string): string {
if (typeof item === "string") return item;

switch (item.type) {
case "text":
return item.text;
case "image_url":
return "[image]";
case "input_audio":
return "[audio]";
default:
return "";
}
}

function formatAssistantContent(
content: ChatResponse["choices"][number]["message"]["content"],
): string {
if (!content) return "";
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content.map(formatContentItem).filter(Boolean).join("\n");
}
return "";
}

function extractAssistantMessage(response: ChatResponse): string {
const [firstChoice] = response.choices;
if (!firstChoice) return "";
return formatAssistantContent(firstChoice.message.content ?? "");
}

export default function Page() {
const { client: openRouter } = useOpenRouter();
const [apiKey] = useApiKey();
Expand Down Expand Up @@ -96,10 +143,24 @@ export default function Page() {
stream: true,
});

if (isChatResponse(result)) {
const finalContent = extractAssistantMessage(result);
setMessages((prev) => {
const newMessages = [...prev];
const lastMessage = newMessages[newMessages.length - 1];
if (lastMessage && lastMessage.role === "assistant") {
lastMessage.content = finalContent;
}
return newMessages;
});
setIsLoading(false);
return;
}

// Stream chunks into the latest message
const chunks: string[] = [];
for await (const chunk of result) {
chunks.push(chunk.data.choices[0].delta.content || "");
chunks.push(chunk.data.choices[0]?.delta.content || "");
setMessages((prev) => {
const newMessages = [...prev];
const lastMessage = newMessages[newMessages.length - 1];
Expand Down
85 changes: 66 additions & 19 deletions examples/nextjs-example/src/app/(app)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,16 @@ import {
OAUTH_CALLBACK_URL,
OPENROUTER_CODE_VERIFIER_KEY,
OPENROUTER_KEY_LOCALSTORAGE_KEY,
OPENROUTER_STATE_LOCALSTORAGE_KEY,
} from "@/lib/config";
import {
createAuthorizationUrl,
createSHA256CodeChallenge,
exchangeAuthorizationCode,
generateOAuthState,
} from "@/lib/oauth";

import { useApiKey } from "@/lib/hooks/use-api-key";
import { useOpenRouter } from "@/lib/hooks/use-openrouter-client";
import {
ArrowRightIcon,
ExternalLink,
Expand All @@ -24,28 +31,66 @@ import {
Zap,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useState } from "react";

export default function Page() {
return (
<Suspense fallback={<InitializingPageContent />}>
<PageContent />
</Suspense>
);
}

export default function Page({ searchParams }: PageProps<"/">) {
function PageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const codeParam = searchParams?.get("code");
const stateParam = searchParams?.get("state");
const errorParam = searchParams?.get("error");
const [connectionState, setConnectionState] = useState<
"disconnected" | "connecting" | "connected" | "initializing" | "error"
>("initializing");
const [code, setCode] = useState<string>();

useEffect(() => {
if (localStorage.getItem(OPENROUTER_KEY_LOCALSTORAGE_KEY))
return setConnectionState("connected");
const initialize = () => {
if (localStorage.getItem(OPENROUTER_KEY_LOCALSTORAGE_KEY)) {
setConnectionState("connected");
return;
}

searchParams.then((p) => {
if (p.code) {
if (codeParam) {
const storedState = localStorage.getItem(
OPENROUTER_STATE_LOCALSTORAGE_KEY,
);

if (!stateParam || !storedState || storedState !== stateParam) {
console.error("OAuth state mismatch detected.");
localStorage.removeItem(OPENROUTER_STATE_LOCALSTORAGE_KEY);
localStorage.removeItem(OPENROUTER_CODE_VERIFIER_KEY);
setConnectionState("error");
router.replace("/?error=state_mismatch");
return;
}

localStorage.removeItem(OPENROUTER_STATE_LOCALSTORAGE_KEY);
setConnectionState("connecting");
setCode(p.code.toString());
} else if (p.error) {
setCode(codeParam);
router.replace("/");
return;
}

if (errorParam) {
setConnectionState("error");
} else setConnectionState("disconnected");
});
}, [searchParams]);
return;
}

setConnectionState("disconnected");
};

initialize();
}, [codeParam, errorParam, router, stateParam]);

switch (connectionState) {
case "initializing":
Expand Down Expand Up @@ -77,7 +122,6 @@ function InitializingPageContent() {

function ConnectingPageContent(props: { code: string }) {
const router = useRouter();
const { client: openRouter } = useOpenRouter();
const [, setApiKey] = useApiKey();

useEffect(() => {
Expand All @@ -92,7 +136,7 @@ function ConnectingPageContent(props: { code: string }) {

try {
// Exchange the authorization code for an API key using PKCE
const result = await openRouter.oAuth.exchangeAuthorizationCode({
const result = await exchangeAuthorizationCode({
code: props.code,
codeVerifier,
codeChallengeMethod: "S256",
Expand All @@ -110,12 +154,13 @@ function ConnectingPageContent(props: { code: string }) {
router.push("/chat");
} catch (error) {
console.error("Failed to exchange authorization code:", error);
localStorage.removeItem(OPENROUTER_CODE_VERIFIER_KEY);
router.push("/?error=exchange_failed");
}
};

exchangeCode();
}, [props.code, router]);
}, [props.code, router, setApiKey]);

return (
<div className="container mx-auto px-4 py-8 max-w-2xl">
Expand Down Expand Up @@ -174,24 +219,26 @@ function ConnectedPageContent() {

function DisconnectedPageContent() {
const [authUrl, setAuthUrl] = useState<string | null>(null);
const { client: openRouter } = useOpenRouter();

useEffect(() => {
const generateAuthUrl = async () => {
// Generate PKCE code challenge
const challenge = await openRouter.oAuth.createSHA256CodeChallenge();
const challenge = await createSHA256CodeChallenge();
const state = generateOAuthState();

// Store the code verifier for later use in the callback
localStorage.setItem(
OPENROUTER_CODE_VERIFIER_KEY,
challenge.codeVerifier,
);
localStorage.setItem(OPENROUTER_STATE_LOCALSTORAGE_KEY, state);

// Generate authorization URL with PKCE
const url = await openRouter.oAuth.createAuthorizationUrl({
const url = await createAuthorizationUrl({
callbackUrl: OAUTH_CALLBACK_URL,
codeChallenge: challenge.codeChallenge,
codeChallengeMethod: "S256",
state,
});

setAuthUrl(url);
Expand Down
14 changes: 10 additions & 4 deletions examples/nextjs-example/src/app/oauth/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,18 @@ import { redirect } from "next/navigation";
export async function GET(request: Request) {
const url = new URL(request.url);
const code = url.searchParams.get("code");
const error = url.searchParams.get("error");
const state = url.searchParams.get("state");

if (!code) {
if (!code && !error) {
return redirect("/?error=missing_code");
}

// Redirect to the main page with the code
// The client-side code will handle the exchange using the stored code verifier
redirect(`/?code=${code}`);
const params = new URLSearchParams();
if (error) params.set("error", error);
if (code) params.set("code", code);
if (state) params.set("state", state);

const dest = params.toString() ? `/?${params.toString()}` : "/";
redirect(dest);
}
1 change: 1 addition & 0 deletions examples/nextjs-example/src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export const OAUTH_CALLBACK_URL = "http://localhost:3000/oauth/callback";
export const OPENROUTER_KEY_LOCALSTORAGE_KEY = "openrouter_key";
export const OPENROUTER_USER_ID_LOCALSTORAGE_KEY = "openrouter_user_id";
export const OPENROUTER_CODE_VERIFIER_KEY = "openrouter_code_verifier";
export const OPENROUTER_STATE_LOCALSTORAGE_KEY = "openrouter_oauth_state";
Loading