Files
ccs/tests/unit/cliproxy/model-catalog.test.js
T
Kai (Tam Nhu) TranGitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
b454834175 feat(release): v7.38.0 - Extended Context, Qwen Models, Bug Fixes (#480)
* fix(version): show active config path instead of deprecated config.json

The version command was using deprecated getConfigPath() which always
returned config.json path. Now uses getActiveConfigPath() which shows
config.yaml in unified mode or config.json in legacy mode.

* chore(release): 7.37.1-dev.1 [skip ci]

* fix(ui): use native dynamic import to fix Node 24 ESM/CJS interop

TypeScript compiles import() to require() when targeting CommonJS,
which breaks ESM packages like ora on Node 24. Use new Function()
to create native dynamic import at runtime, bypassing TS transform.

Closes #472

* chore(release): 7.37.1-dev.2 [skip ci]

* fix(env): strip ANTHROPIC_* from account/default profiles

Account and default profiles inherit process.env which may contain
stale ANTHROPIC_BASE_URL from prior CLIProxy sessions. This causes
ConnectionRefused errors when Claude tries to hit an unavailable proxy.

Settings-based profiles already handle this by explicitly injecting
their own ANTHROPIC_* values. This fix applies the same protection
to account/default profiles by stripping ANTHROPIC_* before spawn.

Closes #474

* test(env): add unit tests for stripAnthropicEnv

Address code review feedback from PR #475. Tests cover:
- Removing all ANTHROPIC_* keys
- Preserving non-ANTHROPIC keys
- Empty object handling
- Undefined value preservation
- Case sensitivity (only uppercase ANTHROPIC_)
- All ANTHROPIC_ prefixed variants stripped

* chore(release): 7.37.1-dev.3 [skip ci]

* feat(cliproxy): add extended context support for 1M token window

Add --1m and --no-1m flags to enable/disable 1M token context window.
Uses Claude Code's [1m] suffix mechanism.

Behavior:
- Gemini models: auto-enabled by default
- Claude models: opt-in with --1m flag
- New extendedContext field in model catalog

Also adds Claude Opus 4.6 to model catalog with extended context support.

Closes #103

* feat(ui): add extended context toggle in dashboard model config

- Add ExtendedContextToggle component for 1M token context window
- Add Claude Opus 4.6 (claude-opus-4-6-20260203) to model catalogs
- Mark Gemini and Claude models with extendedContext: true
- Toggle only appears when selected model supports extended context
- Auto-enabled info for native Gemini, opt-in info for Claude

Part of extended context feature implementation for issue #103.

* fix: address code review findings and CI failure

- Fix CI error: add missing 'provider' prop to ModelConfigSection
- Fix case sensitivity in applyExtendedContextSuffix
- Fix whitespace handling in stripModelSuffixes
- Handle --1m=value and --no-1m=value CLI patterns
- Add warning when --1m used on unsupported model
- Sync agy catalog: add extendedContext to gemini-3-pro-preview
- Extract isNativeGeminiModel to shared utility (DRY)
- Add 21 unit tests for extended-context-config

* fix(catalog): rename claude-opus-4-6-20260203 to claude-opus-4-6

* fix(ui): wire extended context toggle through component tree

- Add extendedContextEnabled and toggleExtendedContext to useProviderEditor hook
- Store setting as CCS_EXTENDED_CONTEXT env var in provider settings
- Pass props through provider-editor → model-config-tab → model-config-section
- Update UseProviderEditorReturn type with new properties

* fix(ui): apply [1m] suffix directly to model strings in settings

- Toggle now applies/strips [1m] suffix to all ANTHROPIC_*MODEL env vars
- Extended context detected by checking if any model has [1m] suffix
- Remove legacy CCS_EXTENDED_CONTEXT flag approach
- Add suffix utilities: applyExtendedContextSuffix, stripExtendedContextSuffix
- Raw Configuration now shows actual model values with [1m] suffix

* fix(qwen): update model catalog with correct context windows and tier mappings

- Update context window specs from official Alibaba docs:
  - Qwen3 Coder Plus: 1M context (was 32K)
  - Qwen3 Max: 256K context (flagship)
  - Qwen3 Coder Flash: fast code generation
- Fix preset mappings for Claude tier equivalence:
  - Opus → qwen3-max (flagship 256K)
  - Sonnet → qwen3-coder-plus (balanced 1M)
  - Haiku → qwen3-coder-flash (fast)
- Update provider descriptions to reflect 256K-1M context range
- Add all 7 Qwen models from CLIProxyAPI: qwen3-coder-plus, qwen3-max,
  qwen3-max-preview, qwen3-235b, qwen3-vl-plus, qwen3-coder-flash, qwen3-32b

Closes #478

* fix(ui): only apply [1m] suffix to ANTHROPIC_MODEL, fix toggle refresh

- Only ANTHROPIC_MODEL gets [1m] suffix, not tier mappings
- Strip [1m] when looking up model in catalog to prevent toggle disappearing
- Fix odd page refresh when toggling extended context

* chore(release): 7.37.1-dev.4 [skip ci]

* fix(ui): remove duplicate import in use-provider-editor

* chore: address PR review feedback - sync comment and unused import

* chore(release): 7.37.1-dev.5 [skip ci]

* feat(cliproxy): add Opus 4.6 to Antigravity model catalog (#482)

* feat(cliproxy): add Opus 4.6 to Antigravity model catalog

- Add gemini-claude-opus-4-6-thinking as new default agy model
- Update preset mappings to route opus tier to Opus 4.6
- Bump CLIProxy fallback versions to v6.8.2
- Keep Opus 4.5 as previous flagship option

* fix(cliproxy): include oauth-model-alias in config generation

Root cause: CLIProxy config.yaml was missing Opus 4.6 alias because:
1. CLIProxyPlus startup migration is disabled (intentional)
2. CCS config generator never wrote oauth-model-alias section
3. Existing users with outdated aliases got 502 on Opus 4.6

Fix:
- Add DEFAULT_ANTIGRAVITY_ALIASES to config generator
- Generate oauth-model-alias section in config.yaml template
- Preserve claude-api-key and custom aliases during regeneration
- Bump config version to v6 to trigger auto-regeneration
- Update model catalog tests for new model count

* fix(cliproxy): preserve YAML indentation in extractYamlSection

- Replace .trim() with regex to strip only leading/trailing newlines,
  preserving 2-space indent on claude-api-key children
- Skip standalone comments at col 0 in section boundary detection
- Update stale test description (4 → 5 models)

* chore(release): 7.37.1-dev.6 [skip ci]

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-02-06 20:28:58 -05:00

305 lines
12 KiB
JavaScript

/**
* Tests for CLIProxy Model Catalog
* Verifies model database structure and lookup functions
*/
const assert = require('assert');
describe('Model Catalog', () => {
const modelCatalog = require('../../../dist/cliproxy/model-catalog');
describe('MODEL_CATALOG structure', () => {
it('contains AGY provider catalog', () => {
const { MODEL_CATALOG } = modelCatalog;
assert(MODEL_CATALOG.agy, 'Should have agy provider');
assert.strictEqual(MODEL_CATALOG.agy.provider, 'agy');
assert.strictEqual(MODEL_CATALOG.agy.displayName, 'Antigravity');
});
it('contains Gemini provider catalog', () => {
const { MODEL_CATALOG } = modelCatalog;
assert(MODEL_CATALOG.gemini, 'Should have gemini provider');
assert.strictEqual(MODEL_CATALOG.gemini.provider, 'gemini');
assert.strictEqual(MODEL_CATALOG.gemini.displayName, 'Gemini');
});
it('contains Codex provider catalog', () => {
const { MODEL_CATALOG } = modelCatalog;
assert(MODEL_CATALOG.codex, 'Should have codex provider');
assert.strictEqual(MODEL_CATALOG.codex.provider, 'codex');
assert.strictEqual(MODEL_CATALOG.codex.displayName, 'Copilot Codex');
});
it('does not contain qwen (not configurable)', () => {
const { MODEL_CATALOG } = modelCatalog;
assert.strictEqual(MODEL_CATALOG.qwen, undefined);
});
});
describe('AGY models', () => {
it('has correct default model', () => {
const { MODEL_CATALOG } = modelCatalog;
assert.strictEqual(MODEL_CATALOG.agy.defaultModel, 'gemini-claude-opus-4-6-thinking');
});
it('includes Claude Opus 4.5 Thinking', () => {
const { MODEL_CATALOG } = modelCatalog;
const opus = MODEL_CATALOG.agy.models.find(
(m) => m.id === 'gemini-claude-opus-4-5-thinking'
);
assert(opus, 'Should include Claude Opus 4.5 Thinking');
assert.strictEqual(opus.name, 'Claude Opus 4.5 Thinking');
});
it('includes Claude Sonnet 4.5 Thinking', () => {
const { MODEL_CATALOG } = modelCatalog;
const sonnetThinking = MODEL_CATALOG.agy.models.find(
(m) => m.id === 'gemini-claude-sonnet-4-5-thinking'
);
assert(sonnetThinking, 'Should include Claude Sonnet 4.5 Thinking');
assert.strictEqual(sonnetThinking.name, 'Claude Sonnet 4.5 Thinking');
});
it('includes Claude Sonnet 4.5', () => {
const { MODEL_CATALOG } = modelCatalog;
const sonnet = MODEL_CATALOG.agy.models.find((m) => m.id === 'gemini-claude-sonnet-4-5');
assert(sonnet, 'Should include Claude Sonnet 4.5');
assert.strictEqual(sonnet.name, 'Claude Sonnet 4.5');
});
it('includes Gemini 3 Pro (free via Antigravity)', () => {
const { MODEL_CATALOG } = modelCatalog;
const gem3 = MODEL_CATALOG.agy.models.find((m) => m.id === 'gemini-3-pro-preview');
assert(gem3, 'Should include Gemini 3 Pro');
assert.strictEqual(gem3.name, 'Gemini 3 Pro');
// AGY models are all free - no paid tier
assert.strictEqual(gem3.tier, undefined, 'AGY models should not have paid tier');
});
it('has 5 models total', () => {
const { MODEL_CATALOG } = modelCatalog;
assert.strictEqual(MODEL_CATALOG.agy.models.length, 5);
});
});
describe('Gemini models', () => {
it('has correct default model', () => {
const { MODEL_CATALOG } = modelCatalog;
assert.strictEqual(MODEL_CATALOG.gemini.defaultModel, 'gemini-2.5-pro');
});
it('includes Gemini 3 Pro with pro tier', () => {
const { MODEL_CATALOG } = modelCatalog;
const gem3 = MODEL_CATALOG.gemini.models.find((m) => m.id === 'gemini-3-pro-preview');
assert(gem3, 'Should include Gemini 3 Pro');
assert.strictEqual(gem3.name, 'Gemini 3 Pro');
assert.strictEqual(gem3.tier, 'pro');
});
it('includes Gemini 2.5 Pro without tier (free)', () => {
const { MODEL_CATALOG } = modelCatalog;
const gem25 = MODEL_CATALOG.gemini.models.find((m) => m.id === 'gemini-2.5-pro');
assert(gem25, 'Should include Gemini 2.5 Pro');
assert.strictEqual(gem25.name, 'Gemini 2.5 Pro');
assert.strictEqual(gem25.tier, undefined);
});
it('has 2 models total', () => {
const { MODEL_CATALOG } = modelCatalog;
assert.strictEqual(MODEL_CATALOG.gemini.models.length, 2);
});
});
describe('supportsModelConfig', () => {
it('returns true for agy', () => {
const { supportsModelConfig } = modelCatalog;
assert.strictEqual(supportsModelConfig('agy'), true);
});
it('returns true for gemini', () => {
const { supportsModelConfig } = modelCatalog;
assert.strictEqual(supportsModelConfig('gemini'), true);
});
it('returns true for codex', () => {
const { supportsModelConfig } = modelCatalog;
assert.strictEqual(supportsModelConfig('codex'), true);
});
it('returns false for qwen', () => {
const { supportsModelConfig } = modelCatalog;
assert.strictEqual(supportsModelConfig('qwen'), false);
});
});
describe('getProviderCatalog', () => {
it('returns catalog for agy', () => {
const { getProviderCatalog } = modelCatalog;
const catalog = getProviderCatalog('agy');
assert(catalog, 'Should return catalog');
assert.strictEqual(catalog.provider, 'agy');
assert(Array.isArray(catalog.models));
});
it('returns catalog for gemini', () => {
const { getProviderCatalog } = modelCatalog;
const catalog = getProviderCatalog('gemini');
assert(catalog, 'Should return catalog');
assert.strictEqual(catalog.provider, 'gemini');
});
it('returns catalog for codex', () => {
const { getProviderCatalog } = modelCatalog;
const catalog = getProviderCatalog('codex');
assert(catalog, 'Should return catalog');
assert.strictEqual(catalog.provider, 'codex');
assert(Array.isArray(catalog.models));
});
});
describe('findModel', () => {
it('finds Claude Opus 4.5 Thinking in agy', () => {
const { findModel } = modelCatalog;
const model = findModel('agy', 'gemini-claude-opus-4-5-thinking');
assert(model, 'Should find model');
assert.strictEqual(model.name, 'Claude Opus 4.5 Thinking');
});
it('finds Gemini 2.5 Pro in gemini', () => {
const { findModel } = modelCatalog;
const model = findModel('gemini', 'gemini-2.5-pro');
assert(model, 'Should find model');
assert.strictEqual(model.name, 'Gemini 2.5 Pro');
});
it('returns undefined for unknown model', () => {
const { findModel } = modelCatalog;
const model = findModel('agy', 'unknown-model');
assert.strictEqual(model, undefined);
});
it('returns undefined for unsupported provider', () => {
const { findModel } = modelCatalog;
const model = findModel('codex', 'any-model');
assert.strictEqual(model, undefined);
});
});
describe('Model entry structure', () => {
it('all models have required fields', () => {
const { MODEL_CATALOG } = modelCatalog;
for (const [provider, catalog] of Object.entries(MODEL_CATALOG)) {
for (const model of catalog.models) {
assert(model.id, `Model in ${provider} should have id`);
assert(typeof model.id === 'string', `Model id should be string`);
assert(model.name, `Model ${model.id} should have name`);
assert(typeof model.name === 'string', `Model name should be string`);
// tier is optional
if (model.tier !== undefined) {
assert(['free', 'pro', 'ultra'].includes(model.tier), `Invalid tier: ${model.tier}`);
}
}
}
});
it('all model IDs are unique within provider', () => {
const { MODEL_CATALOG } = modelCatalog;
for (const [provider, catalog] of Object.entries(MODEL_CATALOG)) {
const ids = catalog.models.map((m) => m.id);
const uniqueIds = new Set(ids);
assert.strictEqual(ids.length, uniqueIds.size, `Duplicate model IDs in ${provider}`);
}
});
it('default model exists in models array', () => {
const { MODEL_CATALOG } = modelCatalog;
for (const [provider, catalog] of Object.entries(MODEL_CATALOG)) {
const defaultExists = catalog.models.some((m) => m.id === catalog.defaultModel);
assert(defaultExists, `Default model ${catalog.defaultModel} not found in ${provider}`);
}
});
});
describe('Thinking models ordering', () => {
it('Claude Opus 4.5 Thinking is not deprecated', () => {
const { MODEL_CATALOG } = modelCatalog;
const opus = MODEL_CATALOG.agy.models.find(
(m) => m.id === 'gemini-claude-opus-4-5-thinking'
);
assert(opus, 'Should include Claude Opus 4.5 Thinking');
assert.strictEqual(opus.deprecated, undefined, 'Should not be marked as deprecated');
});
it('Claude Sonnet 4.5 Thinking is not deprecated', () => {
const { MODEL_CATALOG } = modelCatalog;
const sonnetThinking = MODEL_CATALOG.agy.models.find(
(m) => m.id === 'gemini-claude-sonnet-4-5-thinking'
);
assert(sonnetThinking, 'Should include Claude Sonnet 4.5 Thinking');
assert.strictEqual(sonnetThinking.deprecated, undefined, 'Should not be marked as deprecated');
});
it('thinking models are at the top of the list', () => {
const { MODEL_CATALOG } = modelCatalog;
const models = MODEL_CATALOG.agy.models;
// Find indices of thinking models
const opusIdx = models.findIndex((m) => m.id === 'gemini-claude-opus-4-5-thinking');
const sonnetThinkingIdx = models.findIndex(
(m) => m.id === 'gemini-claude-sonnet-4-5-thinking'
);
// Find indices of non-thinking models
const sonnetIdx = models.findIndex((m) => m.id === 'gemini-claude-sonnet-4-5');
const geminiIdx = models.findIndex((m) => m.id === 'gemini-3-pro-preview');
// Thinking models should come before non-thinking models
assert(opusIdx < sonnetIdx, 'Opus Thinking should be above non-thinking Sonnet');
assert(opusIdx < geminiIdx, 'Opus Thinking should be above non-thinking Gemini');
assert(
sonnetThinkingIdx < sonnetIdx,
'Sonnet Thinking should be above non-thinking Sonnet'
);
assert(
sonnetThinkingIdx < geminiIdx,
'Sonnet Thinking should be above non-thinking Gemini'
);
});
});
describe('isModelDeprecated', () => {
it('returns false for thinking models (no longer deprecated)', () => {
const { isModelDeprecated } = modelCatalog;
assert.strictEqual(isModelDeprecated('agy', 'gemini-claude-opus-4-5-thinking'), false);
assert.strictEqual(isModelDeprecated('agy', 'gemini-claude-sonnet-4-5-thinking'), false);
});
it('returns false for non-deprecated models', () => {
const { isModelDeprecated } = modelCatalog;
assert.strictEqual(isModelDeprecated('agy', 'gemini-claude-sonnet-4-5'), false);
assert.strictEqual(isModelDeprecated('agy', 'gemini-3-pro-preview'), false);
});
it('returns false for unknown models', () => {
const { isModelDeprecated } = modelCatalog;
assert.strictEqual(isModelDeprecated('agy', 'unknown-model'), false);
});
});
describe('getModelDeprecationReason', () => {
it('returns undefined for thinking models (no longer deprecated)', () => {
const { getModelDeprecationReason } = modelCatalog;
assert.strictEqual(getModelDeprecationReason('agy', 'gemini-claude-opus-4-5-thinking'), undefined);
assert.strictEqual(getModelDeprecationReason('agy', 'gemini-claude-sonnet-4-5-thinking'), undefined);
});
it('returns undefined for non-deprecated models', () => {
const { getModelDeprecationReason } = modelCatalog;
assert.strictEqual(getModelDeprecationReason('agy', 'gemini-claude-sonnet-4-5'), undefined);
});
});
});