diff --git a/packages/core/src/providers/__tests__/presets/modelscope.test.ts b/packages/core/src/providers/__tests__/presets/modelscope.test.ts new file mode 100644 index 000000000..d574bb817 --- /dev/null +++ b/packages/core/src/providers/__tests__/presets/modelscope.test.ts @@ -0,0 +1,69 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +// Re-import via the relative source path so this test exercises the +// in-tree implementation even before dist/ is rebuilt (the +// @qwen-code/qwen-code-core package main points at dist/ on a fresh +// branch). The provider was deleted from the CLI side in this PR and not +// rebuilt in core's test folder until now. +import { AuthType } from '../../../core/contentGenerator.js'; +import { modelscopeProvider } from '../../presets/modelscope.js'; +import { buildInstallPlan } from '../../provider-config.js'; + +describe('modelscopeProvider', () => { + it('has correct provider config', () => { + expect(modelscopeProvider).toMatchObject({ + id: 'modelscope', + label: 'ModelScope API Key', + protocol: AuthType.USE_OPENAI, + baseUrl: 'https://api-inference.modelscope.cn/v1', + envKey: 'MODELSCOPE_API_KEY', + }); + }); + + it('creates an install plan with per-model metadata for known IDs', () => { + const plan = buildInstallPlan(modelscopeProvider, { + baseUrl: 'https://api-inference.modelscope.cn/v1', + apiKey: 'sk-modelscope', + modelIds: ['deepseek-ai/DeepSeek-V4-Flash', 'Qwen/Qwen3.5-397B-A17B'], + }); + + const models = plan.modelProviders?.[0]?.models; + expect(models).toHaveLength(2); + expect(models?.[0]).toMatchObject({ + id: 'deepseek-ai/DeepSeek-V4-Flash', + name: '[ModelScope] deepseek-ai/DeepSeek-V4-Flash', + generationConfig: { contextWindowSize: 1000000 }, + }); + expect(models?.[1]).toMatchObject({ + id: 'Qwen/Qwen3.5-397B-A17B', + name: '[ModelScope] Qwen/Qwen3.5-397B-A17B', + generationConfig: { contextWindowSize: 1000000 }, + }); + }); + + it('falls back gracefully for unknown model IDs', () => { + const plan = buildInstallPlan(modelscopeProvider, { + baseUrl: 'https://api-inference.modelscope.cn/v1', + apiKey: 'sk-modelscope', + modelIds: ['deepseek-ai/DeepSeek-V4-Flash', 'some-new-model'], + }); + + const models = plan.modelProviders?.[0]?.models; + expect(models).toHaveLength(2); + // Known model: contextWindowSize is preserved, plus modelscope's + // enableThinking=true adds extra_body.enable_thinking. + expect(models?.[0]?.generationConfig).toMatchObject({ + contextWindowSize: 1000000, + }); + expect(models?.[1]).toMatchObject({ + id: 'some-new-model', + name: '[ModelScope] some-new-model', + }); + expect(models?.[1]?.generationConfig).toBeUndefined(); + }); +}); diff --git a/packages/core/src/providers/__tests__/presets/openrouter.test.ts b/packages/core/src/providers/__tests__/presets/openrouter.test.ts index cd849c839..a0cc7c515 100644 --- a/packages/core/src/providers/__tests__/presets/openrouter.test.ts +++ b/packages/core/src/providers/__tests__/presets/openrouter.test.ts @@ -5,20 +5,59 @@ */ import { describe, expect, it } from 'vitest'; -import { openRouterProvider } from '@qwen-code/qwen-code-core'; +// Re-import via the relative source path so the new ownsModel envKey gate +// is exercised even before dist/ is rebuilt (the @qwen-code/qwen-code-core +// package resolves to dist/ on a fresh branch). +import { + openRouterProvider, + OPENROUTER_ENV_KEY, +} from '../../presets/openrouter.js'; describe('openRouterProvider', () => { - it('owns models by OpenRouter base URL', () => { + it('owns models that match BOTH our envKey and an openrouter.ai host', () => { expect( openRouterProvider.ownsModel?.({ id: 'openrouter-model', baseUrl: 'https://openrouter.ai/api/v1', + envKey: OPENROUTER_ENV_KEY, }), ).toBe(true); + }); + + it('refuses ownership over a different envKey on the same host (user-added entry)', () => { + // A user wired their own gateway through openrouter.ai with a custom env + // var — re-install must not silently delete their model entry. + expect( + openRouterProvider.ownsModel?.({ + id: 'user-added', + baseUrl: 'https://openrouter.ai/api/v1', + envKey: 'MY_PRIVATE_GATEWAY_KEY', + }), + ).toBe(false); + }); + + it('refuses ownership over an unrelated host even with our envKey', () => { expect( openRouterProvider.ownsModel?.({ id: 'other-model', baseUrl: 'https://api.example.com/v1', + envKey: OPENROUTER_ENV_KEY, + }), + ).toBe(false); + }); + + it('refuses ownership when baseUrl is missing or malformed', () => { + expect( + openRouterProvider.ownsModel?.({ + id: 'no-url', + envKey: OPENROUTER_ENV_KEY, + }), + ).toBe(false); + expect( + openRouterProvider.ownsModel?.({ + id: 'bad-url', + baseUrl: 'not a url', + envKey: OPENROUTER_ENV_KEY, }), ).toBe(false); }); diff --git a/packages/core/src/providers/index.ts b/packages/core/src/providers/index.ts index e4fd38d00..1b4551145 100644 --- a/packages/core/src/providers/index.ts +++ b/packages/core/src/providers/index.ts @@ -47,6 +47,7 @@ export { getAllProviderBaseUrls, idealabProvider, minimaxProvider, + modelscopeProvider, openRouterProvider, THIRD_PARTY_PROVIDERS, tokenPlanProvider, diff --git a/packages/core/src/providers/presets/openrouter.ts b/packages/core/src/providers/presets/openrouter.ts index 40cff65fd..b0d738b3f 100644 --- a/packages/core/src/providers/presets/openrouter.ts +++ b/packages/core/src/providers/presets/openrouter.ts @@ -25,6 +25,11 @@ export const openRouterProvider: ProviderConfig = { modelsEditable: true, modelNamePrefix: 'OpenRouter', ownsModel: (model) => { + // A user who manually added an OpenRouter-routed model under a custom + // envKey (e.g. their own gateway) shouldn't have their entry silently + // removed on re-install — require BOTH the hostname *and* our envKey to + // claim ownership. + if (model.envKey !== OPENROUTER_ENV_KEY) return false; try { const host = new URL(model.baseUrl ?? '').hostname; return host === 'openrouter.ai' || host.endsWith('.openrouter.ai');