From 58dcd81b66b665a1004e0ca598586ca3ed08263c Mon Sep 17 00:00:00 2001 From: Juan Jimenez Date: Thu, 7 Aug 2025 10:17:11 +0200 Subject: [PATCH 01/11] Add missing strategies, implement authentication, and Dockerfile setup --- .gitignore | 1 + Dockerfile | 22 ++ apps/tui/src/main.ts | 4 +- docs/api-http.md | 67 ++++- packages/core/src/models.ts | 3 - packages/core/src/pricing.ts | 18 +- packages/dashboard-web/src/App.tsx | 86 ++----- packages/dashboard-web/src/api.ts | 8 + .../src/components/AgentsTab.tsx | 25 +- .../src/components/Dashboard.tsx | 77 ++++++ .../src/components/LoginPage.tsx | 90 +++++++ .../src/components/agents/AgentCard.tsx | 3 +- .../src/components/agents/AgentEditDialog.tsx | 12 +- .../src/components/navigation.tsx | 12 + .../src/contexts/auth-context.tsx | 92 +++++++ packages/database/src/database-operations.ts | 18 ++ packages/database/src/migrations.ts | 14 ++ .../src/repositories/user.repository.ts | 59 +++++ .../http-api/src/handlers/agents-update.ts | 10 +- packages/http-api/src/handlers/auth.ts | 120 +++++++++ packages/http-api/src/router.ts | 233 +++++++++++------- packages/types/src/agent.ts | 1 - packages/types/src/strategy.ts | 6 +- 23 files changed, 780 insertions(+), 201 deletions(-) create mode 100644 Dockerfile create mode 100644 packages/dashboard-web/src/components/Dashboard.tsx create mode 100644 packages/dashboard-web/src/components/LoginPage.tsx create mode 100644 packages/dashboard-web/src/contexts/auth-context.tsx create mode 100644 packages/database/src/repositories/user.repository.ts create mode 100644 packages/http-api/src/handlers/auth.ts diff --git a/.gitignore b/.gitignore index 8020a75f..ff28d626 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ .env .env.local +.gitattributes ccflare.db ccflare.db-wal ccflare.db-shm diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..2619cad1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +# Use the official Bun image +FROM oven/bun:latest + +# Set working directory +WORKDIR /app + +COPY package.json bun.lock* ./ + +COPY . . + +# COPY apps/*/package.json ./apps/ +# COPY packages/*/package.json ./packages/ + +# Install dependencies +RUN bun install + +RUN bun run build +# Expose port 8080 (based on CLAUDE.md) +EXPOSE 8080 + +# Run the server (not the TUI which requires interactive mode) +CMD ["bun", "run", "server"] diff --git a/apps/tui/src/main.ts b/apps/tui/src/main.ts index 4c7c89ea..0709a291 100644 --- a/apps/tui/src/main.ts +++ b/apps/tui/src/main.ts @@ -185,10 +185,12 @@ Examples: if (parsed.setModel) { const config = new Config(); // Validate the model + const _validModels = [CLAUDE_MODEL_IDS.OPUS_4, CLAUDE_MODEL_IDS.SONNET_4]; const modelMap: Record = { "opus-4": CLAUDE_MODEL_IDS.OPUS_4, "sonnet-4": CLAUDE_MODEL_IDS.SONNET_4, - "opus-4.1": CLAUDE_MODEL_IDS.OPUS_4_1, + [CLAUDE_MODEL_IDS.OPUS_4]: CLAUDE_MODEL_IDS.OPUS_4, + [CLAUDE_MODEL_IDS.SONNET_4]: CLAUDE_MODEL_IDS.SONNET_4, }; const fullModel = modelMap[parsed.setModel]; diff --git a/docs/api-http.md b/docs/api-http.md index 20615b07..1aa82381 100644 --- a/docs/api-http.md +++ b/docs/api-http.md @@ -25,7 +25,7 @@ open http://localhost:8080/dashboard ## Overview -ccflare provides a RESTful HTTP API for managing accounts, monitoring usage, and proxying requests to Claude. The API runs on port 8080 by default and requires no authentication. +ccflare provides a RESTful HTTP API for managing accounts, monitoring usage, and proxying requests to Claude. The API runs on port 8080 by default. Dashboard access requires authentication while API endpoints remain open for compatibility. ### Base URL @@ -804,6 +804,16 @@ The dashboard provides a visual interface for: - Managing configuration - Examining request history +### Dashboard Authentication + +The dashboard requires authentication to access. Default credentials: +- **Username**: `ccflare_user` +- **Password**: `ccflare_password` + +Authentication is handled via a database-backed user system. The default user is created automatically on first run. + +**Note**: The previous environment variables `DASHBOARD_USERNAME` and `DASHBOARD_PASSWORD` are now deprecated and no longer used. + --- ## Configuration @@ -851,9 +861,62 @@ The following strategy is available: **⚠️ WARNING:** Only use the session strategy. Other strategies can trigger Claude's anti-abuse systems and result in account bans. +## Authentication + +### API Endpoints + +API endpoints (`/api/*`) do not require authentication for backward compatibility. They are designed for programmatic access and internal use. + +### Dashboard Authentication + +The web dashboard (`/dashboard`) requires authentication: + +#### POST /api/auth/login + +Login to the dashboard. + +**Request:** +```json +{ + "username": "ccflare_user", + "password": "ccflare_password" +} +``` + +**Response:** +```json +{ + "success": true +} +``` + +Sets an HTTP-only session cookie for authentication. + +#### POST /api/auth/logout + +Logout from the dashboard. + +**Response:** +```json +{ + "success": true +} +``` + +#### GET /api/auth/check + +Check authentication status. + +**Response:** +```json +{ + "authenticated": true +} +``` + ## Notes -1. **No Authentication**: The API endpoints do not require authentication. ccflare manages the OAuth tokens internally for proxying to Claude. +1. **API Authentication**: The API endpoints do not require authentication. ccflare manages the OAuth tokens internally for proxying to Claude. 2. **Automatic Failover**: When a request fails or an account is rate limited, ccflare automatically tries the next available account. If no accounts are available, requests are forwarded without authentication as a fallback. diff --git a/packages/core/src/models.ts b/packages/core/src/models.ts index 61d402d4..07d0e27e 100644 --- a/packages/core/src/models.ts +++ b/packages/core/src/models.ts @@ -12,7 +12,6 @@ export const CLAUDE_MODEL_IDS = { // Claude 4 models SONNET_4: "claude-sonnet-4-20250514", OPUS_4: "claude-opus-4-20250514", - OPUS_4_1: "claude-opus-4-1-20250805", // Legacy Claude 3 models (for documentation/API examples) OPUS_3: "claude-3-opus-20240229", @@ -25,7 +24,6 @@ export const MODEL_DISPLAY_NAMES: Record = { [CLAUDE_MODEL_IDS.SONNET_3_5]: "Claude Sonnet 3.5 v2", [CLAUDE_MODEL_IDS.SONNET_4]: "Claude Sonnet 4", [CLAUDE_MODEL_IDS.OPUS_4]: "Claude Opus 4", - [CLAUDE_MODEL_IDS.OPUS_4_1]: "Claude Opus 4.1", [CLAUDE_MODEL_IDS.OPUS_3]: "Claude Opus 3", [CLAUDE_MODEL_IDS.SONNET_3]: "Claude Sonnet 3", }; @@ -36,7 +34,6 @@ export const MODEL_SHORT_NAMES: Record = { [CLAUDE_MODEL_IDS.SONNET_3_5]: "claude-3.5-sonnet", [CLAUDE_MODEL_IDS.SONNET_4]: "claude-sonnet-4", [CLAUDE_MODEL_IDS.OPUS_4]: "claude-opus-4", - [CLAUDE_MODEL_IDS.OPUS_4_1]: "claude-opus-4.1", [CLAUDE_MODEL_IDS.OPUS_3]: "claude-3-opus", [CLAUDE_MODEL_IDS.SONNET_3]: "claude-3-sonnet", }; diff --git a/packages/core/src/pricing.ts b/packages/core/src/pricing.ts index cfe7f75b..8434375f 100644 --- a/packages/core/src/pricing.ts +++ b/packages/core/src/pricing.ts @@ -76,16 +76,6 @@ const BUNDLED_PRICING: ApiResponse = { cache_write: 18.75, }, }, - [CLAUDE_MODEL_IDS.OPUS_4_1]: { - id: CLAUDE_MODEL_IDS.OPUS_4_1, - name: MODEL_DISPLAY_NAMES[CLAUDE_MODEL_IDS.OPUS_4_1], - cost: { - input: 15, - output: 75, - cache_read: 1.5, - cache_write: 18.75, - }, - }, }, }, }; @@ -189,12 +179,12 @@ class PriceCatalogue { return this.priceData; } - // Always attempt to fetch fresh pricing first (once per process start) - let data = await this.fetchRemote(); + // Try loading from disk cache + let data = await this.loadFromCache(); - // If remote fetch failed (offline or error), fall back to disk cache + // If no cache, fetch remote if (!data) { - data = await this.loadFromCache(); + data = await this.fetchRemote(); } // Fall back to bundled pricing diff --git a/packages/dashboard-web/src/App.tsx b/packages/dashboard-web/src/App.tsx index 360d3b87..cedfa771 100644 --- a/packages/dashboard-web/src/App.tsx +++ b/packages/dashboard-web/src/App.tsx @@ -1,13 +1,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { useState } from "react"; -import { AccountsTab } from "./components/AccountsTab"; -import { AgentsTab } from "./components/AgentsTab"; -import { AnalyticsTab } from "./components/AnalyticsTab"; -import { LogsTab } from "./components/LogsTab"; -import { Navigation } from "./components/navigation"; -import { OverviewTab } from "./components/OverviewTab"; -import { RequestsTab } from "./components/RequestsTab"; +import { Dashboard } from "./components/Dashboard"; +import { LoginPage } from "./components/LoginPage"; import { QUERY_CONFIG, REFRESH_INTERVALS } from "./constants"; +import { AuthProvider, useAuth } from "./contexts/auth-context"; import { ThemeProvider } from "./contexts/theme-context"; import "./index.css"; @@ -20,74 +15,23 @@ const queryClient = new QueryClient({ }, }); -export function App() { - const [activeTab, setActiveTab] = useState("overview"); +function AppContent() { + const { isAuthenticated, login } = useAuth(); + + if (!isAuthenticated) { + return ; + } - const renderContent = () => { - switch (activeTab) { - case "overview": - return ; - case "analytics": - return ; - case "requests": - return ; - case "accounts": - return ; - case "agents": - return ; - case "logs": - return ; - default: - return ; - } - }; + return ; +} +export function App() { return ( -
- - - {/* Main Content */} -
- {/* Mobile spacer */} -
- - {/* Page Content */} -
- {/* Page Header */} -
-

- {activeTab === "overview" && "Dashboard Overview"} - {activeTab === "analytics" && "Analytics"} - {activeTab === "requests" && "Request History"} - {activeTab === "accounts" && "Account Management"} - {activeTab === "agents" && "Agent Management"} - {activeTab === "logs" && "System Logs"} -

-

- {activeTab === "overview" && - "Monitor your ccflare performance and usage"} - {activeTab === "analytics" && - "Deep dive into your usage patterns and trends"} - {activeTab === "requests" && - "View detailed request and response data"} - {activeTab === "accounts" && - "Manage your OAuth accounts and settings"} - {activeTab === "agents" && - "Discover and manage Claude Code agents"} - {activeTab === "logs" && - "Real-time system logs and debugging information"} -

-
- - {/* Tab Content */} -
- {renderContent()} -
-
-
-
+ + +
); diff --git a/packages/dashboard-web/src/api.ts b/packages/dashboard-web/src/api.ts index f712ec58..0510814c 100644 --- a/packages/dashboard-web/src/api.ts +++ b/packages/dashboard-web/src/api.ts @@ -46,6 +46,14 @@ class API extends HttpClient { }); } + // Override request to include credentials + async request(url: string, options?: RequestInit): Promise { + return super.request(url, { + ...options, + credentials: "include", + }); + } + async getStats(): Promise { return this.get("/api/stats"); } diff --git a/packages/dashboard-web/src/components/AgentsTab.tsx b/packages/dashboard-web/src/components/AgentsTab.tsx index b1b80a9e..85161b28 100644 --- a/packages/dashboard-web/src/components/AgentsTab.tsx +++ b/packages/dashboard-web/src/components/AgentsTab.tsx @@ -1,5 +1,4 @@ -import { DEFAULT_AGENT_MODEL, getModelDisplayName } from "@ccflare/core"; -import { ALLOWED_MODELS } from "@ccflare/types"; +import { CLAUDE_MODEL_IDS, DEFAULT_AGENT_MODEL } from "@ccflare/core"; import { AlertCircle, Bot, @@ -243,11 +242,12 @@ Your system prompt content here...`} - {ALLOWED_MODELS.map((model) => ( - - {getModelDisplayName(model)} - - ))} + + Claude Opus 4 + + + Claude Sonnet 4 + @@ -293,11 +293,12 @@ Your system prompt content here...`} - {ALLOWED_MODELS.map((model) => ( - - {getModelDisplayName(model)} - - ))} + + Claude Opus 4 + + + Claude Sonnet 4 + diff --git a/packages/dashboard-web/src/components/Dashboard.tsx b/packages/dashboard-web/src/components/Dashboard.tsx new file mode 100644 index 00000000..de154048 --- /dev/null +++ b/packages/dashboard-web/src/components/Dashboard.tsx @@ -0,0 +1,77 @@ +import { useState } from "react"; +import { AccountsTab } from "./AccountsTab"; +import { AgentsTab } from "./AgentsTab"; +import { AnalyticsTab } from "./AnalyticsTab"; +import { LogsTab } from "./LogsTab"; +import { Navigation } from "./navigation"; +import { OverviewTab } from "./OverviewTab"; +import { RequestsTab } from "./RequestsTab"; + +export function Dashboard() { + const [activeTab, setActiveTab] = useState("overview"); + + const renderContent = () => { + switch (activeTab) { + case "overview": + return ; + case "analytics": + return ; + case "requests": + return ; + case "accounts": + return ; + case "agents": + return ; + case "logs": + return ; + default: + return ; + } + }; + + return ( +
+ + + {/* Main Content */} +
+ {/* Mobile spacer */} +
+ + {/* Page Content */} +
+ {/* Page Header */} +
+

+ {activeTab === "overview" && "Dashboard Overview"} + {activeTab === "analytics" && "Analytics"} + {activeTab === "requests" && "Request History"} + {activeTab === "accounts" && "Account Management"} + {activeTab === "agents" && "Agent Management"} + {activeTab === "logs" && "System Logs"} +

+

+ {activeTab === "overview" && + "Monitor your ccflare performance and usage"} + {activeTab === "analytics" && + "Deep dive into your usage patterns and trends"} + {activeTab === "requests" && + "View detailed request and response data"} + {activeTab === "accounts" && + "Manage your OAuth accounts and settings"} + {activeTab === "agents" && + "Discover and manage Claude Code agents"} + {activeTab === "logs" && + "Real-time system logs and debugging information"} +

+
+ + {/* Tab Content */} +
+ {renderContent()} +
+
+
+
+ ); +} diff --git a/packages/dashboard-web/src/components/LoginPage.tsx b/packages/dashboard-web/src/components/LoginPage.tsx new file mode 100644 index 00000000..c390fcc0 --- /dev/null +++ b/packages/dashboard-web/src/components/LoginPage.tsx @@ -0,0 +1,90 @@ +import { useState } from "react"; +import { Button } from "./ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "./ui/card"; +import { Input } from "./ui/input"; +import { Label } from "./ui/label"; + +interface LoginPageProps { + onLogin: (username: string, password: string) => Promise; +} + +export function LoginPage({ onLogin }: LoginPageProps) { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(""); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setIsLoading(true); + + try { + const success = await onLogin(username, password); + if (!success) { + setError("Invalid username or password"); + } + } catch (_err) { + setError("An error occurred. Please try again."); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ + + + Sign in to ccflare + + + Enter your credentials to access the dashboard + + + +
+
+ + setUsername(e.target.value)} + required + disabled={isLoading} + /> +
+
+ + setPassword(e.target.value)} + required + disabled={isLoading} + /> +
+ {error && ( +
+ {error} +
+ )} + +
+
+
+
+ ); +} diff --git a/packages/dashboard-web/src/components/agents/AgentCard.tsx b/packages/dashboard-web/src/components/agents/AgentCard.tsx index dde0d7ea..e4476949 100644 --- a/packages/dashboard-web/src/components/agents/AgentCard.tsx +++ b/packages/dashboard-web/src/components/agents/AgentCard.tsx @@ -1,4 +1,3 @@ -import { getModelDisplayName } from "@ccflare/core"; import type { Agent } from "@ccflare/types"; import { ALLOWED_MODELS } from "@ccflare/types"; import { Bot, Cpu, Edit3, Folder, Globe, Sparkles } from "lucide-react"; @@ -173,7 +172,7 @@ export function AgentCard({ className="flex items-center" > - {getModelDisplayName(model)} + {model.replace("claude-", "").replace("-20250514", "")} {model.includes("opus") && ( Premium diff --git a/packages/dashboard-web/src/components/agents/AgentEditDialog.tsx b/packages/dashboard-web/src/components/agents/AgentEditDialog.tsx index 2c82fb62..ff6293d9 100644 --- a/packages/dashboard-web/src/components/agents/AgentEditDialog.tsx +++ b/packages/dashboard-web/src/components/agents/AgentEditDialog.tsx @@ -1,11 +1,10 @@ -import { getModelDisplayName } from "@ccflare/core"; import type { Agent, AgentTool, AgentUpdatePayload, AllowedModel, } from "@ccflare/types"; -import { ALL_TOOLS, ALLOWED_MODELS } from "@ccflare/types"; +import { ALL_TOOLS } from "@ccflare/types"; import { Cpu, Edit3, FileText, Palette, Save, Shield, X } from "lucide-react"; import { useMemo, useState } from "react"; import { TOOL_PRESETS } from "../../constants"; @@ -76,6 +75,11 @@ const COLORS = [ { name: "cyan", class: "bg-cyan-500" }, ]; +const ALLOWED_MODELS: AllowedModel[] = [ + "claude-opus-4-20250514", + "claude-sonnet-4-20250514", +]; + const TOOL_MODE_INFO = { all: { label: "All Tools", @@ -431,7 +435,9 @@ export function AgentEditDialog({
- {getModelDisplayName(m)} + {m.includes("opus") + ? "Claude Opus 4" + : "Claude Sonnet 4"} {m.includes("opus") && ( Advanced diff --git a/packages/dashboard-web/src/components/navigation.tsx b/packages/dashboard-web/src/components/navigation.tsx index 813da1c6..9e8a1407 100644 --- a/packages/dashboard-web/src/components/navigation.tsx +++ b/packages/dashboard-web/src/components/navigation.tsx @@ -5,6 +5,7 @@ import { FileText, GitBranch, LayoutDashboard, + LogOut, Menu, Shield, Users, @@ -12,6 +13,7 @@ import { Zap, } from "lucide-react"; import { useState } from "react"; +import { useAuth } from "../contexts/auth-context"; import { cn } from "../lib/utils"; import { ThemeToggle } from "./theme-toggle"; import { Button } from "./ui/button"; @@ -40,6 +42,7 @@ interface NavigationProps { export function Navigation({ activeTab, onTabChange }: NavigationProps) { const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const { logout } = useAuth(); return ( <> @@ -147,6 +150,15 @@ export function Navigation({ activeTab, onTabChange }: NavigationProps) {

+ +
diff --git a/packages/dashboard-web/src/contexts/auth-context.tsx b/packages/dashboard-web/src/contexts/auth-context.tsx new file mode 100644 index 00000000..e92e3199 --- /dev/null +++ b/packages/dashboard-web/src/contexts/auth-context.tsx @@ -0,0 +1,92 @@ +import { + createContext, + type ReactNode, + useContext, + useEffect, + useState, +} from "react"; + +interface AuthContextType { + isAuthenticated: boolean; + login: (username: string, password: string) => Promise; + logout: () => void; +} + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + // Check if already authenticated on mount + useEffect(() => { + const checkAuth = async () => { + try { + const response = await fetch("/api/auth/check", { + credentials: "include", + }); + setIsAuthenticated(response.ok); + } catch { + setIsAuthenticated(false); + } finally { + setIsLoading(false); + } + }; + + checkAuth(); + }, []); + + const login = async ( + username: string, + password: string, + ): Promise => { + try { + const response = await fetch("/api/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ username, password }), + credentials: "include", + }); + + if (response.ok) { + setIsAuthenticated(true); + return true; + } + return false; + } catch { + return false; + } + }; + + const logout = async () => { + try { + await fetch("/api/auth/logout", { + method: "POST", + credentials: "include", + }); + } catch { + // Ignore errors + } + setIsAuthenticated(false); + }; + + if (isLoading) { + return
; + } + + return ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +} diff --git a/packages/database/src/database-operations.ts b/packages/database/src/database-operations.ts index 2db7aaa0..0881a600 100644 --- a/packages/database/src/database-operations.ts +++ b/packages/database/src/database-operations.ts @@ -14,6 +14,7 @@ import { } from "./repositories/request.repository"; import { StatsRepository } from "./repositories/stats.repository"; import { StrategyRepository } from "./repositories/strategy.repository"; +import { UserRepository } from "./repositories/user.repository"; export interface RuntimeConfig { sessionDurationMs?: number; @@ -34,6 +35,7 @@ export class DatabaseOperations implements StrategyStore, Disposable { private strategy: StrategyRepository; private stats: StatsRepository; private agentPreferences: AgentPreferenceRepository; + private users: UserRepository; constructor(dbPath?: string) { const resolvedPath = dbPath ?? resolveDbPath(); @@ -59,6 +61,10 @@ export class DatabaseOperations implements StrategyStore, Disposable { this.strategy = new StrategyRepository(this.db); this.stats = new StatsRepository(this.db); this.agentPreferences = new AgentPreferenceRepository(this.db); + this.users = new UserRepository(this.db); + + // Initialize default user on first run + this.users.initializeDefaultUser(); } setRuntimeConfig(runtime: RuntimeConfig): void { @@ -347,4 +353,16 @@ export class DatabaseOperations implements StrategyStore, Disposable { getStatsRepository(): StatsRepository { return this.stats; } + + // User operations delegated to repository + authenticateUser(username: string, password: string): boolean { + const user = this.users.findByUsername(username); + if (!user) return false; + + const isValid = this.users.verifyPassword(password, user.passwordHash); + if (isValid) { + this.users.updateLastLogin(user.id); + } + return isValid; + } } diff --git a/packages/database/src/migrations.ts b/packages/database/src/migrations.ts index 0afb29eb..edaea020 100644 --- a/packages/database/src/migrations.ts +++ b/packages/database/src/migrations.ts @@ -90,6 +90,20 @@ export function ensureSchema(db: Database): void { updated_at INTEGER NOT NULL ) `); + + // Create users table for dashboard authentication + db.run(` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + created_at INTEGER NOT NULL, + last_login INTEGER + ) + `); + + // Create index for faster username lookups + db.run(`CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)`); } export function runMigrations(db: Database): void { diff --git a/packages/database/src/repositories/user.repository.ts b/packages/database/src/repositories/user.repository.ts new file mode 100644 index 00000000..5e65e829 --- /dev/null +++ b/packages/database/src/repositories/user.repository.ts @@ -0,0 +1,59 @@ +import crypto from "node:crypto"; +import { BaseRepository } from "./base.repository"; + +export interface User { + id: string; + username: string; + passwordHash: string; + createdAt: number; + lastLogin: number | null; +} + +export class UserRepository extends BaseRepository { + findByUsername(username: string): User | null { + return this.get( + "SELECT id, username, password_hash as passwordHash, created_at as createdAt, last_login as lastLogin FROM users WHERE username = ?", + [username], + ); + } + + create(username: string, password: string): void { + const id = crypto.randomUUID(); + const passwordHash = this.hashPassword(password); + const createdAt = Date.now(); + + this.run( + "INSERT INTO users (id, username, password_hash, created_at) VALUES (?, ?, ?, ?)", + [id, username, passwordHash, createdAt], + ); + } + + updateLastLogin(userId: string): void { + this.run("UPDATE users SET last_login = ? WHERE id = ?", [ + Date.now(), + userId, + ]); + } + + verifyPassword(password: string, passwordHash: string): boolean { + const hash = this.hashPassword(password); + return hash === passwordHash; + } + + private hashPassword(password: string): string { + // Using SHA-256 for simplicity, but in production you should use bcrypt or argon2 + return crypto.createHash("sha256").update(password).digest("hex"); + } + + // Initialize default user if no users exist + initializeDefaultUser(): void { + const userCount = this.get<{ count: number }>( + "SELECT COUNT(*) as count FROM users", + [], + ); + + if (userCount && userCount.count === 0) { + this.create("ccflare_user", "ccflare_password"); + } + } +} diff --git a/packages/http-api/src/handlers/agents-update.ts b/packages/http-api/src/handlers/agents-update.ts index 3fc03b33..b213590a 100644 --- a/packages/http-api/src/handlers/agents-update.ts +++ b/packages/http-api/src/handlers/agents-update.ts @@ -2,7 +2,7 @@ import { agentRegistry } from "@ccflare/agents"; import type { DatabaseOperations } from "@ccflare/database"; import { errorResponse, jsonResponse } from "@ccflare/http-common"; import type { AgentTool, AllowedModel } from "@ccflare/types"; -import { ALLOWED_MODELS, TOOL_PRESETS } from "@ccflare/types"; +import { TOOL_PRESETS } from "@ccflare/types"; type ToolMode = keyof typeof TOOL_PRESETS | "custom"; @@ -37,9 +37,13 @@ export function createAgentUpdateHandler(dbOps: DatabaseOperations) { } if (body.model !== undefined) { - if (!ALLOWED_MODELS.includes(body.model)) { + const ALLOWED_MODELS_ARRAY = [ + "claude-opus-4-20250514", + "claude-sonnet-4-20250514", + ] as const; + if (!ALLOWED_MODELS_ARRAY.includes(body.model)) { return errorResponse( - `Model must be one of: ${ALLOWED_MODELS.join(", ")}`, + `Model must be one of: ${ALLOWED_MODELS_ARRAY.join(", ")}`, ); } updates.model = body.model; diff --git a/packages/http-api/src/handlers/auth.ts b/packages/http-api/src/handlers/auth.ts new file mode 100644 index 00000000..191f38f1 --- /dev/null +++ b/packages/http-api/src/handlers/auth.ts @@ -0,0 +1,120 @@ +import crypto from "node:crypto"; +import type { DatabaseOperations } from "@ccflare/database"; + +// Session storage (in production, use a proper session store) +const sessions = new Map(); + +// Generate a secure session token +function generateSessionToken(): string { + return crypto.randomBytes(32).toString("hex"); +} + +// Clean expired sessions +function cleanExpiredSessions() { + const now = Date.now(); + for (const [token, session] of sessions.entries()) { + if (session.expires < now) { + sessions.delete(token); + } + } +} + +// Verify session +function verifySession(token: string | null): boolean { + if (!token) return false; + cleanExpiredSessions(); + const session = sessions.get(token); + return session ? session.expires > Date.now() : false; +} + +// Get session token from request +function getSessionToken(req: Request): string | null { + const cookie = req.headers.get("cookie"); + if (!cookie) return null; + + const match = cookie.match(/session=([^;]+)/); + return match ? match[1] : null; +} + +export const createLoginHandler = (db: DatabaseOperations) => { + return async (req: Request): Promise => { + const body = (await req.json()) as { username: string; password: string }; + const { username, password } = body; + + // Validate credentials against database + const isValid = db.authenticateUser(username, password); + + if (!isValid) { + return new Response(JSON.stringify({ error: "Invalid credentials" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + // Create session + const token = generateSessionToken(); + const expires = Date.now() + 24 * 60 * 60 * 1000; // 24 hours + sessions.set(token, { username, expires }); + + // Set session cookie + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { + "Content-Type": "application/json", + "Set-Cookie": `session=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`, + }, + }); + }; +}; + +export const createLogoutHandler = () => { + return async (req: Request): Promise => { + const token = getSessionToken(req); + if (token) { + sessions.delete(token); + } + + return new Response(JSON.stringify({ success: true }), { + status: 200, + headers: { + "Content-Type": "application/json", + "Set-Cookie": "session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0", + }, + }); + }; +}; + +export const createAuthCheckHandler = () => { + return async (req: Request): Promise => { + const token = getSessionToken(req); + const isValid = verifySession(token); + + if (!isValid) { + return new Response(JSON.stringify({ authenticated: false }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + return new Response(JSON.stringify({ authenticated: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; +}; + +// Middleware to check authentication for API routes +export function requireAuth( + handler: (req: Request, url: URL) => Response | Promise, +) { + return async (req: Request, url: URL): Promise => { + const token = getSessionToken(req); + if (!verifySession(token)) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + return handler(req, url); + }; +} diff --git a/packages/http-api/src/router.ts b/packages/http-api/src/router.ts index 22f0fc6f..778e44cf 100644 --- a/packages/http-api/src/router.ts +++ b/packages/http-api/src/router.ts @@ -16,6 +16,12 @@ import { } from "./handlers/agents"; import { createAgentUpdateHandler } from "./handlers/agents-update"; import { createAnalyticsHandler } from "./handlers/analytics"; +import { + createAuthCheckHandler, + createLoginHandler, + createLogoutHandler, + requireAuth, +} from "./handlers/auth"; import { createConfigHandlers } from "./handlers/config"; import { createHealthHandler } from "./handlers/health"; import { createLogsStreamHandler } from "./handlers/logs"; @@ -71,69 +77,127 @@ export class APIRouter { const agentsHandler = createAgentsListHandler(dbOps); const workspacesHandler = createWorkspacesListHandler(); const requestsStreamHandler = createRequestsStreamHandler(); + const loginHandler = createLoginHandler(dbOps); + const logoutHandler = createLogoutHandler(); + const authCheckHandler = createAuthCheckHandler(); // Register routes this.handlers.set("GET:/health", () => healthHandler()); - this.handlers.set("GET:/api/stats", () => statsHandler()); - this.handlers.set("POST:/api/stats/reset", () => statsResetHandler()); - this.handlers.set("GET:/api/accounts", () => accountsHandler()); - this.handlers.set("POST:/api/accounts", (req) => accountAddHandler(req)); - this.handlers.set("POST:/api/oauth/init", (req) => oauthInitHandler(req)); - this.handlers.set("POST:/api/oauth/callback", (req) => - oauthCallbackHandler(req), - ); - this.handlers.set("GET:/api/requests", (_req, url) => { - const limitParam = url.searchParams.get("limit"); - const limit = - validateNumber(limitParam || "50", "limit", { - min: 1, - max: 1000, - integer: true, - }) || 50; - return requestsSummaryHandler(limit); - }); - this.handlers.set("GET:/api/requests/detail", (_req, url) => { - const limitParam = url.searchParams.get("limit"); - const limit = - validateNumber(limitParam || "100", "limit", { - min: 1, - max: 1000, - integer: true, - }) || 100; - return requestsDetailHandler(limit); - }); - this.handlers.set("GET:/api/requests/stream", () => - requestsStreamHandler(), - ); - this.handlers.set("GET:/api/config", () => configHandlers.getConfig()); - this.handlers.set("GET:/api/config/strategy", () => - configHandlers.getStrategy(), - ); - this.handlers.set("POST:/api/config/strategy", (req) => - configHandlers.setStrategy(req), - ); - this.handlers.set("GET:/api/strategies", () => - configHandlers.getStrategies(), - ); - this.handlers.set("GET:/api/config/model", () => - configHandlers.getDefaultAgentModel(), - ); - this.handlers.set("POST:/api/config/model", (req) => - configHandlers.setDefaultAgentModel(req), - ); - this.handlers.set("GET:/api/logs/stream", () => logsStreamHandler()); - this.handlers.set("GET:/api/logs/history", () => logsHistoryHandler()); - this.handlers.set("GET:/api/analytics", (_req, url) => { - return analyticsHandler(url.searchParams); - }); - this.handlers.set("GET:/api/agents", () => agentsHandler()); - this.handlers.set("POST:/api/agents/bulk-preference", (req) => { - const bulkHandler = createBulkAgentPreferenceUpdateHandler( - this.context.dbOps, - ); - return bulkHandler(req); - }); - this.handlers.set("GET:/api/workspaces", () => workspacesHandler()); + + // Auth routes (no auth required) + this.handlers.set("POST:/api/auth/login", (req) => loginHandler(req)); + this.handlers.set("POST:/api/auth/logout", (req) => logoutHandler(req)); + this.handlers.set("GET:/api/auth/check", (req) => authCheckHandler(req)); + // Protected routes (require authentication) + this.handlers.set( + "GET:/api/stats", + requireAuth(() => statsHandler()), + ); + this.handlers.set( + "POST:/api/stats/reset", + requireAuth(() => statsResetHandler()), + ); + this.handlers.set( + "GET:/api/accounts", + requireAuth(() => accountsHandler()), + ); + this.handlers.set( + "POST:/api/accounts", + requireAuth((req) => accountAddHandler(req)), + ); + this.handlers.set( + "POST:/api/oauth/init", + requireAuth((req) => oauthInitHandler(req)), + ); + this.handlers.set( + "POST:/api/oauth/callback", + requireAuth((req) => oauthCallbackHandler(req)), + ); + this.handlers.set( + "GET:/api/requests", + requireAuth((_req, url) => { + const limitParam = url.searchParams.get("limit"); + const limit = + validateNumber(limitParam || "50", "limit", { + min: 1, + max: 1000, + integer: true, + }) || 50; + return requestsSummaryHandler(limit); + }), + ); + this.handlers.set( + "GET:/api/requests/detail", + requireAuth((_req, url) => { + const limitParam = url.searchParams.get("limit"); + const limit = + validateNumber(limitParam || "100", "limit", { + min: 1, + max: 1000, + integer: true, + }) || 100; + return requestsDetailHandler(limit); + }), + ); + this.handlers.set( + "GET:/api/requests/stream", + requireAuth(() => requestsStreamHandler()), + ); + this.handlers.set( + "GET:/api/config", + requireAuth(() => configHandlers.getConfig()), + ); + this.handlers.set( + "GET:/api/config/strategy", + requireAuth(() => configHandlers.getStrategy()), + ); + this.handlers.set( + "POST:/api/config/strategy", + requireAuth((req) => configHandlers.setStrategy(req)), + ); + this.handlers.set( + "GET:/api/strategies", + requireAuth(() => configHandlers.getStrategies()), + ); + this.handlers.set( + "GET:/api/config/model", + requireAuth(() => configHandlers.getDefaultAgentModel()), + ); + this.handlers.set( + "POST:/api/config/model", + requireAuth((req) => configHandlers.setDefaultAgentModel(req)), + ); + this.handlers.set( + "GET:/api/logs/stream", + requireAuth(() => logsStreamHandler()), + ); + this.handlers.set( + "GET:/api/logs/history", + requireAuth(() => logsHistoryHandler()), + ); + this.handlers.set( + "GET:/api/analytics", + requireAuth((_req, url) => { + return analyticsHandler(url.searchParams); + }), + ); + this.handlers.set( + "GET:/api/agents", + requireAuth(() => agentsHandler()), + ); + this.handlers.set( + "POST:/api/agents/bulk-preference", + requireAuth((req) => { + const bulkHandler = createBulkAgentPreferenceUpdateHandler( + this.context.dbOps, + ); + return bulkHandler(req); + }), + ); + this.handlers.set( + "GET:/api/workspaces", + requireAuth(() => workspacesHandler()), + ); } /** @@ -173,46 +237,41 @@ export class APIRouter { // Account tier update if (path.endsWith("/tier") && method === "POST") { const tierHandler = createAccountTierUpdateHandler(this.context.dbOps); - return await this.wrapHandler((req) => tierHandler(req, accountId))( - req, - url, - ); + return await this.wrapHandler( + requireAuth((req) => tierHandler(req, accountId)), + )(req, url); } // Account pause if (path.endsWith("/pause") && method === "POST") { const pauseHandler = createAccountPauseHandler(this.context.dbOps); - return await this.wrapHandler((req) => pauseHandler(req, accountId))( - req, - url, - ); + return await this.wrapHandler( + requireAuth((req) => pauseHandler(req, accountId)), + )(req, url); } // Account resume if (path.endsWith("/resume") && method === "POST") { const resumeHandler = createAccountResumeHandler(this.context.dbOps); - return await this.wrapHandler((req) => resumeHandler(req, accountId))( - req, - url, - ); + return await this.wrapHandler( + requireAuth((req) => resumeHandler(req, accountId)), + )(req, url); } // Account rename if (path.endsWith("/rename") && method === "POST") { const renameHandler = createAccountRenameHandler(this.context.dbOps); - return await this.wrapHandler((req) => renameHandler(req, accountId))( - req, - url, - ); + return await this.wrapHandler( + requireAuth((req) => renameHandler(req, accountId)), + )(req, url); } // Account removal if (parts.length === 4 && method === "DELETE") { const removeHandler = createAccountRemoveHandler(this.context.dbOps); - return await this.wrapHandler((req) => removeHandler(req, accountId))( - req, - url, - ); + return await this.wrapHandler( + requireAuth((req) => removeHandler(req, accountId)), + )(req, url); } } @@ -226,19 +285,17 @@ export class APIRouter { const preferenceHandler = createAgentPreferenceUpdateHandler( this.context.dbOps, ); - return await this.wrapHandler((req) => preferenceHandler(req, agentId))( - req, - url, - ); + return await this.wrapHandler( + requireAuth((req) => preferenceHandler(req, agentId)), + )(req, url); } // Agent update (PATCH /api/agents/:id) if (parts.length === 4 && method === "PATCH") { const updateHandler = createAgentUpdateHandler(this.context.dbOps); - return await this.wrapHandler((req) => updateHandler(req, agentId))( - req, - url, - ); + return await this.wrapHandler( + requireAuth((req) => updateHandler(req, agentId)), + )(req, url); } } diff --git a/packages/types/src/agent.ts b/packages/types/src/agent.ts index 4cfa135b..b99ad22e 100644 --- a/packages/types/src/agent.ts +++ b/packages/types/src/agent.ts @@ -40,7 +40,6 @@ export type AgentResponse = Agent[]; export const ALLOWED_MODELS = [ CLAUDE_MODEL_IDS.OPUS_4, - CLAUDE_MODEL_IDS.OPUS_4_1, CLAUDE_MODEL_IDS.SONNET_4, ] as const; diff --git a/packages/types/src/strategy.ts b/packages/types/src/strategy.ts index 445fd054..ab86f810 100644 --- a/packages/types/src/strategy.ts +++ b/packages/types/src/strategy.ts @@ -1,7 +1,11 @@ import type { Account } from "./account"; export enum StrategyName { - Session = "session", + Session = "session", + LeastRequests = "least-requests", + RoundRobin = "round-robin", + Weighted = "weighted", + WeightedRoundRobin = "weighted-round-robin", } /** From 339daa5637cf7c54f11a98a2a57ec5f1ccf1d2a4 Mon Sep 17 00:00:00 2001 From: Juan Jimenez Date: Thu, 7 Aug 2025 10:23:16 +0200 Subject: [PATCH 02/11] Update readme with auth and strategies --- README.md | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ae8cabad..24102c18 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,23 @@ https://github.com/user-attachments/assets/c859872f-ca5e-4f8b-b6a0-7cc7461fe62a ## Quick Start +### With Docker (Recommended) + +```bash +# Clone the repository +git clone https://github.com/snipeship/ccflare +cd ccflare + +# Build and run with Docker +docker build -t ccflare . +docker run -p 8080:8080 ccflare + +# Configure Claude SDK +export ANTHROPIC_BASE_URL=http://localhost:8080 +``` + +### With Bun + ```bash # Clone and install git clone https://github.com/snipeship/ccflare @@ -36,7 +53,12 @@ export ANTHROPIC_BASE_URL=http://localhost:8080 ## Features ### 🎯 Intelligent Load Balancing -- **Session-based** - Maintain conversation context (5hr sessions) +- **Strategies Supported**: + - **least-requests** – Route to the account with the fewest active requests (**default**). + - **round-robin** – Distribute requests evenly across all accounts. + - **session** – Maintain session stickiness for up to 5 hours per account. + - **weighted** – Route based on tier-adjusted request count (e.g., 1x, 5x, 20x tiers). + - **weighted-round-robin** – Round-robin that gives more slots to higher-tier accounts. ### 📈 Real-Time Analytics - Token usage tracking per request @@ -55,6 +77,8 @@ export ANTHROPIC_BASE_URL=http://localhost:8080 - OAuth token refresh handling - SQLite database for persistence - Configurable retry logic +- Authentication (default credentials are ccflare_user : ccflare_password) +- Docker deployment ## Documentation @@ -101,4 +125,4 @@ MIT - See [LICENSE](LICENSE) for details

Built with ❤️ for developers who ship -

+

\ No newline at end of file From ba4b2184c9b0e344f891d67769065511be63193c Mon Sep 17 00:00:00 2001 From: Juan Jimenez Date: Thu, 7 Aug 2025 10:34:32 +0200 Subject: [PATCH 03/11] update information about strategies --- .env.example | 8 ++------ README.md | 4 ---- packages/types/src/strategy.ts | 6 +----- 3 files changed, 3 insertions(+), 15 deletions(-) diff --git a/.env.example b/.env.example index 4afb91b2..bc833aa1 100644 --- a/.env.example +++ b/.env.example @@ -1,13 +1,9 @@ # Port for the proxy server (optional, defaults to 8080) PORT=8080 -# Load-balancing strategy: least-requests | round-robin | session | weighted | weighted-round-robin -# - least-requests: Route to account with fewest requests (default) -# - round-robin: Distribute requests evenly across all accounts +# Load-balancing strategy: session # - session: Maintain 5-hour sessions per account -# - weighted: Route based on tier-adjusted request count (respects 1x, 5x, 20x tiers) -# - weighted-round-robin: Round-robin that gives more slots to higher tier accounts -LB_STRATEGY=least-requests +LB_STRATEGY=session # Log level: DEBUG | INFO | WARN | ERROR (optional, defaults to INFO) LOG_LEVEL=INFO diff --git a/README.md b/README.md index 24102c18..c3e78445 100644 --- a/README.md +++ b/README.md @@ -54,11 +54,7 @@ export ANTHROPIC_BASE_URL=http://localhost:8080 ### 🎯 Intelligent Load Balancing - **Strategies Supported**: - - **least-requests** – Route to the account with the fewest active requests (**default**). - - **round-robin** – Distribute requests evenly across all accounts. - **session** – Maintain session stickiness for up to 5 hours per account. - - **weighted** – Route based on tier-adjusted request count (e.g., 1x, 5x, 20x tiers). - - **weighted-round-robin** – Round-robin that gives more slots to higher-tier accounts. ### 📈 Real-Time Analytics - Token usage tracking per request diff --git a/packages/types/src/strategy.ts b/packages/types/src/strategy.ts index ab86f810..db934624 100644 --- a/packages/types/src/strategy.ts +++ b/packages/types/src/strategy.ts @@ -1,11 +1,7 @@ import type { Account } from "./account"; export enum StrategyName { - Session = "session", - LeastRequests = "least-requests", - RoundRobin = "round-robin", - Weighted = "weighted", - WeightedRoundRobin = "weighted-round-robin", + Session = "session" } /** From 7eb1cd63e6dc87a8b805faa1c07506b80b220b24 Mon Sep 17 00:00:00 2001 From: Juan Jimenez Date: Thu, 7 Aug 2025 10:36:12 +0200 Subject: [PATCH 04/11] Update dockerfile comments --- Dockerfile | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2619cad1..1f41827f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,18 +4,19 @@ FROM oven/bun:latest # Set working directory WORKDIR /app +# Copy package.json and bun.lockb COPY package.json bun.lock* ./ +# Copy the rest of the application COPY . . -# COPY apps/*/package.json ./apps/ -# COPY packages/*/package.json ./packages/ - # Install dependencies RUN bun install +# Build the project RUN bun run build -# Expose port 8080 (based on CLAUDE.md) + +# Expose port 8080 EXPOSE 8080 # Run the server (not the TUI which requires interactive mode) From eb9817a19ccbcdb02f10b1300b2d345e5512fb9a Mon Sep 17 00:00:00 2001 From: Juan Jimenez Date: Thu, 7 Aug 2025 11:02:40 +0200 Subject: [PATCH 05/11] Update files with main --- apps/tui/src/main.ts | 4 +--- packages/core/src/models.ts | 3 +++ .../src/components/agents/AgentCard.tsx | 3 ++- .../src/components/agents/AgentEditDialog.tsx | 12 +++--------- packages/http-api/src/handlers/agents-update.ts | 10 +++------- 5 files changed, 12 insertions(+), 20 deletions(-) diff --git a/apps/tui/src/main.ts b/apps/tui/src/main.ts index 0709a291..4c7c89ea 100644 --- a/apps/tui/src/main.ts +++ b/apps/tui/src/main.ts @@ -185,12 +185,10 @@ Examples: if (parsed.setModel) { const config = new Config(); // Validate the model - const _validModels = [CLAUDE_MODEL_IDS.OPUS_4, CLAUDE_MODEL_IDS.SONNET_4]; const modelMap: Record = { "opus-4": CLAUDE_MODEL_IDS.OPUS_4, "sonnet-4": CLAUDE_MODEL_IDS.SONNET_4, - [CLAUDE_MODEL_IDS.OPUS_4]: CLAUDE_MODEL_IDS.OPUS_4, - [CLAUDE_MODEL_IDS.SONNET_4]: CLAUDE_MODEL_IDS.SONNET_4, + "opus-4.1": CLAUDE_MODEL_IDS.OPUS_4_1, }; const fullModel = modelMap[parsed.setModel]; diff --git a/packages/core/src/models.ts b/packages/core/src/models.ts index 07d0e27e..61d402d4 100644 --- a/packages/core/src/models.ts +++ b/packages/core/src/models.ts @@ -12,6 +12,7 @@ export const CLAUDE_MODEL_IDS = { // Claude 4 models SONNET_4: "claude-sonnet-4-20250514", OPUS_4: "claude-opus-4-20250514", + OPUS_4_1: "claude-opus-4-1-20250805", // Legacy Claude 3 models (for documentation/API examples) OPUS_3: "claude-3-opus-20240229", @@ -24,6 +25,7 @@ export const MODEL_DISPLAY_NAMES: Record = { [CLAUDE_MODEL_IDS.SONNET_3_5]: "Claude Sonnet 3.5 v2", [CLAUDE_MODEL_IDS.SONNET_4]: "Claude Sonnet 4", [CLAUDE_MODEL_IDS.OPUS_4]: "Claude Opus 4", + [CLAUDE_MODEL_IDS.OPUS_4_1]: "Claude Opus 4.1", [CLAUDE_MODEL_IDS.OPUS_3]: "Claude Opus 3", [CLAUDE_MODEL_IDS.SONNET_3]: "Claude Sonnet 3", }; @@ -34,6 +36,7 @@ export const MODEL_SHORT_NAMES: Record = { [CLAUDE_MODEL_IDS.SONNET_3_5]: "claude-3.5-sonnet", [CLAUDE_MODEL_IDS.SONNET_4]: "claude-sonnet-4", [CLAUDE_MODEL_IDS.OPUS_4]: "claude-opus-4", + [CLAUDE_MODEL_IDS.OPUS_4_1]: "claude-opus-4.1", [CLAUDE_MODEL_IDS.OPUS_3]: "claude-3-opus", [CLAUDE_MODEL_IDS.SONNET_3]: "claude-3-sonnet", }; diff --git a/packages/dashboard-web/src/components/agents/AgentCard.tsx b/packages/dashboard-web/src/components/agents/AgentCard.tsx index e4476949..dde0d7ea 100644 --- a/packages/dashboard-web/src/components/agents/AgentCard.tsx +++ b/packages/dashboard-web/src/components/agents/AgentCard.tsx @@ -1,3 +1,4 @@ +import { getModelDisplayName } from "@ccflare/core"; import type { Agent } from "@ccflare/types"; import { ALLOWED_MODELS } from "@ccflare/types"; import { Bot, Cpu, Edit3, Folder, Globe, Sparkles } from "lucide-react"; @@ -172,7 +173,7 @@ export function AgentCard({ className="flex items-center" > - {model.replace("claude-", "").replace("-20250514", "")} + {getModelDisplayName(model)} {model.includes("opus") && ( Premium diff --git a/packages/dashboard-web/src/components/agents/AgentEditDialog.tsx b/packages/dashboard-web/src/components/agents/AgentEditDialog.tsx index ff6293d9..2c82fb62 100644 --- a/packages/dashboard-web/src/components/agents/AgentEditDialog.tsx +++ b/packages/dashboard-web/src/components/agents/AgentEditDialog.tsx @@ -1,10 +1,11 @@ +import { getModelDisplayName } from "@ccflare/core"; import type { Agent, AgentTool, AgentUpdatePayload, AllowedModel, } from "@ccflare/types"; -import { ALL_TOOLS } from "@ccflare/types"; +import { ALL_TOOLS, ALLOWED_MODELS } from "@ccflare/types"; import { Cpu, Edit3, FileText, Palette, Save, Shield, X } from "lucide-react"; import { useMemo, useState } from "react"; import { TOOL_PRESETS } from "../../constants"; @@ -75,11 +76,6 @@ const COLORS = [ { name: "cyan", class: "bg-cyan-500" }, ]; -const ALLOWED_MODELS: AllowedModel[] = [ - "claude-opus-4-20250514", - "claude-sonnet-4-20250514", -]; - const TOOL_MODE_INFO = { all: { label: "All Tools", @@ -435,9 +431,7 @@ export function AgentEditDialog({
- {m.includes("opus") - ? "Claude Opus 4" - : "Claude Sonnet 4"} + {getModelDisplayName(m)} {m.includes("opus") && ( Advanced diff --git a/packages/http-api/src/handlers/agents-update.ts b/packages/http-api/src/handlers/agents-update.ts index b213590a..3fc03b33 100644 --- a/packages/http-api/src/handlers/agents-update.ts +++ b/packages/http-api/src/handlers/agents-update.ts @@ -2,7 +2,7 @@ import { agentRegistry } from "@ccflare/agents"; import type { DatabaseOperations } from "@ccflare/database"; import { errorResponse, jsonResponse } from "@ccflare/http-common"; import type { AgentTool, AllowedModel } from "@ccflare/types"; -import { TOOL_PRESETS } from "@ccflare/types"; +import { ALLOWED_MODELS, TOOL_PRESETS } from "@ccflare/types"; type ToolMode = keyof typeof TOOL_PRESETS | "custom"; @@ -37,13 +37,9 @@ export function createAgentUpdateHandler(dbOps: DatabaseOperations) { } if (body.model !== undefined) { - const ALLOWED_MODELS_ARRAY = [ - "claude-opus-4-20250514", - "claude-sonnet-4-20250514", - ] as const; - if (!ALLOWED_MODELS_ARRAY.includes(body.model)) { + if (!ALLOWED_MODELS.includes(body.model)) { return errorResponse( - `Model must be one of: ${ALLOWED_MODELS_ARRAY.join(", ")}`, + `Model must be one of: ${ALLOWED_MODELS.join(", ")}`, ); } updates.model = body.model; From d43e62420a21c19a5830d2cd46771c587a4428bf Mon Sep 17 00:00:00 2001 From: Juan Jimenez Date: Thu, 7 Aug 2025 11:03:15 +0200 Subject: [PATCH 06/11] Update files with main --- packages/core/src/pricing.ts | 10 ++++++++ .../src/components/AgentsTab.tsx | 25 +++++++++---------- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/packages/core/src/pricing.ts b/packages/core/src/pricing.ts index 8434375f..f68323c9 100644 --- a/packages/core/src/pricing.ts +++ b/packages/core/src/pricing.ts @@ -76,6 +76,16 @@ const BUNDLED_PRICING: ApiResponse = { cache_write: 18.75, }, }, + [CLAUDE_MODEL_IDS.OPUS_4_1]: { + id: CLAUDE_MODEL_IDS.OPUS_4_1, + name: MODEL_DISPLAY_NAMES[CLAUDE_MODEL_IDS.OPUS_4_1], + cost: { + input: 15, + output: 75, + cache_read: 1.5, + cache_write: 18.75, + }, + }, }, }, }; diff --git a/packages/dashboard-web/src/components/AgentsTab.tsx b/packages/dashboard-web/src/components/AgentsTab.tsx index 85161b28..b1b80a9e 100644 --- a/packages/dashboard-web/src/components/AgentsTab.tsx +++ b/packages/dashboard-web/src/components/AgentsTab.tsx @@ -1,4 +1,5 @@ -import { CLAUDE_MODEL_IDS, DEFAULT_AGENT_MODEL } from "@ccflare/core"; +import { DEFAULT_AGENT_MODEL, getModelDisplayName } from "@ccflare/core"; +import { ALLOWED_MODELS } from "@ccflare/types"; import { AlertCircle, Bot, @@ -242,12 +243,11 @@ Your system prompt content here...`} - - Claude Opus 4 - - - Claude Sonnet 4 - + {ALLOWED_MODELS.map((model) => ( + + {getModelDisplayName(model)} + + ))}
@@ -293,12 +293,11 @@ Your system prompt content here...`} - - Claude Opus 4 - - - Claude Sonnet 4 - + {ALLOWED_MODELS.map((model) => ( + + {getModelDisplayName(model)} + + ))}
From f3b733593ebbceb1e62715e4643b6c395e6e72c8 Mon Sep 17 00:00:00 2001 From: Juan Jimenez Date: Thu, 7 Aug 2025 11:03:34 +0200 Subject: [PATCH 07/11] Update with main --- packages/types/src/agent.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/types/src/agent.ts b/packages/types/src/agent.ts index b99ad22e..4cfa135b 100644 --- a/packages/types/src/agent.ts +++ b/packages/types/src/agent.ts @@ -40,6 +40,7 @@ export type AgentResponse = Agent[]; export const ALLOWED_MODELS = [ CLAUDE_MODEL_IDS.OPUS_4, + CLAUDE_MODEL_IDS.OPUS_4_1, CLAUDE_MODEL_IDS.SONNET_4, ] as const; From bab4d1742cb3b2b017af67f57fd18b28376b0776 Mon Sep 17 00:00:00 2001 From: Juan Jimenez Date: Thu, 7 Aug 2025 11:29:44 +0200 Subject: [PATCH 08/11] Add account priority, log request IP --- apps/tui/src/components/AccountsScreen.tsx | 16 +-- apps/tui/src/components/RequestsScreen.tsx | 4 + bun.lock | 2 +- package.json | 2 +- packages/cli-commands/src/commands/account.ts | 1 + packages/dashboard-web/src/api.ts | 13 +++ .../src/components/AccountsTab.tsx | 16 +++ .../src/components/RequestsTab.tsx | 1 + .../src/components/accounts/AccountList.tsx | 104 ++++++++++++++++-- packages/database/src/database-operations.ts | 14 +++ packages/database/src/migrations.ts | 14 +++ .../src/repositories/account.repository.ts | 28 ++++- .../src/repositories/request.repository.ts | 21 +++- packages/http-api/src/handlers/accounts.ts | 60 +++++++++- packages/http-api/src/handlers/requests.ts | 2 + packages/http-api/src/router.ts | 8 ++ .../load-balancer/src/strategies/index.ts | 18 +-- .../proxy/src/handlers/proxy-operations.ts | 2 + .../proxy/src/handlers/request-handler.ts | 40 +++++++ packages/proxy/src/post-processor.worker.ts | 2 + packages/proxy/src/response-handler.ts | 3 + packages/proxy/src/worker-messages.ts | 3 + packages/tui-core/src/requests.ts | 6 +- packages/types/src/account.ts | 8 ++ packages/types/src/api.ts | 1 + packages/types/src/request.ts | 5 + packages/types/src/strategy.ts | 2 +- 27 files changed, 361 insertions(+), 35 deletions(-) diff --git a/apps/tui/src/components/AccountsScreen.tsx b/apps/tui/src/components/AccountsScreen.tsx index ec3c1c56..89bbc407 100644 --- a/apps/tui/src/components/AccountsScreen.tsx +++ b/apps/tui/src/components/AccountsScreen.tsx @@ -263,13 +263,15 @@ export function AccountsScreen({ onBack }: AccountsScreenProps) { } const menuItems = [ - ...accounts.map((acc) => { - const presenter = new AccountPresenter(acc); - return { - label: `${acc.name} (${presenter.tierDisplay})`, - value: `account:${acc.name}`, - }; - }), + ...accounts + .sort((a, b) => (a.priority || 0) - (b.priority || 0)) + .map((acc, index) => { + const presenter = new AccountPresenter(acc); + return { + label: `#${index + 1} ${acc.name} (${presenter.tierDisplay})`, + value: `account:${acc.name}`, + }; + }), { label: "➕ Add Account", value: "add" }, { label: "← Back", value: "back" }, ]; diff --git a/apps/tui/src/components/RequestsScreen.tsx b/apps/tui/src/components/RequestsScreen.tsx index 6f2812f0..3dfd3149 100644 --- a/apps/tui/src/components/RequestsScreen.tsx +++ b/apps/tui/src/components/RequestsScreen.tsx @@ -139,6 +139,10 @@ export function RequestsScreen({ onBack }: RequestsScreenProps) { Account: {selectedRequest.meta.accountName} )} + {selectedSummary?.clientIp && ( + Client IP: {selectedSummary.clientIp} + )} + {selectedSummary?.model && ( Model: {selectedSummary.model} diff --git a/bun.lock b/bun.lock index 2fb8c879..0f0c74db 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "devDependencies": { "@biomejs/biome": "2.1.2", "@types/bun": "latest", - "bun-types": "latest", + "bun-types": "^1.2.19", "typescript": "^5.0.0", }, }, diff --git a/package.json b/package.json index 38293e23..cafdb72d 100644 --- a/package.json +++ b/package.json @@ -26,8 +26,8 @@ }, "devDependencies": { "@biomejs/biome": "2.1.2", - "bun-types": "latest", "@types/bun": "latest", + "bun-types": "^1.2.19", "typescript": "^5.0.0" }, "overrides": { diff --git a/packages/cli-commands/src/commands/account.ts b/packages/cli-commands/src/commands/account.ts index fc5cc383..7d383f57 100644 --- a/packages/cli-commands/src/commands/account.ts +++ b/packages/cli-commands/src/commands/account.ts @@ -138,6 +138,7 @@ export function getAccountsList(dbOps: DatabaseOperations): AccountListItem[] { sessionInfo, tier: account.account_tier || 1, mode: account.account_tier > 1 ? "max" : "console", + priority: account.priority, }; }); } diff --git a/packages/dashboard-web/src/api.ts b/packages/dashboard-web/src/api.ts index 0510814c..94608daf 100644 --- a/packages/dashboard-web/src/api.ts +++ b/packages/dashboard-web/src/api.ts @@ -305,6 +305,19 @@ class API extends HttpClient { throw error; } } + + async updateAccountsPriorities( + accountPriorities: Array<{ id: string; priority: number }>, + ): Promise { + try { + await this.post("/api/accounts/priorities", { accountPriorities }); + } catch (error) { + if (error instanceof HttpError) { + throw new Error(error.message); + } + throw error; + } + } } export const api = new API(); diff --git a/packages/dashboard-web/src/components/AccountsTab.tsx b/packages/dashboard-web/src/components/AccountsTab.tsx index d6e3b0c1..f23423d0 100644 --- a/packages/dashboard-web/src/components/AccountsTab.tsx +++ b/packages/dashboard-web/src/components/AccountsTab.tsx @@ -134,6 +134,21 @@ export function AccountsTab() { } }; + const handleReorderAccounts = async (reorderedAccounts: Account[]) => { + try { + const accountPriorities = reorderedAccounts.map((account, index) => ({ + id: account.id, + priority: index, + })); + + await api.updateAccountsPriorities(accountPriorities); + await loadAccounts(); + setActionError(null); + } catch (err) { + setActionError(formatError(err)); + } + }; + if (loading) { return ( @@ -195,6 +210,7 @@ export function AccountsTab() { onPauseToggle={handlePauseToggle} onRemove={handleRemoveAccount} onRename={handleRename} + onReorder={handleReorderAccounts} /> diff --git a/packages/dashboard-web/src/components/RequestsTab.tsx b/packages/dashboard-web/src/components/RequestsTab.tsx index 7565e22e..02d9c1fe 100644 --- a/packages/dashboard-web/src/components/RequestsTab.tsx +++ b/packages/dashboard-web/src/components/RequestsTab.tsx @@ -726,6 +726,7 @@ export function RequestsTab() { Retry {request.meta.retry} )} ID: {request.id.slice(0, 8)}... + {summary?.clientIp && IP: {summary.clientIp}}
diff --git a/packages/dashboard-web/src/components/accounts/AccountList.tsx b/packages/dashboard-web/src/components/accounts/AccountList.tsx index 2c6c0f4b..ea89c716 100644 --- a/packages/dashboard-web/src/components/accounts/AccountList.tsx +++ b/packages/dashboard-web/src/components/accounts/AccountList.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import type { Account } from "../../api"; import { AccountListItem } from "./AccountListItem"; @@ -6,6 +7,7 @@ interface AccountListProps { onPauseToggle: (account: Account) => void; onRemove: (name: string) => void; onRename: (account: Account) => void; + onReorder?: (accounts: Account[]) => void; } export function AccountList({ @@ -13,11 +15,30 @@ export function AccountList({ onPauseToggle, onRemove, onRename, + onReorder, }: AccountListProps) { + const [draggedAccount, setDraggedAccount] = useState(null); + const [dragOverIndex, setDragOverIndex] = useState(null); + if (!accounts || accounts.length === 0) { return

No accounts configured

; } + // Sort accounts by priority first, then by most recently used + const sortedAccounts = [...accounts].sort((a, b) => { + // Primary sort: priority (lower values = higher priority) + if (a.priority !== b.priority) { + return (a.priority || 0) - (b.priority || 0); + } + // Secondary sort: most recently used + if (a.lastUsed && b.lastUsed) { + return new Date(b.lastUsed).getTime() - new Date(a.lastUsed).getTime(); + } + if (a.lastUsed) return -1; + if (b.lastUsed) return 1; + return 0; + }); + // Find the most recently used account const mostRecentAccountId = accounts.reduce( (mostRecent, account) => { @@ -35,17 +56,84 @@ export function AccountList({ null as string | null, ); + const handleDragStart = (account: Account) => { + setDraggedAccount(account); + }; + + const handleDragEnd = () => { + setDraggedAccount(null); + setDragOverIndex(null); + }; + + const handleDragOver = (e: React.DragEvent, index: number) => { + e.preventDefault(); + setDragOverIndex(index); + }; + + const handleDrop = (e: React.DragEvent, dropIndex: number) => { + e.preventDefault(); + if (!draggedAccount || !onReorder) return; + + const draggedIndex = sortedAccounts.findIndex( + (acc) => acc.id === draggedAccount.id, + ); + if (draggedIndex === dropIndex) return; + + // Create new array with reordered accounts + const newAccounts = [...sortedAccounts]; + newAccounts.splice(draggedIndex, 1); + newAccounts.splice(dropIndex, 0, draggedAccount); + + // Update priorities based on new order + const accountsWithNewPriorities = newAccounts.map((account, index) => ({ + ...account, + priority: index, + })); + + onReorder(accountsWithNewPriorities); + setDraggedAccount(null); + setDragOverIndex(null); + }; + return (
- {accounts.map((account) => ( - + 💡 Drag and drop accounts to reorder them. Higher accounts will be + prioritized. +

+ )} + {sortedAccounts.map((account, index) => ( +
+ draggable={!!onReorder} + onDragStart={() => handleDragStart(account)} + onDragEnd={handleDragEnd} + onDragOver={(e) => handleDragOver(e, index)} + onDrop={(e) => handleDrop(e, index)} + className={` + ${onReorder ? "cursor-move" : ""} + ${draggedAccount?.id === account.id ? "opacity-50" : ""} + ${dragOverIndex === index ? "ring-2 ring-blue-500 ring-offset-2" : ""} + `} + > +
+ {onReorder && ( +
+ #{index + 1} +
+ )} +
+ +
+
+
))}
); diff --git a/packages/database/src/database-operations.ts b/packages/database/src/database-operations.ts index 0881a600..db580b59 100644 --- a/packages/database/src/database-operations.ts +++ b/packages/database/src/database-operations.ts @@ -128,6 +128,16 @@ export class DatabaseOperations implements StrategyStore, Disposable { this.accounts.rename(accountId, newName); } + updateAccountPriority(accountId: string, priority: number): void { + this.accounts.updatePriority(accountId, priority); + } + + updateAccountsPriorities( + accountPriorities: Array<{ id: string; priority: number }>, + ): void { + this.accounts.updateAccountsPriorities(accountPriorities); + } + resetAccountSession(accountId: string, timestamp: number): void { this.accounts.resetSession(accountId, timestamp); } @@ -144,6 +154,7 @@ export class DatabaseOperations implements StrategyStore, Disposable { accountUsed: string | null, statusCode: number | null, timestamp?: number, + clientIp?: string, ): void { this.requests.saveMeta( id, @@ -152,6 +163,7 @@ export class DatabaseOperations implements StrategyStore, Disposable { accountUsed, statusCode, timestamp, + clientIp, ); } @@ -167,6 +179,7 @@ export class DatabaseOperations implements StrategyStore, Disposable { failoverAttempts: number, usage?: RequestData["usage"], agentUsed?: string, + clientIp?: string, ): void { this.requests.save({ id, @@ -180,6 +193,7 @@ export class DatabaseOperations implements StrategyStore, Disposable { failoverAttempts, usage, agentUsed, + clientIp, }); } diff --git a/packages/database/src/migrations.ts b/packages/database/src/migrations.ts index edaea020..af3df1e6 100644 --- a/packages/database/src/migrations.ts +++ b/packages/database/src/migrations.ts @@ -184,6 +184,14 @@ export function runMigrations(db: Database): void { log.info("Added rate_limit_remaining column to accounts table"); } + // Add priority column if it doesn't exist + if (!accountsColumnNames.includes("priority")) { + db.prepare( + "ALTER TABLE accounts ADD COLUMN priority INTEGER DEFAULT 0", + ).run(); + log.info("Added priority column to accounts table"); + } + // Check columns in requests table const requestsInfo = db .prepare("PRAGMA table_info(requests)") @@ -281,6 +289,12 @@ export function runMigrations(db: Database): void { log.info("Added output_tokens_per_second column to requests table"); } + // Add client_ip column if it doesn't exist + if (!requestsColumnNames.includes("client_ip")) { + db.prepare("ALTER TABLE requests ADD COLUMN client_ip TEXT").run(); + log.info("Added client_ip column to requests table"); + } + // Add performance indexes addPerformanceIndexes(db); } diff --git a/packages/database/src/repositories/account.repository.ts b/packages/database/src/repositories/account.repository.ts index 4aedd72d..d7d7913a 100644 --- a/packages/database/src/repositories/account.repository.ts +++ b/packages/database/src/repositories/account.repository.ts @@ -10,8 +10,10 @@ export class AccountRepository extends BaseRepository { rate_limited_until, session_start, session_request_count, COALESCE(account_tier, 1) as account_tier, COALESCE(paused, 0) as paused, - rate_limit_reset, rate_limit_status, rate_limit_remaining + rate_limit_reset, rate_limit_status, rate_limit_remaining, + COALESCE(priority, 0) as priority FROM accounts + ORDER BY priority ASC, created_at ASC `); return rows.map(toAccount); } @@ -25,7 +27,8 @@ export class AccountRepository extends BaseRepository { rate_limited_until, session_start, session_request_count, COALESCE(account_tier, 1) as account_tier, COALESCE(paused, 0) as paused, - rate_limit_reset, rate_limit_status, rate_limit_remaining + rate_limit_reset, rate_limit_status, rate_limit_remaining, + COALESCE(priority, 0) as priority FROM accounts WHERE id = ? `, @@ -128,4 +131,25 @@ export class AccountRepository extends BaseRepository { rename(accountId: string, newName: string): void { this.run(`UPDATE accounts SET name = ? WHERE id = ?`, [newName, accountId]); } + + updatePriority(accountId: string, priority: number): void { + this.run(`UPDATE accounts SET priority = ? WHERE id = ?`, [ + priority, + accountId, + ]); + } + + updateAccountsPriorities( + accountPriorities: Array<{ id: string; priority: number }>, + ): void { + const transaction = this.db.transaction(() => { + for (const { id, priority } of accountPriorities) { + this.run(`UPDATE accounts SET priority = ? WHERE id = ?`, [ + priority, + id, + ]); + } + }); + transaction(); + } } diff --git a/packages/database/src/repositories/request.repository.ts b/packages/database/src/repositories/request.repository.ts index 0b37b5a0..4743f1fd 100644 --- a/packages/database/src/repositories/request.repository.ts +++ b/packages/database/src/repositories/request.repository.ts @@ -11,6 +11,7 @@ export interface RequestData { responseTime: number; failoverAttempts: number; agentUsed?: string; + clientIp?: string; usage?: { model?: string; promptTokens?: number; @@ -33,16 +34,25 @@ export class RequestRepository extends BaseRepository { accountUsed: string | null, statusCode: number | null, timestamp?: number, + clientIp?: string, ): void { this.run( ` INSERT INTO requests ( id, timestamp, method, path, account_used, - status_code, success, error_message, response_time_ms, failover_attempts + status_code, success, error_message, response_time_ms, failover_attempts, client_ip ) - VALUES (?, ?, ?, ?, ?, ?, 0, NULL, 0, 0) + VALUES (?, ?, ?, ?, ?, ?, 0, NULL, 0, 0, ?) `, - [id, timestamp || Date.now(), method, path, accountUsed, statusCode], + [ + id, + timestamp || Date.now(), + method, + path, + accountUsed, + statusCode, + clientIp || null, + ], ); } @@ -55,9 +65,9 @@ export class RequestRepository extends BaseRepository { status_code, success, error_message, response_time_ms, failover_attempts, model, prompt_tokens, completion_tokens, total_tokens, cost_usd, input_tokens, cache_read_input_tokens, cache_creation_input_tokens, output_tokens, - agent_used, output_tokens_per_second + agent_used, output_tokens_per_second, client_ip ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ data.id, @@ -81,6 +91,7 @@ export class RequestRepository extends BaseRepository { usage?.outputTokens || null, data.agentUsed || null, usage?.tokensPerSecond || null, + data.clientIp || null, ], ); } diff --git a/packages/http-api/src/handlers/accounts.ts b/packages/http-api/src/handlers/accounts.ts index f1e4785d..5290de16 100644 --- a/packages/http-api/src/handlers/accounts.ts +++ b/packages/http-api/src/handlers/accounts.ts @@ -48,6 +48,7 @@ export function createAccountsListHandler(db: Database) { session_request_count, COALESCE(account_tier, 1) as account_tier, COALESCE(paused, 0) as paused, + COALESCE(priority, 0) as priority, CASE WHEN expires_at > ?1 THEN 1 ELSE 0 @@ -62,7 +63,7 @@ export function createAccountsListHandler(db: Database) { ELSE '-' END as session_info FROM accounts - ORDER BY request_count DESC + ORDER BY priority ASC, request_count DESC `, ) .all(now, now, now, sessionDuration) as Array<{ @@ -82,6 +83,7 @@ export function createAccountsListHandler(db: Database) { session_request_count: number; account_tier: number; paused: 0 | 1; + priority: number; token_valid: 0 | 1; rate_limited: 0 | 1; session_info: string | null; @@ -133,6 +135,7 @@ export function createAccountsListHandler(db: Database) { : null, rateLimitRemaining: account.rate_limit_remaining, sessionInfo: account.session_info || "", + priority: account.priority, }; }); @@ -434,3 +437,58 @@ export function createAccountRenameHandler(dbOps: DatabaseOperations) { } }; } + +/** + * Create an accounts priority update handler + */ +export function createAccountsPriorityUpdateHandler(dbOps: DatabaseOperations) { + return async (req: Request): Promise => { + try { + const body = await req.json(); + + // Validate the request body + if (!Array.isArray(body.accountPriorities)) { + return errorResponse(BadRequest("accountPriorities must be an array")); + } + + const accountPriorities = body.accountPriorities as Array<{ + id: string; + priority: number; + }>; + + // Validate each account priority entry + for (const entry of accountPriorities) { + const accountId = validateString(entry.id, "id", { + required: true, + minLength: 1, + }); + + const priority = validateNumber(entry.priority, "priority", { + required: true, + min: 0, + }); + + if (!accountId || priority === undefined) { + return errorResponse( + BadRequest("Each entry must have a valid id and priority"), + ); + } + } + + // Update priorities using database operations method + dbOps.updateAccountsPriorities(accountPriorities); + + return jsonResponse({ + success: true, + message: "Account priorities updated successfully", + }); + } catch (error) { + log.error("Account priorities update error:", error); + return errorResponse( + error instanceof Error + ? error + : new Error("Failed to update account priorities"), + ); + } + }; +} diff --git a/packages/http-api/src/handlers/requests.ts b/packages/http-api/src/handlers/requests.ts index cc584073..25b160b2 100644 --- a/packages/http-api/src/handlers/requests.ts +++ b/packages/http-api/src/handlers/requests.ts @@ -41,6 +41,7 @@ export function createRequestsSummaryHandler(db: Database) { cost_usd: number | null; agent_used: string | null; output_tokens_per_second: number | null; + client_ip: string | null; }>; const response: RequestResponse[] = requests.map((request) => ({ @@ -66,6 +67,7 @@ export function createRequestsSummaryHandler(db: Database) { costUsd: request.cost_usd || undefined, agentUsed: request.agent_used || undefined, tokensPerSecond: request.output_tokens_per_second || undefined, + clientIp: request.client_ip || undefined, })); return jsonResponse(response); diff --git a/packages/http-api/src/router.ts b/packages/http-api/src/router.ts index 778e44cf..4c41a344 100644 --- a/packages/http-api/src/router.ts +++ b/packages/http-api/src/router.ts @@ -6,6 +6,7 @@ import { createAccountRenameHandler, createAccountResumeHandler, createAccountsListHandler, + createAccountsPriorityUpdateHandler, createAccountTierUpdateHandler, } from "./handlers/accounts"; import { @@ -105,6 +106,13 @@ export class APIRouter { "POST:/api/accounts", requireAuth((req) => accountAddHandler(req)), ); + this.handlers.set( + "POST:/api/accounts/priorities", + requireAuth((req) => { + const priorityHandler = createAccountsPriorityUpdateHandler(dbOps); + return priorityHandler(req); + }), + ); this.handlers.set( "POST:/api/oauth/init", requireAuth((req) => oauthInitHandler(req)), diff --git a/packages/load-balancer/src/strategies/index.ts b/packages/load-balancer/src/strategies/index.ts index 0bb1162f..c356e15f 100644 --- a/packages/load-balancer/src/strategies/index.ts +++ b/packages/load-balancer/src/strategies/index.ts @@ -71,24 +71,26 @@ export class SessionStrategy implements LoadBalancingStrategy { this.log.info( `Continuing session for account ${activeAccount.name} (${activeAccount.session_request_count} requests in session)`, ); - // Return active account first, then others as fallback - const others = accounts.filter( - (a) => a.id !== activeAccount.id && isAccountAvailable(a, now), - ); + // Return active account first, then others as fallback (sorted by priority) + const others = accounts + .filter((a) => a.id !== activeAccount.id && isAccountAvailable(a, now)) + .sort((a, b) => (a.priority || 0) - (b.priority || 0)); return [activeAccount, ...others]; } // No active session or active account is rate limited - // Filter available accounts - const available = accounts.filter((a) => isAccountAvailable(a, now)); + // Filter available accounts and sort by priority (lower values = higher priority) + const available = accounts + .filter((a) => isAccountAvailable(a, now)) + .sort((a, b) => (a.priority || 0) - (b.priority || 0)); if (available.length === 0) return []; - // Pick the first available account and start a new session with it + // Pick the first available account (highest priority) and start a new session with it const chosenAccount = available[0]; this.resetSessionIfExpired(chosenAccount); - // Return chosen account first, then others as fallback + // Return chosen account first, then others as fallback (maintaining priority order) const others = available.filter((a) => a.id !== chosenAccount.id); return [chosenAccount, ...others]; } diff --git a/packages/proxy/src/handlers/proxy-operations.ts b/packages/proxy/src/handlers/proxy-operations.ts index f1b69440..02893d64 100644 --- a/packages/proxy/src/handlers/proxy-operations.ts +++ b/packages/proxy/src/handlers/proxy-operations.ts @@ -59,6 +59,7 @@ export async function proxyUnauthenticated( retryAttempt: 0, failoverAttempts: 0, agentUsed: requestMeta.agentUsed, + clientIp: requestMeta.clientIp, }, ctx, ); @@ -140,6 +141,7 @@ export async function proxyWithAccount( retryAttempt: 0, failoverAttempts, agentUsed: requestMeta.agentUsed, + clientIp: requestMeta.clientIp, }, ctx, ); diff --git a/packages/proxy/src/handlers/request-handler.ts b/packages/proxy/src/handlers/request-handler.ts index ccf80a74..9d1e9d77 100644 --- a/packages/proxy/src/handlers/request-handler.ts +++ b/packages/proxy/src/handlers/request-handler.ts @@ -4,6 +4,45 @@ import type { Provider } from "@ccflare/providers"; import type { RequestMeta } from "@ccflare/types"; import { ERROR_MESSAGES } from "./proxy-types"; +/** + * Extract client IP from request headers + * @param req - The incoming request + * @returns Client IP address or null if not available + */ +function getClientIp(req: Request): string | null { + // Check common headers for real client IP (in order of preference) + const headers = req.headers; + + // Check X-Forwarded-For (most common) + const xForwardedFor = headers.get("x-forwarded-for"); + if (xForwardedFor) { + // X-Forwarded-For can contain multiple IPs, get the first one (original client) + return xForwardedFor.split(",")[0].trim(); + } + + // Check CF-Connecting-IP (Cloudflare) + const cfConnectingIp = headers.get("cf-connecting-ip"); + if (cfConnectingIp) { + return cfConnectingIp; + } + + // Check X-Real-IP (nginx) + const xRealIp = headers.get("x-real-ip"); + if (xRealIp) { + return xRealIp; + } + + // Check X-Client-IP + const xClientIp = headers.get("x-client-ip"); + if (xClientIp) { + return xClientIp; + } + + // If no proxy headers, we can't determine the real IP from a Request object + // The actual socket IP would need to be passed from the server + return null; +} + /** * Creates request metadata for tracking and analytics * @param req - The incoming request @@ -16,6 +55,7 @@ export function createRequestMetadata(req: Request, url: URL): RequestMeta { method: req.method, path: url.pathname, timestamp: Date.now(), + clientIp: getClientIp(req), }; } diff --git a/packages/proxy/src/post-processor.worker.ts b/packages/proxy/src/post-processor.worker.ts index 5cc16d1e..2ff1e3ee 100644 --- a/packages/proxy/src/post-processor.worker.ts +++ b/packages/proxy/src/post-processor.worker.ts @@ -425,6 +425,7 @@ async function handleEnd(msg: EndMessage): Promise { } : undefined, state.agentUsed, + startMessage.clientIp || undefined, ), ); @@ -496,6 +497,7 @@ async function handleEnd(msg: EndMessage): Promise { costUsd: state.usage.costUsd, agentUsed: state.agentUsed, tokensPerSecond: state.usage.tokensPerSecond, + clientIp: startMessage.clientIp || undefined, }; self.postMessage({ diff --git a/packages/proxy/src/response-handler.ts b/packages/proxy/src/response-handler.ts index e7528c4d..8892d4f8 100644 --- a/packages/proxy/src/response-handler.ts +++ b/packages/proxy/src/response-handler.ts @@ -33,6 +33,7 @@ export interface ResponseHandlerOptions { retryAttempt: number; failoverAttempts: number; agentUsed?: string | null; + clientIp?: string | null; } /** @@ -56,6 +57,7 @@ export async function forwardToClient( retryAttempt, // Always 0 in new flow, but kept for message compatibility failoverAttempts, agentUsed, + clientIp, } = options; // Always strip compression headers *before* we do anything else @@ -86,6 +88,7 @@ export async function forwardToClient( isStream, providerName: ctx.provider.name, agentUsed: agentUsed || null, + clientIp: clientIp || null, retryAttempt, failoverAttempts, }; diff --git a/packages/proxy/src/worker-messages.ts b/packages/proxy/src/worker-messages.ts index 4d732250..45ef34c4 100644 --- a/packages/proxy/src/worker-messages.ts +++ b/packages/proxy/src/worker-messages.ts @@ -26,6 +26,9 @@ export interface StartMessage { // Agent info agentUsed: string | null; + // Client IP + clientIp: string | null; + // Retry info retryAttempt: number; failoverAttempts: number; diff --git a/packages/tui-core/src/requests.ts b/packages/tui-core/src/requests.ts index 9af1c13b..b7a688ac 100644 --- a/packages/tui-core/src/requests.ts +++ b/packages/tui-core/src/requests.ts @@ -13,6 +13,7 @@ export interface RequestSummary { cacheCreationInputTokens?: number; costUsd?: number; responseTimeMs?: number; + clientIp?: string; } export async function getRequests(limit = 100): Promise { @@ -61,7 +62,8 @@ export async function getRequestSummaries( cache_read_input_tokens as cacheReadInputTokens, cache_creation_input_tokens as cacheCreationInputTokens, cost_usd as costUsd, - response_time_ms as responseTimeMs + response_time_ms as responseTimeMs, + client_ip as clientIp FROM requests ORDER BY timestamp DESC LIMIT ? @@ -76,6 +78,7 @@ export async function getRequestSummaries( cacheCreationInputTokens?: number; costUsd?: number; responseTimeMs?: number; + clientIp?: string; }>; const summaryMap = new Map(); @@ -90,6 +93,7 @@ export async function getRequestSummaries( cacheCreationInputTokens: summary.cacheCreationInputTokens || undefined, costUsd: summary.costUsd || undefined, responseTimeMs: summary.responseTimeMs || undefined, + clientIp: summary.clientIp || undefined, }); }); diff --git a/packages/types/src/account.ts b/packages/types/src/account.ts index 1f2dbc29..ae7bf0df 100644 --- a/packages/types/src/account.ts +++ b/packages/types/src/account.ts @@ -22,6 +22,7 @@ export interface AccountRow { rate_limit_reset?: number | null; rate_limit_status?: string | null; rate_limit_remaining?: number | null; + priority?: number; } // Domain model - used throughout the application @@ -45,6 +46,7 @@ export interface Account { rate_limit_reset: number | null; rate_limit_status: string | null; rate_limit_remaining: number | null; + priority: number; // Lower values = higher priority (0 is highest) } // API response type - what clients receive @@ -64,6 +66,7 @@ export interface AccountResponse { rateLimitReset: string | null; rateLimitRemaining: number | null; sessionInfo: string; + priority: number; } // UI display type - used in TUI and web dashboard @@ -86,6 +89,7 @@ export interface AccountDisplay { session_start?: number | null; session_request_count?: number; access_token?: string | null; + priority: number; } // CLI list item type @@ -104,6 +108,7 @@ export interface AccountListItem { rateLimitStatus: string; sessionInfo: string; mode: "max" | "console"; + priority: number; } // Account creation types @@ -139,6 +144,7 @@ export function toAccount(row: AccountRow): Account { rate_limit_reset: row.rate_limit_reset || null, rate_limit_status: row.rate_limit_status || null, rate_limit_remaining: row.rate_limit_remaining || null, + priority: row.priority || 0, }; } @@ -177,6 +183,7 @@ export function toAccountResponse(account: Account): AccountResponse { : null, rateLimitRemaining: account.rate_limit_remaining, sessionInfo, + priority: account.priority, }; } @@ -212,5 +219,6 @@ export function toAccountDisplay(account: Account): AccountDisplay { session_start: account.session_start, session_request_count: account.session_request_count, access_token: account.access_token, + priority: account.priority, }; } diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index cbadd560..503582c5 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -4,6 +4,7 @@ export interface RequestMeta { path: string; timestamp: number; agentUsed?: string | null; + clientIp?: string | null; } export interface AgentUpdatePayload { diff --git a/packages/types/src/request.ts b/packages/types/src/request.ts index 99bc8b6f..18e40982 100644 --- a/packages/types/src/request.ts +++ b/packages/types/src/request.ts @@ -21,6 +21,7 @@ export interface RequestRow { output_tokens: number | null; agent_used: string | null; output_tokens_per_second: number | null; + client_ip: string | null; } // Domain model @@ -46,6 +47,7 @@ export interface Request { outputTokens?: number; agentUsed?: string; tokensPerSecond?: number; + clientIp?: string; } // API response type @@ -71,6 +73,7 @@ export interface RequestResponse { costUsd?: number; agentUsed?: string; tokensPerSecond?: number; + clientIp?: string; } // Detailed request with payload @@ -125,6 +128,7 @@ export function toRequest(row: RequestRow): Request { outputTokens: row.output_tokens || undefined, agentUsed: row.agent_used || undefined, tokensPerSecond: row.output_tokens_per_second || undefined, + clientIp: row.client_ip || undefined, }; } @@ -151,6 +155,7 @@ export function toRequestResponse(request: Request): RequestResponse { costUsd: request.costUsd, agentUsed: request.agentUsed, tokensPerSecond: request.tokensPerSecond, + clientIp: request.clientIp, }; } diff --git a/packages/types/src/strategy.ts b/packages/types/src/strategy.ts index db934624..445fd054 100644 --- a/packages/types/src/strategy.ts +++ b/packages/types/src/strategy.ts @@ -1,7 +1,7 @@ import type { Account } from "./account"; export enum StrategyName { - Session = "session" + Session = "session", } /** From e0e831effbfb5a21f5ace94d7ba62c57081cd110 Mon Sep 17 00:00:00 2001 From: Juan Jimenez Date: Fri, 8 Aug 2025 20:13:16 +0200 Subject: [PATCH 09/11] Make auth disabled by default --- .env.example | 5 ++++ Dockerfile | 7 ++++++ apps/server/src/server.ts | 1 + packages/dashboard-web/src/App.tsx | 6 +++-- .../src/contexts/auth-context.tsx | 13 +++++++++-- packages/http-api/src/handlers/auth.ts | 23 +++++++++++++++++-- 6 files changed, 49 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index bc833aa1..777fd7a6 100644 --- a/.env.example +++ b/.env.example @@ -13,5 +13,10 @@ LOG_LEVEL=INFO # - json: Structured JSON logs for log aggregators LOG_FORMAT=pretty +# Authentication settings +# Enable/disable authentication for dashboard and API endpoints +# Set to true to require login, false to disable auth (default: false) +AUTH_ENABLED=false + # Example of how to use the proxy with your application: # ANTHROPIC_BASE_URL=http://localhost:8080 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 1f41827f..640147b1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,13 @@ RUN bun install # Build the project RUN bun run build +# Environment variables with default values +ENV PORT=8080 +ENV AUTH_ENABLED=false +ENV LOG_LEVEL=INFO +ENV LOG_FORMAT=pretty +ENV LB_STRATEGY=session + # Expose port 8080 EXPOSE 8080 diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 7c933690..28cc3a83 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -183,6 +183,7 @@ export default function startServer(options?: { } }); + console.log(runtime) // Main server serverInstance = serve({ port: runtime.port, diff --git a/packages/dashboard-web/src/App.tsx b/packages/dashboard-web/src/App.tsx index cedfa771..c2143e25 100644 --- a/packages/dashboard-web/src/App.tsx +++ b/packages/dashboard-web/src/App.tsx @@ -16,9 +16,11 @@ const queryClient = new QueryClient({ }); function AppContent() { - const { isAuthenticated, login } = useAuth(); + const { isAuthenticated, login, authEnabled } = useAuth(); - if (!isAuthenticated) { + // If auth is disabled, go directly to dashboard + // If auth is enabled but user is not authenticated, show login page + if (authEnabled && !isAuthenticated) { return ; } diff --git a/packages/dashboard-web/src/contexts/auth-context.tsx b/packages/dashboard-web/src/contexts/auth-context.tsx index e92e3199..f5505c7c 100644 --- a/packages/dashboard-web/src/contexts/auth-context.tsx +++ b/packages/dashboard-web/src/contexts/auth-context.tsx @@ -10,6 +10,7 @@ interface AuthContextType { isAuthenticated: boolean; login: (username: string, password: string) => Promise; logout: () => void; + authEnabled: boolean; } const AuthContext = createContext(undefined); @@ -17,6 +18,7 @@ const AuthContext = createContext(undefined); export function AuthProvider({ children }: { children: ReactNode }) { const [isAuthenticated, setIsAuthenticated] = useState(false); const [isLoading, setIsLoading] = useState(true); + const [authEnabled, setAuthEnabled] = useState(true); // Check if already authenticated on mount useEffect(() => { @@ -25,9 +27,16 @@ export function AuthProvider({ children }: { children: ReactNode }) { const response = await fetch("/api/auth/check", { credentials: "include", }); - setIsAuthenticated(response.ok); + + const data = await response.json(); + setIsAuthenticated(data.authenticated); + + // Check if auth is enabled by looking for authEnabled field in response + // If not present, assume auth is enabled for backward compatibility + setAuthEnabled(data.authEnabled !== false); } catch { setIsAuthenticated(false); + setAuthEnabled(true); // Assume auth is enabled on error } finally { setIsLoading(false); } @@ -77,7 +86,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { } return ( - + {children} ); diff --git a/packages/http-api/src/handlers/auth.ts b/packages/http-api/src/handlers/auth.ts index 191f38f1..d6919a0a 100644 --- a/packages/http-api/src/handlers/auth.ts +++ b/packages/http-api/src/handlers/auth.ts @@ -86,17 +86,28 @@ export const createLogoutHandler = () => { export const createAuthCheckHandler = () => { return async (req: Request): Promise => { + // Check if auth is enabled via environment variable + const authEnabled = process.env.AUTH_ENABLED === 'true'; + + // If auth is disabled, always return authenticated: true with authEnabled: false + if (!authEnabled) { + return new Response(JSON.stringify({ authenticated: true, authEnabled: false }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + const token = getSessionToken(req); const isValid = verifySession(token); if (!isValid) { - return new Response(JSON.stringify({ authenticated: false }), { + return new Response(JSON.stringify({ authenticated: false, authEnabled: true }), { status: 401, headers: { "Content-Type": "application/json" }, }); } - return new Response(JSON.stringify({ authenticated: true }), { + return new Response(JSON.stringify({ authenticated: true, authEnabled: true }), { status: 200, headers: { "Content-Type": "application/json" }, }); @@ -108,6 +119,14 @@ export function requireAuth( handler: (req: Request, url: URL) => Response | Promise, ) { return async (req: Request, url: URL): Promise => { + // Check if auth is enabled via environment variable + const authEnabled = process.env.AUTH_ENABLED === 'true'; + + // Skip auth check if disabled + if (!authEnabled) { + return handler(req, url); + } + const token = getSessionToken(req); if (!verifySession(token)) { return new Response(JSON.stringify({ error: "Unauthorized" }), { From 3c544dcfd542f38e52dc386abc0a4898b93c4bba Mon Sep 17 00:00:00 2001 From: Juan Jimenez Date: Fri, 8 Aug 2025 20:19:48 +0200 Subject: [PATCH 10/11] Remove log --- apps/server/src/server.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 28cc3a83..7c933690 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -183,7 +183,6 @@ export default function startServer(options?: { } }); - console.log(runtime) // Main server serverInstance = serve({ port: runtime.port, From 02cd2432930f7a398ff31ed63754c083f71576bb Mon Sep 17 00:00:00 2001 From: Juan Jimenez Date: Fri, 8 Aug 2025 20:22:45 +0200 Subject: [PATCH 11/11] Update docs with optional auth --- docs/api-http.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api-http.md b/docs/api-http.md index 1aa82381..b51849cb 100644 --- a/docs/api-http.md +++ b/docs/api-http.md @@ -869,7 +869,7 @@ API endpoints (`/api/*`) do not require authentication for backward compatibilit ### Dashboard Authentication -The web dashboard (`/dashboard`) requires authentication: +The web dashboard (`/dashboard`) does not require authentication, unless enabled with an environment variable `AUTH_ENABLED=(boolean)`. If enabled, default credentials are `ccflare_user` for the username, and `ccflare_password` for the password. #### POST /api/auth/login