Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions src/bin/code-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
logout,
runClaudeOAuth,
runOpenAIOAuth,
runCopilotOAuth,
verifySubscriptions,
type ProviderSelection,
} from '../commands.js';
Expand Down Expand Up @@ -106,7 +107,8 @@ function normalizeProviderSelection(value: string): ProviderSelection {
normalized === 'all' ||
normalized === 'claude' ||
normalized === 'openai' ||
normalized === 'openrouter'
normalized === 'openrouter' ||
normalized === 'copilot'
) {
return normalized;
}
Expand All @@ -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 <claude|openai|status>
code-router logout <claude|openai|all>
code-router verify [--provider claude|openai|copilot|all] [--json]
code-router models [--provider claude|openai|copilot|openrouter|all] [--json]
code-router auth <claude|openai|copilot|status>
code-router logout <claude|openai|copilot|all>
code-router status [--json]

Flags:
Expand Down Expand Up @@ -534,11 +536,17 @@ async function main(): Promise<void> {
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);
Expand Down
104 changes: 99 additions & 5 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<Record<'claude' | 'openai' | 'openrouter', string>>;
copilot?: string[];
errors: Partial<Record<'claude' | 'openai' | 'openrouter' | 'copilot', string>>;
};

type VerifyResult = {
claude?: string;
openai?: string;
errors: Partial<Record<'claude' | 'openai', string>>;
copilot?: string;
errors: Partial<Record<'claude' | 'openai' | 'copilot', string>>;
};

const OPENAI_MODELS_URL =
Expand Down Expand Up @@ -274,6 +285,37 @@ async function deleteIfExists(path: string): Promise<void> {
}
}

async function verifyCopilotSubscription(): Promise<string> {
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<StatusSnapshot> {
const tokens = await loadTokens();
const chatGPTAuthState = await loadOpenAIAuthState();
Expand All @@ -286,6 +328,8 @@ export async function loadStatusSnapshot(): Promise<StatusSnapshot> {
? `${chatGPTAuthState.source} auth file`
: null;

const copilotAuthState = await loadCopilotAuthState();

return {
routerRunning: await isRouterRunning(),
claudeConfigured: Boolean(tokens),
Expand All @@ -294,6 +338,7 @@ export async function loadStatusSnapshot(): Promise<StatusSnapshot> {
tokens?.expires_at ? Math.floor((tokens.expires_at - Date.now()) / 1000 / 60) : null,
chatgptConfigured: chatGPTConfigured,
chatgptSource: chatGPTSource,
copilotConfigured: Boolean(copilotAuthState?.accessToken),
};
}

Expand All @@ -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'}`,
];

Expand All @@ -316,6 +362,7 @@ export async function listModels(provider: ProviderSelection): Promise<ModelsRes
const results: ModelsResult = { errors: {} };
const wantsClaude = provider === 'all' || provider === 'claude' || provider === 'openrouter';
const wantsOpenAI = provider === 'all' || provider === 'openai' || provider === 'openrouter';
const wantsCopilot = provider === 'all' || provider === 'copilot';

const [claudeResult, openAIResult] = await Promise.allSettled([
wantsClaude ? fetchAnthropicModels() : Promise.resolve([]),
Expand All @@ -338,6 +385,20 @@ export async function listModels(provider: ProviderSelection): Promise<ModelsRes
}
}

if (wantsCopilot) {
results.copilot = [
'gpt-4o',
'gpt-4o-mini',
'gpt-4.1',
'gpt-4.1-mini',
'gpt-5',
'gpt-5-mini',
'claude-sonnet-4',
'claude-sonnet-4-6',
'o4-mini',
];
}

if (provider === 'all' || provider === 'openrouter') {
const openRouterModels = [
...(results.openai || []).map((model) => `openai/${model}`),
Expand Down Expand Up @@ -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);
}
Expand All @@ -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) {
Expand All @@ -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;
}

Expand All @@ -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');
}

Expand Down Expand Up @@ -479,12 +560,25 @@ export async function runOpenAIOAuth(): Promise<void> {
}
}

export async function logout(target: 'claude' | 'openai' | 'all'): Promise<void> {
export async function runCopilotOAuth(enterpriseUrl?: string): Promise<void> {
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<void> {
if (target === 'claude' || target === 'all') {
await deleteIfExists(TOKEN_FILE);
}

if (target === 'openai' || target === 'all') {
await deleteIfExists(CHATGPT_KEY_FILE);
}

if (target === 'copilot' || target === 'all') {
await deleteIfExists(COPILOT_TOKEN_FILE);
}
}
Loading