Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions configs/config.json.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"PROMPT_LOG_MODE": "none",
"REQUEST_MAX_RETRIES": 3,
"REQUEST_BASE_DELAY": 1000,
"REQUEST_MAX_RETRY_TIME_MS": 30000,
"CRON_NEAR_MINUTES": 1,
"CRON_REFRESH_TOKEN": false,
"PROVIDER_POOLS_FILE_PATH": "configs/provider_pools.json",
Expand Down
287 changes: 287 additions & 0 deletions src/providers/lmarena/lmarena-core.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
/**
* LMArenaBridge Provider Core
*
* Forwards OpenAI-format chat requests to a running LMArenaBridge Python sidecar.
* LMArenaBridge (https://github.com/CloudWaddie/LMArenaBridge) exposes every model
* available on LMArena's platform (GPT-5, Claude Opus 4+, Gemini 3 Pro, etc.) as a
* single OpenAI-compatible endpoint.
*
* Configuration keys (per pool node):
* LMARENA_BRIDGE_URL Required. URL of the running LMArenaBridge sidecar.
* e.g. "http://localhost:8000"
* LMARENA_BRIDGE_API_KEY Optional. API key if the bridge requires authentication.
* LMARENA_MODEL_OVERRIDE Optional. Force a specific LMArena model for all requests.
*
* Shared retry settings (global config):
* REQUEST_MAX_RETRIES Max number of retry attempts (default: 3).
* REQUEST_BASE_DELAY Base delay in ms for exponential back-off (default: 1000).
* REQUEST_MAX_RETRY_TIME_MS Cap on total retry time in ms; retries stop once this is
* exceeded regardless of REQUEST_MAX_RETRIES (default: 30000).
*
* Setup:
* pip install lmarenabridge camoufox
* python -m lmarenabridge # or: lmarena-bridge --port 8000
*/

import axios from 'axios';
import logger from '../../utils/logger.js';
import * as http from 'http';
import * as https from 'https';
import { configureAxiosProxy, configureTLSSidecar } from '../../utils/proxy-utils.js';
import { isRetryableNetworkError, MODEL_PROVIDER } from '../../utils/common.js';
import { PROVIDER_MODELS } from '../provider-models.js';

const LMARENA_HEALTH_TIMEOUT_MS = 5000;
const LMARENA_REQUEST_TIMEOUT_MS = 120000;

// Single source of truth for the model list lives in provider-models.js.
const _lmarenaModels = PROVIDER_MODELS['lmarena-bridge'];
if (!_lmarenaModels || _lmarenaModels.length === 0) {
logger.warn('[LMArena] No models found for lmarena-bridge in PROVIDER_MODELS. Check provider-models.js.');
}
export const LMARENA_MODELS = _lmarenaModels || [];

/**
* Parse an SSE byte stream into JSON objects.
*
* Handles:
* - JSON payloads split across multiple chunks (buffer accumulation)
* - Streams that end without a trailing newline (flush of remaining buffer)
* - The `[DONE]` sentinel that signals end-of-stream
* - Non-JSON `data:` lines (e.g. keep-alive pings) — these are silently skipped
* at debug level so logs stay clean in production
*
* @param {AsyncIterable<Buffer|string>} stream
* @yields {object} Parsed JSON chunks
*/
export async function* parseSSEStream(stream) {
let buffer = '';

for await (const chunk of stream) {
buffer += chunk.toString();
let newlineIndex;
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
const line = buffer.substring(0, newlineIndex).trim();
buffer = buffer.substring(newlineIndex + 1);

if (!line.startsWith('data: ')) continue;
const jsonData = line.substring(6).trim();
if (jsonData === '[DONE]') return;

try {
yield JSON.parse(jsonData);
} catch {
// Non-JSON data lines are intentionally skipped (e.g. SSE comments, keep-alives).
logger.debug('[LMArena] Skipping non-JSON SSE line:', jsonData);
}
}
}

// Flush any remaining buffer content after the stream ends (no trailing newline case)
const remaining = buffer.trim();
if (remaining.length > 0 && remaining.startsWith('data: ')) {
const jsonData = remaining.substring(6).trim();
if (jsonData !== '[DONE]') {
try {
yield JSON.parse(jsonData);
} catch {
logger.debug('[LMArena] Skipping non-JSON SSE line (flush):', jsonData);
}
}
}
}

export class LMArenaApiService {
constructor(config) {
if (!config.LMARENA_BRIDGE_URL) {
throw new Error(
'[LMArena] LMARENA_BRIDGE_URL is required. ' +
'Start the LMArenaBridge sidecar and set this to its base URL (e.g. http://localhost:8000).'
);
}

this.config = config;
this.baseUrl = config.LMARENA_BRIDGE_URL.replace(/\/$/, '');
this.apiKey = config.LMARENA_BRIDGE_API_KEY || null;
this.modelOverride = config.LMARENA_MODEL_OVERRIDE || null;
this.isInitialized = false;

const httpAgent = new http.Agent({ keepAlive: true, maxSockets: 50 });
const httpsAgent = new https.Agent({ keepAlive: true, maxSockets: 50 });

const headers = { 'Content-Type': 'application/json' };
if (this.apiKey) {
headers['Authorization'] = `Bearer ${this.apiKey}`;
}

const axiosConfig = {
baseURL: this.baseUrl,
httpAgent,
httpsAgent,
headers,
proxy: false,
};

configureAxiosProxy(axiosConfig, config, MODEL_PROVIDER.LMARENA_BRIDGE);
this.axiosInstance = axios.create(axiosConfig);
}

_applySidecar(axiosConfig) {
return configureTLSSidecar(axiosConfig, this.config, MODEL_PROVIDER.LMARENA_BRIDGE, this.baseUrl);
}

/**
* Verify that the LMArenaBridge sidecar is reachable.
* Called during pool initialization.
*/
async initialize() {
try {
const axiosConfig = { method: 'get', url: '/health', timeout: LMARENA_HEALTH_TIMEOUT_MS };
this._applySidecar(axiosConfig);
await this.axiosInstance.request(axiosConfig);
this.isInitialized = true;
logger.info(`[LMArena] Sidecar reachable at ${this.baseUrl}`);
} catch (err) {
// Sidecar not running — mark as uninitialized but do not crash.
// Requests will fail gracefully so the pool manager can rotate.
logger.warn(`[LMArena] Sidecar health check failed (${this.baseUrl}): ${err.message}`);
this.isInitialized = false;
}
return this.isInitialized;
}

/**
* Ping the sidecar and update isInitialized status.
*/
async healthCheck() {
try {
const axiosConfig = { method: 'get', url: '/health', timeout: LMARENA_HEALTH_TIMEOUT_MS };
this._applySidecar(axiosConfig);
await this.axiosInstance.request(axiosConfig);
this.isInitialized = true;
return true;
} catch {
this.isInitialized = false;
return false;
}
}

/**
* Map a BAP model name to the LMArena model string.
* "lmarena-auto" lets the bridge pick any available arena model.
*/
_resolveModel(model) {
if (this.modelOverride) return this.modelOverride;
if (!model || model === 'lmarena-auto') return null; // bridge default
return model;
}

async _callApi(body, isStream = false, retryCount = 0, retryStartTime = null) {
if (!this.isInitialized) {
await this.initialize();
if (!this.isInitialized) {
const err = new Error('[LMArena] Sidecar is not available. Is it running?');
err.shouldSwitchCredential = true;
throw err;
}
}

const maxRetries = this.config.REQUEST_MAX_RETRIES ?? 3;
const baseDelay = this.config.REQUEST_BASE_DELAY ?? 1000;
const maxRetryTimeMs = this.config.REQUEST_MAX_RETRY_TIME_MS ?? 30000;
const startTime = retryStartTime ?? Date.now();

try {
const resolvedModel = this._resolveModel(body.model);
const payload = { ...body };
if (resolvedModel !== null) payload.model = resolvedModel;
if (resolvedModel === null) delete payload.model; // bridge picks automatically

const axiosConfig = {
method: 'post',
url: '/v1/chat/completions',
data: payload,
timeout: LMARENA_REQUEST_TIMEOUT_MS,
};
if (isStream) {
axiosConfig.responseType = 'stream';
}
this._applySidecar(axiosConfig);
const response = await this.axiosInstance.request(axiosConfig);
return response;
} catch (error) {
const status = error.response?.status;
const isNetworkError = isRetryableNetworkError(error);
const elapsed = Date.now() - startTime;

if ((status === 503 || isNetworkError) && retryCount < maxRetries && elapsed < maxRetryTimeMs) {
const delay = baseDelay * Math.pow(2, retryCount);
logger.warn(`[LMArena] Retrying (attempt ${retryCount + 1}/${maxRetries}) after ${delay}ms`);
await new Promise(r => setTimeout(r, delay));
return this._callApi(body, isStream, retryCount + 1, startTime);
}

if (status === 429 || status === 401 || status === 403) {
error.shouldSwitchCredential = true;
}
if (status >= 500 && status < 600 && retryCount < maxRetries && elapsed < maxRetryTimeMs) {
const delay = baseDelay * Math.pow(2, retryCount);
logger.warn(`[LMArena] Server error ${status}, retrying in ${delay}ms`);
await new Promise(r => setTimeout(r, delay));
return this._callApi(body, isStream, retryCount + 1, startTime);
}

logger.error(`[LMArena] API error (status=${status || error.code}): ${error.message}`);
throw error;
}
}

async generateContent(model, requestBody) {
// Strip internal BAP metadata fields
const body = { ...requestBody, model };
delete body._monitorRequestId;
delete body._requestBaseUrl;

const response = await this._callApi(body, false);
return response.data;
}

async *generateContentStream(model, requestBody) {
const body = { ...requestBody, model };
delete body._monitorRequestId;
delete body._requestBaseUrl;

const response = await this._callApi(body, true);
yield* parseSSEStream(response.data);
}

async listModels() {
try {
const axiosConfig = {
method: 'get',
url: '/v1/models',
timeout: LMARENA_HEALTH_TIMEOUT_MS,
};
this._applySidecar(axiosConfig);
const response = await this.axiosInstance.request(axiosConfig);
return response.data;
} catch (err) {
logger.warn(`[LMArena] listModels failed: ${err.message}`);
// Fallback: return static model list
return {
object: 'list',
data: LMARENA_MODELS.map(id => ({
id,
object: 'model',
created: 0,
owned_by: 'lmarena-bridge',
})),
};
}
}

isExpiryDateNear() {
// LMArenaBridge manages its own token refresh internally.
return false;
}
}
11 changes: 11 additions & 0 deletions src/providers/lmarena/lmarena-strategy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* LMArena Provider Strategy
*
* Uses the OpenAI protocol strategy since LMArenaBridge exposes a
* fully OpenAI-compatible /v1/chat/completions endpoint.
*
* We re-export OpenAIStrategy so the gateway routing layer knows
* this provider speaks OpenAI protocol.
*/

export { OpenAIStrategy as LMArenaStrategy } from '../openai/openai-strategy.js';
19 changes: 19 additions & 0 deletions src/providers/provider-models.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,25 @@ export const PROVIDER_MODELS = {
'grok-imagine-1.0-edit',
'grok-imagine-1.0-fast',
'grok-imagine-1.0-fast-edit',
],
'lmarena-bridge': [
'lmarena-auto',
'gpt-5',
'gpt-5-mini',
'claude-opus-4-7',
'claude-opus-4-6',
'claude-sonnet-4-6',
'claude-opus-4-5',
'gemini-3-pro',
'gemini-3-flash',
'gemini-2.5-pro',
'gemini-2.5-flash',
'grok-4.20',
'grok-3',
'deepseek-r2',
'llama-4-scout',
'llama-4-maverick',
'mistral-large-3',
]
};

Expand Down
1 change: 1 addition & 0 deletions src/utils/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,5 +65,6 @@ export const MODEL_PROVIDER = {
CODEX_API: 'openai-codex-oauth',
FORWARD_API: 'forward-api',
GROK_CUSTOM: 'grok-custom',
LMARENA_BRIDGE: 'lmarena-bridge',
AUTO: 'auto',
};
Loading
Loading