diff --git a/src/bin/code-router.ts b/src/bin/code-router.ts index 26b1fd3..0c358a5 100644 --- a/src/bin/code-router.ts +++ b/src/bin/code-router.ts @@ -13,6 +13,7 @@ import { logout, runClaudeOAuth, runOpenAIOAuth, + runCopilotOAuth, verifySubscriptions, type ProviderSelection, } from '../commands.js'; @@ -106,7 +107,8 @@ function normalizeProviderSelection(value: string): ProviderSelection { normalized === 'all' || normalized === 'claude' || normalized === 'openai' || - normalized === 'openrouter' + normalized === 'openrouter' || + normalized === 'copilot' ) { return normalized; } @@ -123,10 +125,10 @@ Usage: code-router serve start [router flags] code-router serve stop [--port PORT] code-router serve apis [--provider openai|claude|openrouter|all] - code-router verify [--provider claude|openai|all] [--json] - code-router models [--provider claude|openai|openrouter|all] [--json] - code-router auth - code-router logout + code-router verify [--provider claude|openai|copilot|all] [--json] + code-router models [--provider claude|openai|copilot|openrouter|all] [--json] + code-router auth + code-router logout code-router status [--json] Flags: @@ -534,11 +536,17 @@ async function main(): Promise { console.log('ChatGPT authentication saved.'); return; } + if (target === 'copilot' || target === 'github-copilot') { + const enterpriseUrl = getOption(parsed.commandArgs, 'enterprise-url'); + await runCopilotOAuth(enterpriseUrl); + console.log('Copilot authentication saved.'); + return; + } throw new Error(`Unsupported auth target: ${target}`); } case 'logout': { - const target = (parsed.commandArgs[0] || 'all') as 'claude' | 'openai' | 'all'; - if (target !== 'claude' && target !== 'openai' && target !== 'all') { + const target = (parsed.commandArgs[0] || 'all') as 'claude' | 'openai' | 'copilot' | 'all'; + if (target !== 'claude' && target !== 'openai' && target !== 'copilot' && target !== 'all') { throw new Error(`Unsupported logout target: ${target}`); } await logout(target); diff --git a/src/commands.ts b/src/commands.ts index 3c6294d..3f92071 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -14,8 +14,16 @@ import { loadOpenAIAuthState, saveOpenAIAuthState, } from './openai-token-manager.js'; +import { runCopilotOAuthFlow } from './copilot-oauth.js'; +import { + COPILOT_TOKEN_FILE, + loadCopilotAuthState, + saveCopilotAuthState, + getValidCopilotAccessToken, + maskToken, +} from './copilot-token-manager.js'; -export type ProviderSelection = 'all' | 'claude' | 'openai' | 'openrouter'; +export type ProviderSelection = 'all' | 'claude' | 'openai' | 'openrouter' | 'copilot'; type StatusSnapshot = { routerRunning: boolean; @@ -24,19 +32,22 @@ type StatusSnapshot = { claudeExpiresInMinutes: number | null; chatgptConfigured: boolean; chatgptSource: string | null; + copilotConfigured: boolean; }; type ModelsResult = { claude?: string[]; openai?: string[]; openrouter?: string[]; - errors: Partial>; + copilot?: string[]; + errors: Partial>; }; type VerifyResult = { claude?: string; openai?: string; - errors: Partial>; + copilot?: string; + errors: Partial>; }; const OPENAI_MODELS_URL = @@ -274,6 +285,37 @@ async function deleteIfExists(path: string): Promise { } } +async function verifyCopilotSubscription(): Promise { + const accessToken = await getValidCopilotAccessToken(); + const response = await fetch('https://api.githubcopilot.com/chat/completions', { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'User-Agent': 'code-router/0.0.0', + 'Openai-Intent': 'conversation-edits', + 'x-initiator': 'user', + }, + body: JSON.stringify({ + model: 'gpt-4o', + max_tokens: 32, + messages: [{ role: 'user', content: 'Reply with exactly: ok' }], + }), + }); + + if (!response.ok) { + throw new Error(await response.text()); + } + + const payload = (await response.json()) as { + model?: string; + choices?: Array<{ message?: { content?: string } }>; + }; + const model = payload.model || 'unknown'; + const text = payload.choices?.[0]?.message?.content?.trim() || '(empty response)'; + return `${model} -> ${text}`; +} + export async function loadStatusSnapshot(): Promise { const tokens = await loadTokens(); const chatGPTAuthState = await loadOpenAIAuthState(); @@ -286,6 +328,8 @@ export async function loadStatusSnapshot(): Promise { ? `${chatGPTAuthState.source} auth file` : null; + const copilotAuthState = await loadCopilotAuthState(); + return { routerRunning: await isRouterRunning(), claudeConfigured: Boolean(tokens), @@ -294,6 +338,7 @@ export async function loadStatusSnapshot(): Promise { tokens?.expires_at ? Math.floor((tokens.expires_at - Date.now()) / 1000 / 60) : null, chatgptConfigured: chatGPTConfigured, chatgptSource: chatGPTSource, + copilotConfigured: Boolean(copilotAuthState?.accessToken), }; } @@ -306,6 +351,7 @@ export function formatStatusText(status: StatusSnapshot): string { ` ChatGPT: ${status.chatgptConfigured ? 'configured' : 'not configured'}${ status.chatgptConfigured && status.chatgptSource ? ` (${status.chatgptSource})` : '' }`, + ` Copilot: ${status.copilotConfigured ? 'configured' : 'not configured'}`, ` Router: ${status.routerRunning ? 'running' : 'not running'}`, ]; @@ -316,6 +362,7 @@ export async function listModels(provider: ProviderSelection): Promise `openai/${model}`), @@ -382,6 +443,10 @@ export function formatModelsText(provider: ProviderSelection, models: ModelsResu pushSection('ChatGPT', models.openai, models.errors.openai); } + if (provider === 'all' || provider === 'copilot') { + pushSection('Copilot', models.copilot, models.errors.copilot); + } + if (provider === 'all' || provider === 'openrouter') { pushSection('OpenRouter', models.openrouter, models.errors.openrouter); } @@ -395,10 +460,12 @@ export async function verifySubscriptions( const results: VerifyResult = { errors: {} }; const wantsClaude = provider === 'all' || provider === 'claude'; const wantsOpenAI = provider === 'all' || provider === 'openai'; + const wantsCopilot = provider === 'all' || provider === 'copilot'; - const [claudeResult, openAIResult] = await Promise.allSettled([ + const [claudeResult, openAIResult, copilotResult] = await Promise.allSettled([ wantsClaude ? verifyAnthropicSubscription() : Promise.resolve(''), wantsOpenAI ? verifyOpenAISubscription() : Promise.resolve(''), + wantsCopilot ? verifyCopilotSubscription() : Promise.resolve(''), ]); if (wantsClaude) { @@ -417,6 +484,14 @@ export async function verifySubscriptions( } } + if (wantsCopilot) { + if (copilotResult.status === 'fulfilled') { + results.copilot = copilotResult.value; + } else { + results.errors.copilot = formatError(copilotResult.reason); + } + } + return results; } @@ -438,6 +513,12 @@ export function formatVerifyText( ); } + if (provider === 'all' || provider === 'copilot') { + lines.push( + results.copilot ? `Copilot: OK ${results.copilot}` : `Copilot: ERROR ${results.errors.copilot}` + ); + } + return lines.join('\n'); } @@ -479,7 +560,16 @@ export async function runOpenAIOAuth(): Promise { } } -export async function logout(target: 'claude' | 'openai' | 'all'): Promise { +export async function runCopilotOAuth(enterpriseUrl?: string): Promise { + const result = await runCopilotOAuthFlow(enterpriseUrl); + await saveCopilotAuthState({ + accessToken: result.accessToken, + enterpriseUrl: result.enterpriseUrl, + createdAt: new Date().toISOString(), + }); +} + +export async function logout(target: 'claude' | 'openai' | 'copilot' | 'all'): Promise { if (target === 'claude' || target === 'all') { await deleteIfExists(TOKEN_FILE); } @@ -487,4 +577,8 @@ export async function logout(target: 'claude' | 'openai' | 'all'): Promise if (target === 'openai' || target === 'all') { await deleteIfExists(CHATGPT_KEY_FILE); } + + if (target === 'copilot' || target === 'all') { + await deleteIfExists(COPILOT_TOKEN_FILE); + } } diff --git a/src/copilot-oauth.ts b/src/copilot-oauth.ts new file mode 100644 index 0000000..a67b3c4 --- /dev/null +++ b/src/copilot-oauth.ts @@ -0,0 +1,228 @@ +/** + * EDUCATIONAL AND ENTERTAINMENT PURPOSES ONLY + * + * This software is provided for educational, research, and entertainment purposes only. + * It is not affiliated with, endorsed by, or sponsored by GitHub or Microsoft. + * Use at your own risk. No warranties provided. Users are solely responsible for + * ensuring compliance with GitHub's Terms of Service and all applicable laws. + * + * Copyright (c) 2025 - Licensed under MIT License + */ + +/** + * GitHub Copilot OAuth - Device Flow authentication + */ + +import { exec } from 'child_process'; +import { readFileSync } from 'fs'; +import { fileURLToPath } from 'url'; + +const CLIENT_ID = 'Ov23li8tweQw6odWQebz'; +const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000; +const DEVICE_FLOW_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code'; + +let packageVersion = '0.0.0'; +try { + const pkg = JSON.parse( + readFileSync(new URL('../package.json', import.meta.url), 'utf8') + ) as { version?: string }; + packageVersion = pkg.version || '0.0.0'; +} catch { + // ignore +} + +function getUserAgent(): string { + return `code-router/${packageVersion}`; +} + +function normalizeDomain(url: string): string { + return url.replace(/^https?:\/\//, '').replace(/\/$/, ''); +} + +function getUrls(domain: string) { + return { + deviceCodeUrl: `https://${domain}/login/device/code`, + accessTokenUrl: `https://${domain}/login/oauth/access_token`, + }; +} + +function safeOpenUrl(url: string): boolean { + const encodedUrl = `"${url}"`; + const command = + process.platform === 'darwin' + ? `open ${encodedUrl}` + : process.platform === 'win32' + ? `start "" ${encodedUrl}` + : `xdg-open ${encodedUrl}`; + + try { + exec(command); + return true; + } catch { + return false; + } +} + +export interface CopilotDeviceCodeResponse { + verification_uri: string; + user_code: string; + device_code: string; + interval: number; +} + +export interface CopilotOAuthResult { + accessToken: string; + enterpriseUrl?: string; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Request a device code from GitHub + */ +async function requestDeviceCode(domain: string): Promise { + const { deviceCodeUrl } = getUrls(domain); + + const response = await fetch(deviceCodeUrl, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': getUserAgent(), + }, + body: JSON.stringify({ + client_id: CLIENT_ID, + scope: 'read:user', + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Failed to initiate device authorization: ${text}`); + } + + return (await response.json()) as CopilotDeviceCodeResponse; +} + +/** + * Poll for the access token after user authorizes + */ +async function pollForToken( + domain: string, + deviceCode: string, + initialInterval: number +): Promise { + const { accessTokenUrl } = getUrls(domain); + let interval = initialInterval; + + while (true) { + const response = await fetch(accessTokenUrl, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': getUserAgent(), + }, + body: JSON.stringify({ + client_id: CLIENT_ID, + device_code: deviceCode, + grant_type: DEVICE_FLOW_GRANT_TYPE, + }), + }); + + if (!response.ok) { + throw new Error('Token polling request failed'); + } + + const data = (await response.json()) as { + access_token?: string; + error?: string; + interval?: number; + }; + + if (data.access_token) { + return data.access_token; + } + + if (data.error === 'authorization_pending') { + await sleep(interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS); + continue; + } + + if (data.error === 'slow_down') { + // RFC 8628 ยง3.5: add 5 seconds to polling interval + let newInterval = (interval + 5) * 1000; + if (data.interval && typeof data.interval === 'number' && data.interval > 0) { + newInterval = data.interval * 1000; + interval = data.interval; + } else { + interval += 5; + } + await sleep(newInterval + OAUTH_POLLING_SAFETY_MARGIN_MS); + continue; + } + + if (data.error === 'expired_token') { + throw new Error('Device code expired. Please try again.'); + } + + if (data.error === 'access_denied') { + throw new Error('Authorization was denied by the user.'); + } + + if (data.error) { + throw new Error(`Authorization failed: ${data.error}`); + } + + await sleep(interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS); + } +} + +/** + * Run the full GitHub Copilot Device Flow OAuth + */ +export async function runCopilotOAuthFlow( + enterpriseUrl?: string +): Promise { + const domain = enterpriseUrl ? normalizeDomain(enterpriseUrl) : 'github.com'; + + console.log('\n๐Ÿ” Starting GitHub Copilot OAuth flow...\n'); + + const deviceData = await requestDeviceCode(domain); + + console.log('Open this URL in your browser:\n'); + console.log(` ${deviceData.verification_uri}\n`); + console.log(`Enter this code: ${deviceData.user_code}\n`); + + const opened = safeOpenUrl(deviceData.verification_uri); + if (opened) { + console.log('Browser opened automatically.'); + } else { + console.log('โš ๏ธ Could not open browser automatically. Open the URL above manually.'); + } + + console.log('\nWaiting for authorization...\n'); + + const accessToken = await pollForToken(domain, deviceData.device_code, deviceData.interval); + + console.log('โœ… GitHub Copilot authorization successful!\n'); + + const result: CopilotOAuthResult = { accessToken }; + if (enterpriseUrl) { + result.enterpriseUrl = domain; + } + + return result; +} + +/** + * Get the Copilot API base URL for a given enterprise domain + */ +export function getCopilotApiBaseUrl(enterpriseUrl?: string): string { + if (enterpriseUrl) { + return `https://copilot-api.${normalizeDomain(enterpriseUrl)}`; + } + return 'https://api.githubcopilot.com'; +} diff --git a/src/copilot-token-manager.ts b/src/copilot-token-manager.ts new file mode 100644 index 0000000..cdd307b --- /dev/null +++ b/src/copilot-token-manager.ts @@ -0,0 +1,99 @@ +/** + * EDUCATIONAL AND ENTERTAINMENT PURPOSES ONLY + * + * This software is provided for educational, research, and entertainment purposes only. + * It is not affiliated with, endorsed by, or sponsored by GitHub or Microsoft. + * Use at your own risk. No warranties provided. Users are solely responsible for + * ensuring compliance with GitHub's Terms of Service and all applicable laws. + * + * Copyright (c) 2025 - Licensed under MIT License + */ + +/** + * GitHub Copilot token persistence helpers + */ + +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; + +export const COPILOT_TOKEN_FILE = path.join(os.homedir(), '.copilot-token.json'); + +export interface CopilotAuthState { + accessToken: string; + enterpriseUrl?: string; + createdAt: string; +} + +interface CopilotAuthFile { + access_token: string; + enterprise_url?: string; + created_at: string; +} + +/** + * Save Copilot auth state to file + */ +export async function saveCopilotAuthState(state: CopilotAuthState): Promise { + const payload: CopilotAuthFile = { + access_token: state.accessToken.trim(), + enterprise_url: state.enterpriseUrl?.trim(), + created_at: state.createdAt, + }; + + await fs.writeFile(COPILOT_TOKEN_FILE, JSON.stringify(payload, null, 2), { + encoding: 'utf-8', + mode: 0o600, + }); + console.log(`โœ… Copilot auth saved to ${COPILOT_TOKEN_FILE}`); +} + +/** + * Load Copilot auth state from file + */ +export async function loadCopilotAuthState(): Promise { + try { + const content = await fs.readFile(COPILOT_TOKEN_FILE, 'utf-8'); + const payload = JSON.parse(content) as CopilotAuthFile; + + if (typeof payload.access_token !== 'string' || !payload.access_token.trim()) { + return null; + } + + return { + accessToken: payload.access_token.trim(), + enterpriseUrl: payload.enterprise_url?.trim(), + createdAt: payload.created_at, + }; + } catch { + return null; + } +} + +/** + * Get a valid Copilot access token (GitHub tokens don't expire) + */ +export async function getValidCopilotAccessToken(): Promise { + const state = await loadCopilotAuthState(); + if (!state) { + throw new Error('No Copilot auth found. Run Copilot OAuth (code-router auth copilot) first.'); + } + return state.accessToken; +} + +/** + * Get the enterprise URL if configured + */ +export async function getCopilotEnterpriseUrl(): Promise { + const state = await loadCopilotAuthState(); + return state?.enterpriseUrl; +} + +/** + * Mask a token for display + */ +export function maskToken(token: string): string { + if (!token) return ''; + if (token.length <= 8) return '*'.repeat(Math.min(token.length, 4)); + return `${token.slice(0, 4)}...${token.slice(-4)}`; +} diff --git a/src/legacy-cli.ts b/src/legacy-cli.ts index 95df0d6..24b87ca 100644 --- a/src/legacy-cli.ts +++ b/src/legacy-cli.ts @@ -33,6 +33,13 @@ import { maskApiKey, getValidOpenAIAccessToken, } from './openai-token-manager.js'; +import { runCopilotOAuthFlow } from './copilot-oauth.js'; +import { + COPILOT_TOKEN_FILE, + loadCopilotAuthState, + saveCopilotAuthState, + maskToken, +} from './copilot-token-manager.js'; import type { OAuthTokens } from './types.js'; const rl = readline.createInterface({ @@ -291,6 +298,18 @@ async function showAuthStatus() { console.log('โŒ ChatGPT not configured\n'); } + const copilotAuthState = await loadCopilotAuthState(); + if (copilotAuthState) { + console.log('โœ… Copilot configured'); + console.log(` Token: ${maskToken(copilotAuthState.accessToken)}`); + if (copilotAuthState.enterpriseUrl) { + console.log(` Enterprise: ${copilotAuthState.enterpriseUrl}`); + } + console.log(''); + } else { + console.log('โŒ Copilot not configured\n'); + } + return tokens; } @@ -361,6 +380,28 @@ async function handleAuthenticateChatGPTOAuth() { } } +async function handleAuthenticateCopilotOAuth() { + printSection('COPILOT OAUTH'); + + const enterpriseUrl = (await question('Enterprise URL (leave blank for github.com): ')).trim(); + + try { + const result = await runCopilotOAuthFlow(enterpriseUrl || undefined); + await saveCopilotAuthState({ + accessToken: result.accessToken, + enterpriseUrl: result.enterpriseUrl, + createdAt: new Date().toISOString(), + }); + console.log('\nโœ… Copilot OAuth authentication saved.\n'); + console.log('Token:'); + console.log(maskToken(result.accessToken)); + console.log('\nRestart router to load updated auth if it is already running.\n'); + } catch (error) { + console.error('\nโŒ Copilot OAuth flow failed:', error instanceof Error ? error.message : error); + console.log(''); + } +} + async function handleAuthenticate() { printSection('CLAUDE MAX OAUTH'); console.log('\nStarting OAuth flow...'); @@ -480,15 +521,19 @@ async function handleAuthenticateMenu() { console.log('\nOptions:'); console.log(' 1. Claude MAX OAuth'); console.log(' 2. ChatGPT OAuth'); - console.log(' 3. Back\n'); + console.log(' 3. Copilot OAuth'); + console.log(' 4. Back\n'); - switch ((await question('Select option (1-3): ')).trim()) { + switch ((await question('Select option (1-4): ')).trim()) { case '1': await handleAuthenticate(); return; case '2': await handleAuthenticateChatGPTOAuth(); return; + case '3': + await handleAuthenticateCopilotOAuth(); + return; default: return; } @@ -527,11 +572,12 @@ async function handleLogout() { console.log('\nOptions:'); console.log(' 1. Claude'); console.log(' 2. ChatGPT'); - console.log(' 3. Both'); - console.log(' 4. Back\n'); + console.log(' 3. Copilot'); + console.log(' 4. All'); + console.log(' 5. Back\n'); - const choice = (await question('Select option (1-4): ')).trim(); - if (choice === '4') { + const choice = (await question('Select option (1-5): ')).trim(); + if (choice === '5') { return; } @@ -541,14 +587,18 @@ async function handleLogout() { return; } - if (choice === '1' || choice === '3') { + if (choice === '1' || choice === '4') { await deleteIfExists(TOKEN_FILE); } - if (choice === '2' || choice === '3') { + if (choice === '2' || choice === '4') { await deleteIfExists(CHATGPT_KEY_FILE); } + if (choice === '3' || choice === '4') { + await deleteIfExists(COPILOT_TOKEN_FILE); + } + console.log('\nโœ… Stored credentials removed.\n'); } diff --git a/src/router/model-mapper.ts b/src/router/model-mapper.ts index 6492f0e..1ba3bca 100644 --- a/src/router/model-mapper.ts +++ b/src/router/model-mapper.ts @@ -137,6 +137,66 @@ export function mapAnthropicModelToOpenAI(modelName: string): string { return 'gpt-4o'; } +/** + * Check if a model name is a Copilot-native model (GPT or OpenAI family) + */ +export function isCopilotNativeModel(modelName: string): boolean { + const normalized = modelName.toLowerCase(); + return ( + normalized.startsWith('gpt-') || + normalized.startsWith('o1') || + normalized.startsWith('o3') || + normalized.startsWith('o4') + ); +} + +/** + * Check if a model should use the Copilot Responses API instead of Chat Completions + */ +export function shouldUseCopilotResponsesApi(modelName: string): boolean { + const match = /^gpt-(\d+)/.exec(modelName); + if (!match) return false; + return Number(match[1]) >= 5 && !modelName.startsWith('gpt-5-mini'); +} + +/** + * Map any model name to one usable via Copilot + * Claude models get mapped to GPT equivalents, GPT models pass through + */ +export function mapModelToCopilot(modelName: string): string { + if (process.env.COPILOT_DEFAULT_MODEL) { + return process.env.COPILOT_DEFAULT_MODEL; + } + + const normalized = modelName.toLowerCase(); + + // GPT and o-series models pass through directly + if (isCopilotNativeModel(normalized)) { + return modelName; + } + + // Claude models are available through Copilot directly + if (normalized.startsWith('claude-')) { + return modelName; + } + + // Map Anthropic-style names to GPT equivalents + if (normalized.includes('opus')) { + return 'gpt-4o'; + } + + if (normalized.includes('haiku')) { + return 'gpt-4o-mini'; + } + + if (normalized.includes('sonnet')) { + return 'gpt-4o'; + } + + // Default to gpt-4o + return 'gpt-4o'; +} + /** * Get a description of which pattern matched for a given model * Used for logging/debugging diff --git a/src/router/server.ts b/src/router/server.ts index ba6e826..5a12df8 100644 --- a/src/router/server.ts +++ b/src/router/server.ts @@ -8,6 +8,9 @@ import { getValidOpenAIAccessToken, loadOpenAIAuthState, } from '../openai-token-manager.js'; +import { getValidCopilotAccessToken, loadCopilotAuthState, getCopilotEnterpriseUrl } from '../copilot-token-manager.js'; +import { getCopilotApiBaseUrl } from '../copilot-oauth.js'; +import { shouldUseCopilotResponsesApi, mapModelToCopilot } from './model-mapper.js'; import { ensureRequiredSystemPrompt, stripUnknownFields } from './middleware.js'; import { AnthropicRequest, @@ -82,6 +85,11 @@ let openAIAuth = { authorization: null as string | null, accountId: undefined as string | undefined, }; +let copilotAuth = { + sourceConfigured: false, + authorization: null as string | null, + enterpriseUrl: undefined as string | undefined, +}; let anthropicAuthConfigured = false; type OpenAIAuthContext = { @@ -98,6 +106,7 @@ function normalizeProvider(value: string | null | undefined): Provider | null { const normalized = value.toLowerCase().trim(); if (normalized === 'openai') return 'openai'; if (normalized === 'anthropic' || normalized === 'claude') return 'anthropic'; + if (normalized === 'copilot' || normalized === 'github-copilot') return 'copilot'; return null; } @@ -215,6 +224,43 @@ function buildOpenAIRequestHeaders( return headers; } +function detectVisionContent(messages: OpenAIMessage[]): boolean { + return messages.some( + (msg) => + Array.isArray(msg.content) && + msg.content.some((part) => part.type === 'image_url') + ); +} + +function detectAgentMode(messages: OpenAIMessage[]): boolean { + if (messages.length === 0) return false; + const last = messages[messages.length - 1]; + return last.role !== 'user'; +} + +function buildCopilotRequestHeaders( + accessToken: string, + requestId: string, + messages: OpenAIMessage[] +): Record { + const isVision = detectVisionContent(messages); + const isAgent = detectAgentMode(messages); + + const headers: Record = { + 'Content-Type': 'application/json', + 'User-Agent': `code-router/${packageJson.version}`, + Authorization: `Bearer ${accessToken}`, + 'Openai-Intent': 'conversation-edits', + 'x-initiator': isAgent ? 'agent' : 'user', + }; + + if (isVision) { + headers['Copilot-Vision-Request'] = 'true'; + } + + return headers; +} + function logOutgoingRequest( provider: 'anthropic' | 'openai', method: string, @@ -259,14 +305,18 @@ function resolveChatProvider(req: Request, request?: OpenAIProviderHint): Provid const availability = getCachedSubscriptionAvailability(); const requestedModelProvider = classifyModelProvider(request?.model); - if (availability.openai && !availability.anthropic) { + if (availability.openai && !availability.anthropic && !availability.copilot) { return 'openai'; } - if (availability.anthropic && !availability.openai) { + if (availability.anthropic && !availability.openai && !availability.copilot) { return 'anthropic'; } + if (availability.copilot && !availability.openai && !availability.anthropic) { + return 'copilot'; + } + if (requestedModelProvider === 'openai' && availability.openai) { return 'openai'; } @@ -275,6 +325,10 @@ function resolveChatProvider(req: Request, request?: OpenAIProviderHint): Provid return 'anthropic'; } + if (hinted === 'copilot' && copilotAuth.sourceConfigured) { + return 'copilot'; + } + if (hinted) { return hinted; } @@ -469,6 +523,28 @@ async function hydrateOpenAIAuthState(): Promise { }; } +async function hydrateCopilotAuthState(): Promise { + if (copilotAuth.sourceConfigured && copilotAuth.authorization) { + return; + } + + const state = await loadCopilotAuthState(); + if (state?.accessToken) { + copilotAuth = { + sourceConfigured: true, + authorization: state.accessToken, + enterpriseUrl: state.enterpriseUrl, + }; + return; + } + + copilotAuth = { + sourceConfigured: false, + authorization: null, + enterpriseUrl: undefined, + }; +} + async function resolveStoredOpenAIAuthorization(): Promise { if (!openAIAuth.sourceConfigured || !openAIAuth.authorization) { return null; @@ -1201,6 +1277,13 @@ const modelCache: Record = { lastError: null, refreshing: null, }, + copilot: { + raw: null, + models: [], + fetchedAt: null, + lastError: null, + refreshing: null, + }, }; function extractOpenAIModels(payload: unknown): Record[] { @@ -1236,6 +1319,7 @@ function getCachedSubscriptionAvailability(): Record { endpointConfig.anthropicEnabled && anthropicAuthConfigured && modelCache.anthropic.models.length > 0, + copilot: copilotAuth.sourceConfigured, }; } @@ -1279,6 +1363,10 @@ function getLatestAnthropicSonnetModel(): string { function remapModelForProvider(provider: Provider, requestedModel: string): string { const requestedProvider = classifyModelProvider(requestedModel); + if (provider === 'copilot') { + return mapModelToCopilot(requestedModel); + } + if (provider === 'openai' && requestedProvider === 'anthropic') { return getLatestOpenAIModel(); } @@ -1716,6 +1804,100 @@ const handleMessagesRequest = async (req: Request, res: Response) => { return; } + if (provider === 'copilot') { + const openaiRequest = translateAnthropicToOpenAIRequest(routedRequest); + const copilotToken = await getValidCopilotAccessToken(); + const baseUrl = getCopilotApiBaseUrl(copilotAuth.enterpriseUrl); + const copilotHeaders = buildCopilotRequestHeaders(copilotToken, requestId, openaiRequest.messages); + + const useCopilotResponsesApi = shouldUseCopilotResponsesApi(openaiRequest.model); + const copilotUrl = useCopilotResponsesApi + ? `${baseUrl}/responses` + : `${baseUrl}/chat/completions`; + + let copilotBody: unknown; + if (useCopilotResponsesApi) { + copilotBody = convertChatCompletionsToResponsesRequest(openaiRequest); + } else { + copilotBody = openaiRequest; + } + + logOutgoingRequest('openai', 'POST', copilotUrl, copilotHeaders, copilotBody); + + const response = await fetch(copilotUrl, { + method: 'POST', + headers: copilotHeaders, + body: JSON.stringify(copilotBody), + }); + + if (openaiRequest.stream && response.headers.get('content-type')?.includes('text/event-stream')) { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.status(response.status); + + const messageId = `chatcmpl-${requestId}`; + if (useCopilotResponsesApi) { + const codexStream = translateCodexResponsesStreamToChatCompletions( + response.body as AsyncIterable, + formatModelForApiMode(openaiRequest.model, apiMode), + messageId + ); + const translatedStream = translateOpenAIStreamToAnthropic( + encodeStringStream(codexStream), + formatModelForApiMode(openaiRequest.model, apiMode), + messageId + ); + await streamResponse(res, translatedStream); + } else { + const translatedStream = translateOpenAIStreamToAnthropic( + response.body as AsyncIterable, + formatModelForApiMode(openaiRequest.model, apiMode), + messageId + ); + await streamResponse(res, translatedStream); + } + + logger.logRequest(requestId, timestamp, routedRequest, hadSystemPrompt, + { status: response.status, data: undefined }, undefined, 'openai'); + return; + } + + if (!response.ok) { + const errorText = await response.text(); + const errorData = { + error: { + message: errorText || `Copilot backend error (${response.status})`, + type: 'invalid_request_error', + param: null, + code: null, + }, + } satisfies OpenAIErrorResponse; + const anthropicError = translateOpenAIErrorToAnthropic(errorData); + res.status(response.status).json(anthropicError); + return; + } + + let openaiResponse: OpenAIChatCompletionResponse; + if (useCopilotResponsesApi) { + openaiResponse = await readCodexResponsesAsChatCompletion( + response.body as AsyncIterable, + formatModelForApiMode(openaiRequest.model, apiMode) + ); + } else { + openaiResponse = (await response.json()) as OpenAIChatCompletionResponse; + } + + const anthropicResponse = translateOpenAIToAnthropicResponse( + openaiResponse, + formatModelForApiMode(routedRequest.model, apiMode) + ); + logger.logRequest(requestId, timestamp, routedRequest, hadSystemPrompt, + { status: response.status, data: anthropicResponse }, undefined, 'openai'); + res.status(response.status).json(anthropicResponse); + return; + } + const modifiedRequest = ensureRequiredSystemPrompt(routedRequest); const clientBearerToken = extractBearerToken(req); @@ -1901,6 +2083,77 @@ const handleChatCompletionsRequest = async (req: Request, res: Response) => { return; } + if (provider === 'copilot') { + const copilotToken = await getValidCopilotAccessToken(); + const baseUrl = getCopilotApiBaseUrl(copilotAuth.enterpriseUrl); + const copilotHeaders = buildCopilotRequestHeaders(copilotToken, requestId, routedOpenAIRequest.messages); + + const useCopilotResponsesApi = shouldUseCopilotResponsesApi(routedOpenAIRequest.model); + const copilotUrl = useCopilotResponsesApi + ? `${baseUrl}/responses` + : `${baseUrl}/chat/completions`; + + let copilotBody: unknown; + if (useCopilotResponsesApi) { + copilotBody = convertChatCompletionsToResponsesRequest(routedOpenAIRequest); + } else { + copilotBody = routedOpenAIRequest; + } + + logOutgoingRequest('openai', 'POST', copilotUrl, copilotHeaders, copilotBody); + + const response = await fetch(copilotUrl, { + method: 'POST', + headers: copilotHeaders, + body: JSON.stringify(copilotBody), + }); + + if (!response.ok) { + const errorText = await response.text(); + res.status(response.status).json({ + error: { + message: errorText || `Copilot backend error (${response.status})`, + type: 'invalid_request_error', + param: null, + code: null, + }, + } satisfies OpenAIErrorResponse); + return; + } + + if (routedOpenAIRequest.stream) { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.status(response.status); + + const messageId = `chatcmpl-${requestId}`; + if (useCopilotResponsesApi) { + const translatedStream = translateCodexResponsesStreamToChatCompletions( + response.body as AsyncIterable, + formatModelForApiMode(routedOpenAIRequest.model, apiMode), + messageId + ); + await streamResponse(res, translatedStream); + } else { + await streamResponse(res, response.body as AsyncIterable); + } + return; + } + + if (useCopilotResponsesApi) { + const responseData = await readCodexResponsesAsChatCompletion( + response.body as AsyncIterable, + formatModelForApiMode(routedOpenAIRequest.model, apiMode) + ); + res.status(response.status).json(responseData); + } else { + const data = await response.json(); + res.status(response.status).json(data); + } + return; + } + const authorization = await getOpenAIAuthorization(req); if (!authorization) { res.status(401).json({ @@ -2117,6 +2370,77 @@ const handleResponsesRequest = async (req: Request, res: Response) => { return; } + if (routedSelection.provider === 'copilot') { + const copilotToken = await getValidCopilotAccessToken(); + const baseUrl = getCopilotApiBaseUrl(copilotAuth.enterpriseUrl); + const copilotHeaders = buildCopilotRequestHeaders(copilotToken, requestId, routedOpenAIRequest.messages); + + const useCopilotResponsesApi = shouldUseCopilotResponsesApi(routedOpenAIRequest.model); + const copilotUrl = useCopilotResponsesApi + ? `${baseUrl}/responses` + : `${baseUrl}/chat/completions`; + + let copilotBody: unknown; + if (useCopilotResponsesApi) { + copilotBody = convertChatCompletionsToResponsesRequest(routedOpenAIRequest); + } else { + copilotBody = routedOpenAIRequest; + } + + logOutgoingRequest('openai', 'POST', copilotUrl, copilotHeaders, copilotBody); + + const response = await fetch(copilotUrl, { + method: 'POST', + headers: copilotHeaders, + body: JSON.stringify(copilotBody), + }); + + if (!response.ok) { + const errorText = await response.text(); + res.status(response.status).json({ + error: { + message: errorText || `Copilot backend error (${response.status})`, + type: 'invalid_request_error', + param: null, + code: null, + }, + } satisfies OpenAIErrorResponse); + return; + } + + if (routedOpenAIRequest.stream) { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.status(response.status); + + const messageId = `chatcmpl-${requestId}`; + if (useCopilotResponsesApi) { + const translatedStream = translateCodexResponsesStreamToChatCompletions( + response.body as AsyncIterable, + formatModelForApiMode(routedOpenAIRequest.model, apiMode), + messageId + ); + await streamResponse(res, translatedStream); + } else { + await streamResponse(res, response.body as AsyncIterable); + } + return; + } + + if (useCopilotResponsesApi) { + const responseData = await readCodexResponsesAsChatCompletion( + response.body as AsyncIterable, + formatModelForApiMode(routedOpenAIRequest.model, apiMode) + ); + res.status(response.status).json(responseData); + } else { + const data = await response.json(); + res.status(response.status).json(data); + } + return; + } + const anthropicRequest = translateOpenAIToAnthropic(routedOpenAIRequest); const hadSystemPrompt = !!(anthropicRequest.system && anthropicRequest.system.length > 0); const modifiedRequest = ensureRequiredSystemPrompt(anthropicRequest); @@ -2242,6 +2566,7 @@ if (endpointConfig.anthropicEnabled || endpointConfig.openaiEnabled) { async function startRouter() { await hydrateOpenAIAuthState(); + await hydrateCopilotAuthState(); logger.startup(''); logger.startup('โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•— โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ•—'); @@ -2335,6 +2660,11 @@ async function startRouter() { logger.startup( ` OpenAI auth: ${openAIAuth.sourceConfigured ? 'configured' : 'missing'} (env key or saved key)` ); + logger.startup( + ` Copilot auth: ${copilotAuth.sourceConfigured ? 'configured' : 'missing'}${ + copilotAuth.enterpriseUrl ? ` (enterprise: ${copilotAuth.enterpriseUrl})` : '' + }` + ); logger.startup(''); }); } diff --git a/src/types.ts b/src/types.ts index f641328..ba06322 100644 --- a/src/types.ts +++ b/src/types.ts @@ -103,7 +103,7 @@ export interface AnthropicResponse { }; } -export type Provider = 'anthropic' | 'openai'; +export type Provider = 'anthropic' | 'openai' | 'copilot'; export interface OpenAIResponsesRequest { [key: string]: unknown;