diff --git a/README.md b/README.md index c059aac6..ae84faa2 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ The dashboard provides visual management for all account types: | **Azure Foundry** | API Key | `ccs foundry` | Claude via Microsoft Azure | | **Minimax** | API Key | `ccs mm` | M2 series, 1M context | | **DeepSeek** | API Key | `ccs deepseek` | V3.2 and R1 reasoning | +| **Novita AI** | API Key | `ccs api create --preset novita` | Anthropic-compatible Novita endpoint for Claude Code | | **Qwen (OAuth)** | OAuth | `ccs qwen` | Qwen Code via CLIProxy | | **Qwen API** | API Key | `ccs api create --preset qwen` | DashScope Anthropic-compatible API | | **Alibaba Coding Plan** | API Key | `ccs api create --preset alibaba-coding-plan` | Model Studio Coding Plan endpoint | diff --git a/src/shared/provider-preset-catalog.ts b/src/shared/provider-preset-catalog.ts index a1704d0d..bdb16441 100644 --- a/src/shared/provider-preset-catalog.ts +++ b/src/shared/provider-preset-catalog.ts @@ -21,6 +21,7 @@ export const PROVIDER_PRESET_IDS = [ 'deepseek', 'qwen', 'ollama-cloud', + 'novita', ] as const; export type ProviderPresetId = (typeof PROVIDER_PRESET_IDS)[number]; @@ -254,6 +255,20 @@ const RAW_PROVIDER_PRESET_DEFINITIONS: readonly ProviderPresetDefinition[] = [ badge: 'Cloud', icon: '/icons/ollama.svg', }, + { + id: 'novita', + name: 'Novita AI', + description: 'Anthropic-compatible API for Claude Code and CCS profiles', + baseUrl: 'https://api.novita.ai/anthropic', + defaultProfileName: 'novita', + defaultModel: 'deepseek/deepseek-v3.2', + apiKeyPlaceholder: 'YOUR_NOVITA_API_KEY', + apiKeyHint: 'Get your API key at novita.ai', + category: 'alternative', + requiresApiKey: true, + badge: 'Anthropic-compatible', + icon: '/icons/novita.svg', + }, ]; function clonePresetDefinition(preset: ProviderPresetDefinition): ProviderPresetDefinition { diff --git a/tests/unit/api/provider-presets-novita.test.ts b/tests/unit/api/provider-presets-novita.test.ts new file mode 100644 index 00000000..b820e536 --- /dev/null +++ b/tests/unit/api/provider-presets-novita.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'bun:test'; +import { getPresetById, isValidPresetId } from '../../../src/api/services/provider-presets'; + +describe('provider-presets-novita', () => { + it('resolves novita preset id', () => { + const preset = getPresetById('novita'); + expect(preset?.id).toBe('novita'); + expect(preset?.baseUrl).toBe('https://api.novita.ai/anthropic'); + expect(preset?.defaultProfileName).toBe('novita'); + }); + + it('resolves novita preset with expected model IDs', () => { + const preset = getPresetById('novita'); + expect(preset?.defaultModel).toBe('deepseek/deepseek-v3.2'); + }); + + it('validates novita preset requires API key', () => { + const preset = getPresetById('novita'); + expect(preset?.requiresApiKey).toBe(true); + }); + + it('treats novita as a valid preset id', () => { + expect(isValidPresetId('novita')).toBe(true); + }); + + it('handles whitespace in novita preset id', () => { + const preset = getPresetById(' novita '); + expect(preset?.id).toBe('novita'); + }); + + it('handles uppercase novita preset id', () => { + const preset = getPresetById('NOVITA'); + expect(preset?.id).toBe('novita'); + }); + + it('does not resolve partial or invalid novita ids', () => { + expect(getPresetById('novita-invalid')).toBeUndefined(); + expect(isValidPresetId('novita-invalid')).toBe(false); + }); +}); diff --git a/tests/unit/api/provider-presets.test.ts b/tests/unit/api/provider-presets.test.ts index 2246ed6e..9f5d03e2 100644 --- a/tests/unit/api/provider-presets.test.ts +++ b/tests/unit/api/provider-presets.test.ts @@ -1,5 +1,11 @@ +import { existsSync } from 'fs'; +import { resolve } from 'path'; import { describe, expect, it } from 'bun:test'; -import { getPresetById, isValidPresetId } from '../../../src/api/services/provider-presets'; +import { + PROVIDER_PRESETS, + getPresetById, + isValidPresetId, +} from '../../../src/api/services/provider-presets'; describe('provider-presets', () => { it('resolves Alibaba Coding Plan preset id', () => { @@ -54,4 +60,13 @@ describe('provider-presets', () => { const preset = getPresetById('qwen'); expect(preset?.defaultProfileName).toBe('qwen-api'); }); + + it('only references provider preset icons that exist in ui/public', () => { + for (const preset of PROVIDER_PRESETS) { + if (!preset.icon) continue; + + const iconPath = resolve(import.meta.dir, '../../../ui/public', preset.icon.replace(/^\/+/, '')); + expect(existsSync(iconPath)).toBe(true); + } + }); }); diff --git a/ui/public/icons/novita.svg b/ui/public/icons/novita.svg new file mode 100644 index 00000000..356b6d69 --- /dev/null +++ b/ui/public/icons/novita.svg @@ -0,0 +1,9 @@ + + Novita AI + +