commit 3857598de4ef88bda862314f6cbc89e37280de3e Author: decolua Date: Mon Jan 5 09:58:59 2026 +0700 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..7ff12a81 --- /dev/null +++ b/.gitignore @@ -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/* diff --git a/.npmignore b/.npmignore new file mode 100644 index 00000000..9bec212d --- /dev/null +++ b/.npmignore @@ -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/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 00000000..061a8a9b --- /dev/null +++ b/README.md @@ -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. diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 00000000..f4438352 --- /dev/null +++ b/eslint.config.mjs @@ -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; diff --git a/images/9router.png b/images/9router.png new file mode 100644 index 00000000..d8520db1 Binary files /dev/null and b/images/9router.png differ diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 00000000..3d72db00 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"], + "open-sse": ["../open-sse"], + "open-sse/*": ["../open-sse/*"] + }, + "module": "ESNext", + "moduleResolution": "bundler" + } +} diff --git a/next.config.mjs b/next.config.mjs new file mode 100644 index 00000000..27204737 --- /dev/null +++ b/next.config.mjs @@ -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; diff --git a/package.json b/package.json new file mode 100644 index 00000000..e6ca09f3 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 00000000..fa4a1da8 --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,6 @@ +export default { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 00000000..a72e45be --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,11 @@ + + + 9 + + + + + + + + diff --git a/public/file.svg b/public/file.svg new file mode 100644 index 00000000..004145cd --- /dev/null +++ b/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/globe.svg b/public/globe.svg new file mode 100644 index 00000000..567f17b0 --- /dev/null +++ b/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/next.svg b/public/next.svg new file mode 100644 index 00000000..5174b28c --- /dev/null +++ b/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/providers/antigravity.png b/public/providers/antigravity.png new file mode 100644 index 00000000..b567e54a Binary files /dev/null and b/public/providers/antigravity.png differ diff --git a/public/providers/claude.png b/public/providers/claude.png new file mode 100644 index 00000000..5a38939e Binary files /dev/null and b/public/providers/claude.png differ diff --git a/public/providers/cline.png b/public/providers/cline.png new file mode 100644 index 00000000..67161e92 Binary files /dev/null and b/public/providers/cline.png differ diff --git a/public/providers/codex.png b/public/providers/codex.png new file mode 100644 index 00000000..2f0ecb97 Binary files /dev/null and b/public/providers/codex.png differ diff --git a/public/providers/continue.png b/public/providers/continue.png new file mode 100644 index 00000000..8b60abbe Binary files /dev/null and b/public/providers/continue.png differ diff --git a/public/providers/copilot.png b/public/providers/copilot.png new file mode 100644 index 00000000..e4f69c18 Binary files /dev/null and b/public/providers/copilot.png differ diff --git a/public/providers/cursor.png b/public/providers/cursor.png new file mode 100644 index 00000000..a0f1fa80 Binary files /dev/null and b/public/providers/cursor.png differ diff --git a/public/providers/gemini-cli.png b/public/providers/gemini-cli.png new file mode 100644 index 00000000..2f26055b Binary files /dev/null and b/public/providers/gemini-cli.png differ diff --git a/public/providers/github.png b/public/providers/github.png new file mode 100644 index 00000000..e4f69c18 Binary files /dev/null and b/public/providers/github.png differ diff --git a/public/providers/iflow.png b/public/providers/iflow.png new file mode 100644 index 00000000..abf46570 Binary files /dev/null and b/public/providers/iflow.png differ diff --git a/public/providers/qwen.png b/public/providers/qwen.png new file mode 100644 index 00000000..d773698a Binary files /dev/null and b/public/providers/qwen.png differ diff --git a/public/providers/roo.png b/public/providers/roo.png new file mode 100644 index 00000000..582c0b7d Binary files /dev/null and b/public/providers/roo.png differ diff --git a/public/vercel.svg b/public/vercel.svg new file mode 100644 index 00000000..77053960 --- /dev/null +++ b/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/window.svg b/public/window.svg new file mode 100644 index 00000000..b2b2a44f --- /dev/null +++ b/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/scripts/prepare-standalone.js b/scripts/prepare-standalone.js new file mode 100644 index 00000000..e17e7bb6 --- /dev/null +++ b/scripts/prepare-standalone.js @@ -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"); + + + + + + + + + + + + + + + + + + diff --git a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js new file mode 100644 index 00000000..8d930bf0 --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js @@ -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 ( +
+
+ + + +
+
+ ); + } + + 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 ( + handleModelMappingChange(toolId, alias, target)} + hasActiveProviders={hasActiveProviders} + cloudEnabled={cloudEnabled} + /> + ); + case "codex": + return ; + default: + return ; + } + }; + + return ( +
+ {!hasActiveProviders && ( + +
+ warning +
+

No active providers

+

Please add and connect providers first to configure CLI tools.

+
+
+
+ )} + +
+ {Object.entries(CLI_TOOLS).map(([toolId, tool]) => renderToolCard(toolId, tool))} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js new file mode 100644 index 00000000..b9152cbe --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js @@ -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" : ""); + 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 ( + +
+
+
+ {tool.name} { e.target.style.display = "none"; }} /> +
+
+
+

{tool.name}

+ {configStatus === "configured" && Connected} + {configStatus === "not_configured" && Not configured} + {configStatus === "other" && Other endpoint} +
+

{tool.description}

+
+
+ expand_more +
+ + {isExpanded && ( +
+ {checkingClaude && ( +
+ progress_activity + Checking Claude CLI... +
+ )} + + {!checkingClaude && claudeStatus && !claudeStatus.installed && ( +
+
+ warning +
+

Claude CLI not installed

+

Please install Claude CLI to use this feature.

+
+ +
+ {showInstallGuide && ( +
+

Installation Guide

+
+
+

macOS / Linux / Windows:

+ npm install -g @anthropic-ai/claude-code +
+

After installation, run claude to verify.

+
+
+ )} +
+ )} + + {!checkingClaude && claudeStatus?.installed && ( + <> +
+ check_circle + URL: + {baseUrl} +
+ +
+ Key: + {apiKeys.length > 0 ? ( + + ) : ( + + {cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router"} + + )} +
+ +
+ {tool.defaultModels.map((model) => ( +
+ {model.name} + arrow_forward + 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" /> + + {modelMappings[model.alias] && } +
+ ))} +
+ + {message && ( +
+ {message.type === "success" ? "check_circle" : "error"} + {message.text} +
+ )} + +
+ + + +
+ + {/* Manual Config Section */} +
+

Or copy config manually:

+
+
+ ~/.claude/settings.json + +
+
{getSettingsContent()}
+
+
+ + )} +
+ )} + + setModalOpen(false)} onSelect={handleModelSelect} selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} activeProviders={activeProviders} modelAliases={modelAliases} title={`Select model for ${currentEditingAlias}`} /> +
+ ); +} + diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js new file mode 100644 index 00000000..9764c9ec --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js @@ -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" : ""); + + 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 ( + +
+
+
+ {tool.name} { e.target.style.display = "none"; }} /> +
+
+
+

{tool.name}

+ {configStatus === "configured" && Connected} + {configStatus === "not_configured" && Not configured} + {configStatus === "other" && Other endpoint} +
+

{tool.description}

+
+
+ expand_more +
+ + {isExpanded && ( +
+ {checkingCodex && ( +
+ progress_activity + Checking Codex CLI... +
+ )} + + {!checkingCodex && codexStatus && !codexStatus.installed && ( +
+
+ warning +
+

Codex CLI not installed

+

Please install Codex CLI to use auto-apply feature.

+
+ +
+ {showInstallGuide && ( +
+

Installation Guide

+
+
+

macOS / Linux / Windows:

+ npm install -g @openai/codex +
+

After installation, run codex to verify.

+
+

+ Codex uses ~/.codex/auth.json with OPENAI_API_KEY. + Click "Apply" to auto-configure. +

+
+
+
+ )} +
+ )} + + {!checkingCodex && codexStatus?.installed && ( + <> +
+ check_circle + URL: + {baseUrl}/v1 +
+ +
+ Key: + {apiKeys.length > 0 ? ( + + ) : ( + + {cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router"} + + )} +
+ +
+ Model + arrow_forward + 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" /> + + {selectedModel && } +
+ + {message && ( +
+ {message.type === "success" ? "check_circle" : "error"} + {message.text} +
+ )} + +
+ + + +
+ + )} + + {/* Manual Config Section */} +
+

Or copy config manually:

+ +
+
+ ~/.codex/config.toml + +
+
{configContent}
+
+ +
+
+ ~/.codex/auth.json + +
+
{authContent}
+
+
+
+ )} + + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={selectedModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Select Model for Codex" + /> +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js new file mode 100644 index 00000000..cf220b8c --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/DefaultToolCard.js @@ -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 ( +
+ {apiKeys && apiKeys.length > 0 ? ( + <> + + + + ) : ( + + {cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router"} + + )} +
+ ); + }; + + const renderModelSelector = () => { + return ( +
+ 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" + /> + + {modelValue && ( + <> + + + + )} +
+ ); + }; + + const renderNotes = () => { + if (!tool.notes || tool.notes.length === 0) return null; + + return ( +
+ {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 ( +
+ {icon} +

{note.text}

+
+ ); + })} +
+ ); + }; + + const canShowGuide = () => { + if (tool.requiresCloud && !cloudEnabled) return false; + return true; + }; + + const renderGuideSteps = () => { + if (!tool.guideSteps) return

Coming soon...

; + + return ( +
+ {renderNotes()} + {canShowGuide() && tool.guideSteps.map((item) => ( +
+
+ {item.step} +
+
+

{item.title}

+ {item.desc &&

{item.desc}

} + {item.type === "apiKeySelector" && renderApiKeySelector()} + {item.type === "modelSelector" && renderModelSelector()} + {item.value && ( +
+ + {replaceVars(item.value)} + + {item.copyable && ( + + )} +
+ )} +
+
+ ))} + + {canShowGuide() && tool.codeBlock && ( +
+
+ {tool.codeBlock.language} + +
+
+              {replaceVars(tool.codeBlock.code)}
+            
+
+ )} +
+ ); + }; + + const renderIcon = () => { + if (tool.image) { + return ( + {tool.name} { e.target.style.display = "none"; }} + /> + ); + } + if (tool.icon) { + return {tool.icon}; + } + return ( + {tool.name} { e.target.style.display = "none"; }} + /> + ); + }; + + return ( + +
+
+
+ {renderIcon()} +
+
+

{tool.name}

+

{tool.description}

+
+
+ expand_more +
+ + {isExpanded && ( +
+ {renderGuideSteps()} +
+ )} + + setShowModelModal(false)} + onSelect={handleSelectModel} + selectedModel={modelValue} + activeProviders={activeProviders} + title="Select Model" + /> +
+ ); +} + diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/index.js b/src/app/(dashboard)/dashboard/cli-tools/components/index.js new file mode 100644 index 00000000..dd07abcb --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/index.js @@ -0,0 +1,4 @@ +export { default as ClaudeToolCard } from "./ClaudeToolCard"; +export { default as CodexToolCard } from "./CodexToolCard"; +export { default as DefaultToolCard } from "./DefaultToolCard"; + diff --git a/src/app/(dashboard)/dashboard/cli-tools/page.js b/src/app/(dashboard)/dashboard/cli-tools/page.js new file mode 100644 index 00000000..24f6030a --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/page.js @@ -0,0 +1,7 @@ +import { getMachineId } from "@/shared/utils/machine"; +import CLIToolsPageClient from "./CLIToolsPageClient"; + +export default async function CLIToolsPage() { + const machineId = await getMachineId(); + return ; +} diff --git a/src/app/(dashboard)/dashboard/combos/page.js b/src/app/(dashboard)/dashboard/combos/page.js new file mode 100644 index 00000000..24e7f3ab --- /dev/null +++ b/src/app/(dashboard)/dashboard/combos/page.js @@ -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 ( +
+ + +
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Combos

+

+ Create model combos with fallback support +

+
+ +
+ + {/* Combos List */} + {combos.length === 0 ? ( + +
+ + layers + +

No combos yet

+ +
+
+ ) : ( +
+ {combos.map((combo) => ( + setEditingCombo(combo)} + onDelete={() => handleDelete(combo.id)} + /> + ))} +
+ )} + + {/* Create Modal - Use key to force remount and reset state */} + setShowCreateModal(false)} + onSave={handleCreate} + activeProviders={activeProviders} + /> + + {/* Edit Modal - Use key to force remount and reset state */} + setEditingCombo(null)} + onSave={(data) => handleUpdate(editingCombo.id, data)} + activeProviders={activeProviders} + /> +
+ ); +} + +function ComboCard({ combo, copied, onCopy, onEdit, onDelete }) { + return ( + +
+
+ {/* Name + Copy */} +
+ layers + {combo.name} + +
+ + {/* Models list */} +
+ {combo.models.length === 0 ? ( +

No models added

+ ) : ( + combo.models.map((model, index) => ( +
+ {index + 1}. + + {model} + + {index === 0 && ( + Primary + )} + {index > 0 && ( + Fallback + )} +
+ )) + )} +
+
+ + {/* Actions */} +
+ + +
+
+
+ ); +} + +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 ( + <> + +
+ {/* Name */} +
+ +

+ Only letters, numbers, - and _ allowed +

+
+ + {/* Models */} +
+
+ + +
+ + {models.length === 0 ? ( +
+

No models added

+

Click "Add Model" to add

+
+ ) : ( +
+ {models.map((model, index) => ( +
+ {/* Priority arrows */} +
+ + +
+ + {/* Model Input */} + handleModelChange(index, e.target.value)} + placeholder="model-name" + className="flex-1" + /> + + {/* Remove */} + +
+ ))} +
+ )} +
+ + {/* Actions */} +
+ + +
+
+
+ + {/* Model Select Modal */} + setShowModelSelect(false)} + onSelect={handleAddModel} + activeProviders={activeProviders} + modelAliases={modelAliases} + title="Add Model to Combo" + /> + + ); +} + diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js new file mode 100644 index 00000000..ea7dc10b --- /dev/null +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js @@ -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 ( +
+ + +
+ ); + } + + // 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 ( +
+ {/* Endpoint Card */} + +
+
+

API Endpoint

+

+ {cloudEnabled ? "Using Cloud Proxy" : "Using Local Server"} +

+
+
+ {cloudEnabled ? ( + + ) : ( + + )} +
+
+ + {/* Endpoint URL */} +
+ + +
+ +
+ + {/* API Keys */} + +
+

API Keys

+ +
+ + {keys.length === 0 ? ( +
+ + vpn_key + +

No API keys yet

+
+ ) : ( +
+ + + + + + + + + + + {keys.map((key) => ( + + + + + + + ))} + +
NameKeyCreatedActions
{key.name} +
+ + {key.key} + +
+
+ {new Date(key.createdAt).toLocaleDateString()} + + +
+
+ )} +
+ + {/* Cloud Proxy Card - Hidden */} + {false && ( + +
+ {/* Header */} +
+
+
+ cloud +
+
+

Cloud Proxy

+

+ {cloudEnabled ? "Connected & Ready" : "Access your API from anywhere"} +

+
+
+
+ {cloudEnabled ? ( + + ) : ( + + )} +
+
+ + {/* Benefits Grid */} +
+ {cloudBenefits.map((benefit) => ( +
+ {benefit.icon} +

{benefit.title}

+

{benefit.desc}

+
+ ))} +
+
+
+ )} + + {/* Cloud Enable Modal */} + setShowCloudModal(false)} + > +
+
+

+ What you will get +

+
    +
  • â€ĸ Access your API from anywhere in the world
  • +
  • â€ĸ Share endpoint with your team easily
  • +
  • â€ĸ No need to open ports or configure firewall
  • +
  • â€ĸ Fast global edge network
  • +
+
+ +
+

+ Note +

+
    +
  • â€ĸ Cloud will keep your auth session for 1 day. If not used, it will be automatically deleted.
  • +
  • â€ĸ Cloud is currently unstable with Claude Code OAuth in some cases.
  • +
+
+ + {/* Sync Progress */} + {cloudSyncing && ( +
+ progress_activity +
+

+ {syncStep === "syncing" && "Syncing data to cloud..."} + {syncStep === "verifying" && "Verifying connection..."} +

+
+
+ )} + +
+ + +
+
+
+ + {/* Add Key Modal */} + { + setShowAddModal(false); + setNewKeyName(""); + }} + > +
+ setNewKeyName(e.target.value)} + placeholder="Production Key" + /> +
+ + +
+
+
+ + {/* Created Key Modal */} + setCreatedKey(null)} + > +
+
+

+ Save this key now! +

+

+ This is the only time you will see this key. Store it securely. +

+
+
+ + +
+ +
+
+ + {/* Disable Cloud Modal */} + !cloudSyncing && setShowDisableModal(false)} + > +
+
+
+ warning +
+

+ Warning +

+

+ All auth sessions will be deleted from cloud. +

+
+
+
+ + {/* Sync Progress */} + {cloudSyncing && ( +
+ progress_activity +
+

+ {syncStep === "syncing" && "Syncing latest data..."} + {syncStep === "disabling" && "Disabling cloud..."} +

+
+
+ )} + +

Are you sure you want to disable cloud proxy?

+ +
+ + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/(dashboard)/dashboard/endpoint/page.js b/src/app/(dashboard)/dashboard/endpoint/page.js new file mode 100644 index 00000000..96a3e31e --- /dev/null +++ b/src/app/(dashboard)/dashboard/endpoint/page.js @@ -0,0 +1,7 @@ +import { getMachineId } from "@/shared/utils/machine"; +import EndpointPageClient from "./EndpointPageClient"; + +export default async function EndpointPage() { + const machineId = await getMachineId(); + return ; +} diff --git a/src/app/(dashboard)/dashboard/page.js b/src/app/(dashboard)/dashboard/page.js new file mode 100644 index 00000000..6a94a1fe --- /dev/null +++ b/src/app/(dashboard)/dashboard/page.js @@ -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 ; +} diff --git a/src/app/(dashboard)/dashboard/profile/page.js b/src/app/(dashboard)/dashboard/profile/page.js new file mode 100644 index 00000000..a68d0eee --- /dev/null +++ b/src/app/(dashboard)/dashboard/profile/page.js @@ -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 ( +
+
+ {/* Local Mode Info */} + +
+
+ computer +
+
+

Local Mode

+

Running on your machine

+
+
+
+

+ All data is stored locally in the data/db.json file. +

+
+
+ + {/* Theme Preferences */} + +

Appearance

+
+
+
+

Dark Mode

+

+ Switch between light and dark themes +

+
+ setTheme(isDark ? "light" : "dark")} + /> +
+ + {/* Theme Options */} +
+ {["light", "dark", "system"].map((option) => ( + + ))} +
+
+
+ + {/* Data Management */} + +

Data

+
+
+
+

Database Location

+

~/9router/data/db.json

+
+
+
+
+ + {/* App Info */} +
+

{APP_CONFIG.name} v{APP_CONFIG.version}

+

Local Mode - All data stored on your machine

+
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js new file mode 100644 index 00000000..e64d8531 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -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 ( +
+

Provider not found

+ + Back to Providers + +
+ ); + } + + if (loading) { + return ( +
+ + +
+ ); + } + + return ( +
+ {/* Header */} +
+ + arrow_back + Back to Providers + +
+
+ {providerInfo.name} { e.target.style.display = "none"; }} + /> +
+
+

{providerInfo.name}

+

+ {connections.length} connection{connections.length !== 1 ? "s" : ""} +

+
+
+
+ + {/* Connections */} + +
+

Connections

+ +
+ + {connections.length === 0 ? ( +
+ + {isOAuth ? "lock" : "key"} + +

No connections yet

+
+ ) : ( +
+ {connections + .sort((a, b) => (a.priority || 0) - (b.priority || 0)) + .map((conn, index) => ( + handleSwapPriority(conn, connections[index - 1])} + onMoveDown={() => handleSwapPriority(conn, connections[index + 1])} + onEdit={() => { + setSelectedConnection(conn); + setShowEditModal(true); + }} + onDelete={() => handleDelete(conn.id)} + /> + ))} +
+ )} +
+ + {/* Models */} + +

+ {providerInfo.passthroughModels ? "Model Aliases" : "Available Models"} +

+ {providerInfo.passthroughModels ? ( + + ) : models.length === 0 ? ( +

No models configured

+ ) : ( +
+ {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 ( + handleSetAlias(model.id, alias)} + onDeleteAlias={() => handleDeleteAlias(existingAlias)} + /> + ); + })} +
+ )} + +
+ + {/* Modals */} + setShowOAuthModal(false)} + /> + setShowAddApiKeyModal(false)} + /> + setShowEditModal(false)} + /> +
+ ); +} + +function ModelRow({ model, fullModel, alias, copied, onCopy }) { + return ( +
+ smart_toy + {fullModel} + +
+ ); +} + +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 ( +
+

+ OpenRouter supports any model. Add models and create aliases for quick access. +

+ + {/* Add new model */} +
+
+ + 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" + /> +
+ +
+ + {/* Models list */} + {allModels.length > 0 && ( +
+ {allModels.map(({ modelId, fullModel, alias }) => ( + onDeleteAlias(alias)} + /> + ))} +
+ )} +
+ ); +} + +function PassthroughModelRow({ modelId, fullModel, copied, onCopy, onDeleteAlias }) { + return ( +
+ smart_toy + +
+

{modelId}

+ +
+ {fullModel} + +
+
+ + {/* Delete button */} + +
+ ); +} + +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 ( + + ⏱ {remaining} + + ); +} + +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 ( +
+
+ {/* Priority arrows */} +
+ + +
+ + {isOAuth ? "lock" : "key"} + +
+

{displayName}

+
+ + {effectiveStatus || "Unknown"} + + {isCooldown && } + {connection.lastError && ( + + {connection.lastError} + + )} + #{connection.priority} + {connection.globalPriority && ( + Auto: {connection.globalPriority} + )} +
+
+
+
+ + +
+
+ ); +} + +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 ( + +
+ setFormData({ ...formData, name: e.target.value })} + placeholder="Production Key" + /> +
+ setFormData({ ...formData, apiKey: e.target.value })} + className="flex-1" + /> +
+ +
+
+ {validationResult && ( + + {validationResult === "success" ? "Valid" : "Invalid"} + + )} + setFormData({ ...formData, priority: parseInt(e.target.value) || 1 })} + /> +
+ + +
+
+
+ ); +} + +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 ( + +
+ setFormData({ ...formData, name: e.target.value })} + placeholder={isOAuth ? "Account name" : "Production Key"} + /> + {isOAuth && connection.email && ( +
+

Email

+

{connection.email}

+
+ )} + setFormData({ ...formData, priority: parseInt(e.target.value) || 1 })} + /> + + {/* Test Connection */} +
+ + {testResult && ( + + {testResult === "success" ? "Valid" : "Failed"} + + )} +
+ +
+ + +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/new/page.js b/src/app/(dashboard)/dashboard/providers/new/page.js new file mode 100644 index 00000000..57d0a7f5 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/new/page.js @@ -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 ( +
+ {/* Header */} +
+ + arrow_back + Back to Providers + +

Add New Provider

+

+ Configure a new AI provider to use with your applications. +

+
+ + {/* Form */} + +
+ {/* Provider Selection */} + handleChange("apiKey", e.target.value)} + error={errors.apiKey} + hint="Your API key will be encrypted and stored securely." + required + /> + )} + + {/* OAuth2 Button */} + {formData.authMethod === "oauth2" && ( + +

+ Connect your account using OAuth2 authentication. +

+ +
+ )} + + {/* Display Name */} + handleChange("displayName", e.target.value)} + hint="Optional. A friendly name to identify this configuration." + /> + + {/* Active Toggle */} + handleChange("isActive", checked)} + label="Active" + description="Enable this provider for use in your applications" + /> + + {/* Error Message */} + {errors.submit && ( +
+ {errors.submit} +
+ )} + + {/* Actions */} +
+ + + + +
+ +
+
+ ); +} + diff --git a/src/app/(dashboard)/dashboard/providers/page.js b/src/app/(dashboard)/dashboard/providers/page.js new file mode 100644 index 00000000..f1ce6c79 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/page.js @@ -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 ( +
+ + +
+ ); + } + + return ( +
+ {/* OAuth Providers */} +
+

OAuth Providers

+
+ {Object.entries(OAUTH_PROVIDERS).map(([key, info]) => ( + + ))} +
+
+ + {/* API Key Providers */} +
+

API Key Providers

+
+ {Object.entries(APIKEY_PROVIDERS).map(([key, info]) => ( + + ))} +
+
+
+ ); +} + +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( + + {connected} Connected + + ); + } + if (error > 0) { + const errText = errorCode ? `${error} Error (${errorCode})` : `${error} Error`; + parts.push( + + {errText} + + ); + } + if (parts.length === 0) { + return No connections; + } + return parts; + }; + + return ( + + +
+
+
+ {!imgError ? ( + {provider.name} setImgError(true)} + /> + ) : ( + + {provider.textIcon || provider.id.slice(0, 2).toUpperCase()} + + )} +
+
+

{provider.name}

+
+ {getStatusDisplay()} + {errorTime && â€ĸ {errorTime}} +
+
+
+ + chevron_right + +
+
+ + ); +} + +// 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( + + {connected} Connected + + ); + } + if (error > 0) { + const errText = errorCode ? `${error} Error (${errorCode})` : `${error} Error`; + parts.push( + + {errText} + + ); + } + if (parts.length === 0) { + return No connections; + } + return parts; + }; + + return ( + + +
+
+
+ + {provider.textIcon || provider.id.slice(0, 2).toUpperCase()} + +
+
+

{provider.name}

+
+ {getStatusDisplay()} + {errorTime && â€ĸ {errorTime}} +
+
+
+ + chevron_right + +
+
+ + ); +} diff --git a/src/app/(dashboard)/layout.js b/src/app/(dashboard)/layout.js new file mode 100644 index 00000000..5ef563e9 --- /dev/null +++ b/src/app/(dashboard)/layout.js @@ -0,0 +1,6 @@ +import { DashboardLayout } from "@/shared/components"; + +export default function DashboardRootLayout({ children }) { + return {children}; +} + diff --git a/src/app/api/cli-tools/claude-settings/route.js b/src/app/api/cli-tools/claude-settings/route.js new file mode 100644 index 00000000..3c2a84a6 --- /dev/null +++ b/src/app/api/cli-tools/claude-settings/route.js @@ -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 } + ); + } +} + diff --git a/src/app/api/cli-tools/codex-settings/route.js b/src/app/api/cli-tools/codex-settings/route.js new file mode 100644 index 00000000..387f9a5b --- /dev/null +++ b/src/app/api/cli-tools/codex-settings/route.js @@ -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 }); + } +} diff --git a/src/app/api/cloud/auth/route.js b/src/app/api/cloud/auth/route.js new file mode 100644 index 00000000..0c4fa780 --- /dev/null +++ b/src/app/api/cloud/auth/route.js @@ -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 }); + } +} diff --git a/src/app/api/cloud/credentials/update/route.js b/src/app/api/cloud/credentials/update/route.js new file mode 100644 index 00000000..fa25ba3c --- /dev/null +++ b/src/app/api/cloud/credentials/update/route.js @@ -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 }); + } +} diff --git a/src/app/api/cloud/model/resolve/route.js b/src/app/api/cloud/model/resolve/route.js new file mode 100644 index 00000000..db6b64ce --- /dev/null +++ b/src/app/api/cloud/model/resolve/route.js @@ -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 }); + } +} diff --git a/src/app/api/cloud/models/alias/route.js b/src/app/api/cloud/models/alias/route.js new file mode 100644 index 00000000..8ae33dd7 --- /dev/null +++ b/src/app/api/cloud/models/alias/route.js @@ -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 }); + } +} diff --git a/src/app/api/combos/[id]/route.js b/src/app/api/combos/[id]/route.js new file mode 100644 index 00000000..af86fa04 --- /dev/null +++ b/src/app/api/combos/[id]/route.js @@ -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); + } +} diff --git a/src/app/api/combos/route.js b/src/app/api/combos/route.js new file mode 100644 index 00000000..fbb89d1e --- /dev/null +++ b/src/app/api/combos/route.js @@ -0,0 +1,66 @@ +import { NextResponse } from "next/server"; +import { getCombos, createCombo, 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 - Get all combos +export async function GET() { + try { + const combos = await getCombos(); + return NextResponse.json({ combos }); + } catch (error) { + console.log("Error fetching combos:", error); + return NextResponse.json({ error: "Failed to fetch combos" }, { status: 500 }); + } +} + +// POST /api/combos - Create new combo +export async function POST(request) { + try { + const body = await request.json(); + const { name, models } = body; + + if (!name) { + return NextResponse.json({ error: "Name is required" }, { status: 400 }); + } + + // Validate name format + if (!VALID_NAME_REGEX.test(name)) { + return NextResponse.json({ error: "Name can only contain letters, numbers, - and _" }, { status: 400 }); + } + + // Check if name already exists + const existing = await getComboByName(name); + if (existing) { + return NextResponse.json({ error: "Combo name already exists" }, { status: 400 }); + } + + const combo = await createCombo({ name, models: models || [] }); + + // Auto sync to Cloud if enabled + await syncToCloudIfEnabled(); + + return NextResponse.json(combo, { status: 201 }); + } catch (error) { + console.log("Error creating combo:", error); + return NextResponse.json({ error: "Failed to create 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); + } +} diff --git a/src/app/api/init/route.js b/src/app/api/init/route.js new file mode 100644 index 00000000..2630babf --- /dev/null +++ b/src/app/api/init/route.js @@ -0,0 +1,7 @@ +// Auto-initialize cloud sync when server starts +import "@/lib/initCloudSync"; + +// This API route is called automatically to initialize sync +export async function GET() { + return new Response("Initialized", { status: 200 }); +} diff --git a/src/app/api/keys/[id]/route.js b/src/app/api/keys/[id]/route.js new file mode 100644 index 00000000..825e2e78 --- /dev/null +++ b/src/app/api/keys/[id]/route.js @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server"; +import { deleteApiKey, isCloudEnabled } from "@/lib/localDb"; +import { getConsistentMachineId } from "@/shared/utils/machineId"; +import { syncToCloud } from "@/app/api/sync/cloud/route"; + +// DELETE /api/keys/[id] - Delete API key +export async function DELETE(request, { params }) { + try { + const { id } = await params; + + const deleted = await deleteApiKey(id); + if (!deleted) { + return NextResponse.json({ error: "Key not found" }, { status: 404 }); + } + + // Auto sync to Cloud if enabled + await syncKeysToCloudIfEnabled(); + + return NextResponse.json({ message: "Key deleted successfully" }); + } catch (error) { + console.log("Error deleting key:", error); + return NextResponse.json({ error: "Failed to delete key" }, { status: 500 }); + } +} + +/** + * Sync API keys to Cloud if enabled + */ +async function syncKeysToCloudIfEnabled() { + try { + const cloudEnabled = await isCloudEnabled(); + if (!cloudEnabled) return; + + const machineId = await getConsistentMachineId(); + await syncToCloud(machineId); + } catch (error) { + console.log("Error syncing keys to cloud:", error); + } +} diff --git a/src/app/api/keys/route.js b/src/app/api/keys/route.js new file mode 100644 index 00000000..28bf0a2c --- /dev/null +++ b/src/app/api/keys/route.js @@ -0,0 +1,59 @@ +import { NextResponse } from "next/server"; +import { getApiKeys, createApiKey, isCloudEnabled } from "@/lib/localDb"; +import { getConsistentMachineId } from "@/shared/utils/machineId"; +import { syncToCloud } from "@/app/api/sync/cloud/route"; + +// GET /api/keys - List API keys +export async function GET() { + try { + const keys = await getApiKeys(); + return NextResponse.json({ keys }); + } catch (error) { + console.log("Error fetching keys:", error); + return NextResponse.json({ error: "Failed to fetch keys" }, { status: 500 }); + } +} + +// POST /api/keys - Create new API key +export async function POST(request) { + try { + const body = await request.json(); + const { name } = body; + + if (!name) { + return NextResponse.json({ error: "Name is required" }, { status: 400 }); + } + + // Always get machineId from server + const machineId = await getConsistentMachineId(); + const apiKey = await createApiKey(name, machineId); + + // Auto sync to Cloud if enabled + await syncKeysToCloudIfEnabled(); + + return NextResponse.json({ + key: apiKey.key, + name: apiKey.name, + id: apiKey.id, + machineId: apiKey.machineId, + }, { status: 201 }); + } catch (error) { + console.log("Error creating key:", error); + return NextResponse.json({ error: "Failed to create key" }, { status: 500 }); + } +} + +/** + * Sync API keys to Cloud if enabled + */ +async function syncKeysToCloudIfEnabled() { + try { + const cloudEnabled = await isCloudEnabled(); + if (!cloudEnabled) return; + + const machineId = await getConsistentMachineId(); + await syncToCloud(machineId); + } catch (error) { + console.log("Error syncing keys to cloud:", error); + } +} diff --git a/src/app/api/models/alias/route.js b/src/app/api/models/alias/route.js new file mode 100644 index 00000000..3c0d1562 --- /dev/null +++ b/src/app/api/models/alias/route.js @@ -0,0 +1,83 @@ +import { NextResponse } from "next/server"; +import { getModelAliases, setModelAlias, deleteModelAlias, isCloudEnabled } from "@/models"; +import { getConsistentMachineId } from "@/shared/utils/machineId"; +import { syncToCloud } from "@/app/api/sync/cloud/route"; + +// GET /api/models/alias - Get all aliases +export async function GET() { + try { + 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 }); + } +} + +// PUT /api/models/alias - Set model alias +export async function PUT(request) { + try { + const body = await request.json(); + const { model, alias } = body; + + if (!model || !alias) { + return NextResponse.json({ error: "Model and alias required" }, { status: 400 }); + } + + const aliases = await getModelAliases(); + + // Check if alias already used by different model + const existingModel = aliases[alias]; + if (existingModel && existingModel !== model) { + return NextResponse.json({ + error: `Alias '${alias}' already in use for model '${existingModel}'` + }, { status: 400 }); + } + + // Delete old alias for this model (if exists and different from new alias) + const oldAlias = Object.entries(aliases).find(([a, m]) => m === model && a !== alias)?.[0]; + if (oldAlias) { + await deleteModelAlias(oldAlias); + } + + await setModelAlias(alias, model); + await syncToCloudIfEnabled(); + + return NextResponse.json({ success: true, model, alias }); + } catch (error) { + console.log("Error updating alias:", error); + return NextResponse.json({ error: "Failed to update alias" }, { status: 500 }); + } +} + +// DELETE /api/models/alias?alias=xxx - Delete alias +export async function DELETE(request) { + try { + const { searchParams } = new URL(request.url); + const alias = searchParams.get("alias"); + + if (!alias) { + return NextResponse.json({ error: "Alias required" }, { status: 400 }); + } + + await deleteModelAlias(alias); + await syncToCloudIfEnabled(); + + return NextResponse.json({ success: true }); + } catch (error) { + console.log("Error deleting alias:", error); + return NextResponse.json({ error: "Failed to delete alias" }, { status: 500 }); + } +} + +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); + } +} diff --git a/src/app/api/models/route.js b/src/app/api/models/route.js new file mode 100644 index 00000000..bac99e65 --- /dev/null +++ b/src/app/api/models/route.js @@ -0,0 +1,55 @@ +import { NextResponse } from "next/server"; +import { getModelAliases, setModelAlias } from "@/models"; +import { AI_MODELS } from "@/shared/constants/config"; + +// GET /api/models - Get models with aliases +export async function GET() { + try { + const modelAliases = await getModelAliases(); + + const models = AI_MODELS.map((m) => { + const fullModel = `${m.provider}/${m.model}`; + return { + ...m, + fullModel, + alias: modelAliases[fullModel] || m.model, + }; + }); + + return NextResponse.json({ models }); + } catch (error) { + console.log("Error fetching models:", error); + return NextResponse.json({ error: "Failed to fetch models" }, { status: 500 }); + } +} + +// PUT /api/models - Update model alias +export async function PUT(request) { + try { + const body = await request.json(); + const { model, alias } = body; + + if (!model || !alias) { + return NextResponse.json({ error: "Model and alias required" }, { status: 400 }); + } + + const modelAliases = await getModelAliases(); + + // Check if alias already exists for different model + const existingModel = Object.entries(modelAliases).find( + ([key, val]) => val === alias && key !== model + ); + + if (existingModel) { + return NextResponse.json({ error: "Alias already in use" }, { status: 400 }); + } + + // Update alias + await setModelAlias(model, alias); + + return NextResponse.json({ success: true, model, alias }); + } catch (error) { + console.log("Error updating alias:", error); + return NextResponse.json({ error: "Failed to update alias" }, { status: 500 }); + } +} diff --git a/src/app/api/oauth/[provider]/[action]/route.js b/src/app/api/oauth/[provider]/[action]/route.js new file mode 100644 index 00000000..4ea39b68 --- /dev/null +++ b/src/app/api/oauth/[provider]/[action]/route.js @@ -0,0 +1,187 @@ +import { NextResponse } from "next/server"; +import { + getProvider, + generateAuthData, + exchangeTokens, + requestDeviceCode, + pollForToken +} from "@/lib/oauth/providers"; +import { createProviderConnection, isCloudEnabled } from "@/models"; +import { getConsistentMachineId } from "@/shared/utils/machineId"; +import { syncToCloud } from "@/app/api/sync/cloud/route"; + +/** + * Dynamic OAuth API Route + * Handles: authorize, exchange, device-code, poll + */ + +// GET /api/oauth/[provider]/authorize - Generate auth URL +// GET /api/oauth/[provider]/device-code - Request device code (for device_code flow) +export async function GET(request, { params }) { + try { + const { provider, action } = await params; + const { searchParams } = new URL(request.url); + + if (action === "authorize") { + const redirectUri = searchParams.get("redirect_uri") || "http://localhost:8080/callback"; + const authData = generateAuthData(provider, redirectUri); + return NextResponse.json(authData); + } + + if (action === "device-code") { + const providerData = getProvider(provider); + if (providerData.flowType !== "device_code") { + return NextResponse.json({ error: "Provider does not support device code flow" }, { status: 400 }); + } + + const authData = generateAuthData(provider, null); + + // For providers that don't use PKCE (like GitHub), don't pass codeChallenge + let deviceData; + if (provider === "github") { + deviceData = await requestDeviceCode(provider); + } else { + // Qwen and other providers use PKCE + deviceData = await requestDeviceCode(provider, authData.codeChallenge); + } + + return NextResponse.json({ + ...deviceData, + codeVerifier: authData.codeVerifier, + }); + } + + return NextResponse.json({ error: "Unknown action" }, { status: 400 }); + } catch (error) { + console.log("OAuth GET error:", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} + +// POST /api/oauth/[provider]/exchange - Exchange code for tokens and save +// POST /api/oauth/[provider]/poll - Poll for token (device_code flow) +export async function POST(request, { params }) { + try { + const { provider, action } = await params; + const body = await request.json(); + + if (action === "exchange") { + const { code, redirectUri, codeVerifier, state } = body; + + if (!code || !redirectUri || !codeVerifier) { + return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); + } + + // Exchange code for tokens + const tokenData = await exchangeTokens(provider, code, redirectUri, codeVerifier, state); + + // Save to database + const connection = await createProviderConnection({ + provider, + authType: "oauth", + ...tokenData, + expiresAt: tokenData.expiresIn + ? new Date(Date.now() + tokenData.expiresIn * 1000).toISOString() + : null, + testStatus: "active", + }); + + // Auto sync to Cloud if enabled + await syncToCloudIfEnabled(); + + return NextResponse.json({ + success: true, + connection: { + id: connection.id, + provider: connection.provider, + email: connection.email, + displayName: connection.displayName, + } + }); + } + + if (action === "poll") { + const { deviceCode, codeVerifier } = body; + + if (!deviceCode) { + return NextResponse.json({ error: "Missing device code" }, { status: 400 }); + } + + // For providers that don't use PKCE (like GitHub), don't pass codeVerifier + let result; + if (provider === "github") { + result = await pollForToken(provider, deviceCode); + } else { + // Qwen and other providers use PKCE + if (!codeVerifier) { + return NextResponse.json({ error: "Missing code verifier" }, { status: 400 }); + } + result = await pollForToken(provider, deviceCode, codeVerifier); + } + + if (result.success) { + // Save to database + const connection = await createProviderConnection({ + provider, + authType: "oauth", + ...result.tokens, + expiresAt: result.tokens.expiresIn + ? new Date(Date.now() + result.tokens.expiresIn * 1000).toISOString() + : null, + testStatus: "active", + }); + + // Auto sync to Cloud if enabled + await syncToCloudIfEnabled(); + + return NextResponse.json({ + success: true, + connection: { + id: connection.id, + provider: connection.provider, + } + }); + } + + // Still pending or error + if (!result.pending) { + // Save error to database for actual errors (not pending) + await createProviderConnection({ + provider, + authType: "oauth", + testStatus: "error", + lastError: result.errorDescription, + errorCode: result.error, + lastErrorAt: new Date().toISOString(), + }); + } + + return NextResponse.json({ + success: false, + error: result.error, + errorDescription: result.errorDescription, + pending: result.pending || result.error === "authorization_pending", + }); + } + + return NextResponse.json({ error: "Unknown action" }, { status: 400 }); + } catch (error) { + console.log("OAuth POST error:", error); + return NextResponse.json({ error: error.message }, { 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 after OAuth:", error); + } +} diff --git a/src/app/api/providers/[id]/models/route.js b/src/app/api/providers/[id]/models/route.js new file mode 100644 index 00000000..a514eec6 --- /dev/null +++ b/src/app/api/providers/[id]/models/route.js @@ -0,0 +1,148 @@ +import { NextResponse } from "next/server"; +import { getProviderConnectionById } from "@/models"; + +// Provider models endpoints configuration +const PROVIDER_MODELS_CONFIG = { + claude: { + url: "https://api.anthropic.com/v1/models", + method: "GET", + headers: { + "Anthropic-Version": "2023-06-01", + "Content-Type": "application/json" + }, + authHeader: "x-api-key", + parseResponse: (data) => data.data || [] + }, + gemini: { + url: "https://generativelanguage.googleapis.com/v1beta/models", + method: "GET", + headers: { "Content-Type": "application/json" }, + authQuery: "key", // Use query param for API key + parseResponse: (data) => data.models || [] + }, + "gemini-cli": { + url: "https://generativelanguage.googleapis.com/v1beta/models", + method: "GET", + headers: { "Content-Type": "application/json" }, + authHeader: "Authorization", + authPrefix: "Bearer ", + parseResponse: (data) => data.models || [] + }, + qwen: { + url: "https://portal.qwen.ai/v1/models", + method: "GET", + headers: { "Content-Type": "application/json" }, + authHeader: "Authorization", + authPrefix: "Bearer ", + parseResponse: (data) => data.data || [] + }, + antigravity: { + url: "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:models", + method: "POST", + headers: { "Content-Type": "application/json" }, + authHeader: "Authorization", + authPrefix: "Bearer ", + body: {}, + parseResponse: (data) => data.models || [] + }, + openai: { + url: "https://api.openai.com/v1/models", + method: "GET", + headers: { "Content-Type": "application/json" }, + authHeader: "Authorization", + authPrefix: "Bearer ", + parseResponse: (data) => data.data || [] + }, + openrouter: { + url: "https://openrouter.ai/api/v1/models", + method: "GET", + headers: { "Content-Type": "application/json" }, + authHeader: "Authorization", + authPrefix: "Bearer ", + parseResponse: (data) => data.data || [] + }, + anthropic: { + url: "https://api.anthropic.com/v1/models", + method: "GET", + headers: { + "Anthropic-Version": "2023-06-01", + "Content-Type": "application/json" + }, + authHeader: "x-api-key", + parseResponse: (data) => data.data || [] + } +}; + +/** + * GET /api/providers/[id]/models - Get models list from provider + */ +export async function GET(request, { params }) { + try { + const { id } = await params; + const connection = await getProviderConnectionById(id); + + if (!connection) { + return NextResponse.json({ error: "Connection not found" }, { status: 404 }); + } + + const config = PROVIDER_MODELS_CONFIG[connection.provider]; + if (!config) { + return NextResponse.json( + { error: `Provider ${connection.provider} does not support models listing` }, + { status: 400 } + ); + } + + // Get auth token + const token = connection.accessToken || connection.apiKey; + if (!token) { + return NextResponse.json({ error: "No valid token found" }, { status: 401 }); + } + + // Build request URL + let url = config.url; + if (config.authQuery) { + url += `?${config.authQuery}=${token}`; + } + + // Build headers + const headers = { ...config.headers }; + if (config.authHeader && !config.authQuery) { + headers[config.authHeader] = (config.authPrefix || "") + token; + } + + // Make request + const fetchOptions = { + method: config.method, + headers + }; + + if (config.body && config.method === "POST") { + fetchOptions.body = JSON.stringify(config.body); + } + + const response = await fetch(url, fetchOptions); + + if (!response.ok) { + const errorText = await response.text(); + console.log(`Error fetching models from ${connection.provider}:`, errorText); + return NextResponse.json( + { error: `Failed to fetch models: ${response.status}` }, + { status: response.status } + ); + } + + const data = await response.json(); + const models = config.parseResponse(data); + + return NextResponse.json({ + provider: connection.provider, + connectionId: connection.id, + models + }); + } catch (error) { + console.log("Error fetching provider models:", error); + return NextResponse.json({ error: "Failed to fetch models" }, { status: 500 }); + } +} + diff --git a/src/app/api/providers/[id]/route.js b/src/app/api/providers/[id]/route.js new file mode 100644 index 00000000..5d1b1477 --- /dev/null +++ b/src/app/api/providers/[id]/route.js @@ -0,0 +1,102 @@ +import { NextResponse } from "next/server"; +import { getProviderConnectionById, updateProviderConnection, deleteProviderConnection, isCloudEnabled } from "@/models"; +import { getConsistentMachineId } from "@/shared/utils/machineId"; +import { syncToCloud } from "@/app/api/sync/cloud/route"; + +// GET /api/providers/[id] - Get single connection +export async function GET(request, { params }) { + try { + const { id } = await params; + const connection = await getProviderConnectionById(id); + + if (!connection) { + return NextResponse.json({ error: "Connection not found" }, { status: 404 }); + } + + // Hide sensitive fields + const result = { ...connection }; + delete result.apiKey; + delete result.accessToken; + delete result.refreshToken; + delete result.idToken; + + return NextResponse.json({ connection: result }); + } catch (error) { + console.log("Error fetching connection:", error); + return NextResponse.json({ error: "Failed to fetch connection" }, { status: 500 }); + } +} + +// PUT /api/providers/[id] - Update connection +export async function PUT(request, { params }) { + try { + const { id } = await params; + const body = await request.json(); + const { name, priority, globalPriority, defaultModel, isActive, apiKey } = body; + + const existing = await getProviderConnectionById(id); + if (!existing) { + return NextResponse.json({ error: "Connection not found" }, { status: 404 }); + } + + const updateData = {}; + if (name !== undefined) updateData.name = name; + if (priority !== undefined) updateData.priority = priority; + if (globalPriority !== undefined) updateData.globalPriority = globalPriority; + if (defaultModel !== undefined) updateData.defaultModel = defaultModel; + if (isActive !== undefined) updateData.isActive = isActive; + if (apiKey && existing.authType === "apikey") updateData.apiKey = apiKey; + + const updated = await updateProviderConnection(id, updateData); + + // Hide sensitive fields + const result = { ...updated }; + delete result.apiKey; + delete result.accessToken; + delete result.refreshToken; + delete result.idToken; + + // Auto sync to Cloud if enabled + await syncToCloudIfEnabled(); + + return NextResponse.json({ connection: result }); + } catch (error) { + console.log("Error updating connection:", error); + return NextResponse.json({ error: "Failed to update connection" }, { status: 500 }); + } +} + +// DELETE /api/providers/[id] - Delete connection +export async function DELETE(request, { params }) { + try { + const { id } = await params; + + const deleted = await deleteProviderConnection(id); + if (!deleted) { + return NextResponse.json({ error: "Connection not found" }, { status: 404 }); + } + + // Auto sync to Cloud if enabled + await syncToCloudIfEnabled(); + + return NextResponse.json({ message: "Connection deleted successfully" }); + } catch (error) { + console.log("Error deleting connection:", error); + return NextResponse.json({ error: "Failed to delete connection" }, { 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 providers to cloud:", error); + } +} diff --git a/src/app/api/providers/[id]/test/route.js b/src/app/api/providers/[id]/test/route.js new file mode 100644 index 00000000..30459c67 --- /dev/null +++ b/src/app/api/providers/[id]/test/route.js @@ -0,0 +1,95 @@ +import { NextResponse } from "next/server"; +import { getProviderConnectionById, updateProviderConnection } from "@/lib/localDb"; + +// POST /api/providers/[id]/test - Test connection +export async function POST(request, { params }) { + try { + const { id } = await params; + const connection = await getProviderConnectionById(id); + + if (!connection) { + return NextResponse.json({ error: "Connection not found" }, { status: 404 }); + } + + let isValid = false; + let error = null; + + try { + if (connection.authType === "apikey") { + // Test API key + switch (connection.provider) { + case "openai": + const openaiRes = await fetch("https://api.openai.com/v1/models", { + headers: { "Authorization": `Bearer ${connection.apiKey}` }, + }); + isValid = openaiRes.ok; + break; + + case "anthropic": + const anthropicRes = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "x-api-key": connection.apiKey, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + }, + body: JSON.stringify({ + model: "claude-3-haiku-20240307", + max_tokens: 1, + messages: [{ role: "user", content: "test" }], + }), + }); + isValid = anthropicRes.status !== 401; + break; + + case "gemini": + const geminiRes = await fetch(`https://generativelanguage.googleapis.com/v1/models?key=${connection.apiKey}`); + isValid = geminiRes.ok; + break; + + case "openrouter": + const openrouterRes = await fetch("https://openrouter.ai/api/v1/models", { + headers: { "Authorization": `Bearer ${connection.apiKey}` }, + }); + isValid = openrouterRes.ok; + break; + + default: + error = "Provider test not supported"; + } + } else { + // OAuth - check if token exists and not expired + if (connection.accessToken) { + if (connection.expiresAt) { + const expiresAt = new Date(connection.expiresAt).getTime(); + isValid = expiresAt > Date.now(); + if (!isValid) error = "Token expired"; + } else { + isValid = true; + } + } else { + error = "No access token"; + } + } + } catch (err) { + error = err.message; + isValid = false; + } + + // Update status in db + await updateProviderConnection(id, { + testStatus: isValid ? "active" : "error", + lastError: isValid ? null : error, + lastErrorAt: isValid ? null : new Date().toISOString(), + }); + + return NextResponse.json({ + valid: isValid, + error: isValid ? null : error, + }); + } catch (error) { + console.log("Error testing connection:", error); + return NextResponse.json({ error: "Test failed" }, { status: 500 }); + } +} + diff --git a/src/app/api/providers/client/route.js b/src/app/api/providers/client/route.js new file mode 100644 index 00000000..5428cd81 --- /dev/null +++ b/src/app/api/providers/client/route.js @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; +import { getProviderConnections } from "@/lib/localDb"; + +// GET /api/providers/client - List all connections for client (includes sensitive fields for sync) +export async function GET() { + try { + const connections = await getProviderConnections(); + + // Include sensitive fields for sync to cloud (only accessible from same origin) + const clientConnections = connections.map(c => ({ + ...c, + // Don't hide sensitive fields here since this is for internal sync + })); + + return NextResponse.json({ connections: clientConnections }); + } catch (error) { + console.log("Error fetching providers for client:", error); + return NextResponse.json({ error: "Failed to fetch providers" }, { status: 500 }); + } +} diff --git a/src/app/api/providers/route.js b/src/app/api/providers/route.js new file mode 100644 index 00000000..6b1fbc77 --- /dev/null +++ b/src/app/api/providers/route.js @@ -0,0 +1,84 @@ +import { NextResponse } from "next/server"; +import { getProviderConnections, createProviderConnection, isCloudEnabled } from "@/models"; +import { APIKEY_PROVIDERS } from "@/shared/constants/config"; +import { getConsistentMachineId } from "@/shared/utils/machineId"; +import { syncToCloud } from "@/app/api/sync/cloud/route"; + +// GET /api/providers - List all connections +export async function GET() { + try { + const connections = await getProviderConnections(); + + // Hide sensitive fields + const safeConnections = connections.map(c => ({ + ...c, + apiKey: undefined, + accessToken: undefined, + refreshToken: undefined, + idToken: undefined, + })); + + return NextResponse.json({ connections: safeConnections }); + } catch (error) { + console.log("Error fetching providers:", error); + return NextResponse.json({ error: "Failed to fetch providers" }, { status: 500 }); + } +} + +// POST /api/providers - Create new connection (API Key only, OAuth via separate flow) +export async function POST(request) { + try { + const body = await request.json(); + const { provider, apiKey, name, priority, globalPriority, defaultModel, testStatus } = body; + + // Validation + if (!provider || !APIKEY_PROVIDERS[provider]) { + return NextResponse.json({ error: "Invalid provider" }, { status: 400 }); + } + if (!apiKey) { + return NextResponse.json({ error: "API Key is required" }, { status: 400 }); + } + if (!name) { + return NextResponse.json({ error: "Name is required" }, { status: 400 }); + } + + const newConnection = await createProviderConnection({ + provider, + authType: "apikey", + name, + apiKey, + priority: priority || 1, + globalPriority: globalPriority || null, + defaultModel: defaultModel || null, + isActive: true, + testStatus: testStatus || "unknown", + }); + + // Hide sensitive fields + const result = { ...newConnection }; + delete result.apiKey; + + // Auto sync to Cloud if enabled + await syncToCloudIfEnabled(); + + return NextResponse.json({ connection: result }, { status: 201 }); + } catch (error) { + console.log("Error creating provider:", error); + return NextResponse.json({ error: "Failed to create provider" }, { 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 providers to cloud:", error); + } +} diff --git a/src/app/api/providers/validate/route.js b/src/app/api/providers/validate/route.js new file mode 100644 index 00000000..94d5b23f --- /dev/null +++ b/src/app/api/providers/validate/route.js @@ -0,0 +1,96 @@ +import { NextResponse } from "next/server"; + +// POST /api/providers/validate - Validate API key with provider +export async function POST(request) { + try { + const body = await request.json(); + const { provider, apiKey } = body; + + if (!provider || !apiKey) { + return NextResponse.json({ error: "Provider and API key required" }, { status: 400 }); + } + + let isValid = false; + let error = null; + + // Validate with each provider + try { + switch (provider) { + case "openai": + const openaiRes = await fetch("https://api.openai.com/v1/models", { + headers: { "Authorization": `Bearer ${apiKey}` }, + }); + isValid = openaiRes.ok; + break; + + case "anthropic": + const anthropicRes = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + }, + body: JSON.stringify({ + model: "claude-3-haiku-20240307", + max_tokens: 1, + messages: [{ role: "user", content: "test" }], + }), + }); + isValid = anthropicRes.status !== 401; + break; + + case "gemini": + const geminiRes = await fetch(`https://generativelanguage.googleapis.com/v1/models?key=${apiKey}`); + isValid = geminiRes.ok; + break; + + case "openrouter": + const openrouterRes = await fetch("https://openrouter.ai/api/v1/models", { + headers: { "Authorization": `Bearer ${apiKey}` }, + }); + isValid = openrouterRes.ok; + break; + + case "glm": + case "kimi": + case "minimax": { + const claudeBaseUrls = { + glm: "https://api.z.ai/api/anthropic/v1/messages", + kimi: "https://api.kimi.com/coding/v1/messages", + minimax: "https://api.minimax.io/anthropic/v1/messages", + }; + const claudeRes = await fetch(claudeBaseUrls[provider], { + method: "POST", + headers: { + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + "content-type": "application/json", + }, + body: JSON.stringify({ + model: "claude-sonnet-4-20250514", + max_tokens: 1, + messages: [{ role: "user", content: "test" }], + }), + }); + isValid = claudeRes.status !== 401; + break; + } + + default: + return NextResponse.json({ error: "Provider validation not supported" }, { status: 400 }); + } + } catch (err) { + error = err.message; + isValid = false; + } + + return NextResponse.json({ + valid: isValid, + error: isValid ? null : (error || "Invalid API key"), + }); + } catch (error) { + console.log("Error validating API key:", error); + return NextResponse.json({ error: "Validation failed" }, { status: 500 }); + } +} diff --git a/src/app/api/settings/route.js b/src/app/api/settings/route.js new file mode 100644 index 00000000..423f1826 --- /dev/null +++ b/src/app/api/settings/route.js @@ -0,0 +1,12 @@ +import { NextResponse } from "next/server"; +import { getSettings } from "@/lib/localDb"; + +export async function GET() { + try { + const settings = await getSettings(); + return NextResponse.json(settings); + } catch (error) { + console.log("Error getting settings:", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/shutdown/route.js b/src/app/api/shutdown/route.js new file mode 100644 index 00000000..7ce76ef9 --- /dev/null +++ b/src/app/api/shutdown/route.js @@ -0,0 +1,12 @@ +import { NextResponse } from "next/server"; + +export async function POST() { + const response = NextResponse.json({ success: true, message: "Shutting down..." }); + + setTimeout(() => { + process.exit(0); + }, 500); + + return response; +} + diff --git a/src/app/api/sync/cloud/route.js b/src/app/api/sync/cloud/route.js new file mode 100644 index 00000000..b0ae37d9 --- /dev/null +++ b/src/app/api/sync/cloud/route.js @@ -0,0 +1,255 @@ +import { NextResponse } from "next/server"; +import { getProviderConnections, getModelAliases, getCombos, getApiKeys, createApiKey, updateProviderConnection, updateSettings } from "@/lib/localDb"; +import { getConsistentMachineId } from "@/shared/utils/machineId"; +import fs from "fs/promises"; +import path from "path"; +import os from "os"; + +const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; + +/** + * POST /api/sync/cloud + * Sync data with Cloud + */ +export async function POST(request) { + try { + const body = await request.json(); + const { action } = body; + + // Always get machineId from server, don't trust client + const machineId = await getConsistentMachineId(); + + switch (action) { + case "enable": + await updateSettings({ cloudEnabled: true }); + // Auto create key if none exists + const keys = await getApiKeys(); + let createdKey = null; + if (keys.length === 0) { + createdKey = await createApiKey("Default Key", machineId); + } + return syncAndVerify(machineId, createdKey?.key, keys); + case "sync": { + const syncResult = await syncToCloud(machineId); + if (syncResult.error) { + return NextResponse.json(syncResult, { status: 502 }); + } + return NextResponse.json(syncResult); + } + case "disable": + await updateSettings({ cloudEnabled: false }); + return handleDisable(machineId, request); + default: + return NextResponse.json({ error: "Invalid action" }, { status: 400 }); + } + } catch (error) { + console.log("Cloud sync error:", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} + +/** + * Sync data to Cloud (exported for reuse) + * @param {string} machineId + * @param {string|null} createdKey - Key created during enable + */ +export async function syncToCloud(machineId, createdKey = null) { + // Get current data from db + const providers = await getProviderConnections(); + const modelAliases = await getModelAliases(); + const combos = await getCombos(); + const apiKeys = await getApiKeys(); + + // Send to Cloud + const response = await fetch(`${CLOUD_URL}/sync/${machineId}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + providers, + modelAliases, + combos, + apiKeys + }) + }); + + if (!response.ok) { + const errorText = await response.text(); + console.log("Cloud sync failed:", errorText); + return NextResponse.json({ error: "Cloud sync failed" }, { status: 502 }); + } + + const result = await response.json(); + + // Update local db with tokens from Cloud (providers stored by ID) + if (result.data && result.data.providers) { + await updateLocalTokens(result.data.providers); + } + + const responseData = { + success: true, + message: "Synced successfully", + changes: result.changes + }; + + if (createdKey) { + responseData.createdKey = createdKey; + } + + return responseData; +} + +/** + * Sync and verify connection with ping + */ +async function syncAndVerify(machineId, createdKey, existingKeys) { + // Step 1: Sync data to cloud + const syncResult = await syncToCloud(machineId, createdKey); + if (syncResult.error) { + return NextResponse.json(syncResult, { status: 502 }); + } + + // Step 2: Verify connection by pinging the cloud + const apiKey = createdKey || existingKeys[0]?.key; + if (!apiKey) { + return NextResponse.json({ + ...syncResult, + verified: false, + verifyError: "No API key available" + }); + } + + try { + const pingResponse = await fetch(`${CLOUD_URL}/${machineId}/v1/verify`, { + method: "GET", + headers: { + "Authorization": `Bearer ${apiKey}`, + "Content-Type": "application/json" + } + }); + + if (pingResponse.ok) { + return NextResponse.json({ + ...syncResult, + verified: true + }); + } else { + return NextResponse.json({ + ...syncResult, + verified: false, + verifyError: `Ping failed: ${pingResponse.status}` + }); + } + } catch (error) { + return NextResponse.json({ + ...syncResult, + verified: false, + verifyError: error.message + }); + } +} + +/** + * Disable Cloud - delete cache and update Claude CLI settings + */ +async function handleDisable(machineId, request) { + const response = await fetch(`${CLOUD_URL}/sync/${machineId}`, { + method: "DELETE" + }); + + if (!response.ok) { + const errorText = await response.text(); + console.log("Cloud disable failed:", errorText); + return NextResponse.json({ error: "Failed to disable cloud" }, { status: 502 }); + } + + // Update Claude CLI settings to use local endpoint + const host = request.headers.get("host") || "localhost:3000"; + await updateClaudeSettingsToLocal(machineId, host); + + return NextResponse.json({ + success: true, + message: "Cloud disabled" + }); +} + +/** + * Update Claude CLI settings to use local endpoint (only if currently using cloud) + */ +async function updateClaudeSettingsToLocal(machineId, host) { + try { + const settingsPath = path.join(os.homedir(), ".claude", "settings.json"); + const cloudUrl = `${CLOUD_URL}/${machineId}`; + const localUrl = `http://${host}`; + + // Read current settings + let settings; + try { + const content = await fs.readFile(settingsPath, "utf-8"); + settings = JSON.parse(content); + } catch (error) { + if (error.code === "ENOENT") { + return; // No settings file, nothing to update + } + throw error; + } + + // Check if ANTHROPIC_BASE_URL matches cloud URL + const currentUrl = settings.env?.ANTHROPIC_BASE_URL; + if (!currentUrl || currentUrl !== cloudUrl) { + return; // Not using cloud URL, don't modify + } + + // Update to local URL + settings.env.ANTHROPIC_BASE_URL = localUrl; + await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2)); + console.log(`Updated Claude CLI settings: ${cloudUrl} → ${localUrl}`); + } catch (error) { + console.log("Failed to update Claude CLI settings:", error.message); + } +} + +/** + * Update local db with data from Cloud + * Simple logic: if Cloud is newer, sync entire provider + * cloudProviders is object keyed by provider ID + */ +async function updateLocalTokens(cloudProviders) { + const localProviders = await getProviderConnections(); + + for (const localProvider of localProviders) { + const cloudProvider = cloudProviders[localProvider.id]; + if (!cloudProvider) continue; + + const cloudUpdatedAt = new Date(cloudProvider.updatedAt || 0).getTime(); + const localUpdatedAt = new Date(localProvider.updatedAt || 0).getTime(); + + // Simple logic: if Cloud is newer, sync entire provider + if (cloudUpdatedAt > localUpdatedAt) { + const updates = { + // Tokens + accessToken: cloudProvider.accessToken, + refreshToken: cloudProvider.refreshToken, + expiresAt: cloudProvider.expiresAt, + expiresIn: cloudProvider.expiresIn, + + // Provider specific data + providerSpecificData: cloudProvider.providerSpecificData || localProvider.providerSpecificData, + + // Status fields + testStatus: cloudProvider.status || "active", + lastError: cloudProvider.lastError, + lastErrorAt: cloudProvider.lastErrorAt, + errorCode: cloudProvider.errorCode, + rateLimitedUntil: cloudProvider.rateLimitedUntil, + + // Metadata + updatedAt: cloudProvider.updatedAt + }; + + await updateProviderConnection(localProvider.id, updates); + console.log(`Updated ${localProvider.provider} (${localProvider.id}) from Cloud (newer: ${new Date(cloudUpdatedAt).toISOString()})`); + } else { + console.log(`Skipped ${localProvider.provider} (${localProvider.id}) - Local is newer or equal`); + } + } +} diff --git a/src/app/api/sync/initialize/route.js b/src/app/api/sync/initialize/route.js new file mode 100644 index 00000000..524cc85e --- /dev/null +++ b/src/app/api/sync/initialize/route.js @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import initializeCloudSync from "@/shared/services/initializeCloudSync"; + +let syncInitialized = false; + +// POST /api/sync/initialize - Initialize cloud sync scheduler +export async function POST(request) { + try { + if (syncInitialized) { + return NextResponse.json({ + message: "Cloud sync already initialized" + }); + } + + await initializeCloudSync(); + syncInitialized = true; + + return NextResponse.json({ + success: true, + message: "Cloud sync initialized successfully" + }); + } catch (error) { + console.log("Error initializing cloud sync:", error); + return NextResponse.json({ + error: "Failed to initialize cloud sync" + }, { status: 500 }); + } +} + +// GET /api/sync/status - Check sync initialization status +export async function GET(request) { + return NextResponse.json({ + initialized: syncInitialized, + message: syncInitialized ? "Cloud sync is running" : "Cloud sync not initialized" + }); +} diff --git a/src/app/api/tags/route.js b/src/app/api/tags/route.js new file mode 100644 index 00000000..53bfb2ed --- /dev/null +++ b/src/app/api/tags/route.js @@ -0,0 +1,18 @@ +import { ollamaModels } from "open-sse/config/ollamaModels.js"; + +const CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "*" +}; + +export async function OPTIONS() { + return new Response(null, { headers: CORS_HEADERS }); +} + +export async function GET() { + return new Response(JSON.stringify(ollamaModels), { + headers: { "Content-Type": "application/json", ...CORS_HEADERS } + }); +} + diff --git a/src/app/api/usage/[connectionId]/route.js b/src/app/api/usage/[connectionId]/route.js new file mode 100644 index 00000000..acec75f9 --- /dev/null +++ b/src/app/api/usage/[connectionId]/route.js @@ -0,0 +1,30 @@ +import { getProviderConnectionById } from "@/lib/localDb"; +import { getUsageForProvider } from "open-sse/services/usage.js"; + +/** + * GET /api/usage/[connectionId] - Get usage data for a specific connection + */ +export async function GET(request, { params }) { + try { + const { connectionId } = await params; + + // Get connection from database + const connection = await getProviderConnectionById(connectionId); + if (!connection) { + return Response.json({ error: "Connection not found" }, { status: 404 }); + } + + // Only OAuth connections have usage APIs + if (connection.authType !== "oauth") { + return Response.json({ message: "Usage not available for API key connections" }); + } + + // Fetch usage from provider API + const usage = await getUsageForProvider(connection); + return Response.json(usage); + } catch (error) { + console.log("Error fetching usage:", error); + return Response.json({ error: error.message }, { status: 500 }); + } +} + diff --git a/src/app/api/v1/api/chat/route.js b/src/app/api/v1/api/chat/route.js new file mode 100644 index 00000000..b7ae8e80 --- /dev/null +++ b/src/app/api/v1/api/chat/route.js @@ -0,0 +1,38 @@ +import { handleChat } from "@/sse/handlers/chat.js"; +import { initTranslators } from "open-sse/translator/index.js"; +import { transformToOllama } from "open-sse/utils/ollamaTransform.js"; + +let initialized = false; + +async function ensureInitialized() { + if (!initialized) { + await initTranslators(); + initialized = true; + console.log("[SSE] Translators initialized"); + } +} + +export async function OPTIONS() { + return new Response(null, { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "*" + } + }); +} + +export async function POST(request) { + await ensureInitialized(); + + const clonedReq = request.clone(); + let modelName = "llama3.2"; + try { + const body = await clonedReq.json(); + modelName = body.model || "llama3.2"; + } catch {} + + const response = await handleChat(request); + return transformToOllama(response, modelName); +} + diff --git a/src/app/api/v1/chat/completions/route.js b/src/app/api/v1/chat/completions/route.js new file mode 100644 index 00000000..cb74a508 --- /dev/null +++ b/src/app/api/v1/chat/completions/route.js @@ -0,0 +1,37 @@ +import { callCloudWithMachineId } from "@/shared/utils/cloud.js"; +import { handleChat } from "@/sse/handlers/chat.js"; +import { initTranslators } from "open-sse/translator/index.js"; + +let initialized = false; + +/** + * Initialize translators once + */ +async function ensureInitialized() { + if (!initialized) { + await initTranslators(); + initialized = true; + console.log("[SSE] Translators initialized"); + } +} + +/** + * Handle CORS preflight + */ +export async function OPTIONS() { + return new Response(null, { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "*" + } + }); +} + +export async function POST(request) { + // Fallback to local handling + await ensureInitialized(); + + return await handleChat(request); +} + diff --git a/src/app/api/v1/messages/count_tokens/route.js b/src/app/api/v1/messages/count_tokens/route.js new file mode 100644 index 00000000..c5a2918f --- /dev/null +++ b/src/app/api/v1/messages/count_tokens/route.js @@ -0,0 +1,52 @@ +const CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "*" +}; + +/** + * Handle CORS preflight + */ +export async function OPTIONS() { + return new Response(null, { headers: CORS_HEADERS }); +} + +/** + * POST /v1/messages/count_tokens - Mock token count response + */ +export async function POST(request) { + let body; + try { + body = await request.json(); + } catch { + return new Response(JSON.stringify({ error: "Invalid JSON body" }), { + status: 400, + headers: { "Content-Type": "application/json", ...CORS_HEADERS } + }); + } + + // Estimate token count based on content length + const messages = body.messages || []; + let totalChars = 0; + for (const msg of messages) { + if (typeof msg.content === "string") { + totalChars += msg.content.length; + } else if (Array.isArray(msg.content)) { + for (const part of msg.content) { + if (part.type === "text" && part.text) { + totalChars += part.text.length; + } + } + } + } + + // Rough estimate: ~4 chars per token + const inputTokens = Math.ceil(totalChars / 4); + + return new Response(JSON.stringify({ + input_tokens: inputTokens + }), { + headers: { "Content-Type": "application/json", ...CORS_HEADERS } + }); +} + diff --git a/src/app/api/v1/messages/route.js b/src/app/api/v1/messages/route.js new file mode 100644 index 00000000..7bfbbbf7 --- /dev/null +++ b/src/app/api/v1/messages/route.js @@ -0,0 +1,37 @@ +import { handleChat } from "@/sse/handlers/chat.js"; +import { initTranslators } from "open-sse/translator/index.js"; + +let initialized = false; + +/** + * Initialize translators once + */ +async function ensureInitialized() { + if (!initialized) { + await initTranslators(); + initialized = true; + console.log("[SSE] Translators initialized for /v1/messages"); + } +} + +/** + * Handle CORS preflight + */ +export async function OPTIONS() { + return new Response(null, { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "*" + } + }); +} + +/** + * POST /v1/messages - Claude format (auto convert via handleChat) + */ +export async function POST(request) { + await ensureInitialized(); + return await handleChat(request); +} + diff --git a/src/app/api/v1/responses/route.js b/src/app/api/v1/responses/route.js new file mode 100644 index 00000000..543138d3 --- /dev/null +++ b/src/app/api/v1/responses/route.js @@ -0,0 +1,31 @@ +import { handleChat } from "@/sse/handlers/chat.js"; +import { initTranslators } from "open-sse/translator/index.js"; + +let initialized = false; + +async function ensureInitialized() { + if (!initialized) { + await initTranslators(); + initialized = true; + console.log("[SSE] Translators initialized for /v1/responses"); + } +} + +export async function OPTIONS() { + return new Response(null, { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "*" + } + }); +} + +/** + * POST /v1/responses - OpenAI Responses API format + * Now handled by translator pattern (openai-responses format auto-detected) + */ +export async function POST(request) { + await ensureInitialized(); + return await handleChat(request); +} diff --git a/src/app/api/v1/route.js b/src/app/api/v1/route.js new file mode 100644 index 00000000..382c6c3d --- /dev/null +++ b/src/app/api/v1/route.js @@ -0,0 +1,32 @@ +const CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "*" +}; + +/** + * Handle CORS preflight + */ +export async function OPTIONS() { + return new Response(null, { headers: CORS_HEADERS }); +} + +/** + * GET /v1 - Return models list (OpenAI compatible) + */ +export async function GET() { + const models = [ + { id: "claude-sonnet-4-20250514", object: "model", owned_by: "anthropic" }, + { id: "claude-3-5-sonnet-20241022", object: "model", owned_by: "anthropic" }, + { id: "gpt-4o", object: "model", owned_by: "openai" }, + { id: "gemini-2.5-pro", object: "model", owned_by: "google" } + ]; + + return new Response(JSON.stringify({ + object: "list", + data: models + }), { + headers: { "Content-Type": "application/json", ...CORS_HEADERS } + }); +} + diff --git a/src/app/api/v1beta/models/[...path]/route.js b/src/app/api/v1beta/models/[...path]/route.js new file mode 100644 index 00000000..cb9a63ca --- /dev/null +++ b/src/app/api/v1beta/models/[...path]/route.js @@ -0,0 +1,113 @@ +import { handleChat } from "@/sse/handlers/chat.js"; +import { initTranslators } from "open-sse/translator/index.js"; + +let initialized = false; + +/** + * Initialize translators once + */ +async function ensureInitialized() { + if (!initialized) { + await initTranslators(); + initialized = true; + console.log("[SSE] Translators initialized for /v1beta/models"); + } +} + +/** + * Handle CORS preflight + */ +export async function OPTIONS() { + return new Response(null, { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "*" + } + }); +} + +/** + * POST /v1beta/models/{model}:generateContent - Gemini compatible endpoint + * Converts Gemini format to internal format and handles via handleChat + */ +export async function POST(request, { params }) { + await ensureInitialized(); + + try { + const { path } = await params; + // path = ["provider", "model:generateContent"] or ["model:generateContent"] + + let model; + if (path.length >= 2) { + // Format: /v1beta/models/provider/model:generateContent + const provider = path[0]; + const modelAction = path[1]; + const modelName = modelAction.replace(":generateContent", "").replace(":streamGenerateContent", ""); + model = `${provider}/${modelName}`; + } else { + // Format: /v1beta/models/model:generateContent + const modelAction = path[0]; + model = modelAction.replace(":generateContent", "").replace(":streamGenerateContent", ""); + } + + const body = await request.json(); + + // Convert Gemini format to OpenAI/internal format + const convertedBody = convertGeminiToInternal(body, model); + + // Create new request with converted body + const newRequest = new Request(request.url, { + method: "POST", + headers: request.headers, + body: JSON.stringify(convertedBody), + }); + + return await handleChat(newRequest); + } catch (error) { + console.log("Error handling Gemini request:", error); + return Response.json( + { error: { message: error.message, code: 500 } }, + { status: 500 } + ); + } +} + +/** + * Convert Gemini request format to internal format + */ +function convertGeminiToInternal(geminiBody, model) { + const messages = []; + + // Convert system instruction + if (geminiBody.systemInstruction) { + const systemText = geminiBody.systemInstruction.parts + ?.map(p => p.text) + .join("\n") || ""; + if (systemText) { + messages.push({ role: "system", content: systemText }); + } + } + + // Convert contents to messages + if (geminiBody.contents) { + for (const content of geminiBody.contents) { + const role = content.role === "model" ? "assistant" : "user"; + const text = content.parts?.map(p => p.text).join("\n") || ""; + messages.push({ role, content: text }); + } + } + + // Determine if streaming + const stream = geminiBody.generationConfig?.stream !== false; + + return { + model, + messages, + stream, + max_tokens: geminiBody.generationConfig?.maxOutputTokens, + temperature: geminiBody.generationConfig?.temperature, + top_p: geminiBody.generationConfig?.topP, + }; +} + diff --git a/src/app/api/v1beta/models/route.js b/src/app/api/v1beta/models/route.js new file mode 100644 index 00000000..806ffa54 --- /dev/null +++ b/src/app/api/v1beta/models/route.js @@ -0,0 +1,44 @@ +import { PROVIDER_MODELS } from "@/shared/constants/models"; + +/** + * Handle CORS preflight + */ +export async function OPTIONS() { + return new Response(null, { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "*" + } + }); +} + +/** + * GET /v1beta/models - Gemini compatible models list + * Returns models in Gemini API format + */ +export async function GET() { + try { + // Collect all models from all providers + const models = []; + + for (const [provider, providerModels] of Object.entries(PROVIDER_MODELS)) { + for (const model of providerModels) { + models.push({ + name: `models/${provider}/${model.id}`, + displayName: model.name || model.id, + description: `${provider} model: ${model.name || model.id}`, + supportedGenerationMethods: ["generateContent"], + inputTokenLimit: 128000, + outputTokenLimit: 8192, + }); + } + } + + return Response.json({ models }); + } catch (error) { + console.log("Error fetching models:", error); + return Response.json({ error: { message: error.message } }, { status: 500 }); + } +} + diff --git a/src/app/callback/page.js b/src/app/callback/page.js new file mode 100644 index 00000000..6638321d --- /dev/null +++ b/src/app/callback/page.js @@ -0,0 +1,142 @@ +"use client"; + +import { Suspense, useEffect, useState } from "react"; +import { useSearchParams } from "next/navigation"; + +/** + * OAuth Callback Page Content + */ +function CallbackContent() { + const searchParams = useSearchParams(); + const [status, setStatus] = useState("processing"); + + useEffect(() => { + const code = searchParams.get("code"); + const state = searchParams.get("state"); + const error = searchParams.get("error"); + const errorDescription = searchParams.get("error_description"); + + const callbackData = { + code, + state, + error, + errorDescription, + fullUrl: window.location.href, + }; + + let sent = false; + + // Check if this callback is from expected origin/port + const expectedOrigins = [ + window.location.origin, // Same origin (for most providers) + "http://localhost:1455", // Codex specific port + ]; + + // Method 1: postMessage to opener (popup mode) + if (window.opener) { + try { + window.opener.postMessage({ type: "oauth_callback", data: callbackData }, "*"); // Allow any origin for local dev + sent = true; + } catch (e) { + console.log("postMessage failed:", e); + } + } + + // Method 2: BroadcastChannel (same origin tabs) + try { + const channel = new BroadcastChannel("oauth_callback"); + channel.postMessage(callbackData); + channel.close(); + sent = true; + } catch (e) { + console.log("BroadcastChannel failed:", e); + } + + // Method 3: localStorage event (fallback) + try { + localStorage.setItem("oauth_callback", JSON.stringify({ ...callbackData, timestamp: Date.now() })); + sent = true; + } catch (e) { + console.log("localStorage failed:", e); + } + + if (sent && (code || error)) { + // Use setTimeout to avoid synchronous setState in effect + setTimeout(() => { + setStatus("success"); + // Auto close after 1.5 seconds + setTimeout(() => { + window.close(); + // If can't close (not a popup), show success message + setTimeout(() => setStatus("done"), 500); + }, 1500); + }, 0); + } else { + setTimeout(() => setStatus("manual"), 0); + } + }, [searchParams]); + + return ( +
+
+ {status === "processing" && ( + <> +
+ progress_activity +
+

Processing...

+

Please wait while we complete the authorization.

+ + )} + + {(status === "success" || status === "done") && ( + <> +
+ check_circle +
+

Authorization Successful!

+

+ {status === "success" ? "This window will close automatically..." : "You can close this tab now."} +

+ + )} + + {status === "manual" && ( + <> +
+ info +
+

Copy This URL

+

+ Please copy the URL from the address bar and paste it in the application. +

+
+ {typeof window !== "undefined" ? window.location.href : ""} +
+ + )} +
+
+ ); +} + +/** + * OAuth Callback Page + * Receives callback from OAuth providers and sends data back via multiple methods + */ +export default function CallbackPage() { + return ( + +
+
+ progress_activity +
+

Loading...

+
+ + }> + +
+ ); +} diff --git a/src/app/favicon.ico b/src/app/favicon.ico new file mode 100644 index 00000000..718d6fea Binary files /dev/null and b/src/app/favicon.ico differ diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 00000000..c20e6d71 --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,169 @@ +@import "tailwindcss"; + +@custom-variant dark (&:where(.dark, .dark *)); + +/* Claude-inspired Color Palette */ +:root { + /* Primary - Warm Coral/Terracotta */ + --color-primary: #D97757; + --color-primary-hover: #C56243; + + /* Light theme */ + --color-bg: #FBF9F6; + --color-bg-alt: #F5F1ED; + --color-surface: #FFFFFF; + --color-sidebar: #F0EFEC; + --color-border: #E6E4DD; + --color-text-main: #383733; + --color-text-muted: #75736E; + + /* Shadows */ + --shadow-soft: 0 2px 10px rgba(0, 0, 0, 0.03), 0 10px 25px rgba(0, 0, 0, 0.02); + --shadow-warm: 0 4px 20px -2px rgba(217, 119, 87, 0.15); + --shadow-elevated: 0 20px 40px -4px rgba(60, 50, 45, 0.08); +} + +.dark { + /* Dark theme */ + --color-bg: #191918; + --color-bg-alt: #1F1F1E; + --color-surface: #242423; + --color-sidebar: #1F1F1E; + --color-border: #333331; + --color-text-main: #ECEBE8; + --color-text-muted: #9E9D99; + + /* Dark shadows */ + --shadow-soft: 0 2px 10px rgba(0, 0, 0, 0.2), 0 10px 25px rgba(0, 0, 0, 0.15); + --shadow-warm: 0 4px 20px -2px rgba(217, 119, 87, 0.2); + --shadow-elevated: 0 20px 40px -4px rgba(0, 0, 0, 0.4); +} + +@theme inline { + /* Primary */ + --color-primary: var(--color-primary); + --color-primary-hover: var(--color-primary-hover); + + /* Auto-switch colors (use CSS variables from :root/.dark) */ + --color-bg: var(--color-bg); + --color-surface: var(--color-surface); + --color-sidebar: var(--color-sidebar); + --color-border: var(--color-border); + --color-text-main: var(--color-text-main); + --color-text-muted: var(--color-text-muted); + + /* Static colors (for explicit light/dark usage) */ + --color-bg-light: #FBF9F6; + --color-bg-dark: #191918; + --color-surface-light: #FFFFFF; + --color-surface-dark: #242423; + --color-sidebar-light: #F0EFEC; + --color-sidebar-dark: #1F1F1E; + --color-border-light: #E6E4DD; + --color-border-dark: #333331; + --color-text-main-light: #383733; + --color-text-main-dark: #ECEBE8; + --color-text-muted-light: #75736E; + --color-text-muted-dark: #9E9D99; + + /* Shadows */ + --shadow-soft: var(--shadow-soft); + --shadow-warm: var(--shadow-warm); + --shadow-elevated: var(--shadow-elevated); + + /* Font */ + --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif; +} + +/* Base styles */ +body { + background-color: var(--color-bg); + color: var(--color-text-main); + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Selection */ +::selection { + background-color: rgba(217, 119, 87, 0.2); + color: var(--color-primary); +} + +/* Custom scrollbar */ +.custom-scrollbar::-webkit-scrollbar { + width: 6px; +} + +.custom-scrollbar::-webkit-scrollbar-track { + background: transparent; +} + +.custom-scrollbar::-webkit-scrollbar-thumb { + background-color: rgba(156, 163, 175, 0.3); + border-radius: 20px; +} + +.custom-scrollbar::-webkit-scrollbar-thumb:hover { + background-color: rgba(156, 163, 175, 0.5); +} + +/* Hero gradient */ +.bg-hero-gradient { + background: linear-gradient(180deg, #F5F1ED 0%, #FEFCFB 100%); +} + +.dark .bg-hero-gradient { + background: linear-gradient(180deg, #1F1F1E 0%, #191918 100%); +} + +/* Material Symbols */ +.material-symbols-outlined { + font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; +} + +.material-symbols-outlined.fill-1 { + font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24; +} + +/* Animations */ +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.animate-spin { + animation: spin 1s linear infinite; +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.animate-pulse { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +@keyframes border-glow { + 0%, 100% { + box-shadow: 0 0 5px rgba(217, 119, 87, 0.3), 0 0 10px rgba(217, 119, 87, 0.2); + border-color: rgba(217, 119, 87, 0.5); + } + 50% { + box-shadow: 0 0 10px rgba(217, 119, 87, 0.5), 0 0 20px rgba(217, 119, 87, 0.3); + border-color: rgba(217, 119, 87, 0.8); + } +} + +.animate-border-glow { + animation: border-glow 2s ease-in-out infinite; +} diff --git a/src/app/landing/components/AnimatedBackground.js b/src/app/landing/components/AnimatedBackground.js new file mode 100644 index 00000000..9251aafc --- /dev/null +++ b/src/app/landing/components/AnimatedBackground.js @@ -0,0 +1,57 @@ +"use client"; + +export default function AnimatedBackground() { + return ( + <> + {/* Animated Background */} +
+ {/* Grid pattern */} +
+ + {/* Animated gradient orbs */} +
+
+
+ + {/* Vignette effect */} +
+
+ + {/* CSS Animations */} + + + ); +} + diff --git a/src/app/landing/components/Features.js b/src/app/landing/components/Features.js new file mode 100644 index 00000000..37c29ab6 --- /dev/null +++ b/src/app/landing/components/Features.js @@ -0,0 +1,133 @@ +"use client"; + +const FEATURES = [ + { + icon: "link", + title: "Unified Endpoint", + desc: "Access all providers via a single standard API URL.", + colors: { + border: "hover:border-blue-500/50", + bg: "hover:bg-blue-500/5", + iconBg: "bg-blue-500/10", + iconText: "text-blue-500", + titleHover: "group-hover:text-blue-400" + } + }, + { + icon: "bolt", + title: "Easy Setup", + desc: "Get up and running in minutes with npx command.", + colors: { + border: "hover:border-orange-500/50", + bg: "hover:bg-orange-500/5", + iconBg: "bg-orange-500/10", + iconText: "text-orange-500", + titleHover: "group-hover:text-orange-400" + } + }, + { + icon: "shield_with_heart", + title: "Model Fallback", + desc: "Automatically switch providers on failure or high latency.", + colors: { + border: "hover:border-rose-500/50", + bg: "hover:bg-rose-500/5", + iconBg: "bg-rose-500/10", + iconText: "text-rose-500", + titleHover: "group-hover:text-rose-400" + } + }, + { + icon: "monitoring", + title: "Usage Tracking", + desc: "Detailed analytics and cost monitoring across all models.", + colors: { + border: "hover:border-purple-500/50", + bg: "hover:bg-purple-500/5", + iconBg: "bg-purple-500/10", + iconText: "text-purple-500", + titleHover: "group-hover:text-purple-400" + } + }, + { + icon: "key", + title: "OAuth & API Keys", + desc: "Securely manage credentials in one vault.", + colors: { + border: "hover:border-amber-500/50", + bg: "hover:bg-amber-500/5", + iconBg: "bg-amber-500/10", + iconText: "text-amber-500", + titleHover: "group-hover:text-amber-400" + } + }, + { + icon: "cloud_sync", + title: "Cloud Sync", + desc: "Sync your configurations across devices instantly.", + colors: { + border: "hover:border-sky-500/50", + bg: "hover:bg-sky-500/5", + iconBg: "bg-sky-500/10", + iconText: "text-sky-500", + titleHover: "group-hover:text-sky-400" + } + }, + { + icon: "terminal", + title: "CLI Support", + desc: "Works with Claude Code, Codex, Cline, Cursor, and more.", + colors: { + border: "hover:border-emerald-500/50", + bg: "hover:bg-emerald-500/5", + iconBg: "bg-emerald-500/10", + iconText: "text-emerald-500", + titleHover: "group-hover:text-emerald-400" + } + }, + { + icon: "dashboard", + title: "Dashboard", + desc: "Visual dashboard for real-time traffic analysis.", + colors: { + border: "hover:border-fuchsia-500/50", + bg: "hover:bg-fuchsia-500/5", + iconBg: "bg-fuchsia-500/10", + iconText: "text-fuchsia-500", + titleHover: "group-hover:text-fuchsia-400" + } + }, +]; + +export default function Features() { + return ( +
+
+
+

Powerful Features

+

+ Everything you need to manage your AI infrastructure in one place, built for scale. +

+
+ +
+ {FEATURES.map((feature) => ( +
+
+ {feature.icon} +
+

+ {feature.title} +

+

{feature.desc}

+
+ ))} +
+
+
+ ); +} + diff --git a/src/app/landing/components/FlowAnimation.js b/src/app/landing/components/FlowAnimation.js new file mode 100644 index 00000000..08d53ced --- /dev/null +++ b/src/app/landing/components/FlowAnimation.js @@ -0,0 +1,120 @@ +"use client"; +import { useEffect, useState } from "react"; +import Image from "next/image"; + +const CLI_TOOLS = [ + { id: "claude", name: "Claude Code", image: "/providers/claude.png" }, + { id: "codex", name: "OpenAI Codex", image: "/providers/codex.png" }, + { id: "cline", name: "Cline", image: "/providers/cline.png" }, + { id: "cursor", name: "Cursor", image: "/providers/cursor.png" }, +]; + +const PROVIDERS = [ + { id: "openai", name: "OpenAI", color: "bg-emerald-500", textColor: "text-white" }, + { id: "anthropic", name: "Anthropic", color: "bg-orange-400", textColor: "text-white" }, + { id: "gemini", name: "Gemini", color: "bg-blue-500", textColor: "text-white" }, + { id: "github", name: "GitHub Copilot", color: "bg-gray-700", textColor: "text-white" }, +]; + +export default function FlowAnimation() { + const [activeFlow, setActiveFlow] = useState(0); + + useEffect(() => { + const interval = setInterval(() => { + setActiveFlow((prev) => (prev + 1) % PROVIDERS.length); + }, 2000); + return () => clearInterval(interval); + }, []); + + return ( +
+ {/* 9Router Hub - Center */} +
+ hub + 9Router +
+
+ + {/* CLI Tools - Left side */} +
+ {CLI_TOOLS.map((tool) => ( +
+
+ {tool.name} +
+
+ ))} +
+ + {/* SVG Lines from CLI to 9Router */} + + + + + + + + {/* SVG Lines from 9Router to Providers */} + + + + + + + + {/* AI Providers - Right side */} +
+ {PROVIDERS.map((provider, idx) => ( +
+ {provider.name} +
+ ))} +
+ + {/* Mobile fallback */} +
+

Interactive diagram visible on desktop

+
+
+ ); +} + diff --git a/src/app/landing/components/Footer.js b/src/app/landing/components/Footer.js new file mode 100644 index 00000000..91209a5a --- /dev/null +++ b/src/app/landing/components/Footer.js @@ -0,0 +1,61 @@ +"use client"; + +export default function Footer() { + return ( +
+
+
+ {/* Brand */} +
+
+
+ hub +
+

9Router

+
+

+ The unified endpoint for AI generation. Connect, route, and manage your AI providers with ease. +

+ +
+ + {/* Product */} +
+

Product

+ Features + Dashboard + Changelog +
+ + {/* Resources */} +
+

Resources

+ Documentation + GitHub + NPM +
+ + {/* Legal */} +
+

Legal

+ MIT License +
+
+ + {/* Bottom */} +
+

Š 2025 9Router. All rights reserved.

+
+ GitHub + NPM +
+
+
+
+ ); +} + diff --git a/src/app/landing/components/GetStarted.js b/src/app/landing/components/GetStarted.js new file mode 100644 index 00000000..4f37bbe7 --- /dev/null +++ b/src/app/landing/components/GetStarted.js @@ -0,0 +1,99 @@ +"use client"; +import { useState } from "react"; + +export default function GetStarted() { + const [copied, setCopied] = useState(false); + + const handleCopy = (text) => { + navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+
+
+ {/* Left: Steps */} +
+

Get Started in 30 Seconds

+

+ Install 9Router, configure your providers via web dashboard, and start routing AI requests. +

+ +
+
+
1
+
+

Install 9Router

+

Run npx command to start the server instantly

+
+
+ +
+
2
+
+

Open Dashboard

+

Configure providers and API keys via web interface

+
+
+ +
+
3
+
+

Route Requests

+

Point your CLI tools to http://localhost:20128

+
+
+
+
+ + {/* Right: Code block */} +
+
+ {/* Terminal header */} +
+
+
+
+
terminal
+
+ + {/* Terminal content */} +
+
handleCopy("npx 9router")} + > + $ + npx 9router + + {copied ? "✓ Copied" : "Copy"} + +
+ +
+ > Starting 9Router...
+ > Server running on http://localhost:20128
+ > Dashboard: http://localhost:20128/dashboard
+ > Ready to route! ✓ +
+ +
+ 📝 Configure providers in dashboard or use environment variables +
+ +
+ Data Location:
+ macOS/Linux: ~/.9router/db.json
+ Windows: %APPDATA%/9router/db.json +
+
+
+
+
+
+
+ ); +} + diff --git a/src/app/landing/components/HeroSection.js b/src/app/landing/components/HeroSection.js new file mode 100644 index 00000000..cb69116a --- /dev/null +++ b/src/app/landing/components/HeroSection.js @@ -0,0 +1,47 @@ +"use client"; + +export default function HeroSection() { + return ( +
+ {/* Glow effect */} +
+ +
+ {/* Version badge */} +
+ + v1.0 is now live +
+ + {/* Main heading */} +

+ One Endpoint for
+ All AI Providers +

+ + {/* Description */} +

+ AI endpoint proxy with web dashboard - A JavaScript port of CLIProxyAPI. Works seamlessly with Claude Code, OpenAI Codex, Cline, RooCode, and other CLI tools. +

+ + {/* CTA Buttons */} +
+ + + code + View on GitHub + +
+
+
+ ); +} + diff --git a/src/app/landing/components/HowItWorks.js b/src/app/landing/components/HowItWorks.js new file mode 100644 index 00000000..d5c381aa --- /dev/null +++ b/src/app/landing/components/HowItWorks.js @@ -0,0 +1,66 @@ +"use client"; + +export default function HowItWorks() { + return ( +
+
+
+

How 9Router Works

+

+ Data flows seamlessly from your application through our intelligent routing layer to the best provider for the job. +

+
+ +
+ {/* Connection line */} +
+ + {/* Step 1: CLI & SDKs */} +
+
+ terminal +
+
+

1. CLI & SDKs

+

+ Your requests start from your favorite tools or our unified SDK. Just change the base URL. +

+
+
+ + {/* Step 2: 9Router Hub */} +
+
+ hub +
+
+

2. 9Router Hub

+

+ Our engine analyzes the prompt, checks provider health, and routes for lowest latency or cost. +

+
+
+ + {/* Step 3: AI Providers */} +
+
+
+
+
+
+
+
+
+
+

3. AI Providers

+

+ The request is fulfilled by OpenAI, Anthropic, Gemini, or others instantly. +

+
+
+
+
+
+ ); +} + diff --git a/src/app/landing/components/Navigation.js b/src/app/landing/components/Navigation.js new file mode 100644 index 00000000..4d20d2f7 --- /dev/null +++ b/src/app/landing/components/Navigation.js @@ -0,0 +1,67 @@ +"use client"; +import { useState } from "react"; +import { useRouter } from "next/navigation"; + +export default function Navigation() { + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const router = useRouter(); + + return ( + + ); +} + diff --git a/src/app/landing/page.js b/src/app/landing/page.js new file mode 100644 index 00000000..8bab89ab --- /dev/null +++ b/src/app/landing/page.js @@ -0,0 +1,104 @@ +"use client"; +import Navigation from "./components/Navigation"; +import HeroSection from "./components/HeroSection"; +import FlowAnimation from "./components/FlowAnimation"; +import HowItWorks from "./components/HowItWorks"; +import Features from "./components/Features"; +import GetStarted from "./components/GetStarted"; +import Footer from "./components/Footer"; + +export default function LandingPage() { + return ( +
+ {/* Animated Background */} +
+ {/* Grid pattern */} +
+ + {/* Animated gradient orbs */} +
+
+
+ + {/* Vignette effect */} +
+
+ +
+ + +
+ {/* Hero with Flow Animation */} +
+ +
+ +
+
+ + + + + + {/* CTA Section */} +
+
+
+

Ready to Simplify Your AI Infrastructure?

+

+ Join developers who are streamlining their AI integrations with 9Router. Open source and free to start. +

+
+ + +
+
+
+
+ +
+
+ + {/* Global styles for keyframes */} + +
+ ); +} + diff --git a/src/app/layout.js b/src/app/layout.js new file mode 100644 index 00000000..8c9c52d0 --- /dev/null +++ b/src/app/layout.js @@ -0,0 +1,33 @@ +import { Inter } from "next/font/google"; +import "./globals.css"; +import { ThemeProvider } from "@/shared/components/ThemeProvider"; +import "@/lib/initCloudSync"; // Auto-initialize cloud sync + +const inter = Inter({ + subsets: ["latin"], + variable: "--font-inter", +}); + +export const metadata = { + title: "9Router - AI Infrastructure Management", + description: "One endpoint for all your AI providers. Manage keys, monitor usage, and scale effortlessly.", +}; + +export default function RootLayout({ children }) { + return ( + + + + + + + + {children} + + + + ); +} diff --git a/src/app/page.js b/src/app/page.js new file mode 100644 index 00000000..449619b1 --- /dev/null +++ b/src/app/page.js @@ -0,0 +1,7 @@ +// Auto-initialize cloud sync when server starts +import "@/lib/initCloudSync"; +import LandingPage from "./landing/page"; + +export default function InitPage() { + return ; +} diff --git a/src/lib/initCloudSync.js b/src/lib/initCloudSync.js new file mode 100644 index 00000000..7479ee9e --- /dev/null +++ b/src/lib/initCloudSync.js @@ -0,0 +1,22 @@ +import initializeCloudSync from "@/shared/services/initializeCloudSync"; + +// Initialize cloud sync when this module is imported +let initialized = false; + +export async function ensureCloudSyncInitialized() { + if (!initialized) { + try { + await initializeCloudSync(); + initialized = true; + } catch (error) { + console.error("[ServerInit] Error initializing cloud sync:", error); + } + } + return initialized; +} + +// Auto-initialize when module loads +ensureCloudSyncInitialized().catch(console.log); + +export default ensureCloudSyncInitialized; + diff --git a/src/lib/localDb.js b/src/lib/localDb.js new file mode 100644 index 00000000..8ac22cdd --- /dev/null +++ b/src/lib/localDb.js @@ -0,0 +1,496 @@ +import { Low } from "lowdb"; +import { JSONFile } from "lowdb/node"; +import { v4 as uuidv4 } from "uuid"; +import path from "path"; +import os from "os"; +import fs from "fs"; +import { fileURLToPath } from "url"; + +// Get app name from root package.json config +function getAppName() { + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + // Look for root package.json (monorepo root) + const rootPkgPath = path.resolve(__dirname, "../../../package.json"); + try { + const pkg = JSON.parse(fs.readFileSync(rootPkgPath, "utf-8")); + return pkg.config?.appName || "9router"; + } catch { + return "9router"; + } +} + +// Get user data directory based on platform +function getUserDataDir() { + const platform = process.platform; + const homeDir = os.homedir(); + const appName = getAppName(); + + if (platform === "win32") { + return path.join(process.env.APPDATA || path.join(homeDir, "AppData", "Roaming"), appName); + } else { + // macOS & Linux: ~/.{appName} + return path.join(homeDir, `.${appName}`); + } +} + +// Data file path - stored in user home directory +const DATA_DIR = getUserDataDir(); +const DB_FILE = path.join(DATA_DIR, "db.json"); + +// Ensure data directory exists +if (!fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR, { recursive: true }); +} + +// Default data structure +const defaultData = { + providerConnections: [], + modelAliases: {}, + combos: [], + apiKeys: [], + settings: { + cloudEnabled: false + } +}; + +// Singleton instance +let dbInstance = null; + +/** + * Get database instance (singleton) + */ +export async function getDb() { + if (!dbInstance) { + const adapter = new JSONFile(DB_FILE); + dbInstance = new Low(adapter, defaultData); + + // Try to read DB with error recovery for corrupt JSON + try { + await dbInstance.read(); + } catch (error) { + if (error instanceof SyntaxError) { + console.warn('[DB] Corrupt JSON detected, resetting to defaults...'); + dbInstance.data = defaultData; + await dbInstance.write(); + } else { + throw error; + } + } + + // Initialize with default data if empty + if (!dbInstance.data) { + dbInstance.data = defaultData; + await dbInstance.write(); + } + } + return dbInstance; +} + +// ============ Provider Connections ============ + +/** + * Get all provider connections + */ +export async function getProviderConnections(filter = {}) { + const db = await getDb(); + let connections = db.data.providerConnections || []; + + if (filter.provider) { + connections = connections.filter(c => c.provider === filter.provider); + } + if (filter.isActive !== undefined) { + connections = connections.filter(c => c.isActive === filter.isActive); + } + + // Sort by priority (lower = higher priority) + connections.sort((a, b) => (a.priority || 999) - (b.priority || 999)); + + return connections; +} + +/** + * Get provider connection by ID + */ +export async function getProviderConnectionById(id) { + const db = await getDb(); + return db.data.providerConnections.find(c => c.id === id) || null; +} + +/** + * Create or update provider connection (upsert by provider + email/name) + */ +export async function createProviderConnection(data) { + const db = await getDb(); + const now = new Date().toISOString(); + + // Check for existing connection with same provider and email (for OAuth) + // or same provider and name (for API key) + let existingIndex = -1; + if (data.authType === "oauth" && data.email) { + existingIndex = db.data.providerConnections.findIndex( + c => c.provider === data.provider && c.authType === "oauth" && c.email === data.email + ); + } else if (data.authType === "apikey" && data.name) { + existingIndex = db.data.providerConnections.findIndex( + c => c.provider === data.provider && c.authType === "apikey" && c.name === data.name + ); + } + + // If exists, update instead of create + if (existingIndex !== -1) { + db.data.providerConnections[existingIndex] = { + ...db.data.providerConnections[existingIndex], + ...data, + updatedAt: now, + }; + await db.write(); + return db.data.providerConnections[existingIndex]; + } + + // Generate name for OAuth if not provided + let connectionName = data.name || null; + if (!connectionName && data.authType === "oauth") { + if (data.email) { + connectionName = data.email; + } else { + // Count existing connections for this provider to generate index + const existingCount = db.data.providerConnections.filter( + c => c.provider === data.provider + ).length; + connectionName = `Account ${existingCount + 1}`; + } + } + + // Auto-increment priority if not provided + let connectionPriority = data.priority; + if (!connectionPriority) { + const providerConnections = db.data.providerConnections.filter( + c => c.provider === data.provider + ); + const maxPriority = providerConnections.reduce((max, c) => Math.max(max, c.priority || 0), 0); + connectionPriority = maxPriority + 1; + } + + // Create new connection - only save fields with actual values + const connection = { + id: uuidv4(), + provider: data.provider, + authType: data.authType || "oauth", + name: connectionName, + priority: connectionPriority, + isActive: data.isActive !== undefined ? data.isActive : true, + createdAt: now, + updatedAt: now, + }; + + // Only add optional fields if they have values + const optionalFields = [ + "displayName", "email", "globalPriority", "defaultModel", + "accessToken", "refreshToken", "expiresAt", "tokenType", + "scope", "idToken", "projectId", "apiKey", "testStatus", + "lastTested", "lastError", "lastErrorAt", "rateLimitedUntil", "expiresIn", "errorCode" + ]; + + for (const field of optionalFields) { + if (data[field] !== undefined && data[field] !== null) { + connection[field] = data[field]; + } + } + + // Only add providerSpecificData if it has content + if (data.providerSpecificData && Object.keys(data.providerSpecificData).length > 0) { + connection.providerSpecificData = data.providerSpecificData; + } + + db.data.providerConnections.push(connection); + await db.write(); + + return connection; +} + +/** + * Update provider connection + */ +export async function updateProviderConnection(id, data) { + const db = await getDb(); + const index = db.data.providerConnections.findIndex(c => c.id === id); + + if (index === -1) return null; + + db.data.providerConnections[index] = { + ...db.data.providerConnections[index], + ...data, + updatedAt: new Date().toISOString(), + }; + + await db.write(); + return db.data.providerConnections[index]; +} + +/** + * Delete provider connection + */ +export async function deleteProviderConnection(id) { + const db = await getDb(); + const index = db.data.providerConnections.findIndex(c => c.id === id); + + if (index === -1) return false; + + db.data.providerConnections.splice(index, 1); + await db.write(); + + return true; +} + +// ============ Model Aliases ============ + +/** + * Get all model aliases + */ +export async function getModelAliases() { + const db = await getDb(); + return db.data.modelAliases || {}; +} + +/** + * Set model alias + */ +export async function setModelAlias(alias, model) { + const db = await getDb(); + db.data.modelAliases[alias] = model; + await db.write(); +} + +/** + * Delete model alias + */ +export async function deleteModelAlias(alias) { + const db = await getDb(); + delete db.data.modelAliases[alias]; + await db.write(); +} + +// ============ Combos ============ + +/** + * Get all combos + */ +export async function getCombos() { + const db = await getDb(); + return db.data.combos || []; +} + +/** + * Get combo by ID + */ +export async function getComboById(id) { + const db = await getDb(); + return (db.data.combos || []).find(c => c.id === id) || null; +} + +/** + * Get combo by name + */ +export async function getComboByName(name) { + const db = await getDb(); + return (db.data.combos || []).find(c => c.name === name) || null; +} + +/** + * Create combo + */ +export async function createCombo(data) { + const db = await getDb(); + if (!db.data.combos) db.data.combos = []; + + const now = new Date().toISOString(); + const combo = { + id: uuidv4(), + name: data.name, + models: data.models || [], + createdAt: now, + updatedAt: now, + }; + + db.data.combos.push(combo); + await db.write(); + return combo; +} + +/** + * Update combo + */ +export async function updateCombo(id, data) { + const db = await getDb(); + if (!db.data.combos) db.data.combos = []; + + const index = db.data.combos.findIndex(c => c.id === id); + if (index === -1) return null; + + db.data.combos[index] = { + ...db.data.combos[index], + ...data, + updatedAt: new Date().toISOString(), + }; + + await db.write(); + return db.data.combos[index]; +} + +/** + * Delete combo + */ +export async function deleteCombo(id) { + const db = await getDb(); + if (!db.data.combos) return false; + + const index = db.data.combos.findIndex(c => c.id === id); + if (index === -1) return false; + + db.data.combos.splice(index, 1); + await db.write(); + return true; +} + +// ============ API Keys ============ + +/** + * Get all API keys + */ +export async function getApiKeys() { + const db = await getDb(); + return db.data.apiKeys || []; +} + +/** + * Generate short random key (8 chars) + */ +function generateShortKey() { + const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; + let result = ""; + for (let i = 0; i < 8; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +} + +/** + * Create API key + * @param {string} name - Key name + * @param {string} machineId - MachineId (required) + */ +export async function createApiKey(name, machineId) { + if (!machineId) { + throw new Error("machineId is required"); + } + + const db = await getDb(); + const now = new Date().toISOString(); + + // Always use new format: sk-{machineId}-{keyId}-{crc8} + const { generateApiKeyWithMachine } = await import("@/shared/utils/apiKey"); + const result = generateApiKeyWithMachine(machineId); + + const apiKey = { + id: uuidv4(), + name: name, + key: result.key, + machineId: machineId, + createdAt: now, + }; + + db.data.apiKeys.push(apiKey); + await db.write(); + + return apiKey; +} + +/** + * Delete API key + */ +export async function deleteApiKey(id) { + const db = await getDb(); + const index = db.data.apiKeys.findIndex(k => k.id === id); + + if (index === -1) return false; + + db.data.apiKeys.splice(index, 1); + await db.write(); + + return true; +} + +/** + * Validate API key + */ +export async function validateApiKey(key) { + const db = await getDb(); + return db.data.apiKeys.some(k => k.key === key); +} + +// ============ Data Cleanup ============ + +/** + * Remove null/empty fields from all provider connections to reduce db size + */ +export async function cleanupProviderConnections() { + const db = await getDb(); + const fieldsToCheck = [ + "displayName", "email", "globalPriority", "defaultModel", + "accessToken", "refreshToken", "expiresAt", "tokenType", + "scope", "idToken", "projectId", "apiKey", "testStatus", + "lastTested", "lastError", "lastErrorAt", "rateLimitedUntil", "expiresIn" + ]; + + let cleaned = 0; + for (const connection of db.data.providerConnections) { + for (const field of fieldsToCheck) { + if (connection[field] === null || connection[field] === undefined) { + delete connection[field]; + cleaned++; + } + } + // Remove empty providerSpecificData + if (connection.providerSpecificData && Object.keys(connection.providerSpecificData).length === 0) { + delete connection.providerSpecificData; + cleaned++; + } + } + + if (cleaned > 0) { + await db.write(); + } + return cleaned; +} + +// ============ Settings ============ + +/** + * Get settings + */ +export async function getSettings() { + const db = await getDb(); + return db.data.settings || { cloudEnabled: false }; +} + +/** + * Update settings + */ +export async function updateSettings(updates) { + const db = await getDb(); + db.data.settings = { + ...db.data.settings, + ...updates + }; + await db.write(); + return db.data.settings; +} + +/** + * Check if cloud is enabled + */ +export async function isCloudEnabled() { + const settings = await getSettings(); + return settings.cloudEnabled === true; +} + diff --git a/src/lib/oauth/constants/oauth.js b/src/lib/oauth/constants/oauth.js new file mode 100644 index 00000000..db57ee73 --- /dev/null +++ b/src/lib/oauth/constants/oauth.js @@ -0,0 +1,126 @@ +/** + * OAuth Configuration Constants + */ + +// Claude OAuth Configuration (Authorization Code Flow with PKCE) +export const CLAUDE_CONFIG = { + clientId: "9d1c250a-e61b-44d9-88ed-5944d1962f5e", + authorizeUrl: "https://claude.ai/oauth/authorize", + tokenUrl: "https://console.anthropic.com/v1/oauth/token", + scopes: ["org:create_api_key", "user:profile", "user:inference"], + codeChallengeMethod: "S256", +}; + +// Codex (OpenAI) OAuth Configuration (Authorization Code Flow with PKCE) +export const CODEX_CONFIG = { + clientId: "app_EMoamEEZ73f0CkXaXp7hrann", + authorizeUrl: "https://auth.openai.com/oauth/authorize", + tokenUrl: "https://auth.openai.com/oauth/token", + scope: "openid profile email offline_access", + codeChallengeMethod: "S256", + // Additional OpenAI-specific params + extraParams: { + id_token_add_organizations: "true", + codex_cli_simplified_flow: "true", + originator: "codex_cli_rs", + }, +}; + +// Gemini (Google) OAuth Configuration (Standard OAuth2) +export const GEMINI_CONFIG = { + clientId: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com", + clientSecret: "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", + authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth", + tokenUrl: "https://oauth2.googleapis.com/token", + userInfoUrl: "https://www.googleapis.com/oauth2/v1/userinfo", + scopes: [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + ], +}; + +// Qwen OAuth Configuration (Device Code Flow with PKCE) +export const QWEN_CONFIG = { + clientId: "f0304373b74a44d2b584a3fb70ca9e56", + deviceCodeUrl: "https://chat.qwen.ai/api/v1/oauth2/device/code", + tokenUrl: "https://chat.qwen.ai/api/v1/oauth2/token", + scope: "openid profile email model.completion", + codeChallengeMethod: "S256", +}; + +// iFlow OAuth Configuration (Authorization Code) +export const IFLOW_CONFIG = { + clientId: "10009311001", + clientSecret: "4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW", + authorizeUrl: "https://iflow.cn/oauth", + tokenUrl: "https://iflow.cn/oauth/token", + userInfoUrl: "https://iflow.cn/api/oauth/getUserInfo", + extraParams: { + loginMethod: "phone", + type: "phone", + }, +}; + +// Antigravity OAuth Configuration (Standard OAuth2 with Google) +export const ANTIGRAVITY_CONFIG = { + clientId: "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com", + clientSecret: "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", + authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth", + tokenUrl: "https://oauth2.googleapis.com/token", + userInfoUrl: "https://www.googleapis.com/oauth2/v1/userinfo", + scopes: [ + "https://www.googleapis.com/auth/cloud-platform", + "https://www.googleapis.com/auth/userinfo.email", + "https://www.googleapis.com/auth/userinfo.profile", + "https://www.googleapis.com/auth/cclog", + "https://www.googleapis.com/auth/experimentsandconfigs", + ], + // Antigravity specific + loadCodeAssistEndpoint: "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + loadCodeAssistUserAgent: "google-api-nodejs-client/9.15.1", + loadCodeAssistApiClient: "google-cloud-sdk vscode_cloudshelleditor/0.1", + loadCodeAssistClientMetadata: `{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}`, +}; + +// OpenAI OAuth Configuration (Authorization Code Flow with PKCE) +export const OPENAI_CONFIG = { + clientId: "app_EMoamEEZ73f0CkXaXp7hrann", + authorizeUrl: "https://auth.openai.com/oauth/authorize", + tokenUrl: "https://auth.openai.com/oauth/token", + scope: "openid profile email offline_access", + codeChallengeMethod: "S256", + extraParams: { + id_token_add_organizations: "true", + originator: "openai_native", + }, +}; + +// GitHub Copilot OAuth Configuration (Device Code Flow) +export const GITHUB_CONFIG = { + clientId: "Iv1.b507a08c87ecfe98", + deviceCodeUrl: "https://github.com/login/device/code", + tokenUrl: "https://github.com/login/oauth/access_token", + userInfoUrl: "https://api.github.com/user", + scopes: "read:user", + apiVersion: "2022-11-28", // Updated to supported version + copilotTokenUrl: "https://api.github.com/copilot_internal/v2/token", + userAgent: "GitHubCopilotChat/0.26.7", + editorVersion: "vscode/1.85.0", + editorPluginVersion: "copilot-chat/0.26.7", +}; + +// OAuth timeout (5 minutes) +export const OAUTH_TIMEOUT = 300000; + +// Provider list +export const PROVIDERS = { + CLAUDE: "claude", + CODEX: "codex", + GEMINI: "gemini-cli", + QWEN: "qwen", + IFLOW: "iflow", + ANTIGRAVITY: "antigravity", + OPENAI: "openai", + GITHUB: "github", +}; diff --git a/src/lib/oauth/providers.js b/src/lib/oauth/providers.js new file mode 100644 index 00000000..e8251a65 --- /dev/null +++ b/src/lib/oauth/providers.js @@ -0,0 +1,619 @@ +/** + * OAuth Provider Configurations and Handlers + * Centralized DRY approach for all OAuth providers + */ + +import { generatePKCE, generateState } from "./utils/pkce"; +import { + CLAUDE_CONFIG, + CODEX_CONFIG, + GEMINI_CONFIG, + QWEN_CONFIG, + IFLOW_CONFIG, + ANTIGRAVITY_CONFIG, + GITHUB_CONFIG, +} from "./constants/oauth"; + +// Provider configurations +const PROVIDERS = { + claude: { + config: CLAUDE_CONFIG, + flowType: "authorization_code_pkce", + buildAuthUrl: (config, redirectUri, state, codeChallenge) => { + const params = new URLSearchParams({ + code: "true", + client_id: config.clientId, + response_type: "code", + redirect_uri: redirectUri, + scope: config.scopes.join(" "), + code_challenge: codeChallenge, + code_challenge_method: config.codeChallengeMethod, + state: state, + }); + return `${config.authorizeUrl}?${params.toString()}`; + }, + exchangeToken: async (config, code, redirectUri, codeVerifier, state) => { + // Parse code - may contain state after # + let authCode = code; + let codeState = ""; + if (authCode.includes("#")) { + const parts = authCode.split("#"); + authCode = parts[0]; + codeState = parts[1] || ""; + } + + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + code: authCode, + state: codeState || state, + grant_type: "authorization_code", + client_id: config.clientId, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + scope: tokens.scope, + }), + }, + + codex: { + config: CODEX_CONFIG, + flowType: "authorization_code_pkce", + fixedPort: 1455, + callbackPath: "/auth/callback", + buildAuthUrl: (config, redirectUri, state, codeChallenge) => { + const params = { + response_type: "code", + client_id: config.clientId, + redirect_uri: redirectUri, + scope: config.scope, + code_challenge: codeChallenge, + code_challenge_method: config.codeChallengeMethod, + ...config.extraParams, + state: state, + }; + const queryString = Object.entries(params) + .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) + .join("&"); + return `${config.authorizeUrl}?${queryString}`; + }, + exchangeToken: async (config, code, redirectUri, codeVerifier) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: config.clientId, + code: code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + idToken: tokens.id_token, + expiresIn: tokens.expires_in, + }), + }, + + "gemini-cli": { + config: GEMINI_CONFIG, + flowType: "authorization_code", + buildAuthUrl: (config, redirectUri, state) => { + const params = new URLSearchParams({ + client_id: config.clientId, + response_type: "code", + redirect_uri: redirectUri, + scope: config.scopes.join(" "), + state: state, + access_type: "offline", + prompt: "consent", + }); + return `${config.authorizeUrl}?${params.toString()}`; + }, + exchangeToken: async (config, code, redirectUri) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: config.clientId, + client_secret: config.clientSecret, + code: code, + redirect_uri: redirectUri, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + }, + postExchange: async (tokens) => { + // Fetch user info + const userInfoRes = await fetch(`${GEMINI_CONFIG.userInfoUrl}?alt=json`, { + headers: { Authorization: `Bearer ${tokens.access_token}` }, + }); + const userInfo = userInfoRes.ok ? await userInfoRes.json() : {}; + + // Fetch project ID + let projectId = ""; + try { + const projectRes = await fetch( + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + { + method: "POST", + headers: { + Authorization: `Bearer ${tokens.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + metadata: { ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI" }, + }), + } + ); + if (projectRes.ok) { + const data = await projectRes.json(); + projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || ""; + } + } catch (e) { + console.log("Failed to fetch project ID:", e); + } + + return { userInfo, projectId }; + }, + mapTokens: (tokens, extra) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + scope: tokens.scope, + email: extra?.userInfo?.email, + projectId: extra?.projectId, + }), + }, + + antigravity: { + config: ANTIGRAVITY_CONFIG, + flowType: "authorization_code", + buildAuthUrl: (config, redirectUri, state) => { + const params = new URLSearchParams({ + client_id: config.clientId, + response_type: "code", + redirect_uri: redirectUri, + scope: config.scopes.join(" "), + state: state, + access_type: "offline", + prompt: "consent", + }); + return `${config.authorizeUrl}?${params.toString()}`; + }, + exchangeToken: async (config, code, redirectUri) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: config.clientId, + client_secret: config.clientSecret, + code: code, + redirect_uri: redirectUri, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + }, + postExchange: async (tokens) => { + // Fetch user info + const userInfoRes = await fetch(`${ANTIGRAVITY_CONFIG.userInfoUrl}?alt=json`, { + headers: { Authorization: `Bearer ${tokens.access_token}` }, + }); + const userInfo = userInfoRes.ok ? await userInfoRes.json() : {}; + + // Fetch project ID from loadCodeAssist + let projectId = ""; + try { + const projectRes = await fetch(ANTIGRAVITY_CONFIG.loadCodeAssistEndpoint, { + method: "POST", + headers: { + Authorization: `Bearer ${tokens.access_token}`, + "Content-Type": "application/json", + "User-Agent": ANTIGRAVITY_CONFIG.loadCodeAssistUserAgent, + "X-Goog-Api-Client": ANTIGRAVITY_CONFIG.loadCodeAssistApiClient, + "Client-Metadata": ANTIGRAVITY_CONFIG.loadCodeAssistClientMetadata, + }, + body: JSON.stringify({ + metadata: { ideType: "IDE_UNSPECIFIED", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI" }, + }), + }); + if (projectRes.ok) { + const data = await projectRes.json(); + projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || ""; + } + } catch (e) { + console.log("Failed to fetch project ID:", e); + } + + return { userInfo, projectId }; + }, + mapTokens: (tokens, extra) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + scope: tokens.scope, + email: extra?.userInfo?.email, + projectId: extra?.projectId, + }), + }, + + iflow: { + config: IFLOW_CONFIG, + flowType: "authorization_code", + buildAuthUrl: (config, redirectUri, state) => { + const params = new URLSearchParams({ + loginMethod: config.extraParams.loginMethod, + type: config.extraParams.type, + redirect: redirectUri, + state: state, + client_id: config.clientId, + }); + return `${config.authorizeUrl}?${params.toString()}`; + }, + exchangeToken: async (config, code, redirectUri) => { + // Create Basic Auth header + const basicAuth = Buffer.from( + `${config.clientId}:${config.clientSecret}` + ).toString("base64"); + + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + Authorization: `Basic ${basicAuth}`, + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code: code, + redirect_uri: redirectUri, + client_id: config.clientId, + client_secret: config.clientSecret, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + }, + postExchange: async (tokens) => { + // Fetch user info + const userInfoRes = await fetch( + `${IFLOW_CONFIG.userInfoUrl}?accessToken=${encodeURIComponent(tokens.access_token)}`, + { + headers: { + Accept: "application/json", + }, + } + ); + const result = userInfoRes.ok ? await userInfoRes.json() : {}; + const userInfo = result.success ? result.data : {}; + return { userInfo }; + }, + mapTokens: (tokens, extra) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + apiKey: extra?.userInfo?.apiKey, + email: extra?.userInfo?.email || extra?.userInfo?.phone, + displayName: extra?.userInfo?.nickname || extra?.userInfo?.name, + }), + }, + + qwen: { + config: QWEN_CONFIG, + flowType: "device_code", + requestDeviceCode: async (config, codeChallenge) => { + const response = await fetch(config.deviceCodeUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + client_id: config.clientId, + scope: config.scope, + code_challenge: codeChallenge, + code_challenge_method: config.codeChallengeMethod, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Device code request failed: ${error}`); + } + + return await response.json(); + }, + pollToken: async (config, deviceCode, codeVerifier) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + client_id: config.clientId, + device_code: deviceCode, + code_verifier: codeVerifier, + }), + }); + + return { + ok: response.ok, + data: await response.json(), + }; + }, + mapTokens: (tokens) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + providerSpecificData: { resourceUrl: tokens.resource_url }, + }), + }, + + github: { + config: GITHUB_CONFIG, + flowType: "device_code", + requestDeviceCode: async (config) => { + const response = await fetch(config.deviceCodeUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + client_id: config.clientId, + scope: config.scopes, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Device code request failed: ${error}`); + } + + return await response.json(); + }, + pollToken: async (config, deviceCode) => { + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + client_id: config.clientId, + device_code: deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }); + + // Handle response properly - if not ok, try to get error as text first + let data; + try { + data = await response.json(); + } catch (e) { + // If response is not JSON, get as text + const text = await response.text(); + data = { error: "invalid_response", error_description: text }; + } + + return { + ok: response.ok, + data: data, + }; + }, + postExchange: async (tokens) => { + // Get Copilot token using GitHub access token + const copilotRes = await fetch(GITHUB_CONFIG.copilotTokenUrl, { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + Accept: "application/json", + "X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion, + "User-Agent": GITHUB_CONFIG.userAgent, + }, + }); + const copilotToken = copilotRes.ok ? await copilotRes.json() : {}; + + // Get user info from GitHub + const userRes = await fetch(GITHUB_CONFIG.userInfoUrl, { + headers: { + Authorization: `Bearer ${tokens.access_token}`, + Accept: "application/json", + "X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion, + "User-Agent": GITHUB_CONFIG.userAgent, + }, + }); + const userInfo = userRes.ok ? await userRes.json() : {}; + + return { copilotToken, userInfo }; + }, + mapTokens: (tokens, extra) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + providerSpecificData: { + copilotToken: extra?.copilotToken?.token, + copilotTokenExpiresAt: extra?.copilotToken?.expires_at, + githubUserId: extra?.userInfo?.id, + githubLogin: extra?.userInfo?.login, + githubName: extra?.userInfo?.name, + githubEmail: extra?.userInfo?.email, + }, + }), + }, +}; + +/** + * Get provider handler + */ +export function getProvider(name) { + const provider = PROVIDERS[name]; + if (!provider) { + throw new Error(`Unknown provider: ${name}`); + } + return provider; +} + +/** + * Get all provider names + */ +export function getProviderNames() { + return Object.keys(PROVIDERS); +} + +/** + * Generate auth data for a provider + */ +export function generateAuthData(providerName, redirectUri) { + const provider = getProvider(providerName); + const { codeVerifier, codeChallenge, state } = generatePKCE(); + + let authUrl; + if (provider.flowType === "device_code") { + // Device code flow doesn't have auth URL upfront + authUrl = null; + } else if (provider.flowType === "authorization_code_pkce") { + authUrl = provider.buildAuthUrl(provider.config, redirectUri, state, codeChallenge); + } else { + authUrl = provider.buildAuthUrl(provider.config, redirectUri, state); + } + + return { + authUrl, + state, + codeVerifier, + codeChallenge, + redirectUri, + flowType: provider.flowType, + fixedPort: provider.fixedPort, + callbackPath: provider.callbackPath || "/callback", + }; +} + +/** + * Exchange code for tokens + */ +export async function exchangeTokens(providerName, code, redirectUri, codeVerifier, state) { + const provider = getProvider(providerName); + + const tokens = await provider.exchangeToken(provider.config, code, redirectUri, codeVerifier, state); + + let extra = null; + if (provider.postExchange) { + extra = await provider.postExchange(tokens); + } + + return provider.mapTokens(tokens, extra); +} + +/** + * Request device code (for device_code flow) + */ +export async function requestDeviceCode(providerName, codeChallenge) { + const provider = getProvider(providerName); + if (provider.flowType !== "device_code") { + throw new Error(`Provider ${providerName} does not support device code flow`); + } + return await provider.requestDeviceCode(provider.config, codeChallenge); +} + +/** + * Poll for token (for device_code flow) + */ +export async function pollForToken(providerName, deviceCode, codeVerifier) { + const provider = getProvider(providerName); + if (provider.flowType !== "device_code") { + throw new Error(`Provider ${providerName} does not support device code flow`); + } + + const result = await provider.pollToken(provider.config, deviceCode, codeVerifier); + + if (result.ok) { + // For device code flows, success is only when we have an access token + if (result.data.access_token) { + // Call postExchange to get additional data (copilotToken, userInfo, etc.) + let extra = null; + if (provider.postExchange) { + extra = await provider.postExchange(result.data); + } + return { success: true, tokens: provider.mapTokens(result.data, extra) }; + } else { + // Check if it's still pending authorization + if (result.data.error === 'authorization_pending' || result.data.error === 'slow_down') { + // This is not a failure, just still waiting + return { + success: false, + error: result.data.error, + errorDescription: result.data.error_description || result.data.message, + pending: result.data.error === 'authorization_pending' + }; + } else { + // Actual error + return { + success: false, + error: result.data.error || 'no_access_token', + errorDescription: result.data.error_description || result.data.message || 'No access token received' + }; + } + } + } + + return { success: false, error: result.data.error, errorDescription: result.data.error_description }; +} + diff --git a/src/lib/oauth/services/antigravity.js b/src/lib/oauth/services/antigravity.js new file mode 100644 index 00000000..d85def01 --- /dev/null +++ b/src/lib/oauth/services/antigravity.js @@ -0,0 +1,239 @@ +import crypto from "crypto"; +import open from "open"; +import { ANTIGRAVITY_CONFIG } from "../constants/oauth.js"; +import { getServerCredentials } from "../config/index.js"; +import { startLocalServer } from "../utils/server.js"; +import { spinner as createSpinner } from "../utils/ui.js"; + +/** + * Antigravity OAuth Service + * Uses standard OAuth2 Authorization Code flow (similar to Gemini) + */ +export class AntigravityService { + constructor() { + this.config = ANTIGRAVITY_CONFIG; + } + + /** + * Build Antigravity authorization URL + */ + buildAuthUrl(redirectUri, state) { + const params = new URLSearchParams({ + client_id: this.config.clientId, + response_type: "code", + redirect_uri: redirectUri, + scope: this.config.scopes.join(" "), + state: state, + access_type: "offline", + prompt: "consent", + }); + + return `${this.config.authorizeUrl}?${params.toString()}`; + } + + /** + * Exchange authorization code for tokens + */ + async exchangeCode(code, redirectUri) { + const response = await fetch(this.config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: this.config.clientId, + client_secret: this.config.clientSecret, + code: code, + redirect_uri: redirectUri, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + } + + /** + * Get user info from Google + */ + async getUserInfo(accessToken) { + const response = await fetch(`${this.config.userInfoUrl}?alt=json`, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + }, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to get user info: ${error}`); + } + + return await response.json(); + } + + /** + * Fetch Project ID from loadCodeAssist API + */ + async fetchProjectId(accessToken) { + const loadReqBody = { + metadata: { + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI", + }, + }; + + const response = await fetch(this.config.loadCodeAssistEndpoint, { + method: "POST", + headers: { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": this.config.loadCodeAssistUserAgent, + "X-Goog-Api-Client": this.config.loadCodeAssistApiClient, + "Client-Metadata": this.config.loadCodeAssistClientMetadata, + }, + body: JSON.stringify(loadReqBody), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Failed to fetch project ID: ${errorText}`); + } + + const loadResp = await response.json(); + let projectId = loadResp.cloudaicompanionProject; + + if (typeof projectId === 'object' && projectId !== null && projectId.id) { + projectId = projectId.id; + } + + if (!projectId) { + throw new Error("No cloudaicompanionProject found in response"); + } + + return projectId; + } + + /** + * Save Antigravity tokens to server + */ + async saveTokens(tokens, userInfo, projectId) { + const { server, token, userId } = getServerCredentials(); + + const response = await fetch(`${server}/api/cli/providers/antigravity`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + "X-User-Id": userId, + }, + body: JSON.stringify({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + scope: tokens.scope, + email: userInfo.email, + projectId: projectId, // Send projectId to server + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to save tokens"); + } + + return await response.json(); + } + + /** + * Complete Antigravity OAuth flow + */ + async connect() { + const spinner = createSpinner("Starting Antigravity OAuth...").start(); + + try { + spinner.text = "Starting local server..."; + + // Start local server for callback + let callbackParams = null; + const { port, close } = await startLocalServer((params) => { + callbackParams = params; + }); + + const redirectUri = `http://localhost:${port}/callback`; + spinner.succeed(`Local server started on port ${port}`); + + // Generate state + const state = crypto.randomBytes(32).toString("base64url"); + + // Build authorization URL + const authUrl = this.buildAuthUrl(redirectUri, state); + + console.log("\nOpening browser for Antigravity authentication..."); + console.log(`If browser doesn't open, visit:\n${authUrl}\n`); + + // Open browser + await open(authUrl); + + // Wait for callback + spinner.start("Waiting for Antigravity authorization..."); + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Authentication timeout (5 minutes)")); + }, 300000); + + const checkInterval = setInterval(() => { + if (callbackParams) { + clearInterval(checkInterval); + clearTimeout(timeout); + resolve(); + } + }, 100); + }); + + close(); + + if (callbackParams.error) { + throw new Error(callbackParams.error_description || callbackParams.error); + } + + if (!callbackParams.code) { + throw new Error("No authorization code received"); + } + + spinner.start("Exchanging code for tokens..."); + + // Exchange code for tokens + const tokens = await this.exchangeCode(callbackParams.code, redirectUri); + + spinner.text = "Fetching user info..."; + + // Get user info + const userInfo = await this.getUserInfo(tokens.access_token); + + spinner.text = "Fetching Google Cloud Project ID..."; + + // Fetch Project ID + const projectId = await this.fetchProjectId(tokens.access_token); + + spinner.text = "Saving tokens to server..."; + + // Save tokens to server + await this.saveTokens(tokens, userInfo, projectId); + + spinner.succeed(`Antigravity connected successfully! (${userInfo.email}, Project: ${projectId})`); + return true; + } catch (error) { + spinner.fail(`Failed: ${error.message}`); + throw error; + } + } +} + diff --git a/src/lib/oauth/services/claude.js b/src/lib/oauth/services/claude.js new file mode 100644 index 00000000..de713c2e --- /dev/null +++ b/src/lib/oauth/services/claude.js @@ -0,0 +1,136 @@ +import { OAuthService } from "./oauth.js"; +import { CLAUDE_CONFIG } from "../constants/oauth.js"; +import { getServerCredentials } from "../config/index.js"; +import { spinner as createSpinner } from "../utils/ui.js"; + +/** + * Claude OAuth Service + */ +export class ClaudeService extends OAuthService { + constructor() { + super(CLAUDE_CONFIG); + } + + /** + * Build Claude authorization URL + */ + buildClaudeAuthUrl(redirectUri, state, codeChallenge) { + const scopeStr = CLAUDE_CONFIG.scopes.join(" "); + const params = new URLSearchParams({ + code: "true", + client_id: CLAUDE_CONFIG.clientId, + response_type: "code", + redirect_uri: redirectUri, + scope: scopeStr, + code_challenge: codeChallenge, + code_challenge_method: CLAUDE_CONFIG.codeChallengeMethod, + state: state, + }); + + return `${CLAUDE_CONFIG.authorizeUrl}?${params.toString()}`; + } + + /** + * Exchange Claude authorization code (with special handling) + */ + async exchangeClaudeCode(code, redirectUri, codeVerifier, state) { + // Parse code - may contain state after # + let authCode = code; + let codeState = ""; + if (authCode.includes("#")) { + const parts = authCode.split("#"); + authCode = parts[0]; + codeState = parts[1] || ""; + } + + // Claude uses JSON format (not form-urlencoded) + const tokenPayload = { + code: authCode, + state: codeState || state, + grant_type: "authorization_code", + client_id: CLAUDE_CONFIG.clientId, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }; + + const response = await fetch(CLAUDE_CONFIG.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(tokenPayload), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + } + + /** + * Save Claude tokens to server + */ + async saveTokens(tokens) { + const { server, token, userId } = getServerCredentials(); + + // Server will auto-generate displayName based on existing account count + const response = await fetch(`${server}/api/cli/providers/claude`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + "X-User-Id": userId, + }, + body: JSON.stringify({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + scope: tokens.scope, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to save tokens"); + } + + return await response.json(); + } + + /** + * Complete Claude OAuth flow + */ + async connect() { + const spinner = createSpinner("Starting Claude OAuth...").start(); + + try { + spinner.text = "Starting local server..."; + + // Authenticate and get authorization code + const { code, state, codeVerifier, redirectUri } = await this.authenticate( + "Claude", + this.buildClaudeAuthUrl.bind(this) + ); + + spinner.start("Exchanging code for tokens..."); + + // Exchange code for tokens + const tokens = await this.exchangeClaudeCode(code, redirectUri, codeVerifier, state); + + spinner.text = "Saving tokens to server..."; + + // Save tokens to server + await this.saveTokens(tokens); + + spinner.succeed("Claude connected successfully!"); + return true; + } catch (error) { + spinner.fail(`Failed: ${error.message}`); + throw error; + } + } +} + diff --git a/src/lib/oauth/services/codex.js b/src/lib/oauth/services/codex.js new file mode 100644 index 00000000..1edce836 --- /dev/null +++ b/src/lib/oauth/services/codex.js @@ -0,0 +1,145 @@ +import open from "open"; +import { OAuthService } from "./oauth.js"; +import { CODEX_CONFIG } from "../constants/oauth.js"; +import { getServerCredentials } from "../config/index.js"; +import { startLocalServer } from "../utils/server.js"; +import { generatePKCE } from "../utils/pkce.js"; +import { spinner as createSpinner } from "../utils/ui.js"; + +/** + * Codex (OpenAI) OAuth Service + */ +export class CodexService extends OAuthService { + constructor() { + super(CODEX_CONFIG); + } + + /** + * Build Codex authorization URL + */ + buildCodexAuthUrl(redirectUri, state, codeChallenge) { + // Build URL manually to ensure space encoding as %20 instead of + + const params = { + response_type: "code", + client_id: CODEX_CONFIG.clientId, + redirect_uri: redirectUri, + scope: CODEX_CONFIG.scope, + code_challenge: codeChallenge, + code_challenge_method: CODEX_CONFIG.codeChallengeMethod, + ...CODEX_CONFIG.extraParams, + state: state, + }; + + const queryString = Object.entries(params) + .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) + .join("&"); + + return `${CODEX_CONFIG.authorizeUrl}?${queryString}`; + } + + /** + * Save Codex tokens to server + */ + async saveTokens(tokens) { + const { server, token, userId } = getServerCredentials(); + + const response = await fetch(`${server}/api/cli/providers/codex`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + "X-User-Id": userId, + }, + body: JSON.stringify({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + idToken: tokens.id_token, + expiresIn: tokens.expires_in, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to save tokens"); + } + + return await response.json(); + } + + /** + * Complete Codex OAuth flow + */ + async connect() { + const spinner = createSpinner("Starting Codex OAuth...").start(); + + try { + spinner.text = "Starting local server..."; + + // Start local server for callback (use fixed port 1455 like real Codex CLI) + const fixedPort = 1455; + let callbackParams = null; + const { port, close } = await startLocalServer((params) => { + callbackParams = params; + }, fixedPort); + + const redirectUri = `http://localhost:${port}/auth/callback`; + spinner.succeed(`Local server started on port ${port}`); + + // Generate PKCE + const { codeVerifier, codeChallenge, state } = generatePKCE(); + + // Build authorization URL + const authUrl = this.buildCodexAuthUrl(redirectUri, state, codeChallenge); + + console.log("\nOpening browser for OpenAI authentication..."); + console.log(`If browser doesn't open, visit:\n${authUrl}\n`); + + // Open browser + await open(authUrl); + + // Wait for callback + spinner.start("Waiting for OpenAI authorization..."); + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Authentication timeout (5 minutes)")); + }, 300000); + + const checkInterval = setInterval(() => { + if (callbackParams) { + clearInterval(checkInterval); + clearTimeout(timeout); + resolve(); + } + }, 100); + }); + + close(); + + if (callbackParams.error) { + throw new Error(callbackParams.error_description || callbackParams.error); + } + + if (!callbackParams.code) { + throw new Error("No authorization code received"); + } + + spinner.start("Exchanging code for tokens..."); + + // Exchange code for tokens (Codex uses form-urlencoded) + const tokens = await this.exchangeCode(callbackParams.code, redirectUri, codeVerifier, "application/x-www-form-urlencoded"); + + spinner.text = "Saving tokens to server..."; + + // Save tokens to server + await this.saveTokens(tokens); + + spinner.succeed("Codex connected successfully!"); + return true; + } catch (error) { + spinner.fail(`Failed: ${error.message}`); + throw error; + } + } +} + diff --git a/src/lib/oauth/services/gemini.js b/src/lib/oauth/services/gemini.js new file mode 100644 index 00000000..1830c94a --- /dev/null +++ b/src/lib/oauth/services/gemini.js @@ -0,0 +1,247 @@ +import crypto from "crypto"; +import open from "open"; +import { GEMINI_CONFIG } from "../constants/oauth.js"; +import { getServerCredentials } from "../config/index.js"; +import { startLocalServer } from "../utils/server.js"; +import { spinner as createSpinner } from "../utils/ui.js"; + +/** + * Gemini CLI (Google Cloud Code Assist) OAuth Service + * Uses standard OAuth2 Authorization Code flow (no PKCE) + */ +export class GeminiCLIService { + constructor() { + this.config = GEMINI_CONFIG; + } + + /** + * Build Gemini CLI authorization URL + */ + buildAuthUrl(redirectUri, state) { + const params = new URLSearchParams({ + client_id: this.config.clientId, + response_type: "code", + redirect_uri: redirectUri, + scope: this.config.scopes.join(" "), + state: state, + access_type: "offline", + prompt: "consent", + }); + + return `${this.config.authorizeUrl}?${params.toString()}`; + } + + /** + * Exchange authorization code for tokens + */ + async exchangeCode(code, redirectUri) { + const response = await fetch(this.config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: this.config.clientId, + client_secret: this.config.clientSecret, + code: code, + redirect_uri: redirectUri, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + } + + /** + * Fetch project ID from Google Cloud Code Assist + */ + async fetchProjectId(accessToken) { + const response = await fetch( + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + { + method: "POST", + headers: { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json", + "User-Agent": "google-api-nodejs-client/9.15.1", + "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1", + "Client-Metadata": JSON.stringify({ + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI" + }) + }, + body: JSON.stringify({ + metadata: { + ideType: "IDE_UNSPECIFIED", + platform: "PLATFORM_UNSPECIFIED", + pluginType: "GEMINI" + } + }) + } + ); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to fetch project ID: ${error}`); + } + + const data = await response.json(); + + // Extract project ID + let projectId = ""; + if (typeof data.cloudaicompanionProject === "string") { + projectId = data.cloudaicompanionProject.trim(); + } else if (data.cloudaicompanionProject?.id) { + projectId = data.cloudaicompanionProject.id.trim(); + } + + if (!projectId) { + throw new Error("No project ID found in response"); + } + + return projectId; + } + + /** + * Get user info from Google + */ + async getUserInfo(accessToken) { + const response = await fetch(`${this.config.userInfoUrl}?alt=json`, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + }, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to get user info: ${error}`); + } + + return await response.json(); + } + + /** + * Save Gemini CLI tokens to server + */ + async saveTokens(tokens, userInfo, projectId) { + const { server, token, userId } = getServerCredentials(); + + const response = await fetch(`${server}/api/cli/providers/gemini-cli`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + "X-User-Id": userId, + }, + body: JSON.stringify({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + scope: tokens.scope, + email: userInfo.email, + projectId: projectId, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to save tokens"); + } + + return await response.json(); + } + + /** + * Complete Gemini OAuth flow + */ + async connect() { + const spinner = createSpinner("Starting Gemini OAuth...").start(); + + try { + spinner.text = "Starting local server..."; + + // Start local server for callback + let callbackParams = null; + const { port, close } = await startLocalServer((params) => { + callbackParams = params; + }); + + const redirectUri = `http://localhost:${port}/callback`; + spinner.succeed(`Local server started on port ${port}`); + + // Generate state + const state = crypto.randomBytes(32).toString("base64url"); + + // Build authorization URL + const authUrl = this.buildAuthUrl(redirectUri, state); + + console.log("\nOpening browser for Google authentication..."); + console.log(`If browser doesn't open, visit:\n${authUrl}\n`); + + // Open browser + await open(authUrl); + + // Wait for callback + spinner.start("Waiting for Google authorization..."); + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Authentication timeout (5 minutes)")); + }, 300000); + + const checkInterval = setInterval(() => { + if (callbackParams) { + clearInterval(checkInterval); + clearTimeout(timeout); + resolve(); + } + }, 100); + }); + + close(); + + if (callbackParams.error) { + throw new Error(callbackParams.error_description || callbackParams.error); + } + + if (!callbackParams.code) { + throw new Error("No authorization code received"); + } + + spinner.start("Exchanging code for tokens..."); + + // Exchange code for tokens + const tokens = await this.exchangeCode(callbackParams.code, redirectUri); + + spinner.text = "Fetching user info..."; + + // Get user info + const userInfo = await this.getUserInfo(tokens.access_token); + + spinner.text = "Fetching project ID..."; + + // Fetch project ID + const projectId = await this.fetchProjectId(tokens.access_token); + + spinner.text = "Saving tokens to server..."; + + // Save tokens to server + await this.saveTokens(tokens, userInfo, projectId); + + spinner.succeed(`Gemini CLI connected successfully! (${userInfo.email}, Project: ${projectId})`); + return true; + } catch (error) { + spinner.fail(`Failed: ${error.message}`); + throw error; + } + } +} + diff --git a/src/lib/oauth/services/github.js b/src/lib/oauth/services/github.js new file mode 100644 index 00000000..79f1e31e --- /dev/null +++ b/src/lib/oauth/services/github.js @@ -0,0 +1,225 @@ +import { OAuthService } from "./oauth.js"; +import { GITHUB_CONFIG } from "../constants/oauth.js"; +import { spinner as createSpinner } from "../utils/ui.js"; + +/** + * GitHub Copilot OAuth Service + * Uses Device Code Flow for authentication + */ +export class GitHubService extends OAuthService { + constructor() { + super(GITHUB_CONFIG); + } + + /** + * Get device code for GitHub authentication + */ + async getDeviceCode() { + const response = await fetch(`${GITHUB_CONFIG.deviceCodeUrl}`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + client_id: GITHUB_CONFIG.clientId, + scope: GITHUB_CONFIG.scopes, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to get device code: ${error}`); + } + + return await response.json(); + } + + /** + * Poll for access token using device code + */ + async pollAccessToken(deviceCode, verificationUri, userCode, interval = 5000) { + const spinner = createSpinner("Waiting for GitHub authentication...").start(); + + // Show user code and verification URL + console.log(`\nPlease visit: ${verificationUri}`); + console.log(`Enter code: ${userCode}\n`); + + // Open browser automatically + try { + const open = (await import("open")).default; + await open(verificationUri); + } catch (error) { + console.log("Could not open browser automatically. Please visit the URL above manually."); + } + + // Poll for access token + while (true) { + await new Promise(resolve => setTimeout(resolve, interval)); + + const response = await fetch(`${GITHUB_CONFIG.tokenUrl}`, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + client_id: GITHUB_CONFIG.clientId, + device_code: deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }); + + const data = await response.json(); + + if (data.access_token) { + spinner.succeed("GitHub authentication successful!"); + return { + access_token: data.access_token, + token_type: data.token_type, + scope: data.scope, + }; + } else if (data.error === "authorization_pending") { + // Continue polling + continue; + } else if (data.error === "slow_down") { + // Increase polling interval + interval += 5000; + continue; + } else if (data.error === "expired_token") { + spinner.fail("Device code expired. Please try again."); + throw new Error("Device code expired"); + } else if (data.error === "access_denied") { + spinner.fail("Access denied by user."); + throw new Error("Access denied"); + } else { + spinner.fail("Failed to get access token."); + throw new Error(data.error_description || data.error); + } + } + } + + /** + * Get Copilot token using GitHub access token + */ + async getCopilotToken(accessToken) { + const response = await fetch(`${GITHUB_CONFIG.copilotTokenUrl}`, { + headers: { + Authorization: `Bearer ${accessToken}`, // GitHub API typically uses Bearer + Accept: "application/json", + "X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion, + "User-Agent": GITHUB_CONFIG.userAgent, + }, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to get Copilot token: ${error}`); + } + + return await response.json(); + } + + /** + * Get user info using GitHub access token + */ + async getUserInfo(accessToken) { + const response = await fetch(`${GITHUB_CONFIG.userInfoUrl}`, { + headers: { + Authorization: `Bearer ${accessToken}`, // GitHub API typically uses Bearer + Accept: "application/json", + "X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion, + "User-Agent": GITHUB_CONFIG.userAgent, + }, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to get user info: ${error}`); + } + + return await response.json(); + } + + /** + * Complete GitHub Copilot authentication flow + */ + async authenticate() { + try { + // Get device code + const deviceResponse = await this.getDeviceCode(); + + // Poll for access token + const tokenResponse = await this.pollAccessToken( + deviceResponse.device_code, + deviceResponse.verification_uri, + deviceResponse.user_code + ); + + // Get Copilot token + const copilotToken = await this.getCopilotToken(tokenResponse.access_token); + + // Get user info + const userInfo = await this.getUserInfo(tokenResponse.access_token); + + console.log(`\n✅ Successfully authenticated as ${userInfo.login}`); + + return { + accessToken: tokenResponse.access_token, + copilotToken: copilotToken.token, + refreshToken: null, // GitHub device flow doesn't return refresh token + expiresIn: copilotToken.expires_at, + userInfo: { + id: userInfo.id, + login: userInfo.login, + name: userInfo.name, + email: userInfo.email, + }, + copilotTokenInfo: copilotToken, + }; + } catch (error) { + throw new Error(`GitHub authentication failed: ${error.message}`); + } + } + + /** + * Connect to server with GitHub credentials + */ + async connect() { + try { + // Authenticate with GitHub + const authResult = await this.authenticate(); + + // Send credentials to server + const { server, token, userId } = await import("../config/index.js").then(m => m.getServerCredentials()); + const spinner = (await import("../utils/ui.js")).spinner("Connecting to server...").start(); + + const response = await fetch(`${server}/api/cli/providers/github`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + "X-User-Id": userId, + }, + body: JSON.stringify({ + accessToken: authResult.accessToken, + copilotToken: authResult.copilotToken, + userInfo: authResult.userInfo, + copilotTokenInfo: authResult.copilotTokenInfo, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Failed to connect to server"); + } + + spinner.succeed("GitHub Copilot connected successfully!"); + console.log(`\nConnected as: ${authResult.userInfo.login}`); + } catch (error) { + const { error: showError } = await import("../utils/ui.js"); + showError(`GitHub connection failed: ${error.message}`); + throw error; + } + } +} diff --git a/src/lib/oauth/services/iflow.js b/src/lib/oauth/services/iflow.js new file mode 100644 index 00000000..307522e0 --- /dev/null +++ b/src/lib/oauth/services/iflow.js @@ -0,0 +1,202 @@ +import crypto from "crypto"; +import open from "open"; +import { IFLOW_CONFIG } from "../constants/oauth.js"; +import { getServerCredentials } from "../config/index.js"; +import { startLocalServer } from "../utils/server.js"; +import { spinner as createSpinner } from "../utils/ui.js"; + +/** + * iFlow OAuth Service + * Uses Authorization Code flow with Basic Auth + */ +export class IFlowService { + constructor() { + this.config = IFLOW_CONFIG; + } + + /** + * Build iFlow authorization URL + */ + buildAuthUrl(redirectUri, state) { + const params = new URLSearchParams({ + loginMethod: this.config.extraParams.loginMethod, + type: this.config.extraParams.type, + redirect: redirectUri, + state: state, + client_id: this.config.clientId, + }); + + return `${this.config.authorizeUrl}?${params.toString()}`; + } + + /** + * Exchange authorization code for tokens + */ + async exchangeCode(code, redirectUri) { + // Create Basic Auth header + const basicAuth = Buffer.from( + `${this.config.clientId}:${this.config.clientSecret}` + ).toString("base64"); + + const response = await fetch(this.config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + Authorization: `Basic ${basicAuth}`, + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code: code, + redirect_uri: redirectUri, + client_id: this.config.clientId, + client_secret: this.config.clientSecret, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + } + + /** + * Get user info from iFlow + */ + async getUserInfo(accessToken) { + const response = await fetch( + `${this.config.userInfoUrl}?accessToken=${encodeURIComponent(accessToken)}`, + { + headers: { + Accept: "application/json", + }, + } + ); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to get user info: ${error}`); + } + + const result = await response.json(); + + if (!result.success) { + throw new Error("Failed to get user info"); + } + + return result.data; + } + + /** + * Save iFlow tokens to server + */ + async saveTokens(tokens, userInfo) { + const { server, token, userId } = getServerCredentials(); + + const response = await fetch(`${server}/api/cli/providers/iflow`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + "X-User-Id": userId, + }, + body: JSON.stringify({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + apiKey: userInfo.apiKey, + email: userInfo.email || userInfo.phone, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to save tokens"); + } + + return await response.json(); + } + + /** + * Complete iFlow OAuth flow + */ + async connect() { + const spinner = createSpinner("Starting iFlow OAuth...").start(); + + try { + spinner.text = "Starting local server..."; + + // Start local server for callback + let callbackParams = null; + const { port, close } = await startLocalServer((params) => { + callbackParams = params; + }); + + const redirectUri = `http://localhost:${port}/callback`; + spinner.succeed(`Local server started on port ${port}`); + + // Generate state + const state = crypto.randomBytes(32).toString("base64url"); + + // Build authorization URL + const authUrl = this.buildAuthUrl(redirectUri, state); + + console.log("\nOpening browser for iFlow authentication..."); + console.log(`If browser doesn't open, visit:\n${authUrl}\n`); + + // Open browser + await open(authUrl); + + // Wait for callback + spinner.start("Waiting for iFlow authorization..."); + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Authentication timeout (5 minutes)")); + }, 300000); + + const checkInterval = setInterval(() => { + if (callbackParams) { + clearInterval(checkInterval); + clearTimeout(timeout); + resolve(); + } + }, 100); + }); + + close(); + + if (callbackParams.error) { + throw new Error(callbackParams.error_description || callbackParams.error); + } + + if (!callbackParams.code) { + throw new Error("No authorization code received"); + } + + spinner.start("Exchanging code for tokens..."); + + // Exchange code for tokens + const tokens = await this.exchangeCode(callbackParams.code, redirectUri); + + spinner.text = "Fetching user info..."; + + // Get user info (includes API key) + const userInfo = await this.getUserInfo(tokens.access_token); + + spinner.text = "Saving tokens to server..."; + + // Save tokens to server + await this.saveTokens(tokens, userInfo); + + spinner.succeed(`iFlow connected successfully! (${userInfo.email || userInfo.phone})`); + return true; + } catch (error) { + spinner.fail(`Failed: ${error.message}`); + throw error; + } + } +} + diff --git a/src/lib/oauth/services/index.js b/src/lib/oauth/services/index.js new file mode 100644 index 00000000..b3d82842 --- /dev/null +++ b/src/lib/oauth/services/index.js @@ -0,0 +1,14 @@ +/** + * Export all services + */ + +export { OAuthService } from "./oauth.js"; +export { ClaudeService } from "./claude.js"; +export { CodexService } from "./codex.js"; +export { GeminiCLIService } from "./gemini.js"; +export { QwenService } from "./qwen.js"; +export { IFlowService } from "./iflow.js"; +export { AntigravityService } from "./antigravity.js"; +export { OpenAIService } from "./openai.js"; +export { GitHubService } from "./github.js"; + diff --git a/src/lib/oauth/services/oauth.js b/src/lib/oauth/services/oauth.js new file mode 100644 index 00000000..f056171d --- /dev/null +++ b/src/lib/oauth/services/oauth.js @@ -0,0 +1,157 @@ +import open from "open"; +import { startLocalServer } from "../utils/server.js"; +import { generatePKCE } from "../utils/pkce.js"; +import { spinner as createSpinner } from "../utils/ui.js"; +import { OAUTH_TIMEOUT } from "../constants/oauth.js"; + +/** + * Generic OAuth Authorization Code Flow with PKCE + */ +export class OAuthService { + constructor(config) { + this.config = config; + } + + /** + * Build authorization URL + */ + buildAuthUrl(redirectUri, state, codeChallenge, extraParams = {}) { + const params = new URLSearchParams({ + client_id: this.config.clientId, + response_type: "code", + redirect_uri: redirectUri, + state: state, + code_challenge: codeChallenge, + code_challenge_method: this.config.codeChallengeMethod, + ...extraParams, + }); + + return `${this.config.authorizeUrl}?${params.toString()}`; + } + + /** + * Start local server and wait for callback + */ + async startAuthFlow(authUrl, providerName) { + const spinner = createSpinner("Starting local server...").start(); + + // Start local server for callback + let callbackParams = null; + const { port, close } = await startLocalServer((params) => { + callbackParams = params; + }); + + const redirectUri = `http://localhost:${port}/callback`; + spinner.succeed(`Local server started on port ${port}`); + + return { + redirectUri, + port, + close, + waitForCallback: async () => { + spinner.start(`Waiting for ${providerName} authorization...`); + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Authentication timeout (5 minutes)")); + }, OAUTH_TIMEOUT); + + const checkInterval = setInterval(() => { + if (callbackParams) { + clearInterval(checkInterval); + clearTimeout(timeout); + resolve(); + } + }, 100); + }); + + spinner.stop(); + close(); + + if (callbackParams.error) { + throw new Error(callbackParams.error_description || callbackParams.error); + } + + if (!callbackParams.code) { + throw new Error("No authorization code received"); + } + + return callbackParams; + }, + }; + } + + /** + * Exchange authorization code for tokens + */ + async exchangeCode(code, redirectUri, codeVerifier, contentType = "application/x-www-form-urlencoded") { + const body = + contentType === "application/json" + ? JSON.stringify({ + grant_type: "authorization_code", + client_id: this.config.clientId, + code: code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }) + : new URLSearchParams({ + grant_type: "authorization_code", + client_id: this.config.clientId, + code: code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }); + + const response = await fetch(this.config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": contentType, + Accept: "application/json", + }, + body: body, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + } + + /** + * Complete OAuth flow + */ + async authenticate(providerName, buildAuthUrlFn) { + // Generate PKCE + const { codeVerifier, codeChallenge, state } = generatePKCE(); + + // Start local server and get redirect URI + const { redirectUri, waitForCallback } = await this.startAuthFlow(null, providerName); + + // Build authorization URL + const authUrl = buildAuthUrlFn(redirectUri, state, codeChallenge); + + console.log(`\nOpening browser for ${providerName} authentication...`); + console.log(`If browser doesn't open, visit:\n${authUrl}\n`); + + // Open browser + await open(authUrl); + + // Wait for callback + const callbackParams = await waitForCallback(); + + // Validate state + if (callbackParams.state !== state) { + throw new Error("Invalid state parameter"); + } + + return { + code: callbackParams.code, + state: callbackParams.state, + codeVerifier, + redirectUri, + }; + } +} + diff --git a/src/lib/oauth/services/openai.js b/src/lib/oauth/services/openai.js new file mode 100644 index 00000000..851260a9 --- /dev/null +++ b/src/lib/oauth/services/openai.js @@ -0,0 +1,123 @@ +import { OAuthService } from "./oauth.js"; +import { OPENAI_CONFIG } from "../constants/oauth.js"; +import { getServerCredentials } from "../config/index.js"; +import { spinner as createSpinner } from "../utils/ui.js"; + +/** + * OpenAI OAuth Service (Native) + * Uses Authorization Code Flow with PKCE (similar to Codex) + */ +export class OpenAIService extends OAuthService { + constructor() { + super(OPENAI_CONFIG); + } + + /** + * Build OpenAI authorization URL + */ + buildOpenAIAuthUrl(redirectUri, state, codeChallenge) { + const params = new URLSearchParams({ + client_id: OPENAI_CONFIG.clientId, + response_type: "code", + redirect_uri: redirectUri, + scope: OPENAI_CONFIG.scope, + state: state, + code_challenge: codeChallenge, + code_challenge_method: OPENAI_CONFIG.codeChallengeMethod, + ...OPENAI_CONFIG.extraParams, + }); + + return `${OPENAI_CONFIG.authorizeUrl}?${params.toString()}`; + } + + /** + * Exchange OpenAI authorization code for tokens + */ + async exchangeOpenAICode(code, redirectUri, codeVerifier) { + const response = await fetch(OPENAI_CONFIG.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + client_id: OPENAI_CONFIG.clientId, + code: code, + redirect_uri: redirectUri, + code_verifier: codeVerifier, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + } + + /** + * Save OpenAI tokens to server + */ + async saveTokens(tokens) { + const { server, token, userId } = getServerCredentials(); + + const response = await fetch(`${server}/api/cli/providers/openai`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + "X-User-Id": userId, + }, + body: JSON.stringify({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + idToken: tokens.id_token, + scope: tokens.scope, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to save tokens"); + } + + return await response.json(); + } + + /** + * Complete OpenAI OAuth flow + */ + async connect() { + const spinner = createSpinner("Starting OpenAI OAuth...").start(); + + try { + spinner.text = "Starting local server..."; + + // Authenticate and get authorization code + const { code, codeVerifier, redirectUri } = await this.authenticate( + "OpenAI", + this.buildOpenAIAuthUrl.bind(this) + ); + + spinner.start("Exchanging code for tokens..."); + + // Exchange code for tokens + const tokens = await this.exchangeOpenAICode(code, redirectUri, codeVerifier); + + spinner.text = "Saving tokens to server..."; + + // Save tokens to server + await this.saveTokens(tokens); + + spinner.succeed("OpenAI connected successfully!"); + return true; + } catch (error) { + spinner.fail(`Failed: ${error.message}`); + throw error; + } + } +} + diff --git a/src/lib/oauth/services/qwen.js b/src/lib/oauth/services/qwen.js new file mode 100644 index 00000000..ee4489ea --- /dev/null +++ b/src/lib/oauth/services/qwen.js @@ -0,0 +1,170 @@ +import open from "open"; +import { QWEN_CONFIG } from "../constants/oauth.js"; +import { getServerCredentials } from "../config/index.js"; +import { generatePKCE } from "../utils/pkce.js"; +import { spinner as createSpinner } from "../utils/ui.js"; + +/** + * Qwen OAuth Service + * Uses Device Code Flow with PKCE + */ +export class QwenService { + constructor() { + this.config = QWEN_CONFIG; + } + + /** + * Request device code + */ + async requestDeviceCode(codeChallenge) { + const response = await fetch(this.config.deviceCodeUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + client_id: this.config.clientId, + scope: this.config.scope, + code_challenge: codeChallenge, + code_challenge_method: this.config.codeChallengeMethod, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Device code request failed: ${error}`); + } + + return await response.json(); + } + + /** + * Poll for token + */ + async pollForToken(deviceCode, codeVerifier, interval = 5) { + const maxAttempts = 60; // 5 minutes + const pollInterval = interval * 1000; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + await new Promise((r) => setTimeout(r, pollInterval)); + + const response = await fetch(this.config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams({ + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + client_id: this.config.clientId, + device_code: deviceCode, + code_verifier: codeVerifier, + }), + }); + + if (response.ok) { + return await response.json(); + } + + const error = await response.json(); + + if (error.error === "authorization_pending") { + continue; + } else if (error.error === "slow_down") { + await new Promise((r) => setTimeout(r, 5000)); + continue; + } else if (error.error === "expired_token") { + throw new Error("Device code expired"); + } else if (error.error === "access_denied") { + throw new Error("Access denied"); + } else { + throw new Error(error.error_description || error.error); + } + } + + throw new Error("Authorization timeout"); + } + + /** + * Save Qwen tokens to server + */ + async saveTokens(tokens) { + const { server, token, userId } = getServerCredentials(); + + const response = await fetch(`${server}/api/cli/providers/qwen`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + "X-User-Id": userId, + }, + body: JSON.stringify({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + resourceUrl: tokens.resource_url, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to save tokens"); + } + + return await response.json(); + } + + /** + * Complete Qwen OAuth flow + */ + async connect() { + const spinner = createSpinner("Starting Qwen OAuth...").start(); + + try { + spinner.text = "Generating PKCE..."; + + // Generate PKCE + const { codeVerifier, codeChallenge } = generatePKCE(); + + spinner.text = "Requesting device code..."; + + // Request device code + const deviceData = await this.requestDeviceCode(codeChallenge); + + spinner.stop(); + + console.log("\n📋 Please visit the following URL and enter the code:\n"); + console.log(` ${deviceData.verification_uri}\n`); + console.log(` Code: ${deviceData.user_code}\n`); + + // Open browser + if (deviceData.verification_uri_complete) { + await open(deviceData.verification_uri_complete); + } else { + await open(deviceData.verification_uri); + } + + spinner.start("Waiting for authorization..."); + + // Poll for token + const tokens = await this.pollForToken( + deviceData.device_code, + codeVerifier, + deviceData.interval || 5 + ); + + spinner.text = "Saving tokens to server..."; + + // Save tokens to server + await this.saveTokens(tokens); + + spinner.succeed("Qwen connected successfully!"); + return true; + } catch (error) { + spinner.fail(`Failed: ${error.message}`); + throw error; + } + } +} + diff --git a/src/lib/oauth/utils/banner.js b/src/lib/oauth/utils/banner.js new file mode 100644 index 00000000..a5743e2d --- /dev/null +++ b/src/lib/oauth/utils/banner.js @@ -0,0 +1,63 @@ +import figlet from "figlet"; +import gradient from "gradient-string"; +import chalkAnimation from "chalk-animation"; + +/** + * Display banner + */ +export function showBanner() { + const banner = figlet.textSync("LLM Proxy", { + font: "ANSI Shadow", + horizontalLayout: "default", + verticalLayout: "default", + }); + + console.log("\n" + gradient.pastel.multiline(banner)); + console.log(gradient.cristal(" 🚀 OAuth CLI for AI Providers\n")); +} + +/** + * Display simple banner (no animation) + */ +export function showSimpleBanner() { + const banner = figlet.textSync("EP CLI", { + font: "Standard", + horizontalLayout: "default", + }); + console.log(gradient.pastel.multiline(banner)); + console.log(gradient.cristal(" OAuth CLI for AI Providers\n")); +} + +/** + * Display success animation + */ +export async function showSuccess(message) { + return new Promise((resolve) => { + const animation = chalkAnimation.rainbow(`\n✨ ${message}\n`); + setTimeout(() => { + animation.stop(); + resolve(); + }, 1000); + }); +} + +/** + * Display loading animation + */ +export function showLoading(text) { + const frames = ["⠋", "⠙", "â š", "â ¸", "â ŧ", "â ´", "â Ļ", "â §", "⠇", "⠏"]; + let i = 0; + + const interval = setInterval(() => { + process.stdout.write(`\r${frames[i]} ${text}`); + i = (i + 1) % frames.length; + }, 80); + + return { + stop: () => { + clearInterval(interval); + process.stdout.write("\r"); + }, + }; +} + diff --git a/src/lib/oauth/utils/pkce.js b/src/lib/oauth/utils/pkce.js new file mode 100644 index 00000000..260096c7 --- /dev/null +++ b/src/lib/oauth/utils/pkce.js @@ -0,0 +1,38 @@ +import crypto from "crypto"; + +/** + * Generate PKCE code verifier (43-128 characters) + */ +export function generateCodeVerifier() { + return crypto.randomBytes(32).toString("base64url"); +} + +/** + * Generate PKCE code challenge from verifier (S256 method) + */ +export function generateCodeChallenge(verifier) { + return crypto.createHash("sha256").update(verifier).digest("base64url"); +} + +/** + * Generate random state for CSRF protection + */ +export function generateState() { + return crypto.randomBytes(32).toString("base64url"); +} + +/** + * Generate complete PKCE pair + */ +export function generatePKCE() { + const codeVerifier = generateCodeVerifier(); + const codeChallenge = generateCodeChallenge(codeVerifier); + const state = generateState(); + + return { + codeVerifier, + codeChallenge, + state, + }; +} + diff --git a/src/lib/oauth/utils/server.js b/src/lib/oauth/utils/server.js new file mode 100644 index 00000000..e3013fc6 --- /dev/null +++ b/src/lib/oauth/utils/server.js @@ -0,0 +1,116 @@ +import http from "http"; +import { URL } from "url"; + +/** + * Start a local HTTP server to receive OAuth callback + * @param {Function} onCallback - Called with query params when callback received + * @param {number} fixedPort - Optional fixed port number (default: random) + * @returns {Promise<{server: http.Server, port: number, close: Function}>} + */ +export function startLocalServer(onCallback, fixedPort = null) { + return new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + const url = new URL(req.url, `http://localhost`); + + if (url.pathname === "/callback" || url.pathname === "/auth/callback") { + const params = Object.fromEntries(url.searchParams); + + // Send success response to browser with auto-close attempt + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(` + + + + Authentication Successful + + + +
+
+

Authentication Successful

+

Closing in 3 seconds...

+
+ + +`); + + // Call callback with params + onCallback(params); + } else { + res.writeHead(404); + res.end("Not found"); + } + }); + + // Listen on fixed port or find available port + const portToUse = fixedPort || 0; + server.listen(portToUse, "127.0.0.1", () => { + const { port } = server.address(); + resolve({ + server, + port, + close: () => server.close(), + }); + }); + + server.on("error", (err) => { + if (err.code === "EADDRINUSE" && fixedPort) { + reject(new Error(`Port ${fixedPort} is already in use. Please close other applications using this port.`)); + } else { + reject(err); + } + }); + }); +} + +/** + * Wait for callback with timeout + * @param {number} timeoutMs - Timeout in milliseconds + * @returns {Promise} - Callback params + */ +export function waitForCallback(timeoutMs = 300000) { + return new Promise((resolve, reject) => { + let resolved = false; + + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true; + reject(new Error("Authentication timeout")); + } + }, timeoutMs); + + const onCallback = (params) => { + if (!resolved) { + resolved = true; + clearTimeout(timeout); + resolve(params); + } + }; + + // Return the callback function + resolve.__onCallback = onCallback; + }); +} + diff --git a/src/lib/oauth/utils/ui.js b/src/lib/oauth/utils/ui.js new file mode 100644 index 00000000..a81ff48e --- /dev/null +++ b/src/lib/oauth/utils/ui.js @@ -0,0 +1,48 @@ +import chalk from "chalk"; +import ora from "ora"; + +/** + * UI Helper Functions + */ + +export function success(message) { + console.log(chalk.green(`\n✓ ${message}\n`)); +} + +export function error(message) { + console.log(chalk.red(`\n✗ ${message}\n`)); +} + +export function info(message) { + console.log(chalk.blue(`\n${message}\n`)); +} + +export function warn(message) { + console.log(chalk.yellow(`\n⚠ ${message}\n`)); +} + +export function gray(message) { + console.log(chalk.gray(message)); +} + +export function spinner(text) { + return ora(text); +} + +export function printSection(title) { + console.log(chalk.blue(`\n${title}\n`)); +} + +export function printKeyValue(key, value, isSuccess = false) { + const color = isSuccess ? chalk.green : chalk.gray; + console.log(color(` ${key}: ${value}`)); +} + +export function printList(items, isSuccess = false) { + const symbol = isSuccess ? "✓" : "✗"; + const color = isSuccess ? chalk.green : chalk.gray; + items.forEach((item) => { + console.log(color(` ${symbol} ${item}`)); + }); +} + diff --git a/src/lib/usage/fetcher.js b/src/lib/usage/fetcher.js new file mode 100644 index 00000000..683bceca --- /dev/null +++ b/src/lib/usage/fetcher.js @@ -0,0 +1,202 @@ +/** + * Usage Fetcher - Get usage data from provider APIs + */ + +import { GITHUB_CONFIG, GEMINI_CONFIG, ANTIGRAVITY_CONFIG } from "@/lib/oauth/constants/oauth"; + +/** + * Get usage data for a provider connection + * @param {Object} connection - Provider connection with accessToken + * @returns {Object} Usage data with quotas + */ +export async function getUsageForProvider(connection) { + const { provider, accessToken, providerSpecificData } = connection; + + switch (provider) { + case "github": + return await getGitHubUsage(accessToken, providerSpecificData); + case "gemini-cli": + return await getGeminiUsage(accessToken); + case "antigravity": + return await getAntigravityUsage(accessToken); + case "claude": + return await getClaudeUsage(accessToken); + case "codex": + return await getCodexUsage(accessToken); + case "qwen": + return await getQwenUsage(accessToken, providerSpecificData); + case "iflow": + return await getIflowUsage(accessToken); + default: + return { message: `Usage API not implemented for ${provider}` }; + } +} + +/** + * GitHub Copilot Usage + */ +async function getGitHubUsage(accessToken, providerSpecificData) { + try { + const response = await fetch("https://api.github.com/copilot_internal/user", { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + "X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion, + "User-Agent": GITHUB_CONFIG.userAgent, + }, + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`GitHub API error: ${error}`); + } + + const data = await response.json(); + + // Handle different response formats (paid vs free) + if (data.quota_snapshots) { + // Paid plan format + const snapshots = data.quota_snapshots; + return { + plan: data.copilot_plan, + resetDate: data.quota_reset_date, + quotas: { + chat: formatGitHubQuotaSnapshot(snapshots.chat), + completions: formatGitHubQuotaSnapshot(snapshots.completions), + premium_interactions: formatGitHubQuotaSnapshot(snapshots.premium_interactions), + }, + }; + } else if (data.monthly_quotas || data.limited_user_quotas) { + // Free/limited plan format + const monthlyQuotas = data.monthly_quotas || {}; + const usedQuotas = data.limited_user_quotas || {}; + + return { + plan: data.copilot_plan || data.access_type_sku, + resetDate: data.limited_user_reset_date, + quotas: { + chat: { + used: usedQuotas.chat || 0, + total: monthlyQuotas.chat || 0, + unlimited: false, + }, + completions: { + used: usedQuotas.completions || 0, + total: monthlyQuotas.completions || 0, + unlimited: false, + }, + }, + }; + } + + return { message: "GitHub Copilot connected. Unable to parse quota data." }; + } catch (error) { + throw new Error(`Failed to fetch GitHub usage: ${error.message}`); + } +} + +function formatGitHubQuotaSnapshot(quota) { + if (!quota) return { used: 0, total: 0, unlimited: true }; + + return { + used: quota.entitlement - quota.remaining, + total: quota.entitlement, + remaining: quota.remaining, + unlimited: quota.unlimited || false, + }; +} + +/** + * Gemini CLI Usage (Google Cloud) + */ +async function getGeminiUsage(accessToken) { + try { + // Gemini CLI uses Google Cloud quotas + // Try to get quota info from Cloud Resource Manager + const response = await fetch( + "https://cloudresourcemanager.googleapis.com/v1/projects?filter=lifecycleState:ACTIVE", + { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + }, + } + ); + + if (!response.ok) { + // Quota API may not be accessible, return generic message + return { message: "Gemini CLI uses Google Cloud quotas. Check Google Cloud Console for details." }; + } + + return { message: "Gemini CLI connected. Usage tracked via Google Cloud Console." }; + } catch (error) { + return { message: "Unable to fetch Gemini usage. Check Google Cloud Console." }; + } +} + +/** + * Antigravity Usage + */ +async function getAntigravityUsage(accessToken) { + try { + // Similar to Gemini, uses Google Cloud + return { message: "Antigravity connected. Usage tracked via Google Cloud Console." }; + } catch (error) { + return { message: "Unable to fetch Antigravity usage." }; + } +} + +/** + * Claude Usage + */ +async function getClaudeUsage(accessToken) { + try { + // Claude OAuth doesn't expose usage API directly + // Could potentially check via inference endpoint + return { message: "Claude connected. Usage tracked per request." }; + } catch (error) { + return { message: "Unable to fetch Claude usage." }; + } +} + +/** + * Codex (OpenAI) Usage + */ +async function getCodexUsage(accessToken) { + try { + // OpenAI usage requires organization API access + return { message: "Codex connected. Check OpenAI dashboard for usage." }; + } catch (error) { + return { message: "Unable to fetch Codex usage." }; + } +} + +/** + * Qwen Usage + */ +async function getQwenUsage(accessToken, providerSpecificData) { + try { + const resourceUrl = providerSpecificData?.resourceUrl; + if (!resourceUrl) { + return { message: "Qwen connected. No resource URL available." }; + } + + // Qwen may have usage endpoint at resource URL + return { message: "Qwen connected. Usage tracked per request." }; + } catch (error) { + return { message: "Unable to fetch Qwen usage." }; + } +} + +/** + * iFlow Usage + */ +async function getIflowUsage(accessToken) { + try { + // iFlow may have usage endpoint + return { message: "iFlow connected. Usage tracked per request." }; + } catch (error) { + return { message: "Unable to fetch iFlow usage." }; + } +} + diff --git a/src/models/index.js b/src/models/index.js new file mode 100644 index 00000000..4319e157 --- /dev/null +++ b/src/models/index.js @@ -0,0 +1,16 @@ +// Database Models - Export all from localDb +export { + getProviderConnections, + getProviderConnectionById, + createProviderConnection, + updateProviderConnection, + deleteProviderConnection, + getModelAliases, + setModelAlias, + deleteModelAlias, + getApiKeys, + createApiKey, + deleteApiKey, + validateApiKey, + isCloudEnabled, +} from "@/lib/localDb"; diff --git a/src/server-init.js b/src/server-init.js new file mode 100644 index 00000000..4c46a548 --- /dev/null +++ b/src/server-init.js @@ -0,0 +1,21 @@ +// Server startup script +import initializeCloudSync from "./shared/services/initializeCloudSync.js"; + +async function startServer() { + console.log("Starting server with cloud sync..."); + + try { + // Initialize cloud sync + await initializeCloudSync(); + console.log("Server started with cloud sync initialized"); + } catch (error) { + console.log("Error initializing cloud sync:", error); + process.exit(1); + } +} + +// Start the server initialization +startServer().catch(console.log); + +// Export for use as module if needed +export default startServer; diff --git a/src/shared/components/Avatar.js b/src/shared/components/Avatar.js new file mode 100644 index 00000000..ae0a7552 --- /dev/null +++ b/src/shared/components/Avatar.js @@ -0,0 +1,88 @@ +"use client"; + +import { cn } from "@/shared/utils/cn"; + +export default function Avatar({ + src, + alt = "Avatar", + name, + size = "md", + className, +}) { + const sizes = { + xs: "size-6 text-xs", + sm: "size-8 text-sm", + md: "size-10 text-base", + lg: "size-12 text-lg", + xl: "size-16 text-xl", + }; + + // Get initials from name + const getInitials = (name) => { + if (!name) return "?"; + const parts = name.split(" "); + if (parts.length >= 2) { + return `${parts[0][0]}${parts[1][0]}`.toUpperCase(); + } + return name.substring(0, 2).toUpperCase(); + }; + + // Generate color from name + const getColorFromName = (name) => { + if (!name) return "bg-primary"; + const colors = [ + "bg-red-500", + "bg-orange-500", + "bg-amber-500", + "bg-yellow-500", + "bg-lime-500", + "bg-green-500", + "bg-emerald-500", + "bg-teal-500", + "bg-cyan-500", + "bg-sky-500", + "bg-blue-500", + "bg-indigo-500", + "bg-violet-500", + "bg-purple-500", + "bg-fuchsia-500", + "bg-pink-500", + "bg-rose-500", + ]; + const index = name.charCodeAt(0) % colors.length; + return colors[index]; + }; + + if (src) { + return ( +
+ ); + } + + return ( +
+ {getInitials(name)} +
+ ); +} + diff --git a/src/shared/components/Badge.js b/src/shared/components/Badge.js new file mode 100644 index 00000000..1a412a74 --- /dev/null +++ b/src/shared/components/Badge.js @@ -0,0 +1,55 @@ +"use client"; + +import { cn } from "@/shared/utils/cn"; + +const variants = { + default: "bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300", + primary: "bg-primary/10 text-primary", + success: "bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400 border border-green-100 dark:border-green-800/30", + warning: "bg-yellow-50 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-500 border border-yellow-100 dark:border-yellow-800/30", + error: "bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 border border-red-100 dark:border-red-800/30", + info: "bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400 border border-blue-100 dark:border-blue-800/30", +}; + +const sizes = { + sm: "px-2 py-0.5 text-[10px]", + md: "px-2.5 py-1 text-xs", + lg: "px-3 py-1.5 text-sm", +}; + +export default function Badge({ + children, + variant = "default", + size = "md", + dot = false, + icon, + className, +}) { + return ( + + {dot && ( + + )} + {icon && {icon}} + {children} + + ); +} + diff --git a/src/shared/components/Button.js b/src/shared/components/Button.js new file mode 100644 index 00000000..bb5e8ce3 --- /dev/null +++ b/src/shared/components/Button.js @@ -0,0 +1,56 @@ +"use client"; + +import { cn } from "@/shared/utils/cn"; + +const variants = { + primary: "bg-primary text-white hover:bg-primary-hover shadow-warm", + secondary: "bg-surface border border-border text-text-main hover:bg-black/5 shadow-sm", + outline: "border border-border text-text-main hover:bg-black/5", + ghost: "text-text-muted hover:bg-black/5 hover:text-text-main", + danger: "bg-red-500 text-white hover:bg-red-600 shadow-sm", +}; + +const sizes = { + sm: "h-8 px-3 text-xs rounded-md", + md: "h-10 px-5 text-sm rounded-lg", + lg: "h-12 px-8 text-base rounded-xl", +}; + +export default function Button({ + children, + variant = "primary", + size = "md", + icon, + iconRight, + disabled = false, + loading = false, + fullWidth = false, + className, + ...props +}) { + return ( + + ); +} + diff --git a/src/shared/components/Card.js b/src/shared/components/Card.js new file mode 100644 index 00000000..e77858f3 --- /dev/null +++ b/src/shared/components/Card.js @@ -0,0 +1,92 @@ +"use client"; + +import { cn } from "@/shared/utils/cn"; + +export default function Card({ + children, + title, + subtitle, + icon, + action, + padding = "md", + hover = false, + className, + ...props +}) { + const paddings = { + none: "", + sm: "p-4", + md: "p-6", + lg: "p-8", + }; + + return ( +
+ {(title || action) && ( +
+
+ {icon && ( +
+ {icon} +
+ )} +
+ {title && ( +

{title}

+ )} + {subtitle && ( +

{subtitle}

+ )} +
+
+ {action} +
+ )} + {children} +
+ ); +} + +// Sub-component: Bordered section inside Card +Card.Section = function CardSection({ children, className, ...props }) { + return ( +
+ {children} +
+ ); +}; + +// Sub-component: Hoverable row inside Card +Card.Row = function CardRow({ children, className, ...props }) { + return ( +
+ {children} +
+ ); +}; + diff --git a/src/shared/components/Footer.js b/src/shared/components/Footer.js new file mode 100644 index 00000000..641ebdb0 --- /dev/null +++ b/src/shared/components/Footer.js @@ -0,0 +1,132 @@ +"use client"; + +import Link from "next/link"; +import { APP_CONFIG } from "@/shared/constants/config"; + +const footerLinks = { + product: [ + { label: "Features", href: "#features" }, + { label: "Pricing", href: "#pricing" }, + { label: "Changelog", href: "#" }, + ], + resources: [ + { label: "Documentation", href: "#" }, + { label: "API Reference", href: "#" }, + { label: "Help Center", href: "#" }, + ], + company: [ + { label: "About", href: "#" }, + { label: "Blog", href: "#" }, + { label: "Contact", href: "#" }, + ], +}; + +export default function Footer() { + return ( +
+
+
+ {/* Brand */} +
+
+
+ + + +
+ + {APP_CONFIG.name} + +
+

+ The unified interface for modern AI infrastructure. Secure, observable, and scalable. +

+ {/* Social links */} + +
+ + {/* Product */} +
+

Product

+
    + {footerLinks.product.map((link) => ( +
  • + + {link.label} + +
  • + ))} +
+
+ + {/* Resources */} +
+

Resources

+
    + {footerLinks.resources.map((link) => ( +
  • + + {link.label} + +
  • + ))} +
+
+ + {/* Company */} +
+

Company

+
    + {footerLinks.company.map((link) => ( +
  • + + {link.label} + +
  • + ))} +
+
+
+ + {/* Bottom */} +
+

+ Š {new Date().getFullYear()} {APP_CONFIG.name} Inc. All rights reserved. +

+
+ + Privacy Policy + + + Terms of Service + +
+
+
+
+ ); +} + diff --git a/src/shared/components/Header.js b/src/shared/components/Header.js new file mode 100644 index 00000000..7376e255 --- /dev/null +++ b/src/shared/components/Header.js @@ -0,0 +1,109 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import Link from "next/link"; +import { ThemeToggle } from "@/shared/components"; +import { APP_CONFIG, OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config"; + +const getPageInfo = (pathname) => { + if (!pathname) return { title: "", description: "", breadcrumbs: [] }; + + // Provider detail page: /dashboard/providers/[id] + const providerMatch = pathname.match(/\/providers\/([^/]+)$/); + if (providerMatch) { + const providerId = providerMatch[1]; + const providerInfo = OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId]; + if (providerInfo) { + return { + title: providerInfo.name, + description: "", + breadcrumbs: [ + { label: "Providers", href: "/dashboard/providers" }, + { label: providerInfo.name, image: `/providers/${providerInfo.id}.png` } + ] + }; + } + } + + if (pathname.includes("/providers")) return { title: "Providers", description: "Manage your AI provider connections", breadcrumbs: [] }; + if (pathname.includes("/combos")) return { title: "Combos", description: "Model combos with fallback", breadcrumbs: [] }; + if (pathname.includes("/cli-tools")) return { title: "CLI Tools", description: "Configure CLI tools", breadcrumbs: [] }; + if (pathname.includes("/endpoint")) return { title: "Endpoint", description: "API endpoint configuration", breadcrumbs: [] }; + if (pathname.includes("/profile")) return { title: "Settings", description: "Manage your preferences", breadcrumbs: [] }; + if (pathname === "/dashboard") return { title: "Endpoint", description: "API endpoint configuration", breadcrumbs: [] }; + return { title: "", description: "", breadcrumbs: [] }; +}; + +export default function Header({ onMenuClick, showMenuButton = true }) { + const pathname = usePathname(); + const { title, description, breadcrumbs } = getPageInfo(pathname); + + return ( +
+ {/* Mobile menu button */} +
+ {showMenuButton && ( + + )} +
+ + {/* Page title with breadcrumbs - desktop */} +
+ {breadcrumbs.length > 0 ? ( +
+ {breadcrumbs.map((crumb, index) => ( +
+ {index > 0 && ( + + chevron_right + + )} + {crumb.href ? ( + + {crumb.label} + + ) : ( +
+ {crumb.image && ( + {crumb.label} { e.target.style.display = "none"; }} + /> + )} +

+ {crumb.label} +

+
+ )} +
+ ))} +
+ ) : title ? ( +
+

{title}

+ {description && ( +

{description}

+ )} +
+ ) : null} +
+ + {/* Right actions */} +
+ {/* Theme toggle */} + +
+
+ ); +} + diff --git a/src/shared/components/Input.js b/src/shared/components/Input.js new file mode 100644 index 00000000..85f4588d --- /dev/null +++ b/src/shared/components/Input.js @@ -0,0 +1,69 @@ +"use client"; + +import { cn } from "@/shared/utils/cn"; + +export default function Input({ + label, + type = "text", + placeholder, + value, + onChange, + error, + hint, + icon, + disabled = false, + required = false, + className, + inputClassName, + ...props +}) { + return ( +
+ {label && ( + + )} +
+ {icon && ( +
+ {icon} +
+ )} + +
+ {error && ( +

+ error + {error} +

+ )} + {hint && !error && ( +

{hint}

+ )} +
+ ); +} + diff --git a/src/shared/components/Loading.js b/src/shared/components/Loading.js new file mode 100644 index 00000000..cd08935b --- /dev/null +++ b/src/shared/components/Loading.js @@ -0,0 +1,77 @@ +"use client"; + +import { cn } from "@/shared/utils/cn"; + +// Spinner loading +export function Spinner({ size = "md", className }) { + const sizes = { + sm: "size-4", + md: "size-6", + lg: "size-8", + xl: "size-12", + }; + + return ( + + progress_activity + + ); +} + +// Full page loading +export function PageLoading({ message = "Loading..." }) { + return ( +
+ +

{message}

+
+ ); +} + +// Skeleton loading +export function Skeleton({ className, ...props }) { + return ( +
+ ); +} + +// Card skeleton +export function CardSkeleton() { + return ( +
+
+ + +
+ + +
+ ); +} + +// Default export +export default function Loading({ type = "spinner", ...props }) { + switch (type) { + case "page": + return ; + case "skeleton": + return ; + case "card": + return ; + default: + return ; + } +} + diff --git a/src/shared/components/Modal.js b/src/shared/components/Modal.js new file mode 100644 index 00000000..7413cb46 --- /dev/null +++ b/src/shared/components/Modal.js @@ -0,0 +1,136 @@ +"use client"; + +import { useEffect } from "react"; +import { cn } from "@/shared/utils/cn"; +import Button from "./Button"; + +export default function Modal({ + isOpen, + onClose, + title, + children, + footer, + size = "md", + closeOnOverlay = true, + showCloseButton = true, + className, +}) { + const sizes = { + sm: "max-w-sm", + md: "max-w-md", + lg: "max-w-lg", + xl: "max-w-xl", + full: "max-w-4xl", + }; + + // Lock body scroll when modal is open + useEffect(() => { + if (isOpen) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + return () => { + document.body.style.overflow = ""; + }; + }, [isOpen]); + + // Handle escape key + useEffect(() => { + const handleEscape = (e) => { + if (e.key === "Escape" && isOpen) { + onClose(); + } + }; + document.addEventListener("keydown", handleEscape); + return () => document.removeEventListener("keydown", handleEscape); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + return ( +
+ {/* Overlay */} +
+ + {/* Modal content */} +
+ {/* Header */} + {(title || showCloseButton) && ( +
+ {title && ( +

+ {title} +

+ )} + {showCloseButton && ( + + )} +
+ )} + + {/* Body */} +
{children}
+ + {/* Footer */} + {footer && ( +
+ {footer} +
+ )} +
+
+ ); +} + +// Confirm Modal helper +export function ConfirmModal({ + isOpen, + onClose, + onConfirm, + title = "Confirm", + message, + confirmText = "Confirm", + cancelText = "Cancel", + variant = "danger", + loading = false, +}) { + return ( + + + + + } + > +

{message}

+
+ ); +} + diff --git a/src/shared/components/ModelSelectModal.js b/src/shared/components/ModelSelectModal.js new file mode 100644 index 00000000..25e06daf --- /dev/null +++ b/src/shared/components/ModelSelectModal.js @@ -0,0 +1,183 @@ +"use client"; + +import { useState, useMemo } from "react"; +import Modal from "./Modal"; +import { getModelsByProviderId, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models"; +import { AI_PROVIDERS } from "@/shared/constants/providers"; + +export default function ModelSelectModal({ + isOpen, + onClose, + onSelect, + selectedModel, + activeProviders = [], + title = "Select Model", + modelAliases = {}, +}) { + const [searchQuery, setSearchQuery] = useState(""); + + // Group models by provider + const groupedModels = useMemo(() => { + const groups = {}; + + // Get active provider IDs + const activeProviderIds = activeProviders.length > 0 + ? activeProviders.map(p => p.provider) + : Object.keys(AI_PROVIDERS); + + activeProviderIds.forEach((providerId) => { + const alias = PROVIDER_ID_TO_ALIAS[providerId] || providerId; + const providerInfo = AI_PROVIDERS[providerId] || { name: providerId, color: "#666" }; + + // For passthrough providers, get models from aliases + if (providerInfo.passthroughModels) { + const aliasModels = Object.entries(modelAliases) + .filter(([, fullModel]) => fullModel.startsWith(`${alias}/`)) + .map(([aliasName, fullModel]) => ({ + id: fullModel.replace(`${alias}/`, ""), + name: aliasName, + value: fullModel, + })); + + if (aliasModels.length > 0) { + groups[providerId] = { + name: providerInfo.name, + alias: alias, + color: providerInfo.color, + models: aliasModels, + }; + } + } else { + const models = getModelsByProviderId(providerId); + if (models.length > 0) { + groups[providerId] = { + name: providerInfo.name, + alias: alias, + color: providerInfo.color, + models: models.map((m) => ({ + id: m.id, + name: m.name, + value: `${alias}/${m.id}`, + })), + }; + } + } + }); + + return groups; + }, [activeProviders, modelAliases]); + + // Filter models by search query + const filteredGroups = useMemo(() => { + if (!searchQuery.trim()) return groupedModels; + + const query = searchQuery.toLowerCase(); + const filtered = {}; + + Object.entries(groupedModels).forEach(([providerId, group]) => { + const matchedModels = group.models.filter( + (m) => + m.name.toLowerCase().includes(query) || + m.id.toLowerCase().includes(query) || + group.name.toLowerCase().includes(query) + ); + + if (matchedModels.length > 0) { + filtered[providerId] = { + ...group, + models: matchedModels, + }; + } + }); + + return filtered; + }, [groupedModels, searchQuery]); + + const handleSelect = (model) => { + onSelect(model); + onClose(); + setSearchQuery(""); + }; + + return ( + { + onClose(); + setSearchQuery(""); + }} + title={title} + size="md" + className="!p-4" + > + {/* Search - compact */} +
+
+ + search + + setSearchQuery(e.target.value)} + className="w-full pl-8 pr-3 py-1.5 bg-surface border border-border rounded text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" + /> +
+
+ + {/* Models grouped by provider - compact */} +
+ {Object.entries(filteredGroups).map(([providerId, group]) => ( +
+ {/* Provider header */} +
+
+ + {group.name} + + + ({group.models.length}) + +
+ + {/* Models as wrap chips - compact */} +
+ {group.models.map((model) => { + const isSelected = selectedModel === model.value; + return ( + + ); + })} +
+
+ ))} + + {Object.keys(filteredGroups).length === 0 && ( +
+ + search_off + +

No models found

+
+ )} +
+ + ); +} + diff --git a/src/shared/components/OAuthModal.js b/src/shared/components/OAuthModal.js new file mode 100644 index 00000000..81be4e54 --- /dev/null +++ b/src/shared/components/OAuthModal.js @@ -0,0 +1,419 @@ +"use client"; + +import { useState, useEffect, useRef, useCallback } from "react"; +import { Modal, Button, Input } from "@/shared/components"; +import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; + +/** + * OAuth Modal Component + * - Localhost: Auto callback via popup message + * - Remote: Manual paste callback URL + */ +export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess, onClose }) { + const [step, setStep] = useState("waiting"); // waiting | input | success | error + const [authData, setAuthData] = useState(null); + const [callbackUrl, setCallbackUrl] = useState(""); + const [error, setError] = useState(null); + const [isDeviceCode, setIsDeviceCode] = useState(false); + const [deviceData, setDeviceData] = useState(null); + const [polling, setPolling] = useState(false); + const popupRef = useRef(null); + const { copied, copy } = useCopyToClipboard(); + + // Detect if running on localhost + const isLocalhost = typeof window !== "undefined" && + (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1"); + + // Reset state and start OAuth when modal opens + useEffect(() => { + if (isOpen && provider) { + setAuthData(null); + setCallbackUrl(""); + setError(null); + setIsDeviceCode(false); + setDeviceData(null); + setPolling(false); + // Auto start OAuth + startOAuthFlow(); + } + }, [isOpen, provider]); + + // Listen for OAuth callback via multiple methods + const callbackProcessedRef = useRef(false); + + useEffect(() => { + if (!authData) return; + callbackProcessedRef.current = false; // Reset when authData changes + + // Handler for callback data - only process once + const handleCallback = async (data) => { + if (callbackProcessedRef.current) return; // Already processed + + const { code, state, error: callbackError, errorDescription } = data; + + if (callbackError) { + callbackProcessedRef.current = true; + setError(errorDescription || callbackError); + setStep("error"); + return; + } + + if (code) { + callbackProcessedRef.current = true; + await exchangeTokens(code, state); + } + }; + + // Method 1: postMessage from popup + const handleMessage = (event) => { + if (event.origin !== window.location.origin) return; + if (event.data?.type === "oauth_callback") { + handleCallback(event.data.data); + } + }; + window.addEventListener("message", handleMessage); + + // Method 2: BroadcastChannel + let channel; + try { + channel = new BroadcastChannel("oauth_callback"); + channel.onmessage = (event) => handleCallback(event.data); + } catch (e) { + console.log("BroadcastChannel not supported"); + } + + // Method 3: localStorage event + const handleStorage = (event) => { + if (event.key === "oauth_callback" && event.newValue) { + try { + const data = JSON.parse(event.newValue); + handleCallback(data); + localStorage.removeItem("oauth_callback"); + } catch (e) { + console.log("Failed to parse localStorage data"); + } + } + }; + window.addEventListener("storage", handleStorage); + + // Also check localStorage on mount (in case callback already happened) + try { + const stored = localStorage.getItem("oauth_callback"); + if (stored) { + const data = JSON.parse(stored); + // Only use if recent (within 30 seconds) + if (data.timestamp && Date.now() - data.timestamp < 30000) { + handleCallback(data); + localStorage.removeItem("oauth_callback"); + } + } + } catch (e) {} + + return () => { + window.removeEventListener("message", handleMessage); + window.removeEventListener("storage", handleStorage); + if (channel) channel.close(); + }; + }, [authData]); + + // Exchange tokens + const exchangeTokens = async (code, state) => { + try { + const res = await fetch(`/api/oauth/${provider}/exchange`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + code, + redirectUri: authData.redirectUri, + codeVerifier: authData.codeVerifier, + state, + }), + }); + + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + + setStep("success"); + onSuccess?.(); + } catch (err) { + setError(err.message); + setStep("error"); + } + }; + + // Start OAuth flow + const startOAuthFlow = async () => { + if (!provider) return; + try { + setError(null); + + // Device code flow (GitHub, Qwen) + if (provider === "github" || provider === "qwen") { + setIsDeviceCode(true); + setStep("waiting"); + + const res = await fetch(`/api/oauth/${provider}/device-code`); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + + setDeviceData(data); + + // Open verification URL + const verifyUrl = data.verification_uri_complete || data.verification_uri; + if (verifyUrl) window.open(verifyUrl, "_blank"); + + // Start polling + startPolling(data.device_code, data.codeVerifier, data.interval || 5); + return; + } + + // Authorization code flow - always use localhost with current port (except Codex) + let redirectUri; + if (provider === "codex") { + // Codex requires fixed port 1455 + redirectUri = "http://localhost:1455/auth/callback"; + } else { + // Always use localhost with current port for OAuth callback + const port = window.location.port || (window.location.protocol === "https:" ? "443" : "80"); + redirectUri = `http://localhost:${port}/callback`; + } + + const res = await fetch(`/api/oauth/${provider}/authorize?redirect_uri=${encodeURIComponent(redirectUri)}`); + const data = await res.json(); + if (!res.ok) throw new Error(data.error); + + setAuthData({ ...data, redirectUri }); + + // For Codex, always use manual input since it requires fixed port 1455 + if (provider === "codex") { + setStep("input"); + window.open(data.authUrl, "_blank"); + } else if (isLocalhost) { + // Other providers on localhost: Open popup and wait for message + setStep("waiting"); + popupRef.current = window.open(data.authUrl, "oauth_popup", "width=600,height=700"); + + // Check if popup was blocked + if (!popupRef.current) { + setStep("input"); + } + } else { + // Remote: Show manual input + setStep("input"); + window.open(data.authUrl, "_blank"); + } + } catch (err) { + setError(err.message); + setStep("error"); + } + }; + + // Poll for device code token + const startPolling = async (deviceCode, codeVerifier, interval) => { + setPolling(true); + const maxAttempts = 60; + + for (let i = 0; i < maxAttempts; i++) { + await new Promise((r) => setTimeout(r, interval * 1000)); + + try { + const res = await fetch(`/api/oauth/${provider}/poll`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ deviceCode, codeVerifier }), + }); + + const data = await res.json(); + + if (data.success) { + setStep("success"); + setPolling(false); + onSuccess?.(); + return; + } + + if (data.error === "expired_token" || data.error === "access_denied") { + throw new Error(data.errorDescription || data.error); + } + + if (data.error === "slow_down") { + interval = Math.min(interval + 5, 30); + } + } catch (err) { + setError(err.message); + setStep("error"); + setPolling(false); + return; + } + } + + setError("Authorization timeout"); + setStep("error"); + setPolling(false); + }; + + // Handle manual URL input + const handleManualSubmit = async () => { + try { + setError(null); + const url = new URL(callbackUrl); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const errorParam = url.searchParams.get("error"); + + if (errorParam) { + throw new Error(url.searchParams.get("error_description") || errorParam); + } + + if (!code) { + throw new Error("No authorization code found in URL"); + } + + await exchangeTokens(code, state); + } catch (err) { + setError(err.message); + setStep("error"); + } + }; + + if (!provider || !providerInfo) return null; + + return ( + +
+ {/* Waiting Step (Localhost - popup mode) */} + {step === "waiting" && !isDeviceCode && ( +
+
+ + progress_activity + +
+

Waiting for Authorization

+

+ Complete the authorization in the popup window. +

+ +
+ )} + + {/* Device Code Flow - Waiting */} + {step === "waiting" && isDeviceCode && deviceData && ( + <> +
+

+ Visit the URL below and enter the code: +

+
+

Verification URL

+
+ {deviceData.verification_uri} +
+
+
+

Your Code

+
+

{deviceData.user_code}

+
+
+
+ {polling && ( +
+ progress_activity + Waiting for authorization... +
+ )} + + )} + + {/* Manual Input Step */} + {step === "input" && !isDeviceCode && ( + <> +
+
+

Step 1: Open this URL in your browser

+
+ + +
+
+ +
+

Step 2: Paste the callback URL here

+

+ After authorization, copy the full URL from your browser. +

+ setCallbackUrl(e.target.value)} + placeholder={`${window.location.origin}/callback?code=...`} + className="font-mono text-xs" + /> +
+
+ +
+ + +
+ + )} + + {/* Success Step */} + {step === "success" && ( +
+
+ check_circle +
+

Connected Successfully!

+

+ Your {providerInfo.name} account has been connected. +

+ +
+ )} + + {/* Error Step */} + {step === "error" && ( +
+
+ error +
+

Connection Failed

+

{error}

+
+ + +
+
+ )} +
+
+ ); +} diff --git a/src/shared/components/Select.js b/src/shared/components/Select.js new file mode 100644 index 00000000..066b805a --- /dev/null +++ b/src/shared/components/Select.js @@ -0,0 +1,70 @@ +"use client"; + +import { cn } from "@/shared/utils/cn"; + +export default function Select({ + label, + options = [], + value, + onChange, + placeholder = "Select an option", + error, + hint, + disabled = false, + required = false, + className, + selectClassName, + ...props +}) { + return ( +
+ {label && ( + + )} +
+ +
+ expand_more +
+
+ {error && ( +

+ error + {error} +

+ )} + {hint && !error && ( +

{hint}

+ )} +
+ ); +} + diff --git a/src/shared/components/Sidebar.js b/src/shared/components/Sidebar.js new file mode 100644 index 00000000..445c8e5f --- /dev/null +++ b/src/shared/components/Sidebar.js @@ -0,0 +1,171 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { cn } from "@/shared/utils/cn"; +import { APP_CONFIG } from "@/shared/constants/config"; +import Button from "./Button"; +import { ConfirmModal } from "./Modal"; + +const navItems = [ + { href: "/dashboard/endpoint", label: "Endpoint", icon: "api" }, + { href: "/dashboard/providers", label: "Providers", icon: "dns" }, + { href: "/dashboard/combos", label: "Combos", icon: "layers" }, + { href: "/dashboard/cli-tools", label: "CLI Tools", icon: "terminal" }, +]; + +const systemItems = [ + { href: "/dashboard/profile", label: "Settings", icon: "settings" }, +]; + +export default function Sidebar({ onClose }) { + const pathname = usePathname(); + const [showShutdownModal, setShowShutdownModal] = useState(false); + const [isShuttingDown, setIsShuttingDown] = useState(false); + const [isDisconnected, setIsDisconnected] = useState(false); + + const isActive = (href) => { + if (href === "/dashboard/endpoint") { + return pathname === "/dashboard" || pathname.startsWith("/dashboard/endpoint"); + } + return pathname.startsWith(href); + }; + + const handleShutdown = async () => { + setIsShuttingDown(true); + try { + await fetch("/api/shutdown", { method: "POST" }); + } catch (e) { + // Expected to fail as server shuts down + } + setIsShuttingDown(false); + setShowShutdownModal(false); + setIsDisconnected(true); + }; + + return ( + <> + + + {/* Shutdown Confirmation Modal */} + setShowShutdownModal(false)} + onConfirm={handleShutdown} + title="Close Proxy" + message="Are you sure you want to close the proxy server?" + confirmText="Close" + cancelText="Cancel" + variant="danger" + loading={isShuttingDown} + /> + + {/* Disconnected Overlay */} + {isDisconnected && ( +
+
+
+ power_off +
+

Server Disconnected

+

The proxy server has been stopped.

+ +
+
+ )} + + ); +} diff --git a/src/shared/components/ThemeProvider.js b/src/shared/components/ThemeProvider.js new file mode 100644 index 00000000..a4745f4c --- /dev/null +++ b/src/shared/components/ThemeProvider.js @@ -0,0 +1,15 @@ +"use client"; + +import { useEffect } from "react"; +import useThemeStore from "@/store/themeStore"; + +export function ThemeProvider({ children }) { + const { initTheme } = useThemeStore(); + + useEffect(() => { + initTheme(); + }, [initTheme]); + + return <>{children}; +} + diff --git a/src/shared/components/ThemeToggle.js b/src/shared/components/ThemeToggle.js new file mode 100644 index 00000000..02b61bec --- /dev/null +++ b/src/shared/components/ThemeToggle.js @@ -0,0 +1,47 @@ +"use client"; + +import { useTheme } from "@/shared/hooks/useTheme"; +import { cn } from "@/shared/utils/cn"; + +export default function ThemeToggle({ className, variant = "default" }) { + const { theme, toggleTheme, isDark } = useTheme(); + + const variants = { + default: cn( + "flex items-center justify-center size-10 rounded-full", + "text-text-muted", + "hover:bg-black/5", + "hover:text-text-main", + "transition-colors" + ), + card: cn( + "flex items-center justify-center size-11 rounded-full", + "bg-surface/60", + "hover:bg-surface", + "border border-border", + "backdrop-blur-md shadow-sm hover:shadow-md", + "text-text-muted-light hover:text-primary", + "hover:text-primary", + "transition-all group" + ), + }; + + return ( + + ); +} + diff --git a/src/shared/components/Toggle.js b/src/shared/components/Toggle.js new file mode 100644 index 00000000..61b9539f --- /dev/null +++ b/src/shared/components/Toggle.js @@ -0,0 +1,91 @@ +"use client"; + +import { cn } from "@/shared/utils/cn"; + +export default function Toggle({ + checked = false, + onChange, + label, + description, + disabled = false, + size = "md", + className, +}) { + const sizes = { + sm: { + track: "w-8 h-4", + thumb: "size-3", + translate: "translate-x-4", + }, + md: { + track: "w-11 h-6", + thumb: "size-5", + translate: "translate-x-5", + }, + lg: { + track: "w-14 h-7", + thumb: "size-6", + translate: "translate-x-7", + }, + }; + + const handleClick = () => { + if (!disabled && onChange) { + onChange(!checked); + } + }; + + return ( +
+ + {(label || description) && ( +
+ {label && ( + + {label} + + )} + {description && ( + + {description} + + )} +
+ )} +
+ ); +} + diff --git a/src/shared/components/index.js b/src/shared/components/index.js new file mode 100644 index 00000000..a3a25b2a --- /dev/null +++ b/src/shared/components/index.js @@ -0,0 +1,21 @@ +// Shared Components - Export all +export { default as Button } from "./Button"; +export { default as Input } from "./Input"; +export { default as Select } from "./Select"; +export { default as Card } from "./Card"; +export { default as Modal, ConfirmModal } from "./Modal"; +export { default as Loading, Spinner, PageLoading, Skeleton, CardSkeleton } from "./Loading"; +export { default as Avatar } from "./Avatar"; +export { default as Badge } from "./Badge"; +export { default as Toggle } from "./Toggle"; +export { default as ThemeToggle } from "./ThemeToggle"; +export { ThemeProvider } from "./ThemeProvider"; +export { default as Sidebar } from "./Sidebar"; +export { default as Header } from "./Header"; +export { default as Footer } from "./Footer"; +export { default as OAuthModal } from "./OAuthModal"; +export { default as ModelSelectModal } from "./ModelSelectModal"; + +// Layouts +export * from "./layouts"; + diff --git a/src/shared/components/layouts/AuthLayout.js b/src/shared/components/layouts/AuthLayout.js new file mode 100644 index 00000000..8e6d71b5 --- /dev/null +++ b/src/shared/components/layouts/AuthLayout.js @@ -0,0 +1,24 @@ +"use client"; + +import { ThemeToggle } from "@/shared/components"; + +export default function AuthLayout({ children }) { + return ( +
+ {/* Background effects */} +
+
+ + {/* Theme toggle */} +
+ +
+ + {/* Content */} +
+ {children} +
+
+ ); +} + diff --git a/src/shared/components/layouts/DashboardLayout.js b/src/shared/components/layouts/DashboardLayout.js new file mode 100644 index 00000000..30bad750 --- /dev/null +++ b/src/shared/components/layouts/DashboardLayout.js @@ -0,0 +1,43 @@ +"use client"; + +import { useState } from "react"; +import Sidebar from "../Sidebar"; +import Header from "../Header"; + +export default function DashboardLayout({ children }) { + const [sidebarOpen, setSidebarOpen] = useState(false); + + return ( +
+ {/* Mobile sidebar overlay */} + {sidebarOpen && ( +
setSidebarOpen(false)} + /> + )} + + {/* Sidebar - Desktop */} +
+ +
+ + {/* Sidebar - Mobile */} +
+ setSidebarOpen(false)} /> +
+ + {/* Main content */} +
+
setSidebarOpen(true)} /> +
+
{children}
+
+
+
+ ); +} diff --git a/src/shared/components/layouts/index.js b/src/shared/components/layouts/index.js new file mode 100644 index 00000000..4b6ac5fc --- /dev/null +++ b/src/shared/components/layouts/index.js @@ -0,0 +1,4 @@ +// Layout Components - Export all +export { default as DashboardLayout } from "./DashboardLayout"; +export { default as AuthLayout } from "./AuthLayout"; + diff --git a/src/shared/constants/cliTools.js b/src/shared/constants/cliTools.js new file mode 100644 index 00000000..0e8b7f1c --- /dev/null +++ b/src/shared/constants/cliTools.js @@ -0,0 +1,142 @@ +// CLI Tools configuration +export const CLI_TOOLS = { + claude: { + id: "claude", + name: "Claude Code", + icon: "terminal", + color: "#D97757", + description: "Anthropic Claude Code CLI", + configType: "env", + envVars: { + baseUrl: "ANTHROPIC_BASE_URL", + model: "ANTHROPIC_MODEL", + opusModel: "ANTHROPIC_DEFAULT_OPUS_MODEL", + sonnetModel: "ANTHROPIC_DEFAULT_SONNET_MODEL", + haikuModel: "ANTHROPIC_DEFAULT_HAIKU_MODEL", + }, + modelAliases: ["default", "sonnet", "opus", "haiku", "opusplan"], + settingsFile: "~/.claude/settings.json", + defaultModels: [ + { id: "opus", name: "Claude Opus", alias: "opus", envKey: "ANTHROPIC_DEFAULT_OPUS_MODEL", defaultValue: "cc/claude-opus-4-5-20251101" }, + { id: "sonnet", name: "Claude Sonnet", alias: "sonnet", envKey: "ANTHROPIC_DEFAULT_SONNET_MODEL", defaultValue: "cc/claude-sonnet-4-5-20250929" }, + { id: "haiku", name: "Claude Haiku", alias: "haiku", envKey: "ANTHROPIC_DEFAULT_HAIKU_MODEL", defaultValue: "cc/claude-haiku-4-5-20251001" }, + ], + }, + codex: { + id: "codex", + name: "OpenAI Codex CLI", + image: "/providers/codex.png", + color: "#10A37F", + description: "OpenAI Codex CLI", + configType: "custom", + }, + cursor: { + id: "cursor", + name: "Cursor", + image: "/providers/cursor.png", + color: "#000000", + description: "Cursor AI Code Editor", + configType: "guide", + requiresCloud: true, + notes: [ + { type: "warning", text: "Requires Cursor Pro account to use this feature." }, + { type: "cloudCheck", text: "Cursor routes requests through its own server, so local endpoint is not supported. Please enable Cloud Endpoint in Settings." }, + ], + guideSteps: [ + { step: 1, title: "Open Settings", desc: "Go to Settings → Models" }, + { step: 2, title: "Enable OpenAI API", desc: "Enable \"OpenAI API key\" option" }, + { step: 3, title: "Base URL", value: "{{baseUrl}}/v1", copyable: true }, + { step: 4, title: "API Key", type: "apiKeySelector" }, + { step: 5, title: "Add Custom Model", desc: "Click \"View All Model\" → \"Add Custom Model\"" }, + { step: 6, title: "Select Model", type: "modelSelector" }, + ], + }, + cline: { + id: "cline", + name: "CLINE", + image: "/providers/cline.png", + color: "#00D1B2", + description: "CLINE AI Assistant", + configType: "guide", + guideSteps: [ + { step: 1, title: "Open Settings", desc: "Go to CLINE Settings panel" }, + { step: 2, title: "Select Provider", desc: "Choose API Provider → Ollama" }, + { step: 3, title: "Base URL", value: "{{baseUrl}}", copyable: true }, + { step: 4, title: "API Key", type: "apiKeySelector" }, + { step: 5, title: "Select Model", type: "modelSelector" }, + ], + }, + roo: { + id: "roo", + name: "Roo", + image: "/providers/roo.png", + color: "#FF6B6B", + description: "Roo AI Assistant", + configType: "guide", + guideSteps: [ + { step: 1, title: "Open Settings", desc: "Go to Roo Settings panel" }, + { step: 2, title: "Select Provider", desc: "Choose API Provider → Ollama" }, + { step: 3, title: "Base URL", value: "{{baseUrl}}", copyable: true }, + { step: 4, title: "API Key", type: "apiKeySelector" }, + { step: 5, title: "Select Model", type: "modelSelector" }, + ], + }, + continue: { + id: "continue", + name: "Continue", + image: "/providers/continue.png", + color: "#7C3AED", + description: "Continue AI Assistant", + configType: "guide", + guideSteps: [ + { step: 1, title: "Open Config", desc: "Open Continue configuration file" }, + { step: 2, title: "API Key", type: "apiKeySelector" }, + { step: 3, title: "Select Model", type: "modelSelector" }, + { step: 4, title: "Add Model Config", desc: "Add the following configuration to your models array:" }, + ], + codeBlock: { + language: "json", + code: `{ + "apiBase": "{{baseUrl}}", + "title": "{{model}}", + "model": "{{model}}", + "provider": "openai", + "apiKey": "{{apiKey}}" +}`, + }, + }, + // HIDDEN: gemini-cli + // "gemini-cli": { + // id: "gemini-cli", + // name: "Gemini CLI", + // icon: "terminal", + // color: "#4285F4", + // description: "Google Gemini CLI", + // configType: "env", + // envVars: { + // baseUrl: "GEMINI_API_BASE_URL", + // model: "GEMINI_MODEL", + // }, + // defaultModels: [ + // { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", alias: "pro" }, + // { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", alias: "flash" }, + // ], + // }, +}; + +// Get all provider models for mapping dropdown +export const getProviderModelsForMapping = (providers) => { + const result = []; + providers.forEach(conn => { + if (conn.isActive && (conn.testStatus === "active" || conn.testStatus === "success")) { + result.push({ + connectionId: conn.id, + provider: conn.provider, + name: conn.name, + models: conn.models || [], + }); + } + }); + return result; +}; + diff --git a/src/shared/constants/colors.js b/src/shared/constants/colors.js new file mode 100644 index 00000000..a4bb99f3 --- /dev/null +++ b/src/shared/constants/colors.js @@ -0,0 +1,77 @@ +// Claude-inspired color palette for Endpoint Proxy +// Light theme: Warm beige/cream tones +// Dark theme: Deep charcoal/brown tones + +export const COLORS = { + // Primary - Warm Coral/Terracotta (Claude-like) + primary: { + DEFAULT: "#D97757", + hover: "#C56243", + light: "#E8A58C", + dark: "#B0664D", + }, + + // Light theme backgrounds + light: { + bg: "#FBF9F6", + bgAlt: "#F5F1ED", + surface: "#FFFFFF", + sidebar: "#F0EFEC", + border: "#E6E4DD", + textMain: "#383733", + textMuted: "#75736E", + }, + + // Dark theme backgrounds + dark: { + bg: "#191918", + bgAlt: "#1F1F1E", + surface: "#242423", + sidebar: "#1F1F1E", + border: "#333331", + textMain: "#ECEBE8", + textMuted: "#9E9D99", + }, + + // Status colors + status: { + success: "#22C55E", + successLight: "#DCFCE7", + successDark: "#166534", + warning: "#F59E0B", + warningLight: "#FEF3C7", + warningDark: "#92400E", + error: "#EF4444", + errorLight: "#FEE2E2", + errorDark: "#991B1B", + info: "#3B82F6", + infoLight: "#DBEAFE", + infoDark: "#1E40AF", + }, +}; + +// CSS Variables mapping for Tailwind +export const CSS_VARIABLES = { + light: { + "--color-primary": COLORS.primary.DEFAULT, + "--color-primary-hover": COLORS.primary.hover, + "--color-bg": COLORS.light.bg, + "--color-bg-alt": COLORS.light.bgAlt, + "--color-surface": COLORS.light.surface, + "--color-sidebar": COLORS.light.sidebar, + "--color-border": COLORS.light.border, + "--color-text-main": COLORS.light.textMain, + "--color-text-muted": COLORS.light.textMuted, + }, + dark: { + "--color-primary": COLORS.primary.DEFAULT, + "--color-primary-hover": COLORS.primary.hover, + "--color-bg": COLORS.dark.bg, + "--color-bg-alt": COLORS.dark.bgAlt, + "--color-surface": COLORS.dark.surface, + "--color-sidebar": COLORS.dark.sidebar, + "--color-border": COLORS.dark.border, + "--color-text-main": COLORS.dark.textMain, + "--color-text-muted": COLORS.dark.textMuted, + }, +}; diff --git a/src/shared/constants/config.js b/src/shared/constants/config.js new file mode 100644 index 00000000..a4381512 --- /dev/null +++ b/src/shared/constants/config.js @@ -0,0 +1,53 @@ +// App configuration +export const APP_CONFIG = { + name: "Endpoint Proxy", + description: "AI Infrastructure Management", + version: "1.0.0", +}; + +// Theme configuration +export const THEME_CONFIG = { + storageKey: "theme", + defaultTheme: "system", // "light" | "dark" | "system" +}; + +// Subscription +export const SUBSCRIPTION_CONFIG = { + price: 1.0, + currency: "USD", + interval: "month", + planName: "Pro Plan", +}; + +// API endpoints +export const API_ENDPOINTS = { + users: "/api/users", + providers: "/api/providers", + payments: "/api/payments", + auth: "/api/auth", +}; + +// Provider API endpoints (for display only) +export const PROVIDER_ENDPOINTS = { + openrouter: "https://openrouter.ai/api/v1/chat/completions", + glm: "https://api.z.ai/api/anthropic/v1/messages", + kimi: "https://api.kimi.com/coding/v1/messages", + minimax: "https://api.minimax.io/anthropic/v1/messages", + openai: "https://api.openai.com/v1/chat/completions", + anthropic: "https://api.anthropic.com/v1/messages", + gemini: "https://generativelanguage.googleapis.com/v1beta/models", +}; + +// Re-export from providers.js for backward compatibility +export { + OAUTH_PROVIDERS, + APIKEY_PROVIDERS, + AI_PROVIDERS, + AUTH_METHODS, +} from "./providers.js"; + +// Re-export from models.js for backward compatibility +export { + PROVIDER_MODELS, + AI_MODELS, +} from "./models.js"; diff --git a/src/shared/constants/index.js b/src/shared/constants/index.js new file mode 100644 index 00000000..820029c5 --- /dev/null +++ b/src/shared/constants/index.js @@ -0,0 +1,4 @@ +// Shared Constants - Export all +export * from "./colors"; +export * from "./config"; + diff --git a/src/shared/constants/models.js b/src/shared/constants/models.js new file mode 100644 index 00000000..30becdf1 --- /dev/null +++ b/src/shared/constants/models.js @@ -0,0 +1,34 @@ +// Re-export from open-sse (single source of truth) +export { + PROVIDER_MODELS, + getProviderModels, + getDefaultModel, + isValidModel as isValidModelCore, + findModelName, + getModelTargetFormat, + PROVIDER_ID_TO_ALIAS, + getModelsByProviderId +} from "open-sse"; + +import { AI_PROVIDERS } from "./providers.js"; +import { PROVIDER_MODELS as MODELS } from "open-sse"; + +// Providers that accept any model (passthrough) +const PASSTHROUGH_PROVIDERS = new Set( + Object.entries(AI_PROVIDERS) + .filter(([, p]) => p.passthroughModels) + .map(([key]) => key) +); + +// Wrap isValidModel with passthrough providers +export function isValidModel(aliasOrId, modelId) { + if (PASSTHROUGH_PROVIDERS.has(aliasOrId)) return true; + const models = MODELS[aliasOrId]; + if (!models) return false; + return models.some(m => m.id === modelId); +} + +// Legacy AI_MODELS for backward compatibility +export const AI_MODELS = Object.entries(MODELS).flatMap(([alias, models]) => + models.map(m => ({ provider: alias, model: m.id, name: m.name })) +); diff --git a/src/shared/constants/providers.js b/src/shared/constants/providers.js new file mode 100644 index 00000000..7f5efa09 --- /dev/null +++ b/src/shared/constants/providers.js @@ -0,0 +1,65 @@ +// Provider definitions + +// OAuth Providers +export const OAUTH_PROVIDERS = { + claude: { id: "claude", alias: "cc", name: "Claude Code", icon: "smart_toy", color: "#D97757" }, + antigravity: { id: "antigravity", alias: "ag", name: "Antigravity", icon: "rocket_launch", color: "#F59E0B" }, + codex: { id: "codex", alias: "cx", name: "OpenAI Codex", icon: "code", color: "#3B82F6" }, + iflow: { id: "iflow", alias: "if", name: "iFlow AI", icon: "water_drop", color: "#6366F1" }, + qwen: { id: "qwen", alias: "qw", name: "Qwen Code", icon: "psychology", color: "#10B981" }, + "gemini-cli": { id: "gemini-cli", alias: "gc", name: "Gemini CLI", icon: "terminal", color: "#4285F4" }, + github: { id: "github", alias: "gh", name: "GitHub Copilot", icon: "code", color: "#333333" }, +}; + +export const APIKEY_PROVIDERS = { + openrouter: { id: "openrouter", alias: "openrouter", name: "OpenRouter", icon: "router", color: "#6366F1", textIcon: "OR" , passthroughModels: true }, + glm: { id: "glm", alias: "glm", name: "GLM Coding", icon: "code", color: "#2563EB", textIcon: "GL" }, + kimi: { id: "kimi", alias: "kimi", name: "Kimi Coding", icon: "psychology", color: "#1E3A8A", textIcon: "KM" }, + minimax: { id: "minimax", alias: "minimax", name: "Minimax Coding", icon: "memory", color: "#7C3AED", textIcon: "MM" }, + openai: { id: "openai", alias: "openai", name: "OpenAI", icon: "auto_awesome", color: "#10A37F", textIcon: "OA" }, + anthropic: { id: "anthropic", alias: "anthropic", name: "Anthropic", icon: "smart_toy", color: "#D97757", textIcon: "AN" }, + gemini: { id: "gemini", alias: "gemini", name: "Gemini", icon: "diamond", color: "#4285F4", textIcon: "GE" }, +}; + +// All providers (combined) +export const AI_PROVIDERS = { ...OAUTH_PROVIDERS, ...APIKEY_PROVIDERS }; + +// Auth methods +export const AUTH_METHODS = { + oauth: { id: "oauth", name: "OAuth", icon: "lock" }, + apikey: { id: "apikey", name: "API Key", icon: "key" }, +}; + +// Helper: Get provider by alias +export function getProviderByAlias(alias) { + for (const provider of Object.values(AI_PROVIDERS)) { + if (provider.alias === alias || provider.id === alias) { + return provider; + } + } + return null; +} + +// Helper: Get provider ID from alias +export function resolveProviderId(aliasOrId) { + const provider = getProviderByAlias(aliasOrId); + return provider?.id || aliasOrId; +} + +// Helper: Get alias from provider ID +export function getProviderAlias(providerId) { + const provider = AI_PROVIDERS[providerId]; + return provider?.alias || providerId; +} + +// Alias to ID mapping (for quick lookup) +export const ALIAS_TO_ID = Object.values(AI_PROVIDERS).reduce((acc, p) => { + acc[p.alias] = p.id; + return acc; +}, {}); + +// ID to Alias mapping +export const ID_TO_ALIAS = Object.values(AI_PROVIDERS).reduce((acc, p) => { + acc[p.id] = p.alias; + return acc; +}, {}); diff --git a/src/shared/hooks/index.js b/src/shared/hooks/index.js new file mode 100644 index 00000000..68c81c2d --- /dev/null +++ b/src/shared/hooks/index.js @@ -0,0 +1,2 @@ +// Shared Hooks - Export all +export { useTheme } from "./useTheme"; diff --git a/src/shared/hooks/useCopyToClipboard.js b/src/shared/hooks/useCopyToClipboard.js new file mode 100644 index 00000000..558aad89 --- /dev/null +++ b/src/shared/hooks/useCopyToClipboard.js @@ -0,0 +1,29 @@ +"use client"; + +import { useState, useCallback, useRef } from "react"; + +/** + * Hook for copy to clipboard with feedback + * @param {number} resetDelay - Time in ms before resetting copied state (default: 2000) + * @returns {{ copied: string|null, copy: (text: string, id?: string) => void }} + */ +export function useCopyToClipboard(resetDelay = 2000) { + const [copied, setCopied] = useState(null); + const timeoutRef = useRef(null); + + const copy = useCallback((text, id = "default") => { + navigator.clipboard.writeText(text); + setCopied(id); + + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(() => { + setCopied(null); + }, resetDelay); + }, [resetDelay]); + + return { copied, copy }; +} + diff --git a/src/shared/hooks/useTheme.js b/src/shared/hooks/useTheme.js new file mode 100644 index 00000000..029be308 --- /dev/null +++ b/src/shared/hooks/useTheme.js @@ -0,0 +1,31 @@ +"use client"; + +import { useEffect } from "react"; +import useThemeStore from "@/store/themeStore"; + +export function useTheme() { + const { theme, setTheme, toggleTheme, initTheme } = useThemeStore(); + + useEffect(() => { + initTheme(); + + // Listen for system theme changes + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + const handleChange = () => { + if (theme === "system") { + initTheme(); + } + }; + + mediaQuery.addEventListener("change", handleChange); + return () => mediaQuery.removeEventListener("change", handleChange); + }, [theme, initTheme]); + + return { + theme, + setTheme, + toggleTheme, + isDark: theme === "dark" || (theme === "system" && typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches), + }; +} + diff --git a/src/shared/services/cloudSyncScheduler.js b/src/shared/services/cloudSyncScheduler.js new file mode 100644 index 00000000..59848f5b --- /dev/null +++ b/src/shared/services/cloudSyncScheduler.js @@ -0,0 +1,117 @@ +import { getConsistentMachineId } from "@/shared/utils/machineId"; +import { isCloudEnabled } from "@/lib/localDb"; + +/** + * Cloud sync scheduler + */ +export class CloudSyncScheduler { + constructor(machineId = null, intervalMinutes = 15) { + this.machineId = machineId; + this.intervalMinutes = intervalMinutes; + this.intervalId = null; + } + + /** + * Initialize machine ID if not provided + */ + async initializeMachineId() { + if (!this.machineId) { + this.machineId = await getConsistentMachineId(); + } + } + + /** + * Start periodic sync (delays first sync to allow server to be ready) + */ + async start() { + if (this.intervalId) { + return; + } + + await this.initializeMachineId(); + + // Delay first sync by 30 seconds to ensure server is ready + setTimeout(() => { + this.syncWithRetry().catch(() => {}); + }, 30000); + + // Then sync periodically + this.intervalId = setInterval(() => { + this.syncWithRetry().catch(() => {}); + }, this.intervalMinutes * 60 * 1000); + } + + /** + * Stop periodic sync + */ + stop() { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + } + + /** + * Sync with retry logic (exponential backoff) + */ + async syncWithRetry(maxRetries = 1) { + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const result = await this.sync(); + return result; + } catch (error) { + if (attempt === maxRetries) { + return null; + } + + const delay = Math.min(1000 * Math.pow(2, attempt), 10000); // Max 10s + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + } + + /** + * Perform sync via internal API route (handles token update to db.json) + */ + async sync() { + // Check if cloud is enabled + const enabled = await isCloudEnabled(); + if (!enabled) { + return null; + } + + await this.initializeMachineId(); + + // Call internal API route which handles both sync and token update + const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000"}/api/sync/cloud`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ machineId: this.machineId, action: "sync" }) + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || "Sync failed"); + } + + const result = await response.json(); + return result; + } + + /** + * Check if scheduler is running + */ + isRunning() { + return this.intervalId !== null; + } +} + +// Export a singleton instance if needed +let cloudSyncScheduler = null; + +export async function getCloudSyncScheduler(machineId = null, intervalMinutes = 15) { + if (!cloudSyncScheduler) { + cloudSyncScheduler = new CloudSyncScheduler(machineId, intervalMinutes); + } + return cloudSyncScheduler; +} diff --git a/src/shared/services/initializeCloudSync.js b/src/shared/services/initializeCloudSync.js new file mode 100644 index 00000000..4191eaaf --- /dev/null +++ b/src/shared/services/initializeCloudSync.js @@ -0,0 +1,32 @@ +import { getCloudSyncScheduler } from "@/shared/services/cloudSyncScheduler"; +import { isCloudEnabled, cleanupProviderConnections } from "@/lib/localDb"; + +/** + * Initialize cloud sync scheduler + * This should be called when the application starts + */ +export async function initializeCloudSync() { + try { + // Cleanup null fields from existing data + await cleanupProviderConnections(); + + // Create scheduler instance with default 15-minute interval + const scheduler = await getCloudSyncScheduler(null, 15); + + // Start the scheduler + await scheduler.start(); + + return scheduler; + } catch (error) { + console.error("[CloudSync] Error initializing scheduler:", error); + throw error; + } +} + +// For development/testing purposes +if (typeof require !== "undefined" && require.main === module) { + initializeCloudSync().catch(console.log); +} + +export default initializeCloudSync; + diff --git a/src/shared/utils/api.js b/src/shared/utils/api.js new file mode 100644 index 00000000..2e0ad905 --- /dev/null +++ b/src/shared/utils/api.js @@ -0,0 +1,92 @@ +/** + * API utility functions for making HTTP requests + */ + +const DEFAULT_HEADERS = { + "Content-Type": "application/json", +}; + +/** + * Make a GET request + * @param {string} url - API endpoint + * @param {object} options - Fetch options + * @returns {Promise} + */ +export async function get(url, options = {}) { + const response = await fetch(url, { + method: "GET", + headers: { ...DEFAULT_HEADERS, ...options.headers }, + ...options, + }); + return handleResponse(response); +} + +/** + * Make a POST request + * @param {string} url - API endpoint + * @param {object} data - Request body + * @param {object} options - Fetch options + * @returns {Promise} + */ +export async function post(url, data, options = {}) { + const response = await fetch(url, { + method: "POST", + headers: { ...DEFAULT_HEADERS, ...options.headers }, + body: JSON.stringify(data), + ...options, + }); + return handleResponse(response); +} + +/** + * Make a PUT request + * @param {string} url - API endpoint + * @param {object} data - Request body + * @param {object} options - Fetch options + * @returns {Promise} + */ +export async function put(url, data, options = {}) { + const response = await fetch(url, { + method: "PUT", + headers: { ...DEFAULT_HEADERS, ...options.headers }, + body: JSON.stringify(data), + ...options, + }); + return handleResponse(response); +} + +/** + * Make a DELETE request + * @param {string} url - API endpoint + * @param {object} options - Fetch options + * @returns {Promise} + */ +export async function del(url, options = {}) { + const response = await fetch(url, { + method: "DELETE", + headers: { ...DEFAULT_HEADERS, ...options.headers }, + ...options, + }); + return handleResponse(response); +} + +/** + * Handle API response + * @param {Response} response - Fetch response + * @returns {Promise} + */ +async function handleResponse(response) { + const data = await response.json(); + + if (!response.ok) { + const error = new Error(data.error || "An error occurred"); + error.status = response.status; + error.data = data; + throw error; + } + + return data; +} + +export default { get, post, put, del }; + diff --git a/src/shared/utils/apiKey.js b/src/shared/utils/apiKey.js new file mode 100644 index 00000000..046abceb --- /dev/null +++ b/src/shared/utils/apiKey.js @@ -0,0 +1,98 @@ +import crypto from "crypto"; + +const API_KEY_SECRET = process.env.API_KEY_SECRET || "endpoint-proxy-api-key-secret"; + +/** + * Generate 6-char random keyId + */ +function generateKeyId() { + const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; + let result = ""; + for (let i = 0; i < 6; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +} + +/** + * Generate CRC (8-char HMAC) + */ +function generateCrc(machineId, keyId) { + return crypto + .createHmac("sha256", API_KEY_SECRET) + .update(machineId + keyId) + .digest("hex") + .slice(0, 8); +} + +/** + * Generate API key with machineId embedded + * Format: sk-{machineId}-{keyId}-{crc8} + * @param {string} machineId - 16-char machine ID + * @returns {{ key: string, keyId: string }} + */ +export function generateApiKeyWithMachine(machineId) { + const keyId = generateKeyId(); + const crc = generateCrc(machineId, keyId); + const key = `sk-${machineId}-${keyId}-${crc}`; + return { key, keyId }; +} + +/** + * Parse API key and extract machineId + keyId + * Supports both formats: + * - New: sk-{machineId}-{keyId}-{crc8} + * - Old: sk-{random8} + * @param {string} apiKey + * @returns {{ machineId: string, keyId: string, isNewFormat: boolean } | null} + */ +export function parseApiKey(apiKey) { + if (!apiKey || !apiKey.startsWith("sk-")) return null; + + const parts = apiKey.split("-"); + + // New format: sk-{machineId}-{keyId}-{crc8} = 4 parts + if (parts.length === 4) { + const [, machineId, keyId, crc] = parts; + + // Validate CRC + const expectedCrc = generateCrc(machineId, keyId); + if (crc !== expectedCrc) return null; + + return { machineId, keyId, isNewFormat: true }; + } + + // Old format: sk-{random8} = 2 parts + if (parts.length === 2) { + return { machineId: null, keyId: parts[1], isNewFormat: false }; + } + + return null; +} + +/** + * Verify API key CRC (only for new format) + * @param {string} apiKey + * @returns {boolean} + */ +export function verifyApiKeyCrc(apiKey) { + const parsed = parseApiKey(apiKey); + if (!parsed) return false; + + // Old format doesn't have CRC, always valid if parsed + if (!parsed.isNewFormat) return true; + + // New format already verified in parseApiKey + return true; +} + +/** + * Check if API key is new format (contains machineId) + * @param {string} apiKey + * @returns {boolean} + */ +export function isNewFormatKey(apiKey) { + const parsed = parseApiKey(apiKey); + return parsed?.isNewFormat === true; +} + diff --git a/src/shared/utils/cloud.js b/src/shared/utils/cloud.js new file mode 100644 index 00000000..02c8ff07 --- /dev/null +++ b/src/shared/utils/cloud.js @@ -0,0 +1,40 @@ +import { getMachineId } from "@/shared/utils/machine"; + +// Function to get cloud URL with machine ID +export function getCloudUrl(machineId) { + // Get from environment or default to localhost:8787 + const cloudUrl = process.env.NEXT_PUBLIC_CLOUD_URL || "http://localhost:8787"; + return `${cloudUrl}/${machineId}/v1/chat/completions`; +} + +// Function to call cloud with machine ID +export async function callCloudWithMachineId(request) { + const machineId = await getMachineId(); + if (!machineId) { + throw new Error("Could not get machine ID"); + } + + const cloudUrl = getCloudUrl(machineId); + + // Get the original request body and headers + const body = await request.json(); + const headers = new Headers(request.headers); + + // Remove authorization header since cloud won't need it (uses machineId instead) + headers.delete("authorization"); + + // Call the cloud with machine ID + const response = await fetch(cloudUrl, { + method: "POST", + headers: headers, + body: JSON.stringify(body) + }); + + return response; +} + +// Function to periodically sync provider data to cloud (now a no-op) +export function startProviderSync(cloudUrl, intervalMs = 900000) { // Default 15 minutes + console.log("Frontend sync is disabled. Use backend sync instead."); + return null; +} diff --git a/src/shared/utils/cn.js b/src/shared/utils/cn.js new file mode 100644 index 00000000..cbcfe066 --- /dev/null +++ b/src/shared/utils/cn.js @@ -0,0 +1,11 @@ +// Utility function to merge class names +// Handles conditional classes and removes duplicates + +export function cn(...classes) { + return classes + .filter(Boolean) + .join(" ") + .replace(/\s+/g, " ") + .trim(); +} + diff --git a/src/shared/utils/index.js b/src/shared/utils/index.js new file mode 100644 index 00000000..d7db9d95 --- /dev/null +++ b/src/shared/utils/index.js @@ -0,0 +1,32 @@ +// Shared Utils - Export all +export { cn } from "./cn"; +export * as api from "./api"; + +/** + * Extract error code from error message (401, 429, 503...) + * @param {string} lastError - Error message + * @returns {string|null} Error code or null + */ +export function getErrorCode(lastError) { + if (!lastError) return null; + const match = lastError.match(/\b([45]\d{2})\b/); + return match ? match[1] : "ERR"; +} + +/** + * Get relative time string (e.g. "5 min ago") + * @param {string} isoDate - ISO date string + * @returns {string} Relative time + */ +export function getRelativeTime(isoDate) { + if (!isoDate) return ""; + const diff = Date.now() - new Date(isoDate).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + diff --git a/src/shared/utils/machine.js b/src/shared/utils/machine.js new file mode 100644 index 00000000..c2038a93 --- /dev/null +++ b/src/shared/utils/machine.js @@ -0,0 +1,18 @@ +import { getConsistentMachineId } from './machineId'; + +// Get machine ID using node-machine-id with salt +export async function getMachineId() { + return await getConsistentMachineId(); +} + +// Keep sync functions for backward compatibility but make them no-ops +// (Frontend sync is disabled - use backend sync instead) +export async function syncProviderDataToCloud(cloudUrl) { + console.log("Frontend sync is disabled. Use backend sync instead."); + return Promise.resolve(true); +} + +export async function getProvidersNeedingRefresh() { + console.log("Frontend sync is disabled. Use backend sync instead."); + return Promise.resolve([]); +} diff --git a/src/shared/utils/machineId.js b/src/shared/utils/machineId.js new file mode 100644 index 00000000..7e632417 --- /dev/null +++ b/src/shared/utils/machineId.js @@ -0,0 +1,58 @@ +import { machineIdSync } from 'node-machine-id'; + +/** + * Get consistent machine ID using node-machine-id with salt + * This ensures the same physical machine gets the same ID across runs + * + * @param {string} salt - Optional salt to use (defaults to environment variable) + * @returns {Promise} Machine ID (16-character base32) + */ +export async function getConsistentMachineId(salt = null) { + // For server-side, use node-machine-id with salt + const saltValue = salt || process.env.MACHINE_ID_SALT || 'endpoint-proxy-salt'; + try { + const rawMachineId = machineIdSync(); + // Create consistent ID using salt + const crypto = await import('crypto'); + const hashedMachineId = crypto.createHash('sha256').update(rawMachineId + saltValue).digest('hex'); + // Return only first 16 characters for brevity + return hashedMachineId.substring(0, 16); + } catch (error) { + console.log('Error getting machine ID:', error); + // Fallback to random ID if node-machine-id fails + return crypto.randomUUID ? crypto.randomUUID() : + 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0; + const v = c == 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + } +} + +/** + * Get raw machine ID without hashing (for debugging purposes) + * @returns {Promise} Raw machine ID + */ +export async function getRawMachineId() { + // For server-side, use raw node-machine-id + try { + return machineIdSync(); + } catch (error) { + console.log('Error getting raw machine ID:', error); + // Fallback to random ID if node-machine-id fails + return crypto.randomUUID ? crypto.randomUUID() : + 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0; + const v = c == 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + } +} + +/** + * Check if we're running in browser or server environment + * @returns {boolean} True if in browser, false if in server + */ +export function isBrowser() { + return typeof window !== 'undefined'; +} diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js new file mode 100644 index 00000000..ad01d03e --- /dev/null +++ b/src/sse/handlers/chat.js @@ -0,0 +1,134 @@ +import { getProviderCredentials, markAccountUnavailable, clearAccountError } from "../services/auth.js"; +import { getModelInfo, getComboModels } from "../services/model.js"; +import { handleChatCore } from "open-sse/handlers/chatCore.js"; +import { errorResponse } from "open-sse/utils/error.js"; +import { checkFallbackError } from "open-sse/services/accountFallback.js"; +import { handleComboChat } from "open-sse/services/combo.js"; +import * as log from "../utils/logger.js"; +import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js"; + +/** + * Handle chat completion request + * Supports: OpenAI, Claude, Gemini, OpenAI Responses API formats + * Format detection and translation handled by translator + */ +export async function handleChat(request, clientRawRequest = null) { + let body; + try { + body = await request.json(); + } catch { + log.warn("CHAT", "Invalid JSON body"); + return errorResponse(400, "Invalid JSON body"); + } + + // Build clientRawRequest for logging (if not provided) + if (!clientRawRequest) { + const url = new URL(request.url); + clientRawRequest = { + endpoint: url.pathname, + body, + headers: Object.fromEntries(request.headers.entries()) + }; + } + + // Count messages (support both messages[] and input[] formats) + const msgCount = body.messages?.length || body.input?.length || 0; + const toolCount = body.tools?.length || 0; + log.request("POST", `${body.model} | ${msgCount} msgs${toolCount ? ` | ${toolCount} tools` : ""}`); + + const modelStr = body.model; + if (!modelStr) { + log.warn("CHAT", "Missing model"); + return errorResponse(400, "Missing model"); + } + + // Check if model is a combo (has multiple models with fallback) + const comboModels = await getComboModels(modelStr); + if (comboModels) { + log.info("CHAT", `Combo "${modelStr}" with ${comboModels.length} models`); + return handleComboChat({ + body, + models: comboModels, + handleSingleModel: (b, m) => handleSingleModelChat(b, m, clientRawRequest), + log + }); + } + + // Single model request + return handleSingleModelChat(body, modelStr, clientRawRequest); +} + +/** + * Handle single model chat request + */ +async function handleSingleModelChat(body, modelStr, clientRawRequest = null) { + const modelInfo = await getModelInfo(modelStr); + if (!modelInfo.provider) { + log.warn("CHAT", "Invalid model format", { model: modelStr }); + return errorResponse(400, "Invalid model format"); + } + + const { provider, model } = modelInfo; + + // Try with available accounts (fallback on errors) + let excludeConnectionId = null; + let lastError = null; + + while (true) { + const credentials = await getProviderCredentials(provider, excludeConnectionId); + if (!credentials) { + if (!excludeConnectionId) { + return errorResponse(400, `No credentials for provider: ${provider}`); + } + log.warn("CHAT", "No more accounts available", { provider }); + return new Response( + JSON.stringify({ error: lastError || "All accounts unavailable" }), + { status: 503, headers: { "Content-Type": "application/json" } } + ); + } + + log.debug("CHAT", `Using account ${credentials.connectionId} for ${provider}`); + + const refreshedCredentials = await checkAndRefreshToken(provider, credentials); + + // Use shared chatCore + const result = await handleChatCore({ + body: { ...body, model: `${provider}/${model}` }, + modelInfo: { provider, model }, + credentials: refreshedCredentials, + log, + clientRawRequest, + onCredentialsRefreshed: async (newCreds) => { + await updateProviderCredentials(credentials.connectionId, { + accessToken: newCreds.accessToken, + refreshToken: newCreds.refreshToken, + providerSpecificData: newCreds.providerSpecificData, + testStatus: "active" + }); + }, + onRequestSuccess: async () => { + // Clear error status only if currently has error (optimization) + await clearAccountError(credentials.connectionId, credentials); + } + }); + + if (result.success) return result.response; + + // Check if should fallback to next account + const { shouldFallback, cooldownMs } = checkFallbackError(result.status, result.error); + + if (shouldFallback) { + log.warn("CHAT", "Account unavailable, trying next", { + provider, + connectionId: credentials.connectionId, + status: result.status + }); + await markAccountUnavailable(credentials.connectionId, cooldownMs, result.error?.slice(0, 100), result.status, provider); + excludeConnectionId = credentials.connectionId; + lastError = result.error; + continue; + } + + return result.response; + } +} diff --git a/src/sse/services/auth.js b/src/sse/services/auth.js new file mode 100644 index 00000000..1520042a --- /dev/null +++ b/src/sse/services/auth.js @@ -0,0 +1,106 @@ +import { getProviderConnections, validateApiKey, updateProviderConnection } from "@/lib/localDb"; +import { isAccountUnavailable, getUnavailableUntil } from "open-sse/services/accountFallback.js"; +import * as log from "../utils/logger.js"; + +/** + * Get provider credentials from localDb + * Filters out unavailable accounts and returns the highest priority available account + * @param {string} provider - Provider name + * @param {string|null} excludeConnectionId - Connection ID to exclude (for retry with next account) + */ +export async function getProviderCredentials(provider, excludeConnectionId = null) { + const connections = await getProviderConnections({ provider, isActive: true }); + + if (connections.length === 0) { + log.warn("AUTH", `No credentials for ${provider}`); + return null; + } + + // Filter out unavailable accounts and excluded connection + const availableConnections = connections.filter(c => { + if (excludeConnectionId && c.id === excludeConnectionId) return false; + if (isAccountUnavailable(c.rateLimitedUntil)) return false; + return true; + }); + + if (availableConnections.length === 0) { + log.warn("AUTH", `All ${connections.length} accounts for ${provider} unavailable`); + return null; + } + + const connection = availableConnections[0]; + + return { + apiKey: connection.apiKey, + accessToken: connection.accessToken, + refreshToken: connection.refreshToken, + projectId: connection.projectId, + copilotToken: connection.providerSpecificData?.copilotToken, + providerSpecificData: connection.providerSpecificData, + connectionId: connection.id, + // Include current status for optimization check + testStatus: connection.testStatus, + lastError: connection.lastError, + rateLimitedUntil: connection.rateLimitedUntil + }; +} + +/** + * Mark account as unavailable with cooldown + */ +export async function markAccountUnavailable(connectionId, cooldownMs, reason = "Provider error", errorCode = null, provider = null) { + const rateLimitedUntil = getUnavailableUntil(cooldownMs); + await updateProviderConnection(connectionId, { + rateLimitedUntil, + testStatus: "unavailable", + lastError: reason, + errorCode, + lastErrorAt: new Date().toISOString() + }); + // log.warn("AUTH", `Account ${connectionId.slice(0,8)} unavailable until ${rateLimitedUntil}`); + + // Log to stderr for CLI to display + if (provider && errorCode && reason) { + console.error(`❌ ${provider} [${errorCode}]: ${reason}`); + } +} + +/** + * Clear account error status (only if currently has error) + * Optimized to avoid unnecessary DB updates + */ +export async function clearAccountError(connectionId, currentConnection) { + // Only update if currently has error status + const hasError = currentConnection.testStatus === "unavailable" || + currentConnection.lastError || + currentConnection.rateLimitedUntil; + + if (!hasError) return; // Skip if already clean + + await updateProviderConnection(connectionId, { + testStatus: "active", + lastError: null, + lastErrorAt: null, + rateLimitedUntil: null + }); + log.info("AUTH", `Account ${connectionId.slice(0,8)} error cleared`); +} + +/** + * Extract API key from request headers + */ +export function extractApiKey(request) { + const authHeader = request.headers.get("Authorization"); + if (authHeader?.startsWith("Bearer ")) { + return authHeader.slice(7); + } + return null; +} + +/** + * Validate API key (optional - for local use can skip) + */ +export async function isValidApiKey(apiKey) { + if (!apiKey) return false; + return await validateApiKey(apiKey); +} diff --git a/src/sse/services/model.js b/src/sse/services/model.js new file mode 100644 index 00000000..e20b6892 --- /dev/null +++ b/src/sse/services/model.js @@ -0,0 +1,35 @@ +// Re-export from open-sse with localDb integration +import { getModelAliases, getComboByName } from "@/lib/localDb"; +import { parseModel, resolveModelAliasFromMap, getModelInfoCore } from "open-sse/services/model.js"; + +export { parseModel }; + +/** + * Resolve model alias from localDb + */ +export async function resolveModelAlias(alias) { + const aliases = await getModelAliases(); + return resolveModelAliasFromMap(alias, aliases); +} + +/** + * Get full model info (parse or resolve) + */ +export async function getModelInfo(modelStr) { + return getModelInfoCore(modelStr, getModelAliases); +} + +/** + * Check if model is a combo and get models list + * @returns {Promise} Array of models or null if not a combo + */ +export async function getComboModels(modelStr) { + // Only check if it's not in provider/model format + if (modelStr.includes("/")) return null; + + const combo = await getComboByName(modelStr); + if (combo && combo.models && combo.models.length > 0) { + return combo.models; + } + return null; +} diff --git a/src/sse/services/tokenRefresh.js b/src/sse/services/tokenRefresh.js new file mode 100644 index 00000000..716db9b9 --- /dev/null +++ b/src/sse/services/tokenRefresh.js @@ -0,0 +1,173 @@ +// Re-export from open-sse with local logger +import * as log from "../utils/logger.js"; +import { updateProviderConnection } from "../../lib/localDb.js"; +import { + TOKEN_EXPIRY_BUFFER_MS as BUFFER_MS, + refreshAccessToken as _refreshAccessToken, + refreshClaudeOAuthToken as _refreshClaudeOAuthToken, + refreshGoogleToken as _refreshGoogleToken, + refreshQwenToken as _refreshQwenToken, + refreshCodexToken as _refreshCodexToken, + refreshIflowToken as _refreshIflowToken, + refreshGitHubToken as _refreshGitHubToken, + refreshCopilotToken as _refreshCopilotToken, + getAccessToken as _getAccessToken, + refreshTokenByProvider as _refreshTokenByProvider, + formatProviderCredentials as _formatProviderCredentials, + getAllAccessTokens as _getAllAccessTokens +} from "open-sse/services/tokenRefresh.js"; + +export const TOKEN_EXPIRY_BUFFER_MS = BUFFER_MS; + +// Wrap functions with local logger +export const refreshAccessToken = (provider, refreshToken, credentials) => + _refreshAccessToken(provider, refreshToken, credentials, log); + +export const refreshClaudeOAuthToken = (refreshToken) => + _refreshClaudeOAuthToken(refreshToken, log); + +export const refreshGoogleToken = (refreshToken, clientId, clientSecret) => + _refreshGoogleToken(refreshToken, clientId, clientSecret, log); + +export const refreshQwenToken = (refreshToken) => + _refreshQwenToken(refreshToken, log); + +export const refreshCodexToken = (refreshToken) => + _refreshCodexToken(refreshToken, log); + +export const refreshIflowToken = (refreshToken) => + _refreshIflowToken(refreshToken, log); + +export const refreshGitHubToken = (refreshToken) => + _refreshGitHubToken(refreshToken, log); + +export const refreshCopilotToken = (githubAccessToken) => + _refreshCopilotToken(githubAccessToken, log); + +export const getAccessToken = (provider, credentials) => + _getAccessToken(provider, credentials, log); + +export const refreshTokenByProvider = (provider, credentials) => + _refreshTokenByProvider(provider, credentials, log); + +export const formatProviderCredentials = (provider, credentials) => + _formatProviderCredentials(provider, credentials, log); + +export const getAllAccessTokens = (userInfo) => + _getAllAccessTokens(userInfo, log); + +// Local-specific: Update credentials in localDb +export async function updateProviderCredentials(connectionId, newCredentials) { + try { + const updates = {}; + + if (newCredentials.accessToken) { + updates.accessToken = newCredentials.accessToken; + } + if (newCredentials.refreshToken) { + updates.refreshToken = newCredentials.refreshToken; + } + if (newCredentials.expiresIn) { + updates.expiresAt = new Date(Date.now() + newCredentials.expiresIn * 1000).toISOString(); + updates.expiresIn = newCredentials.expiresIn; + } + if (newCredentials.providerSpecificData) { + updates.providerSpecificData = newCredentials.providerSpecificData; + } + + const result = await updateProviderConnection(connectionId, updates); + log.info("TOKEN_REFRESH", "Credentials updated in localDb", { + connectionId, + success: !!result + }); + return !!result; + } catch (error) { + log.error("TOKEN_REFRESH", "Error updating credentials in localDb", { + connectionId, + error: error.message, + }); + return false; + } +} + +// Local-specific: Check and refresh token proactively +export async function checkAndRefreshToken(provider, credentials) { + let updatedCredentials = { ...credentials }; + + // Check regular token expiry + if (updatedCredentials.expiresAt) { + const expiresAt = new Date(updatedCredentials.expiresAt).getTime(); + const now = Date.now(); + + if (expiresAt - now < TOKEN_EXPIRY_BUFFER_MS) { + log.info("TOKEN_REFRESH", "Token expiring soon, refreshing proactively", { + provider, + expiresIn: Math.round((expiresAt - now) / 1000) + }); + + const newCredentials = await getAccessToken(provider, updatedCredentials); + if (newCredentials && newCredentials.accessToken) { + await updateProviderCredentials(updatedCredentials.connectionId, newCredentials); + + updatedCredentials = { + ...updatedCredentials, + accessToken: newCredentials.accessToken, + refreshToken: newCredentials.refreshToken || updatedCredentials.refreshToken, + expiresAt: newCredentials.expiresIn + ? new Date(Date.now() + newCredentials.expiresIn * 1000).toISOString() + : updatedCredentials.expiresAt + }; + } + } + } + + // Check GitHub copilot token expiry + if (provider === "github" && updatedCredentials.providerSpecificData?.copilotTokenExpiresAt) { + const copilotExpiresAt = updatedCredentials.providerSpecificData.copilotTokenExpiresAt * 1000; + const now = Date.now(); + + if (copilotExpiresAt - now < TOKEN_EXPIRY_BUFFER_MS) { + log.info("TOKEN_REFRESH", "Copilot token expiring soon, refreshing proactively", { + provider, + expiresIn: Math.round((copilotExpiresAt - now) / 1000) + }); + + const copilotToken = await refreshCopilotToken(updatedCredentials.accessToken); + if (copilotToken) { + await updateProviderCredentials(updatedCredentials.connectionId, { + providerSpecificData: { + ...updatedCredentials.providerSpecificData, + copilotToken: copilotToken.token, + copilotTokenExpiresAt: copilotToken.expiresAt + } + }); + + updatedCredentials.providerSpecificData = { + ...updatedCredentials.providerSpecificData, + copilotToken: copilotToken.token, + copilotTokenExpiresAt: copilotToken.expiresAt + }; + } + } + } + + return updatedCredentials; +} + +// Local-specific: Refresh GitHub and Copilot tokens together +export async function refreshGitHubAndCopilotTokens(credentials) { + const newGitHubCredentials = await refreshGitHubToken(credentials.refreshToken); + if (newGitHubCredentials?.accessToken) { + const copilotToken = await refreshCopilotToken(newGitHubCredentials.accessToken); + if (copilotToken) { + return { + ...newGitHubCredentials, + providerSpecificData: { + copilotToken: copilotToken.token, + copilotTokenExpiresAt: copilotToken.expiresAt + } + }; + } + } + return newGitHubCredentials; +} diff --git a/src/sse/utils/logger.js b/src/sse/utils/logger.js new file mode 100644 index 00000000..d8127086 --- /dev/null +++ b/src/sse/utils/logger.js @@ -0,0 +1,75 @@ +// Logger utility for cloud + +const LOG_LEVELS = { + DEBUG: 0, + INFO: 1, + WARN: 2, + ERROR: 3 +}; + +const LEVEL = LOG_LEVELS.DEBUG; + +function formatTime() { + return new Date().toLocaleTimeString("en-US", { hour12: false }); +} + +function formatData(data) { + if (!data) return ""; + if (typeof data === "string") return data; + try { + return JSON.stringify(data); + } catch { + return String(data); + } +} + +export function debug(tag, message, data) { + if (LEVEL <= LOG_LEVELS.DEBUG) { + const dataStr = data ? ` ${formatData(data)}` : ""; + console.log(`[${formatTime()}] 🔍 [${tag}] ${message}${dataStr}`); + } +} + +export function info(tag, message, data) { + if (LEVEL <= LOG_LEVELS.INFO) { + const dataStr = data ? ` ${formatData(data)}` : ""; + console.log(`[${formatTime()}] â„šī¸ [${tag}] ${message}${dataStr}`); + } +} + +export function warn(tag, message, data) { + if (LEVEL <= LOG_LEVELS.WARN) { + const dataStr = data ? ` ${formatData(data)}` : ""; + // console.warn(`[${formatTime()}] âš ī¸ [${tag}] ${message}${dataStr}`); + } +} + +export function error(tag, message, data) { + if (LEVEL <= LOG_LEVELS.ERROR) { + const dataStr = data ? ` ${formatData(data)}` : ""; + console.log(`[${formatTime()}] ❌ [${tag}] ${message}${dataStr}`); + } +} + +export function request(method, path, extra) { + const dataStr = extra ? ` ${formatData(extra)}` : ""; + console.log(`\x1b[36m[${formatTime()}] đŸ“Ĩ ${method} ${path}${dataStr}\x1b[0m`); +} + +export function response(status, duration, extra) { + const icon = status < 400 ? "📤" : "đŸ’Ĩ"; + const dataStr = extra ? ` ${formatData(extra)}` : ""; + console.log(`[${formatTime()}] ${icon} ${status} (${duration}ms)${dataStr}`); +} + +export function stream(event, data) { + const dataStr = data ? ` ${formatData(data)}` : ""; + console.log(`[${formatTime()}] 🌊 [STREAM] ${event}${dataStr}`); +} + +// Mask sensitive data +export function maskKey(key) { + if (!key || key.length < 8) return "***"; + return `${key.slice(0, 4)}...${key.slice(-4)}`; +} + diff --git a/src/store/index.js b/src/store/index.js new file mode 100644 index 00000000..de81f82e --- /dev/null +++ b/src/store/index.js @@ -0,0 +1,5 @@ +// Zustand Stores - Export all +export { default as useThemeStore } from "./themeStore"; +export { default as useUserStore } from "./userStore"; +export { default as useProviderStore } from "./providerStore"; + diff --git a/src/store/providerStore.js b/src/store/providerStore.js new file mode 100644 index 00000000..a67badfa --- /dev/null +++ b/src/store/providerStore.js @@ -0,0 +1,48 @@ +"use client"; + +import { create } from "zustand"; + +const useProviderStore = create((set, get) => ({ + providers: [], + loading: false, + error: null, + + setProviders: (providers) => set({ providers }), + + addProvider: (provider) => + set((state) => ({ providers: [provider, ...state.providers] })), + + updateProvider: (id, updates) => + set((state) => ({ + providers: state.providers.map((p) => + p._id === id ? { ...p, ...updates } : p + ), + })), + + removeProvider: (id) => + set((state) => ({ + providers: state.providers.filter((p) => p._id !== id), + })), + + setLoading: (loading) => set({ loading }), + + setError: (error) => set({ error }), + + fetchProviders: async () => { + set({ loading: true, error: null }); + try { + const response = await fetch("/api/providers"); + const data = await response.json(); + if (response.ok) { + set({ providers: data.providers, loading: false }); + } else { + set({ error: data.error, loading: false }); + } + } catch (error) { + set({ error: "Failed to fetch providers", loading: false }); + } + }, +})); + +export default useProviderStore; + diff --git a/src/store/themeStore.js b/src/store/themeStore.js new file mode 100644 index 00000000..ecd9a092 --- /dev/null +++ b/src/store/themeStore.js @@ -0,0 +1,54 @@ +"use client"; + +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import { THEME_CONFIG } from "@/shared/constants/config"; + +const useThemeStore = create( + persist( + (set, get) => ({ + theme: THEME_CONFIG.defaultTheme, + + setTheme: (theme) => { + set({ theme }); + applyTheme(theme); + }, + + toggleTheme: () => { + const currentTheme = get().theme; + const newTheme = currentTheme === "dark" ? "light" : "dark"; + set({ theme: newTheme }); + applyTheme(newTheme); + }, + + initTheme: () => { + const theme = get().theme; + applyTheme(theme); + }, + }), + { + name: THEME_CONFIG.storageKey, + } + ) +); + +// Apply theme to document +function applyTheme(theme) { + if (typeof window === "undefined") return; + + const root = document.documentElement; + const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; + + const effectiveTheme = theme === "system" ? systemTheme : theme; + + if (effectiveTheme === "dark") { + root.classList.add("dark"); + } else { + root.classList.remove("dark"); + } +} + +export default useThemeStore; + diff --git a/src/store/userStore.js b/src/store/userStore.js new file mode 100644 index 00000000..a82e1449 --- /dev/null +++ b/src/store/userStore.js @@ -0,0 +1,20 @@ +"use client"; + +import { create } from "zustand"; + +const useUserStore = create((set) => ({ + user: null, + loading: false, + error: null, + + setUser: (user) => set({ user }), + + clearUser: () => set({ user: null }), + + setLoading: (loading) => set({ loading }), + + setError: (error) => set({ error }), +})); + +export default useUserStore; +