diff --git a/config/base-qoder.settings.json b/config/base-qoder.settings.json new file mode 100644 index 00000000..d4ade6d3 --- /dev/null +++ b/config/base-qoder.settings.json @@ -0,0 +1,10 @@ +{ + "env": { + "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/qoder", + "ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed", + "ANTHROPIC_MODEL": "qoder/auto", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "qoder/ultimate", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "qoder/performance", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "qoder/efficient" + } +} diff --git a/src/cliproxy/__tests__/provider-capabilities.test.ts b/src/cliproxy/__tests__/provider-capabilities.test.ts index 02fea0b0..16f15b21 100644 --- a/src/cliproxy/__tests__/provider-capabilities.test.ts +++ b/src/cliproxy/__tests__/provider-capabilities.test.ts @@ -46,6 +46,7 @@ describe('provider-capabilities', () => { 'gitlab', 'codebuddy', 'kilo', + 'qoder', ]); }); @@ -66,6 +67,7 @@ describe('provider-capabilities', () => { 'cursor', 'codebuddy', 'kilo', + 'qoder', ]); expect(getProvidersByOAuthFlow('authorization_code')).toEqual([ 'gemini', @@ -85,6 +87,7 @@ describe('provider-capabilities', () => { 'kimi', 'codebuddy', 'kilo', + 'qoder', ]); }); diff --git a/src/cliproxy/auth/auth-types.ts b/src/cliproxy/auth/auth-types.ts index 868c9a89..41661cce 100644 --- a/src/cliproxy/auth/auth-types.ts +++ b/src/cliproxy/auth/auth-types.ts @@ -306,6 +306,13 @@ export const OAUTH_CONFIGS: Record = { scopes: [], authFlag: '--kilo-login', }, + qoder: { + provider: 'qoder', + displayName: 'Qoder', + authUrl: 'https://qoder.com/device/selectAccounts', + scopes: [], + authFlag: '--qoder-login', + }, }; /** diff --git a/src/cliproxy/config/__tests__/base-config-loader.test.ts b/src/cliproxy/config/__tests__/base-config-loader.test.ts index 88a74338..4a5bd42a 100644 --- a/src/cliproxy/config/__tests__/base-config-loader.test.ts +++ b/src/cliproxy/config/__tests__/base-config-loader.test.ts @@ -8,6 +8,7 @@ describe('base-config-loader new providers', () => { ['gitlab', '/api/provider/gitlab', 'gitlab-duo'], ['codebuddy', '/api/provider/codebuddy', 'auto'], ['kilo', '/api/provider/kilo', 'kilo/auto'], + ['qoder', '/api/provider/qoder', 'qoder/auto'], ] as const)('loads base settings for %s', (provider, baseUrlPath, defaultModel) => { const config = loadBaseConfig(provider); diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 29f6052e..e4144394 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -265,6 +265,68 @@ export const MODEL_CATALOG: Partial> = }, ], }, + qoder: { + provider: 'qoder', + displayName: 'Qoder', + defaultModel: 'qoder/auto', + models: [ + { + id: 'qoder/auto', + name: 'Qoder Auto', + description: 'Auto selects the best Qoder model for your prompt', + }, + { + id: 'qoder/ultimate', + name: 'Qoder Ultimate', + description: 'Highest quality Qoder tier', + }, + { + id: 'qoder/performance', + name: 'Qoder Performance', + description: 'Balanced quality and speed', + }, + { + id: 'qoder/efficient', + name: 'Qoder Efficient', + description: 'Cost-efficient Qoder tier', + }, + { + id: 'qoder/lite', + name: 'Qoder Lite', + description: 'Fastest and most affordable Qoder tier', + }, + { + id: 'qoder/qmodel', + name: 'Qwen 3.6 Plus (via Qoder)', + description: 'Qwen 3.6 Plus frontier model', + }, + { + id: 'qoder/dmodel', + name: 'DeepSeek V4 Pro (via Qoder)', + description: 'DeepSeek V4 Pro frontier model', + }, + { + id: 'qoder/dfmodel', + name: 'DeepSeek V4 Flash (via Qoder)', + description: 'DeepSeek V4 Flash frontier model', + }, + { + id: 'qoder/gm51model', + name: 'GLM 5.1 (via Qoder)', + description: 'GLM 5.1 frontier model', + }, + { + id: 'qoder/kmodel', + name: 'Kimi K2.6 (via Qoder)', + description: 'Kimi K2.6 frontier model', + }, + { + id: 'qoder/mmodel', + name: 'MiniMax M2.7 (via Qoder)', + description: 'MiniMax M2.7 frontier model', + }, + ], + }, kimi: { provider: 'kimi', displayName: 'Kimi (Moonshot)', diff --git a/src/cliproxy/provider-capabilities.ts b/src/cliproxy/provider-capabilities.ts index 1d020d77..eb942cd7 100644 --- a/src/cliproxy/provider-capabilities.ts +++ b/src/cliproxy/provider-capabilities.ts @@ -182,6 +182,18 @@ export const PROVIDER_CAPABILITIES: Record = { kimi: 'kimi', kiro: 'kiro', 'github-copilot': 'ghcp', + qoder: 'qoder', }; /** CCS provider → channel name mapping (reverse) */ diff --git a/src/cliproxy/types/provider-types.ts b/src/cliproxy/types/provider-types.ts index 8aa97976..e96fade7 100644 --- a/src/cliproxy/types/provider-types.ts +++ b/src/cliproxy/types/provider-types.ts @@ -16,7 +16,8 @@ export type CLIProxyProvider = | 'cursor' | 'gitlab' | 'codebuddy' - | 'kilo'; + | 'kilo' + | 'qoder'; /** CLIProxy backend selection */ export type CLIProxyBackend = 'original' | 'plus'; @@ -32,6 +33,7 @@ export const PLUS_ONLY_PROVIDERS: CLIProxyProvider[] = [ 'gitlab', 'codebuddy', 'kilo', + 'qoder', ]; /** Model mapping for each provider */ diff --git a/src/commands/command-catalog.ts b/src/commands/command-catalog.ts index a4f81c32..c3b899a9 100644 --- a/src/commands/command-catalog.ts +++ b/src/commands/command-catalog.ts @@ -205,6 +205,7 @@ export const BUILTIN_PROVIDER_SHORTCUTS: readonly ShortcutEntry[] = CLIPROXY_PRO gitlab: 'GitLab Duo via CLIProxy OAuth', codebuddy: 'CodeBuddy via CLIProxy OAuth', kilo: 'Kilo AI via CLIProxy OAuth', + qoder: 'Qoder AI via CLIProxy OAuth', }[name] || 'CLIProxy OAuth provider', }) ); diff --git a/src/config/reserved-names.ts b/src/config/reserved-names.ts index ed80ecd5..dacfb97d 100644 --- a/src/config/reserved-names.ts +++ b/src/config/reserved-names.ts @@ -16,6 +16,7 @@ export const RESERVED_PROFILE_NAMES = [ 'gitlab', 'codebuddy', 'kilo', + 'qoder', // Copilot API (GitHub Copilot proxy) 'copilot', // Cursor IDE (Cursor proxy daemon) diff --git a/src/shared/cliproxy-model-routing.ts b/src/shared/cliproxy-model-routing.ts index b6194ac4..9c12f52d 100644 --- a/src/shared/cliproxy-model-routing.ts +++ b/src/shared/cliproxy-model-routing.ts @@ -57,6 +57,7 @@ const PROVIDER_OWNER_HINTS: Record = { kimi: ['kimi', 'moonshot'], kiro: ['kiro', 'aws'], ghcp: ['github', 'copilot'], + qoder: ['qoder'], }; function normalize(value: string | null | undefined): string { diff --git a/tests/unit/auth/profile-detector.test.ts b/tests/unit/auth/profile-detector.test.ts index 6c8293c4..7bdb344a 100644 --- a/tests/unit/auth/profile-detector.test.ts +++ b/tests/unit/auth/profile-detector.test.ts @@ -97,6 +97,7 @@ describe('ProfileDetector', () => { expect(detector.detectProfileType('gitlab').provider).toBe('gitlab'); expect(detector.detectProfileType('codebuddy').provider).toBe('codebuddy'); expect(detector.detectProfileType('kilo').provider).toBe('kilo'); + expect(detector.detectProfileType('qoder').provider).toBe('qoder'); }); it('should detect settings-based profile from unified config', () => { diff --git a/tests/unit/commands/completion-backend.test.ts b/tests/unit/commands/completion-backend.test.ts index 456e724f..b98ae57a 100644 --- a/tests/unit/commands/completion-backend.test.ts +++ b/tests/unit/commands/completion-backend.test.ts @@ -85,6 +85,7 @@ describe('completion backend', () => { expect(values).toContain('gitlab'); expect(values).toContain('codebuddy'); expect(values).toContain('kilo'); + expect(values).toContain('qoder'); expect(values).toContain('localglm'); expect(values).toContain('work'); expect(values).toContain('my-codex'); diff --git a/tests/unit/commands/help-command-parity.test.ts b/tests/unit/commands/help-command-parity.test.ts index bd7f6692..739f4488 100644 --- a/tests/unit/commands/help-command-parity.test.ts +++ b/tests/unit/commands/help-command-parity.test.ts @@ -61,6 +61,7 @@ describe('help command parity', () => { expect(rendered.includes('gitlab')).toBe(true); expect(rendered.includes('codebuddy')).toBe(true); expect(rendered.includes('kilo')).toBe(true); + expect(rendered.includes('qoder')).toBe(true); expect(rendered.includes('--gitlab-token-login')).toBe(true); expect(rendered.includes('--token-login')).toBe(true); expect(rendered.includes('--gitlab-url ')).toBe(true); diff --git a/tests/unit/web-server/cliproxy-auth-routes.test.ts b/tests/unit/web-server/cliproxy-auth-routes.test.ts index 93a1eed5..1a0fb267 100644 --- a/tests/unit/web-server/cliproxy-auth-routes.test.ts +++ b/tests/unit/web-server/cliproxy-auth-routes.test.ts @@ -18,6 +18,7 @@ describe('cliproxy-auth-routes start-url guard', () => { "Provider 'codebuddy' uses Device Code flow" ); expect(getStartUrlUnsupportedReason('kilo')).toContain("Provider 'kilo' uses Device Code flow"); + expect(getStartUrlUnsupportedReason('qoder')).toContain("Provider 'qoder' uses Device Code flow"); }); it('allows Cursor browser URL auth on start-url', () => { diff --git a/ui/public/assets/providers/qoder.svg b/ui/public/assets/providers/qoder.svg new file mode 100644 index 00000000..829ec79a --- /dev/null +++ b/ui/public/assets/providers/qoder.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index dc3cb84a..b4df3f7a 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -93,6 +93,7 @@ export function ProviderEditor({ qwen: ['alibaba', 'qwen'], iflow: ['iflow'], kilo: ['kilo'], + qoder: ['qoder'], kiro: ['kiro', 'aws'], ghcp: ['github', 'copilot'], kimi: ['kimi', 'moonshot'], diff --git a/ui/src/components/setup/wizard/constants.ts b/ui/src/components/setup/wizard/constants.ts index f40b591e..1fca25f4 100644 --- a/ui/src/components/setup/wizard/constants.ts +++ b/ui/src/components/setup/wizard/constants.ts @@ -25,6 +25,7 @@ const WIZARD_PROVIDER_ORDER: CLIProxyProvider[] = [ 'iflow', 'kilo', 'kiro', + 'qoder', ]; export const PROVIDERS: ProviderOption[] = WIZARD_PROVIDER_ORDER.map((id) => ({ diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index df7d35dd..805120af 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -454,6 +454,128 @@ export const MODEL_CATALOGS: Record = { }, ], }, + qoder: { + provider: 'qoder', + displayName: 'Qoder', + defaultModel: 'qoder/auto', + models: [ + { + id: 'qoder/auto', + name: 'Qoder Auto', + description: 'Auto selects the best Qoder model for your prompt', + presetMapping: { + default: 'qoder/auto', + opus: 'qoder/auto', + sonnet: 'qoder/auto', + haiku: 'qoder/auto', + }, + }, + { + id: 'qoder/ultimate', + name: 'Qoder Ultimate', + description: 'Highest quality Qoder tier', + presetMapping: { + default: 'qoder/ultimate', + opus: 'qoder/ultimate', + sonnet: 'qoder/ultimate', + haiku: 'qoder/ultimate', + }, + }, + { + id: 'qoder/performance', + name: 'Qoder Performance', + description: 'Balanced quality and speed', + presetMapping: { + default: 'qoder/performance', + opus: 'qoder/performance', + sonnet: 'qoder/performance', + haiku: 'qoder/performance', + }, + }, + { + id: 'qoder/efficient', + name: 'Qoder Efficient', + description: 'Cost-efficient Qoder tier', + presetMapping: { + default: 'qoder/efficient', + opus: 'qoder/efficient', + sonnet: 'qoder/efficient', + haiku: 'qoder/efficient', + }, + }, + { + id: 'qoder/lite', + name: 'Qoder Lite', + description: 'Fastest and most affordable Qoder tier', + presetMapping: { + default: 'qoder/lite', + opus: 'qoder/lite', + sonnet: 'qoder/lite', + haiku: 'qoder/lite', + }, + }, + { + id: 'qoder/qmodel', + name: 'Qwen 3.6 Plus (via Qoder)', + description: 'Qwen 3.6 Plus frontier model', + presetMapping: { + default: 'qoder/qmodel', + opus: 'qoder/qmodel', + sonnet: 'qoder/qmodel', + haiku: 'qoder/qmodel', + }, + }, + { + id: 'qoder/dmodel', + name: 'DeepSeek V4 Pro (via Qoder)', + description: 'DeepSeek V4 Pro frontier model', + presetMapping: { + default: 'qoder/dmodel', + opus: 'qoder/dmodel', + sonnet: 'qoder/dmodel', + haiku: 'qoder/dfmodel', + }, + }, + { + id: 'qoder/dfmodel', + name: 'DeepSeek V4 Flash (via Qoder)', + description: 'DeepSeek V4 Flash frontier model', + }, + { + id: 'qoder/gm51model', + name: 'GLM 5.1 (via Qoder)', + description: 'GLM 5.1 frontier model', + presetMapping: { + default: 'qoder/gm51model', + opus: 'qoder/gm51model', + sonnet: 'qoder/gm51model', + haiku: 'qoder/gm51model', + }, + }, + { + id: 'qoder/kmodel', + name: 'Kimi K2.6 (via Qoder)', + description: 'Kimi K2.6 frontier model', + presetMapping: { + default: 'qoder/kmodel', + opus: 'qoder/kmodel', + sonnet: 'qoder/kmodel', + haiku: 'qoder/kmodel', + }, + }, + { + id: 'qoder/mmodel', + name: 'MiniMax M2.7 (via Qoder)', + description: 'MiniMax M2.7 frontier model', + presetMapping: { + default: 'qoder/mmodel', + opus: 'qoder/mmodel', + sonnet: 'qoder/mmodel', + haiku: 'qoder/mmodel', + }, + }, + ], + }, kimi: { provider: 'kimi', displayName: 'Kimi (Moonshot)', diff --git a/ui/src/lib/provider-config.ts b/ui/src/lib/provider-config.ts index b99bf270..cda84a9b 100644 --- a/ui/src/lib/provider-config.ts +++ b/ui/src/lib/provider-config.ts @@ -105,6 +105,7 @@ export const PROVIDER_ASSETS: Partial> = { gitlab: '/assets/providers/gitlab.svg', codebuddy: '/assets/providers/codebuddy.png', kilo: '/assets/providers/kilo.png', + qoder: '/assets/providers/qoder.svg', ghcp: '/assets/providers/copilot.svg', claude: '/assets/providers/claude.svg', kimi: '/assets/providers/kimi.svg', @@ -135,6 +136,7 @@ export const PROVIDER_FALLBACK_VISUALS: Record = { gitlab: '#FC6D26', codebuddy: '#2563EB', kilo: '#E11D48', + qoder: '#D97706', ghcp: '#43aa8b', claude: '#D97757', kimi: '#FF6B35', diff --git a/ui/tests/unit/ui/lib/provider-config.test.ts b/ui/tests/unit/ui/lib/provider-config.test.ts index f4552276..8d11c520 100644 --- a/ui/tests/unit/ui/lib/provider-config.test.ts +++ b/ui/tests/unit/ui/lib/provider-config.test.ts @@ -68,6 +68,7 @@ describe('provider presentation metadata', () => { 'gitlab', 'codebuddy', 'kilo', + 'qoder', ]); expect(getProviderSection('gitlab')?.id).toBe('plus-extra'); expect(getProviderSection('gemini')?.id).toBe('core'); @@ -128,6 +129,7 @@ describe('provider presentation metadata', () => { '/assets/providers/codebuddy.png', ], ['kilo', 'Kilo AI', 'Kilo AI coding assistant', '/assets/providers/kilo.png'], + ['qoder', 'Qoder', 'Qoder AI coding assistant', '/assets/providers/qoder.svg'], ])('recognizes %s across dashboard display helpers', (provider, name, description, asset) => { expect(getProviderDisplayName(provider)).toBe(name); expect(getProviderDescription(provider)).toBe(description);