mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 18:21:09 +00:00
fix(copilot): surface upstream model limits
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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'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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user