diff --git a/src/commands/copilot-command.ts b/src/commands/copilot-command.ts index 7b6620ee..9fbf0c1a 100644 --- a/src/commands/copilot-command.ts +++ b/src/commands/copilot-command.ts @@ -13,6 +13,7 @@ import { getAvailableModels, isCopilotApiInstalled, } from '../copilot'; +import type { CopilotModel } from '../copilot'; import { loadOrCreateUnifiedConfig, saveUnifiedConfig } from '../config/unified-config-loader'; import { DEFAULT_COPILOT_CONFIG } from '../config/unified-config-types'; import { ok, fail, info, color } from '../utils/ui'; @@ -195,14 +196,56 @@ async function handleModels(): Promise { const defaultMark = model.isDefault ? ' (default)' : ''; console.log(` ${model.id}${current}${defaultMark}`); console.log(` Provider: ${model.provider}`); + const limits = formatModelLimits(model); + if (limits) { + console.log(` Limits: ${limits}`); + } } + console.log(''); + if (models.some((model) => formatModelLimits(model))) { + console.log('Live limits above come from GitHub Copilot model metadata.'); + } else { + console.log('Live Copilot limits were unavailable. Start the daemon and rerun this command.'); + } + console.log( + 'CCS can switch Copilot models, but it cannot raise GitHub Copilot prompt/context caps.' + ); console.log(''); console.log('To change model: ccs config (Copilot section)'); return 0; } +function formatCompactTokens(value: number): string { + if (value >= 1_000_000) { + const millions = value / 1_000_000; + return millions % 1 === 0 ? `${millions}M` : `${millions.toFixed(1)}M`; + } + if (value >= 1_000) { + const thousands = value / 1_000; + return thousands % 1 === 0 ? `${thousands}K` : `${thousands.toFixed(1)}K`; + } + return `${value}`; +} + +function formatModelLimits(model: CopilotModel): string | null { + if (!model.limits) return null; + + const parts: string[] = []; + if (model.limits.maxPromptTokens) { + parts.push(`prompt ${formatCompactTokens(model.limits.maxPromptTokens)}`); + } + if (model.limits.maxContextWindowTokens) { + parts.push(`context ${formatCompactTokens(model.limits.maxContextWindowTokens)}`); + } + if (model.limits.maxOutputTokens) { + parts.push(`output ${formatCompactTokens(model.limits.maxOutputTokens)}`); + } + + return parts.length > 0 ? parts.join(' | ') : null; +} + function formatQuotaLine( label: string, snapshot: { diff --git a/src/copilot/copilot-models.ts b/src/copilot/copilot-models.ts index 07aaf195..667e6d06 100644 --- a/src/copilot/copilot-models.ts +++ b/src/copilot/copilot-models.ts @@ -9,6 +9,27 @@ 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. @@ -154,6 +175,13 @@ export const DEFAULT_COPILOT_MODELS: CopilotModel[] = [ */ export async function fetchModelsFromDaemon(port: number): Promise { 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 @@ -168,36 +196,37 @@ export async function fetchModelsFromDaemon(port: number): Promise { 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 { data?: Array<{ id: string }> }; - if (response.data && Array.isArray(response.data)) { - const models: CopilotModel[] = response.data.map((m) => ({ - id: m.id, - name: formatModelName(m.id), - provider: detectProvider(m.id), - isDefault: m.id === 'gpt-4.1', // Free tier default - })); - resolve(models.length > 0 ? models : DEFAULT_COPILOT_MODELS); + 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 { - resolve(DEFAULT_COPILOT_MODELS); + safeResolve(DEFAULT_COPILOT_MODELS); } } catch { - resolve(DEFAULT_COPILOT_MODELS); + safeResolve(DEFAULT_COPILOT_MODELS); } }); } ); req.on('error', () => { - resolve(DEFAULT_COPILOT_MODELS); + safeResolve(DEFAULT_COPILOT_MODELS); }); req.on('timeout', () => { req.destroy(); - resolve(DEFAULT_COPILOT_MODELS); + safeResolve(DEFAULT_COPILOT_MODELS); }); req.end(); @@ -216,7 +245,7 @@ export async function getAvailableModels(port: number): Promise * Uses gpt-4.1 as it's available on free tier. */ export function getDefaultModel(): string { - return 'gpt-4.1'; + return DEFAULT_COPILOT_MODEL_ID; } /** @@ -246,3 +275,40 @@ function formatModelName(modelId: string): string { .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 } : {}), + }; +} diff --git a/src/copilot/types.ts b/src/copilot/types.ts index e8d8b5f7..d08b8823 100644 --- a/src/copilot/types.ts +++ b/src/copilot/types.ts @@ -41,6 +41,15 @@ export interface CopilotStatus { */ export type CopilotPlanTier = 'free' | 'pro' | 'pro+' | 'business' | 'enterprise'; +export interface CopilotModelLimits { + /** Maximum total context window exposed by GitHub Copilot */ + maxContextWindowTokens?: number; + /** Maximum output/completion tokens exposed by GitHub Copilot */ + maxOutputTokens?: number; + /** Maximum prompt/input tokens exposed by GitHub Copilot */ + maxPromptTokens?: number; +} + /** * Copilot model information. */ @@ -57,6 +66,8 @@ export interface CopilotModel { multiplier?: number; /** Whether this model is in preview */ preview?: boolean; + /** Live model limits returned by GitHub Copilot metadata, when available */ + limits?: CopilotModelLimits; } /** diff --git a/tests/unit/copilot/copilot-models.test.ts b/tests/unit/copilot/copilot-models.test.ts new file mode 100644 index 00000000..c7074ced --- /dev/null +++ b/tests/unit/copilot/copilot-models.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'bun:test'; +import * as http from 'http'; +import { DEFAULT_COPILOT_MODELS, fetchModelsFromDaemon } from '../../../src/copilot/copilot-models'; + +describe('fetchModelsFromDaemon', () => { + it('falls back to defaults when daemon is unreachable', async () => { + const models = await fetchModelsFromDaemon(9999); + expect(models).toEqual(DEFAULT_COPILOT_MODELS); + }); + + it('falls back to defaults when daemon returns invalid JSON', async () => { + const server = http.createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{not-valid-json'); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + + try { + const models = await fetchModelsFromDaemon(address.port); + expect(models).toEqual(DEFAULT_COPILOT_MODELS); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('parses live limits from daemon metadata and preserves known model metadata', async () => { + const server = http.createServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + data: [ + { + id: 'claude-sonnet-4.5', + name: 'Claude Sonnet 4.5', + capabilities: { + limits: { + max_context_window_tokens: 128000, + max_prompt_tokens: 128000, + max_output_tokens: 64000, + }, + }, + }, + { + id: 'claude-sonnet-4.6', + name: 'Claude Sonnet 4.6', + capabilities: { + limits: { + max_context_window_tokens: 128000, + max_prompt_tokens: 128000, + max_output_tokens: 64000, + }, + }, + }, + { + id: '', + name: 'invalid-entry', + }, + ], + }) + ); + }); + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + + try { + const models = await fetchModelsFromDaemon(address.port); + expect(models).toHaveLength(2); + + const knownModel = models.find((model) => model.id === 'claude-sonnet-4.5'); + expect(knownModel?.provider).toBe('anthropic'); + expect(knownModel?.minPlan).toBe('pro'); + expect(knownModel?.multiplier).toBe(1); + expect(knownModel?.limits).toEqual({ + maxContextWindowTokens: 128000, + maxPromptTokens: 128000, + maxOutputTokens: 64000, + }); + + const liveOnlyModel = models.find((model) => model.id === 'claude-sonnet-4.6'); + expect(liveOnlyModel?.name).toBe('Claude Sonnet 4.6'); + expect(liveOnlyModel?.provider).toBe('anthropic'); + expect(liveOnlyModel?.limits?.maxPromptTokens).toBe(128000); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); +}); diff --git a/ui/src/components/copilot/config-form/model-config-tab.tsx b/ui/src/components/copilot/config-form/model-config-tab.tsx index ab53250d..e21e0e1b 100644 --- a/ui/src/components/copilot/config-form/model-config-tab.tsx +++ b/ui/src/components/copilot/config-form/model-config-tab.tsx @@ -14,6 +14,35 @@ import { FREE_PRESETS, PAID_PRESETS } from './presets'; import { FlexibleModelSelector } from './model-selector'; import type { ModelPreset } from './types'; +function formatCompactTokens(value: number): string { + if (value >= 1_000_000) { + const millions = value / 1_000_000; + return millions % 1 === 0 ? `${millions}M` : `${millions.toFixed(1)}M`; + } + if (value >= 1_000) { + const thousands = value / 1_000; + return thousands % 1 === 0 ? `${thousands}K` : `${thousands.toFixed(1)}K`; + } + return `${value}`; +} + +function formatModelLimits(model?: CopilotModel): string | null { + if (!model?.limits) return null; + + const parts: string[] = []; + if (model.limits.maxPromptTokens) { + parts.push(`prompt ${formatCompactTokens(model.limits.maxPromptTokens)}`); + } + if (model.limits.maxContextWindowTokens) { + parts.push(`context ${formatCompactTokens(model.limits.maxContextWindowTokens)}`); + } + if (model.limits.maxOutputTokens) { + parts.push(`output ${formatCompactTokens(model.limits.maxOutputTokens)}`); + } + + return parts.length > 0 ? parts.join(' | ') : null; +} + interface ModelConfigTabProps { currentModel: string; opusModel: string; @@ -41,6 +70,19 @@ export function ModelConfigTab({ onUpdateSonnetModel, onUpdateHaikuModel, }: ModelConfigTabProps) { + const mappedModelLimits = [ + { label: 'Default', id: currentModel }, + { label: 'Opus', id: opusModel || currentModel }, + { label: 'Sonnet', id: sonnetModel || currentModel }, + { label: 'Haiku', id: haikuModel || currentModel }, + ] + .map(({ label, id }) => { + const model = models.find((entry) => entry.id === id); + const limits = formatModelLimits(model); + return limits ? { label, id, limits } : null; + }) + .filter((entry): entry is { label: string; id: string; limits: string } => entry !== null); + return ( Configure which models to use for each tier

+
+

GitHub Copilot controls prompt/context limits upstream.

+

+ CCS can switch Copilot models, but it cannot increase the provider's max prompt + or context window. +

+ {mappedModelLimits.length > 0 ? ( +
+ {mappedModelLimits.map((entry) => ( +

+ {entry.label}: {entry.id} ({entry.limits}) +

+ ))} +
+ ) : ( +

+ Start the daemon to inspect live model limits from GitHub Copilot metadata. +

+ )} +