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.
+
+ )}
+