feat: OpenAI compatibility improvements & build fixes

- Fix hydration mismatches and initialization errors
- Add /v1/models endpoint for OpenAI clients
- Add Codex response translator (Responses → OpenAI)
- Fix circular dependencies and PropTypes
- Add Material Symbols font and CSS fixes
- Update README with deployment guide

Co-merged from PR #18 (14/15 commits, skipped debug)
This commit is contained in:
decolua 2026-01-20 13:16:34 +07:00
parent 0848dd5d13
commit d9b8e48725
15 changed files with 762 additions and 171 deletions

View file

@ -1,6 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import PropTypes from "prop-types";
import { Card, Button, Input, Modal, CardSkeleton } from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
@ -198,10 +199,16 @@ export default function APIPageClient({ machineId }) {
}
};
const baseUrl = typeof window !== "undefined" ? `${window.location.origin}/v1` : "/v1";
// New format: /v1 (machineId in key), Old format: /{machineId}/v1
const [baseUrl, setBaseUrl] = useState("/v1");
const cloudEndpointNew = `${CLOUD_URL}/v1`;
// Hydration fix: Only access window on client side
useEffect(() => {
if (typeof window !== "undefined") {
setBaseUrl(`${window.location.origin}/v1`);
}
}, []);
if (loading) {
return (
<div className="flex flex-col gap-8">
@ -601,5 +608,5 @@ export default function APIPageClient({ machineId }) {
}
APIPageClient.propTypes = {
machineId: import("prop-types").string.isRequired,
machineId: PropTypes.string.isRequired,
};

View file

@ -159,7 +159,7 @@ export default function ProfilePage() {
)}
<div className="pt-2">
<Button type="submit" variant="primary" isLoading={passLoading}>
<Button type="submit" variant="primary" loading={passLoading}>
Update Password
</Button>
</div>

View file

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { useState, useEffect, useMemo, useCallback } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
@ -27,11 +27,7 @@ export default function ProviderDetailPage() {
const models = getModelsByProviderId(providerId);
const providerAlias = getProviderAlias(providerId);
useEffect(() => {
fetchConnections();
fetchAliases();
}, [fetchConnections, fetchAliases]);
// Define callbacks BEFORE the useEffect that uses them
const fetchAliases = useCallback(async () => {
try {
const res = await fetch("/api/models/alias");
@ -44,6 +40,26 @@ export default function ProviderDetailPage() {
}
}, []);
const fetchConnections = useCallback(async () => {
try {
const res = await fetch("/api/providers");
const data = await res.json();
if (res.ok) {
const filtered = (data.connections || []).filter(c => c.provider === providerId);
setConnections(filtered);
}
} catch (error) {
console.log("Error fetching connections:", error);
} finally {
setLoading(false);
}
}, [providerId]);
useEffect(() => {
fetchConnections();
fetchAliases();
}, [fetchConnections, fetchAliases]);
const handleSetAlias = async (modelId, alias) => {
const fullModel = `${providerAlias}/${modelId}`;
try {
@ -76,21 +92,6 @@ export default function ProviderDetailPage() {
}
};
const fetchConnections = useCallback(async () => {
try {
const res = await fetch("/api/providers");
const data = await res.json();
if (res.ok) {
const filtered = (data.connections || []).filter(c => c.provider === providerId);
setConnections(filtered);
}
} catch (error) {
console.log("Error fetching connections:", error);
} finally {
setLoading(false);
}
}, [providerId]);
const handleDelete = async (id) => {
if (!confirm("Delete this connection?")) return;
try {

View file

@ -0,0 +1,101 @@
import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models";
import { getProviderConnections, getCombos } from "@/lib/localDb";
/**
* Handle CORS preflight
*/
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
});
}
/**
* GET /v1/models - OpenAI compatible models list
* Returns models from all active providers and combos in OpenAI format
*/
export async function GET() {
try {
// Get active provider connections
let connections = [];
try {
connections = await getProviderConnections();
// Filter to only active connections
connections = connections.filter(c => c.isActive !== false);
} catch (e) {
// If database not available, return all models
console.log("Could not fetch providers, returning all models");
}
// Get combos
let combos = [];
try {
combos = await getCombos();
} catch (e) {
console.log("Could not fetch combos");
}
// Build set of active provider aliases
const activeAliases = new Set();
for (const conn of connections) {
const alias = PROVIDER_ID_TO_ALIAS[conn.provider] || conn.provider;
activeAliases.add(alias);
}
// Collect models from active providers (or all if none active)
const models = [];
const timestamp = Math.floor(Date.now() / 1000);
// Add combos first (they appear at the top)
for (const combo of combos) {
models.push({
id: combo.name,
object: "model",
created: timestamp,
owned_by: "combo",
permission: [],
root: combo.name,
parent: null,
});
}
// Add provider models
for (const [alias, providerModels] of Object.entries(PROVIDER_MODELS)) {
// If we have active providers, only include those; otherwise include all
if (connections.length > 0 && !activeAliases.has(alias)) {
continue;
}
for (const model of providerModels) {
models.push({
id: `${alias}/${model.id}`,
object: "model",
created: timestamp,
owned_by: alias,
permission: [],
root: model.id,
parent: null,
});
}
}
return Response.json({
object: "list",
data: models,
}, {
headers: {
"Access-Control-Allow-Origin": "*",
},
});
} catch (error) {
console.log("Error fetching models:", error);
return Response.json(
{ error: { message: error.message, type: "server_error" } },
{ status: 500 }
);
}
}

View file

@ -120,6 +120,20 @@ body {
/* Material Symbols */
.material-symbols-outlined {
font-family: 'Material Symbols Outlined', sans-serif;
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
font-feature-settings: 'liga';
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}

View file

@ -11,14 +11,22 @@ const inter = Inter({
export const metadata = {
title: "9Router - AI Infrastructure Management",
description: "One endpoint for all your AI providers. Manage keys, monitor usage, and scale effortlessly.",
icons: {
icon: "/favicon.svg",
},
};
export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
{/* eslint-disable-next-line @next/next/no-page-custom-font */}
<link
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap"
rel="stylesheet"
/>
</head>
<body className={`${inter.variable} font-sans antialiased`}>
<ThemeProvider>

View file

@ -106,7 +106,7 @@ export default function LoginPage() {
type="submit"
variant="primary"
className="w-full"
isLoading={loading}
loading={loading}
>
Login
</Button>

View file

@ -20,101 +20,22 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
const popupRef = useRef(null);
const { copied, copy } = useCopyToClipboard();
// Detect if running on localhost
const isLocalhost = typeof window !== "undefined" &&
(window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1");
// Reset state and start OAuth when modal opens
useEffect(() => {
if (isOpen && provider) {
setAuthData(null);
setCallbackUrl("");
setError(null);
setIsDeviceCode(false);
setDeviceData(null);
setPolling(false);
// Auto start OAuth
startOAuthFlow();
}
}, [isOpen, provider, startOAuthFlow]);
// Listen for OAuth callback via multiple methods
// State for client-only values to avoid hydration mismatch
const [isLocalhost, setIsLocalhost] = useState(false);
const [placeholderUrl, setPlaceholderUrl] = useState("/callback?code=...");
const callbackProcessedRef = useRef(false);
// Detect if running on localhost (client-side only)
useEffect(() => {
if (!authData) return;
callbackProcessedRef.current = false; // Reset when authData changes
// Handler for callback data - only process once
const handleCallback = async (data) => {
if (callbackProcessedRef.current) return; // Already processed
const { code, state, error: callbackError, errorDescription } = data;
if (callbackError) {
callbackProcessedRef.current = true;
setError(errorDescription || callbackError);
setStep("error");
return;
}
if (code) {
callbackProcessedRef.current = true;
await exchangeTokens(code, state);
}
};
// Method 1: postMessage from popup
const handleMessage = (event) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type === "oauth_callback") {
handleCallback(event.data.data);
}
};
window.addEventListener("message", handleMessage);
// Method 2: BroadcastChannel
let channel;
try {
channel = new BroadcastChannel("oauth_callback");
channel.onmessage = (event) => handleCallback(event.data);
} catch (e) {
console.log("BroadcastChannel not supported");
if (typeof window !== "undefined") {
setIsLocalhost(
window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1"
);
setPlaceholderUrl(`${window.location.origin}/callback?code=...`);
}
}, []);
// Method 3: localStorage event
const handleStorage = (event) => {
if (event.key === "oauth_callback" && event.newValue) {
try {
const data = JSON.parse(event.newValue);
handleCallback(data);
localStorage.removeItem("oauth_callback");
} catch (e) {
console.log("Failed to parse localStorage data");
}
}
};
window.addEventListener("storage", handleStorage);
// Also check localStorage on mount (in case callback already happened)
try {
const stored = localStorage.getItem("oauth_callback");
if (stored) {
const data = JSON.parse(stored);
// Only use if recent (within 30 seconds)
if (data.timestamp && Date.now() - data.timestamp < 30000) {
handleCallback(data);
localStorage.removeItem("oauth_callback");
}
}
} catch (e) {}
return () => {
window.removeEventListener("message", handleMessage);
window.removeEventListener("storage", handleStorage);
if (channel) channel.close();
};
}, [authData, exchangeTokens]);
// Define all useCallback hooks BEFORE the useEffects that reference them
// Exchange tokens
const exchangeTokens = useCallback(async (code, state) => {
@ -254,6 +175,96 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
}
}, [provider, isLocalhost, startPolling]);
// Reset state and start OAuth when modal opens
useEffect(() => {
if (isOpen && provider) {
setAuthData(null);
setCallbackUrl("");
setError(null);
setIsDeviceCode(false);
setDeviceData(null);
setPolling(false);
// Auto start OAuth
startOAuthFlow();
}
}, [isOpen, provider, startOAuthFlow]);
// Listen for OAuth callback via multiple methods
useEffect(() => {
if (!authData) return;
callbackProcessedRef.current = false; // Reset when authData changes
// Handler for callback data - only process once
const handleCallback = async (data) => {
if (callbackProcessedRef.current) return; // Already processed
const { code, state, error: callbackError, errorDescription } = data;
if (callbackError) {
callbackProcessedRef.current = true;
setError(errorDescription || callbackError);
setStep("error");
return;
}
if (code) {
callbackProcessedRef.current = true;
await exchangeTokens(code, state);
}
};
// Method 1: postMessage from popup
const handleMessage = (event) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type === "oauth_callback") {
handleCallback(event.data.data);
}
};
window.addEventListener("message", handleMessage);
// Method 2: BroadcastChannel
let channel;
try {
channel = new BroadcastChannel("oauth_callback");
channel.onmessage = (event) => handleCallback(event.data);
} catch (e) {
console.log("BroadcastChannel not supported");
}
// Method 3: localStorage event
const handleStorage = (event) => {
if (event.key === "oauth_callback" && event.newValue) {
try {
const data = JSON.parse(event.newValue);
handleCallback(data);
localStorage.removeItem("oauth_callback");
} catch (e) {
console.log("Failed to parse localStorage data");
}
}
};
window.addEventListener("storage", handleStorage);
// Also check localStorage on mount (in case callback already happened)
try {
const stored = localStorage.getItem("oauth_callback");
if (stored) {
const data = JSON.parse(stored);
// Only use if recent (within 30 seconds)
if (data.timestamp && Date.now() - data.timestamp < 30000) {
handleCallback(data);
localStorage.removeItem("oauth_callback");
}
}
} catch (e) {}
return () => {
window.removeEventListener("message", handleMessage);
window.removeEventListener("storage", handleStorage);
if (channel) channel.close();
};
}, [authData, exchangeTokens]);
// Handle manual URL input
const handleManualSubmit = async () => {
try {
@ -364,7 +375,7 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
<Input
value={callbackUrl}
onChange={(e) => setCallbackUrl(e.target.value)}
placeholder={`${window.location.origin}/callback?code=...`}
placeholder={placeholderUrl}
className="font-mono text-xs"
/>
</div>

View file

@ -1,6 +1,6 @@
"use client";
import { ThemeToggle } from "@/shared/components";
import ThemeToggle from "../ThemeToggle";
export default function AuthLayout({ children }) {
return (

View file

@ -1,31 +1,60 @@
"use client";
import { useEffect } from "react";
import { useEffect, useState, useSyncExternalStore } from "react";
import useThemeStore from "@/store/themeStore";
// Subscribe to system theme changes
function subscribeToSystemTheme(callback) {
if (typeof window === "undefined") return () => {};
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaQuery.addEventListener("change", callback);
return () => mediaQuery.removeEventListener("change", callback);
}
// Get current system theme preference
function getSystemThemeSnapshot() {
if (typeof window === "undefined") return false;
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
// Server snapshot always returns false
function getServerSnapshot() {
return false;
}
export function useTheme() {
const { theme, setTheme, toggleTheme, initTheme } = useThemeStore();
// Use useSyncExternalStore to safely subscribe to system theme
const systemPrefersDark = useSyncExternalStore(
subscribeToSystemTheme,
getSystemThemeSnapshot,
getServerSnapshot
);
useEffect(() => {
initTheme();
}, [initTheme]);
// Listen for system theme changes when theme is "system"
useEffect(() => {
if (theme !== "system") return;
// Listen for system theme changes
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
if (theme === "system") {
initTheme();
}
};
const handleChange = () => initTheme();
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, [theme, initTheme]);
// Compute isDark from current state (no effect needed)
const isDark = theme === "dark" || (theme === "system" && systemPrefersDark);
return {
theme,
setTheme,
toggleTheme,
isDark: theme === "dark" || (theme === "system" && typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches),
isDark,
};
}