From 074e90055789bd146d96fd3d1ad917f91e602186 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 14 Apr 2026 19:26:59 -0400 Subject: [PATCH] feat(proxy): add openai-compatible local proxy runtime --- src/ccs.ts | 52 +++ src/commands/command-catalog.ts | 7 + src/commands/completion-backend.ts | 8 + src/commands/help-command.ts | 3 + src/commands/index.ts | 1 + src/commands/proxy-command.ts | 165 ++++++++ src/commands/root-command-router.ts | 7 + src/config/reserved-names.ts | 1 + src/glmt/sse-parser.ts | 102 ++--- src/proxy/index.ts | 7 + src/proxy/profile-router.ts | 70 ++++ src/proxy/proxy-daemon-entry.ts | 80 ++++ src/proxy/proxy-daemon-paths.ts | 17 + src/proxy/proxy-daemon-state.ts | 82 ++++ src/proxy/proxy-daemon.ts | 380 ++++++++++++++++++ src/proxy/proxy-env.ts | 23 ++ src/proxy/server/http-helpers.ts | 81 ++++ src/proxy/server/messages-route.ts | 174 ++++++++ src/proxy/server/proxy-server.ts | 75 ++++ src/proxy/transformers/request-transformer.ts | 89 ++++ .../transformers/sse-stream-transformer.ts | 14 + src/proxy/upstream-url.ts | 36 ++ 22 files changed, 1428 insertions(+), 46 deletions(-) create mode 100644 src/commands/proxy-command.ts create mode 100644 src/proxy/index.ts create mode 100644 src/proxy/profile-router.ts create mode 100644 src/proxy/proxy-daemon-entry.ts create mode 100644 src/proxy/proxy-daemon-paths.ts create mode 100644 src/proxy/proxy-daemon-state.ts create mode 100644 src/proxy/proxy-daemon.ts create mode 100644 src/proxy/proxy-env.ts create mode 100644 src/proxy/server/http-helpers.ts create mode 100644 src/proxy/server/messages-route.ts create mode 100644 src/proxy/server/proxy-server.ts create mode 100644 src/proxy/transformers/request-transformer.ts create mode 100644 src/proxy/transformers/sse-stream-transformer.ts create mode 100644 src/proxy/upstream-url.ts diff --git a/src/ccs.ts b/src/ccs.ts index 5c907085..c1bb2ec3 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -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 { 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, diff --git a/src/commands/command-catalog.ts b/src/commands/command-catalog.ts index 3b14c3b7..099923d7 100644 --- a/src/commands/command-catalog.ts +++ b/src/commands/command-catalog.ts @@ -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', diff --git a/src/commands/completion-backend.ts b/src/commands/completion-backend.ts index 523c08ad..9082687f 100644 --- a/src/commands/completion-backend.ts +++ b/src/commands/completion-backend.ts @@ -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': diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index e3912a11..d1e4ab9f 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -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']), diff --git a/src/commands/index.ts b/src/commands/index.ts index 22d62035..ef9de709 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -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'; diff --git a/src/commands/proxy-command.ts b/src/commands/proxy-command.ts new file mode 100644 index 00000000..9110f3f1 --- /dev/null +++ b/src/commands/proxy-command.ts @@ -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 [profile] [options]'); + console.log(''); + console.log('Commands:'); + console.log( + ' start 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 Override the local proxy port (default: 3456)'); + console.log(' --shell 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 { + const profileName = args.find((arg) => !arg.startsWith('-')); + if (!profileName) { + console.error(fail('Usage: ccs proxy start [--port ] [--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 { + 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 { + 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 ')); + 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 { + 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; + } +} diff --git a/src/commands/root-command-router.ts b/src/commands/root-command-router.ts index be45acc7..250c5910 100644 --- a/src/commands/root-command-router.ts +++ b/src/commands/root-command-router.ts @@ -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) => { diff --git a/src/config/reserved-names.ts b/src/config/reserved-names.ts index 8705d63a..552621b5 100644 --- a/src/config/reserved-names.ts +++ b/src/config/reserved-names.ts @@ -17,6 +17,7 @@ export const RESERVED_PROFILE_NAMES = [ 'default', 'config', 'cliproxy', + 'proxy', ] as const; export type ReservedProfileName = (typeof RESERVED_PROFILE_NAMES)[number]; diff --git a/src/glmt/sse-parser.ts b/src/glmt/sse-parser.ts index 9a731654..c161a7df 100644 --- a/src/glmt/sse-parser.ts +++ b/src/glmt/sse-parser.ts @@ -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; diff --git a/src/proxy/index.ts b/src/proxy/index.ts new file mode 100644 index 00000000..24a0a516 --- /dev/null +++ b/src/proxy/index.ts @@ -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'; diff --git a/src/proxy/profile-router.ts b/src/proxy/profile-router.ts new file mode 100644 index 00000000..da856f6f --- /dev/null +++ b/src/proxy/profile-router.ts @@ -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, + }; +} diff --git a/src/proxy/proxy-daemon-entry.ts b/src/proxy/proxy-daemon-entry.ts new file mode 100644 index 00000000..226433bb --- /dev/null +++ b/src/proxy/proxy-daemon-entry.ts @@ -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))); +} diff --git a/src/proxy/proxy-daemon-paths.ts b/src/proxy/proxy-daemon-paths.ts new file mode 100644 index 00000000..92b6b95b --- /dev/null +++ b/src/proxy/proxy-daemon-paths.ts @@ -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'); +} diff --git a/src/proxy/proxy-daemon-state.ts b/src/proxy/proxy-daemon-state.ts new file mode 100644 index 00000000..c1921948 --- /dev/null +++ b/src/proxy/proxy-daemon-state.ts @@ -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]; +} diff --git a/src/proxy/proxy-daemon.ts b/src/proxy/proxy-daemon.ts new file mode 100644 index 00000000..603c3097 --- /dev/null +++ b/src/proxy/proxy-daemon.ts @@ -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 { + 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(operation: () => Promise): Promise { + const proxyDir = getOpenAICompatProxyDir(); + await fs.promises.mkdir(proxyDir, { recursive: true }); + + let release: (() => Promise) | 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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}`, + }); + }); + }); + }); +} diff --git a/src/proxy/proxy-env.ts b/src/proxy/proxy-env.ts new file mode 100644 index 00000000..ca11813a --- /dev/null +++ b/src/proxy/proxy-env.ts @@ -0,0 +1,23 @@ +import type { OpenAICompatProfileConfig } from './profile-router'; + +export function buildOpenAICompatProxyEnv( + profile: OpenAICompatProfileConfig, + port: number, + authToken: string, + claudeConfigDir?: string +): Record { + 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 } : {}), + }; +} diff --git a/src/proxy/server/http-helpers.ts b/src/proxy/server/http-helpers.ts new file mode 100644 index 00000000..77a4adbc --- /dev/null +++ b/src/proxy/server/http-helpers.ts @@ -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 { + 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 { + 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); + await new Promise((resolve, reject) => { + nodeStream.on('error', reject); + nodeStream.on('end', resolve); + nodeStream.pipe(res); + }); +} diff --git a/src/proxy/server/messages-route.ts b/src/proxy/server/messages-route.ts new file mode 100644 index 00000000..49a66585 --- /dev/null +++ b/src/proxy/server/messages-route.ts @@ -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 { + 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).dispatcher = insecureDispatcher; + } + + return init; +} + +export async function handleProxyMessagesRequest( + req: http.IncomingMessage, + res: http.ServerResponse, + profile: OpenAICompatProfileConfig, + expectedAuthToken: string, + insecureDispatcher?: Dispatcher +): Promise { + 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 }); +} diff --git a/src/proxy/server/proxy-server.ts b/src/proxy/server/proxy-server.ts new file mode 100644 index 00000000..b808d9ee --- /dev/null +++ b/src/proxy/server/proxy-server.ts @@ -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; +} diff --git a/src/proxy/transformers/request-transformer.ts b/src/proxy/transformers/request-transformer.ts new file mode 100644 index 00000000..1804d426 --- /dev/null +++ b/src/proxy/transformers/request-transformer.ts @@ -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 { + max_tokens?: number; + temperature?: number; + top_p?: number; + stop?: string[]; + metadata?: Record; + tools?: Array<{ + type: 'function'; + function: { + name: string; + description?: string; + parameters: Record; + }; + }>; +} + +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 | undefined { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : 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) + : { 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), + }; + } +} diff --git a/src/proxy/transformers/sse-stream-transformer.ts b/src/proxy/transformers/sse-stream-transformer.ts new file mode 100644 index 00000000..53774c21 --- /dev/null +++ b/src/proxy/transformers/sse-stream-transformer.ts @@ -0,0 +1,14 @@ +import { + createAnthropicErrorResponse, + createAnthropicProxyResponse, +} from '../../cursor/cursor-anthropic-response'; + +export class ProxySseStreamTransformer { + async transform(response: Response): Promise { + return createAnthropicProxyResponse(response); + } + + error(status: number, type: string, message: string): Response { + return createAnthropicErrorResponse(status, type, message); + } +} diff --git a/src/proxy/upstream-url.ts b/src/proxy/upstream-url.ts new file mode 100644 index 00000000..d41d89b6 --- /dev/null +++ b/src/proxy/upstream-url.ts @@ -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'); +}