mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 10:16:49 +00:00
305 lines
7.5 KiB
TypeScript
305 lines
7.5 KiB
TypeScript
/**
|
|
* Copilot Model Catalog
|
|
*
|
|
* Manages available models from copilot-api.
|
|
* Based on GitHub Copilot supported models:
|
|
* https://docs.github.com/copilot/reference/ai-models/supported-models
|
|
*/
|
|
|
|
import * as http from 'http';
|
|
import { CopilotModel } from './types';
|
|
|
|
const DEFAULT_COPILOT_MODEL_ID = 'gpt-4.1';
|
|
const MAX_MODELS_BODY_SIZE = 1024 * 1024; // 1MB
|
|
|
|
interface CopilotDaemonModelLimits {
|
|
max_context_window_tokens?: unknown;
|
|
max_output_tokens?: unknown;
|
|
max_prompt_tokens?: unknown;
|
|
}
|
|
|
|
interface CopilotDaemonModel {
|
|
id?: unknown;
|
|
name?: unknown;
|
|
capabilities?: {
|
|
limits?: CopilotDaemonModelLimits;
|
|
};
|
|
}
|
|
|
|
interface CopilotModelsResponse {
|
|
data?: CopilotDaemonModel[];
|
|
}
|
|
|
|
/**
|
|
* Default models available through copilot-api.
|
|
* Used as fallback when API is not reachable.
|
|
* Source: GitHub Copilot Supported Models (Dec 2025)
|
|
*
|
|
* Plan tiers: free, pro, pro+, business, enterprise
|
|
* Multipliers: 0 = free tier, 0.25-0.33 = cheap, 1 = standard, 3-10 = premium
|
|
*/
|
|
export const DEFAULT_COPILOT_MODELS: CopilotModel[] = [
|
|
// Anthropic Models - All require paid Copilot subscription
|
|
{
|
|
id: 'claude-sonnet-4.5',
|
|
name: 'Claude Sonnet 4.5',
|
|
provider: 'anthropic',
|
|
minPlan: 'pro',
|
|
multiplier: 1,
|
|
},
|
|
{
|
|
id: 'claude-sonnet-4',
|
|
name: 'Claude Sonnet 4',
|
|
provider: 'anthropic',
|
|
minPlan: 'pro',
|
|
multiplier: 1,
|
|
},
|
|
{
|
|
id: 'claude-opus-4.5',
|
|
name: 'Claude Opus 4.5',
|
|
provider: 'anthropic',
|
|
minPlan: 'pro',
|
|
multiplier: 3,
|
|
preview: true,
|
|
},
|
|
{
|
|
id: 'claude-opus-4.1',
|
|
name: 'Claude Opus 4.1',
|
|
provider: 'anthropic',
|
|
minPlan: 'pro',
|
|
multiplier: 10,
|
|
},
|
|
{
|
|
id: 'claude-haiku-4.5',
|
|
name: 'Claude Haiku 4.5',
|
|
provider: 'anthropic',
|
|
minPlan: 'pro', // Requires paid Copilot subscription
|
|
multiplier: 0.33,
|
|
},
|
|
|
|
// OpenAI Models
|
|
{
|
|
id: 'gpt-5.2',
|
|
name: 'GPT-5.2',
|
|
provider: 'openai',
|
|
minPlan: 'pro',
|
|
multiplier: 1,
|
|
preview: true,
|
|
},
|
|
{
|
|
id: 'gpt-5.1-codex-max',
|
|
name: 'GPT-5.1 Codex Max',
|
|
provider: 'openai',
|
|
minPlan: 'pro',
|
|
multiplier: 1,
|
|
},
|
|
{ id: 'gpt-5.1-codex', name: 'GPT-5.1 Codex', provider: 'openai', minPlan: 'pro', multiplier: 1 },
|
|
{
|
|
id: 'gpt-5.1-codex-mini',
|
|
name: 'GPT-5.1 Codex Mini',
|
|
provider: 'openai',
|
|
minPlan: 'pro',
|
|
multiplier: 0.33,
|
|
preview: true,
|
|
},
|
|
{ id: 'gpt-5.1', name: 'GPT-5.1', provider: 'openai', minPlan: 'pro', multiplier: 1 },
|
|
{
|
|
id: 'gpt-5-codex',
|
|
name: 'GPT-5 Codex',
|
|
provider: 'openai',
|
|
minPlan: 'pro',
|
|
multiplier: 1,
|
|
preview: true,
|
|
},
|
|
{ id: 'gpt-5', name: 'GPT-5', provider: 'openai', minPlan: 'pro', multiplier: 1 },
|
|
{ id: 'gpt-5-mini', name: 'GPT-5 Mini', provider: 'openai', minPlan: 'free', multiplier: 0 },
|
|
{
|
|
id: 'gpt-4.1',
|
|
name: 'GPT-4.1',
|
|
provider: 'openai',
|
|
isDefault: true,
|
|
minPlan: 'free',
|
|
multiplier: 0,
|
|
},
|
|
|
|
// Google Models
|
|
{
|
|
id: 'gemini-3-pro',
|
|
name: 'Gemini 3 Pro',
|
|
provider: 'openai',
|
|
minPlan: 'pro',
|
|
multiplier: 1,
|
|
preview: true,
|
|
},
|
|
{
|
|
id: 'gemini-3-flash',
|
|
name: 'Gemini 3 Flash',
|
|
provider: 'openai',
|
|
minPlan: 'pro',
|
|
multiplier: 0.33,
|
|
preview: true,
|
|
},
|
|
{
|
|
id: 'gemini-2.5-pro',
|
|
name: 'Gemini 2.5 Pro',
|
|
provider: 'openai',
|
|
minPlan: 'pro',
|
|
multiplier: 1,
|
|
},
|
|
|
|
// xAI Models
|
|
{
|
|
id: 'grok-code-fast-1',
|
|
name: 'Grok Code Fast 1',
|
|
provider: 'openai',
|
|
minPlan: 'pro',
|
|
multiplier: 0.25,
|
|
},
|
|
];
|
|
|
|
/**
|
|
* Fetch available models from running copilot-api daemon.
|
|
*
|
|
* @param port The port copilot-api is running on
|
|
* @returns List of available models
|
|
*/
|
|
export async function fetchModelsFromDaemon(port: number): Promise<CopilotModel[]> {
|
|
return new Promise((resolve) => {
|
|
let resolved = false;
|
|
const safeResolve = (models: CopilotModel[]) => {
|
|
if (resolved) return;
|
|
resolved = true;
|
|
resolve(models);
|
|
};
|
|
|
|
const req = http.request(
|
|
{
|
|
// Use 127.0.0.1 instead of localhost for more reliable local connections
|
|
hostname: '127.0.0.1',
|
|
port,
|
|
path: '/v1/models',
|
|
method: 'GET',
|
|
timeout: 5000,
|
|
},
|
|
(res) => {
|
|
let data = '';
|
|
|
|
res.on('data', (chunk) => {
|
|
data += chunk;
|
|
if (data.length > MAX_MODELS_BODY_SIZE) {
|
|
req.destroy();
|
|
safeResolve(DEFAULT_COPILOT_MODELS);
|
|
}
|
|
});
|
|
|
|
res.on('end', () => {
|
|
try {
|
|
const response = JSON.parse(data) as CopilotModelsResponse;
|
|
if (Array.isArray(response.data)) {
|
|
const models = response.data
|
|
.map(mapDaemonModel)
|
|
.filter((model): model is CopilotModel => model !== null);
|
|
safeResolve(models.length > 0 ? models : DEFAULT_COPILOT_MODELS);
|
|
} else {
|
|
safeResolve(DEFAULT_COPILOT_MODELS);
|
|
}
|
|
} catch {
|
|
safeResolve(DEFAULT_COPILOT_MODELS);
|
|
}
|
|
});
|
|
}
|
|
);
|
|
|
|
req.on('error', () => {
|
|
safeResolve(DEFAULT_COPILOT_MODELS);
|
|
});
|
|
|
|
req.on('timeout', () => {
|
|
req.destroy();
|
|
safeResolve(DEFAULT_COPILOT_MODELS);
|
|
});
|
|
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get available models (from daemon or defaults).
|
|
*/
|
|
export async function getAvailableModels(port: number): Promise<CopilotModel[]> {
|
|
return fetchModelsFromDaemon(port);
|
|
}
|
|
|
|
/**
|
|
* Get the default model.
|
|
* Uses gpt-4.1 as it's available on free tier.
|
|
*/
|
|
export function getDefaultModel(): string {
|
|
return DEFAULT_COPILOT_MODEL_ID;
|
|
}
|
|
|
|
/**
|
|
* Detect provider from model ID.
|
|
*/
|
|
function detectProvider(modelId: string): 'openai' | 'anthropic' {
|
|
if (modelId.includes('claude')) return 'anthropic';
|
|
return 'openai';
|
|
}
|
|
|
|
/**
|
|
* Format model ID to human-readable name.
|
|
* Includes badges for preview and plan tier.
|
|
*/
|
|
function formatModelName(modelId: string): string {
|
|
// Find model in catalog for metadata
|
|
const model = DEFAULT_COPILOT_MODELS.find((m) => m.id === modelId);
|
|
if (model) {
|
|
let name = model.name;
|
|
if (model.preview) name += ' (Preview)';
|
|
return name;
|
|
}
|
|
|
|
// Fallback: convert kebab-case to title case
|
|
return modelId
|
|
.split('-')
|
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
.join(' ');
|
|
}
|
|
|
|
function normalizeLimitValue(value: unknown): number | undefined {
|
|
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
|
|
}
|
|
|
|
function extractLimits(limits?: CopilotDaemonModelLimits): CopilotModel['limits'] | undefined {
|
|
if (!limits) return undefined;
|
|
|
|
const normalized = {
|
|
maxContextWindowTokens: normalizeLimitValue(limits.max_context_window_tokens),
|
|
maxOutputTokens: normalizeLimitValue(limits.max_output_tokens),
|
|
maxPromptTokens: normalizeLimitValue(limits.max_prompt_tokens),
|
|
};
|
|
|
|
return Object.values(normalized).some((value) => value !== undefined) ? normalized : undefined;
|
|
}
|
|
|
|
function mapDaemonModel(entry: CopilotDaemonModel): CopilotModel | null {
|
|
if (typeof entry.id !== 'string') return null;
|
|
const id = entry.id.trim();
|
|
if (!id) return null;
|
|
|
|
const defaultMeta = DEFAULT_COPILOT_MODELS.find((model) => model.id === id);
|
|
const limits = extractLimits(entry.capabilities?.limits);
|
|
|
|
return {
|
|
...defaultMeta,
|
|
id,
|
|
name:
|
|
typeof entry.name === 'string' && entry.name.trim().length > 0
|
|
? entry.name.trim()
|
|
: (defaultMeta?.name ?? formatModelName(id)),
|
|
provider: defaultMeta?.provider ?? detectProvider(id),
|
|
isDefault: id === DEFAULT_COPILOT_MODEL_ID,
|
|
...(limits ? { limits } : {}),
|
|
};
|
|
}
|