fix: enable verified ModelScope speech downloads
Selecting ModelScope could leave supported speech models unavailable because their fixed targets were incomplete and the official LFS redirect host was not allowed. Speech models now resolve complete per-source artifact fingerprints while preserving integrity checks, ZIP compatibility, and source isolation. Release note: 修复选择 ModelScope 后部分语音模型仍无法下载的问题;六个语音模型现均可从已验证的固定 ModelScope 来源下载。
This commit is contained in:
@@ -32,7 +32,10 @@ import {
|
||||
} from '../shared/document-parsing-contracts'
|
||||
import {
|
||||
MODEL_DOWNLOAD_SOURCES,
|
||||
getMaximumModelPackageBytes,
|
||||
getModelDownloadAvailability,
|
||||
getModelPackageFingerprints,
|
||||
modelPackageFingerprintMatches,
|
||||
resolveModelDownloadPackage,
|
||||
type ModelDownloadSource,
|
||||
type ResolvedModelArtifactFile
|
||||
@@ -105,6 +108,21 @@ function toCatalogView(entry: DocumentOcrModelCatalogEntry) {
|
||||
})
|
||||
}
|
||||
|
||||
function documentOcrPackageMatches(
|
||||
entry: DocumentOcrModelCatalogEntry,
|
||||
files: readonly {
|
||||
name: string
|
||||
role: string
|
||||
size: number
|
||||
sha256: string
|
||||
}[]
|
||||
): boolean {
|
||||
return getModelPackageFingerprints(entry.files).some(
|
||||
(fingerprint) =>
|
||||
modelPackageFingerprintMatches(fingerprint, files)
|
||||
)
|
||||
}
|
||||
|
||||
function safeChild(parent: string, name: string): string {
|
||||
return managedModelChild(
|
||||
parent,
|
||||
@@ -408,6 +426,9 @@ export class DocumentOcrModelManager {
|
||||
if (!installed) {
|
||||
throw new Error('只能导出已安装的 OCR 模型')
|
||||
}
|
||||
if (!documentOcrPackageMatches(entry, installed.files)) {
|
||||
throw new Error('OCR 模型文件与当前模型目录不匹配')
|
||||
}
|
||||
const directory = this.modelDirectory(entry.id)
|
||||
const files = []
|
||||
for (const expected of entry.files) {
|
||||
@@ -416,11 +437,7 @@ export class DocumentOcrModelManager {
|
||||
file.name === expected.name &&
|
||||
file.role === expected.role
|
||||
)
|
||||
if (
|
||||
!recorded ||
|
||||
recorded.size !== expected.size ||
|
||||
recorded.sha256 !== expected.sha256
|
||||
) {
|
||||
if (!recorded) {
|
||||
throw new Error(`OCR 模型文件校验失败:${expected.name}`)
|
||||
}
|
||||
files.push({
|
||||
@@ -447,10 +464,7 @@ export class DocumentOcrModelManager {
|
||||
archivePath: string
|
||||
): Promise<InstalledDocumentOcrModel> {
|
||||
const entry = this.requireCatalogEntry(modelId)
|
||||
const expectedTotal = entry.files.reduce(
|
||||
(total, file) => total + file.size,
|
||||
0
|
||||
)
|
||||
const expectedTotal = getMaximumModelPackageBytes(entry.files)
|
||||
const operation = this.beginOperation(
|
||||
entry.id,
|
||||
'import',
|
||||
@@ -482,22 +496,13 @@ export class DocumentOcrModelManager {
|
||||
operation.progress.completedBytes = completedBytes
|
||||
}
|
||||
})
|
||||
for (const expected of entry.files) {
|
||||
const archived = descriptor.files.find(
|
||||
(file) =>
|
||||
file.name === expected.name &&
|
||||
file.role === expected.role
|
||||
)
|
||||
if (
|
||||
!archived ||
|
||||
archived.size !== expected.size ||
|
||||
archived.sha256 !== expected.sha256
|
||||
) {
|
||||
throw new Error(
|
||||
`OCR 模型 ZIP 与当前模型目录不匹配:${expected.name}`
|
||||
)
|
||||
}
|
||||
if (!documentOcrPackageMatches(entry, descriptor.files)) {
|
||||
throw new Error('OCR 模型 ZIP 与当前模型目录不匹配')
|
||||
}
|
||||
operation.progress.totalBytes = descriptor.files.reduce(
|
||||
(total, file) => total + file.size,
|
||||
0
|
||||
)
|
||||
operation.progress.phase = 'installing'
|
||||
operation.progress.currentFile = null
|
||||
const installed = installedDocumentOcrModelSchema.parse({
|
||||
@@ -723,6 +728,7 @@ export class DocumentOcrModelManager {
|
||||
throw new Error('本地 OCR 模型目录包含不安全文件')
|
||||
}
|
||||
}
|
||||
const actualFiles: InstalledDocumentOcrModel['files'] = []
|
||||
for (const file of entry.files) {
|
||||
ensureModelOperationNotAborted(signal)
|
||||
const path = safeChild(sourceDirectory, file.name)
|
||||
@@ -730,13 +736,22 @@ export class DocumentOcrModelManager {
|
||||
if (!info.isFile() || info.isSymbolicLink()) {
|
||||
throw new Error(`OCR 模型文件必须是普通文件:${file.name}`)
|
||||
}
|
||||
const actual = await hashModelFile(path, signal)
|
||||
if (
|
||||
actual.size !== file.size ||
|
||||
actual.sha256 !== file.sha256
|
||||
info.size <= 0 ||
|
||||
info.size > this.maxFileBytes
|
||||
) {
|
||||
throw new Error(`本地 OCR 模型文件校验失败:${file.name}`)
|
||||
throw new RangeError(`OCR 模型文件大小无效:${file.name}`)
|
||||
}
|
||||
actualFiles.push({
|
||||
name: file.name,
|
||||
role: file.role,
|
||||
...(await hashModelFile(path, signal))
|
||||
})
|
||||
}
|
||||
if (!documentOcrPackageMatches(entry, actualFiles)) {
|
||||
throw new Error(
|
||||
'本地 OCR 模型文件校验失败:与当前模型目录不匹配'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -765,6 +780,9 @@ export class DocumentOcrModelManager {
|
||||
installedAt: new Date().toISOString(),
|
||||
files
|
||||
})
|
||||
if (!documentOcrPackageMatches(entry, manifest.files)) {
|
||||
throw new Error('OCR 模型文件与当前模型目录不匹配')
|
||||
}
|
||||
await writeFile(
|
||||
safeChild(stagingDirectory, MANIFEST_FILE_NAME),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
@@ -831,6 +849,9 @@ export class DocumentOcrModelManager {
|
||||
): Promise<void> {
|
||||
const directory = this.modelDirectory(entry.id)
|
||||
const manifest = await this.readInstalledManifest(entry)
|
||||
if (!documentOcrPackageMatches(entry, manifest.files)) {
|
||||
throw new Error('OCR 模型文件与当前模型目录不匹配')
|
||||
}
|
||||
for (const file of entry.files) {
|
||||
const installed = manifest.files.find(
|
||||
(candidate) =>
|
||||
@@ -842,8 +863,6 @@ export class DocumentOcrModelManager {
|
||||
)
|
||||
if (
|
||||
!installed ||
|
||||
actual.size !== file.size ||
|
||||
actual.sha256 !== file.sha256 ||
|
||||
actual.size !== installed.size ||
|
||||
actual.sha256 !== installed.sha256
|
||||
) {
|
||||
@@ -893,6 +912,9 @@ export class DocumentOcrModelManager {
|
||||
): Promise<DocumentOcrAssets> {
|
||||
const directory = this.modelDirectory(entry.id)
|
||||
const manifest = await this.readInstalledManifest(entry)
|
||||
if (!documentOcrPackageMatches(entry, manifest.files)) {
|
||||
throw new Error('OCR 模型文件与当前模型目录不匹配')
|
||||
}
|
||||
const loaded = new Map<
|
||||
DocumentOcrModelFile['role'],
|
||||
ArrayBuffer
|
||||
@@ -911,8 +933,6 @@ export class DocumentOcrModelManager {
|
||||
}
|
||||
if (
|
||||
!installed ||
|
||||
actual.size !== file.size ||
|
||||
actual.sha256 !== file.sha256 ||
|
||||
actual.size !== installed.size ||
|
||||
actual.sha256 !== installed.sha256
|
||||
) {
|
||||
|
||||
@@ -1,29 +1,36 @@
|
||||
import {
|
||||
MODEL_DOWNLOAD_REDIRECT_HOSTS,
|
||||
type ModelArtifactFingerprint,
|
||||
type ModelArtifactTarget
|
||||
} from '../shared/model-download-contracts'
|
||||
|
||||
export function modelScopeTarget(
|
||||
repository: string,
|
||||
revision: string,
|
||||
file: string
|
||||
file: string,
|
||||
fingerprint?: ModelArtifactFingerprint
|
||||
): ModelArtifactTarget {
|
||||
const repositoryUrl = `https://modelscope.cn/models/${repository}`
|
||||
return {
|
||||
...fingerprint,
|
||||
url: `${repositoryUrl}/resolve/${revision}/${file}`,
|
||||
repositoryUrl,
|
||||
revision,
|
||||
redirectHosts: []
|
||||
redirectHosts: [
|
||||
...MODEL_DOWNLOAD_REDIRECT_HOSTS.modelscope
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export function huggingFaceTarget(
|
||||
repository: string,
|
||||
revision: string,
|
||||
file: string
|
||||
file: string,
|
||||
fingerprint?: ModelArtifactFingerprint
|
||||
): ModelArtifactTarget {
|
||||
const repositoryUrl = `https://huggingface.co/${repository}`
|
||||
return {
|
||||
...fingerprint,
|
||||
url: `${repositoryUrl}/resolve/${revision}/${file}`,
|
||||
repositoryUrl,
|
||||
revision,
|
||||
|
||||
@@ -25,10 +25,31 @@ const whisperTinyHuggingFaceRepository =
|
||||
const whisperTinyHuggingFaceRevision =
|
||||
'65176e2deb88badc814a94058666cadccc29b61c'
|
||||
|
||||
const paraformerBilingualModelScopeRepository =
|
||||
'pengzhendong/sherpa-onnx-paraformer-zh'
|
||||
const paraformerBilingualModelScopeRevision =
|
||||
'66e549ea2ba4951d5d533b8ad3ffad09a29a4ba9'
|
||||
|
||||
const paraformerTrilingualModelScopeRepository =
|
||||
'pengzhendong/sherpa-onnx-paraformer-trilingual-zh-cantonese-en'
|
||||
const paraformerTrilingualModelScopeRevision =
|
||||
'7cd698783acc17a4d0a7d3390e1588b534d949c7'
|
||||
|
||||
const whisperSmallModelScopeRepository =
|
||||
'pengzhendong/sherpa-onnx-whisper-small'
|
||||
const whisperSmallModelScopeRevision =
|
||||
'893b50bd57565edb13eb1dfe84d33bfef77b0102'
|
||||
|
||||
const whisperMediumModelScopeRepository =
|
||||
'pengzhendong/sherpa-onnx-whisper-medium'
|
||||
const whisperMediumModelScopeRevision =
|
||||
'4d75596a69806a269ff5911bb56fbae542f35463'
|
||||
|
||||
/**
|
||||
* Model weights are never bundled with GoodBuddy. Canonical file identity is
|
||||
* source-independent; a source target is included only after its bytes match
|
||||
* the declared size and SHA-256.
|
||||
* Model weights are never bundled with GoodBuddy. Each file keeps a canonical
|
||||
* fingerprint for local import compatibility, while a source target can pin a
|
||||
* different verified size and SHA-256 when that source publishes another
|
||||
* compatible artifact revision.
|
||||
*/
|
||||
export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
|
||||
speechModelCatalogEntrySchema.array().parse([
|
||||
@@ -193,17 +214,17 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
|
||||
speed: 'fast',
|
||||
recommended: true,
|
||||
repositoryUrls: {
|
||||
modelscope:
|
||||
`https://modelscope.cn/models/${paraformerBilingualModelScopeRepository}`,
|
||||
'hugging-face':
|
||||
'https://huggingface.co/csukuangfj/sherpa-onnx-paraformer-bilingual-zh-en'
|
||||
},
|
||||
license: {
|
||||
name: 'MIT License',
|
||||
name: 'Apache License 2.0 / MIT License',
|
||||
notice:
|
||||
'转换仓库声明 MIT License;模型源自 FunASR Paraformer,使用前请同时阅读仓库说明。',
|
||||
'ModelScope 工件声明 Apache License 2.0;Hugging Face 转换仓库声明 MIT License。模型源自 FunASR Paraformer,使用前请阅读所选来源的仓库说明。',
|
||||
url:
|
||||
'https://huggingface.co/csukuangfj/' +
|
||||
'sherpa-onnx-paraformer-bilingual-zh-en/blob/' +
|
||||
'4b891f7b5c73d874e607797a4b0578fd4c35dd4b/README.md'
|
||||
`https://modelscope.cn/models/${paraformerBilingualModelScopeRepository}`
|
||||
},
|
||||
manualOnly: false,
|
||||
files: [
|
||||
@@ -214,6 +235,16 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
|
||||
sha256:
|
||||
'9ada9127ca5b82320385ac12340eb8b05dee64fd45cf8cf593ec693826ec2fd7',
|
||||
targets: {
|
||||
modelscope: modelScopeTarget(
|
||||
paraformerBilingualModelScopeRepository,
|
||||
paraformerBilingualModelScopeRevision,
|
||||
'model.int8.onnx',
|
||||
{
|
||||
size: 227_330_205,
|
||||
sha256:
|
||||
'90bc03034ae1bef9575f8cc798cd1519c8be8aa9e8b458a033e32017ff4d584c'
|
||||
}
|
||||
),
|
||||
'hugging-face': huggingFaceTarget(
|
||||
'csukuangfj/sherpa-onnx-paraformer-bilingual-zh-en',
|
||||
'4b891f7b5c73d874e607797a4b0578fd4c35dd4b',
|
||||
@@ -228,6 +259,16 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
|
||||
sha256:
|
||||
'59aba8873a2ed1e122c25fee421e25f283b63290efbde85c1f01a853d83cb6e6',
|
||||
targets: {
|
||||
modelscope: modelScopeTarget(
|
||||
paraformerBilingualModelScopeRepository,
|
||||
paraformerBilingualModelScopeRevision,
|
||||
'tokens.txt',
|
||||
{
|
||||
size: 75_354,
|
||||
sha256:
|
||||
'6c0e3b35cece259829e6cb5b8d90d13db88f61ea3a2953d11898e4b2bfd7a2e2'
|
||||
}
|
||||
),
|
||||
'hugging-face': huggingFaceTarget(
|
||||
'csukuangfj/sherpa-onnx-paraformer-bilingual-zh-en',
|
||||
'4b891f7b5c73d874e607797a4b0578fd4c35dd4b',
|
||||
@@ -249,6 +290,8 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
|
||||
speed: 'balanced',
|
||||
recommended: false,
|
||||
repositoryUrls: {
|
||||
modelscope:
|
||||
`https://modelscope.cn/models/${paraformerTrilingualModelScopeRepository}`,
|
||||
'hugging-face':
|
||||
'https://huggingface.co/csukuangfj/sherpa-onnx-paraformer-trilingual-zh-cantonese-en'
|
||||
},
|
||||
@@ -270,6 +313,11 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
|
||||
sha256:
|
||||
'eb3cdd288f535cf73258f491cdd7d68ad5a00aee135c0bba4c0884ea8d926144',
|
||||
targets: {
|
||||
modelscope: modelScopeTarget(
|
||||
paraformerTrilingualModelScopeRepository,
|
||||
paraformerTrilingualModelScopeRevision,
|
||||
'model.int8.onnx'
|
||||
),
|
||||
'hugging-face': huggingFaceTarget(
|
||||
'csukuangfj/sherpa-onnx-paraformer-trilingual-zh-cantonese-en',
|
||||
'8d90151338178bb433354c9fb677bd3acb8023cd',
|
||||
@@ -284,6 +332,11 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
|
||||
sha256:
|
||||
'8e4593d7a2eb2404ff82976b5494265e9a06283ca4d5e8605bf7b4fed557a492',
|
||||
targets: {
|
||||
modelscope: modelScopeTarget(
|
||||
paraformerTrilingualModelScopeRepository,
|
||||
paraformerTrilingualModelScopeRevision,
|
||||
'tokens.txt'
|
||||
),
|
||||
'hugging-face': huggingFaceTarget(
|
||||
'csukuangfj/sherpa-onnx-paraformer-trilingual-zh-cantonese-en',
|
||||
'8d90151338178bb433354c9fb677bd3acb8023cd',
|
||||
@@ -305,6 +358,8 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
|
||||
speed: 'balanced',
|
||||
recommended: false,
|
||||
repositoryUrls: {
|
||||
modelscope:
|
||||
`https://modelscope.cn/models/${whisperSmallModelScopeRepository}`,
|
||||
'hugging-face':
|
||||
'https://huggingface.co/csukuangfj/sherpa-onnx-whisper-small'
|
||||
},
|
||||
@@ -323,6 +378,11 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
|
||||
sha256:
|
||||
'4cbe7b22fa9026b843b60a68640c747de05bafb1a11b57edc0e66c232d9f33a9',
|
||||
targets: {
|
||||
modelscope: modelScopeTarget(
|
||||
whisperSmallModelScopeRepository,
|
||||
whisperSmallModelScopeRevision,
|
||||
'small-encoder.int8.onnx'
|
||||
),
|
||||
'hugging-face': huggingFaceTarget(
|
||||
'csukuangfj/sherpa-onnx-whisper-small',
|
||||
'8f3c18b358db4d1f2fc1eae49d75cd20989e4309',
|
||||
@@ -337,6 +397,11 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
|
||||
sha256:
|
||||
'acad50b5c782696e91b55914cc5ab4f756f1532f76e22aa6fc615f39fb69a8ee',
|
||||
targets: {
|
||||
modelscope: modelScopeTarget(
|
||||
whisperSmallModelScopeRepository,
|
||||
whisperSmallModelScopeRevision,
|
||||
'small-decoder.int8.onnx'
|
||||
),
|
||||
'hugging-face': huggingFaceTarget(
|
||||
'csukuangfj/sherpa-onnx-whisper-small',
|
||||
'8f3c18b358db4d1f2fc1eae49d75cd20989e4309',
|
||||
@@ -351,6 +416,11 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
|
||||
sha256:
|
||||
'b34b360dbb493e781e479794586d661700670d65564001f23024971d1f2fa126',
|
||||
targets: {
|
||||
modelscope: modelScopeTarget(
|
||||
whisperSmallModelScopeRepository,
|
||||
whisperSmallModelScopeRevision,
|
||||
'small-tokens.txt'
|
||||
),
|
||||
'hugging-face': huggingFaceTarget(
|
||||
'csukuangfj/sherpa-onnx-whisper-small',
|
||||
'8f3c18b358db4d1f2fc1eae49d75cd20989e4309',
|
||||
@@ -372,6 +442,8 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
|
||||
speed: 'slow',
|
||||
recommended: false,
|
||||
repositoryUrls: {
|
||||
modelscope:
|
||||
`https://modelscope.cn/models/${whisperMediumModelScopeRepository}`,
|
||||
'hugging-face':
|
||||
'https://huggingface.co/csukuangfj/sherpa-onnx-whisper-medium'
|
||||
},
|
||||
@@ -390,6 +462,11 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
|
||||
sha256:
|
||||
'1c54582b4d829de0089f6cb63bbbdb3bf7555398bacaf855fbecf1a84dfd193e',
|
||||
targets: {
|
||||
modelscope: modelScopeTarget(
|
||||
whisperMediumModelScopeRepository,
|
||||
whisperMediumModelScopeRevision,
|
||||
'medium-encoder.int8.onnx'
|
||||
),
|
||||
'hugging-face': huggingFaceTarget(
|
||||
'csukuangfj/sherpa-onnx-whisper-medium',
|
||||
'8c31d28503847560985df21f90e14f0c736e075e',
|
||||
@@ -404,6 +481,11 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
|
||||
sha256:
|
||||
'595d00a338a365a7bfa0ca7f296cabc639583bef770ab6130df90f49a6412747',
|
||||
targets: {
|
||||
modelscope: modelScopeTarget(
|
||||
whisperMediumModelScopeRepository,
|
||||
whisperMediumModelScopeRevision,
|
||||
'medium-decoder.int8.onnx'
|
||||
),
|
||||
'hugging-face': huggingFaceTarget(
|
||||
'csukuangfj/sherpa-onnx-whisper-medium',
|
||||
'8c31d28503847560985df21f90e14f0c736e075e',
|
||||
@@ -418,6 +500,11 @@ export const SPEECH_MODEL_CATALOG: readonly SpeechModelCatalogEntry[] =
|
||||
sha256:
|
||||
'b34b360dbb493e781e479794586d661700670d65564001f23024971d1f2fa126',
|
||||
targets: {
|
||||
modelscope: modelScopeTarget(
|
||||
whisperMediumModelScopeRepository,
|
||||
whisperMediumModelScopeRevision,
|
||||
'medium-tokens.txt'
|
||||
),
|
||||
'hugging-face': huggingFaceTarget(
|
||||
'csukuangfj/sherpa-onnx-whisper-medium',
|
||||
'8c31d28503847560985df21f90e14f0c736e075e',
|
||||
|
||||
@@ -204,6 +204,20 @@ describe('speech model catalog', () => {
|
||||
file.targets['hugging-face'] !== undefined
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
SPEECH_MODEL_CATALOG.filter((entry) =>
|
||||
entry.files.every(
|
||||
(file) => file.targets.modelscope !== undefined
|
||||
)
|
||||
).map((entry) => entry.id)
|
||||
).toEqual([
|
||||
'sensevoice-small-int8',
|
||||
'whisper-tiny-multilingual',
|
||||
'paraformer-bilingual-zh-en-int8',
|
||||
'paraformer-trilingual-zh-yue-en-int8',
|
||||
'whisper-small-multilingual-int8',
|
||||
'whisper-medium-multilingual-int8'
|
||||
])
|
||||
expect(whisper?.files.map((file) => file.name)).toEqual([
|
||||
'tiny-encoder.int8.onnx',
|
||||
'tiny-decoder.int8.onnx',
|
||||
@@ -212,7 +226,7 @@ describe('speech model catalog', () => {
|
||||
expect(paraformerBilingual).toMatchObject({
|
||||
family: 'paraformer',
|
||||
languages: ['中文', '英语'],
|
||||
license: { name: 'MIT License' },
|
||||
license: { name: 'Apache License 2.0 / MIT License' },
|
||||
recommended: true
|
||||
})
|
||||
expect(paraformerTrilingual).toMatchObject({
|
||||
@@ -240,13 +254,32 @@ describe('speech model catalog', () => {
|
||||
/^https:\/\/(?:modelscope\.cn\/models|huggingface\.co)\/[^/]+\/[^/]+\/resolve\/[a-f0-9]{40}\/[^/]+$/u
|
||||
)
|
||||
}
|
||||
if (file.targets.modelscope) {
|
||||
expect(file.targets.modelscope.redirectHosts).toEqual([
|
||||
'cdn-lfs-cn-1.modelscope.cn'
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(
|
||||
paraformerBilingual?.files.some(
|
||||
(file) => file.targets.modelscope !== undefined
|
||||
)
|
||||
).toBe(false)
|
||||
paraformerBilingual?.files.map((file) => ({
|
||||
canonicalSize: file.size,
|
||||
modelscopeSize: file.targets.modelscope?.size,
|
||||
sameSha256:
|
||||
file.sha256 === file.targets.modelscope?.sha256
|
||||
}))
|
||||
).toEqual([
|
||||
{
|
||||
canonicalSize: 223_385_835,
|
||||
modelscopeSize: 227_330_205,
|
||||
sameSha256: false
|
||||
},
|
||||
{
|
||||
canonicalSize: 75_756,
|
||||
modelscopeSize: 75_354,
|
||||
sameSha256: false
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -473,6 +506,140 @@ describe('SpeechModelManager downloads', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('installs and round-trips a source-specific artifact fingerprint', async () => {
|
||||
const userData = await temporaryDirectory()
|
||||
const importUserData = await temporaryDirectory()
|
||||
const mixedUserData = await temporaryDirectory()
|
||||
const huggingFaceModel = new TextEncoder().encode('hf model')
|
||||
const huggingFaceTokens = new TextEncoder().encode('hf tokens')
|
||||
const modelScopeModel = new TextEncoder().encode(
|
||||
'modelscope model variant'
|
||||
)
|
||||
const modelScopeTokens = new TextEncoder().encode(
|
||||
'modelscope tokens variant'
|
||||
)
|
||||
const catalog = downloadableCatalog(
|
||||
huggingFaceModel,
|
||||
huggingFaceTokens
|
||||
).map((entry) => ({
|
||||
...entry,
|
||||
files: entry.files.map((file) => {
|
||||
const bytes =
|
||||
file.role === 'model'
|
||||
? modelScopeModel
|
||||
: modelScopeTokens
|
||||
return {
|
||||
...file,
|
||||
targets: {
|
||||
...file.targets,
|
||||
modelscope: file.targets.modelscope
|
||||
? {
|
||||
...file.targets.modelscope,
|
||||
size: bytes.byteLength,
|
||||
sha256: sha256(bytes)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
})
|
||||
}))
|
||||
const transport = vi.fn<typeof fetch>(async (input) => {
|
||||
const url = String(input)
|
||||
const bytes = url.endsWith('model.onnx')
|
||||
? modelScopeModel
|
||||
: modelScopeTokens
|
||||
return new Response(bytes, {
|
||||
headers: { 'content-length': String(bytes.byteLength) }
|
||||
})
|
||||
})
|
||||
const manager = new SpeechModelManager({
|
||||
userDataDirectory: userData,
|
||||
fetch: transport,
|
||||
catalog
|
||||
})
|
||||
|
||||
await expect(manager.snapshot()).resolves.toMatchObject({
|
||||
catalog: [
|
||||
{
|
||||
downloadAvailability: [
|
||||
{
|
||||
source: 'modelscope',
|
||||
available: true,
|
||||
totalBytes:
|
||||
modelScopeModel.byteLength +
|
||||
modelScopeTokens.byteLength
|
||||
},
|
||||
{
|
||||
source: 'hugging-face',
|
||||
available: true,
|
||||
totalBytes:
|
||||
huggingFaceModel.byteLength +
|
||||
huggingFaceTokens.byteLength
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
const installed = await manager.install(
|
||||
'download-test-model',
|
||||
'modelscope'
|
||||
)
|
||||
expect(installed.files).toMatchObject([
|
||||
{
|
||||
name: 'model.onnx',
|
||||
size: modelScopeModel.byteLength,
|
||||
sha256: sha256(modelScopeModel)
|
||||
},
|
||||
{
|
||||
name: 'tokens.txt',
|
||||
size: modelScopeTokens.byteLength,
|
||||
sha256: sha256(modelScopeTokens)
|
||||
}
|
||||
])
|
||||
await manager.select('download-test-model')
|
||||
await expect(manager.getSelectedRuntimeModel()).resolves.toMatchObject({
|
||||
id: 'download-test-model',
|
||||
files: installed.files
|
||||
})
|
||||
|
||||
const archive = join(userData, 'modelscope-variant.zip')
|
||||
await manager.exportArchive('download-test-model', archive)
|
||||
const importing = new SpeechModelManager({
|
||||
userDataDirectory: importUserData,
|
||||
fetch: vi.fn<typeof fetch>(),
|
||||
catalog
|
||||
})
|
||||
await expect(
|
||||
importing.importArchive('download-test-model', archive)
|
||||
).resolves.toMatchObject({
|
||||
files: installed.files
|
||||
})
|
||||
|
||||
const mixedDirectory = join(mixedUserData, 'mixed-package')
|
||||
await mkdir(mixedDirectory)
|
||||
await Promise.all([
|
||||
writeFile(
|
||||
join(mixedDirectory, 'model.onnx'),
|
||||
modelScopeModel
|
||||
),
|
||||
writeFile(
|
||||
join(mixedDirectory, 'tokens.txt'),
|
||||
huggingFaceTokens
|
||||
)
|
||||
])
|
||||
const mixed = new SpeechModelManager({
|
||||
userDataDirectory: mixedUserData,
|
||||
fetch: vi.fn<typeof fetch>(),
|
||||
catalog
|
||||
})
|
||||
await expect(
|
||||
mixed.registerLocalDirectory(
|
||||
'download-test-model',
|
||||
mixedDirectory
|
||||
)
|
||||
).rejects.toThrow('与当前模型目录不匹配')
|
||||
})
|
||||
|
||||
it('cleans only manager-owned stale staging and partial artifacts', async () => {
|
||||
const userData = await temporaryDirectory()
|
||||
const modelBytes = new TextEncoder().encode('verified model bytes')
|
||||
|
||||
@@ -28,7 +28,10 @@ import {
|
||||
} from '../../shared/speech-model-contracts'
|
||||
import {
|
||||
MODEL_DOWNLOAD_SOURCES,
|
||||
getMaximumModelPackageBytes,
|
||||
getModelDownloadAvailability,
|
||||
getModelPackageFingerprints,
|
||||
modelPackageFingerprintMatches,
|
||||
resolveModelDownloadPackage,
|
||||
type ModelDownloadSource,
|
||||
type ResolvedModelArtifactFile
|
||||
@@ -126,6 +129,21 @@ function toCatalogView(entry: SpeechModelCatalogEntry) {
|
||||
})
|
||||
}
|
||||
|
||||
function speechModelPackageMatches(
|
||||
entry: SpeechModelCatalogEntry,
|
||||
files: readonly {
|
||||
name: string
|
||||
role: string
|
||||
size: number
|
||||
sha256: string
|
||||
}[]
|
||||
): boolean {
|
||||
return getModelPackageFingerprints(entry.files).some(
|
||||
(fingerprint) =>
|
||||
modelPackageFingerprintMatches(fingerprint, files)
|
||||
)
|
||||
}
|
||||
|
||||
function validateMaximumBytes(value: number | undefined): number {
|
||||
const maximum = value ?? DEFAULT_MAX_FILE_BYTES
|
||||
if (
|
||||
@@ -457,6 +475,9 @@ export class SpeechModelManager {
|
||||
if (!installed) {
|
||||
throw new Error('只能导出已安装的语音模型')
|
||||
}
|
||||
if (!speechModelPackageMatches(entry, installed.files)) {
|
||||
throw new Error('语音模型文件与当前模型目录不匹配')
|
||||
}
|
||||
const directory = this.modelDirectory(entry.id)
|
||||
const files = []
|
||||
for (const expected of entry.files) {
|
||||
@@ -467,9 +488,7 @@ export class SpeechModelManager {
|
||||
if (
|
||||
!recorded ||
|
||||
recorded.size <= 0 ||
|
||||
recorded.size > this.maxFileBytes ||
|
||||
recorded.size !== expected.size ||
|
||||
recorded.sha256 !== expected.sha256
|
||||
recorded.size > this.maxFileBytes
|
||||
) {
|
||||
throw new Error(`语音模型文件不可导出:${expected.name}`)
|
||||
}
|
||||
@@ -497,10 +516,7 @@ export class SpeechModelManager {
|
||||
archivePath: string
|
||||
): Promise<InstalledSpeechModel> {
|
||||
const entry = this.requireCatalogEntry(modelId)
|
||||
const expectedTotal = entry.files.reduce(
|
||||
(total, file) => total + file.size,
|
||||
0
|
||||
)
|
||||
const expectedTotal = getMaximumModelPackageBytes(entry.files)
|
||||
const maximumTotalBytes = Math.min(
|
||||
MAXIMUM_ARCHIVE_BYTES,
|
||||
expectedTotal + ARCHIVE_OVERHEAD_BYTES
|
||||
@@ -536,23 +552,18 @@ export class SpeechModelManager {
|
||||
operation.progress.completedBytes = completedBytes
|
||||
}
|
||||
})
|
||||
for (const expected of entry.files) {
|
||||
const archived = descriptor.files.find(
|
||||
(file) =>
|
||||
file.name === expected.name &&
|
||||
file.role === expected.role
|
||||
)
|
||||
if (
|
||||
!archived ||
|
||||
archived.size > this.maxFileBytes ||
|
||||
archived.size !== expected.size ||
|
||||
archived.sha256 !== expected.sha256
|
||||
) {
|
||||
throw new Error(
|
||||
`语音模型 ZIP 与当前模型目录不匹配:${expected.name}`
|
||||
)
|
||||
}
|
||||
if (
|
||||
descriptor.files.some(
|
||||
(file) => file.size > this.maxFileBytes
|
||||
) ||
|
||||
!speechModelPackageMatches(entry, descriptor.files)
|
||||
) {
|
||||
throw new Error('语音模型 ZIP 与当前模型目录不匹配')
|
||||
}
|
||||
operation.progress.totalBytes = descriptor.files.reduce(
|
||||
(total, file) => total + file.size,
|
||||
0
|
||||
)
|
||||
operation.progress.phase = 'installing'
|
||||
operation.progress.currentFile = null
|
||||
const installed = installedSpeechModelSchema.parse({
|
||||
@@ -631,18 +642,7 @@ export class SpeechModelManager {
|
||||
}
|
||||
if (
|
||||
installed.files.length !== catalogEntry.files.length ||
|
||||
catalogEntry.files.some((expected) => {
|
||||
const recorded = installed.files.find(
|
||||
(file) =>
|
||||
file.name === expected.name &&
|
||||
file.role === expected.role
|
||||
)
|
||||
return (
|
||||
!recorded ||
|
||||
recorded.size !== expected.size ||
|
||||
recorded.sha256 !== expected.sha256
|
||||
)
|
||||
})
|
||||
!speechModelPackageMatches(catalogEntry, installed.files)
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
@@ -901,6 +901,7 @@ export class SpeechModelManager {
|
||||
await this.rejectUnsafeLocalEntries(sourceDirectory, signal, {
|
||||
visited: 0
|
||||
})
|
||||
const actualFiles: InstalledSpeechModel['files'] = []
|
||||
for (const expectedFile of entry.files) {
|
||||
ensureModelOperationNotAborted(signal)
|
||||
const sourceFile = safeChild(sourceDirectory, expectedFile.name)
|
||||
@@ -917,13 +918,14 @@ export class SpeechModelManager {
|
||||
) {
|
||||
throw new RangeError(`模型文件大小无效:${expectedFile.name}`)
|
||||
}
|
||||
if (
|
||||
sourceFileInfo.size !== expectedFile.size ||
|
||||
(await hashModelFile(sourceFile, signal)).sha256 !==
|
||||
expectedFile.sha256
|
||||
) {
|
||||
throw new Error(`本地模型文件校验失败:${expectedFile.name}`)
|
||||
}
|
||||
actualFiles.push({
|
||||
name: expectedFile.name,
|
||||
role: expectedFile.role,
|
||||
...(await hashModelFile(sourceFile, signal))
|
||||
})
|
||||
}
|
||||
if (!speechModelPackageMatches(entry, actualFiles)) {
|
||||
throw new Error('本地模型文件与当前模型目录不匹配')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1022,6 +1024,9 @@ export class SpeechModelManager {
|
||||
installedAt: new Date().toISOString(),
|
||||
files
|
||||
})
|
||||
if (!speechModelPackageMatches(entry, manifest.files)) {
|
||||
throw new Error('语音模型文件与当前模型目录不匹配')
|
||||
}
|
||||
await writeFile(
|
||||
safeChild(stagingDirectory, MANIFEST_FILE_NAME),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
|
||||
@@ -501,6 +501,13 @@ export function DocumentParsingSettingsSection({
|
||||
)
|
||||
const modelSourceAvailable =
|
||||
modelDownloadAvailability?.available === true
|
||||
const modelSize = installedModel
|
||||
? installedModel.files.reduce(
|
||||
(total, file) => total + file.size,
|
||||
0
|
||||
)
|
||||
: modelDownloadAvailability?.totalBytes ??
|
||||
(model ? catalogSize(model) : undefined)
|
||||
const pendingModelSelection =
|
||||
draft.localOcrModelId !== snapshot.settings.localOcrModelId
|
||||
const selectedModelReady = installedModel !== undefined
|
||||
@@ -845,7 +852,9 @@ export function DocumentParsingSettingsSection({
|
||||
})}
|
||||
</span>
|
||||
<span className="speech-model-tag">
|
||||
{formatBytes(catalogSize(model))}
|
||||
{formatBytes(
|
||||
modelSize ?? catalogSize(model)
|
||||
)}
|
||||
</span>
|
||||
<span className="speech-model-tag">
|
||||
{model.license.name}
|
||||
|
||||
@@ -117,6 +117,50 @@ describe('SpeechModelSettingsSection', () => {
|
||||
expect(openRepository).toHaveBeenCalledWith('sensevoice-small-int8')
|
||||
})
|
||||
|
||||
it('shows the selected download source artifact size', async () => {
|
||||
await changeUiLocale('en-US')
|
||||
const sourceSpecificSnapshot: SpeechModelSnapshot = {
|
||||
...snapshot,
|
||||
catalog: [
|
||||
{
|
||||
...entry,
|
||||
downloadAvailability: [
|
||||
{
|
||||
source: 'modelscope',
|
||||
available: true,
|
||||
totalBytes: 2 * 1024 * 1024
|
||||
},
|
||||
{
|
||||
source: 'hugging-face',
|
||||
available: true,
|
||||
totalBytes: 1_100
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Object.defineProperty(window, 'goodbuddy', {
|
||||
configurable: true,
|
||||
value: {
|
||||
speechModels: {
|
||||
getSnapshot: vi.fn(async () => sourceSpecificSnapshot),
|
||||
install: vi.fn(),
|
||||
cancel: vi.fn(async () => true),
|
||||
remove: vi.fn(),
|
||||
select: vi.fn(),
|
||||
importArchive: vi.fn(),
|
||||
exportArchive: vi.fn(),
|
||||
openRepository: vi.fn(),
|
||||
openModelsDirectory: vi.fn()
|
||||
}
|
||||
} as unknown as DesktopApi
|
||||
})
|
||||
|
||||
render(<SpeechModelSettingsSection />)
|
||||
|
||||
expect(await screen.findByText('2.0 MB')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lists downloadable models and starts a verified download', async () => {
|
||||
const installedSnapshot: SpeechModelSnapshot = {
|
||||
...snapshot,
|
||||
|
||||
@@ -309,11 +309,17 @@ export function SpeechModelSettingsSection({
|
||||
const percent = operation
|
||||
? progressPercent(operation)
|
||||
: undefined
|
||||
const size = model ? catalogSize(model) : undefined
|
||||
const downloadAvailability = model?.downloadAvailability.find(
|
||||
(availability) =>
|
||||
availability.source === snapshot.selectedDownloadSource
|
||||
)
|
||||
const size = installed
|
||||
? installed.files.reduce(
|
||||
(total, file) => total + file.size,
|
||||
0
|
||||
)
|
||||
: downloadAvailability?.totalBytes ??
|
||||
(model ? catalogSize(model) : undefined)
|
||||
const sourceAvailable = downloadAvailability?.available === true
|
||||
const selected = model?.id === effectiveSelectedModelId
|
||||
const inUse = model?.id === effectivePersistedModelId
|
||||
|
||||
@@ -16,7 +16,7 @@ export type ModelDownloadSource = z.infer<
|
||||
|
||||
export const MODEL_DOWNLOAD_SOURCES = modelDownloadSourceSchema.options
|
||||
export const MODEL_DOWNLOAD_REDIRECT_HOSTS = {
|
||||
modelscope: [],
|
||||
modelscope: ['cdn-lfs-cn-1.modelscope.cn'],
|
||||
'hugging-face': [
|
||||
'cdn-lfs.hf.co',
|
||||
'cdn-lfs-us-1.hf.co',
|
||||
@@ -37,8 +37,17 @@ function isSourceHost(
|
||||
: hostname === 'huggingface.co'
|
||||
}
|
||||
|
||||
export const modelArtifactFingerprintSchema = z
|
||||
.object({
|
||||
size: z.number().int().positive().safe(),
|
||||
sha256: z.string().regex(sha256Pattern)
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const modelArtifactTargetSchema = z
|
||||
.object({
|
||||
size: modelArtifactFingerprintSchema.shape.size.optional(),
|
||||
sha256: modelArtifactFingerprintSchema.shape.sha256.optional(),
|
||||
url: z.url().max(2_048),
|
||||
repositoryUrl: z.url().max(2_048),
|
||||
revision: z.string().regex(immutableRevisionPattern),
|
||||
@@ -49,6 +58,13 @@ export const modelArtifactTargetSchema = z
|
||||
})
|
||||
.strict()
|
||||
.superRefine((target, context) => {
|
||||
if ((target.size === undefined) !== (target.sha256 === undefined)) {
|
||||
context.addIssue({
|
||||
code: 'custom',
|
||||
path: ['size'],
|
||||
message: '来源专用模型工件必须同时声明大小和 SHA-256'
|
||||
})
|
||||
}
|
||||
for (const [key, value] of [
|
||||
['url', target.url],
|
||||
['repositoryUrl', target.repositoryUrl]
|
||||
@@ -157,6 +173,9 @@ export const modelDownloadAvailabilitySchema = z
|
||||
export type ModelArtifactTarget = z.infer<
|
||||
typeof modelArtifactTargetSchema
|
||||
>
|
||||
export type ModelArtifactFingerprint = z.infer<
|
||||
typeof modelArtifactFingerprintSchema
|
||||
>
|
||||
export type ModelArtifactTargets = z.infer<
|
||||
typeof modelArtifactTargetsSchema
|
||||
>
|
||||
@@ -186,6 +205,35 @@ export type ResolvedModelPackage<Role extends string = string> = {
|
||||
files: ResolvedModelArtifactFile<Role>[]
|
||||
}
|
||||
|
||||
export type ModelPackageFingerprintFile<
|
||||
Role extends string = string
|
||||
> = {
|
||||
name: string
|
||||
role: Role
|
||||
size: number
|
||||
sha256: string
|
||||
}
|
||||
|
||||
export type ModelPackageFingerprint<
|
||||
Role extends string = string
|
||||
> = {
|
||||
totalBytes: number
|
||||
files: ModelPackageFingerprintFile<Role>[]
|
||||
}
|
||||
|
||||
function totalModelBytes(
|
||||
files: readonly { size: number }[]
|
||||
): number {
|
||||
const totalBytes = files.reduce(
|
||||
(total, file) => total + file.size,
|
||||
0
|
||||
)
|
||||
if (!Number.isSafeInteger(totalBytes) || totalBytes <= 0) {
|
||||
throw new RangeError('模型总大小超出安全范围')
|
||||
}
|
||||
return totalBytes
|
||||
}
|
||||
|
||||
export function getModelDownloadAvailability(
|
||||
files: readonly ResolvableModelArtifactFile[],
|
||||
source: ModelDownloadSource
|
||||
@@ -199,14 +247,14 @@ export function getModelDownloadAvailability(
|
||||
unavailableReason: '当前下载源暂不提供此模型的完整已验证文件'
|
||||
})
|
||||
}
|
||||
const totalBytes = files.reduce((total, file) => total + file.size, 0)
|
||||
if (!Number.isSafeInteger(totalBytes) || totalBytes <= 0) {
|
||||
throw new RangeError('模型总大小超出安全范围')
|
||||
}
|
||||
return modelDownloadAvailabilitySchema.parse({
|
||||
source,
|
||||
available: true,
|
||||
totalBytes
|
||||
totalBytes: totalModelBytes(
|
||||
files.map((file) => ({
|
||||
size: file.targets[source]!.size ?? file.size
|
||||
}))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -232,18 +280,88 @@ export function resolveModelDownloadPackage<Role extends string>(
|
||||
return {
|
||||
name: file.name,
|
||||
role: file.role,
|
||||
size: file.size,
|
||||
sha256: file.sha256,
|
||||
size: target.size ?? file.size,
|
||||
sha256: target.sha256 ?? file.sha256,
|
||||
target
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function getModelPackageFingerprints<Role extends string>(
|
||||
files: readonly ResolvableModelArtifactFile<Role>[]
|
||||
): ModelPackageFingerprint<Role>[] {
|
||||
const candidates: ModelPackageFingerprint<Role>[] = [
|
||||
{
|
||||
totalBytes: totalModelBytes(files),
|
||||
files: files.map((file) => ({
|
||||
name: file.name,
|
||||
role: file.role,
|
||||
size: file.size,
|
||||
sha256: file.sha256
|
||||
}))
|
||||
}
|
||||
]
|
||||
for (const source of MODEL_DOWNLOAD_SOURCES) {
|
||||
if (!files.every((file) => file.targets[source])) {
|
||||
continue
|
||||
}
|
||||
const resolved = resolveModelDownloadPackage(files, source)
|
||||
candidates.push({
|
||||
totalBytes: resolved.totalBytes,
|
||||
files: resolved.files.map((file) => ({
|
||||
name: file.name,
|
||||
role: file.role,
|
||||
size: file.size,
|
||||
sha256: file.sha256
|
||||
}))
|
||||
})
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
return candidates.filter((candidate) => {
|
||||
const key = JSON.stringify(candidate.files)
|
||||
if (seen.has(key)) {
|
||||
return false
|
||||
}
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function modelPackageFingerprintMatches<
|
||||
Role extends string
|
||||
>(
|
||||
expected: ModelPackageFingerprint<Role>,
|
||||
actual: readonly ModelPackageFingerprintFile[]
|
||||
): boolean {
|
||||
return (
|
||||
actual.length === expected.files.length &&
|
||||
expected.files.every((file) =>
|
||||
actual.some(
|
||||
(candidate) =>
|
||||
candidate.name === file.name &&
|
||||
candidate.role === file.role &&
|
||||
candidate.size === file.size &&
|
||||
candidate.sha256 === file.sha256
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export function getMaximumModelPackageBytes(
|
||||
files: readonly ResolvableModelArtifactFile[]
|
||||
): number {
|
||||
return Math.max(
|
||||
...getModelPackageFingerprints(files).map(
|
||||
(fingerprint) => fingerprint.totalBytes
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export const modelArtifactIdentitySchema = z
|
||||
.object({
|
||||
size: z.number().int().positive().safe(),
|
||||
sha256: z.string().regex(sha256Pattern),
|
||||
size: modelArtifactFingerprintSchema.shape.size,
|
||||
sha256: modelArtifactFingerprintSchema.shape.sha256,
|
||||
targets: modelArtifactTargetsSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { modelArtifactTargetsSchema } from './model-download-contracts'
|
||||
import {
|
||||
speechModelCatalogEntrySchema,
|
||||
speechModelLocalDirectoryInputSchema,
|
||||
@@ -173,6 +174,50 @@ describe('speech model contracts', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('allows only the declared ModelScope CDN redirect host', () => {
|
||||
const modelscopeRepositoryUrl =
|
||||
'https://modelscope.cn/models/example/test-speech-model'
|
||||
const target = {
|
||||
url:
|
||||
`${modelscopeRepositoryUrl}/resolve/${revision}/` +
|
||||
'model.onnx',
|
||||
repositoryUrl: modelscopeRepositoryUrl,
|
||||
revision,
|
||||
redirectHosts: ['cdn-lfs-cn-1.modelscope.cn']
|
||||
}
|
||||
|
||||
expect(
|
||||
modelArtifactTargetsSchema.safeParse({
|
||||
modelscope: target
|
||||
}).success
|
||||
).toBe(true)
|
||||
expect(
|
||||
modelArtifactTargetsSchema.safeParse({
|
||||
modelscope: {
|
||||
...target,
|
||||
size: 13,
|
||||
sha256: 'b'.repeat(64)
|
||||
}
|
||||
}).success
|
||||
).toBe(true)
|
||||
expect(
|
||||
modelArtifactTargetsSchema.safeParse({
|
||||
modelscope: {
|
||||
...target,
|
||||
size: 13
|
||||
}
|
||||
}).success
|
||||
).toBe(false)
|
||||
expect(
|
||||
modelArtifactTargetsSchema.safeParse({
|
||||
modelscope: {
|
||||
...target,
|
||||
redirectHosts: ['untrusted.example']
|
||||
}
|
||||
}).success
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps repository links consistent with source targets', () => {
|
||||
expect(
|
||||
speechModelCatalogEntrySchema.safeParse({
|
||||
|
||||
Reference in New Issue
Block a user