feat: update configuration for OpenAI and Anthropic endpoints

- Created a new .env.example file with default environment variables for PORT, OPENAI_UPSTREAM_URL, ANTHROPIC_UPSTREAM_URL, and DATABASE_URL.
- Updated .npmignore to exclude all .env files except .env.example.
- Revised CONTRIBUTING.md to simplify the contribution process and provide clearer setup instructions.
- Enhanced cost.ts with detailed type definitions and improved cost calculation logic.
- Updated proxy.ts to include new environment variables and improved logging functionality.
- Modified README.md to reflect new configuration instructions and usage examples.
- Removed unnecessary dashboard files and streamlined the project structure.
This commit is contained in:
Praveen Thirumurugan
2025-12-23 12:37:40 +05:30
parent e5231cac8c
commit 18d4c93216
17 changed files with 1968 additions and 2653 deletions

4
.env.example Normal file
View File

@@ -0,0 +1,4 @@
PORT=3007
OPENAI_UPSTREAM_URL=https://api.z.ai/api/coding/paas/v4
ANTHROPIC_UPSTREAM_URL=https://api.z.ai/api/anthropic/v1
DATABASE_URL=postgresql://user:password@localhost:5432/database

View File

@@ -11,10 +11,8 @@ build/
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.env.*
!.env.example
# IDE and editor files
.vscode/

View File

@@ -1,172 +1,14 @@
# Contributing to LLM Proxy Server
# Contributing
Thank you for your interest in contributing to the LLM Proxy Server! This document provides guidelines and instructions for contributing to the project.
Get started today and for assistance, you can reach on [X](https://x.com/praveentcom). You can also shoot PRs on the way if you'd like something added to OpenProxy or if there is a bug that's bothering you.
## Table of Contents
## Getting started
- [Getting Started](#getting-started)
- [Development Setup](#development-setup)
- [Making Changes](#making-changes)
- [Submitting Changes](#submitting-changes)
- [Code Style](#code-style)
- [Testing](#testing)
- [Reporting Issues](#reporting-issues)
- [Feature Requests](#feature-requests)
- Fork and clone the repo
- Install dependencies: `pnpm install`
- Run dev server: `pnpm dev`
- Run build: `pnpm build`
## Getting Started
## Commit messages
1. **Fork the repository** on GitHub
2. **Clone your fork** locally:
```bash
git clone https://github.com/praveentcom/openproxy.git
cd openproxy
```
3. **Set up your development environment** (see below)
4. **Create a new branch** for your changes:
```bash
git checkout -b feature/your-feature-name
```
## Development Setup
### Prerequisites
- Node.js 18 or higher
- PostgreSQL (for testing the database integration)
- npm or yarn
### Installation
1. Install dependencies:
```bash
npm install
```
2. Set up environment variables:
```bash
cp .env.example .env
# Edit .env with your configuration
```
3. Build the project:
```bash
npm run build
```
### Running in Development Mode
```bash
npm run dev
```
## Making Changes
### Code Style
- Follow TypeScript best practices
- Use meaningful variable and function names
- Add comments for complex logic
- Keep functions focused and single-purpose
- Use proper error handling
### Testing
Currently, the project doesn't have automated tests. When adding new features:
1. Test your changes manually
2. Consider adding integration tests for database operations
3. Test both streaming and non-streaming responses
### Documentation
- Update README.md if you add new features
- Add inline comments for complex code
- Update this CONTRIBUTING.md if you add new development processes
## Submitting Changes
### Pull Request Process
1. **Update your fork** with the latest changes:
```bash
git fetch upstream
git rebase upstream/main
```
2. **Commit your changes** with clear, descriptive messages:
```bash
git commit -m "feat: add support for custom cost models"
```
3. **Push to your fork**:
```bash
git push origin feature/your-feature-name
```
4. **Create a Pull Request** on GitHub with:
- Clear title and description
- Links to any related issues
- Screenshots if applicable
- Testing instructions
### Pull Request Guidelines
- **One feature per PR** - separate features into different PRs
- **Update documentation** - include any necessary changes to README.md
- **Test thoroughly** - ensure your changes work as expected
- **Follow the code style** - maintain consistency with existing code
- **Include tests** - add tests for new functionality if possible
## Code Review
All pull requests will be reviewed by maintainers. Please be responsive to feedback and make requested changes in a timely manner.
## Reporting Issues
### Bug Reports
When reporting bugs, please include:
1. **Environment details**:
- Node.js version
- PostgreSQL version (if applicable)
- Operating system
2. **Steps to reproduce**:
- Clear, step-by-step instructions
- Sample code if applicable
3. **Expected behavior**:
- What should happen
4. **Actual behavior**:
- What actually happens
5. **Error messages**:
- Full error stack traces
### Security Issues
Please report security vulnerabilities privately to mail@praveent.com. Do not create public issues for security problems.
## Feature Requests
We welcome feature requests! Please:
1. **Check existing issues** first to avoid duplicates
2. **Create a new issue** with:
- Clear description of the feature
- Use case and motivation
- Implementation suggestions if you have any
- Potential alternatives you've considered
## Community
- Join our discussions in GitHub issues
- Be respectful and inclusive
- Help others when you can
- Celebrate milestones and successes
## License
By contributing to this project, you agree that your contributions will be licensed under the MIT License.
- Use clear, descriptive messages. Conventional Commits are appreciated but not required.

View File

@@ -1,19 +1,23 @@
# Dockerfile
FROM node:22-slim
# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
# Copy package files first for caching
COPY package*.json ./
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY dashboard/package.json ./dashboard/
# Install ALL dependencies (including devDeps for TypeScript)
RUN npm install
RUN pnpm install --frozen-lockfile
# Copy all source code
COPY . .
# Compile TypeScript inside container
RUN npx tsc
RUN pnpm run build
ENV PORT=8080
EXPOSE 8080

203
README.md
View File

@@ -1,42 +1,66 @@
# OpenProxy
# OpenProxy: Multi-provider LLM proxy with cost tracking
A lightweight, production-ready OpenAI-compatible proxy server that seamlessly forwards LLM API requests to any endpoint with comprehensive logging, cost tracking, and PostgreSQL integration. Perfect for monitoring API usage, calculating costs, and maintaining audit trails for your AI applications.
OpenProxy is a lightweight, production-ready proxy server that seamlessly forwards API requests to OpenAI and Anthropic compatible endpoints with comprehensive logging, cost tracking, and PostgreSQL integration.
<img width="2400" height="1260" alt="cover-image" src="https://github.com/user-attachments/assets/60f6e577-7d2e-45d0-accb-3af62894840f" />
## ⚙️ Configuration
## How to configure?
| Environment Variable | Description | Default Value |
|----------------------|-------------|-----------------|
| `PORT` | Server port | `3007` |
| `UPSTREAM_URL` | Your LLM endpoint URL | **Required** |
| `DATABASE_URL` | PostgreSQL connection string for logging | **Required** |
| `DATABASE_TABLE` | Name of the table to store the logs | `"llm_proxy"` |
### Setting up
## 💰 Cost Calculation
I'd recommend forking this repository or cloning it directly. Once done, you should be able to get OpenProxy running with minimal configuration.
The cost is calculated based on the model and token usage with configurable pricing per model.
You'll need to add the cost configuration (in cost per million tokens) for your models in the `cost.ts` file. The default cost configuration in the project (with sample values from `z.ai` models) is:
```typescript
export const MODEL_COSTS: Record<string, CostConfig> = {
"glm-4.5-air": { input: 0.2, cached: 0.03, output: 1.1 },
"glm-4.6": { input: 0.6, cached: 0.11, output: 2.2 },
"default": { input: 0, cached: 0, output: 0 },
};
```bash
pnpm install
```
You can add more models to the `MODEL_COSTS` object to support your specific LLM providers.
Set your environment variables:
## 📊 PostgreSQL Table Schema
```bash
export PORT=3007
export OPENAI_UPSTREAM_URL="https://api.example.com/v1"
export ANTHROPIC_UPSTREAM_URL="https://api.example.com/api/anthropic/v1"
export DATABASE_URL="postgresql://user:password@localhost:5432/database_name"
```
Start the server:
```bash
# Development mode with auto-reload
pnpm dev
# Production build
pnpm build && pnpm start
```
### Configuration
| Environment Variable | Description | Default |
|----------------------|-------------|---------|
| `PORT` | Server port | `3007` |
| `OPENAI_UPSTREAM_URL` | OpenAI-compatible endpoint URL | - |
| `ANTHROPIC_UPSTREAM_URL` | Anthropic-compatible endpoint URL | - |
| `DATABASE_URL` | PostgreSQL connection string | **Required** |
OpenProxy uses path prefixes for clean provider detection:
| Proxy Path | Routes To | Auth Header |
|------------|-----------|-------------|
| `/openai/*` | `OPENAI_UPSTREAM_URL/*` | `Authorization: Bearer <key>` |
| `/anthropic/*` | `ANTHROPIC_UPSTREAM_URL/*` | `x-api-key: <key>` or `Authorization: Bearer <key>` |
### PostgreSQL Logging
Every request is logged with comprehensive details to the PostgreSQL database. The table schema is as follows:
```sql
CREATE TABLE IF NOT EXISTS <DATABASE_TABLE> (
CREATE TABLE IF NOT EXISTS llm_proxy (
timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
request_method VARCHAR(10) NOT NULL,
request_path VARCHAR(255) NOT NULL,
model VARCHAR(20) NOT NULL,
provider TEXT,
model VARCHAR(50) NOT NULL,
completion_tokens INTEGER,
prompt_tokens INTEGER,
total_tokens INTEGER,
@@ -56,123 +80,50 @@ CREATE TABLE IF NOT EXISTS <DATABASE_TABLE> (
max_tokens INTEGER,
request_id UUID
);
CREATE INDEX IF NOT EXISTS idx_<DATABASE_TABLE>_timestamp ON <DATABASE_TABLE> (timestamp);
CREATE INDEX IF NOT EXISTS idx_<DATABASE_TABLE>_request_id ON <DATABASE_TABLE> (request_id);
```
## 🚀 Quick Start
### Cost Calculation
### Installation
OpenProxy automatically calculates costs based on model and token usage using the Helicone API. You can customize the costs for your own models in `cost.ts`.
## How to use?
### Using with Claude Code
For example, to use Z.AI or other Anthropic-compatible providers with Claude Code:
```bash
npm install
export ANTHROPIC_UPSTREAM_URL="https://api.z.ai/api/anthropic"
export DATABASE_URL="postgresql://user:password@localhost:5432/database_name"
pnpm dev
# Configure Claude Code to use:
# API Base URL: http://localhost:3007/anthropic
```
### Configuration
### Using with OpenAI-compatible clients
Set your environment variables:
For example, to use Z.AI or other OpenAI-compatible providers with OpenAI-compatible clients:
```bash
export PORT=3007
export UPSTREAM_URL="https://api.example.com/v1"
export DATABASE_URL="postgresql://user:password@localhost:5432/llm_logs"
export DATABASE_TABLE="llm_proxy"
export OPENAI_UPSTREAM_URL="https://api.z.ai/api/coding/paas/v4"
export DATABASE_URL="postgresql://user:password@localhost:5432/database_name"
pnpm dev
# Configure your client to use:
# API Base URL: http://localhost:3007/openai
```
### Running
## Metrics Dashboard
OpenProxy includes a lightweight Next.js dashboard for real-time metrics visualization. The dashboard is accessible at `http://localhost:3008`. To run the dashboard, run the following command:
```bash
# Development mode with auto-reload
npm run dev
# Production build
npm run build
npm start
pnpm --filter dashboard dev
```
## 💻 Usage
## Final notes
The proxy works with any OpenAI-compatible endpoint. Just point your client to the proxy:
Get started today and for assistance, you can reach me on [GitHub](https://github.com/praveentcom/openproxy) or [X](https://x.com/praveentcom). PRs are always welcome if you'd like to add features or fix bugs!
```bash
curl -X POST http://localhost:3007/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer your-api-key" \
-d '{
"model": "your-model",
"messages": [{"role": "user", "content": "Hello!"}]
}'
```
### Example Response with Cost Tracking
All responses are logged to PostgreSQL with detailed usage and cost information:
```json
{
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1677652288,
"model": "glm-4.5-air",
"usage": {
"prompt_tokens": 20,
"completion_tokens": 30,
"total_tokens": 50,
"prompt_tokens_details": {
"cached_tokens": 5
}
},
"choices": [...]
}
```
The corresponding database entry will include:
- Token usage breakdown
- Calculated cost based on your model pricing
- Response time metrics
- Complete request/response bodies for audit purposes
## 🛡️ Security
- Bearer token authentication required
- CORS headers configured for cross-origin requests
- No sensitive data stored in logs (authentication headers are not logged)
- Input validation and error handling
## 📈 Monitoring
Monitor your API usage through the PostgreSQL logs:
- Track costs across different models
- Analyze response times and performance
- Identify usage patterns and optimize costs
- Maintain compliance with audit requirements
### Metrics Dashboard
OpenProxy includes a lightweight Next.js dashboard for real-time metrics visualization:
```bash
cd dashboard
npm install
cp .env.example .env
# Configure DATABASE_URL in .env
npm run dev
```
The dashboard (available at `http://localhost:3008`) provides:
- **Real-time Overview**: Total requests, tokens, costs, and response times
- **Model Breakdown**: Usage statistics grouped by LLM model
- **Hourly Trends**: Visual charts showing request patterns over time
- **Recent Requests**: Detailed table of recent API calls
- **Auto-refresh**: Automatic updates every 30 seconds
See [dashboard/README.md](./dashboard/README.md) for detailed setup instructions.
## 🤝 Contributing
Feel free to submit issues and enhancement requests!
## 📄 License
This project is open source and available under the MIT License.
This project is open source and available under the [MIT License](./LICENSE).

233
cost.ts
View File

@@ -1,47 +1,232 @@
// cost.ts
// Helper for calculating token-based LLM costs
/**
* Usage object for logging.
*
* @param prompt_tokens: The number of prompt tokens.
* @param completion_tokens: The number of completion tokens.
* @param total_tokens: The total number of tokens.
* @param prompt_tokens_details: The details of the prompt tokens.
* @returns The usage object.
*/
export type Usage = {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
prompt_tokens_details?: {
cached_tokens?: number;
}
};
};
/**
* Cost configuration for a model.
*
* @param input: The cost per million prompt tokens (USD).
* @param cached: The cost per million cached tokens (USD).
* @param output: The cost per million completion tokens (USD).
* @returns The cost configuration.
*/
export type CostConfig = {
input: number; // cost per million prompt tokens (USD)
cached: number; // cost per million cached tokens (USD)
output: number; // cost per million completion tokens (USD)
input: number;
cached: number;
output: number;
};
// Cost table per model (USD per million tokens)
export const MODEL_COSTS: Record<string, CostConfig> = {
"glm-4.5-air": { input: 0.2, cached: 0.03, output: 1.1 },
"glm-4.6": { input: 0.6, cached: 0.11, output: 2.2 },
"default": { input: 0, cached: 0, output: 0 },
/**
* Model pricing table.
*
* @param models: Canonical model pricing.
* @param aliases: Alias to canonical model mapping.
* @returns The pricing table.
*/
export type ModelCostTable = Record<string, CostConfig>;
/**
* Helicone API response types
*/
interface HeliconeModelCost {
provider: string;
model: string;
operator: "equals" | "startsWith" | "includes";
input_cost_per_1m: number;
output_cost_per_1m: number;
prompt_cache_write_per_1m?: number;
prompt_cache_read_per_1m?: number;
show_in_playground?: boolean;
}
interface HeliconeApiResponse {
metadata: {
total_models: number;
};
data: HeliconeModelCost[];
}
/**
* Internal storage for cost data with matching operators
*/
interface CostEntry {
operator: "equals" | "startsWith" | "includes";
config: CostConfig;
}
// Storage for Helicone costs (loaded at runtime)
let heliconeCosts: Map<string, CostEntry> = new Map();
let heliconeCostsLoaded = false;
/**
* ============================================================================
* CUSTOM MODEL COSTS
* ============================================================================
*
* Add your custom model costs here. These will take precedence over costs
* fetched from the Helicone API. This is useful for:
*
* - Custom/fine-tuned models (e.g., "zlm-4.6")
* - Self-hosted models with custom pricing
* - Overriding Helicone costs for specific models
* - Models not yet in the Helicone database
*
* Format:
* "model-name": { input: <cost>, cached: <cost>, output: <cost> }
*
* All costs are in USD per million tokens.
*
* @example
* ```ts
* export const CUSTOM_MODEL_COSTS: ModelCostTable = {
* "zlm-4.6": { input: 2.5, cached: 1.25, output: 10 },
* "zlm-4.5-air": { input: 0.15, cached: 0.075, output: 0.6 },
* };
* ```
*/
export const CUSTOM_MODEL_COSTS: ModelCostTable = {
// Add your custom model costs here
};
// Compute total cost (in USD)
export function calculateCost(model: string, usage?: Usage): number | null {
/**
* Fetches and loads cost data from the Helicone API.
* This should be called once at application startup.
*
* @returns Promise that resolves when costs are loaded
*/
export async function loadHeliconeCosts(): Promise<void> {
try {
const response = await fetch("https://www.helicone.ai/api/llm-costs");
if (!response.ok) {
throw new Error(`Helicone API returned ${response.status}: ${response.statusText}`);
}
const data: HeliconeApiResponse = await response.json();
heliconeCosts.clear();
for (const model of data.data) {
const config: CostConfig = {
input: model.input_cost_per_1m ?? 0,
output: model.output_cost_per_1m ?? 0,
cached: model.prompt_cache_read_per_1m ?? model.input_cost_per_1m ?? 0,
};
heliconeCosts.set(model.model.toLowerCase(), {
operator: model.operator,
config,
});
}
heliconeCostsLoaded = true;
console.log(`\x1b[36m 🌎 Loaded ${data.metadata.total_models} model costs from Helicone\x1b[0m`);
} catch (error) {
console.warn(`\x1b[33m ⚠️ Failed to load Helicone costs: ${error instanceof Error ? error.message : error}\x1b[0m`);
}
}
/**
* Gets the cost configuration for a model.
*
* Priority order:
* 1. Custom model costs (CUSTOM_MODEL_COSTS)
* 2. Helicone API costs (with operator matching)
* 3. Fallback cost
*
* @param model: The model name to look up
* @returns The cost configuration for the model
*/
export function getCostConfig(model: string): CostConfig {
const normalizedModel = model.toLowerCase();
/**
* Check custom costs first (highest priority)
*/
if (CUSTOM_MODEL_COSTS[normalizedModel]) {
return CUSTOM_MODEL_COSTS[normalizedModel];
} else if (CUSTOM_MODEL_COSTS[model]) {
return CUSTOM_MODEL_COSTS[model];
}
/**
* Check Helicone costs with operator matching
*/
const exactMatch = heliconeCosts.get(normalizedModel);
if (exactMatch && exactMatch.operator === "equals") {
return exactMatch.config;
}
for (const [pattern, entry] of heliconeCosts) {
if (entry.operator === "startsWith" && normalizedModel.startsWith(pattern)) {
return entry.config;
}
}
for (const [pattern, entry] of heliconeCosts) {
if (entry.operator === "includes" && normalizedModel.includes(pattern)) {
return entry.config;
}
}
if (exactMatch) {
return exactMatch.config;
}
/**
* Return fallback since no matching cost was found
*/
return { input: 0, cached: 0, output: 0 };
}
/**
* Computes the total cost (in USD) for a given model and usage.
*
* @param model: The model to compute the cost for.
* @param usage: The usage object.
* @returns The total cost (in USD), or null if no usage data.
*/
export function calculateCost(
model: string,
usage?: Usage
): number | null {
if (!usage) return null;
const { prompt_tokens = 0, completion_tokens = 0, prompt_tokens_details = { cached_tokens: 0 } } = usage;
const cost = MODEL_COSTS[model.toLowerCase()] || MODEL_COSTS["default"];
const {
prompt_tokens = 0,
completion_tokens = 0,
prompt_tokens_details = { cached_tokens: 0 },
} = usage;
let inputCost = 0;
let cachedCost = 0;
let outputCost = 0;
const cost = getCostConfig(model);
if (prompt_tokens_details?.cached_tokens) {
cachedCost = ((prompt_tokens_details.cached_tokens) / 1_000_000) * cost.cached;
inputCost = ((prompt_tokens - prompt_tokens_details.cached_tokens) / 1_000_000) * cost.input;
let inputCost = 0, cachedCost = 0;
if (prompt_tokens_details.cached_tokens && cost.cached > 0) {
cachedCost =
(prompt_tokens_details.cached_tokens / 1_000_000) * cost.cached;
inputCost =
((prompt_tokens - prompt_tokens_details.cached_tokens) / 1_000_000) *
cost.input;
} else {
inputCost = ((prompt_tokens) / 1_000_000) * cost.input;
inputCost = (prompt_tokens / 1_000_000) * cost.input;
}
outputCost = (completion_tokens / 1_000_000) * cost.output;
const outputCost =
(completion_tokens / 1_000_000) * cost.output;
const total = inputCost + cachedCost + outputCost;
return total > 0 ? Number(total.toFixed(6)) : null;

View File

@@ -1,5 +0,0 @@
# PostgreSQL connection string (same as proxy server)
DATABASE_URL=postgresql://user:password@localhost:5432/database
# Database table name (default: llm_proxy)
DATABASE_TABLE=llm_proxy

View File

@@ -1,195 +0,0 @@
# OpenProxy Metrics Dashboard
A lightweight Next.js dashboard for visualizing OpenProxy LLM request metrics in real-time.
## Features
- **Real-time Metrics Overview**: Total requests, tokens, costs, and response times
- **Model Breakdown**: Usage statistics grouped by LLM model
- **Hourly Trends**: Visual charts showing request patterns over time
- **Recent Requests**: Detailed table of recent API calls
- **Auto-refresh**: Automatic updates every 30 seconds
- **Time Range Selection**: View metrics for the last hour, 6 hours, 24 hours, or 7 days
## Prerequisites
- Node.js 18 or higher
- PostgreSQL database (same as the proxy server)
- OpenProxy proxy server running
## Installation
1. Navigate to the dashboard directory:
```bash
cd dashboard
```
2. Install dependencies:
```bash
npm install
```
3. Create a `.env` file (copy from `.env.example`):
```bash
cp .env.example .env
```
4. Configure your `.env` file:
```env
DATABASE_URL=postgresql://user:password@localhost:5432/database
DATABASE_TABLE=llm_proxy
```
## Running the Dashboard
### Development Mode
```bash
npm run dev
```
The dashboard will be available at `http://localhost:3008`
### Production Mode
1. Build the application:
```bash
npm run build
```
2. Start the production server:
```bash
npm start
```
## Dashboard Sections
### 1. Overview Cards
Displays key metrics at a glance:
- Total requests processed
- Total tokens consumed
- Total cost incurred
- Average response time
- Number of unique models used
- Number of unique client IPs
### 2. Hourly Trends
Two charts showing:
- Requests count and average response time over time
- Token usage and costs over time
### 3. Model Breakdown
Table showing per-model statistics:
- Request count
- Total tokens used
- Total cost
- Average response time
### 4. Recent Requests
Detailed table of recent API calls showing:
- Timestamp
- Model used
- Token breakdown (prompt + completion = total)
- Cost
- Response time
- HTTP status code
- Client IP address
- Whether the request was streamed
## Configuration
### Port
The dashboard runs on port 3008 by default. To change this, modify the `dev` and `start` scripts in `package.json`:
```json
"dev": "next dev -p YOUR_PORT",
"start": "next start -p YOUR_PORT"
```
### Database Connection
Ensure the `DATABASE_URL` in your `.env` file matches the PostgreSQL connection string used by the proxy server.
### Time Ranges
Available time ranges:
- Last Hour (1 hour)
- Last 6 Hours
- Last 24 Hours (default)
- Last 7 Days (168 hours)
## Troubleshooting
### "Failed to fetch metrics" Error
- Verify that the `DATABASE_URL` in `.env` is correct
- Ensure PostgreSQL is running and accessible
- Check that the `llm_proxy` table exists in your database
- Verify network connectivity to the database
### Empty Dashboard
- Ensure the proxy server is running and processing requests
- Verify that requests are being logged to the database
- Check that the `DATABASE_TABLE` name matches your configuration
### Port Conflicts
If port 3008 is already in use, change the port in `package.json` scripts.
## Technology Stack
- **Framework**: Next.js 14 (React 18)
- **Charts**: Recharts
- **Database**: PostgreSQL (via `pg` driver)
- **Language**: TypeScript
- **Styling**: Inline CSS (no external dependencies)
## Architecture
```
dashboard/
├── app/
│ ├── api/
│ │ └── metrics/
│ │ └── route.ts # API endpoint for fetching metrics
│ ├── layout.tsx # Root layout
│ └── page.tsx # Main dashboard page
├── components/
│ ├── MetricsOverview.tsx # Overview cards component
│ ├── ModelBreakdown.tsx # Model statistics table
│ ├── RecentRequests.tsx # Recent requests table
│ └── TrendsChart.tsx # Hourly trends charts
├── package.json
├── tsconfig.json
├── next.config.js
└── README.md
```
## API Endpoints
### GET `/api/metrics`
Query parameters:
- `hours` (optional): Number of hours to look back (default: 24)
- `limit` (optional): Maximum number of recent requests to return (default: 100)
Response:
```json
{
"success": true,
"data": {
"summary": {
"totalRequests": 1234,
"totalTokens": 567890,
"totalCost": 12.34,
"avgResponseTime": 450.5,
"uniqueModels": 3,
"uniqueClients": 15
},
"recentRequests": [...],
"modelBreakdown": [...],
"hourlyTrends": [...]
},
"timeRange": "24 hours"
}
```
## License
Same as OpenProxy parent project.

View File

@@ -5,11 +5,7 @@ const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
const TABLE_NAME = process.env.DATABASE_TABLE || 'llm_proxy';
// Validate table name against whitelist to prevent SQL injection
const ALLOWED_TABLES = ['llm_proxy', 'llm_proxy_dev', 'llm_proxy_test'];
const validatedTableName = ALLOWED_TABLES.includes(TABLE_NAME) ? TABLE_NAME : 'llm_proxy';
const TABLE_NAME = 'llm_proxy';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
@@ -35,7 +31,7 @@ export async function GET(request: NextRequest) {
AVG(response_time) as avg_response_time,
COUNT(DISTINCT model) as unique_models,
COUNT(DISTINCT client_ip) as unique_clients
FROM ${validatedTableName}
FROM ${TABLE_NAME}
WHERE timestamp >= NOW() - INTERVAL '1 hour' * $1
`;
const summaryResult = await client.query(summaryQuery, [hours]);
@@ -55,7 +51,7 @@ export async function GET(request: NextRequest) {
response_status,
client_ip,
stream
FROM ${validatedTableName}
FROM ${TABLE_NAME}
WHERE timestamp >= NOW() - INTERVAL '1 hour' * $1
ORDER BY timestamp DESC
LIMIT $2
@@ -71,7 +67,7 @@ export async function GET(request: NextRequest) {
SUM(total_tokens) as total_tokens,
SUM(total_cost) as total_cost,
AVG(response_time) as avg_response_time
FROM ${validatedTableName}
FROM ${TABLE_NAME}
WHERE timestamp >= NOW() - INTERVAL '1 hour' * $1
GROUP BY model
ORDER BY request_count DESC
@@ -87,7 +83,7 @@ export async function GET(request: NextRequest) {
SUM(total_tokens) as tokens,
SUM(total_cost) as cost,
AVG(response_time) as avg_response_time
FROM ${validatedTableName}
FROM ${TABLE_NAME}
WHERE timestamp >= NOW() - INTERVAL '1 hour' * $1
GROUP BY hour
ORDER BY hour ASC

View File

@@ -1,3 +1,7 @@
const path = require('path');
require('dotenv').config({ path: path.resolve(__dirname, '../.env') });
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@
"lint": "next lint"
},
"dependencies": {
"dotenv": "^17.2.3",
"next": "^14.2.35",
"react": "^18.3.0",
"react-dom": "^18.3.0",

1001
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "openproxy",
"version": "1.0.0",
"version": "1.1.0",
"description": "A lightweight, production-ready OpenAI-compatible proxy server that seamlessly forwards LLM API requests to any endpoint with comprehensive logging, cost tracking, and PostgreSQL integration. Perfect for monitoring API usage, calculating costs, and maintaining audit trails for your AI applications.",
"main": "proxy.ts",
"scripts": {
@@ -41,5 +41,6 @@
"dotenv": "^17.2.3",
"pg": "^8.16.3",
"uuid": "^13.0.0"
}
},
"packageManager": "pnpm@10.26.1+sha512.664074abc367d2c9324fdc18037097ce0a8f126034160f709928e9e9f95d98714347044e5c3164d65bd5da6c59c6be362b107546292a8eecb7999196e5ce58fa"
}

1297
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

4
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,4 @@
packages:
- "."
- "dashboard"

430
proxy.ts
View File

@@ -1,50 +1,118 @@
// proxy.ts
// Minimal OpenAI-compatible proxy with streaming & PostgreSQL logging.
// Node 18+ required.
/**
* OpenProxy is a proxy server for OpenAI and Anthropic compatible endpoints.
*
* To configure the proxy, refer to the README.md file for instructions on how to
* set up the environment variables. The server supports both OpenAI and Anthropic
* compatible endpoints.
*
* Once configured, the server will be accessible via the following URLs:
*
* - http://localhost:3007/openai/* for OpenAI compatible endpoints
* - http://localhost:3007/anthropic/* for Anthropic compatible endpoints
*
* @example
* ```bash
* curl -X POST http://localhost:3007/openai/v1/chat/completions \
* -H "Content-Type: application/json" \
* -H "Authorization: Bearer your-api-key" \
* -d '{
* "model": "gpt-4o",
* "messages": [{"role": "user", "content": "Hello!"}]
* }'
* ```
*
* @example
* ```bash
* curl -X POST http://localhost:3007/anthropic/v1/messages \
* -H "Content-Type: application/json" \
* -H "x-api-key: your-api-key" \
* -H "anthropic-version: 2023-06-01" \
* -d '{
* "model": "claude-sonnet-4-20250514",
* "max_tokens": 1024,
* "messages": [{"role": "user", "content": "Hello!"}]
* }'
* ```
*/
import "dotenv/config";
import http, { IncomingMessage, ServerResponse } from "http";
import { TextDecoder } from "util";
import { Pool } from "pg";
import { v4 as uuidv4 } from "uuid";
import { calculateCost } from "./cost";
import { calculateCost, loadHeliconeCosts } from "./cost";
/**
* Configuration Options
*
* - PORT: The port number to listen on. Default is 3007.
* - OPENAI_UPSTREAM_URL: The URL of the OpenAI compatible endpoint.
* - ANTHROPIC_UPSTREAM_URL: The URL of the Anthropic compatible endpoint.
* - DATABASE_URL: The URL of the PostgreSQL database to use for logging.
*/
const PORT = Number(process.env.PORT || 3007);
const UPSTREAM_URL = (process.env.UPSTREAM_URL || "").replace(/\/+$/, "");
// Validate UPSTREAM_URL is configured
if (!UPSTREAM_URL) {
console.error("❌ UPSTREAM_URL environment variable is required");
process.exit(1);
}
// --- PostgreSQL connection ---
const OPENAI_UPSTREAM_URL = (process.env.OPENAI_UPSTREAM_URL || "").replace(/\/+$/, "");
const ANTHROPIC_UPSTREAM_URL = (process.env.ANTHROPIC_UPSTREAM_URL || "").replace(/\/+$/, "");
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
// --- Helper functions ---
function generateRequestId(): string {
return uuidv4();
/**
* Types
*
* - Provider: The provider of the API. Can be "openai" or "anthropic".
* - ProviderConfig: The configuration for the provider.
* - ParsedRoute: The parsed route from the request URL.
*/
type Provider = "openai" | "anthropic";
interface ProviderConfig {
upstreamUrl: string;
}
// Function to convert IPv6-mapped IPv4 addresses to IPv4 format
function normalizeIp(ip: string | null | undefined): string | null {
interface ParsedRoute {
provider: Provider;
upstreamPath: string;
}
const PROVIDER_PREFIXES: { prefix: string; provider: Provider }[] = [
{ prefix: "/openai", provider: "openai" },
{ prefix: "/anthropic", provider: "anthropic" },
];
/**
* Normalizes the IP address to remove IPv6-mapped IPv4 addresses.
*
* @param ip: The IP address to normalize.
* @returns The normalized IP address.
*/
function normalizeIP(ip: string | null | undefined): string | null {
if (!ip) return null;
// Handle IPv6-mapped IPv4 addresses (::ffff:x.x.x.x)
if (ip.startsWith('::ffff:') && ip.length > 7) {
return ip.substring(7);
}
return ip;
}
/**
* Sets the CORS headers for the response.
*
* @param res: The response object.
*/
function setCors(res: ServerResponse) {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Requested-With");
res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Requested-With, x-api-key, anthropic-version, anthropic-beta");
res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
}
/**
* Reads the body of the request.
*
* @param req: The request object.
* @returns The body of the request.
*/
function readBody(req: IncomingMessage): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
@@ -54,76 +122,226 @@ function readBody(req: IncomingMessage): Promise<Buffer> {
});
}
function okPath(path: string) {
return path.startsWith("/chat/completions") ||
path.startsWith("/completions") ||
path.startsWith("/models");
/**
* Parses the route from the request URL.
*
* @param path: The path of the request URL.
* @returns The parsed route.
*/
function parseRoute(path: string): ParsedRoute | null {
for (const { prefix, provider } of PROVIDER_PREFIXES) {
if (path === prefix || path.startsWith(`${prefix}/`)) {
const upstreamPath = path.slice(prefix.length) || "/";
return { provider, upstreamPath };
}
}
return null;
}
// --- Logging to Postgres ---
async function logToPG(data: Record<string, any>) {
/**
* Gets the provider configuration.
*
* @param provider: The provider of the API.
* @returns The provider configuration.
*/
function getProviderConfig(provider: Provider): ProviderConfig {
switch (provider) {
case "anthropic":
return { upstreamUrl: ANTHROPIC_UPSTREAM_URL };
case "openai":
default:
return { upstreamUrl: OPENAI_UPSTREAM_URL };
}
}
/**
* Gets the authentication token from the request headers.
*
* @param req: The request object.
* @param provider: The provider of the API.
* @returns The authentication token.
*/
function getAuthToken(req: IncomingMessage, provider: Provider): string | null {
if (provider === "anthropic") {
const apiKey = req.headers["x-api-key"];
if (apiKey) return String(apiKey);
const auth = req.headers["authorization"];
if (auth?.startsWith("Bearer ")) {
return auth.slice(7);
}
return null;
} else {
const auth = req.headers["authorization"];
if (auth?.startsWith("Bearer ")) {
return auth.slice(7);
}
return null;
}
}
/**
* Builds the headers for the upstream request.
*
* @param req: The request object.
* @param provider: The provider of the API.
* @param authToken: The authentication token.
* @returns The headers for the upstream request.
*/
function buildUpstreamHeaders(req: IncomingMessage, provider: Provider, authToken: string): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": (req.headers["content-type"] as string) || "application/json",
};
if (provider === "anthropic") {
headers["x-api-key"] = authToken;
if (req.headers["anthropic-version"]) {
headers["anthropic-version"] = String(req.headers["anthropic-version"]);
} else {
headers["anthropic-version"] = "2023-06-01";
}
if (req.headers["anthropic-beta"]) {
headers["anthropic-beta"] = String(req.headers["anthropic-beta"]);
}
} else {
headers["Authorization"] = `Bearer ${authToken}`;
}
return headers;
}
/**
* Normalizes the usage from different providers.
*
* @param usage: The usage object.
* @param provider: The provider of the API.
* @returns The normalized usage.
*/
interface NormalizedUsage {
prompt_tokens: number | null;
completion_tokens: number | null;
total_tokens: number | null;
cached_tokens: number | null;
}
/**
* Normalizes the usage from different providers.
*
* @param usage: The usage object.
* @param provider: The provider of the API.
* @returns The normalized usage.
*/
function normalizeUsage(usage: any, provider: Provider): NormalizedUsage {
if (!usage) {
return { prompt_tokens: null, completion_tokens: null, total_tokens: null, cached_tokens: null };
}
if (provider === "anthropic") {
const inputTokens = usage.input_tokens || 0;
const outputTokens = usage.output_tokens || 0;
const cachedTokens = usage.cache_read_input_tokens || usage.cache_creation_input_tokens || 0;
return {
prompt_tokens: inputTokens,
completion_tokens: outputTokens,
total_tokens: inputTokens + outputTokens,
cached_tokens: cachedTokens || null,
};
} else {
return {
prompt_tokens: usage.prompt_tokens || null,
completion_tokens: usage.completion_tokens || null,
total_tokens: usage.total_tokens || null,
cached_tokens: usage.prompt_tokens_details?.cached_tokens || null,
};
}
}
/**
* Logs the data to the PostgreSQL database.
*
* @param data: The data to log.
*/
async function persistDatabaseRecord(data: Record<string, any>) {
const keys = Object.keys(data);
const cols = keys.map(k => `"${k}"`).join(",");
const vals = keys.map((_, i) => `$${i + 1}`).join(",");
const values = Object.values(data);
// Validate table name against whitelist to prevent SQL injection
const TABLE_NAME = process.env.DATABASE_TABLE || "llm_proxy";
const ALLOWED_TABLES = ["llm_proxy", "llm_proxy_dev", "llm_proxy_test"];
const validatedTableName = ALLOWED_TABLES.includes(TABLE_NAME) ? TABLE_NAME : "llm_proxy";
await pool.query(`INSERT INTO ${validatedTableName} (${cols}) VALUES (${vals})`, values);
const TABLE_NAME = "llm_proxy";
await pool.query(`INSERT INTO ${TABLE_NAME} (${cols}) VALUES (${vals})`, values);
}
// --- Main proxy server ---
const server = http.createServer(async (req, res) => {
const start = Date.now();
setCors(res);
if (req.method === "OPTIONS") {
res.statusCode = 204;
return res.end();
}
/**
* Creates the proxy server.
*
* @param req: The request object.
* @param res: The response object.
*/
const server = http.createServer(async (req: IncomingMessage, res: ServerResponse) => {
const startTime = Date.now();
try {
const url = new URL(req.url || "/", `http://${req.headers.host}`);
const path = url.pathname;
const method = req.method || "GET";
if (!okPath(path)) {
const route = parseRoute(path);
if (!route) {
res.statusCode = 404;
res.end(JSON.stringify({ error: "Not found" }));
res.end(JSON.stringify({
error: "INVALID_PROVIDER_PREFIX",
message: "Invalid provider prefix in request URL"
}));
return;
}
const auth = req.headers["authorization"];
if (!auth?.startsWith("Bearer ")) {
const { provider, upstreamPath } = route;
const config = getProviderConfig(provider);
if (!config.upstreamUrl) {
res.statusCode = 503;
res.end(JSON.stringify({
error: "UPSTREAM_URL_NOT_CONFIGURED",
message: `${provider.toUpperCase()}_UPSTREAM_URL is not configured`
}));
return;
}
const authToken = getAuthToken(req, provider);
if (!authToken) {
res.statusCode = 401;
res.end(JSON.stringify({ error: "Missing or invalid Authorization header" }));
res.end(JSON.stringify({
error: "MISSING_OR_INVALID_AUTHORIZATION_HEADER",
message: "Missing or invalid Authorization header"
}));
return;
}
const bodyBuf = method === "POST" ? await readBody(req) : Buffer.from("");
const requestJson = bodyBuf.length ? JSON.parse(bodyBuf.toString()) : null;
const targetUrl = UPSTREAM_URL + path + url.search;
const targetUrl = config.upstreamUrl + upstreamPath + url.search;
const upstreamHeaders = buildUpstreamHeaders(req, provider, authToken);
let upstreamRes;
let upstreamRes: Response;
try {
upstreamRes = await fetch(targetUrl, {
method,
headers: {
"Content-Type": (req.headers["content-type"] as string) || "application/json",
Authorization: auth,
},
// @ts-ignore
duplex: "half",
method,
headers: upstreamHeaders,
body: method === "POST" ? bodyBuf.toString() : undefined,
});
} catch (fetchError: any) {
console.error("Fetch error:", fetchError.message, "URL:", targetUrl);
console.error(`[${provider}] Upstream connection failed:`, fetchError.message, "URL:", targetUrl);
res.statusCode = 502;
res.end(JSON.stringify({ error: "Failed to connect to upstream", message: fetchError.message }));
res.end(JSON.stringify({
error: "UPSTREAM_CONNECTION_FAILED",
message: fetchError.message
}));
return;
}
@@ -131,9 +349,10 @@ const server = http.createServer(async (req, res) => {
res.statusCode = upstreamRes.status;
res.setHeader("Content-Type", contentType);
// --- Streaming or non-streaming response handling ---
let responseBody: any = null;
if (contentType.includes("text/event-stream")) {
const isStreaming = contentType.includes("text/event-stream");
if (isStreaming) {
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("Transfer-Encoding", "chunked");
@@ -165,23 +384,26 @@ const server = http.createServer(async (req, res) => {
try {
const obj = JSON.parse(jsonStr);
if (obj.usage) usageFromStream = obj.usage;
} catch {
/* ignore partial lines */
}
if (obj.type === "message_delta" && obj.usage) {
usageFromStream = obj.usage;
}
if (obj.type === "message_start" && obj.message?.usage) {
usageFromStream = { ...usageFromStream, ...obj.message.usage };
}
} catch {} // eslint-disable-line no-empty
}
}
}
}
res.end();
// Use whatever we captured from the stream
responseBody = {
streamed: true,
preview: rawText.slice(0, 5000),
usage: usageFromStream,
};
}
else {
} else {
const text = await upstreamRes.text();
res.end(text);
try {
@@ -191,44 +413,84 @@ const server = http.createServer(async (req, res) => {
}
}
// --- Token usage and metadata ---
const usage = responseBody?.usage || {};
const totalCost = calculateCost(requestJson?.model || "default", usage);
/**
* In an async manner, we calculate the usage and cost for logging.
* Once the calculation is complete, we persist the data to the database.
*
* These actions don't block the main thread and allows the proxy to continue
* processing incoming requests and outgoing responses.
*/
// Calculate usage and cost
const rawUsage = responseBody?.usage || {};
const normalizedUsage = normalizeUsage(rawUsage, provider);
const model = requestJson?.model || "default";
const totalCost = calculateCost(model, {
prompt_tokens: normalizedUsage.prompt_tokens || 0,
completion_tokens: normalizedUsage.completion_tokens || 0,
prompt_tokens_details: normalizedUsage.cached_tokens ? { cached_tokens: normalizedUsage.cached_tokens } : undefined,
});
// Prepare data for database persistence
const logData = {
timestamp: new Date(),
request_method: method,
request_path: path,
model: (requestJson?.model || "default").toLowerCase(),
completion_tokens: usage.completion_tokens || null,
prompt_tokens: usage.prompt_tokens || null,
total_tokens: usage.total_tokens || null,
cached_tokens: usage.prompt_tokens_details?.cached_tokens || null,
request_path: upstreamPath,
provider: provider,
model: model.toLowerCase(),
completion_tokens: normalizedUsage.completion_tokens,
prompt_tokens: normalizedUsage.prompt_tokens,
total_tokens: normalizedUsage.total_tokens,
cached_tokens: normalizedUsage.cached_tokens,
total_cost: totalCost,
response_time: Date.now() - start,
response_time: Date.now() - startTime,
request_body: requestJson,
response_body: responseBody,
response_status: upstreamRes.status,
provider_url: UPSTREAM_URL,
client_ip: normalizeIp(req.socket?.remoteAddress),
provider_url: config.upstreamUrl,
client_ip: normalizeIP(req.socket?.remoteAddress),
user_agent: req.headers["user-agent"] || null,
request_size: bodyBuf.length,
response_size: Buffer.from(JSON.stringify(responseBody)).length,
stream: contentType.includes("text/event-stream"),
stream: isStreaming,
temperature: requestJson?.temperature || null,
max_tokens: requestJson?.max_tokens || null,
request_id: generateRequestId(),
request_id: uuidv4(),
};
logToPG(logData).catch(err => console.error("PG log error:", err));
// Persist data to the database
persistDatabaseRecord(logData).catch(err => console.error("Database persistence error:", err));
} catch (err: any) {
console.error("Proxy error:", err);
console.error("Internal server error:", err);
res.statusCode = 502;
res.end(JSON.stringify({ error: "Proxy error", message: err?.message }));
res.end(JSON.stringify({ error: "INTERNAL_SERVER_ERROR", message: err?.message }));
}
});
server.listen(PORT, () => {
console.log(`✅ Proxy running at http://localhost:${PORT}`);
/**
* Starts the proxy server.
*
* Loads Helicone cost data before starting the server to ensure
* accurate cost calculations are available for all requests.
*
* @param port: The port number to listen on.
* @returns The proxy server.
*/
async function startServer() {
console.log(`\n\x1b[32mOpenProxy starting...\x1b[0m\n`);
await loadHeliconeCosts();
server.listen(PORT, () => {
console.log(`\n\x1b[32mOpenProxy upstream connections activated ⟣⟢\x1b[0m\n`);
if (OPENAI_UPSTREAM_URL) console.log(`\x1b[34m 📡 http://localhost:${PORT}/openai/* → ${OPENAI_UPSTREAM_URL}\x1b[0m`);
if (ANTHROPIC_UPSTREAM_URL) console.log(`\x1b[34m 📡 http://localhost:${PORT}/anthropic/* → ${ANTHROPIC_UPSTREAM_URL}\x1b[0m\n`);
});
}
startServer().catch((err) => {
console.error("Failed to start server:", err);
process.exit(1);
});