mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 16:14:31 +00:00
feat(proxy): complete openai routing scope
This commit is contained in:
1 parent
b346693c3a
commit
17187c4abd
24 files changed
+1819
-53
No files matched your search
@@ -4,6 +4,7 @@ import { startOpenAICompatProxyServer } from './server/proxy-server';
|
||||
|
||||
interface RuntimeOptions {
|
||||
port: number;
|
||||
host: string;
|
||||
profileName: string;
|
||||
settingsPath: string;
|
||||
authToken: string;
|
||||
@@ -12,6 +13,7 @@ interface RuntimeOptions {
|
||||
|
||||
function parseArgs(argv: string[]): RuntimeOptions {
|
||||
let port = 3456;
|
||||
let host = '127.0.0.1';
|
||||
let profileName = '';
|
||||
let settingsPath = '';
|
||||
let authToken = '';
|
||||
@@ -23,6 +25,10 @@ function parseArgs(argv: string[]): RuntimeOptions {
|
||||
port = Number.parseInt(argv[++i] || '', 10) || port;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--host' && argv[i + 1]) {
|
||||
host = argv[++i] || host;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--profile' && argv[i + 1]) {
|
||||
profileName = argv[++i] || '';
|
||||
continue;
|
||||
@@ -40,7 +46,7 @@ function parseArgs(argv: string[]): RuntimeOptions {
|
||||
}
|
||||
}
|
||||
|
||||
return { port, profileName, settingsPath, authToken, insecure };
|
||||
return { port, host, profileName, settingsPath, authToken, insecure };
|
||||
}
|
||||
|
||||
function startRuntime(options: RuntimeOptions): void {
|
||||
@@ -62,6 +68,7 @@ function startRuntime(options: RuntimeOptions): void {
|
||||
|
||||
const server = startOpenAICompatProxyServer({
|
||||
profile,
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
authToken: options.authToken,
|
||||
insecure: options.insecure,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
export interface OpenAICompatProxySession {
|
||||
profileName: string;
|
||||
settingsPath: string;
|
||||
host: string;
|
||||
port: number;
|
||||
baseUrl: string;
|
||||
authToken: string;
|
||||
|
||||
@@ -225,10 +225,11 @@ export async function stopOpenAICompatProxy(): Promise<{ success: boolean; error
|
||||
|
||||
export async function startOpenAICompatProxy(
|
||||
profile: OpenAICompatProfileConfig,
|
||||
options: { port?: number; insecure?: boolean } = {}
|
||||
options: { port?: number; host?: string; insecure?: boolean } = {}
|
||||
): Promise<StartOpenAICompatProxyResult> {
|
||||
return withOpenAICompatProxyLock(async () => {
|
||||
const status = await getOpenAICompatProxyStatus();
|
||||
const host = options.host?.trim() || status.host || '127.0.0.1';
|
||||
const port =
|
||||
typeof options.port === 'number'
|
||||
? options.port
|
||||
@@ -245,7 +246,12 @@ export async function startOpenAICompatProxy(
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
return { success: false, port, error: `Invalid port: ${port}` };
|
||||
}
|
||||
if (status.running && status.profileName === profile.profileName && status.port === port) {
|
||||
if (
|
||||
status.running &&
|
||||
status.profileName === profile.profileName &&
|
||||
status.port === port &&
|
||||
(status.host || '127.0.0.1') === host
|
||||
) {
|
||||
return {
|
||||
success: true,
|
||||
alreadyRunning: true,
|
||||
@@ -304,6 +310,8 @@ export async function startOpenAICompatProxy(
|
||||
daemonEntry,
|
||||
'--port',
|
||||
String(port),
|
||||
'--host',
|
||||
host,
|
||||
'--profile',
|
||||
profile.profileName,
|
||||
'--settings-path',
|
||||
@@ -323,6 +331,7 @@ export async function startOpenAICompatProxy(
|
||||
writeOpenAICompatProxySession({
|
||||
profileName: profile.profileName,
|
||||
settingsPath: profile.settingsPath,
|
||||
host,
|
||||
port,
|
||||
baseUrl: profile.baseUrl,
|
||||
authToken,
|
||||
|
||||
@@ -4,11 +4,17 @@ export function buildOpenAICompatProxyEnv(
|
||||
profile: OpenAICompatProfileConfig,
|
||||
port: number,
|
||||
authToken: string,
|
||||
claudeConfigDir?: string
|
||||
claudeConfigDir?: string,
|
||||
host = '127.0.0.1'
|
||||
): Record<string, string> {
|
||||
const localBaseUrl = `http://${host}:${port}`;
|
||||
return {
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`,
|
||||
ANTHROPIC_BASE_URL: localBaseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: authToken,
|
||||
DISABLE_TELEMETRY: '1',
|
||||
DISABLE_COST_WARNINGS: '1',
|
||||
API_TIMEOUT_MS: '600000',
|
||||
NO_PROXY: host === '127.0.0.1' ? '127.0.0.1,localhost' : `${host},127.0.0.1,localhost`,
|
||||
...(profile.model ? { ANTHROPIC_MODEL: profile.model } : {}),
|
||||
...(profile.opusModel ? { ANTHROPIC_DEFAULT_OPUS_MODEL: profile.opusModel } : {}),
|
||||
...(profile.sonnetModel ? { ANTHROPIC_DEFAULT_SONNET_MODEL: profile.sonnetModel } : {}),
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { loadConfigSafe, loadSettings } from '../utils/config-manager';
|
||||
import { expandPath } from '../utils/helpers';
|
||||
import type { ProxyOpenAIRequest } from './transformers/request-transformer';
|
||||
import {
|
||||
loadOpenAICompatProxyRoutingConfig,
|
||||
type OpenAICompatProxyRoutingConfig,
|
||||
} from './routing-config';
|
||||
import { resolveOpenAICompatProfileConfig, type OpenAICompatProfileConfig } from './profile-router';
|
||||
|
||||
export type ProxyRoutingScenario = 'default' | 'background' | 'think' | 'longContext' | 'webSearch';
|
||||
|
||||
export interface ProxyRequestRoute {
|
||||
profile: OpenAICompatProfileConfig;
|
||||
model?: string;
|
||||
scenario?: ProxyRoutingScenario;
|
||||
estimatedTokens: number;
|
||||
source:
|
||||
| 'explicit-profile'
|
||||
| 'scenario'
|
||||
| 'profile-model-match'
|
||||
| 'profile-name'
|
||||
| 'request-model'
|
||||
| 'active-default';
|
||||
}
|
||||
|
||||
function loadOpenAICompatProfiles(
|
||||
activeProfile: OpenAICompatProfileConfig
|
||||
): OpenAICompatProfileConfig[] {
|
||||
const config = loadConfigSafe();
|
||||
const profiles = [activeProfile];
|
||||
|
||||
for (const [profileName, settingsPath] of Object.entries(config.profiles)) {
|
||||
if (profileName === activeProfile.profileName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const expandedPath = expandPath(settingsPath);
|
||||
const settings = loadSettings(expandedPath);
|
||||
const profile = resolveOpenAICompatProfileConfig(
|
||||
profileName,
|
||||
expandedPath,
|
||||
settings.env || {}
|
||||
);
|
||||
if (profile) {
|
||||
profiles.push(profile);
|
||||
}
|
||||
} catch {
|
||||
// Ignore invalid profiles while routing a live request.
|
||||
}
|
||||
}
|
||||
|
||||
return profiles;
|
||||
}
|
||||
|
||||
function resolveSelectorTarget(
|
||||
selector: string,
|
||||
activeProfile: OpenAICompatProfileConfig,
|
||||
profiles: OpenAICompatProfileConfig[]
|
||||
): { profile: OpenAICompatProfileConfig; model?: string; explicitProfile: boolean } | null {
|
||||
const trimmed = selector.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const colonIndex = trimmed.indexOf(':');
|
||||
if (colonIndex > 0) {
|
||||
const profileName = trimmed.slice(0, colonIndex).trim();
|
||||
const profile = profiles.find((candidate) => candidate.profileName === profileName);
|
||||
if (profile) {
|
||||
const model = trimmed.slice(colonIndex + 1).trim() || profile.model;
|
||||
return { profile, model, explicitProfile: true };
|
||||
}
|
||||
}
|
||||
|
||||
const namedProfile = profiles.find((candidate) => candidate.profileName === trimmed);
|
||||
if (namedProfile) {
|
||||
return { profile: namedProfile, model: namedProfile.model, explicitProfile: false };
|
||||
}
|
||||
|
||||
return {
|
||||
profile: activeProfile,
|
||||
model: trimmed,
|
||||
explicitProfile: false,
|
||||
};
|
||||
}
|
||||
|
||||
function profileSupportsModel(profile: OpenAICompatProfileConfig, model: string): boolean {
|
||||
return [profile.model, profile.opusModel, profile.sonnetModel, profile.haikuModel].some(
|
||||
(candidate) => typeof candidate === 'string' && candidate === model
|
||||
);
|
||||
}
|
||||
|
||||
function estimateTokens(request: ProxyOpenAIRequest): number {
|
||||
let characters = 0;
|
||||
for (const message of request.messages) {
|
||||
if (typeof message.content === 'string') {
|
||||
characters += message.content.length;
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(message.content)) {
|
||||
for (const part of message.content) {
|
||||
characters += part.type === 'text' ? part.text.length : part.image_url.url.length;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(message.tool_calls)) {
|
||||
for (const toolCall of message.tool_calls) {
|
||||
characters += toolCall.function.name.length + toolCall.function.arguments.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(request.tools)) {
|
||||
characters += JSON.stringify(request.tools).length;
|
||||
}
|
||||
|
||||
return Math.max(1, Math.ceil(characters / 4));
|
||||
}
|
||||
|
||||
function detectScenario(
|
||||
request: ProxyOpenAIRequest,
|
||||
requestedModel: string | undefined,
|
||||
routing: OpenAICompatProxyRoutingConfig
|
||||
): { scenario?: ProxyRoutingScenario; selector?: string; estimatedTokens: number } {
|
||||
const estimatedTokens = estimateTokens(request);
|
||||
const hasWebSearchTool =
|
||||
request.tools?.some((tool) => tool.function.name === 'web_search') === true;
|
||||
const thinkingEnabled =
|
||||
request.reasoning?.enabled === true || typeof request.reasoning_effort === 'string';
|
||||
const modelId = requestedModel || '';
|
||||
const longContextThreshold = routing.longContextThreshold ?? 60_000;
|
||||
|
||||
if (hasWebSearchTool && routing.webSearch) {
|
||||
return { scenario: 'webSearch', selector: routing.webSearch, estimatedTokens };
|
||||
}
|
||||
if (thinkingEnabled && routing.think) {
|
||||
return { scenario: 'think', selector: routing.think, estimatedTokens };
|
||||
}
|
||||
if (estimatedTokens > longContextThreshold && routing.longContext) {
|
||||
return { scenario: 'longContext', selector: routing.longContext, estimatedTokens };
|
||||
}
|
||||
if (modelId.toLowerCase().includes('haiku') && routing.background) {
|
||||
return { scenario: 'background', selector: routing.background, estimatedTokens };
|
||||
}
|
||||
if (!requestedModel && routing.default) {
|
||||
return { scenario: 'default', selector: routing.default, estimatedTokens };
|
||||
}
|
||||
|
||||
return { estimatedTokens };
|
||||
}
|
||||
|
||||
export function resolveProxyRequestRoute(
|
||||
activeProfile: OpenAICompatProfileConfig,
|
||||
request: ProxyOpenAIRequest
|
||||
): ProxyRequestRoute {
|
||||
const profiles = loadOpenAICompatProfiles(activeProfile);
|
||||
const requestedModel = request.model?.trim() || undefined;
|
||||
const explicitTarget = requestedModel
|
||||
? resolveSelectorTarget(requestedModel, activeProfile, profiles)
|
||||
: null;
|
||||
const routing = loadOpenAICompatProxyRoutingConfig();
|
||||
|
||||
if (explicitTarget?.explicitProfile) {
|
||||
return {
|
||||
profile: explicitTarget.profile,
|
||||
model: explicitTarget.model,
|
||||
estimatedTokens: estimateTokens(request),
|
||||
source: 'explicit-profile',
|
||||
};
|
||||
}
|
||||
|
||||
const scenario = detectScenario(request, requestedModel, routing);
|
||||
if (scenario.selector) {
|
||||
const scenarioTarget = resolveSelectorTarget(scenario.selector, activeProfile, profiles);
|
||||
if (scenarioTarget) {
|
||||
return {
|
||||
profile: scenarioTarget.profile,
|
||||
model: scenarioTarget.model,
|
||||
scenario: scenario.scenario,
|
||||
estimatedTokens: scenario.estimatedTokens,
|
||||
source: 'scenario',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (explicitTarget && explicitTarget.profile.profileName !== activeProfile.profileName) {
|
||||
return {
|
||||
profile: explicitTarget.profile,
|
||||
model: explicitTarget.model,
|
||||
estimatedTokens: scenario.estimatedTokens,
|
||||
source: 'profile-name',
|
||||
};
|
||||
}
|
||||
|
||||
if (requestedModel) {
|
||||
const matchedProfile = profiles.find((profile) =>
|
||||
profileSupportsModel(profile, requestedModel)
|
||||
);
|
||||
if (matchedProfile) {
|
||||
return {
|
||||
profile: matchedProfile,
|
||||
model: requestedModel,
|
||||
estimatedTokens: scenario.estimatedTokens,
|
||||
source: 'profile-model-match',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
profile: activeProfile,
|
||||
model: requestedModel,
|
||||
estimatedTokens: scenario.estimatedTokens,
|
||||
source: 'request-model',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
profile: activeProfile,
|
||||
model: activeProfile.model,
|
||||
estimatedTokens: scenario.estimatedTokens,
|
||||
source: 'active-default',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import * as fs from 'fs';
|
||||
import * as yaml from 'js-yaml';
|
||||
import { getActiveConfigPath, getConfigPath } from '../utils/config-manager';
|
||||
|
||||
export interface OpenAICompatProxyRoutingConfig {
|
||||
default?: string;
|
||||
background?: string;
|
||||
think?: string;
|
||||
longContext?: string;
|
||||
webSearch?: string;
|
||||
longContextThreshold?: number;
|
||||
}
|
||||
|
||||
function readRawConfigObject(configPath: string): Record<string, unknown> | null {
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(configPath, 'utf8');
|
||||
const parsed =
|
||||
configPath.endsWith('.yaml') || configPath.endsWith('.yml') ? yaml.load(raw) : JSON.parse(raw);
|
||||
return typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function normalizeRoutingConfig(value: unknown): OpenAICompatProxyRoutingConfig {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const config = value as Record<string, unknown>;
|
||||
return {
|
||||
default:
|
||||
typeof config.default === 'string' && config.default.trim()
|
||||
? config.default.trim()
|
||||
: undefined,
|
||||
background:
|
||||
typeof config.background === 'string' && config.background.trim()
|
||||
? config.background.trim()
|
||||
: undefined,
|
||||
think:
|
||||
typeof config.think === 'string' && config.think.trim() ? config.think.trim() : undefined,
|
||||
longContext:
|
||||
typeof config.longContext === 'string' && config.longContext.trim()
|
||||
? config.longContext.trim()
|
||||
: undefined,
|
||||
webSearch:
|
||||
typeof config.webSearch === 'string' && config.webSearch.trim()
|
||||
? config.webSearch.trim()
|
||||
: undefined,
|
||||
longContextThreshold:
|
||||
typeof config.longContextThreshold === 'number' &&
|
||||
Number.isFinite(config.longContextThreshold)
|
||||
? config.longContextThreshold
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function loadOpenAICompatProxyRoutingConfig(): OpenAICompatProxyRoutingConfig {
|
||||
const activePath = getActiveConfigPath();
|
||||
const rawConfig = readRawConfigObject(activePath);
|
||||
if (rawConfig?.proxy && typeof rawConfig.proxy === 'object') {
|
||||
return normalizeRoutingConfig((rawConfig.proxy as Record<string, unknown>).routing);
|
||||
}
|
||||
|
||||
const legacyPath = getConfigPath();
|
||||
if (legacyPath !== activePath) {
|
||||
const legacyConfig = readRawConfigObject(legacyPath);
|
||||
if (legacyConfig?.proxy && typeof legacyConfig.proxy === 'object') {
|
||||
return normalizeRoutingConfig((legacyConfig.proxy as Record<string, unknown>).routing);
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import * as http from 'http';
|
||||
import type { Dispatcher } from 'undici';
|
||||
import type { OpenAICompatProfileConfig } from '../profile-router';
|
||||
import { resolveProxyRequestRoute } from '../request-router';
|
||||
import { ProxyRequestTransformer } from '../transformers/request-transformer';
|
||||
import { ProxySseStreamTransformer } from '../transformers/sse-stream-transformer';
|
||||
import { resolveOpenAIChatCompletionsUrl } from '../upstream-url';
|
||||
import { createLogger } from '../../services/logging';
|
||||
import { pipeWebResponseToNode, readJsonBody, writeJson } from './http-helpers';
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 30_000;
|
||||
const REQUEST_TIMEOUT_MS = 600_000;
|
||||
const logger = createLogger('proxy:openai-compat:messages');
|
||||
|
||||
class ProxyInputError extends Error {
|
||||
constructor(message: string) {
|
||||
@@ -23,7 +26,10 @@ function buildUpstreamHeaders(profile: OpenAICompatProfileConfig): Record<string
|
||||
};
|
||||
}
|
||||
|
||||
function buildUpstreamBody(profile: OpenAICompatProfileConfig, rawBody: unknown): string {
|
||||
function buildUpstreamRequest(
|
||||
profile: OpenAICompatProfileConfig,
|
||||
rawBody: unknown
|
||||
): { body: string; route: ReturnType<typeof resolveProxyRequestRoute> } {
|
||||
let transformed;
|
||||
try {
|
||||
const transformer = new ProxyRequestTransformer();
|
||||
@@ -32,12 +38,13 @@ function buildUpstreamBody(profile: OpenAICompatProfileConfig, rawBody: unknown)
|
||||
const message = error instanceof Error ? error.message : 'Invalid Anthropic request';
|
||||
throw new ProxyInputError(message);
|
||||
}
|
||||
const route = resolveProxyRequestRoute(profile, transformed);
|
||||
const body = {
|
||||
...transformed,
|
||||
model: transformed.model || profile.model,
|
||||
model: route.model || route.profile.model,
|
||||
stream: transformed.stream === true,
|
||||
};
|
||||
return JSON.stringify(body);
|
||||
return { body: JSON.stringify(body), route };
|
||||
}
|
||||
|
||||
export function extractIncomingProxyToken(headers: http.IncomingHttpHeaders): string | null {
|
||||
@@ -88,6 +95,20 @@ function buildFetchInit(
|
||||
return init;
|
||||
}
|
||||
|
||||
function getRequestTimeoutMs(): number {
|
||||
const rawValue = process.env.CCS_OPENAI_PROXY_REQUEST_TIMEOUT_MS;
|
||||
if (!rawValue) {
|
||||
return REQUEST_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(rawValue, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : REQUEST_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
function formatTimeoutDuration(timeoutMs: number): string {
|
||||
return timeoutMs % 1000 === 0 ? `${timeoutMs / 1000} seconds` : `${timeoutMs}ms`;
|
||||
}
|
||||
|
||||
export async function handleProxyMessagesRequest(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
@@ -98,6 +119,9 @@ export async function handleProxyMessagesRequest(
|
||||
const transformer = new ProxySseStreamTransformer();
|
||||
|
||||
if (!validateIncomingProxyAuth(req.headers, expectedAuthToken)) {
|
||||
logger.warn('auth.invalid', 'Rejected proxy message request with invalid auth token', {
|
||||
remoteAddress: req.socket.remoteAddress || null,
|
||||
});
|
||||
await pipeWebResponseToNode(
|
||||
transformer.error(401, 'authentication_error', 'Missing or invalid local proxy token'),
|
||||
res
|
||||
@@ -105,34 +129,77 @@ export async function handleProxyMessagesRequest(
|
||||
return;
|
||||
}
|
||||
|
||||
let timeoutMs = REQUEST_TIMEOUT_MS;
|
||||
try {
|
||||
const rawBody = await readJsonBody(req);
|
||||
const upstreamBody = buildUpstreamBody(profile, rawBody);
|
||||
const upstream = buildUpstreamRequest(profile, rawBody);
|
||||
logger.info('request.forward', 'Forwarding Anthropic request to OpenAI-compatible upstream', {
|
||||
profileName: upstream.route.profile.profileName,
|
||||
provider: upstream.route.profile.provider,
|
||||
baseUrl: upstream.route.profile.baseUrl,
|
||||
model: upstream.route.model || upstream.route.profile.model || null,
|
||||
routeSource: upstream.route.source,
|
||||
scenario: upstream.route.scenario || null,
|
||||
estimatedTokens: upstream.route.estimatedTokens,
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
const abortOnDisconnect = () => {
|
||||
timeoutMs = getRequestTimeoutMs();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const abortOnDisconnect = (source: string) => {
|
||||
if (!controller.signal.aborted && !res.writableEnded) {
|
||||
logger.info(
|
||||
'request.disconnect',
|
||||
'Aborting upstream request after local client disconnect',
|
||||
{
|
||||
profileName: profile.profileName,
|
||||
source,
|
||||
}
|
||||
);
|
||||
controller.abort();
|
||||
}
|
||||
};
|
||||
|
||||
req.on('aborted', abortOnDisconnect);
|
||||
req.on('close', abortOnDisconnect);
|
||||
res.on('close', abortOnDisconnect);
|
||||
req.on('aborted', () => abortOnDisconnect('req.aborted'));
|
||||
req.on('close', () => abortOnDisconnect('req.close'));
|
||||
req.socket?.on('close', () => abortOnDisconnect('req.socket.close'));
|
||||
res.on('close', () => abortOnDisconnect('res.close'));
|
||||
res.socket?.on('close', () => abortOnDisconnect('res.socket.close'));
|
||||
const disconnectPoll = setInterval(() => {
|
||||
if (
|
||||
req.destroyed ||
|
||||
res.destroyed ||
|
||||
req.socket?.destroyed === true ||
|
||||
res.socket?.destroyed === true
|
||||
) {
|
||||
abortOnDisconnect('poll.destroyed');
|
||||
}
|
||||
}, 50);
|
||||
|
||||
try {
|
||||
const upstreamResponse = await fetch(
|
||||
resolveOpenAIChatCompletionsUrl(profile.baseUrl),
|
||||
buildFetchInit(profile, upstreamBody, controller.signal, insecureDispatcher)
|
||||
resolveOpenAIChatCompletionsUrl(upstream.route.profile.baseUrl),
|
||||
buildFetchInit(upstream.route.profile, upstream.body, controller.signal, insecureDispatcher)
|
||||
);
|
||||
clearTimeout(timeout);
|
||||
clearInterval(disconnectPoll);
|
||||
logger.info('response.received', 'Received upstream response', {
|
||||
profileName: profile.profileName,
|
||||
routedProfileName: upstream.route.profile.profileName,
|
||||
status: upstreamResponse.status,
|
||||
});
|
||||
const response = await transformer.transform(upstreamResponse);
|
||||
await pipeWebResponseToNode(response, res);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
clearInterval(disconnectPoll);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown proxy error';
|
||||
logger.error('request.failed', 'Proxy message request failed', {
|
||||
profileName: profile.profileName,
|
||||
error: message,
|
||||
abort: error instanceof Error && error.name === 'AbortError',
|
||||
});
|
||||
const status =
|
||||
error instanceof Error && error.name === 'AbortError'
|
||||
? 502
|
||||
@@ -149,7 +216,7 @@ export async function handleProxyMessagesRequest(
|
||||
status,
|
||||
type,
|
||||
error instanceof Error && error.name === 'AbortError'
|
||||
? 'The upstream provider did not respond within 30 seconds'
|
||||
? `The upstream provider did not respond within ${formatTimeoutDuration(timeoutMs)}`
|
||||
: message
|
||||
),
|
||||
res
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as http from 'http';
|
||||
import { Agent } from 'undici';
|
||||
import type { OpenAICompatProfileConfig } from '../profile-router';
|
||||
import { OPENAI_COMPAT_PROXY_SERVICE_NAME } from '../proxy-daemon-paths';
|
||||
import { createLogger } from '../../services/logging';
|
||||
import {
|
||||
handleProxyMessagesRequest,
|
||||
handleProxyModelsRequest,
|
||||
@@ -11,12 +12,19 @@ import { writeJson } from './http-helpers';
|
||||
|
||||
export interface OpenAICompatProxyServerOptions {
|
||||
profile: OpenAICompatProfileConfig;
|
||||
host?: string;
|
||||
port: number;
|
||||
authToken: string;
|
||||
insecure?: boolean;
|
||||
}
|
||||
|
||||
export function startOpenAICompatProxyServer(options: OpenAICompatProxyServerOptions): http.Server {
|
||||
const host = options.host?.trim() || '127.0.0.1';
|
||||
const logger = createLogger('proxy:openai-compat', {
|
||||
profileName: options.profile.profileName,
|
||||
host,
|
||||
port: options.port,
|
||||
});
|
||||
const insecureDispatcher = options.insecure
|
||||
? new Agent({ connect: { rejectUnauthorized: false } })
|
||||
: undefined;
|
||||
@@ -31,12 +39,31 @@ export function startOpenAICompatProxyServer(options: OpenAICompatProxyServerOpt
|
||||
writeJson(res, 200, {
|
||||
ok: true,
|
||||
service: OPENAI_COMPAT_PROXY_SERVICE_NAME,
|
||||
host,
|
||||
profile: options.profile.profileName,
|
||||
port: options.port,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'GET' && pathname === '/') {
|
||||
writeJson(res, 200, {
|
||||
ok: true,
|
||||
service: OPENAI_COMPAT_PROXY_SERVICE_NAME,
|
||||
bind: {
|
||||
host,
|
||||
port: options.port,
|
||||
},
|
||||
profile: {
|
||||
name: options.profile.profileName,
|
||||
provider: options.profile.provider,
|
||||
model: options.profile.model || null,
|
||||
},
|
||||
endpoints: ['/health', '/v1/messages', '/v1/models'],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'GET' && pathname === '/v1/models') {
|
||||
if (!validateIncomingProxyAuth(req.headers, options.authToken)) {
|
||||
writeJson(res, 401, {
|
||||
@@ -63,13 +90,21 @@ export function startOpenAICompatProxyServer(options: OpenAICompatProxyServerOpt
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn('http.not_found', 'Rejected unknown proxy route', {
|
||||
method,
|
||||
pathname,
|
||||
});
|
||||
writeJson(res, 404, { error: 'Not found' });
|
||||
});
|
||||
|
||||
logger.info('server.start', 'OpenAI-compatible proxy server listening', {
|
||||
baseUrl: `http://${host}:${options.port}`,
|
||||
});
|
||||
server.on('close', () => {
|
||||
logger.info('server.stop', 'OpenAI-compatible proxy server stopped');
|
||||
void insecureDispatcher?.close();
|
||||
});
|
||||
|
||||
server.listen(options.port, '127.0.0.1');
|
||||
server.listen(options.port, host);
|
||||
return server;
|
||||
}
|
||||
@@ -1,20 +1,97 @@
|
||||
import { translateAnthropicRequest } from '../../cursor/cursor-anthropic-translator';
|
||||
interface AnthropicThinking {
|
||||
type?: 'enabled' | 'disabled' | string;
|
||||
budget_tokens?: number;
|
||||
}
|
||||
|
||||
interface AnthropicTextBlock {
|
||||
type: 'text';
|
||||
text?: string;
|
||||
}
|
||||
|
||||
interface AnthropicImageBlock {
|
||||
type: 'image';
|
||||
source?: {
|
||||
type?: string;
|
||||
media_type?: string;
|
||||
data?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface AnthropicToolUseBlock {
|
||||
type: 'tool_use';
|
||||
id?: string;
|
||||
name?: string;
|
||||
input?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface AnthropicToolResultBlock {
|
||||
type: 'tool_result';
|
||||
tool_use_id?: string;
|
||||
content?: unknown;
|
||||
}
|
||||
|
||||
type AnthropicContentBlock =
|
||||
| AnthropicTextBlock
|
||||
| AnthropicImageBlock
|
||||
| AnthropicToolUseBlock
|
||||
| AnthropicToolResultBlock
|
||||
| { type: string; [key: string]: unknown };
|
||||
|
||||
interface AnthropicMessage {
|
||||
role?: 'user' | 'assistant' | string;
|
||||
content?: string | AnthropicContentBlock[];
|
||||
}
|
||||
|
||||
interface AnthropicProxyRequestShape {
|
||||
model?: unknown;
|
||||
system?: unknown;
|
||||
messages?: unknown;
|
||||
max_tokens?: unknown;
|
||||
temperature?: unknown;
|
||||
top_p?: unknown;
|
||||
stop_sequences?: unknown;
|
||||
metadata?: unknown;
|
||||
tools?: unknown;
|
||||
stream?: unknown;
|
||||
thinking?: AnthropicThinking;
|
||||
}
|
||||
|
||||
export interface ProxyOpenAIRequest extends ReturnType<typeof translateAnthropicRequest> {
|
||||
max_tokens?: number;
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
stop?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
interface OpenAITextPart {
|
||||
type: 'text';
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface OpenAIImagePart {
|
||||
type: 'image_url';
|
||||
image_url: {
|
||||
url: string;
|
||||
};
|
||||
}
|
||||
|
||||
type OpenAIContentPart = OpenAITextPart | OpenAIImagePart;
|
||||
|
||||
interface OpenAIMessage {
|
||||
role: 'system' | 'user' | 'assistant' | 'tool';
|
||||
content: string | OpenAIContentPart[] | null;
|
||||
tool_call_id?: string;
|
||||
tool_calls?: Array<{
|
||||
id: string;
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
arguments: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ProxyOpenAIRequest {
|
||||
model?: string;
|
||||
stream: boolean;
|
||||
reasoning_effort?: string;
|
||||
reasoning?: {
|
||||
enabled: boolean;
|
||||
effort: string;
|
||||
};
|
||||
tools?: Array<{
|
||||
type: 'function';
|
||||
function: {
|
||||
@@ -23,6 +100,23 @@ export interface ProxyOpenAIRequest extends ReturnType<typeof translateAnthropic
|
||||
parameters: Record<string, unknown>;
|
||||
};
|
||||
}>;
|
||||
messages: OpenAIMessage[];
|
||||
max_tokens?: number;
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
stop?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const TOOL_RESULT_SERIALIZATION_FALLBACK = '[unserializable content]';
|
||||
const TOOL_USE_ARGUMENTS_FALLBACK = '{}';
|
||||
|
||||
function assertObject(value: unknown, label: string): Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
throw new Error(`${label} must be an object`);
|
||||
}
|
||||
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | undefined {
|
||||
@@ -46,6 +140,90 @@ function asMetadata(value: unknown): Record<string, unknown> | undefined {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function safeJsonStringify(value: unknown, fallback: string): string {
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
return typeof serialized === 'string' ? serialized : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function flattenTextContent(content: unknown, label: string): string {
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
throw new Error(`${label} must be a string or content block array`);
|
||||
}
|
||||
|
||||
return content
|
||||
.map((block, index) => {
|
||||
const parsed = assertObject(block, `${label}[${index}]`);
|
||||
if (parsed.type !== 'text') {
|
||||
throw new Error(`${label}[${index}].type "${String(parsed.type)}" is not supported`);
|
||||
}
|
||||
return typeof parsed.text === 'string' ? parsed.text : '';
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function toToolResultContent(content: unknown, label: string): string {
|
||||
if (content === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
return flattenTextContent(content, label);
|
||||
}
|
||||
return safeJsonStringify(content, TOOL_RESULT_SERIALIZATION_FALLBACK);
|
||||
}
|
||||
|
||||
function createFallbackToolId(messageIndex: number, blockIndex: number): string {
|
||||
return `toolu_proxy_fallback_${messageIndex}_${blockIndex}`;
|
||||
}
|
||||
|
||||
function toImagePart(block: AnthropicImageBlock, label: string): OpenAIImagePart {
|
||||
const source = block.source;
|
||||
if (!source || source.type !== 'base64' || !source.media_type || !source.data) {
|
||||
throw new Error(`${label}.source must be a base64 image payload`);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: `data:${source.media_type};base64,${source.data}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isImageBlock(block: AnthropicContentBlock): block is AnthropicImageBlock {
|
||||
return block.type === 'image';
|
||||
}
|
||||
|
||||
function isToolUseBlock(block: AnthropicContentBlock): block is AnthropicToolUseBlock {
|
||||
return block.type === 'tool_use';
|
||||
}
|
||||
|
||||
function isToolResultBlock(block: AnthropicContentBlock): block is AnthropicToolResultBlock {
|
||||
return block.type === 'tool_result';
|
||||
}
|
||||
|
||||
function flushUserContent(messages: OpenAIMessage[], parts: OpenAIContentPart[]): void {
|
||||
if (parts.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onlyText = parts.every((part) => part.type === 'text');
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: onlyText ? parts.map((part) => (part as OpenAITextPart).text).join('\n') : [...parts],
|
||||
});
|
||||
parts.length = 0;
|
||||
}
|
||||
|
||||
function transformTools(value: unknown): ProxyOpenAIRequest['tools'] {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
@@ -71,19 +249,179 @@ function transformTools(value: unknown): ProxyOpenAIRequest['tools'] {
|
||||
return tools.length > 0 ? tools : undefined;
|
||||
}
|
||||
|
||||
function mapThinkingToReasoning(
|
||||
thinking: AnthropicThinking | undefined
|
||||
): Pick<ProxyOpenAIRequest, 'reasoning' | 'reasoning_effort'> {
|
||||
if (!thinking || thinking.type === 'disabled') {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (thinking.type !== 'enabled') {
|
||||
throw new Error('thinking.type must be "enabled" or "disabled"');
|
||||
}
|
||||
|
||||
const effort =
|
||||
typeof thinking.budget_tokens === 'number' && thinking.budget_tokens >= 8192
|
||||
? 'high'
|
||||
: 'medium';
|
||||
|
||||
return {
|
||||
reasoning_effort: effort,
|
||||
reasoning: {
|
||||
enabled: true,
|
||||
effort,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function transformMessages(messagesValue: unknown): OpenAIMessage[] {
|
||||
if (!Array.isArray(messagesValue)) {
|
||||
throw new Error('messages must be an array');
|
||||
}
|
||||
|
||||
const translatedMessages: OpenAIMessage[] = [];
|
||||
|
||||
messagesValue.forEach((message, messageIndex) => {
|
||||
const parsedMessage = assertObject(message, `messages[${messageIndex}]`) as AnthropicMessage;
|
||||
const role = parsedMessage.role;
|
||||
if (role !== 'user' && role !== 'assistant') {
|
||||
throw new Error(`messages[${messageIndex}].role must be "user" or "assistant"`);
|
||||
}
|
||||
|
||||
const content = parsedMessage.content;
|
||||
if (typeof content === 'string') {
|
||||
translatedMessages.push({ role, content });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
throw new Error(`messages[${messageIndex}].content must be a string or array`);
|
||||
}
|
||||
|
||||
const userParts: OpenAIContentPart[] = [];
|
||||
const assistantTextParts: string[] = [];
|
||||
const toolCalls: NonNullable<OpenAIMessage['tool_calls']> = [];
|
||||
let sawToolResult = false;
|
||||
|
||||
content.forEach((block, blockIndex) => {
|
||||
const parsed = assertObject(
|
||||
block,
|
||||
`messages[${messageIndex}].content[${blockIndex}]`
|
||||
) as AnthropicContentBlock;
|
||||
|
||||
if (parsed.type === 'text') {
|
||||
const text = typeof parsed.text === 'string' ? parsed.text : '';
|
||||
if (role === 'user') {
|
||||
userParts.push({ type: 'text', text });
|
||||
} else {
|
||||
assistantTextParts.push(text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isImageBlock(parsed)) {
|
||||
if (role !== 'user') {
|
||||
throw new Error(
|
||||
`messages[${messageIndex}].content[${blockIndex}] image requires user role`
|
||||
);
|
||||
}
|
||||
userParts.push(toImagePart(parsed, `messages[${messageIndex}].content[${blockIndex}]`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isToolUseBlock(parsed)) {
|
||||
if (role !== 'assistant') {
|
||||
throw new Error(
|
||||
`messages[${messageIndex}].content[${blockIndex}] tool_use requires assistant role`
|
||||
);
|
||||
}
|
||||
toolCalls.push({
|
||||
id:
|
||||
typeof parsed.id === 'string' && parsed.id.length > 0
|
||||
? parsed.id
|
||||
: createFallbackToolId(messageIndex, blockIndex),
|
||||
type: 'function',
|
||||
function: {
|
||||
name: typeof parsed.name === 'string' ? parsed.name : 'tool',
|
||||
arguments: safeJsonStringify(parsed.input ?? {}, TOOL_USE_ARGUMENTS_FALLBACK),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isToolResultBlock(parsed)) {
|
||||
if (role !== 'user') {
|
||||
throw new Error(
|
||||
`messages[${messageIndex}].content[${blockIndex}] tool_result requires user role`
|
||||
);
|
||||
}
|
||||
if (typeof parsed.tool_use_id !== 'string' || parsed.tool_use_id.trim().length === 0) {
|
||||
throw new Error(
|
||||
`messages[${messageIndex}].content[${blockIndex}].tool_use_id must be a non-empty string`
|
||||
);
|
||||
}
|
||||
sawToolResult = true;
|
||||
flushUserContent(translatedMessages, userParts);
|
||||
translatedMessages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: parsed.tool_use_id,
|
||||
content: toToolResultContent(
|
||||
parsed.content,
|
||||
`messages[${messageIndex}].content[${blockIndex}].content`
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`messages[${messageIndex}].content[${blockIndex}].type "${String(parsed.type)}" is not supported`
|
||||
);
|
||||
});
|
||||
|
||||
if (role === 'assistant') {
|
||||
translatedMessages.push({
|
||||
role: 'assistant',
|
||||
content: assistantTextParts.join('\n'),
|
||||
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (userParts.length > 0 || !sawToolResult) {
|
||||
flushUserContent(translatedMessages, userParts);
|
||||
}
|
||||
});
|
||||
|
||||
return translatedMessages;
|
||||
}
|
||||
|
||||
export class ProxyRequestTransformer {
|
||||
transform(raw: unknown): ProxyOpenAIRequest {
|
||||
const translated = translateAnthropicRequest(raw);
|
||||
const source = (raw || {}) as AnthropicProxyRequestShape;
|
||||
const source = assertObject(raw || {}, 'request') as AnthropicProxyRequestShape;
|
||||
const messages = transformMessages(source.messages);
|
||||
const system = source.system;
|
||||
const allMessages =
|
||||
system !== undefined
|
||||
? [
|
||||
{ role: 'system', content: flattenTextContent(system, 'system') } as OpenAIMessage,
|
||||
...messages,
|
||||
]
|
||||
: messages;
|
||||
|
||||
return {
|
||||
...translated,
|
||||
model:
|
||||
typeof source.model === 'string' && source.model.trim().length > 0
|
||||
? source.model.trim()
|
||||
: undefined,
|
||||
stream: source.stream === true,
|
||||
messages: allMessages,
|
||||
max_tokens: asNumber(source.max_tokens),
|
||||
temperature: asNumber(source.temperature),
|
||||
top_p: asNumber(source.top_p),
|
||||
stop: asStringArray(source.stop_sequences),
|
||||
metadata: asMetadata(source.metadata),
|
||||
tools: transformTools(source.tools),
|
||||
...mapThinkingToReasoning(source.thinking),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user