mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +00:00
feat(proxy): add openai-compatible local proxy runtime
This commit is contained in:
+52
@@ -97,6 +97,11 @@ import {
|
||||
} from './targets/droid-reasoning-runtime';
|
||||
import { DroidCommandRouterError, routeDroidCommandArgs } from './targets/droid-command-router';
|
||||
import { resolveCliproxyBridgeMetadata } from './api/services/cliproxy-profile-bridge';
|
||||
import {
|
||||
buildOpenAICompatProxyEnv,
|
||||
resolveOpenAICompatProfileConfig,
|
||||
startOpenAICompatProxy,
|
||||
} from './proxy';
|
||||
|
||||
// Version and Update check utilities
|
||||
import { getVersion } from './utils/version';
|
||||
@@ -1304,6 +1309,53 @@ async function main(): Promise<void> {
|
||||
const browserArgs = browserRuntimeEnv
|
||||
? appendBrowserToolArgs(imageAnalysisArgs)
|
||||
: imageAnalysisArgs;
|
||||
const openAICompatProfile = resolveOpenAICompatProfileConfig(
|
||||
profileInfo.name,
|
||||
expandedSettingsPath,
|
||||
settingsEnv
|
||||
);
|
||||
if (openAICompatProfile) {
|
||||
const proxyStart = await startOpenAICompatProxy(openAICompatProfile, {
|
||||
insecure: openAICompatProfile.insecure,
|
||||
});
|
||||
if (!proxyStart.success) {
|
||||
console.error(fail(proxyStart.error || 'Failed to start local OpenAI-compatible proxy'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.error(
|
||||
info(
|
||||
`Using local OpenAI-compatible proxy for "${profileInfo.name}" on port ${proxyStart.port}`
|
||||
)
|
||||
);
|
||||
|
||||
const proxyEnv = {
|
||||
...envVars,
|
||||
...buildOpenAICompatProxyEnv(
|
||||
openAICompatProfile,
|
||||
proxyStart.port,
|
||||
proxyStart.authToken || '',
|
||||
inheritedClaudeConfigDir
|
||||
),
|
||||
};
|
||||
delete proxyEnv.ANTHROPIC_API_KEY;
|
||||
|
||||
const launchArgs = [
|
||||
'--settings',
|
||||
expandedSettingsPath,
|
||||
...appendThirdPartyWebSearchToolArgs(browserArgs),
|
||||
];
|
||||
const traceEnv = createWebSearchTraceContext({
|
||||
launcher: 'ccs.settings-profile.proxy',
|
||||
args: launchArgs,
|
||||
profile: profileInfo.name,
|
||||
profileType: profileInfo.type,
|
||||
settingsPath: expandedSettingsPath,
|
||||
});
|
||||
|
||||
execClaude(claudeCli, launchArgs, { ...proxyEnv, ...traceEnv });
|
||||
return;
|
||||
}
|
||||
const launchArgs = [
|
||||
'--settings',
|
||||
expandedSettingsPath,
|
||||
|
||||
@@ -109,6 +109,12 @@ export const ROOT_COMMAND_CATALOG: readonly RootCommandEntry[] = [
|
||||
group: 'runtime',
|
||||
visibility: 'public',
|
||||
},
|
||||
{
|
||||
name: 'proxy',
|
||||
summary: 'Start or inspect the OpenAI-compatible local proxy',
|
||||
group: 'runtime',
|
||||
visibility: 'public',
|
||||
},
|
||||
{
|
||||
name: 'copilot',
|
||||
summary: 'Run or manage the GitHub Copilot bridge',
|
||||
@@ -252,6 +258,7 @@ export const CLIPROXY_SUBCOMMANDS = [
|
||||
] as const;
|
||||
export const CONFIG_SUBCOMMANDS = ['auth', 'channels', 'image-analysis', 'thinking'] as const;
|
||||
export const DOCKER_SUBCOMMANDS = ['up', 'down', 'status', 'update', 'logs', 'config'] as const;
|
||||
export const PROXY_SUBCOMMANDS = ['start', 'stop', 'status', 'activate'] as const;
|
||||
export const TOKENS_FLAGS = [
|
||||
'--show',
|
||||
'--api-key',
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ROOT_COMMAND_FLAGS,
|
||||
ROOT_HELP_TOPICS,
|
||||
TOKENS_FLAGS,
|
||||
PROXY_SUBCOMMANDS,
|
||||
PROVIDER_FLAGS,
|
||||
uniqueStrings,
|
||||
getPublicRootCommandTokens,
|
||||
@@ -189,6 +190,13 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg
|
||||
return completeSubcommands([], COMMAND_FLAG_SUGGESTIONS.docker);
|
||||
case 'cursor':
|
||||
return completeSubcommands(CURSOR_COMPLETION_SUBCOMMANDS);
|
||||
case 'proxy':
|
||||
if (lastToken === '--shell')
|
||||
return completeSubcommands(['auto', 'bash', 'zsh', 'fish', 'powershell']);
|
||||
return completeSubcommands(
|
||||
[...PROXY_SUBCOMMANDS],
|
||||
['--port', '--shell', '--insecure', '--help', '-h']
|
||||
);
|
||||
case 'copilot':
|
||||
return completeSubcommands(COPILOT_COMPLETION_SUBCOMMANDS);
|
||||
case 'env':
|
||||
|
||||
@@ -215,6 +215,7 @@ export async function handleHelpCommand(writeLine: HelpWriter = console.log): Pr
|
||||
name: 'ccs cliproxy --help',
|
||||
summary: 'Deep help for variants, routing, quota, and lifecycle',
|
||||
},
|
||||
{ name: 'ccs proxy --help', summary: 'Deep help for the OpenAI-compatible local proxy' },
|
||||
{ name: 'ccs docker --help', summary: 'Deep help for Docker deployment commands' },
|
||||
{ name: 'ccs cursor --help', summary: 'Deep help for Cursor runtime/admin commands' },
|
||||
{ name: 'ccs copilot --help', summary: 'Deep help for GitHub Copilot commands' },
|
||||
@@ -275,6 +276,8 @@ export async function handleHelpRoute(
|
||||
process.exit(await (await import('./copilot-command')).handleCopilotCommand(['--help'])),
|
||||
cursor: async () =>
|
||||
process.exit(await (await import('./cursor-command')).handleCursorCommand(['--help'])),
|
||||
proxy: async () =>
|
||||
process.exit(await (await import('./proxy-command')).handleProxyCommand(['--help'])),
|
||||
docker: async () => (await import('./docker/help-subcommand')).showHelp(),
|
||||
migrate: async () => (await import('./migrate-command')).printMigrateHelp(),
|
||||
setup: async () => (await import('./setup-command')).handleSetupCommand(['--help']),
|
||||
|
||||
@@ -13,6 +13,7 @@ export { handleDockerCommand } from './docker-command';
|
||||
export { handleHelpCommand } from './help-command';
|
||||
export { handleInstallCommand } from './install-command';
|
||||
export { handleMigrateCommand } from './migrate-command';
|
||||
export { handleProxyCommand } from './proxy-command';
|
||||
export { handleShellCompletionCommand } from './shell-completion-command';
|
||||
export { handleSyncCommand } from './sync-command';
|
||||
export { handleUpdateCommand } from './update-command';
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { detectShell, formatExportLine } from './env-command';
|
||||
import { getSettingsPath, loadSettings } from '../utils/config-manager';
|
||||
import { expandPath } from '../utils/helpers';
|
||||
import { fail, info, ok } from '../utils/ui';
|
||||
import {
|
||||
buildOpenAICompatProxyEnv,
|
||||
getOpenAICompatProxyStatus,
|
||||
resolveOpenAICompatProfileConfig,
|
||||
startOpenAICompatProxy,
|
||||
stopOpenAICompatProxy,
|
||||
} from '../proxy';
|
||||
|
||||
function parseOptionValue(args: string[], key: string): string | undefined {
|
||||
const exactIndex = args.findIndex((arg) => arg === key);
|
||||
if (exactIndex !== -1 && args[exactIndex + 1]) {
|
||||
return args[exactIndex + 1];
|
||||
}
|
||||
|
||||
const prefix = `${key}=`;
|
||||
const withEquals = args.find((arg) => arg.startsWith(prefix));
|
||||
return withEquals ? withEquals.slice(prefix.length) : undefined;
|
||||
}
|
||||
|
||||
function showHelp(): number {
|
||||
console.log('OpenAI-Compatible Proxy');
|
||||
console.log('');
|
||||
console.log('Usage: ccs proxy <start|stop|status|activate> [profile] [options]');
|
||||
console.log('');
|
||||
console.log('Commands:');
|
||||
console.log(
|
||||
' start <profile> Start the local proxy for an OpenAI-compatible settings profile'
|
||||
);
|
||||
console.log(' stop Stop the running proxy');
|
||||
console.log(' status Show daemon status and active profile');
|
||||
console.log(' activate Print shell exports for the running proxy');
|
||||
console.log('');
|
||||
console.log('Options:');
|
||||
console.log(' --port <n> Override the local proxy port (default: 3456)');
|
||||
console.log(' --shell <name> activate only: auto|bash|zsh|fish|powershell');
|
||||
console.log(' --insecure Disable upstream TLS verification');
|
||||
console.log('');
|
||||
console.log('Examples:');
|
||||
console.log(' ccs proxy start hf');
|
||||
console.log(' eval "$(ccs proxy activate)"');
|
||||
console.log(' ccs proxy status');
|
||||
console.log(' ccs proxy stop');
|
||||
console.log('');
|
||||
return 0;
|
||||
}
|
||||
|
||||
function resolveProfile(profileName: string) {
|
||||
const settingsPath = expandPath(getSettingsPath(profileName));
|
||||
const settings = loadSettings(settingsPath);
|
||||
const profile = resolveOpenAICompatProfileConfig(profileName, settingsPath, settings.env || {});
|
||||
if (!profile) {
|
||||
throw new Error(`Profile "${profileName}" is not configured for an OpenAI-compatible endpoint`);
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
async function handleStart(args: string[]): Promise<number> {
|
||||
const profileName = args.find((arg) => !arg.startsWith('-'));
|
||||
if (!profileName) {
|
||||
console.error(fail('Usage: ccs proxy start <profile> [--port <n>] [--insecure]'));
|
||||
return 1;
|
||||
}
|
||||
|
||||
const portValue = parseOptionValue(args, '--port');
|
||||
const port = portValue ? Number.parseInt(portValue, 10) || 3456 : undefined;
|
||||
let profile;
|
||||
try {
|
||||
profile = resolveProfile(profileName);
|
||||
} catch (error) {
|
||||
console.error(fail((error as Error).message));
|
||||
return 1;
|
||||
}
|
||||
|
||||
const result = await startOpenAICompatProxy(profile, {
|
||||
...(port ? { port } : {}),
|
||||
insecure: args.includes('--insecure'),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
console.error(fail(result.error || 'Failed to start proxy'));
|
||||
return 1;
|
||||
}
|
||||
|
||||
console.log(
|
||||
result.alreadyRunning
|
||||
? info(`Proxy already running on port ${result.port}`)
|
||||
: ok(`Proxy started on port ${result.port}`)
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function handleStatus(): Promise<number> {
|
||||
const status = await getOpenAICompatProxyStatus();
|
||||
if (!status.running) {
|
||||
console.log(info('Proxy is not running'));
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.log(ok(`Proxy running on port ${status.port}`));
|
||||
console.log(` Profile: ${status.profileName}`);
|
||||
console.log(` Base URL: ${status.baseUrl}`);
|
||||
if (status.model) {
|
||||
console.log(` Model: ${status.model}`);
|
||||
}
|
||||
if (status.pid) {
|
||||
console.log(` PID: ${status.pid}`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function handleActivate(args: string[]): Promise<number> {
|
||||
const status = await getOpenAICompatProxyStatus();
|
||||
if (!status.running || !status.profileName || !status.port || !status.authToken) {
|
||||
console.error(fail('Proxy is not running. Start it with: ccs proxy start <profile>'));
|
||||
return 1;
|
||||
}
|
||||
|
||||
const shell = detectShell(parseOptionValue(args, '--shell'));
|
||||
let profile;
|
||||
try {
|
||||
profile = resolveProfile(status.profileName);
|
||||
} catch (error) {
|
||||
console.error(fail((error as Error).message));
|
||||
return 1;
|
||||
}
|
||||
|
||||
const env = buildOpenAICompatProxyEnv(profile, status.port, status.authToken);
|
||||
Object.entries(env).forEach(([key, value]) => {
|
||||
console.log(formatExportLine(shell, key, value));
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function handleProxyCommand(args: string[]): Promise<number> {
|
||||
const subcommand = args[0];
|
||||
switch (subcommand) {
|
||||
case undefined:
|
||||
case 'help':
|
||||
case '--help':
|
||||
case '-h':
|
||||
return showHelp();
|
||||
case 'start':
|
||||
return handleStart(args.slice(1));
|
||||
case 'stop': {
|
||||
const result = await stopOpenAICompatProxy();
|
||||
if (!result.success) {
|
||||
console.error(fail(result.error || 'Failed to stop proxy'));
|
||||
return 1;
|
||||
}
|
||||
console.log(ok('Proxy stopped'));
|
||||
return 0;
|
||||
}
|
||||
case 'status':
|
||||
return handleStatus();
|
||||
case 'activate':
|
||||
return handleActivate(args.slice(1));
|
||||
default:
|
||||
console.error(fail(`Unknown proxy subcommand: ${subcommand}`));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -136,6 +136,13 @@ export const ROOT_COMMAND_ROUTES: readonly NamedCommandRoute[] = [
|
||||
await handleCliproxyCommand(args);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'proxy',
|
||||
handle: async (args) => {
|
||||
const { handleProxyCommand } = await import('./proxy-command');
|
||||
process.exit(await handleProxyCommand(args));
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'docker',
|
||||
handle: async (args) => {
|
||||
|
||||
@@ -17,6 +17,7 @@ export const RESERVED_PROFILE_NAMES = [
|
||||
'default',
|
||||
'config',
|
||||
'cliproxy',
|
||||
'proxy',
|
||||
] as const;
|
||||
|
||||
export type ReservedProfileName = (typeof RESERVED_PROFILE_NAMES)[number];
|
||||
|
||||
+56
-46
@@ -49,63 +49,73 @@ export class SSEParser {
|
||||
* @returns Array of parsed events
|
||||
*/
|
||||
parse(chunk: Buffer | string): SSEEvent[] {
|
||||
this.buffer += chunk.toString();
|
||||
this.buffer += chunk.toString().replace(/\r\n/g, '\n');
|
||||
|
||||
// C-01 Fix: Prevent unbounded buffer growth (DoS protection)
|
||||
if (this.buffer.length > this.maxBufferSize) {
|
||||
throw new Error(`SSE buffer exceeded ${this.maxBufferSize} bytes (DoS protection)`);
|
||||
}
|
||||
|
||||
const lines = this.buffer.split('\n');
|
||||
|
||||
// Keep incomplete line in buffer
|
||||
this.buffer = lines.pop() || '';
|
||||
|
||||
const events: SSEEvent[] = [];
|
||||
let currentEvent: SSEEvent = { event: 'message', data: '' };
|
||||
const segments = this.buffer.split('\n\n');
|
||||
this.buffer = segments.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) {
|
||||
currentEvent.event = line.substring(7).trim();
|
||||
} else if (line.startsWith('data: ')) {
|
||||
const data = line.substring(6);
|
||||
for (const segment of segments) {
|
||||
const lines = segment.split('\n');
|
||||
const currentEvent: SSEEvent = { event: 'message', data: '' };
|
||||
const dataLines: string[] = [];
|
||||
|
||||
if (data === '[DONE]') {
|
||||
this.eventCount++;
|
||||
events.push({
|
||||
event: 'done',
|
||||
data: null,
|
||||
index: this.eventCount,
|
||||
});
|
||||
currentEvent = { event: 'message', data: '' };
|
||||
} else {
|
||||
try {
|
||||
currentEvent.data = JSON.parse(data);
|
||||
this.eventCount++;
|
||||
currentEvent.index = this.eventCount;
|
||||
events.push({ ...currentEvent });
|
||||
currentEvent = { event: 'message', data: '' };
|
||||
} catch (e) {
|
||||
// H-01 Fix: Log parse errors for debugging
|
||||
if (typeof console !== 'undefined' && console.error) {
|
||||
console.error(
|
||||
'[SSEParser] Malformed JSON event:',
|
||||
(e as Error).message,
|
||||
'Data:',
|
||||
data.substring(0, 100)
|
||||
);
|
||||
}
|
||||
if (this.throwOnMalformedJson) {
|
||||
throw new Error(`Malformed SSE JSON event: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trimEnd();
|
||||
if (!line || line.startsWith(':')) {
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('event: ')) {
|
||||
currentEvent.event = line.substring(7).trim();
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('data:')) {
|
||||
dataLines.push(line.substring(5).trimStart());
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('id: ')) {
|
||||
currentEvent.id = line.substring(4).trim();
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('retry: ')) {
|
||||
currentEvent.retry = parseInt(line.substring(7), 10);
|
||||
}
|
||||
}
|
||||
|
||||
const data = dataLines.join('\n');
|
||||
if (!data) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (data === '[DONE]') {
|
||||
this.eventCount++;
|
||||
events.push({ event: 'done', data: null, index: this.eventCount });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
currentEvent.data = JSON.parse(data);
|
||||
this.eventCount++;
|
||||
currentEvent.index = this.eventCount;
|
||||
events.push({ ...currentEvent });
|
||||
} catch (e) {
|
||||
if (typeof console !== 'undefined' && console.error) {
|
||||
console.error(
|
||||
'[SSEParser] Malformed JSON event:',
|
||||
(e as Error).message,
|
||||
'Data:',
|
||||
data.substring(0, 100)
|
||||
);
|
||||
}
|
||||
if (this.throwOnMalformedJson) {
|
||||
throw new Error(`Malformed SSE JSON event: ${(e as Error).message}`);
|
||||
}
|
||||
} else if (line.startsWith('id: ')) {
|
||||
currentEvent.id = line.substring(4).trim();
|
||||
} else if (line.startsWith('retry: ')) {
|
||||
currentEvent.retry = parseInt(line.substring(7), 10);
|
||||
}
|
||||
// Empty lines separate events (already handled by JSON parsing)
|
||||
}
|
||||
|
||||
return events;
|
||||
|
||||
@@ -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