Initial commit

This commit is contained in:
decolua
2026-01-05 09:58:59 +07:00
commit 3857598de4
159 changed files with 14537 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
cloud/*
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.bin/*
data/
logs/*
source/*
.cursor/*
docs/*
test/*
bin/*
open-sse/test/*
RM.vn.md
RM.md
cursor/*
stitch_router4_landing_page/*
+60
View File
@@ -0,0 +1,60 @@
# Database files - NEVER publish
data/
**/data/
**/db.json
# Development
src/
docs/
test/
agents/
scripts/
worker/
shared-sse/
copilot-api/
CLIProxyAPI/
# Config files
*.md
!README.md
.gitignore
.env*
jsconfig.json
eslint.config.mjs
postcss.config.mjs
next.config.mjs
tsconfig.json
# Build artifacts that shouldn't be published
.next/cache/
.next/standalone/data/
+155
View File
@@ -0,0 +1,155 @@
# 🚀 9ROUTER
[![npm version](https://img.shields.io/npm/v/9router.svg)](https://www.npmjs.com/package/9router)
[![License](https://img.shields.io/npm/l/9router.svg)](https://github.com/yourusername/9router/blob/main/LICENSE)
AI endpoint proxy with web dashboard - A JavaScript port of [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI).
![9Router Dashboard](https://github.com/decolua/9router/raw/main/images/9router.png)
## 📖 Introduction
**9Router** is a powerful AI API proxy server that provides unified access to multiple AI providers through a single endpoint. It features automatic format translation, intelligent fallback routing, OAuth authentication, and a modern web dashboard for easy management.
**Key Highlights:**
- **JavaScript Port**: Converted from [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) (Go) to JavaScript/Node.js for better accessibility and easier deployment
- **Universal CLI Support**: Works seamlessly with Claude Code, OpenAI Codex, Cline, RooCode, AmpCode, and other CLI tools
- **Cross-Platform**: Runs on Windows, Linux, and macOS
- **Easy Deployment**: Simple installation via npx, or deploy to VPS
## ✨ Features
### Core Features
- **🔄 Multi-Provider Support**: Unified endpoint for 15+ AI providers (Claude, OpenAI, Gemini, GitHub Copilot, Qwen, iFlow, DeepSeek, Kimi, MiniMax, GLM, etc.)
- **🔐 OAuth & API Key Authentication**: Supports both OAuth2 flow and API key authentication
- **🎯 Format Translation**: Automatic request/response translation between OpenAI, Claude, Gemini, Codex, and Ollama formats
- **🌐 Web Dashboard**: Beautiful React-based dashboard for managing providers, combos, API keys, and settings
- **📊 Usage Tracking**: Real-time monitoring and analytics for all API requests
### Advanced Features
- **🎲 Combo System**: Create model combos with automatic fallback support
- **♻️ Intelligent Fallback**: Automatic account rotation when rate limits or errors occur
- **⚡ Response Caching**: Optimized caching for Claude Code (1-hour cache vs default 5 minutes)
- **🔧 Model Aliases**: Create custom model aliases for easier management
- **🔍 WebSearch Hook**: Model-based web search integration for Claude Code CLI
- **☁️ Cloud Deployment**: Deploy to Cloud for Cursor IDE integration with global edge performance
### Format Support
- **OpenAI Format**: Standard OpenAI Chat Completions API
- **Claude Format**: Anthropic Messages API
- **Gemini Format**: Google Generative AI API
- **OpenAI Responses API**: Codex CLI format
- **Ollama Format**: Compatible with Ollama-based tools
### CLI Integration
- Works with: Claude Code, OpenAI Codex, Cline, RooCode, AmpCode, Cursor, and more
- Seamless integration with popular AI coding assistants
- WebSearch hook for enhanced Claude Code capabilities
## 📦 Install
```bash
# Run directly with npx (recommended)
npx 9router
# Or install globally
npm install -g 9router
9router
```
## 🚀 Quick Start
```bash
9router # Start server with default settings
9router --port 8080 # Custom port
9router --no-browser # Don't open browser
9router --skip-update # Skip auto-update check
9router --help # Show help
```
**Dashboard**: `http://localhost:20128/dashboard`
## 💾 Data Location
User data stored at:
- macOS/Linux: `~/.9router/db.json`
- Windows: `%APPDATA%/9router/db.json`
## 🛠️ Development
### Setup
```bash
# Clone repository
git clone https://github.com/yourusername/9router.git
cd 9router
# Install dependencies
npm install
# Start development server
npm run dev
```
### Project Structure
```
9router/
├── bin/ # CLI entry point
│ ├── cli.js # Main CLI script
│ └── hooks/ # CLI hooks (WebSearch)
├── src/
│ ├── app/ # Next.js app (dashboard & API routes)
│ ├── lib/ # Core libraries (DB, OAuth, etc.)
│ ├── shared/ # Shared components & utilities
│ └── sse/ # SSE streaming handlers
├── open-sse/ # Core proxy engine (translator, handlers)
│ ├── translator/ # Format translators
│ ├── handlers/ # Request handlers
│ ├── services/ # Core services
│ └── config/ # Provider configurations
└── public/ # Static assets
```
## 📤 Build & Publish
```bash
# Build standalone binary
npm run build:standalone
# Test locally
npm link
9router --help
# Publish to npm
npm login
npm publish
```
## 🧰 Tech Stack
| Layer | Technology |
|-------|------------|
| **Runtime** | Node.js 20+ / Bun |
| **Framework** | Next.js 15 |
| **Dashboard** | React 19 + Tailwind CSS 4 |
| **Database** | LowDB (JSON file-based) |
| **CLI** | Node.js CLI with auto-update |
| **Streaming** | Server-Sent Events (SSE) |
| **Auth** | OAuth 2.0 (PKCE) + API Keys |
| **Deployment** | Standalone / VPS |
| **State Management** | Zustand |
### Core Libraries
- **lowdb**: Lightweight JSON database
- **undici**: High-performance HTTP client
- **uuid**: Unique identifier generation
- **open**: Cross-platform browser launcher
## 🙏 Acknowledgments
Special thanks to:
- **[CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)**: The original Go implementation that inspired this project. 9Router is a JavaScript port with enhanced features and web dashboard.
## 📄 License
MIT License - see [LICENSE](LICENSE) for details.
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
const eslintConfig = defineConfig([
...nextVitals,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"open-sse": ["../open-sse"],
"open-sse/*": ["../open-sse/*"]
},
"module": "ESNext",
"moduleResolution": "bundler"
}
}
+44
View File
@@ -0,0 +1,44 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
env: {
NEXT_PUBLIC_CLOUD_URL: "https://9router.com",
},
webpack: (config, { isServer }) => {
// Ignore fs/path modules in browser bundle
if (!isServer) {
config.resolve.fallback = {
...config.resolve.fallback,
fs: false,
path: false,
};
}
return config;
},
async rewrites() {
return [
{
source: "/v1/v1/:path*",
destination: "/api/v1/:path*"
},
{
source: "/v1/v1",
destination: "/api/v1"
},
{
source: "/codex/:path*",
destination: "/api/v1/responses"
},
{
source: "/v1/:path*",
destination: "/api/v1/:path*"
},
{
source: "/v1",
destination: "/api/v1"
}
];
}
};
export default nextConfig;
+38
View File
@@ -0,0 +1,38 @@
{
"name": "9router-app",
"version": "0.2.13",
"description": "9Router web dashboard",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"build:standalone": "next build && node scripts/prepare-standalone.js",
"start": "next start",
"start:cli": "node bin/cli.js",
"lint": "eslint",
"prepublishOnly": "npm run build:standalone"
},
"dependencies": {
"fs": "^0.0.1-security",
"lowdb": "^7.0.1",
"next": "^15.2.0",
"node-machine-id": "^1.1.12",
"open": "^10.1.0",
"open-sse": "^1.0.0",
"ora": "^5.4.1",
"react": "19.2.1",
"react-dom": "19.2.1",
"undici": "^7.16.0",
"uuid": "^13.0.0",
"zustand": "^5.0.9"
},
"overrides": {
"open-sse": "file:../open-sse"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.18",
"eslint": "^9",
"eslint-config-next": "16.0.10",
"tailwindcss": "^4"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
+11
View File
@@ -0,0 +1,11 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="6" fill="url(#gradient)"/>
<text x="16" y="24" font-family="system-ui, -apple-system, sans-serif" font-size="20" font-weight="700" fill="white" text-anchor="middle">9</text>
<defs>
<linearGradient id="gradient" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
<stop stop-color="#f97815"/>
<stop offset="1" stop-color="#c2590a"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 533 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
const projectRoot = path.resolve(__dirname, "..");
const standaloneDir = path.join(projectRoot, ".next/standalone");
const staticSrc = path.join(projectRoot, ".next/static");
const staticDest = path.join(standaloneDir, ".next/static");
const publicSrc = path.join(projectRoot, "public");
const publicDest = path.join(standaloneDir, "public");
function copyRecursive(src, dest) {
if (!fs.existsSync(src)) return;
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true });
}
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
copyRecursive(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
console.log("Preparing standalone build...");
// Copy static files
if (fs.existsSync(staticSrc)) {
copyRecursive(staticSrc, staticDest);
console.log("✓ Copied .next/static");
}
// Copy public folder
if (fs.existsSync(publicSrc)) {
copyRecursive(publicSrc, publicDest);
console.log("✓ Copied public");
}
console.log("✓ Standalone build ready");
@@ -0,0 +1,193 @@
"use client";
import { useState, useEffect } from "react";
import { Card, CardSkeleton } from "@/shared/components";
import { CLI_TOOLS } from "@/shared/constants/cliTools";
import { PROVIDER_MODELS, getModelsByProviderId, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models";
import { ClaudeToolCard, CodexToolCard, DefaultToolCard } from "./components";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
export default function CLIToolsPageClient({ machineId }) {
const [connections, setConnections] = useState([]);
const [loading, setLoading] = useState(true);
const [expandedTool, setExpandedTool] = useState(null);
const [modelMappings, setModelMappings] = useState({});
const [cloudEnabled, setCloudEnabled] = useState(false);
const [apiKeys, setApiKeys] = useState([]);
useEffect(() => {
fetchConnections();
loadCloudSettings();
fetchApiKeys();
}, []);
const loadCloudSettings = async () => {
try {
const res = await fetch("/api/settings");
if (res.ok) {
const data = await res.json();
setCloudEnabled(data.cloudEnabled || false);
}
} catch (error) {
console.log("Error loading cloud settings:", error);
}
};
const fetchApiKeys = async () => {
try {
const res = await fetch("/api/keys");
if (res.ok) {
const data = await res.json();
setApiKeys(data.keys || []);
}
} catch (error) {
console.log("Error fetching API keys:", error);
}
};
const fetchConnections = async () => {
try {
const res = await fetch("/api/providers");
const data = await res.json();
if (res.ok) {
setConnections(data.connections || []);
}
} catch (error) {
console.log("Error fetching connections:", error);
} finally {
setLoading(false);
}
};
const getActiveProviders = () => {
return connections.filter(c => c.isActive !== false);
};
const getAllAvailableModels = () => {
const activeProviders = getActiveProviders();
const models = [];
const seenModels = new Set();
activeProviders.forEach(conn => {
const alias = PROVIDER_ID_TO_ALIAS[conn.provider] || conn.provider;
const providerModels = getModelsByProviderId(conn.provider);
providerModels.forEach(m => {
const modelValue = `${alias}/${m.id}`;
if (!seenModels.has(modelValue)) {
seenModels.add(modelValue);
models.push({
value: modelValue,
label: `${alias}/${m.id}`,
provider: conn.provider,
alias: alias,
connectionName: conn.name,
modelId: m.id,
});
}
});
});
if (models.length === 0) {
Object.entries(PROVIDER_MODELS).forEach(([alias, providerModels]) => {
providerModels.forEach(m => {
const modelValue = `${alias}/${m.id}`;
models.push({
value: modelValue,
label: `${alias}/${m.id}`,
provider: alias,
alias: alias,
connectionName: alias,
modelId: m.id,
});
});
});
}
return models;
};
const handleModelMappingChange = (toolId, modelAlias, targetModel) => {
setModelMappings(prev => ({
...prev,
[toolId]: {
...prev[toolId],
[modelAlias]: targetModel,
},
}));
};
const getBaseUrl = () => {
if (cloudEnabled && CLOUD_URL) {
return CLOUD_URL;
}
if (typeof window !== "undefined") {
return window.location.origin;
}
return "http://localhost:3000";
};
if (loading) {
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4">
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
</div>
</div>
);
}
const availableModels = getAllAvailableModels();
const hasActiveProviders = availableModels.length > 0;
const renderToolCard = (toolId, tool) => {
const commonProps = {
tool,
isExpanded: expandedTool === toolId,
onToggle: () => setExpandedTool(expandedTool === toolId ? null : toolId),
baseUrl: getBaseUrl(),
apiKeys,
};
switch (toolId) {
case "claude":
return (
<ClaudeToolCard
key={toolId}
{...commonProps}
activeProviders={getActiveProviders()}
modelMappings={modelMappings[toolId] || {}}
onModelMappingChange={(alias, target) => handleModelMappingChange(toolId, alias, target)}
hasActiveProviders={hasActiveProviders}
cloudEnabled={cloudEnabled}
/>
);
case "codex":
return <CodexToolCard key={toolId} {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} />;
default:
return <DefaultToolCard key={toolId} toolId={toolId} {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} />;
}
};
return (
<div className="flex flex-col gap-6">
{!hasActiveProviders && (
<Card className="border-yellow-500/50 bg-yellow-500/5">
<div className="flex items-center gap-3">
<span className="material-symbols-outlined text-yellow-500">warning</span>
<div>
<p className="font-medium text-yellow-600 dark:text-yellow-400">No active providers</p>
<p className="text-sm text-text-muted">Please add and connect providers first to configure CLI tools.</p>
</div>
</div>
</Card>
)}
<div className="flex flex-col gap-4">
{Object.entries(CLI_TOOLS).map(([toolId, tool]) => renderToolCard(toolId, tool))}
</div>
</div>
);
}
@@ -0,0 +1,320 @@
"use client";
import { useState, useEffect } from "react";
import { Card, Button, ModelSelectModal } from "@/shared/components";
import Image from "next/image";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
export default function ClaudeToolCard({
tool,
isExpanded,
onToggle,
activeProviders,
modelMappings,
onModelMappingChange,
baseUrl,
hasActiveProviders,
apiKeys,
cloudEnabled,
}) {
const [claudeStatus, setClaudeStatus] = useState(null);
const [checkingClaude, setCheckingClaude] = useState(false);
const [applying, setApplying] = useState(false);
const [restoring, setRestoring] = useState(false);
const [message, setMessage] = useState(null);
const [showInstallGuide, setShowInstallGuide] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [currentEditingAlias, setCurrentEditingAlias] = useState(null);
const [selectedApiKey, setSelectedApiKey] = useState("");
const [copiedConfig, setCopiedConfig] = useState(false);
const [modelAliases, setModelAliases] = useState({});
const getConfigStatus = () => {
if (!claudeStatus?.installed) return null;
const currentUrl = claudeStatus.settings?.env?.ANTHROPIC_BASE_URL;
if (!currentUrl) return "not_configured";
const localMatch = currentUrl.includes("localhost") || currentUrl.includes("127.0.0.1");
const cloudMatch = cloudEnabled && CLOUD_URL && currentUrl.startsWith(CLOUD_URL);
if (localMatch || cloudMatch) return "configured";
return "other";
};
const configStatus = getConfigStatus();
useEffect(() => {
if (apiKeys?.length > 0 && !selectedApiKey) {
setSelectedApiKey(apiKeys[0].key);
}
}, [apiKeys]);
useEffect(() => {
if (isExpanded && !claudeStatus) {
checkClaudeStatus();
fetchModelAliases();
}
}, [isExpanded]);
const fetchModelAliases = async () => {
try {
const res = await fetch("/api/models/alias");
const data = await res.json();
if (res.ok) setModelAliases(data.aliases || {});
} catch (error) {
console.log("Error fetching model aliases:", error);
}
};
useEffect(() => {
if (claudeStatus?.installed) {
const env = claudeStatus.settings?.env || {};
tool.defaultModels.forEach((model) => {
if (model.envKey) {
const value = env[model.envKey] || model.defaultValue || "";
if (value) onModelMappingChange(model.alias, value);
}
});
// Only set selectedApiKey if it exists in apiKeys list
const tokenFromFile = env.ANTHROPIC_AUTH_TOKEN;
if (tokenFromFile && apiKeys?.some(k => k.key === tokenFromFile)) {
setSelectedApiKey(tokenFromFile);
}
}
}, [claudeStatus, apiKeys]);
const checkClaudeStatus = async () => {
setCheckingClaude(true);
try {
const res = await fetch("/api/cli-tools/claude-settings");
const data = await res.json();
setClaudeStatus(data);
} catch (error) {
setClaudeStatus({ installed: false, error: error.message });
} finally {
setCheckingClaude(false);
}
};
const handleApplySettings = async () => {
setApplying(true);
setMessage(null);
try {
const env = { ANTHROPIC_BASE_URL: baseUrl };
// Get key from dropdown, fallback to first key or sk_9router for localhost
const keyToUse = selectedApiKey?.trim()
|| (apiKeys?.length > 0 ? apiKeys[0].key : null)
|| (!cloudEnabled ? "sk_9router" : null);
if (keyToUse) {
env.ANTHROPIC_AUTH_TOKEN = keyToUse;
}
tool.defaultModels.forEach((model) => {
const targetModel = modelMappings[model.alias];
if (targetModel && model.envKey) env[model.envKey] = targetModel;
});
const res = await fetch("/api/cli-tools/claude-settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ env }),
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings applied successfully!" });
setClaudeStatus(prev => ({ ...prev, hasBackup: true, settings: { ...prev?.settings, env } }));
} else {
setMessage({ type: "error", text: data.error || "Failed to apply settings" });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
} finally {
setApplying(false);
}
};
const handleResetSettings = async () => {
setRestoring(true);
setMessage(null);
try {
const res = await fetch("/api/cli-tools/claude-settings", { method: "DELETE" });
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings reset successfully!" });
tool.defaultModels.forEach((model) => onModelMappingChange(model.alias, model.defaultValue || ""));
setSelectedApiKey("");
} else {
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
} finally {
setRestoring(false);
}
};
const openModelSelector = (alias) => {
setCurrentEditingAlias(alias);
setModalOpen(true);
};
const handleModelSelect = (model) => {
if (currentEditingAlias) onModelMappingChange(currentEditingAlias, model.value);
};
// Generate settings.json content for manual copy
const getSettingsContent = () => {
const keyToUse = (selectedApiKey && selectedApiKey.trim())
? selectedApiKey
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
const env = { ANTHROPIC_BASE_URL: baseUrl, ANTHROPIC_AUTH_TOKEN: keyToUse };
tool.defaultModels.forEach((model) => {
const targetModel = modelMappings[model.alias];
if (targetModel && model.envKey) env[model.envKey] = targetModel;
});
return JSON.stringify({ env }, null, 2);
};
const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text);
setCopiedConfig(true);
setTimeout(() => setCopiedConfig(false), 2000);
} catch (err) {
console.log("Failed to copy:", err);
}
};
return (
<Card className="overflow-hidden">
<div className="flex items-center justify-between hover:cursor-pointer" onClick={onToggle}>
<div className="flex items-center gap-4">
<div className="size-12 rounded-xl flex items-center justify-center" style={{ backgroundColor: `${tool.color}15` }}>
<Image src="/providers/claude.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" onError={(e) => { e.target.style.display = "none"; }} />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg">{tool.name}</h3>
{configStatus === "configured" && <span className="px-2 py-0.5 text-xs font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">Connected</span>}
{configStatus === "not_configured" && <span className="px-2 py-0.5 text-xs font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">Not configured</span>}
{configStatus === "other" && <span className="px-2 py-0.5 text-xs font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">Other endpoint</span>}
</div>
<p className="text-sm text-text-muted">{tool.description}</p>
</div>
</div>
<span className={`material-symbols-outlined text-text-muted transition-transform ${isExpanded ? "rotate-180" : ""}`}>expand_more</span>
</div>
{isExpanded && (
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
{checkingClaude && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking Claude CLI...</span>
</div>
)}
{!checkingClaude && claudeStatus && !claudeStatus.installed && (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3 p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<span className="material-symbols-outlined text-yellow-500">warning</span>
<div className="flex-1">
<p className="font-medium text-yellow-600 dark:text-yellow-400">Claude CLI not installed</p>
<p className="text-sm text-text-muted">Please install Claude CLI to use this feature.</p>
</div>
<Button variant="outline" size="sm" onClick={() => setShowInstallGuide(!showInstallGuide)}>
<span className="material-symbols-outlined text-[18px] mr-1">{showInstallGuide ? "expand_less" : "help"}</span>
{showInstallGuide ? "Hide" : "How to Install"}
</Button>
</div>
{showInstallGuide && (
<div className="p-4 bg-surface border border-border rounded-lg">
<h4 className="font-medium mb-3">Installation Guide</h4>
<div className="space-y-3 text-sm">
<div>
<p className="text-text-muted mb-1">macOS / Linux / Windows:</p>
<code className="block px-3 py-2 bg-black/5 dark:bg-white/5 rounded font-mono text-xs">npm install -g @anthropic-ai/claude-code</code>
</div>
<p className="text-text-muted">After installation, run <code className="px-1 bg-black/5 dark:bg-white/5 rounded">claude</code> to verify.</p>
</div>
</div>
)}
</div>
)}
{!checkingClaude && claudeStatus?.installed && (
<>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-green-500 text-[16px]">check_circle</span>
<span className="text-xs text-text-muted shrink-0">URL:</span>
<code className="text-xs font-mono text-text-main truncate">{baseUrl}</code>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted shrink-0">Key:</span>
{apiKeys.length > 0 ? (
<select value={selectedApiKey} onChange={(e) => setSelectedApiKey(e.target.value)} className="flex-1 px-2 py-1.5 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50">
{apiKeys.map((key) => <option key={key.id} value={key.key}>{key.key}</option>)}
</select>
) : (
<span className="text-xs text-text-muted">
{cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router"}
</span>
)}
</div>
<div className="flex flex-col gap-2">
{tool.defaultModels.map((model) => (
<div key={model.alias} className="flex items-center gap-2">
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">{model.name}</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">arrow_forward</span>
<input type="text" value={modelMappings[model.alias] || ""} onChange={(e) => onModelMappingChange(model.alias, e.target.value)} placeholder="provider/model-id" className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" />
<button onClick={() => openModelSelector(model.alias)} disabled={!hasActiveProviders} className={`px-2 py-1.5 rounded border text-xs transition-colors shrink-0 whitespace-nowrap ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select Model</button>
{modelMappings[model.alias] && <button onClick={() => onModelMappingChange(model.alias, "")} className="p-1 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></button>}
</div>
))}
</div>
{message && (
<div className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}>
<span className="material-symbols-outlined text-[14px]">{message.type === "success" ? "check_circle" : "error"}</span>
<span>{message.text}</span>
</div>
)}
<div className="flex items-center gap-2">
<Button variant="primary" size="sm" onClick={handleApplySettings} disabled={!hasActiveProviders} loading={applying}>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
</Button>
<Button variant="outline" size="sm" onClick={handleResetSettings} disabled={!claudeStatus?.has9Router} loading={restoring}>
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
</Button>
<Button variant="ghost" size="sm" onClick={checkClaudeStatus}>
<span className="material-symbols-outlined text-[14px]">refresh</span>
</Button>
</div>
{/* Manual Config Section */}
<div className="pt-4 border-t border-border flex flex-col gap-3">
<p className="text-xs text-text-muted">Or copy config manually:</p>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-text-main">~/.claude/settings.json</span>
<Button variant="ghost" size="sm" onClick={() => copyToClipboard(getSettingsContent())}>
<span className="material-symbols-outlined text-[14px] mr-1">{copiedConfig ? "check" : "content_copy"}</span>
{copiedConfig ? "Copied!" : "Copy"}
</Button>
</div>
<pre className="px-3 py-2 bg-black/5 dark:bg-white/5 rounded font-mono text-xs overflow-x-auto whitespace-pre-wrap break-all max-h-40 overflow-y-auto">{getSettingsContent()}</pre>
</div>
</div>
</>
)}
</div>
)}
<ModelSelectModal isOpen={modalOpen} onClose={() => setModalOpen(false)} onSelect={handleModelSelect} selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} activeProviders={activeProviders} modelAliases={modelAliases} title={`Select model for ${currentEditingAlias}`} />
</Card>
);
}
@@ -0,0 +1,307 @@
"use client";
import { useState, useEffect } from "react";
import { Card, Button, ModelSelectModal } from "@/shared/components";
import Image from "next/image";
export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled }) {
const [codexStatus, setCodexStatus] = useState(null);
const [checkingCodex, setCheckingCodex] = useState(false);
const [applying, setApplying] = useState(false);
const [restoring, setRestoring] = useState(false);
const [message, setMessage] = useState(null);
const [showInstallGuide, setShowInstallGuide] = useState(false);
const [selectedApiKey, setSelectedApiKey] = useState("");
const [selectedModel, setSelectedModel] = useState("");
const [copiedConfig, setCopiedConfig] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [modelAliases, setModelAliases] = useState({});
useEffect(() => {
if (apiKeys?.length > 0 && !selectedApiKey) {
setSelectedApiKey(apiKeys[0].key);
}
}, [apiKeys]);
useEffect(() => {
if (isExpanded && !codexStatus) {
checkCodexStatus();
fetchModelAliases();
}
}, [isExpanded]);
const fetchModelAliases = async () => {
try {
const res = await fetch("/api/models/alias");
const data = await res.json();
if (res.ok) setModelAliases(data.aliases || {});
} catch (error) {
console.log("Error fetching model aliases:", error);
}
};
// Parse model from config content
useEffect(() => {
if (codexStatus?.config) {
const modelMatch = codexStatus.config.match(/^model\s*=\s*"([^"]+)"/m);
if (modelMatch) setSelectedModel(modelMatch[1]);
}
}, [codexStatus]);
const getConfigStatus = () => {
if (!codexStatus?.installed) return null;
if (!codexStatus.config) return "not_configured";
const hasBaseUrl = codexStatus.config.includes(baseUrl) || codexStatus.config.includes("localhost") || codexStatus.config.includes("127.0.0.1");
return hasBaseUrl ? "configured" : "other";
};
const configStatus = getConfigStatus();
const checkCodexStatus = async () => {
setCheckingCodex(true);
try {
const res = await fetch("/api/cli-tools/codex-settings");
const data = await res.json();
setCodexStatus(data);
} catch (error) {
setCodexStatus({ installed: false, error: error.message });
} finally {
setCheckingCodex(false);
}
};
const handleApplySettings = async () => {
setApplying(true);
setMessage(null);
try {
// Use sk_9router for localhost if no key, otherwise use selected key
const keyToUse = (selectedApiKey && selectedApiKey.trim())
? selectedApiKey
: (!cloudEnabled ? "sk_9router" : selectedApiKey);
const res = await fetch("/api/cli-tools/codex-settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ baseUrl, apiKey: keyToUse, model: selectedModel }),
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings applied successfully!" });
checkCodexStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to apply settings" });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
} finally {
setApplying(false);
}
};
const handleResetSettings = async () => {
setRestoring(true);
setMessage(null);
try {
const res = await fetch("/api/cli-tools/codex-settings", { method: "DELETE" });
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Settings reset successfully!" });
setSelectedModel("");
checkCodexStatus();
} else {
setMessage({ type: "error", text: data.error || "Failed to reset settings" });
}
} catch (error) {
setMessage({ type: "error", text: error.message });
} finally {
setRestoring(false);
}
};
const handleModelSelect = (model) => {
setSelectedModel(model.value);
setModalOpen(false);
};
const configContent = `# 9Router Configuration for Codex CLI
model = "${selectedModel}"
model_provider = "9router"
[model_providers.9router]
name = "9Router"
base_url = "${baseUrl}/v1"
wire_api = "responses"
`;
const keyToUse = (selectedApiKey && selectedApiKey.trim())
? selectedApiKey
: (!cloudEnabled ? "sk_9router" : "<API_KEY_FROM_DASHBOARD>");
const authContent = JSON.stringify({
OPENAI_API_KEY: keyToUse
}, null, 2);
const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text);
setCopiedConfig(true);
setTimeout(() => setCopiedConfig(false), 2000);
} catch (err) {
console.log("Failed to copy:", err);
}
};
return (
<Card className="overflow-hidden">
<div className="flex items-center justify-between hover:cursor-pointer" onClick={onToggle}>
<div className="flex items-center gap-4">
<div className="size-12 rounded-xl flex items-center justify-center" style={{ backgroundColor: `${tool.color}15` }}>
<Image src="/providers/codex.png" alt={tool.name} width={32} height={32} className="size-8 object-contain rounded-lg" onError={(e) => { e.target.style.display = "none"; }} />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg">{tool.name}</h3>
{configStatus === "configured" && <span className="px-2 py-0.5 text-xs font-medium bg-green-500/10 text-green-600 dark:text-green-400 rounded-full">Connected</span>}
{configStatus === "not_configured" && <span className="px-2 py-0.5 text-xs font-medium bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 rounded-full">Not configured</span>}
{configStatus === "other" && <span className="px-2 py-0.5 text-xs font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 rounded-full">Other endpoint</span>}
</div>
<p className="text-sm text-text-muted">{tool.description}</p>
</div>
</div>
<span className={`material-symbols-outlined text-text-muted transition-transform ${isExpanded ? "rotate-180" : ""}`}>expand_more</span>
</div>
{isExpanded && (
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
{checkingCodex && (
<div className="flex items-center gap-2 text-text-muted">
<span className="material-symbols-outlined animate-spin">progress_activity</span>
<span>Checking Codex CLI...</span>
</div>
)}
{!checkingCodex && codexStatus && !codexStatus.installed && (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3 p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<span className="material-symbols-outlined text-yellow-500">warning</span>
<div className="flex-1">
<p className="font-medium text-yellow-600 dark:text-yellow-400">Codex CLI not installed</p>
<p className="text-sm text-text-muted">Please install Codex CLI to use auto-apply feature.</p>
</div>
<Button variant="outline" size="sm" onClick={() => setShowInstallGuide(!showInstallGuide)}>
<span className="material-symbols-outlined text-[18px] mr-1">{showInstallGuide ? "expand_less" : "help"}</span>
{showInstallGuide ? "Hide" : "How to Install"}
</Button>
</div>
{showInstallGuide && (
<div className="p-4 bg-surface border border-border rounded-lg">
<h4 className="font-medium mb-3">Installation Guide</h4>
<div className="space-y-3 text-sm">
<div>
<p className="text-text-muted mb-1">macOS / Linux / Windows:</p>
<code className="block px-3 py-2 bg-black/5 dark:bg-white/5 rounded font-mono text-xs">npm install -g @openai/codex</code>
</div>
<p className="text-text-muted">After installation, run <code className="px-1 bg-black/5 dark:bg-white/5 rounded">codex</code> to verify.</p>
<div className="pt-2 border-t border-border">
<p className="text-text-muted text-xs">
Codex uses <code className="px-1 bg-black/5 dark:bg-white/5 rounded">~/.codex/auth.json</code> with <code className="px-1 bg-black/5 dark:bg-white/5 rounded">OPENAI_API_KEY</code>.
Click &quot;Apply&quot; to auto-configure.
</p>
</div>
</div>
</div>
)}
</div>
)}
{!checkingCodex && codexStatus?.installed && (
<>
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-green-500 text-[16px]">check_circle</span>
<span className="text-xs text-text-muted shrink-0">URL:</span>
<code className="text-xs font-mono text-text-main truncate">{baseUrl}/v1</code>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted shrink-0">Key:</span>
{apiKeys.length > 0 ? (
<select value={selectedApiKey} onChange={(e) => setSelectedApiKey(e.target.value)} className="flex-1 px-2 py-1.5 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50">
{apiKeys.map((key) => <option key={key.id} value={key.key}>{key.key}</option>)}
</select>
) : (
<span className="text-xs text-text-muted">
{cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router"}
</span>
)}
</div>
<div className="flex items-center gap-2">
<span className="w-16 shrink-0 text-sm font-semibold text-text-main text-right">Model</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">arrow_forward</span>
<input type="text" value={selectedModel} onChange={(e) => setSelectedModel(e.target.value)} placeholder="provider/model-id" className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" />
<button onClick={() => setModalOpen(true)} disabled={!activeProviders?.length} className={`px-2 py-1.5 rounded border text-xs transition-colors shrink-0 whitespace-nowrap ${activeProviders?.length ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}>Select Model</button>
{selectedModel && <button onClick={() => setSelectedModel("")} className="p-1 text-text-muted hover:text-red-500 rounded transition-colors" title="Clear"><span className="material-symbols-outlined text-[14px]">close</span></button>}
</div>
{message && (
<div className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}>
<span className="material-symbols-outlined text-[14px]">{message.type === "success" ? "check_circle" : "error"}</span>
<span>{message.text}</span>
</div>
)}
<div className="flex items-center gap-2">
<Button variant="primary" size="sm" onClick={handleApplySettings} disabled={!selectedApiKey || !selectedModel} loading={applying}>
<span className="material-symbols-outlined text-[14px] mr-1">save</span>Apply
</Button>
<Button variant="outline" size="sm" onClick={handleResetSettings} disabled={!codexStatus.has9Router} loading={restoring}>
<span className="material-symbols-outlined text-[14px] mr-1">restore</span>Reset
</Button>
<Button variant="ghost" size="sm" onClick={checkCodexStatus}>
<span className="material-symbols-outlined text-[14px]">refresh</span>
</Button>
</div>
</>
)}
{/* Manual Config Section */}
<div className="pt-4 border-t border-border flex flex-col gap-3">
<p className="text-xs text-text-muted">Or copy config manually:</p>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-text-main">~/.codex/config.toml</span>
<Button variant="ghost" size="sm" onClick={() => copyToClipboard(configContent)}>
<span className="material-symbols-outlined text-[14px] mr-1">{copiedConfig ? "check" : "content_copy"}</span>
{copiedConfig ? "Copied!" : "Copy"}
</Button>
</div>
<pre className="px-3 py-2 bg-black/5 dark:bg-white/5 rounded font-mono text-xs overflow-x-auto whitespace-pre-wrap break-all max-h-32 overflow-y-auto">{configContent}</pre>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-text-main">~/.codex/auth.json</span>
<Button variant="ghost" size="sm" onClick={() => copyToClipboard(authContent)}>
<span className="material-symbols-outlined text-[14px] mr-1">{copiedConfig ? "check" : "content_copy"}</span>
{copiedConfig ? "Copied!" : "Copy"}
</Button>
</div>
<pre className="px-3 py-2 bg-black/5 dark:bg-white/5 rounded font-mono text-xs overflow-x-auto whitespace-pre-wrap break-all max-h-32 overflow-y-auto">{authContent}</pre>
</div>
</div>
</div>
)}
<ModelSelectModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
onSelect={handleModelSelect}
selectedModel={selectedModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Select Model for Codex"
/>
</Card>
);
}
@@ -0,0 +1,284 @@
"use client";
import { useState } from "react";
import { Card, ModelSelectModal } from "@/shared/components";
import Image from "next/image";
export default function DefaultToolCard({ toolId, tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders = [], cloudEnabled = false }) {
const [copiedField, setCopiedField] = useState(null);
const [showModelModal, setShowModelModal] = useState(false);
const [modelValue, setModelValue] = useState("");
// Initialize state directly with computed value - no need for useEffect
const [selectedApiKey, setSelectedApiKey] = useState(() =>
apiKeys?.length > 0 ? apiKeys[0].key : ""
);
const replaceVars = (text) => {
const keyToUse = (selectedApiKey && selectedApiKey.trim())
? selectedApiKey
: (!cloudEnabled ? "sk_9router" : "your-api-key");
return text
.replace(/\{\{baseUrl\}\}/g, baseUrl || "http://localhost:3000")
.replace(/\{\{apiKey\}\}/g, keyToUse)
.replace(/\{\{model\}\}/g, modelValue || "provider/model-id");
};
const handleCopy = async (text, field) => {
await navigator.clipboard.writeText(replaceVars(text));
setCopiedField(field);
setTimeout(() => setCopiedField(null), 2000);
};
const handleSelectModel = (model) => {
setModelValue(model.value);
};
const hasActiveProviders = activeProviders.length > 0;
const renderApiKeySelector = () => {
return (
<div className="mt-2 flex items-center gap-2">
{apiKeys && apiKeys.length > 0 ? (
<>
<select
value={selectedApiKey}
onChange={(e) => setSelectedApiKey(e.target.value)}
className="flex-1 px-3 py-2 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
>
{apiKeys.map((key) => (
<option key={key.id} value={key.key}>{key.key}</option>
))}
</select>
<button
onClick={() => handleCopy(selectedApiKey, "apiKey")}
className="shrink-0 px-3 py-2 bg-bg-secondary hover:bg-bg-tertiary rounded-lg border border-border transition-colors"
>
<span className="material-symbols-outlined text-lg">
{copiedField === "apiKey" ? "check" : "content_copy"}
</span>
</button>
</>
) : (
<span className="text-sm text-text-muted">
{cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router"}
</span>
)}
</div>
);
};
const renderModelSelector = () => {
return (
<div className="mt-2 flex items-center gap-2">
<input
type="text"
value={modelValue}
onChange={(e) => setModelValue(e.target.value)}
placeholder="provider/model-id"
className="flex-1 px-3 py-2 bg-bg-secondary rounded-lg text-sm border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
/>
<button
onClick={() => setShowModelModal(true)}
disabled={!hasActiveProviders}
className={`shrink-0 px-3 py-2 rounded-lg border text-sm transition-colors ${
hasActiveProviders
? "bg-bg-secondary border-border text-text-main hover:border-primary cursor-pointer"
: "opacity-50 cursor-not-allowed border-border"
}`}
>
Select Model
</button>
{modelValue && (
<>
<button
onClick={() => handleCopy(modelValue, "model")}
className="shrink-0 px-3 py-2 bg-bg-secondary hover:bg-bg-tertiary rounded-lg border border-border transition-colors"
>
<span className="material-symbols-outlined text-lg">
{copiedField === "model" ? "check" : "content_copy"}
</span>
</button>
<button
onClick={() => setModelValue("")}
className="p-2 text-text-muted hover:text-red-500 rounded transition-colors"
title="Clear"
>
<span className="material-symbols-outlined text-lg">close</span>
</button>
</>
)}
</div>
);
};
const renderNotes = () => {
if (!tool.notes || tool.notes.length === 0) return null;
return (
<div className="flex flex-col gap-2 mb-4">
{tool.notes.map((note, index) => {
// Skip cloudCheck note if cloud is enabled
if (note.type === "cloudCheck" && cloudEnabled) return null;
const isWarning = note.type === "warning";
const isError = note.type === "cloudCheck" && !cloudEnabled;
let bgClass = "bg-blue-500/10 border-blue-500/30";
let textClass = "text-blue-600 dark:text-blue-400";
let iconClass = "text-blue-500";
let icon = "info";
if (isWarning) {
bgClass = "bg-yellow-500/10 border-yellow-500/30";
textClass = "text-yellow-600 dark:text-yellow-400";
iconClass = "text-yellow-500";
icon = "warning";
} else if (isError) {
bgClass = "bg-red-500/10 border-red-500/30";
textClass = "text-red-600 dark:text-red-400";
iconClass = "text-red-500";
icon = "error";
}
return (
<div key={index} className={`flex items-start gap-3 p-3 rounded-lg border ${bgClass}`}>
<span className={`material-symbols-outlined text-lg ${iconClass}`}>{icon}</span>
<p className={`text-sm ${textClass}`}>{note.text}</p>
</div>
);
})}
</div>
);
};
const canShowGuide = () => {
if (tool.requiresCloud && !cloudEnabled) return false;
return true;
};
const renderGuideSteps = () => {
if (!tool.guideSteps) return <p className="text-text-muted text-sm">Coming soon...</p>;
return (
<div className="flex flex-col gap-4">
{renderNotes()}
{canShowGuide() && tool.guideSteps.map((item) => (
<div key={item.step} className="flex items-start gap-4">
<div
className="size-8 rounded-full flex items-center justify-center shrink-0 text-sm font-semibold text-white"
style={{ backgroundColor: tool.color }}
>
{item.step}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-text">{item.title}</p>
{item.desc && <p className="text-sm text-text-muted mt-0.5">{item.desc}</p>}
{item.type === "apiKeySelector" && renderApiKeySelector()}
{item.type === "modelSelector" && renderModelSelector()}
{item.value && (
<div className="mt-2 flex items-center gap-2">
<code className="flex-1 px-3 py-2 bg-bg-secondary rounded-lg text-sm font-mono border border-border truncate">
{replaceVars(item.value)}
</code>
{item.copyable && (
<button
onClick={() => handleCopy(item.value, `${item.step}-${item.title}`)}
className="shrink-0 px-3 py-2 bg-bg-secondary hover:bg-bg-tertiary rounded-lg border border-border transition-colors"
>
<span className="material-symbols-outlined text-lg">
{copiedField === `${item.step}-${item.title}` ? "check" : "content_copy"}
</span>
</button>
)}
</div>
)}
</div>
</div>
))}
{canShowGuide() && tool.codeBlock && (
<div className="mt-2">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-text-muted uppercase tracking-wide">{tool.codeBlock.language}</span>
<button
onClick={() => handleCopy(tool.codeBlock.code, "codeblock")}
className="flex items-center gap-1 px-2 py-1 text-xs bg-bg-secondary hover:bg-bg-tertiary rounded border border-border transition-colors"
>
<span className="material-symbols-outlined text-sm">
{copiedField === "codeblock" ? "check" : "content_copy"}
</span>
{copiedField === "codeblock" ? "Copied!" : "Copy"}
</button>
</div>
<pre className="p-4 bg-bg-secondary rounded-lg border border-border overflow-x-auto">
<code className="text-sm font-mono whitespace-pre">{replaceVars(tool.codeBlock.code)}</code>
</pre>
</div>
)}
</div>
);
};
const renderIcon = () => {
if (tool.image) {
return (
<Image
src={tool.image}
alt={tool.name}
width={32}
height={32}
className="size-8 object-contain rounded-lg"
onError={(e) => { e.target.style.display = "none"; }}
/>
);
}
if (tool.icon) {
return <span className="material-symbols-outlined text-2xl" style={{ color: tool.color }}>{tool.icon}</span>;
}
return (
<Image
src={`/providers/${toolId}.png`}
alt={tool.name}
width={32}
height={32}
className="size-8 object-contain rounded-lg"
onError={(e) => { e.target.style.display = "none"; }}
/>
);
};
return (
<Card className="overflow-hidden">
<div className="flex items-center justify-between hover:cursor-pointer" onClick={onToggle}>
<div className="flex items-center gap-4">
<div className="size-12 rounded-xl flex items-center justify-center" style={{ backgroundColor: `${tool.color}15` }}>
{renderIcon()}
</div>
<div>
<h3 className="font-semibold text-lg">{tool.name}</h3>
<p className="text-sm text-text-muted">{tool.description}</p>
</div>
</div>
<span className={`material-symbols-outlined text-text-muted transition-transform ${isExpanded ? "rotate-180" : ""}`}>expand_more</span>
</div>
{isExpanded && (
<div className="mt-6 pt-6 border-t border-border">
{renderGuideSteps()}
</div>
)}
<ModelSelectModal
isOpen={showModelModal}
onClose={() => setShowModelModal(false)}
onSelect={handleSelectModel}
selectedModel={modelValue}
activeProviders={activeProviders}
title="Select Model"
/>
</Card>
);
}
@@ -0,0 +1,4 @@
export { default as ClaudeToolCard } from "./ClaudeToolCard";
export { default as CodexToolCard } from "./CodexToolCard";
export { default as DefaultToolCard } from "./DefaultToolCard";
@@ -0,0 +1,7 @@
import { getMachineId } from "@/shared/utils/machine";
import CLIToolsPageClient from "./CLIToolsPageClient";
export default async function CLIToolsPage() {
const machineId = await getMachineId();
return <CLIToolsPageClient machineId={machineId} />;
}
@@ -0,0 +1,434 @@
"use client";
import { useState, useEffect } from "react";
import { Card, Button, Modal, Input, CardSkeleton, ModelSelectModal } from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
// Validate combo name: only a-z, A-Z, 0-9, -, _
const VALID_NAME_REGEX = /^[a-zA-Z0-9_-]+$/;
export default function CombosPage() {
const [combos, setCombos] = useState([]);
const [loading, setLoading] = useState(true);
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingCombo, setEditingCombo] = useState(null);
const [activeProviders, setActiveProviders] = useState([]);
const { copied, copy } = useCopyToClipboard();
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
const [combosRes, providersRes] = await Promise.all([
fetch("/api/combos"),
fetch("/api/providers"),
]);
const combosData = await combosRes.json();
const providersData = await providersRes.json();
if (combosRes.ok) setCombos(combosData.combos || []);
if (providersRes.ok) {
const active = (providersData.connections || []).filter(
c => c.testStatus === "active" || c.testStatus === "success"
);
setActiveProviders(active);
}
} catch (error) {
console.log("Error fetching data:", error);
} finally {
setLoading(false);
}
};
const handleCreate = async (data) => {
try {
const res = await fetch("/api/combos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (res.ok) {
await fetchData();
setShowCreateModal(false);
} else {
const err = await res.json();
alert(err.error || "Failed to create combo");
}
} catch (error) {
console.log("Error creating combo:", error);
}
};
const handleUpdate = async (id, data) => {
try {
const res = await fetch(`/api/combos/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (res.ok) {
await fetchData();
setEditingCombo(null);
} else {
const err = await res.json();
alert(err.error || "Failed to update combo");
}
} catch (error) {
console.log("Error updating combo:", error);
}
};
const handleDelete = async (id) => {
if (!confirm("Delete this combo?")) return;
try {
const res = await fetch(`/api/combos/${id}`, { method: "DELETE" });
if (res.ok) {
setCombos(combos.filter(c => c.id !== id));
}
} catch (error) {
console.log("Error deleting combo:", error);
}
};
if (loading) {
return (
<div className="flex flex-col gap-6">
<CardSkeleton />
<CardSkeleton />
</div>
);
}
return (
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold">Combos</h1>
<p className="text-sm text-text-muted mt-1">
Create model combos with fallback support
</p>
</div>
<Button icon="add" onClick={() => setShowCreateModal(true)}>
Create Combo
</Button>
</div>
{/* Combos List */}
{combos.length === 0 ? (
<Card>
<div className="text-center py-12">
<span className="material-symbols-outlined text-5xl text-text-muted mb-3 block">
layers
</span>
<p className="text-text-muted mb-4">No combos yet</p>
<Button icon="add" onClick={() => setShowCreateModal(true)}>
Create your first combo
</Button>
</div>
</Card>
) : (
<div className="flex flex-col gap-4">
{combos.map((combo) => (
<ComboCard
key={combo.id}
combo={combo}
copied={copied}
onCopy={copy}
onEdit={() => setEditingCombo(combo)}
onDelete={() => handleDelete(combo.id)}
/>
))}
</div>
)}
{/* Create Modal - Use key to force remount and reset state */}
<ComboFormModal
key="create"
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
onSave={handleCreate}
activeProviders={activeProviders}
/>
{/* Edit Modal - Use key to force remount and reset state */}
<ComboFormModal
key={editingCombo?.id || "new"}
isOpen={!!editingCombo}
combo={editingCombo}
onClose={() => setEditingCombo(null)}
onSave={(data) => handleUpdate(editingCombo.id, data)}
activeProviders={activeProviders}
/>
</div>
);
}
function ComboCard({ combo, copied, onCopy, onEdit, onDelete }) {
return (
<Card>
<div className="flex items-start justify-between">
<div className="flex-1">
{/* Name + Copy */}
<div className="flex items-center gap-2 mb-3">
<span className="material-symbols-outlined text-primary">layers</span>
<code className="text-lg font-semibold font-mono">{combo.name}</code>
<button
onClick={() => onCopy(combo.name, `combo-${combo.id}`)}
className="p-1 hover:bg-sidebar rounded text-text-muted hover:text-primary"
title="Copy combo name"
>
<span className="material-symbols-outlined text-sm">
{copied === `combo-${combo.id}` ? "check" : "content_copy"}
</span>
</button>
</div>
{/* Models list */}
<div className="flex flex-col gap-1.5">
{combo.models.length === 0 ? (
<p className="text-sm text-text-muted italic">No models added</p>
) : (
combo.models.map((model, index) => (
<div key={index} className="flex items-center gap-2">
<span className="text-xs text-text-muted w-5">{index + 1}.</span>
<code className="text-sm font-mono bg-sidebar px-2 py-0.5 rounded">
{model}
</code>
{index === 0 && (
<span className="text-xs text-primary font-medium">Primary</span>
)}
{index > 0 && (
<span className="text-xs text-text-muted">Fallback</span>
)}
</div>
))
)}
</div>
</div>
{/* Actions */}
<div className="flex gap-1">
<button
onClick={onEdit}
className="p-2 hover:bg-sidebar rounded text-text-muted hover:text-primary"
title="Edit"
>
<span className="material-symbols-outlined text-lg">edit</span>
</button>
<button
onClick={onDelete}
className="p-2 hover:bg-red-50 rounded text-red-500"
title="Delete"
>
<span className="material-symbols-outlined text-lg">delete</span>
</button>
</div>
</div>
</Card>
);
}
function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
// Initialize state with combo values - key prop on parent handles reset on remount
const [name, setName] = useState(combo?.name || "");
const [models, setModels] = useState(combo?.models || []);
const [showModelSelect, setShowModelSelect] = useState(false);
const [saving, setSaving] = useState(false);
const [nameError, setNameError] = useState("");
const [modelAliases, setModelAliases] = useState({});
// Fetch model aliases when modal opens
useEffect(() => {
if (isOpen) {
const fetchModelAliases = async () => {
try {
const res = await fetch("/api/models/alias");
const data = await res.json();
if (res.ok) setModelAliases(data.aliases || {});
} catch (error) {
console.log("Error fetching model aliases:", error);
}
};
fetchModelAliases();
}
}, [isOpen]);
const validateName = (value) => {
if (!value.trim()) {
setNameError("Name is required");
return false;
}
if (!VALID_NAME_REGEX.test(value)) {
setNameError("Only letters, numbers, - and _ allowed");
return false;
}
setNameError("");
return true;
};
const handleNameChange = (e) => {
const value = e.target.value;
setName(value);
if (value) validateName(value);
else setNameError("");
};
const handleAddModel = (model) => {
if (!models.includes(model.value)) {
setModels([...models, model.value]);
}
};
const handleRemoveModel = (index) => {
setModels(models.filter((_, i) => i !== index));
};
const handleModelChange = (index, value) => {
const newModels = [...models];
newModels[index] = value;
setModels(newModels);
};
const handleMoveUp = (index) => {
if (index === 0) return;
const newModels = [...models];
[newModels[index - 1], newModels[index]] = [newModels[index], newModels[index - 1]];
setModels(newModels);
};
const handleMoveDown = (index) => {
if (index === models.length - 1) return;
const newModels = [...models];
[newModels[index], newModels[index + 1]] = [newModels[index + 1], newModels[index]];
setModels(newModels);
};
const handleSave = async () => {
if (!validateName(name)) return;
setSaving(true);
await onSave({ name: name.trim(), models });
setSaving(false);
};
const isEdit = !!combo;
return (
<>
<Modal
isOpen={isOpen}
onClose={onClose}
title={isEdit ? "Edit Combo" : "Create Combo"}
size="md"
>
<div className="flex flex-col gap-4">
{/* Name */}
<div>
<Input
label="Combo Name"
value={name}
onChange={handleNameChange}
placeholder="my-combo"
error={nameError}
/>
<p className="text-xs text-text-muted mt-1">
Only letters, numbers, - and _ allowed
</p>
</div>
{/* Models */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium">Models</label>
<Button
size="sm"
variant="secondary"
icon="add"
onClick={() => setShowModelSelect(true)}
>
Add Model
</Button>
</div>
{models.length === 0 ? (
<div className="text-center py-6 border border-dashed border-border rounded-lg">
<p className="text-sm text-text-muted">No models added</p>
<p className="text-xs text-text-muted mt-1">Click &quot;Add Model&quot; to add</p>
</div>
) : (
<div className="flex flex-col gap-2 max-h-[240px] overflow-y-auto">
{models.map((model, index) => (
<div
key={index}
className="flex items-center gap-2"
>
{/* Priority arrows */}
<div className="flex flex-col gap-0">
<button
onClick={() => handleMoveUp(index)}
disabled={index === 0}
className={`p-0.5 rounded ${index === 0 ? "text-text-muted/30" : "hover:bg-surface text-text-muted hover:text-primary"}`}
>
<span className="material-symbols-outlined text-sm leading-none">keyboard_arrow_up</span>
</button>
<button
onClick={() => handleMoveDown(index)}
disabled={index === models.length - 1}
className={`p-0.5 rounded ${index === models.length - 1 ? "text-text-muted/30" : "hover:bg-surface text-text-muted hover:text-primary"}`}
>
<span className="material-symbols-outlined text-sm leading-none">keyboard_arrow_down</span>
</button>
</div>
{/* Model Input */}
<Input
value={model}
onChange={(e) => handleModelChange(index, e.target.value)}
placeholder="model-name"
className="flex-1"
/>
{/* Remove */}
<button
onClick={() => handleRemoveModel(index)}
className="p-2 hover:bg-red-50 rounded text-red-500"
>
<span className="material-symbols-outlined text-sm">close</span>
</button>
</div>
))}
</div>
)}
</div>
{/* Actions */}
<div className="flex gap-2 pt-2">
<Button
onClick={handleSave}
fullWidth
disabled={!name.trim() || !!nameError || saving}
>
{saving ? "Saving..." : "Apply"}
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
Cancel
</Button>
</div>
</div>
</Modal>
{/* Model Select Modal */}
<ModelSelectModal
isOpen={showModelSelect}
onClose={() => setShowModelSelect(false)}
onSelect={handleAddModel}
activeProviders={activeProviders}
modelAliases={modelAliases}
title="Add Model to Combo"
/>
</>
);
}
@@ -0,0 +1,605 @@
"use client";
import { useState, useEffect } from "react";
import { Card, Button, Input, Modal, CardSkeleton } from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
export default function APIPageClient({ machineId }) {
const [keys, setKeys] = useState([]);
const [loading, setLoading] = useState(true);
const [showAddModal, setShowAddModal] = useState(false);
const [newKeyName, setNewKeyName] = useState("");
const [createdKey, setCreatedKey] = useState(null);
// Cloud sync state
const [cloudEnabled, setCloudEnabled] = useState(false);
const [showCloudModal, setShowCloudModal] = useState(false);
const [showDisableModal, setShowDisableModal] = useState(false);
const [cloudSyncing, setCloudSyncing] = useState(false);
const [cloudStatus, setCloudStatus] = useState(null);
const [syncStep, setSyncStep] = useState(""); // "syncing" | "verifying" | "disabling" | ""
const { copied, copy } = useCopyToClipboard();
useEffect(() => {
fetchData();
loadCloudSettings();
}, []);
const loadCloudSettings = async () => {
try {
const res = await fetch("/api/settings");
if (res.ok) {
const data = await res.json();
setCloudEnabled(data.cloudEnabled || false);
}
} catch (error) {
console.log("Error loading cloud settings:", error);
}
};
const fetchData = async () => {
try {
const keysRes = await fetch("/api/keys");
const keysData = await keysRes.json();
if (keysRes.ok) {
setKeys(keysData.keys || []);
}
} catch (error) {
console.log("Error fetching data:", error);
} finally {
setLoading(false);
}
};
const handleCloudToggle = (checked) => {
if (checked) {
setShowCloudModal(true);
} else {
setShowDisableModal(true);
}
};
const handleEnableCloud = async () => {
setCloudSyncing(true);
setSyncStep("syncing");
try {
const res = await fetch("/api/sync/cloud", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "enable" })
});
const data = await res.json();
if (res.ok) {
setSyncStep("verifying");
if (data.verified) {
setCloudEnabled(true);
setCloudStatus({ type: "success", message: "Cloud Proxy connected and verified!" });
setShowCloudModal(false);
} else {
setCloudEnabled(true);
setCloudStatus({
type: "warning",
message: data.verifyError || "Connected but verification failed"
});
setShowCloudModal(false);
}
// Refresh keys list if new key was created
if (data.createdKey) {
await fetchData();
}
} else {
setCloudStatus({ type: "error", message: data.error || "Failed to enable cloud" });
}
} catch (error) {
setCloudStatus({ type: "error", message: error.message });
} finally {
setCloudSyncing(false);
setSyncStep("");
}
};
const handleConfirmDisable = async () => {
setCloudSyncing(true);
setSyncStep("syncing");
try {
// Step 1: Sync latest data from cloud
const syncRes = await fetch("/api/sync/cloud", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "sync" })
});
setSyncStep("disabling");
// Step 2: Disable cloud
const disableRes = await fetch("/api/sync/cloud", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "disable" })
});
if (disableRes.ok) {
setCloudEnabled(false);
setCloudStatus({ type: "success", message: "Cloud disabled" });
setShowDisableModal(false);
}
} catch (error) {
console.log("Error disabling cloud:", error);
setCloudStatus({ type: "error", message: "Failed to disable cloud" });
} finally {
setCloudSyncing(false);
setSyncStep("");
}
};
const handleSyncCloud = async () => {
if (!cloudEnabled) return;
setCloudSyncing(true);
try {
const res = await fetch("/api/sync/cloud", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "sync" })
});
const data = await res.json();
if (res.ok) {
setCloudStatus({ type: "success", message: "Synced successfully" });
} else {
setCloudStatus({ type: "error", message: data.error });
}
} catch (error) {
setCloudStatus({ type: "error", message: error.message });
} finally {
setCloudSyncing(false);
}
};
const handleCreateKey = async () => {
if (!newKeyName.trim()) return;
try {
const res = await fetch("/api/keys", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: newKeyName }),
});
const data = await res.json();
if (res.ok) {
setCreatedKey(data.key);
await fetchData();
setNewKeyName("");
setShowAddModal(false);
}
} catch (error) {
console.log("Error creating key:", error);
}
};
const handleDeleteKey = async (id) => {
if (!confirm("Delete this API key?")) return;
try {
const res = await fetch(`/api/keys/${id}`, { method: "DELETE" });
if (res.ok) {
setKeys(keys.filter((k) => k.id !== id));
}
} catch (error) {
console.log("Error deleting key:", error);
}
};
const isLocalhost = typeof window !== "undefined" &&
(window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1");
const baseUrl = typeof window !== "undefined" ? `${window.location.origin}/v1` : "/v1";
const localApiKey = "HELLO";
// New format: /v1 (machineId in key), Old format: /{machineId}/v1
const cloudEndpointNew = `${CLOUD_URL}/v1`;
const cloudEndpointOld = `${CLOUD_URL}/${machineId}/v1`;
if (loading) {
return (
<div className="flex flex-col gap-8">
<CardSkeleton />
<CardSkeleton />
</div>
);
}
// Use new format endpoint (machineId embedded in key)
const currentEndpoint = cloudEnabled ? cloudEndpointNew : baseUrl;
const cloudBenefits = [
{ icon: "public", title: "Access Anywhere", desc: "No port forwarding needed" },
{ icon: "group", title: "Share Endpoint", desc: "Easy team collaboration" },
{ icon: "schedule", title: "Always Online", desc: "24/7 availability" },
{ icon: "speed", title: "Global Edge", desc: "Fast worldwide access" },
];
return (
<div className="flex flex-col gap-8">
{/* Endpoint Card */}
<Card className={cloudEnabled ? "" : ""}>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold">API Endpoint</h2>
<p className="text-sm text-text-muted">
{cloudEnabled ? "Using Cloud Proxy" : "Using Local Server"}
</p>
</div>
<div className="flex items-center gap-2">
{cloudEnabled ? (
<Button
size="sm"
variant="secondary"
icon="cloud_off"
onClick={() => handleCloudToggle(false)}
disabled={cloudSyncing}
className="!bg-red-500/10 !text-red-500 hover:!bg-red-500/20 !border-red-500/30"
>
Disable Cloud
</Button>
) : (
<Button
variant="primary"
icon="cloud_upload"
onClick={() => handleCloudToggle(true)}
disabled={cloudSyncing}
className="bg-gradient-to-r from-primary to-blue-500 hover:from-primary-hover hover:to-blue-600"
>
Enable Cloud
</Button>
)}
</div>
</div>
{/* Endpoint URL */}
<div className="flex gap-2 mb-3">
<Input
value={currentEndpoint}
readOnly
className={`flex-1 font-mono text-sm ${cloudEnabled ? "animate-border-glow" : ""}`}
/>
<Button
variant="secondary"
icon={copied === "endpoint_url" ? "check" : "content_copy"}
onClick={() => copy(currentEndpoint, "endpoint_url")}
>
{copied === "endpoint_url" ? "Copied!" : "Copy"}
</Button>
</div>
</Card>
{/* API Keys */}
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">API Keys</h2>
<Button icon="add" onClick={() => setShowAddModal(true)}>
Create Key
</Button>
</div>
{keys.length === 0 ? (
<div className="text-center py-8">
<span className="material-symbols-outlined text-4xl text-text-muted mb-2">
vpn_key
</span>
<p className="text-sm text-text-muted">No API keys yet</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border">
<th className="text-left p-3 font-medium">Name</th>
<th className="text-left p-3 font-medium">Key</th>
<th className="text-left p-3 font-medium">Created</th>
<th className="text-left p-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{keys.map((key) => (
<tr key={key.id} className="border-b border-border hover:bg-sidebar/30">
<td className="p-3 text-sm">{key.name}</td>
<td className="p-3">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-text-muted">
{key.key}
</span>
<Button
size="sm"
variant="ghost"
icon={copied === key.id ? "check" : "content_copy"}
onClick={() => copy(key.key, key.id)}
/>
</div>
</td>
<td className="p-3 text-sm text-text-muted">
{new Date(key.createdAt).toLocaleDateString()}
</td>
<td className="p-3">
<Button
size="sm"
variant="ghost"
icon="delete"
className="text-red-500"
onClick={() => handleDeleteKey(key.id)}
>
Delete
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
{/* Cloud Proxy Card - Hidden */}
{false && (
<Card className={cloudEnabled ? "bg-primary/5" : ""}>
<div className="flex flex-col gap-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`p-2 rounded-lg ${cloudEnabled ? "bg-primary text-white" : "bg-sidebar text-text-muted"}`}>
<span className="material-symbols-outlined text-xl">cloud</span>
</div>
<div>
<h2 className="text-lg font-semibold">Cloud Proxy</h2>
<p className="text-xs text-text-muted">
{cloudEnabled ? "Connected & Ready" : "Access your API from anywhere"}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{cloudEnabled ? (
<Button
size="sm"
variant="secondary"
icon="cloud_off"
onClick={() => handleCloudToggle(false)}
disabled={cloudSyncing}
className="!bg-red-500/10 !text-red-500 hover:!bg-red-500/20 !border-red-500/30"
>
Disable
</Button>
) : (
<Button
variant="primary"
icon="cloud_upload"
onClick={() => handleCloudToggle(true)}
disabled={cloudSyncing}
className="bg-gradient-to-r from-primary to-blue-500 hover:from-primary-hover hover:to-blue-600 px-6"
>
Enable Cloud
</Button>
)}
</div>
</div>
{/* Benefits Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{cloudBenefits.map((benefit) => (
<div key={benefit.title} className="flex flex-col items-center text-center p-3 rounded-lg bg-sidebar/50">
<span className="material-symbols-outlined text-xl text-primary mb-1">{benefit.icon}</span>
<p className="text-xs font-semibold">{benefit.title}</p>
<p className="text-xs text-text-muted">{benefit.desc}</p>
</div>
))}
</div>
</div>
</Card>
)}
{/* Cloud Enable Modal */}
<Modal
isOpen={showCloudModal}
title="Enable Cloud Proxy"
onClose={() => setShowCloudModal(false)}
>
<div className="flex flex-col gap-4">
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<p className="text-sm text-blue-800 dark:text-blue-200 font-medium mb-2">
What you will get
</p>
<ul className="text-sm text-blue-700 dark:text-blue-300 space-y-1">
<li>• Access your API from anywhere in the world</li>
<li>• Share endpoint with your team easily</li>
<li>• No need to open ports or configure firewall</li>
<li>• Fast global edge network</li>
</ul>
</div>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<p className="text-sm text-yellow-800 dark:text-yellow-200 font-medium mb-1">
Note
</p>
<ul className="text-sm text-yellow-700 dark:text-yellow-300 space-y-1">
<li>• Cloud will keep your auth session for 1 day. If not used, it will be automatically deleted.</li>
<li>• Cloud is currently unstable with Claude Code OAuth in some cases.</li>
</ul>
</div>
{/* Sync Progress */}
{cloudSyncing && (
<div className="flex items-center gap-3 p-3 bg-primary/10 border border-primary/30 rounded-lg">
<span className="material-symbols-outlined animate-spin text-primary">progress_activity</span>
<div className="flex-1">
<p className="text-sm font-medium text-primary">
{syncStep === "syncing" && "Syncing data to cloud..."}
{syncStep === "verifying" && "Verifying connection..."}
</p>
</div>
</div>
)}
<div className="flex gap-2">
<Button
onClick={handleEnableCloud}
fullWidth
disabled={cloudSyncing}
>
{cloudSyncing ? (
<span className="flex items-center gap-2">
<span className="material-symbols-outlined animate-spin text-sm">progress_activity</span>
{syncStep === "syncing" ? "Syncing..." : "Verifying..."}
</span>
) : "Enable Cloud"}
</Button>
<Button
onClick={() => setShowCloudModal(false)}
variant="ghost"
fullWidth
disabled={cloudSyncing}
>
Cancel
</Button>
</div>
</div>
</Modal>
{/* Add Key Modal */}
<Modal
isOpen={showAddModal}
title="Create API Key"
onClose={() => {
setShowAddModal(false);
setNewKeyName("");
}}
>
<div className="flex flex-col gap-4">
<Input
label="Key Name"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="Production Key"
/>
<div className="flex gap-2">
<Button onClick={handleCreateKey} fullWidth disabled={!newKeyName.trim()}>
Create
</Button>
<Button
onClick={() => {
setShowAddModal(false);
setNewKeyName("");
}}
variant="ghost"
fullWidth
>
Cancel
</Button>
</div>
</div>
</Modal>
{/* Created Key Modal */}
<Modal
isOpen={!!createdKey}
title="API Key Created"
onClose={() => setCreatedKey(null)}
>
<div className="flex flex-col gap-4">
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<p className="text-sm text-yellow-800 dark:text-yellow-200 mb-2 font-medium">
Save this key now!
</p>
<p className="text-sm text-yellow-700 dark:text-yellow-300">
This is the only time you will see this key. Store it securely.
</p>
</div>
<div className="flex gap-2">
<Input
value={createdKey || ""}
readOnly
className="flex-1 font-mono text-sm"
/>
<Button
variant="secondary"
icon={copied === "created_key" ? "check" : "content_copy"}
onClick={() => copy(createdKey, "created_key")}
>
{copied === "created_key" ? "Copied!" : "Copy"}
</Button>
</div>
<Button onClick={() => setCreatedKey(null)} fullWidth>
Done
</Button>
</div>
</Modal>
{/* Disable Cloud Modal */}
<Modal
isOpen={showDisableModal}
title="Disable Cloud Proxy"
onClose={() => !cloudSyncing && setShowDisableModal(false)}
>
<div className="flex flex-col gap-4">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-red-600 dark:text-red-400">warning</span>
<div>
<p className="text-sm text-red-800 dark:text-red-200 font-medium mb-1">
Warning
</p>
<p className="text-sm text-red-700 dark:text-red-300">
All auth sessions will be deleted from cloud.
</p>
</div>
</div>
</div>
{/* Sync Progress */}
{cloudSyncing && (
<div className="flex items-center gap-3 p-3 bg-primary/10 border border-primary/30 rounded-lg">
<span className="material-symbols-outlined animate-spin text-primary">progress_activity</span>
<div className="flex-1">
<p className="text-sm font-medium text-primary">
{syncStep === "syncing" && "Syncing latest data..."}
{syncStep === "disabling" && "Disabling cloud..."}
</p>
</div>
</div>
)}
<p className="text-sm text-text-muted">Are you sure you want to disable cloud proxy?</p>
<div className="flex gap-2">
<Button
onClick={handleConfirmDisable}
fullWidth
disabled={cloudSyncing}
className="!bg-red-500 hover:!bg-red-600 !text-white"
>
{cloudSyncing ? (
<span className="flex items-center gap-2">
<span className="material-symbols-outlined animate-spin text-sm">progress_activity</span>
{syncStep === "syncing" ? "Syncing..." : "Disabling..."}
</span>
) : "Disable Cloud"}
</Button>
<Button
onClick={() => setShowDisableModal(false)}
variant="ghost"
fullWidth
disabled={cloudSyncing}
>
Cancel
</Button>
</div>
</div>
</Modal>
</div>
);
}
@@ -0,0 +1,7 @@
import { getMachineId } from "@/shared/utils/machine";
import EndpointPageClient from "./EndpointPageClient";
export default async function EndpointPage() {
const machineId = await getMachineId();
return <EndpointPageClient machineId={machineId} />;
}
+7
View File
@@ -0,0 +1,7 @@
import { getMachineId } from "@/shared/utils/machine";
import EndpointPageClient from "./endpoint/EndpointPageClient";
export default async function DashboardPage() {
const machineId = await getMachineId();
return <EndpointPageClient machineId={machineId} />;
}
@@ -0,0 +1,95 @@
"use client";
import { Card, Button, Badge, Toggle } from "@/shared/components";
import { useTheme } from "@/shared/hooks/useTheme";
import { APP_CONFIG } from "@/shared/constants/config";
export default function ProfilePage() {
const { theme, setTheme, isDark } = useTheme();
return (
<div className="max-w-2xl mx-auto">
<div className="flex flex-col gap-6">
{/* Local Mode Info */}
<Card>
<div className="flex items-center gap-4 mb-4">
<div className="size-12 rounded-lg bg-green-500/10 text-green-500 flex items-center justify-center">
<span className="material-symbols-outlined text-2xl">computer</span>
</div>
<div>
<h2 className="text-xl font-semibold">Local Mode</h2>
<p className="text-text-muted">Running on your machine</p>
</div>
</div>
<div className="pt-4 border-t border-border">
<p className="text-sm text-text-muted">
All data is stored locally in the <code className="bg-sidebar px-1 rounded">data/db.json</code> file.
</p>
</div>
</Card>
{/* Theme Preferences */}
<Card>
<h3 className="text-lg font-semibold mb-4">Appearance</h3>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Dark Mode</p>
<p className="text-sm text-text-muted">
Switch between light and dark themes
</p>
</div>
<Toggle
checked={isDark}
onChange={() => setTheme(isDark ? "light" : "dark")}
/>
</div>
{/* Theme Options */}
<div className="flex gap-3 pt-4 border-t border-border">
{["light", "dark", "system"].map((option) => (
<button
key={option}
onClick={() => setTheme(option)}
className={`flex-1 flex flex-col items-center gap-2 p-4 rounded-lg border transition-all ${
theme === option
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50"
}`}
>
<span className="material-symbols-outlined text-2xl">
{option === "light"
? "light_mode"
: option === "dark"
? "dark_mode"
: "contrast"}
</span>
<span className="text-sm font-medium capitalize">{option}</span>
</button>
))}
</div>
</div>
</Card>
{/* Data Management */}
<Card>
<h3 className="text-lg font-semibold mb-4">Data</h3>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between p-4 rounded-lg bg-bg border border-border">
<div>
<p className="font-medium">Database Location</p>
<p className="text-sm text-text-muted font-mono">~/9router/data/db.json</p>
</div>
</div>
</div>
</Card>
{/* App Info */}
<div className="text-center text-sm text-text-muted py-4">
<p>{APP_CONFIG.name} v{APP_CONFIG.version}</p>
<p className="mt-1">Local Mode - All data stored on your machine</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,764 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal } from "@/shared/components";
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, getProviderAlias } from "@/shared/constants/providers";
import { getModelsByProviderId } from "@/shared/constants/models";
import { PROVIDER_ENDPOINTS } from "@/shared/constants/config";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
export default function ProviderDetailPage() {
const params = useParams();
const providerId = params.id;
const [connections, setConnections] = useState([]);
const [loading, setLoading] = useState(true);
const [showOAuthModal, setShowOAuthModal] = useState(false);
const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [selectedConnection, setSelectedConnection] = useState(null);
const [modelAliases, setModelAliases] = useState({});
const { copied, copy } = useCopyToClipboard();
const providerInfo = OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId];
const isOAuth = !!OAUTH_PROVIDERS[providerId];
const models = getModelsByProviderId(providerId);
const providerAlias = getProviderAlias(providerId);
useEffect(() => {
fetchConnections();
fetchAliases();
}, [providerId]);
const fetchAliases = async () => {
try {
const res = await fetch("/api/models/alias");
const data = await res.json();
if (res.ok) {
setModelAliases(data.aliases || {});
}
} catch (error) {
console.log("Error fetching aliases:", error);
}
};
const handleSetAlias = async (modelId, alias) => {
const fullModel = `${providerAlias}/${modelId}`;
try {
const res = await fetch("/api/models/alias", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: fullModel, alias }),
});
if (res.ok) {
await fetchAliases();
} else {
const data = await res.json();
alert(data.error || "Failed to set alias");
}
} catch (error) {
console.log("Error setting alias:", error);
}
};
const handleDeleteAlias = async (alias) => {
try {
const res = await fetch(`/api/models/alias?alias=${encodeURIComponent(alias)}`, {
method: "DELETE",
});
if (res.ok) {
await fetchAliases();
}
} catch (error) {
console.log("Error deleting alias:", error);
}
};
const fetchConnections = 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);
}
};
const handleDelete = async (id) => {
if (!confirm("Delete this connection?")) return;
try {
const res = await fetch(`/api/providers/${id}`, { method: "DELETE" });
if (res.ok) {
setConnections(connections.filter(c => c.id !== id));
}
} catch (error) {
console.log("Error deleting connection:", error);
}
};
const handleOAuthSuccess = () => {
fetchConnections();
setShowOAuthModal(false);
};
const handleSaveApiKey = async (formData) => {
try {
const res = await fetch("/api/providers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider: providerId, ...formData }),
});
if (res.ok) {
await fetchConnections();
setShowAddApiKeyModal(false);
}
} catch (error) {
console.log("Error saving connection:", error);
}
};
const handleUpdateConnection = async (formData) => {
try {
const res = await fetch(`/api/providers/${selectedConnection.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
});
if (res.ok) {
await fetchConnections();
setShowEditModal(false);
}
} catch (error) {
console.log("Error updating connection:", error);
}
};
const handleSwapPriority = async (conn1, conn2) => {
if (!conn1 || !conn2) return;
try {
// Swap priorities
await Promise.all([
fetch(`/api/providers/${conn1.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ priority: conn2.priority }),
}),
fetch(`/api/providers/${conn2.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ priority: conn1.priority }),
}),
]);
await fetchConnections();
} catch (error) {
console.log("Error swapping priority:", error);
}
};
if (!providerInfo) {
return (
<div className="text-center py-20">
<p className="text-text-muted">Provider not found</p>
<Link href="/dashboard/providers" className="text-primary mt-4 inline-block">
Back to Providers
</Link>
</div>
);
}
if (loading) {
return (
<div className="flex flex-col gap-8">
<CardSkeleton />
<CardSkeleton />
</div>
);
}
return (
<div className="flex flex-col gap-8">
{/* Header */}
<div>
<Link
href="/dashboard/providers"
className="inline-flex items-center gap-1 text-sm text-text-muted hover:text-primary transition-colors mb-4"
>
<span className="material-symbols-outlined text-lg">arrow_back</span>
Back to Providers
</Link>
<div className="flex items-center gap-4">
<div
className="rounded-lg flex items-center justify-center"
style={{ backgroundColor: `${providerInfo.color}15` }}
>
<img
src={`/providers/${providerInfo.id}.png`}
alt={providerInfo.name}
className="size-12 object-contain rounded-lg"
onError={(e) => { e.target.style.display = "none"; }}
/>
</div>
<div>
<h1 className="text-3xl font-semibold tracking-tight">{providerInfo.name}</h1>
<p className="text-text-muted">
{connections.length} connection{connections.length !== 1 ? "s" : ""}
</p>
</div>
</div>
</div>
{/* Connections */}
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold">Connections</h2>
<Button
size="sm"
icon="add"
onClick={() => isOAuth ? setShowOAuthModal(true) : setShowAddApiKeyModal(true)}
>
Add
</Button>
</div>
{connections.length === 0 ? (
<div className="text-center py-8">
<span className="material-symbols-outlined text-4xl text-text-muted mb-2">
{isOAuth ? "lock" : "key"}
</span>
<p className="text-sm text-text-muted">No connections yet</p>
</div>
) : (
<div className="flex flex-col gap-2">
{connections
.sort((a, b) => (a.priority || 0) - (b.priority || 0))
.map((conn, index) => (
<ConnectionRow
key={conn.id}
connection={conn}
isOAuth={isOAuth}
isFirst={index === 0}
isLast={index === connections.length - 1}
onMoveUp={() => handleSwapPriority(conn, connections[index - 1])}
onMoveDown={() => handleSwapPriority(conn, connections[index + 1])}
onEdit={() => {
setSelectedConnection(conn);
setShowEditModal(true);
}}
onDelete={() => handleDelete(conn.id)}
/>
))}
</div>
)}
</Card>
{/* Models */}
<Card>
<h2 className="text-lg font-semibold mb-4">
{providerInfo.passthroughModels ? "Model Aliases" : "Available Models"}
</h2>
{providerInfo.passthroughModels ? (
<PassthroughModelsSection
providerAlias={providerAlias}
modelAliases={modelAliases}
copied={copied}
onCopy={copy}
onSetAlias={handleSetAlias}
onDeleteAlias={handleDeleteAlias}
/>
) : models.length === 0 ? (
<p className="text-sm text-text-muted">No models configured</p>
) : (
<div className="flex flex-wrap gap-3">
{models.map((model) => {
const fullModel = `${providerAlias}/${model.id}`;
// Also check for old format (providerId/model) for backward compatibility
const oldFormatModel = `${providerId}/${model.id}`;
const existingAlias = Object.entries(modelAliases).find(
([, m]) => m === fullModel || m === oldFormatModel
)?.[0];
return (
<ModelRow
key={model.id}
model={model}
fullModel={fullModel}
alias={existingAlias}
copied={copied}
onCopy={copy}
onSetAlias={(alias) => handleSetAlias(model.id, alias)}
onDeleteAlias={() => handleDeleteAlias(existingAlias)}
/>
);
})}
</div>
)}
</Card>
{/* Modals */}
<OAuthModal
isOpen={showOAuthModal}
provider={providerId}
providerInfo={providerInfo}
onSuccess={handleOAuthSuccess}
onClose={() => setShowOAuthModal(false)}
/>
<AddApiKeyModal
isOpen={showAddApiKeyModal}
provider={providerId}
onSave={handleSaveApiKey}
onClose={() => setShowAddApiKeyModal(false)}
/>
<EditConnectionModal
isOpen={showEditModal}
connection={selectedConnection}
onSave={handleUpdateConnection}
onClose={() => setShowEditModal(false)}
/>
</div>
);
}
function ModelRow({ model, fullModel, alias, copied, onCopy }) {
return (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg border border-border hover:bg-sidebar/50">
<span className="material-symbols-outlined text-base text-text-muted">smart_toy</span>
<code className="text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">{fullModel}</code>
<button
onClick={() => onCopy(fullModel, `model-${model.id}`)}
className="p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary"
title="Copy model"
>
<span className="material-symbols-outlined text-sm">
{copied === `model-${model.id}` ? "check" : "content_copy"}
</span>
</button>
</div>
);
}
function PassthroughModelsSection({ providerAlias, modelAliases, copied, onCopy, onSetAlias, onDeleteAlias }) {
const [newModel, setNewModel] = useState("");
const [adding, setAdding] = useState(false);
// Filter aliases for this provider - models are persisted via alias
const providerAliases = Object.entries(modelAliases).filter(
([, model]) => model.startsWith(`${providerAlias}/`)
);
const allModels = providerAliases.map(([alias, fullModel]) => ({
modelId: fullModel.replace(`${providerAlias}/`, ""),
fullModel,
alias,
}));
// Generate default alias from modelId (last part after /)
const generateDefaultAlias = (modelId) => {
const parts = modelId.split("/");
return parts[parts.length - 1];
};
const handleAdd = async () => {
if (!newModel.trim() || adding) return;
const modelId = newModel.trim();
const defaultAlias = generateDefaultAlias(modelId);
// Check if alias already exists
if (modelAliases[defaultAlias]) {
alert(`Alias "${defaultAlias}" already exists. Please use a different model or edit existing alias.`);
return;
}
setAdding(true);
try {
await onSetAlias(modelId, defaultAlias);
setNewModel("");
} catch (error) {
console.log("Error adding model:", error);
} finally {
setAdding(false);
}
};
return (
<div className="flex flex-col gap-4">
<p className="text-sm text-text-muted">
OpenRouter supports any model. Add models and create aliases for quick access.
</p>
{/* Add new model */}
<div className="flex items-end gap-2">
<div className="flex-1">
<label className="text-xs text-text-muted mb-1 block">Model ID (from OpenRouter)</label>
<input
type="text"
value={newModel}
onChange={(e) => setNewModel(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
placeholder="anthropic/claude-3-opus"
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
/>
</div>
<Button size="sm" icon="add" onClick={handleAdd} disabled={!newModel.trim() || adding}>
{adding ? "Adding..." : "Add"}
</Button>
</div>
{/* Models list */}
{allModels.length > 0 && (
<div className="flex flex-col gap-3">
{allModels.map(({ modelId, fullModel, alias }) => (
<PassthroughModelRow
key={fullModel}
modelId={modelId}
fullModel={fullModel}
copied={copied}
onCopy={onCopy}
onDeleteAlias={() => onDeleteAlias(alias)}
/>
))}
</div>
)}
</div>
);
}
function PassthroughModelRow({ modelId, fullModel, copied, onCopy, onDeleteAlias }) {
return (
<div className="flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-sidebar/50">
<span className="material-symbols-outlined text-base text-text-muted">smart_toy</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{modelId}</p>
<div className="flex items-center gap-1 mt-1">
<code className="text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">{fullModel}</code>
<button
onClick={() => onCopy(fullModel, `model-${modelId}`)}
className="p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary"
title="Copy model"
>
<span className="material-symbols-outlined text-sm">
{copied === `model-${modelId}` ? "check" : "content_copy"}
</span>
</button>
</div>
</div>
{/* Delete button */}
<button
onClick={onDeleteAlias}
className="p-1 hover:bg-red-50 rounded text-red-500"
title="Remove model"
>
<span className="material-symbols-outlined text-sm">delete</span>
</button>
</div>
);
}
function CooldownTimer({ until }) {
const [remaining, setRemaining] = useState("");
useEffect(() => {
const updateRemaining = () => {
const diff = new Date(until).getTime() - Date.now();
if (diff <= 0) {
setRemaining("");
return;
}
const secs = Math.floor(diff / 1000);
if (secs < 60) {
setRemaining(`${secs}s`);
} else if (secs < 3600) {
setRemaining(`${Math.floor(secs / 60)}m ${secs % 60}s`);
} else {
const hrs = Math.floor(secs / 3600);
const mins = Math.floor((secs % 3600) / 60);
setRemaining(`${hrs}h ${mins}m`);
}
};
updateRemaining();
const interval = setInterval(updateRemaining, 1000);
return () => clearInterval(interval);
}, [until]);
if (!remaining) return null;
return (
<span className="text-xs text-orange-500 font-mono">
⏱ {remaining}
</span>
);
}
function ConnectionRow({ connection, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onEdit, onDelete }) {
const displayName = isOAuth
? connection.name || connection.email || connection.displayName || "OAuth Account"
: connection.name;
// Use useState + useEffect for impure Date.now() to avoid calling during render
const [isCooldown, setIsCooldown] = useState(false);
useEffect(() => {
const checkCooldown = () => {
const cooldown = connection.rateLimitedUntil &&
new Date(connection.rateLimitedUntil).getTime() > Date.now();
setIsCooldown(cooldown);
};
checkCooldown();
// Update every second while in cooldown
const interval = connection.rateLimitedUntil ? setInterval(checkCooldown, 1000) : null;
return () => {
if (interval) clearInterval(interval);
};
}, [connection.rateLimitedUntil]);
// Determine effective status (override unavailable if cooldown expired)
const effectiveStatus = (connection.testStatus === "unavailable" && !isCooldown)
? "active" // Cooldown expired → treat as active
: connection.testStatus;
const getStatusVariant = () => {
if (effectiveStatus === "active" || effectiveStatus === "success") return "success";
if (effectiveStatus === "error" || effectiveStatus === "expired" || effectiveStatus === "unavailable") return "error";
return "default";
};
const hasError = effectiveStatus === "error" || effectiveStatus === "expired" || effectiveStatus === "unavailable";
return (
<div className="flex items-center justify-between p-3 rounded-lg border border-border hover:bg-sidebar/50 hover:cursor-pointer">
<div className="flex items-center gap-3 flex-1 min-w-0">
{/* Priority arrows */}
<div className="flex flex-col">
<button
onClick={onMoveUp}
disabled={isFirst}
className={`p-0.5 rounded ${isFirst ? "text-text-muted/30 cursor-not-allowed" : "hover:bg-sidebar text-text-muted hover:text-primary"}`}
>
<span className="material-symbols-outlined text-sm">keyboard_arrow_up</span>
</button>
<button
onClick={onMoveDown}
disabled={isLast}
className={`p-0.5 rounded ${isLast ? "text-text-muted/30 cursor-not-allowed" : "hover:bg-sidebar text-text-muted hover:text-primary"}`}
>
<span className="material-symbols-outlined text-sm">keyboard_arrow_down</span>
</button>
</div>
<span className="material-symbols-outlined text-base text-text-muted">
{isOAuth ? "lock" : "key"}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{displayName}</p>
<div className="flex items-center gap-2 mt-1">
<Badge variant={getStatusVariant()} size="sm" dot>
{effectiveStatus || "Unknown"}
</Badge>
{isCooldown && <CooldownTimer until={connection.rateLimitedUntil} />}
{connection.lastError && (
<span className="text-xs text-red-500 truncate max-w-[300px]" title={connection.lastError}>
{connection.lastError}
</span>
)}
<span className="text-xs text-text-muted">#{connection.priority}</span>
{connection.globalPriority && (
<span className="text-xs text-text-muted">Auto: {connection.globalPriority}</span>
)}
</div>
</div>
</div>
<div className="flex gap-1">
<button onClick={onEdit} className="p-2 hover:bg-sidebar rounded">
<span className="material-symbols-outlined text-base">edit</span>
</button>
<button onClick={onDelete} className="p-2 hover:bg-red-50 rounded text-red-500">
<span className="material-symbols-outlined text-base">delete</span>
</button>
</div>
</div>
);
}
function AddApiKeyModal({ isOpen, provider, onSave, onClose }) {
const [formData, setFormData] = useState({
name: "",
apiKey: "",
priority: 1,
});
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState(null);
const handleValidate = async () => {
setValidating(true);
try {
const res = await fetch("/api/providers/validate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider, apiKey: formData.apiKey }),
});
const data = await res.json();
setValidationResult(data.valid ? "success" : "failed");
} catch {
setValidationResult("failed");
} finally {
setValidating(false);
}
};
const handleSubmit = () => {
onSave({
name: formData.name,
apiKey: formData.apiKey,
priority: formData.priority,
testStatus: validationResult === "success" ? "active" : "unknown",
});
};
if (!provider) return null;
return (
<Modal isOpen={isOpen} title={`Add ${provider} API Key`} onClose={onClose}>
<div className="flex flex-col gap-4">
<Input
label="Name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Production Key"
/>
<div className="flex gap-2">
<Input
label="API Key"
type="password"
value={formData.apiKey}
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
className="flex-1"
/>
<div className="pt-6">
<Button onClick={handleValidate} disabled={!formData.apiKey || validating} variant="secondary">
{validating ? "Checking..." : "Check"}
</Button>
</div>
</div>
{validationResult && (
<Badge variant={validationResult === "success" ? "success" : "error"}>
{validationResult === "success" ? "Valid" : "Invalid"}
</Badge>
)}
<Input
label="Priority"
type="number"
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: parseInt(e.target.value) || 1 })}
/>
<div className="flex gap-2">
<Button onClick={handleSubmit} fullWidth disabled={!formData.name || !formData.apiKey}>
Save
</Button>
<Button onClick={onClose} variant="ghost" fullWidth>
Cancel
</Button>
</div>
</div>
</Modal>
);
}
function EditConnectionModal({ isOpen, connection, onSave, onClose }) {
const [formData, setFormData] = useState({
name: "",
priority: 1,
});
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState(null);
useEffect(() => {
if (connection) {
setFormData({
name: connection.name || "",
priority: connection.priority || 1,
});
setTestResult(null);
}
}, [connection]);
const handleTest = async () => {
if (!connection?.provider) return;
setTesting(true);
setTestResult(null);
try {
const res = await fetch(`/api/providers/${connection.id}/test`, { method: "POST" });
const data = await res.json();
setTestResult(data.valid ? "success" : "failed");
if (data.valid) {
onSave({ testStatus: "active", lastError: null, lastErrorAt: null });
} else {
onSave({ testStatus: "error", lastError: data.error, lastErrorAt: new Date().toISOString() });
}
} catch {
setTestResult("failed");
} finally {
setTesting(false);
}
};
const handleSubmit = () => {
const updates = { name: formData.name, priority: formData.priority };
onSave(updates);
};
if (!connection) return null;
const isOAuth = connection.authType === "oauth";
return (
<Modal isOpen={isOpen} title="Edit Connection" onClose={onClose}>
<div className="flex flex-col gap-4">
<Input
label="Name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder={isOAuth ? "Account name" : "Production Key"}
/>
{isOAuth && connection.email && (
<div className="bg-sidebar/50 p-3 rounded-lg">
<p className="text-sm text-text-muted mb-1">Email</p>
<p className="font-medium">{connection.email}</p>
</div>
)}
<Input
label="Priority"
type="number"
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: parseInt(e.target.value) || 1 })}
/>
{/* Test Connection */}
<div className="flex items-center gap-3">
<Button onClick={handleTest} variant="secondary" disabled={testing}>
{testing ? "Testing..." : "Test Connection"}
</Button>
{testResult && (
<Badge variant={testResult === "success" ? "success" : "error"}>
{testResult === "success" ? "Valid" : "Failed"}
</Badge>
)}
</div>
<div className="flex gap-2">
<Button onClick={handleSubmit} fullWidth>Save</Button>
<Button onClick={onClose} variant="ghost" fullWidth>Cancel</Button>
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,220 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Card, Button, Input, Select, Toggle } from "@/shared/components";
import { AI_PROVIDERS, AUTH_METHODS } from "@/shared/constants/config";
const providerOptions = Object.values(AI_PROVIDERS).map((p) => ({
value: p.id,
label: p.name,
}));
const authMethodOptions = Object.values(AUTH_METHODS).map((m) => ({
value: m.id,
label: m.name,
}));
export default function NewProviderPage() {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
provider: "",
authMethod: "api_key",
apiKey: "",
displayName: "",
isActive: true,
});
const [errors, setErrors] = useState({});
const handleChange = (field, value) => {
setFormData((prev) => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: null }));
}
};
const validate = () => {
const newErrors = {};
if (!formData.provider) newErrors.provider = "Please select a provider";
if (formData.authMethod === "api_key" && !formData.apiKey) {
newErrors.apiKey = "API Key is required";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validate()) return;
setLoading(true);
try {
const response = await fetch("/api/providers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
});
if (response.ok) {
router.push("/dashboard/providers");
} else {
const data = await response.json();
setErrors({ submit: data.error || "Failed to create provider" });
}
} catch (error) {
setErrors({ submit: "An error occurred. Please try again." });
} finally {
setLoading(false);
}
};
const selectedProvider = AI_PROVIDERS[formData.provider];
return (
<div className="max-w-2xl mx-auto">
{/* Header */}
<div className="mb-8">
<Link
href="/dashboard/providers"
className="inline-flex items-center gap-1 text-sm text-text-muted hover:text-primary transition-colors mb-4"
>
<span className="material-symbols-outlined text-lg">arrow_back</span>
Back to Providers
</Link>
<h1 className="text-3xl font-semibold tracking-tight">Add New Provider</h1>
<p className="text-text-muted mt-2">
Configure a new AI provider to use with your applications.
</p>
</div>
{/* Form */}
<Card>
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
{/* Provider Selection */}
<Select
label="Provider"
options={providerOptions}
value={formData.provider}
onChange={(e) => handleChange("provider", e.target.value)}
placeholder="Select a provider"
error={errors.provider}
required
/>
{/* Provider Info */}
{selectedProvider && (
<Card.Section className="flex items-center gap-3">
<div
className="size-10 rounded-lg flex items-center justify-center bg-bg border border-border"
>
<span
className="material-symbols-outlined text-xl"
style={{ color: selectedProvider.color }}
>
{selectedProvider.icon}
</span>
</div>
<div>
<p className="font-medium">{selectedProvider.name}</p>
<p className="text-sm text-text-muted">
Selected provider
</p>
</div>
</Card.Section>
)}
{/* Auth Method */}
<div className="flex flex-col gap-3">
<label className="text-sm font-medium">
Authentication Method <span className="text-red-500">*</span>
</label>
<div className="flex gap-3">
{authMethodOptions.map((method) => (
<button
key={method.value}
type="button"
onClick={() => handleChange("authMethod", method.value)}
className={`flex-1 flex items-center justify-center gap-2 p-4 rounded-lg border transition-all ${
formData.authMethod === method.value
? "border-primary bg-primary/5 text-primary"
: "border-border hover:border-primary/50"
}`}
>
<span className="material-symbols-outlined">
{method.value === "api_key" ? "key" : "lock"}
</span>
<span className="font-medium">{method.label}</span>
</button>
))}
</div>
</div>
{/* API Key Input */}
{formData.authMethod === "api_key" && (
<Input
label="API Key"
type="password"
placeholder="Enter your API key"
value={formData.apiKey}
onChange={(e) => handleChange("apiKey", e.target.value)}
error={errors.apiKey}
hint="Your API key will be encrypted and stored securely."
required
/>
)}
{/* OAuth2 Button */}
{formData.authMethod === "oauth2" && (
<Card.Section>
<p className="text-sm text-text-muted mb-4">
Connect your account using OAuth2 authentication.
</p>
<Button type="button" variant="secondary" icon="link">
Connect with OAuth2
</Button>
</Card.Section>
)}
{/* Display Name */}
<Input
label="Display Name"
placeholder="e.g., Production API, Dev Environment"
value={formData.displayName}
onChange={(e) => handleChange("displayName", e.target.value)}
hint="Optional. A friendly name to identify this configuration."
/>
{/* Active Toggle */}
<Toggle
checked={formData.isActive}
onChange={(checked) => handleChange("isActive", checked)}
label="Active"
description="Enable this provider for use in your applications"
/>
{/* Error Message */}
{errors.submit && (
<div className="p-4 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 text-sm">
{errors.submit}
</div>
)}
{/* Actions */}
<div className="flex gap-3 pt-4 border-t border-border">
<Link href="/dashboard/providers" className="flex-1">
<Button type="button" variant="ghost" fullWidth>
Cancel
</Button>
</Link>
<Button type="submit" loading={loading} fullWidth className="flex-1">
Create Provider
</Button>
</div>
</form>
</Card>
</div>
);
}
@@ -0,0 +1,235 @@
"use client";
import { useState, useEffect } from "react";
import { Card, CardSkeleton, Badge } from "@/shared/components";
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
import Image from "next/image";
import Link from "next/link";
import { getErrorCode, getRelativeTime } from "@/shared/utils";
export default function ProvidersPage() {
const [connections, setConnections] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
const res = await fetch("/api/providers");
const data = await res.json();
if (res.ok) setConnections(data.connections || []);
} catch (error) {
console.log("Error fetching data:", error);
} finally {
setLoading(false);
}
};
const getProviderStats = (providerId, authType) => {
const providerConnections = connections.filter(
c => c.provider === providerId && c.authType === authType
);
// Helper: check if connection is effectively active (cooldown expired)
const getEffectiveStatus = (conn) => {
const isCooldown = conn.rateLimitedUntil && new Date(conn.rateLimitedUntil).getTime() > Date.now();
return (conn.testStatus === "unavailable" && !isCooldown) ? "active" : conn.testStatus;
};
const connected = providerConnections.filter(c => {
const status = getEffectiveStatus(c);
return status === "active" || status === "success";
}).length;
const errorConns = providerConnections.filter(c => {
const status = getEffectiveStatus(c);
return status === "error" || status === "expired" || status === "unavailable";
});
const error = errorConns.length;
const total = providerConnections.length;
// Get latest error info
const latestError = errorConns.sort((a, b) =>
new Date(b.lastErrorAt || 0) - new Date(a.lastErrorAt || 0)
)[0];
const errorCode = latestError ? getErrorCode(latestError.lastError) : null;
const errorTime = latestError?.lastErrorAt ? getRelativeTime(latestError.lastErrorAt) : null;
return { connected, error, total, errorCode, errorTime };
};
if (loading) {
return (
<div className="flex flex-col gap-8">
<CardSkeleton />
<CardSkeleton />
</div>
);
}
return (
<div className="flex flex-col gap-8">
{/* OAuth Providers */}
<div className="flex flex-col gap-4">
<h2 className="text-xl font-semibold">OAuth Providers</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{Object.entries(OAUTH_PROVIDERS).map(([key, info]) => (
<ProviderCard
key={key}
providerId={key}
provider={info}
stats={getProviderStats(key, "oauth")}
/>
))}
</div>
</div>
{/* API Key Providers */}
<div className="flex flex-col gap-4">
<h2 className="text-xl font-semibold">API Key Providers</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{Object.entries(APIKEY_PROVIDERS).map(([key, info]) => (
<ApiKeyProviderCard
key={key}
providerId={key}
provider={info}
stats={getProviderStats(key, "apikey")}
/>
))}
</div>
</div>
</div>
);
}
function ProviderCard({ providerId, provider, stats }) {
const { connected, error, errorCode, errorTime } = stats;
const [imgError, setImgError] = useState(false);
const getStatusDisplay = () => {
const parts = [];
if (connected > 0) {
parts.push(
<Badge key="connected" variant="success" size="sm" dot>
{connected} Connected
</Badge>
);
}
if (error > 0) {
const errText = errorCode ? `${error} Error (${errorCode})` : `${error} Error`;
parts.push(
<Badge key="error" variant="error" size="sm" dot>
{errText}
</Badge>
);
}
if (parts.length === 0) {
return <span className="text-text-muted">No connections</span>;
}
return parts;
};
return (
<Link href={`/dashboard/providers/${providerId}`}>
<Card className="h-full hover:border-primary/50 transition-colors cursor-pointer">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className="size-10 rounded-lg flex items-center justify-center"
style={{ backgroundColor: `${provider.color}15` }}
>
{!imgError ? (
<img
src={`/providers/${provider.id}.png`}
alt={provider.name}
className="size-10 object-contain rounded-lg"
onError={() => setImgError(true)}
/>
) : (
<span
className="text-sm font-bold"
style={{ color: provider.color }}
>
{provider.textIcon || provider.id.slice(0, 2).toUpperCase()}
</span>
)}
</div>
<div>
<h3 className="font-semibold">{provider.name}</h3>
<div className="flex items-center gap-2 text-xs flex-wrap">
{getStatusDisplay()}
{errorTime && <span className="text-text-muted">• {errorTime}</span>}
</div>
</div>
</div>
<span className="material-symbols-outlined text-text-muted">
chevron_right
</span>
</div>
</Card>
</Link>
);
}
// API Key providers - only use textIcon, no image
function ApiKeyProviderCard({ providerId, provider, stats }) {
const { connected, error, errorCode, errorTime } = stats;
const getStatusDisplay = () => {
const parts = [];
if (connected > 0) {
parts.push(
<Badge key="connected" variant="success" size="sm" dot>
{connected} Connected
</Badge>
);
}
if (error > 0) {
const errText = errorCode ? `${error} Error (${errorCode})` : `${error} Error`;
parts.push(
<Badge key="error" variant="error" size="sm" dot>
{errText}
</Badge>
);
}
if (parts.length === 0) {
return <span className="text-text-muted">No connections</span>;
}
return parts;
};
return (
<Link href={`/dashboard/providers/${providerId}`}>
<Card className="h-full hover:border-primary/50 transition-colors cursor-pointer">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className="size-10 rounded-lg flex items-center justify-center"
style={{ backgroundColor: `${provider.color}15` }}
>
<span
className="text-sm font-bold"
style={{ color: provider.color }}
>
{provider.textIcon || provider.id.slice(0, 2).toUpperCase()}
</span>
</div>
<div>
<h3 className="font-semibold">{provider.name}</h3>
<div className="flex items-center gap-2 text-xs flex-wrap">
{getStatusDisplay()}
{errorTime && <span className="text-text-muted">• {errorTime}</span>}
</div>
</div>
</div>
<span className="material-symbols-outlined text-text-muted">
chevron_right
</span>
</div>
</Card>
</Link>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { DashboardLayout } from "@/shared/components";
export default function DashboardRootLayout({ children }) {
return <DashboardLayout>{children}</DashboardLayout>;
}
@@ -0,0 +1,187 @@
"use server";
import { NextResponse } from "next/server";
import { exec } from "child_process";
import { promisify } from "util";
import fs from "fs/promises";
import path from "path";
import os from "os";
const execAsync = promisify(exec);
// Get claude settings path based on OS
const getClaudeSettingsPath = () => {
const homeDir = os.homedir();
return path.join(homeDir, ".claude", "settings.json");
};
// Check if claude CLI is installed
const checkClaudeInstalled = async () => {
try {
const isWindows = os.platform() === "win32";
const command = isWindows ? "where claude" : "which claude";
await execAsync(command);
return true;
} catch {
return false;
}
};
// Read current settings
const readSettings = async () => {
try {
const settingsPath = getClaudeSettingsPath();
const content = await fs.readFile(settingsPath, "utf-8");
return JSON.parse(content);
} catch (error) {
if (error.code === "ENOENT") {
return null;
}
throw error;
}
};
// GET - Check claude CLI and read current settings
export async function GET() {
try {
const isInstalled = await checkClaudeInstalled();
if (!isInstalled) {
return NextResponse.json({
installed: false,
settings: null,
message: "Claude CLI is not installed",
});
}
const settings = await readSettings();
const has9Router = !!(settings?.env?.ANTHROPIC_BASE_URL);
return NextResponse.json({
installed: true,
settings: settings,
has9Router: has9Router,
settingsPath: getClaudeSettingsPath(),
});
} catch (error) {
console.log("Error checking claude settings:", error);
return NextResponse.json(
{ error: "Failed to check claude settings" },
{ status: 500 }
);
}
}
// POST - Backup old fields and write new settings
export async function POST(request) {
try {
const { env } = await request.json();
if (!env || typeof env !== "object") {
return NextResponse.json(
{ error: "Invalid env object" },
{ status: 400 }
);
}
const settingsPath = getClaudeSettingsPath();
const claudeDir = path.dirname(settingsPath);
// Ensure .claude directory exists
await fs.mkdir(claudeDir, { recursive: true });
// Read current settings
let currentSettings = {};
try {
const content = await fs.readFile(settingsPath, "utf-8");
currentSettings = JSON.parse(content);
} catch (error) {
if (error.code !== "ENOENT") {
throw error;
}
}
// Merge new env with existing settings
const newSettings = {
...currentSettings,
env: {
...(currentSettings.env || {}),
...env,
},
};
// Write new settings
await fs.writeFile(settingsPath, JSON.stringify(newSettings, null, 2));
return NextResponse.json({
success: true,
message: "Settings updated successfully",
});
} catch (error) {
console.log("Error updating claude settings:", error);
return NextResponse.json(
{ error: "Failed to update claude settings" },
{ status: 500 }
);
}
}
// Fields to remove when resetting
const RESET_ENV_KEYS = [
"ANTHROPIC_BASE_URL",
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"API_TIMEOUT_MS",
];
// DELETE - Reset settings (remove env fields)
export async function DELETE() {
try {
const settingsPath = getClaudeSettingsPath();
// Read current settings
let currentSettings = {};
try {
const content = await fs.readFile(settingsPath, "utf-8");
currentSettings = JSON.parse(content);
} catch (error) {
if (error.code === "ENOENT") {
return NextResponse.json({
success: true,
message: "No settings file to reset",
});
}
throw error;
}
// Remove specified env fields
if (currentSettings.env) {
RESET_ENV_KEYS.forEach((key) => {
delete currentSettings.env[key];
});
// Clean up empty env object
if (Object.keys(currentSettings.env).length === 0) {
delete currentSettings.env;
}
}
// Write updated settings
await fs.writeFile(settingsPath, JSON.stringify(currentSettings, null, 2));
return NextResponse.json({
success: true,
message: "Settings reset successfully",
});
} catch (error) {
console.log("Error resetting claude settings:", error);
return NextResponse.json(
{ error: "Failed to reset claude settings" },
{ status: 500 }
);
}
}
@@ -0,0 +1,246 @@
"use server";
import { NextResponse } from "next/server";
import { exec } from "child_process";
import { promisify } from "util";
import fs from "fs/promises";
import path from "path";
import os from "os";
const execAsync = promisify(exec);
const getCodexDir = () => path.join(os.homedir(), ".codex");
const getCodexConfigPath = () => path.join(getCodexDir(), "config.toml");
const getCodexAuthPath = () => path.join(getCodexDir(), "auth.json");
// Parse TOML config to object (simple parser for codex config)
const parseToml = (content) => {
const result = { _root: {}, _sections: {} };
let currentSection = "_root";
content.split("\n").forEach((line) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) return;
// Section header like [model_providers.9router]
const sectionMatch = trimmed.match(/^\[(.+)\]$/);
if (sectionMatch) {
currentSection = sectionMatch[1];
result._sections[currentSection] = {};
return;
}
// Key = value
const kvMatch = trimmed.match(/^([^=]+)\s*=\s*(.+)$/);
if (kvMatch) {
const key = kvMatch[1].trim();
let value = kvMatch[2].trim();
// Remove quotes
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
if (currentSection === "_root") {
result._root[key] = value;
} else {
result._sections[currentSection][key] = value;
}
}
});
return result;
};
// Convert parsed object back to TOML string
const toToml = (parsed) => {
let lines = [];
// Root level keys
Object.entries(parsed._root).forEach(([key, value]) => {
lines.push(`${key} = "${value}"`);
});
// Sections
Object.entries(parsed._sections).forEach(([section, values]) => {
lines.push("");
lines.push(`[${section}]`);
Object.entries(values).forEach(([key, value]) => {
lines.push(`${key} = "${value}"`);
});
});
return lines.join("\n") + "\n";
};
// Check if codex CLI is installed
const checkCodexInstalled = async () => {
try {
const isWindows = os.platform() === "win32";
const command = isWindows ? "where codex" : "which codex";
await execAsync(command);
return true;
} catch {
return false;
}
};
// Read current config.toml
const readConfig = async () => {
try {
const configPath = getCodexConfigPath();
const content = await fs.readFile(configPath, "utf-8");
return content;
} catch (error) {
if (error.code === "ENOENT") return null;
throw error;
}
};
// Check if config has 9Router settings
const has9RouterConfig = (config) => {
if (!config) return false;
return config.includes("model_provider = \"9router\"") || config.includes("[model_providers.9router]");
};
// GET - Check codex CLI and read current settings
export async function GET() {
try {
const isInstalled = await checkCodexInstalled();
if (!isInstalled) {
return NextResponse.json({
installed: false,
config: null,
message: "Codex CLI is not installed",
});
}
const config = await readConfig();
return NextResponse.json({
installed: true,
config,
has9Router: has9RouterConfig(config),
configPath: getCodexConfigPath(),
});
} catch (error) {
console.log("Error checking codex settings:", error);
return NextResponse.json({ error: "Failed to check codex settings" }, { status: 500 });
}
}
// POST - Update 9Router settings (merge with existing config)
export async function POST(request) {
try {
const { baseUrl, apiKey, model } = await request.json();
if (!baseUrl || !apiKey || !model) {
return NextResponse.json({ error: "baseUrl, apiKey and model are required" }, { status: 400 });
}
const codexDir = getCodexDir();
const configPath = getCodexConfigPath();
// Ensure directory exists
await fs.mkdir(codexDir, { recursive: true });
// Read and parse existing config
let parsed = { _root: {}, _sections: {} };
try {
const existingConfig = await fs.readFile(configPath, "utf-8");
parsed = parseToml(existingConfig);
} catch { /* No existing config */ }
// Update only 9Router related fields (api_key goes to auth.json, not config.toml)
parsed._root.model = model;
parsed._root.model_provider = "9router";
// Update or create 9router provider section (no api_key - Codex reads from auth.json)
parsed._sections["model_providers.9router"] = {
name: "9Router",
base_url: `${baseUrl}/v1`,
wire_api: "responses",
};
// Write merged config
const configContent = toToml(parsed);
await fs.writeFile(configPath, configContent);
// Update auth.json with OPENAI_API_KEY (Codex reads this first)
const authPath = getCodexAuthPath();
let authData = {};
try {
const existingAuth = await fs.readFile(authPath, "utf-8");
authData = JSON.parse(existingAuth);
} catch { /* No existing auth */ }
authData.OPENAI_API_KEY = apiKey;
await fs.writeFile(authPath, JSON.stringify(authData, null, 2));
return NextResponse.json({
success: true,
message: "Codex settings applied successfully!",
configPath,
});
} catch (error) {
console.log("Error updating codex settings:", error);
return NextResponse.json({ error: "Failed to update codex settings" }, { status: 500 });
}
}
// DELETE - Remove 9Router settings only (keep other settings)
export async function DELETE() {
try {
const configPath = getCodexConfigPath();
// Read and parse existing config
let parsed = { _root: {}, _sections: {} };
try {
const existingConfig = await fs.readFile(configPath, "utf-8");
parsed = parseToml(existingConfig);
} catch (error) {
if (error.code === "ENOENT") {
return NextResponse.json({
success: true,
message: "No config file to reset",
});
}
throw error;
}
// Remove 9Router related root fields only if they point to 9router
if (parsed._root.model_provider === "9router") {
delete parsed._root.model;
delete parsed._root.model_provider;
}
// Remove 9router provider section
delete parsed._sections["model_providers.9router"];
// Write updated config
const configContent = toToml(parsed);
await fs.writeFile(configPath, configContent);
// Remove OPENAI_API_KEY from auth.json
const authPath = getCodexAuthPath();
try {
const existingAuth = await fs.readFile(authPath, "utf-8");
const authData = JSON.parse(existingAuth);
delete authData.OPENAI_API_KEY;
// Write back or delete if empty
if (Object.keys(authData).length === 0) {
await fs.unlink(authPath);
} else {
await fs.writeFile(authPath, JSON.stringify(authData, null, 2));
}
} catch { /* No auth file */ }
return NextResponse.json({
success: true,
message: "9Router settings removed successfully",
});
} catch (error) {
console.log("Error resetting codex settings:", error);
return NextResponse.json({ error: "Failed to reset codex settings" }, { status: 500 });
}
}
+50
View File
@@ -0,0 +1,50 @@
import { NextResponse } from "next/server";
import { validateApiKey, getProviderConnections, getModelAliases } from "@/models";
// Verify API key and return provider credentials
export async function POST(request) {
try {
const authHeader = request.headers.get("Authorization");
if (!authHeader?.startsWith("Bearer ")) {
// return NextResponse.json({ error: "Missing API key" }, { status: 401 });
}
const apiKey = authHeader.slice(7);
// Validate API key
const isValid = await validateApiKey(apiKey);
if (!isValid) {
// return NextResponse.json({ error: "Invalid API key" }, { status: 401 });
}
// Get active provider connections
const connections = await getProviderConnections({ isActive: true });
// Map connections
const mappedConnections = connections.map(conn => ({
provider: conn.provider,
authType: conn.authType,
apiKey: conn.apiKey || null,
accessToken: conn.accessToken || null,
refreshToken: conn.refreshToken || null,
projectId: conn.projectId || null,
expiresAt: conn.expiresAt,
priority: conn.priority,
globalPriority: conn.globalPriority,
defaultModel: conn.defaultModel,
isActive: conn.isActive
}));
// Get model aliases
const modelAliases = await getModelAliases();
return NextResponse.json({
connections: mappedConnections,
modelAliases
});
} catch (error) {
console.log("Cloud auth error:", error);
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}
@@ -0,0 +1,57 @@
import { NextResponse } from "next/server";
import { validateApiKey, getProviderConnections, updateProviderConnection } from "@/models";
// Update provider credentials (for cloud token refresh)
export async function PUT(request) {
try {
const authHeader = request.headers.get("Authorization");
if (!authHeader?.startsWith("Bearer ")) {
return NextResponse.json({ error: "Missing API key" }, { status: 401 });
}
const apiKey = authHeader.slice(7);
const body = await request.json();
const { provider, credentials } = body;
if (!provider || !credentials) {
return NextResponse.json({ error: "Provider and credentials required" }, { status: 400 });
}
// Validate API key
const isValid = await validateApiKey(apiKey);
if (!isValid) {
return NextResponse.json({ error: "Invalid API key" }, { status: 401 });
}
// Find active connection for provider
const connections = await getProviderConnections({ provider, isActive: true });
const connection = connections[0];
if (!connection) {
return NextResponse.json({ error: `No active connection found for provider: ${provider}` }, { status: 404 });
}
// Update credentials
const updateData = {};
if (credentials.accessToken) {
updateData.accessToken = credentials.accessToken;
}
if (credentials.refreshToken) {
updateData.refreshToken = credentials.refreshToken;
}
if (credentials.expiresIn) {
updateData.expiresAt = new Date(Date.now() + credentials.expiresIn * 1000).toISOString();
}
await updateProviderConnection(connection.id, updateData);
return NextResponse.json({
success: true,
message: `Credentials updated for provider: ${provider}`
});
} catch (error) {
console.log("Update credentials error:", error);
return NextResponse.json({ error: "Failed to update credentials" }, { status: 500 });
}
}
+50
View File
@@ -0,0 +1,50 @@
import { NextResponse } from "next/server";
import { validateApiKey, getModelAliases } from "@/models";
// Resolve model alias to provider/model
export async function POST(request) {
try {
const authHeader = request.headers.get("Authorization");
if (!authHeader?.startsWith("Bearer ")) {
return NextResponse.json({ error: "Missing API key" }, { status: 401 });
}
const apiKey = authHeader.slice(7);
const body = await request.json();
const { alias } = body;
if (!alias) {
return NextResponse.json({ error: "Missing alias" }, { status: 400 });
}
// Validate API key
const isValid = await validateApiKey(apiKey);
if (!isValid) {
return NextResponse.json({ error: "Invalid API key" }, { status: 401 });
}
// Get model aliases
const modelAliases = await getModelAliases();
const resolved = modelAliases[alias];
if (resolved) {
// Parse provider/model
const firstSlash = resolved.indexOf("/");
if (firstSlash > 0) {
return NextResponse.json({
alias,
provider: resolved.slice(0, firstSlash),
model: resolved.slice(firstSlash + 1)
});
}
}
// Not found
return NextResponse.json({ error: "Alias not found" }, { status: 404 });
} catch (error) {
console.log("Model resolve error:", error);
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}
+92
View File
@@ -0,0 +1,92 @@
import { NextResponse } from "next/server";
import { validateApiKey, getModelAliases, setModelAlias, isCloudEnabled } from "@/models";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/app/api/sync/cloud/route";
// PUT /api/cloud/models/alias - Set model alias (for cloud/CLI)
export async function PUT(request) {
try {
const authHeader = request.headers.get("authorization");
const apiKey = authHeader?.replace("Bearer ", "");
if (!apiKey) {
return NextResponse.json({ error: "Missing API key" }, { status: 401 });
}
const isValid = await validateApiKey(apiKey);
if (!isValid) {
return NextResponse.json({ error: "Invalid API key" }, { status: 401 });
}
const body = await request.json();
const { model, alias } = body;
if (!model || !alias) {
return NextResponse.json({ error: "Model and alias required" }, { status: 400 });
}
// Check if alias already exists for different model
const aliases = await getModelAliases();
const existingModel = aliases[alias];
if (existingModel && existingModel !== model) {
return NextResponse.json({
error: `Alias '${alias}' already in use for model '${existingModel}'`
}, { status: 400 });
}
// Update alias
await setModelAlias(alias, model);
// Auto sync to Cloud if enabled
await syncToCloudIfEnabled();
return NextResponse.json({
success: true,
model,
alias,
message: `Alias '${alias}' set for model '${model}'`
});
} catch (error) {
console.log("Error updating alias:", error);
return NextResponse.json({ error: "Failed to update alias" }, { status: 500 });
}
}
/**
* Sync to Cloud if enabled
*/
async function syncToCloudIfEnabled() {
try {
const cloudEnabled = await isCloudEnabled();
if (!cloudEnabled) return;
const machineId = await getConsistentMachineId();
await syncToCloud(machineId);
} catch (error) {
console.log("Error syncing aliases to cloud:", error);
}
}
// GET /api/cloud/models/alias - Get all aliases
export async function GET(request) {
try {
const authHeader = request.headers.get("authorization");
const apiKey = authHeader?.replace("Bearer ", "");
if (!apiKey) {
return NextResponse.json({ error: "Missing API key" }, { status: 401 });
}
const isValid = await validateApiKey(apiKey);
if (!isValid) {
return NextResponse.json({ error: "Invalid API key" }, { status: 401 });
}
const aliases = await getModelAliases();
return NextResponse.json({ aliases });
} catch (error) {
console.log("Error fetching aliases:", error);
return NextResponse.json({ error: "Failed to fetch aliases" }, { status: 500 });
}
}
+94
View File
@@ -0,0 +1,94 @@
import { NextResponse } from "next/server";
import { getComboById, updateCombo, deleteCombo, getComboByName, isCloudEnabled } from "@/lib/localDb";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/app/api/sync/cloud/route";
// Validate combo name: only a-z, A-Z, 0-9, -, _
const VALID_NAME_REGEX = /^[a-zA-Z0-9_-]+$/;
// GET /api/combos/[id] - Get combo by ID
export async function GET(request, { params }) {
try {
const { id } = await params;
const combo = await getComboById(id);
if (!combo) {
return NextResponse.json({ error: "Combo not found" }, { status: 404 });
}
return NextResponse.json(combo);
} catch (error) {
console.log("Error fetching combo:", error);
return NextResponse.json({ error: "Failed to fetch combo" }, { status: 500 });
}
}
// PUT /api/combos/[id] - Update combo
export async function PUT(request, { params }) {
try {
const { id } = await params;
const body = await request.json();
// Validate name format if provided
if (body.name) {
if (!VALID_NAME_REGEX.test(body.name)) {
return NextResponse.json({ error: "Name can only contain letters, numbers, - and _" }, { status: 400 });
}
// Check if name already exists (exclude current combo)
const existing = await getComboByName(body.name);
if (existing && existing.id !== id) {
return NextResponse.json({ error: "Combo name already exists" }, { status: 400 });
}
}
const combo = await updateCombo(id, body);
if (!combo) {
return NextResponse.json({ error: "Combo not found" }, { status: 404 });
}
// Auto sync to Cloud if enabled
await syncToCloudIfEnabled();
return NextResponse.json(combo);
} catch (error) {
console.log("Error updating combo:", error);
return NextResponse.json({ error: "Failed to update combo" }, { status: 500 });
}
}
// DELETE /api/combos/[id] - Delete combo
export async function DELETE(request, { params }) {
try {
const { id } = await params;
const success = await deleteCombo(id);
if (!success) {
return NextResponse.json({ error: "Combo not found" }, { status: 404 });
}
// Auto sync to Cloud if enabled
await syncToCloudIfEnabled();
return NextResponse.json({ success: true });
} catch (error) {
console.log("Error deleting combo:", error);
return NextResponse.json({ error: "Failed to delete combo" }, { status: 500 });
}
}
/**
* Sync to Cloud if enabled
*/
async function syncToCloudIfEnabled() {
try {
const cloudEnabled = await isCloudEnabled();
if (!cloudEnabled) return;
const machineId = await getConsistentMachineId();
await syncToCloud(machineId);
} catch (error) {
console.log("Error syncing to cloud:", error);
}
}

Some files were not shown because too many files have changed in this diff Show More