fix(copilot): surface upstream model limits

This commit is contained in:
Tam Nhu Tran
2026-03-07 09:26:09 +07:00
parent 8403284cd2
commit 8a5ed656ee
6 changed files with 300 additions and 14 deletions
+43
View File
@@ -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<number> {
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: {
+80 -14
View File
@@ -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<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
@@ -168,36 +196,37 @@ export async function fetchModelsFromDaemon(port: number): Promise<CopilotModel[
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 { 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<CopilotModel[]>
* 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 } : {}),
};
}
+11
View File
@@ -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;
}
/**
+96
View File
@@ -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<void>((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<void>((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<void>((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<void>((resolve) => server.close(() => resolve()));
}
});
});
@@ -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 (
<TabsContent
value="config"
@@ -125,6 +167,26 @@ export function ModelConfigTab({
<p className="text-xs text-muted-foreground mb-4">
Configure which models to use for each tier
</p>
<div className="mb-4 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs text-amber-900 dark:text-amber-200">
<p className="font-medium">GitHub Copilot controls prompt/context limits upstream.</p>
<p className="mt-1">
CCS can switch Copilot models, but it cannot increase the provider&apos;s max prompt
or context window.
</p>
{mappedModelLimits.length > 0 ? (
<div className="mt-2 space-y-1 text-[11px] font-mono">
{mappedModelLimits.map((entry) => (
<p key={`${entry.label}-${entry.id}`}>
{entry.label}: {entry.id} ({entry.limits})
</p>
))}
</div>
) : (
<p className="mt-2 text-[11px] font-mono">
Start the daemon to inspect live model limits from GitHub Copilot metadata.
</p>
)}
</div>
<div className="space-y-4">
<FlexibleModelSelector
label="Default Model"
+8
View File
@@ -54,6 +54,12 @@ export interface CopilotConfig {
/** GitHub Copilot plan tiers */
export type CopilotPlanTier = 'free' | 'pro' | 'pro+' | 'business' | 'enterprise';
export interface CopilotModelLimits {
maxContextWindowTokens?: number;
maxOutputTokens?: number;
maxPromptTokens?: number;
}
export interface CopilotModel {
id: string;
name: string;
@@ -66,6 +72,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;
}
export interface CopilotRawSettings {