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:
parent
0848dd5d13
commit
d9b8e48725
15 changed files with 762 additions and 171 deletions
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
101
src/app/api/v1/models/route.js
Normal file
101
src/app/api/v1/models/route.js
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ export default function LoginPage() {
|
|||
type="submit"
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
isLoading={loading}
|
||||
loading={loading}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue