mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 02:15:36 +00:00
feat(proxy): add openai-compatible local proxy runtime
This commit is contained in:
1 parent
9545499487
commit
074e900557
22 files changed
+1428
-46
No files matched your search
@@ -0,0 +1,7 @@
|
||||
export * from './profile-router';
|
||||
export * from './proxy-daemon';
|
||||
export * from './proxy-daemon-paths';
|
||||
export * from './proxy-env';
|
||||
export * from './upstream-url';
|
||||
export * from './transformers/request-transformer';
|
||||
export * from './transformers/sse-stream-transformer';
|
||||
@@ -0,0 +1,70 @@
|
||||
import { resolveDroidProvider, type DroidProvider } from '../targets/droid-provider';
|
||||
|
||||
export interface OpenAICompatProfileConfig {
|
||||
profileName: string;
|
||||
settingsPath: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
provider: DroidProvider;
|
||||
insecure?: boolean;
|
||||
model?: string;
|
||||
opusModel?: string;
|
||||
sonnetModel?: string;
|
||||
haikuModel?: string;
|
||||
}
|
||||
|
||||
export interface OpenAICompatProfileEnv {
|
||||
ANTHROPIC_BASE_URL?: string;
|
||||
ANTHROPIC_AUTH_TOKEN?: string;
|
||||
ANTHROPIC_API_KEY?: string;
|
||||
ANTHROPIC_MODEL?: string;
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL?: string;
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL?: string;
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL?: string;
|
||||
ANTHROPIC_SMALL_FAST_MODEL?: string;
|
||||
CCS_DROID_PROVIDER?: string;
|
||||
CCS_OPENAI_PROXY_INSECURE?: string;
|
||||
}
|
||||
|
||||
export function isOpenAICompatProvider(provider: DroidProvider | null): provider is DroidProvider {
|
||||
return provider === 'openai' || provider === 'generic-chat-completion-api';
|
||||
}
|
||||
|
||||
export function resolveOpenAICompatProfileConfig(
|
||||
profileName: string,
|
||||
settingsPath: string,
|
||||
env: OpenAICompatProfileEnv
|
||||
): OpenAICompatProfileConfig | null {
|
||||
const baseUrl = env.ANTHROPIC_BASE_URL?.trim() || '';
|
||||
const apiKey = env.ANTHROPIC_AUTH_TOKEN?.trim() || env.ANTHROPIC_API_KEY?.trim() || '';
|
||||
if (!baseUrl || !apiKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const provider = resolveDroidProvider({
|
||||
provider: env.CCS_DROID_PROVIDER,
|
||||
baseUrl,
|
||||
model: env.ANTHROPIC_MODEL,
|
||||
});
|
||||
if (!isOpenAICompatProvider(provider)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
profileName,
|
||||
settingsPath,
|
||||
baseUrl,
|
||||
apiKey,
|
||||
provider,
|
||||
insecure:
|
||||
env.CCS_OPENAI_PROXY_INSECURE === '1' ||
|
||||
env.CCS_OPENAI_PROXY_INSECURE?.toLowerCase() === 'true',
|
||||
model: env.ANTHROPIC_MODEL?.trim() || undefined,
|
||||
opusModel: env.ANTHROPIC_DEFAULT_OPUS_MODEL?.trim() || undefined,
|
||||
sonnetModel: env.ANTHROPIC_DEFAULT_SONNET_MODEL?.trim() || undefined,
|
||||
haikuModel:
|
||||
env.ANTHROPIC_DEFAULT_HAIKU_MODEL?.trim() ||
|
||||
env.ANTHROPIC_SMALL_FAST_MODEL?.trim() ||
|
||||
undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { loadSettings } from '../utils/config-manager';
|
||||
import { resolveOpenAICompatProfileConfig } from './profile-router';
|
||||
import { startOpenAICompatProxyServer } from './server/proxy-server';
|
||||
|
||||
interface RuntimeOptions {
|
||||
port: number;
|
||||
profileName: string;
|
||||
settingsPath: string;
|
||||
authToken: string;
|
||||
insecure: boolean;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): RuntimeOptions {
|
||||
let port = 3456;
|
||||
let profileName = '';
|
||||
let settingsPath = '';
|
||||
let authToken = '';
|
||||
let insecure = false;
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === '--port' && argv[i + 1]) {
|
||||
port = Number.parseInt(argv[++i] || '', 10) || port;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--profile' && argv[i + 1]) {
|
||||
profileName = argv[++i] || '';
|
||||
continue;
|
||||
}
|
||||
if (arg === '--settings-path' && argv[i + 1]) {
|
||||
settingsPath = argv[++i] || '';
|
||||
continue;
|
||||
}
|
||||
if (arg === '--auth-token' && argv[i + 1]) {
|
||||
authToken = argv[++i] || '';
|
||||
continue;
|
||||
}
|
||||
if (arg === '--insecure') {
|
||||
insecure = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { port, profileName, settingsPath, authToken, insecure };
|
||||
}
|
||||
|
||||
function startRuntime(options: RuntimeOptions): void {
|
||||
if (!options.authToken.trim()) {
|
||||
throw new Error('Missing local proxy auth token');
|
||||
}
|
||||
|
||||
const settings = loadSettings(options.settingsPath);
|
||||
const profile = resolveOpenAICompatProfileConfig(
|
||||
options.profileName,
|
||||
options.settingsPath,
|
||||
settings.env || {}
|
||||
);
|
||||
if (!profile) {
|
||||
throw new Error(
|
||||
`Profile "${options.profileName}" is not an OpenAI-compatible settings profile`
|
||||
);
|
||||
}
|
||||
|
||||
const server = startOpenAICompatProxyServer({
|
||||
profile,
|
||||
port: options.port,
|
||||
authToken: options.authToken,
|
||||
insecure: options.insecure,
|
||||
});
|
||||
server.once('error', (error) => {
|
||||
console.error((error as Error).message);
|
||||
process.exit(1);
|
||||
});
|
||||
const shutdown = () => server.close();
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
startRuntime(parseArgs(process.argv.slice(2)));
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as path from 'path';
|
||||
import { getCcsDir } from '../utils/config-manager';
|
||||
|
||||
export const OPENAI_COMPAT_PROXY_DEFAULT_PORT = 3456;
|
||||
export const OPENAI_COMPAT_PROXY_SERVICE_NAME = 'ccs-openai-compat-proxy';
|
||||
|
||||
export function getOpenAICompatProxyDir(): string {
|
||||
return path.join(getCcsDir(), 'proxy');
|
||||
}
|
||||
|
||||
export function getOpenAICompatProxyPidPath(): string {
|
||||
return path.join(getOpenAICompatProxyDir(), 'daemon.pid');
|
||||
}
|
||||
|
||||
export function getOpenAICompatProxySessionPath(): string {
|
||||
return path.join(getOpenAICompatProxyDir(), 'session.json');
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
getOpenAICompatProxyDir,
|
||||
getOpenAICompatProxyPidPath,
|
||||
getOpenAICompatProxySessionPath,
|
||||
} from './proxy-daemon-paths';
|
||||
|
||||
export interface OpenAICompatProxySession {
|
||||
profileName: string;
|
||||
settingsPath: string;
|
||||
port: number;
|
||||
baseUrl: string;
|
||||
authToken: string;
|
||||
model?: string;
|
||||
insecure?: boolean;
|
||||
}
|
||||
|
||||
function ensureProxyDir(): void {
|
||||
fs.mkdirSync(getOpenAICompatProxyDir(), { recursive: true });
|
||||
}
|
||||
|
||||
export function getOpenAICompatProxyPid(): number | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(getOpenAICompatProxyPidPath(), 'utf8').trim();
|
||||
const pid = Number.parseInt(raw, 10);
|
||||
return Number.isInteger(pid) ? pid : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeOpenAICompatProxyPid(pid: number): void {
|
||||
ensureProxyDir();
|
||||
fs.writeFileSync(getOpenAICompatProxyPidPath(), String(pid), 'utf8');
|
||||
}
|
||||
|
||||
export function removeOpenAICompatProxyPid(): void {
|
||||
try {
|
||||
fs.unlinkSync(getOpenAICompatProxyPidPath());
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
export function readOpenAICompatProxySession(): OpenAICompatProxySession | null {
|
||||
try {
|
||||
return JSON.parse(
|
||||
fs.readFileSync(getOpenAICompatProxySessionPath(), 'utf8')
|
||||
) as OpenAICompatProxySession;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeOpenAICompatProxySession(session: OpenAICompatProxySession): void {
|
||||
ensureProxyDir();
|
||||
fs.writeFileSync(
|
||||
getOpenAICompatProxySessionPath(),
|
||||
JSON.stringify(session, null, 2) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
export function removeOpenAICompatProxySession(): void {
|
||||
try {
|
||||
fs.unlinkSync(getOpenAICompatProxySessionPath());
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveOpenAICompatProxyEntrypointCandidates(): string[] {
|
||||
const jsEntry = path.join(__dirname, 'proxy-daemon-entry.js');
|
||||
const tsEntry = path.join(__dirname, 'proxy-daemon-entry.ts');
|
||||
const isBunRuntime = process.execPath.toLowerCase().includes('bun');
|
||||
const runningFromDist = __filename.endsWith('.js');
|
||||
if (runningFromDist) {
|
||||
return [jsEntry];
|
||||
}
|
||||
return isBunRuntime ? [tsEntry, jsEntry] : [jsEntry];
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
import { spawn, type ChildProcess } from 'child_process';
|
||||
import * as crypto from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as http from 'http';
|
||||
import * as net from 'net';
|
||||
import * as lockfile from 'proper-lockfile';
|
||||
import { verifyProcessOwnership } from '../cursor/daemon-process-ownership';
|
||||
import type { OpenAICompatProfileConfig } from './profile-router';
|
||||
import {
|
||||
OPENAI_COMPAT_PROXY_DEFAULT_PORT,
|
||||
OPENAI_COMPAT_PROXY_SERVICE_NAME,
|
||||
getOpenAICompatProxyDir,
|
||||
} from './proxy-daemon-paths';
|
||||
import {
|
||||
getOpenAICompatProxyPid,
|
||||
readOpenAICompatProxySession,
|
||||
removeOpenAICompatProxyPid,
|
||||
removeOpenAICompatProxySession,
|
||||
resolveOpenAICompatProxyEntrypointCandidates,
|
||||
type OpenAICompatProxySession,
|
||||
writeOpenAICompatProxyPid,
|
||||
writeOpenAICompatProxySession,
|
||||
} from './proxy-daemon-state';
|
||||
|
||||
export interface OpenAICompatProxyStatus extends Partial<OpenAICompatProxySession> {
|
||||
running: boolean;
|
||||
pid?: number;
|
||||
}
|
||||
|
||||
export interface StartOpenAICompatProxyResult {
|
||||
success: boolean;
|
||||
alreadyRunning?: boolean;
|
||||
authToken?: string;
|
||||
pid?: number;
|
||||
port: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function generateProxyAuthToken(): string {
|
||||
return crypto.randomBytes(24).toString('hex');
|
||||
}
|
||||
|
||||
async function withOpenAICompatProxyLock<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const proxyDir = getOpenAICompatProxyDir();
|
||||
await fs.promises.mkdir(proxyDir, { recursive: true });
|
||||
|
||||
let release: (() => Promise<void>) | undefined;
|
||||
try {
|
||||
release = await lockfile.lock(proxyDir, {
|
||||
stale: 10000,
|
||||
retries: { retries: 20, minTimeout: 50, maxTimeout: 250 },
|
||||
realpath: false,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to lock OpenAI-compatible proxy directory (${proxyDir}): ${(error as Error).message}`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
if (release) {
|
||||
try {
|
||||
await release();
|
||||
} catch {
|
||||
// Best-effort release.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function isPortOccupied(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.createConnection({ host: '127.0.0.1', port });
|
||||
const finish = (occupied: boolean) => {
|
||||
socket.removeAllListeners();
|
||||
socket.destroy();
|
||||
resolve(occupied);
|
||||
};
|
||||
|
||||
socket.once('connect', () => finish(true));
|
||||
socket.once('error', () => finish(false));
|
||||
socket.setTimeout(500, () => {
|
||||
finish(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function findOpenAICompatProxyPort(): Promise<number> {
|
||||
for (
|
||||
let candidate = OPENAI_COMPAT_PROXY_DEFAULT_PORT;
|
||||
candidate <= OPENAI_COMPAT_PROXY_DEFAULT_PORT + 10;
|
||||
candidate += 1
|
||||
) {
|
||||
if (!(await isPortOccupied(candidate))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function resolveDaemonEntrypoint(): Promise<string | null> {
|
||||
for (const candidate of resolveOpenAICompatProxyEntrypointCandidates()) {
|
||||
try {
|
||||
await fs.promises.access(candidate, fs.constants.R_OK);
|
||||
return candidate;
|
||||
} catch {
|
||||
// Try next candidate.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function isOpenAICompatProxyRunning(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.request(
|
||||
{ hostname: '127.0.0.1', port, path: '/health', method: 'GET', timeout: 3000 },
|
||||
(res) => {
|
||||
let body = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (chunk) => (body += chunk));
|
||||
res.on('end', () => {
|
||||
if (res.statusCode !== 200) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = JSON.parse(body) as { service?: string };
|
||||
resolve(payload.service === OPENAI_COMPAT_PROXY_SERVICE_NAME);
|
||||
} catch {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
req.on('error', () => resolve(false));
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOpenAICompatProxyStatus(): Promise<OpenAICompatProxyStatus> {
|
||||
const session = readOpenAICompatProxySession();
|
||||
const port = session?.port ?? OPENAI_COMPAT_PROXY_DEFAULT_PORT;
|
||||
const running = await isOpenAICompatProxyRunning(port);
|
||||
return {
|
||||
running,
|
||||
pid: running ? getOpenAICompatProxyPid() || undefined : undefined,
|
||||
...session,
|
||||
};
|
||||
}
|
||||
|
||||
async function stopOpenAICompatProxyUnlocked(): Promise<{ success: boolean; error?: string }> {
|
||||
const pid = getOpenAICompatProxyPid();
|
||||
if (!pid) {
|
||||
removeOpenAICompatProxySession();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const ownership = verifyProcessOwnership(
|
||||
pid,
|
||||
(commandLine) =>
|
||||
commandLine.includes('--ccs-openai-proxy-daemon') &&
|
||||
commandLine.includes('proxy-daemon-entry')
|
||||
);
|
||||
|
||||
if (ownership === 'not-owned') {
|
||||
removeOpenAICompatProxyPid();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (ownership === 'unknown') {
|
||||
return {
|
||||
success: false,
|
||||
error: `Refusing to stop PID ${pid}: unable to verify daemon ownership`,
|
||||
};
|
||||
}
|
||||
|
||||
if (ownership === 'not-running') {
|
||||
removeOpenAICompatProxyPid();
|
||||
removeOpenAICompatProxySession();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
let attempts = 0;
|
||||
while (attempts < 10) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
attempts += 1;
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (attempts >= 10) {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch {
|
||||
// Already exited.
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
if (err.code !== 'ESRCH') {
|
||||
return { success: false, error: `Failed to stop daemon: ${err.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
removeOpenAICompatProxyPid();
|
||||
removeOpenAICompatProxySession();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function stopOpenAICompatProxy(): Promise<{ success: boolean; error?: string }> {
|
||||
return withOpenAICompatProxyLock(() => stopOpenAICompatProxyUnlocked());
|
||||
}
|
||||
|
||||
export async function startOpenAICompatProxy(
|
||||
profile: OpenAICompatProfileConfig,
|
||||
options: { port?: number; insecure?: boolean } = {}
|
||||
): Promise<StartOpenAICompatProxyResult> {
|
||||
return withOpenAICompatProxyLock(async () => {
|
||||
const status = await getOpenAICompatProxyStatus();
|
||||
const port =
|
||||
typeof options.port === 'number'
|
||||
? options.port
|
||||
: status.running && status.profileName === profile.profileName && status.port
|
||||
? status.port
|
||||
: await findOpenAICompatProxyPort();
|
||||
if (port === 0) {
|
||||
return {
|
||||
success: false,
|
||||
port: OPENAI_COMPAT_PROXY_DEFAULT_PORT,
|
||||
error: `No free proxy port found in range ${OPENAI_COMPAT_PROXY_DEFAULT_PORT}-${OPENAI_COMPAT_PROXY_DEFAULT_PORT + 10}`,
|
||||
};
|
||||
}
|
||||
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) {
|
||||
return {
|
||||
success: true,
|
||||
alreadyRunning: true,
|
||||
pid: status.pid,
|
||||
port,
|
||||
authToken: status.authToken,
|
||||
};
|
||||
}
|
||||
if (status.running) {
|
||||
if (status.profileName !== profile.profileName) {
|
||||
return {
|
||||
success: false,
|
||||
port,
|
||||
error: `Proxy already running for profile "${status.profileName}" on port ${status.port}. Stop it before starting a different profile.`,
|
||||
};
|
||||
}
|
||||
|
||||
const stopped = await stopOpenAICompatProxyUnlocked();
|
||||
if (!stopped.success) {
|
||||
return {
|
||||
success: false,
|
||||
port,
|
||||
error: stopped.error || 'Failed to restart the running proxy',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const daemonEntry = await resolveDaemonEntrypoint();
|
||||
if (!daemonEntry) {
|
||||
return {
|
||||
success: false,
|
||||
port,
|
||||
error: 'OpenAI proxy daemon entrypoint not found. Run `bun run build` and retry.',
|
||||
};
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
const authToken = generateProxyAuthToken();
|
||||
|
||||
const finish = (result: StartOpenAICompatProxyResult) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
if (timeout) clearTimeout(timeout);
|
||||
if (!result.success) {
|
||||
removeOpenAICompatProxyPid();
|
||||
removeOpenAICompatProxySession();
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const proc: ChildProcess = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
daemonEntry,
|
||||
'--port',
|
||||
String(port),
|
||||
'--profile',
|
||||
profile.profileName,
|
||||
'--settings-path',
|
||||
profile.settingsPath,
|
||||
'--auth-token',
|
||||
authToken,
|
||||
...(options.insecure ? ['--insecure'] : []),
|
||||
'--ccs-openai-proxy-daemon',
|
||||
],
|
||||
{ stdio: 'ignore', detached: true }
|
||||
);
|
||||
|
||||
proc.unref();
|
||||
if (proc.pid) {
|
||||
writeOpenAICompatProxyPid(proc.pid);
|
||||
}
|
||||
writeOpenAICompatProxySession({
|
||||
profileName: profile.profileName,
|
||||
settingsPath: profile.settingsPath,
|
||||
port,
|
||||
baseUrl: profile.baseUrl,
|
||||
authToken,
|
||||
model: profile.model,
|
||||
insecure: options.insecure,
|
||||
});
|
||||
|
||||
let attempts = 0;
|
||||
const poll = async () => {
|
||||
attempts += 1;
|
||||
if (await isOpenAICompatProxyRunning(port)) {
|
||||
finish({ success: true, pid: proc.pid, port, authToken });
|
||||
return;
|
||||
}
|
||||
if (attempts >= 30) {
|
||||
finish({
|
||||
success: false,
|
||||
port,
|
||||
error: `Proxy daemon did not start within 30 seconds on port ${port}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
timeout = setTimeout(poll, 1000);
|
||||
};
|
||||
|
||||
timeout = setTimeout(poll, 1000);
|
||||
proc.on('error', (error) => {
|
||||
finish({ success: false, port, error: error.message });
|
||||
});
|
||||
proc.on('exit', (code, signal) => {
|
||||
if (code === 0) {
|
||||
finish({
|
||||
success: false,
|
||||
port,
|
||||
error: 'Proxy daemon exited before becoming healthy',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (code !== null) {
|
||||
finish({
|
||||
success: false,
|
||||
port,
|
||||
error: `Proxy daemon exited with code ${code}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
finish({
|
||||
success: false,
|
||||
port,
|
||||
error: `Proxy daemon was killed by signal ${signal}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { OpenAICompatProfileConfig } from './profile-router';
|
||||
|
||||
export function buildOpenAICompatProxyEnv(
|
||||
profile: OpenAICompatProfileConfig,
|
||||
port: number,
|
||||
authToken: string,
|
||||
claudeConfigDir?: string
|
||||
): Record<string, string> {
|
||||
return {
|
||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`,
|
||||
ANTHROPIC_AUTH_TOKEN: authToken,
|
||||
...(profile.model ? { ANTHROPIC_MODEL: profile.model } : {}),
|
||||
...(profile.opusModel ? { ANTHROPIC_DEFAULT_OPUS_MODEL: profile.opusModel } : {}),
|
||||
...(profile.sonnetModel ? { ANTHROPIC_DEFAULT_SONNET_MODEL: profile.sonnetModel } : {}),
|
||||
...(profile.haikuModel
|
||||
? {
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: profile.haikuModel,
|
||||
ANTHROPIC_SMALL_FAST_MODEL: profile.haikuModel,
|
||||
}
|
||||
: {}),
|
||||
...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import * as http from 'http';
|
||||
import { Readable } from 'stream';
|
||||
|
||||
const MAX_BODY_SIZE = 10 * 1024 * 1024;
|
||||
|
||||
export function writeJson(res: http.ServerResponse, statusCode: number, payload: unknown): void {
|
||||
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
export function readJsonBody(req: http.IncomingMessage): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
let settled = false;
|
||||
|
||||
const resolveOnce = (payload: unknown) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve(payload);
|
||||
}
|
||||
};
|
||||
|
||||
const rejectOnce = (error: Error) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
req.on('data', (chunk: Buffer) => {
|
||||
total += chunk.length;
|
||||
if (total > MAX_BODY_SIZE) {
|
||||
req.pause();
|
||||
rejectOnce(new Error('Request body too large (max 10MB)'));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
req.on('end', () => {
|
||||
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
||||
if (!raw) {
|
||||
resolveOnce({});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
resolveOnce(JSON.parse(raw));
|
||||
} catch {
|
||||
rejectOnce(new Error('Invalid JSON in request body'));
|
||||
}
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
rejectOnce(error instanceof Error ? error : new Error(String(error)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function pipeWebResponseToNode(
|
||||
response: Response,
|
||||
res: http.ServerResponse
|
||||
): Promise<void> {
|
||||
res.statusCode = response.status;
|
||||
response.headers.forEach((value, key) => {
|
||||
res.setHeader(key, value);
|
||||
});
|
||||
|
||||
if (!response.body) {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeStream = Readable.fromWeb(response.body as unknown as ReadableStream<Uint8Array>);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
nodeStream.on('error', reject);
|
||||
nodeStream.on('end', resolve);
|
||||
nodeStream.pipe(res);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import * as http from 'http';
|
||||
import type { Dispatcher } from 'undici';
|
||||
import type { OpenAICompatProfileConfig } from '../profile-router';
|
||||
import { ProxyRequestTransformer } from '../transformers/request-transformer';
|
||||
import { ProxySseStreamTransformer } from '../transformers/sse-stream-transformer';
|
||||
import { resolveOpenAIChatCompletionsUrl } from '../upstream-url';
|
||||
import { pipeWebResponseToNode, readJsonBody, writeJson } from './http-helpers';
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
class ProxyInputError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ProxyInputError';
|
||||
}
|
||||
}
|
||||
|
||||
function buildUpstreamHeaders(profile: OpenAICompatProfileConfig): Record<string, string> {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${profile.apiKey}`,
|
||||
'User-Agent': 'CCS-OpenAI-Compat-Proxy/1.0',
|
||||
};
|
||||
}
|
||||
|
||||
function buildUpstreamBody(profile: OpenAICompatProfileConfig, rawBody: unknown): string {
|
||||
let transformed;
|
||||
try {
|
||||
const transformer = new ProxyRequestTransformer();
|
||||
transformed = transformer.transform(rawBody);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Invalid Anthropic request';
|
||||
throw new ProxyInputError(message);
|
||||
}
|
||||
const body = {
|
||||
...transformed,
|
||||
model: transformed.model || profile.model,
|
||||
stream: transformed.stream === true,
|
||||
};
|
||||
return JSON.stringify(body);
|
||||
}
|
||||
|
||||
export function extractIncomingProxyToken(headers: http.IncomingHttpHeaders): string | null {
|
||||
const xApiKey = headers['x-api-key'];
|
||||
if (typeof xApiKey === 'string' && xApiKey.trim().length > 0) {
|
||||
return xApiKey.trim();
|
||||
}
|
||||
|
||||
const anthropicApiKey = headers['anthropic-api-key'];
|
||||
if (typeof anthropicApiKey === 'string' && anthropicApiKey.trim().length > 0) {
|
||||
return anthropicApiKey.trim();
|
||||
}
|
||||
|
||||
const authHeader = headers.authorization;
|
||||
if (typeof authHeader === 'string' && authHeader.trim().length > 0) {
|
||||
const trimmed = authHeader.trim();
|
||||
const bearerPrefix = 'Bearer ';
|
||||
return trimmed.startsWith(bearerPrefix) ? trimmed.slice(bearerPrefix.length).trim() : trimmed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateIncomingProxyAuth(
|
||||
headers: http.IncomingHttpHeaders,
|
||||
expectedToken: string
|
||||
): boolean {
|
||||
return extractIncomingProxyToken(headers) === expectedToken;
|
||||
}
|
||||
|
||||
function buildFetchInit(
|
||||
profile: OpenAICompatProfileConfig,
|
||||
body: string,
|
||||
signal: AbortSignal,
|
||||
insecureDispatcher?: Dispatcher
|
||||
): RequestInit {
|
||||
const init: RequestInit = {
|
||||
method: 'POST',
|
||||
headers: buildUpstreamHeaders(profile),
|
||||
body,
|
||||
signal,
|
||||
};
|
||||
|
||||
if (insecureDispatcher) {
|
||||
(init as Record<string, unknown>).dispatcher = insecureDispatcher;
|
||||
}
|
||||
|
||||
return init;
|
||||
}
|
||||
|
||||
export async function handleProxyMessagesRequest(
|
||||
req: http.IncomingMessage,
|
||||
res: http.ServerResponse,
|
||||
profile: OpenAICompatProfileConfig,
|
||||
expectedAuthToken: string,
|
||||
insecureDispatcher?: Dispatcher
|
||||
): Promise<void> {
|
||||
const transformer = new ProxySseStreamTransformer();
|
||||
|
||||
if (!validateIncomingProxyAuth(req.headers, expectedAuthToken)) {
|
||||
await pipeWebResponseToNode(
|
||||
transformer.error(401, 'authentication_error', 'Missing or invalid local proxy token'),
|
||||
res
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const rawBody = await readJsonBody(req);
|
||||
const upstreamBody = buildUpstreamBody(profile, rawBody);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
const abortOnDisconnect = () => {
|
||||
if (!controller.signal.aborted && !res.writableEnded) {
|
||||
controller.abort();
|
||||
}
|
||||
};
|
||||
|
||||
req.on('aborted', abortOnDisconnect);
|
||||
req.on('close', abortOnDisconnect);
|
||||
res.on('close', abortOnDisconnect);
|
||||
|
||||
try {
|
||||
const upstreamResponse = await fetch(
|
||||
resolveOpenAIChatCompletionsUrl(profile.baseUrl),
|
||||
buildFetchInit(profile, upstreamBody, controller.signal, insecureDispatcher)
|
||||
);
|
||||
clearTimeout(timeout);
|
||||
const response = await transformer.transform(upstreamResponse);
|
||||
await pipeWebResponseToNode(response, res);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown proxy error';
|
||||
const status =
|
||||
error instanceof Error && error.name === 'AbortError'
|
||||
? 502
|
||||
: error instanceof ProxyInputError
|
||||
? 400
|
||||
: message.includes('Request body too large')
|
||||
? 413
|
||||
: message.includes('Invalid JSON')
|
||||
? 400
|
||||
: 502;
|
||||
const type = status >= 500 ? 'api_error' : 'invalid_request_error';
|
||||
await pipeWebResponseToNode(
|
||||
transformer.error(
|
||||
status,
|
||||
type,
|
||||
error instanceof Error && error.name === 'AbortError'
|
||||
? 'The upstream provider did not respond within 30 seconds'
|
||||
: message
|
||||
),
|
||||
res
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function handleProxyModelsRequest(
|
||||
res: http.ServerResponse,
|
||||
profile: OpenAICompatProfileConfig
|
||||
): void {
|
||||
const data = [profile.model, profile.opusModel, profile.sonnetModel, profile.haikuModel]
|
||||
.filter((value): value is string => typeof value === 'string' && value.length > 0)
|
||||
.map((id) => ({
|
||||
id,
|
||||
object: 'model',
|
||||
created: 0,
|
||||
owned_by: profile.provider,
|
||||
}));
|
||||
|
||||
writeJson(res, 200, { object: 'list', data });
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
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 {
|
||||
handleProxyMessagesRequest,
|
||||
handleProxyModelsRequest,
|
||||
validateIncomingProxyAuth,
|
||||
} from './messages-route';
|
||||
import { writeJson } from './http-helpers';
|
||||
|
||||
export interface OpenAICompatProxyServerOptions {
|
||||
profile: OpenAICompatProfileConfig;
|
||||
port: number;
|
||||
authToken: string;
|
||||
insecure?: boolean;
|
||||
}
|
||||
|
||||
export function startOpenAICompatProxyServer(options: OpenAICompatProxyServerOptions): http.Server {
|
||||
const insecureDispatcher = options.insecure
|
||||
? new Agent({ connect: { rejectUnauthorized: false } })
|
||||
: undefined;
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const method = req.method || 'GET';
|
||||
const requestUrl = req.url || '/';
|
||||
const parsedUrl = new URL(requestUrl, 'http://127.0.0.1');
|
||||
const pathname =
|
||||
parsedUrl.pathname.length > 1 ? parsedUrl.pathname.replace(/\/+$/, '') : parsedUrl.pathname;
|
||||
|
||||
if (method === 'GET' && pathname === '/health') {
|
||||
writeJson(res, 200, {
|
||||
ok: true,
|
||||
service: OPENAI_COMPAT_PROXY_SERVICE_NAME,
|
||||
profile: options.profile.profileName,
|
||||
port: options.port,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'GET' && pathname === '/v1/models') {
|
||||
if (!validateIncomingProxyAuth(req.headers, options.authToken)) {
|
||||
writeJson(res, 401, {
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'authentication_error',
|
||||
message: 'Missing or invalid local proxy token',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
handleProxyModelsRequest(res, options.profile);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === 'POST' && pathname === '/v1/messages') {
|
||||
await handleProxyMessagesRequest(
|
||||
req,
|
||||
res,
|
||||
options.profile,
|
||||
options.authToken,
|
||||
insecureDispatcher
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
writeJson(res, 404, { error: 'Not found' });
|
||||
});
|
||||
|
||||
server.on('close', () => {
|
||||
void insecureDispatcher?.close();
|
||||
});
|
||||
|
||||
server.listen(options.port, '127.0.0.1');
|
||||
return server;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { translateAnthropicRequest } from '../../cursor/cursor-anthropic-translator';
|
||||
|
||||
interface AnthropicProxyRequestShape {
|
||||
max_tokens?: unknown;
|
||||
temperature?: unknown;
|
||||
top_p?: unknown;
|
||||
stop_sequences?: unknown;
|
||||
metadata?: unknown;
|
||||
tools?: unknown;
|
||||
}
|
||||
|
||||
export interface ProxyOpenAIRequest extends ReturnType<typeof translateAnthropicRequest> {
|
||||
max_tokens?: number;
|
||||
temperature?: number;
|
||||
top_p?: number;
|
||||
stop?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
tools?: Array<{
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
description?: string;
|
||||
parameters: Record<string, unknown>;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const result = value.filter(
|
||||
(entry): entry is string => typeof entry === 'string' && entry.length > 0
|
||||
);
|
||||
return result.length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
function asMetadata(value: unknown): Record<string, unknown> | undefined {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function transformTools(value: unknown): ProxyOpenAIRequest['tools'] {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const tools = value
|
||||
.filter(
|
||||
(entry): entry is { name?: unknown; description?: unknown; input_schema?: unknown } =>
|
||||
typeof entry === 'object' && entry !== null
|
||||
)
|
||||
.map((entry) => ({
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: typeof entry.name === 'string' ? entry.name : 'tool',
|
||||
...(typeof entry.description === 'string' ? { description: entry.description } : {}),
|
||||
parameters:
|
||||
typeof entry.input_schema === 'object' && entry.input_schema !== null
|
||||
? (entry.input_schema as Record<string, unknown>)
|
||||
: { type: 'object', properties: {} },
|
||||
},
|
||||
}));
|
||||
|
||||
return tools.length > 0 ? tools : undefined;
|
||||
}
|
||||
|
||||
export class ProxyRequestTransformer {
|
||||
transform(raw: unknown): ProxyOpenAIRequest {
|
||||
const translated = translateAnthropicRequest(raw);
|
||||
const source = (raw || {}) as AnthropicProxyRequestShape;
|
||||
|
||||
return {
|
||||
...translated,
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import {
|
||||
createAnthropicErrorResponse,
|
||||
createAnthropicProxyResponse,
|
||||
} from '../../cursor/cursor-anthropic-response';
|
||||
|
||||
export class ProxySseStreamTransformer {
|
||||
async transform(response: Response): Promise<Response> {
|
||||
return createAnthropicProxyResponse(response);
|
||||
}
|
||||
|
||||
error(status: number, type: string, message: string): Response {
|
||||
return createAnthropicErrorResponse(status, type, message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
function normalizePathname(pathname: string): string {
|
||||
const trimmed = pathname.replace(/\/+$/, '');
|
||||
return trimmed || '';
|
||||
}
|
||||
|
||||
function ensureSupportedProtocol(parsed: URL): void {
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
throw new Error(`Unsupported upstream protocol: ${parsed.protocol}`);
|
||||
}
|
||||
}
|
||||
|
||||
function buildResolvedUrl(baseUrl: string, suffix: string): string {
|
||||
const parsed = new URL(baseUrl);
|
||||
ensureSupportedProtocol(parsed);
|
||||
|
||||
const pathname = normalizePathname(parsed.pathname);
|
||||
if (pathname.endsWith(suffix)) {
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
if (pathname.endsWith('/v1') || pathname.endsWith('/api')) {
|
||||
parsed.pathname = `${pathname}${suffix.startsWith('/') ? suffix : `/${suffix}`}`;
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
parsed.pathname = pathname ? `${pathname}/v1${suffix}` : `/v1${suffix}`;
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
export function resolveOpenAIChatCompletionsUrl(baseUrl: string): string {
|
||||
return buildResolvedUrl(baseUrl, '/chat/completions');
|
||||
}
|
||||
|
||||
export function resolveOpenAIModelsUrl(baseUrl: string): string {
|
||||
return buildResolvedUrl(baseUrl, '/models');
|
||||
}
|
||||
Reference in new issue
Block a user