diff --git a/docs/reports/hardening-inventory.json b/docs/reports/hardening-inventory.json index 547ba845..d36b40a8 100644 --- a/docs/reports/hardening-inventory.json +++ b/docs/reports/hardening-inventory.json @@ -1,8 +1,8 @@ { "scope": "src/**/*.{ts,tsx,js,jsx,mjs,cjs}", "syncFs": { - "totalOccurrences": 2379, - "filesAffected": 255, + "totalOccurrences": 2382, + "filesAffected": 256, "hotpathOccurrences": 1103, "hotpathFilesAffected": 151, "topHotpathFiles": [ @@ -741,6 +741,10 @@ "file": "src/cliproxy/auth/oauth-process.ts", "loc": 1048 }, + { + "file": "src/cliproxy/config/env-builder.ts", + "loc": 1045 + }, { "file": "src/web-server/routes/settings-routes.ts", "loc": 1042 @@ -753,10 +757,6 @@ "file": "src/cliproxy/proxy/tool-sanitization-proxy.ts", "loc": 1020 }, - { - "file": "src/cliproxy/config/env-builder.ts", - "loc": 1017 - }, { "file": "src/commands/cliproxy/variant-subcommand.ts", "loc": 997 diff --git a/docs/reports/hardening-inventory.md b/docs/reports/hardening-inventory.md index 0b264adb..ef785347 100644 --- a/docs/reports/hardening-inventory.md +++ b/docs/reports/hardening-inventory.md @@ -6,8 +6,8 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | Metric | Value | |---|---:| -| Sync fs occurrences (all) | 2379 | -| Sync fs files affected (all) | 255 | +| Sync fs occurrences (all) | 2382 | +| Sync fs files affected (all) | 256 | | Sync fs occurrences (runtime hotpaths) | 1103 | | Sync fs files affected (runtime hotpaths) | 151 | | Legacy shim markers | 456 | @@ -95,10 +95,10 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | `src/cursor/cursor-executor.ts` | 1234 | | `src/web-server/model-pricing.ts` | 1127 | | `src/cliproxy/auth/oauth-process.ts` | 1048 | +| `src/cliproxy/config/env-builder.ts` | 1045 | | `src/web-server/routes/settings-routes.ts` | 1042 | | `src/cliproxy/config/generator.ts` | 1034 | | `src/cliproxy/proxy/tool-sanitization-proxy.ts` | 1020 | -| `src/cliproxy/config/env-builder.ts` | 1017 | | `src/commands/cliproxy/variant-subcommand.ts` | 997 | | `src/cliproxy/quota/quota-manager.ts` | 954 | | `src/web-server/services/codex-dashboard-service.ts` | 940 | diff --git a/src/cliproxy/__tests__/model-catalog-compat.test.ts b/src/cliproxy/__tests__/model-catalog-compat.test.ts index ff8bce9f..aaaa01d6 100644 --- a/src/cliproxy/__tests__/model-catalog-compat.test.ts +++ b/src/cliproxy/__tests__/model-catalog-compat.test.ts @@ -75,6 +75,18 @@ describe('model-catalog compatibility lookups', () => { expect(catalog?.models[0]?.extendedContext).toBeUndefined(); }); + it('maps positive integer live context length into contextWindow metadata', () => { + const catalog = mergeCatalog('xai', [ + { + id: 'grok-4.3', + display_name: 'Grok 4.3 Live', + context_length: 1_000_000, + }, + ]); + + expect(catalog?.models[0]?.contextWindow).toBe(1_000_000); + }); + it('does not re-add stale static-only models when live catalog data is present', () => { const catalog = mergeCatalog('gemini', [ { diff --git a/src/cliproxy/config/__tests__/auto-compact-window.test.ts b/src/cliproxy/config/__tests__/auto-compact-window.test.ts new file mode 100644 index 00000000..c3a99476 --- /dev/null +++ b/src/cliproxy/config/__tests__/auto-compact-window.test.ts @@ -0,0 +1,180 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; + +import { invalidateConfigCache } from '../../../config/config-loader-facade'; +import { clearConfigCache } from '../base-config-loader'; +import { applyClaudeAutoCompactWindow } from '../env-builder'; +import { buildClaudeEnvironment } from '../../executor/env-resolver'; + +describe('applyClaudeAutoCompactWindow', () => { + it('injects the catalog window for the effective model after suffix normalization', () => { + const env = applyClaudeAutoCompactWindow( + { + ANTHROPIC_MODEL: 'gpt-5.6-sol-xhigh[1m]', + }, + 'codex' + ); + + expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe('372000'); + }); + + it('uses the selected provider when the same model family exists in multiple catalogs', () => { + const env = applyClaudeAutoCompactWindow( + { + ANTHROPIC_MODEL: 'claude-opus-4-6-thinking', + }, + 'agy' + ); + + expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe('200000'); + }); + + it('does not inject a value for unknown or custom models', () => { + const env = applyClaudeAutoCompactWindow( + { + ANTHROPIC_MODEL: 'custom-model-with-unknown-window', + }, + 'codex' + ); + + expect('CLAUDE_CODE_AUTO_COMPACT_WINDOW' in env).toBe(false); + }); + + it('preserves an explicit user value', () => { + const env = applyClaudeAutoCompactWindow( + { + ANTHROPIC_MODEL: 'gpt-5.6-sol', + CLAUDE_CODE_AUTO_COMPACT_WINDOW: '123456', + }, + 'codex' + ); + + expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe('123456'); + }); + + it('treats an explicit empty value as authoritative', () => { + const env = applyClaudeAutoCompactWindow( + { + ANTHROPIC_MODEL: 'gpt-5.6-sol', + CLAUDE_CODE_AUTO_COMPACT_WINDOW: '', + }, + 'codex' + ); + + expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe(''); + }); + + it('does not mistake a differently cased variable for the Claude Code key', () => { + const env = applyClaudeAutoCompactWindow( + { + ANTHROPIC_MODEL: 'gpt-5.6-sol', + claude_code_auto_compact_window: '123456', + }, + 'codex' + ); + + expect(env.claude_code_auto_compact_window).toBe('123456'); + expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe('372000'); + }); +}); + +describe('Claude launch auto-compact integration', () => { + let tempHome: string; + let originalCcsHome: string | undefined; + let originalAutoCompactWindow: string | undefined; + + beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + originalAutoCompactWindow = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW; + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-auto-compact-')); + process.env.CCS_HOME = tempHome; + delete process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW; + invalidateConfigCache(); + clearConfigCache(); + }); + + afterEach(() => { + if (originalCcsHome === undefined) delete process.env.CCS_HOME; + else process.env.CCS_HOME = originalCcsHome; + + if (originalAutoCompactWindow === undefined) { + delete process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW; + } else { + process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = originalAutoCompactWindow; + } + + invalidateConfigCache(); + clearConfigCache(); + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + it('injects the catalog window for the provider default model on Claude launches', () => { + const env = buildClaudeEnvironment({ + provider: 'codex', + useRemoteProxy: false, + localPort: 8317, + verbose: false, + }); + + expect(env.ANTHROPIC_MODEL).toBe('gpt-5.4(high)'); + expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe('1050000'); + }); + + it('preserves an inherited user value on Claude launches', () => { + process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW = ''; + + const env = buildClaudeEnvironment({ + provider: 'codex', + useRemoteProxy: false, + localPort: 8317, + verbose: false, + }); + + expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe(''); + }); + + it('uses the composite default tier provider for model lookup', () => { + const env = buildClaudeEnvironment({ + provider: 'codex', + useRemoteProxy: false, + localPort: 8317, + verbose: false, + isComposite: true, + compositeTiers: { + opus: { provider: 'agy', model: 'claude-opus-4-6-thinking' }, + sonnet: { provider: 'codex', model: 'gpt-5.6-sol' }, + haiku: { provider: 'kimi', model: 'kimi-k2' }, + }, + compositeDefaultTier: 'opus', + }); + + expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe('200000'); + }); + + it('preserves an explicit empty value from a custom settings file', () => { + const settingsPath = path.join(tempHome, 'codex-custom.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317', + ANTHROPIC_AUTH_TOKEN: 'test-token', + ANTHROPIC_MODEL: 'gpt-5.6-sol', + CLAUDE_CODE_AUTO_COMPACT_WINDOW: '', + }, + }) + ); + + const env = buildClaudeEnvironment({ + provider: 'codex', + useRemoteProxy: false, + localPort: 8317, + customSettingsPath: settingsPath, + verbose: false, + }); + + expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe(''); + }); +}); diff --git a/src/cliproxy/config/env-builder.ts b/src/cliproxy/config/env-builder.ts index 6cc77712..7050885f 100644 --- a/src/cliproxy/config/env-builder.ts +++ b/src/cliproxy/config/env-builder.ts @@ -38,6 +38,7 @@ import { } from '../../config/config-loader-facade'; import { buildCliproxyProviderPath, buildLocalProviderBaseUrl } from './provider-route'; import { ConfigError } from '../../errors/error-types'; +import { findModel } from '../model-catalog'; export { buildCliproxyProviderPath, buildLocalProviderBaseUrl } from './provider-route'; @@ -68,11 +69,38 @@ const CURSOR_LEGACY_ENV_OVERRIDE_KEYS = new Set([ 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY', ]); +const CLAUDE_AUTO_COMPACT_WINDOW_ENV = 'CLAUDE_CODE_AUTO_COMPACT_WINDOW'; function isObjectRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +/** + * Add Claude Code's auto-compact window for a known effective model. + * Existing env keys win by presence, including an explicit empty value. + */ +export function applyClaudeAutoCompactWindow( + envVars: NodeJS.ProcessEnv, + provider: CLIProxyProvider +): NodeJS.ProcessEnv { + if (Object.prototype.hasOwnProperty.call(envVars, CLAUDE_AUTO_COMPACT_WINDOW_ENV)) { + return envVars; + } + + const modelId = envVars.ANTHROPIC_MODEL; + if (typeof modelId !== 'string' || modelId.trim().length === 0) return envVars; + + const contextWindow = findModel(provider, modelId)?.contextWindow; + if (typeof contextWindow !== 'number' || !Number.isInteger(contextWindow) || contextWindow <= 0) { + return envVars; + } + + return { + ...envVars, + [CLAUDE_AUTO_COMPACT_WINDOW_ENV]: String(contextWindow), + }; +} + /** * Migrate deprecated gemini-claude-* model names to upstream claude-* names in a settings file. * CLIProxyAPI registry no longer recognizes the gemini-claude-* prefix convention. diff --git a/src/cliproxy/executor/env-resolver.ts b/src/cliproxy/executor/env-resolver.ts index d94c4f5a..1353e15b 100644 --- a/src/cliproxy/executor/env-resolver.ts +++ b/src/cliproxy/executor/env-resolver.ts @@ -15,6 +15,7 @@ import { getRemoteEnvVars, getCompositeEnvVars, applyThinkingConfig, + applyClaudeAutoCompactWindow, } from '../config/config-generator'; import { applyExtendedContextConfig } from '../config/extended-context-config'; import { CLIProxyProvider } from '../types'; @@ -414,8 +415,17 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record v !== undefined) + Object.entries(autoCompactEnv).filter(([, v]) => v !== undefined) ) as Record; } diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 376cdfce..942730bb 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -52,6 +52,8 @@ export interface ModelEntry { tier?: 'free' | 'pro' | 'ultra'; /** Optional description for the model */ description?: string; + /** Total context window in tokens when authoritatively known */ + contextWindow?: number; /** Model has known issues - show warning when selected */ broken?: boolean; /** Issue URL for broken models */ @@ -95,6 +97,7 @@ export const MODEL_CATALOG: Partial> = id: 'claude-opus-4-6-thinking', name: 'Claude Opus 4.6 Thinking', description: 'Latest flagship, extended thinking', + contextWindow: 200000, nativeImageInput: true, thinking: { type: 'budget', @@ -111,6 +114,7 @@ export const MODEL_CATALOG: Partial> = id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', description: 'Latest Sonnet with thinking budget support', + contextWindow: 200000, nativeImageInput: true, thinking: { type: 'budget', @@ -194,6 +198,7 @@ export const MODEL_CATALOG: Partial> = id: 'gpt-5.6-sol', name: 'GPT-5.6 Sol', description: 'Latest frontier agentic coding model.', + contextWindow: 372000, thinking: { type: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], @@ -206,6 +211,7 @@ export const MODEL_CATALOG: Partial> = id: 'gpt-5.6-terra', name: 'GPT-5.6 Terra', description: 'Balanced agentic coding model for everyday work.', + contextWindow: 372000, thinking: { type: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], @@ -218,6 +224,7 @@ export const MODEL_CATALOG: Partial> = id: 'gpt-5.6-luna', name: 'GPT-5.6 Luna', description: 'Fast and affordable agentic coding model.', + contextWindow: 372000, thinking: { type: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], @@ -232,6 +239,7 @@ export const MODEL_CATALOG: Partial> = tier: 'pro', description: 'Newest Codex-released GPT-5 family model; falls back to GPT-5.4 on free plans', + contextWindow: 272000, thinking: { type: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], @@ -244,6 +252,7 @@ export const MODEL_CATALOG: Partial> = id: 'gpt-5.4', name: 'GPT-5.4', description: 'Recommended Codex default for most coding and agentic tasks', + contextWindow: 1050000, thinking: { type: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], @@ -256,6 +265,7 @@ export const MODEL_CATALOG: Partial> = id: 'gpt-5.4-mini', name: 'GPT-5.4 Mini', description: 'Fast, lower-cost Codex option for lighter tasks and haiku-tier routing', + contextWindow: 400000, thinking: { type: 'levels', levels: ['low', 'medium', 'high'], @@ -281,6 +291,7 @@ export const MODEL_CATALOG: Partial> = tier: 'pro', description: 'Research preview model for ChatGPT Pro subscribers, optimized for near-instant coding iteration', + contextWindow: 128000, thinking: { type: 'levels', levels: ['low', 'medium', 'high', 'xhigh'], @@ -310,11 +321,13 @@ export const MODEL_CATALOG: Partial> = id: 'grok-build-0.1', name: 'Grok Build 0.1', description: 'Fast coding model for agentic software engineering workflows', + contextWindow: 256000, }, { id: 'grok-4.5', name: 'Grok 4.5', description: 'Frontier model for coding, engineering, and agentic workflows', + contextWindow: 500000, thinking: { type: 'levels', levels: ['low', 'medium', 'high'], @@ -325,6 +338,7 @@ export const MODEL_CATALOG: Partial> = id: 'grok-4.3', name: 'Grok 4.3', description: 'General-purpose Grok model with a one-million-token context window', + contextWindow: 1000000, thinking: { type: 'levels', levels: ['none', 'low', 'medium', 'high'], @@ -335,34 +349,40 @@ export const MODEL_CATALOG: Partial> = id: 'grok-4.20-0309-reasoning', name: 'Grok 4.20 0309 Reasoning', description: 'Reasoning model with a two-million-token context window', + contextWindow: 2000000, }, { id: 'grok-4.20-0309-non-reasoning', name: 'Grok 4.20 0309 Non Reasoning', description: 'Non-reasoning model with a two-million-token context window', + contextWindow: 2000000, }, { id: 'grok-4.20-multi-agent-0309', name: 'Grok 4.20 Multi Agent 0309', description: 'Multi-agent model with a two-million-token context window', + contextWindow: 2000000, thinking: { type: 'levels', levels: ['low', 'medium', 'high'] }, }, { id: 'grok-3-mini', name: 'Grok 3 Mini', description: 'Compact reasoning model', + contextWindow: 131072, thinking: { type: 'levels', levels: ['low', 'medium', 'high'] }, }, { id: 'grok-3-mini-fast', name: 'Grok 3 Mini Fast', description: 'Faster compact reasoning model', + contextWindow: 131072, thinking: { type: 'levels', levels: ['low', 'medium', 'high'] }, }, { id: 'grok-composer-2.5-fast', name: 'Grok Composer 2.5 Fast', description: 'Fast Composer model for lightweight coding tasks', + contextWindow: 200000, }, ], }, @@ -375,56 +395,67 @@ export const MODEL_CATALOG: Partial> = id: 'qoder/auto', name: 'Qoder Auto', description: 'Auto selects the best Qoder model for your prompt', + contextWindow: 180000, }, { id: 'qoder/ultimate', name: 'Qoder Ultimate', description: 'Highest quality Qoder tier', + contextWindow: 180000, }, { id: 'qoder/performance', name: 'Qoder Performance', description: 'Balanced quality and speed', + contextWindow: 272000, }, { id: 'qoder/efficient', name: 'Qoder Efficient', description: 'Cost-efficient Qoder tier', + contextWindow: 180000, }, { id: 'qoder/lite', name: 'Qoder Lite', description: 'Fastest and most affordable Qoder tier', + contextWindow: 180000, }, { id: 'qoder/qmodel', name: 'Qwen 3.6 Plus (via Qoder)', description: 'Qwen 3.6 Plus frontier model', + contextWindow: 180000, }, { id: 'qoder/dmodel', name: 'DeepSeek V4 Pro (via Qoder)', description: 'DeepSeek V4 Pro frontier model', + contextWindow: 180000, }, { id: 'qoder/dfmodel', name: 'DeepSeek V4 Flash (via Qoder)', description: 'DeepSeek V4 Flash frontier model', + contextWindow: 180000, }, { id: 'qoder/gm51model', name: 'GLM 5.1 (via Qoder)', description: 'GLM 5.1 frontier model', + contextWindow: 180000, }, { id: 'qoder/kmodel', name: 'Kimi K2.6 (via Qoder)', description: 'Kimi K2.6 frontier model', + contextWindow: 256000, }, { id: 'qoder/mmodel', name: 'MiniMax M2.7 (via Qoder)', description: 'MiniMax M2.7 frontier model', + contextWindow: 180000, }, ], }, @@ -437,6 +468,7 @@ export const MODEL_CATALOG: Partial> = id: 'kimi-k2.5', name: 'Kimi K2.5', description: 'Latest multimodal model (262K context)', + contextWindow: 262144, nativeImageInput: true, thinking: { type: 'budget', @@ -450,6 +482,7 @@ export const MODEL_CATALOG: Partial> = id: 'kimi-k2-thinking', name: 'Kimi K2 Thinking', description: 'Extended reasoning model', + contextWindow: 131072, thinking: { type: 'budget', min: 1024, @@ -462,6 +495,7 @@ export const MODEL_CATALOG: Partial> = id: 'kimi-k2', name: 'Kimi K2', description: 'Flagship coding model', + contextWindow: 131072, }, ], }, @@ -474,6 +508,7 @@ export const MODEL_CATALOG: Partial> = id: 'claude-sonnet-5', name: 'Claude Sonnet 5', description: 'Latest Sonnet model', + contextWindow: 1000000, nativeImageInput: true, // Sonnet 5 uses adaptive thinking; manual budget_tokens is rejected with 400. thinking: { @@ -488,6 +523,7 @@ export const MODEL_CATALOG: Partial> = id: 'claude-fable-5', name: 'Claude Fable 5', description: 'Most powerful model', + contextWindow: 1000000, nativeImageInput: true, // New tier above Opus. Same adaptive-thinking surface as Opus 4.8: // Anthropic accepts only effort levels; manual budget_tokens is rejected with 400. @@ -520,6 +556,7 @@ export const MODEL_CATALOG: Partial> = id: 'claude-opus-4-8', name: 'Claude Opus 4.8', description: 'Latest flagship model', + contextWindow: 1000000, nativeImageInput: true, // Mirrors 4.7: Anthropic accepts only adaptive thinking levels on the // current Opus generation; manual budget_tokens is rejected with 400. @@ -535,6 +572,7 @@ export const MODEL_CATALOG: Partial> = id: 'claude-opus-4-7', name: 'Claude Opus 4.7', description: 'Previous flagship model', + contextWindow: 1000000, nativeImageInput: true, // Opus 4.7 only supports adaptive thinking on the Anthropic API; manual // thinking.type: "enabled" with budget_tokens is rejected with 400. @@ -552,6 +590,7 @@ export const MODEL_CATALOG: Partial> = id: 'claude-opus-4-6', name: 'Claude Opus 4.6', description: 'Older flagship model', + contextWindow: 1000000, nativeImageInput: true, thinking: { type: 'budget', @@ -566,6 +605,7 @@ export const MODEL_CATALOG: Partial> = id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6', description: 'Balanced performance and speed', + contextWindow: 200000, nativeImageInput: true, thinking: { type: 'budget', @@ -580,6 +620,7 @@ export const MODEL_CATALOG: Partial> = id: 'claude-opus-4-5-20251101', name: 'Claude Opus 4.5', description: 'Most capable Claude model', + contextWindow: 200000, nativeImageInput: true, thinking: { type: 'budget', @@ -594,6 +635,7 @@ export const MODEL_CATALOG: Partial> = id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5', description: 'Balanced performance and speed', + contextWindow: 200000, nativeImageInput: true, thinking: { type: 'budget', @@ -608,6 +650,7 @@ export const MODEL_CATALOG: Partial> = id: 'claude-sonnet-4-20250514', name: 'Claude Sonnet 4', description: 'Previous generation Sonnet', + contextWindow: 200000, nativeImageInput: true, thinking: { type: 'budget', @@ -622,6 +665,7 @@ export const MODEL_CATALOG: Partial> = id: 'claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5', description: 'Fast and efficient', + contextWindow: 200000, nativeImageInput: true, thinking: { type: 'none' }, }, @@ -675,6 +719,16 @@ export function findModel(provider: CLIProxyProvider, modelId: string): ModelEnt .trim() .toLowerCase(); const lookupCandidates = new Set([normalizedId, providerNormalizedId]); + if (provider === 'codex') { + for (const candidate of [...lookupCandidates]) { + const tuningMatch = candidate.match( + /^(.*?)(?:-(?:minimal|low|medium|high|xhigh)(?:-fast)?|-fast(?:-(?:minimal|low|medium|high|xhigh))?)$/i + ); + if (tuningMatch?.[1]) { + lookupCandidates.add(tuningMatch[1].trim()); + } + } + } if (isAntigravityProvider(provider)) { const migratedRaw = migrateDeniedAntigravityModelAliases(normalizedId).trim().toLowerCase(); const migratedProvider = migrateDeniedAntigravityModelAliases(providerNormalizedId) diff --git a/src/cliproxy/services/catalog-cache.ts b/src/cliproxy/services/catalog-cache.ts index 0c6511cd..bfbf0560 100644 --- a/src/cliproxy/services/catalog-cache.ts +++ b/src/cliproxy/services/catalog-cache.ts @@ -221,6 +221,13 @@ function mapRemoteToModelEntry(provider: CLIProxyProvider, remote: RemoteModelIn name: remote.display_name || remote.id, }; if (remote.description) entry.description = remote.description; + if ( + typeof remote.context_length === 'number' && + Number.isInteger(remote.context_length) && + remote.context_length > 0 + ) { + entry.contextWindow = remote.context_length; + } // xAI context length is inherent to the model ID; its API does not accept // Claude's [1m] model suffix. if (provider !== 'xai' && remote.context_length && remote.context_length >= 1_000_000) { @@ -286,6 +293,7 @@ export function mergeCatalog( issueUrl: staticEntry.issueUrl, deprecated: staticEntry.deprecated, deprecationReason: staticEntry.deprecationReason, + contextWindow: remoteEntry.contextWindow ?? staticEntry.contextWindow, }); } else { mergedModels.push(remoteEntry); diff --git a/src/commands/cliproxy/catalog-subcommand.ts b/src/commands/cliproxy/catalog-subcommand.ts index 10449eb9..56505589 100644 --- a/src/commands/cliproxy/catalog-subcommand.ts +++ b/src/commands/cliproxy/catalog-subcommand.ts @@ -181,6 +181,7 @@ interface CatalogJsonModel { name: string; tier?: 'free' | 'pro' | 'ultra'; description?: string; + contextWindow?: number; deprecated?: boolean; deprecationReason?: string; broken?: boolean; @@ -206,6 +207,7 @@ export function handleCatalogJson(): void { const entry: CatalogJsonModel = { id: m.id, name: m.name }; if (m.tier !== undefined) entry.tier = m.tier; if (m.description !== undefined) entry.description = m.description; + if (m.contextWindow !== undefined) entry.contextWindow = m.contextWindow; if (m.deprecated !== undefined) entry.deprecated = m.deprecated; if (m.deprecationReason !== undefined) entry.deprecationReason = m.deprecationReason; if (m.broken !== undefined) entry.broken = m.broken; diff --git a/tests/unit/commands/cliproxy-catalog-json.test.ts b/tests/unit/commands/cliproxy-catalog-json.test.ts index 7bc3e5ea..8c99c79a 100644 --- a/tests/unit/commands/cliproxy-catalog-json.test.ts +++ b/tests/unit/commands/cliproxy-catalog-json.test.ts @@ -42,10 +42,7 @@ describe('cliproxy catalog --json output', () => { it('includes metadata fields when present on model entries', () => { handleCatalogJson(); - const parsed = JSON.parse(capturedOutput[0]) as Record< - string, - Array> - >; + const parsed = JSON.parse(capturedOutput[0]) as Record>>; const allModels = Object.values(parsed).flat(); // At least some models in the static catalog have tier set @@ -56,15 +53,19 @@ describe('cliproxy catalog --json output', () => { for (const model of withTier) { expect(['free', 'pro', 'ultra']).toContain(model.tier); } + + const withContextWindow = allModels.filter((m) => m.contextWindow !== undefined); + expect(withContextWindow.length).toBeGreaterThan(0); + for (const model of withContextWindow) { + expect(Number.isInteger(model.contextWindow)).toBe(true); + expect(model.contextWindow as number).toBeGreaterThan(0); + } }); it('omits undefined optional fields instead of including nulls', () => { handleCatalogJson(); - const parsed = JSON.parse(capturedOutput[0]) as Record< - string, - Array> - >; + const parsed = JSON.parse(capturedOutput[0]) as Record>>; const allModels = Object.values(parsed).flat(); for (const model of allModels) { @@ -78,10 +79,7 @@ describe('cliproxy catalog --json output', () => { it('includes explicit false boolean values in output', () => { handleCatalogJson(); - const parsed = JSON.parse(capturedOutput[0]) as Record< - string, - Array> - >; + const parsed = JSON.parse(capturedOutput[0]) as Record>>; const allModels = Object.values(parsed).flat(); // Static catalog has models with extendedContext: false @@ -92,10 +90,7 @@ describe('cliproxy catalog --json output', () => { it('includes thinking configuration when present on models', () => { handleCatalogJson(); - const parsed = JSON.parse(capturedOutput[0]) as Record< - string, - Array> - >; + const parsed = JSON.parse(capturedOutput[0]) as Record>>; const allModels = Object.values(parsed).flat(); // Static catalog has thinking models (e.g. Claude Opus 4.6 Thinking)