language-models 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/parser.ts ADDED
@@ -0,0 +1,485 @@
1
+ import camelCase from 'camelcase'
2
+ import { aliases } from './aliases'
3
+ import { Model, Provider } from './types'
4
+ import rawModels from './models'
5
+
6
+ type InternalModel = Model
7
+
8
+ const allModels = rawModels as unknown as {
9
+ models: Model[]
10
+ }
11
+
12
+ type ParsedModelIdentifier = {
13
+ provider?: string
14
+ author?: string
15
+ model?: string
16
+ systemConfig?: Record<string, string | number>
17
+ alias?: string
18
+ priorities?: string[]
19
+ providerConstraints?: {
20
+ field: string
21
+ value: string
22
+ type: 'gt' | 'lt' | 'eq'
23
+ }[]
24
+ tools?: Record<string, string | number | boolean | Record<string, unknown>>
25
+ capabilities?: Record<string, string | number | boolean>
26
+ outputFormat?: string
27
+ outputSchema?: string
28
+ // Anything the parser doesnt know what to do with
29
+ // Mostly used to pass tools, schemas, and other parameters to a higher level
30
+ unasignedParameters?: Record<string, string | number | boolean>
31
+ }
32
+
33
+ // Priorities may be on its own, which indicates to just find the best value for that priority
34
+ // or it may be in one of the following formats:
35
+ // cost:<1 -> A model that has a cost of less than $1 per million tokens
36
+ // latency:<50 -> A model with a latency of less than 50ms
37
+ // throughput:>250 -> A model with a throughput higher than 250 tokens per second
38
+ // Additional notes: if just cost is specified, then it will be treated as outputCost
39
+ const priorities = ['cost', 'latency', 'throughput', 'inputCost', 'outputCost']
40
+ const capabilities = ['reasoning', 'thinking', 'tools', 'structuredOutput', 'responseFormat', 'pdf']
41
+ const defaultTools = ['exec', 'online']
42
+ const systemConfiguration = ['seed', 'thread', 'temperature', 'topP', 'topK']
43
+ // Any of the following is an output format, anything else that starts with a capital letter is an output *schema*
44
+ const outputFormats = ['Object', 'ObjectArray', 'Text', 'TextArray', 'Markdown', 'Code']
45
+
46
+ export function parse(modelIdentifier: string): ParsedModelIdentifier {
47
+ const output: ParsedModelIdentifier = {
48
+ model: '',
49
+ }
50
+
51
+ // Locate all paratheses, even nested ones
52
+ const parentheses = modelIdentifier.match(/\(([^)]+)\)/g)
53
+
54
+ output.model = modelIdentifier.split('(')[0]
55
+ const modelName = output.model.includes('/') ? output.model.split('/')[1] : output.model
56
+
57
+ if (aliases[modelName]) {
58
+ output.model = aliases[modelName]
59
+ }
60
+
61
+ if (output.model.includes('/')) {
62
+ const [author, model] = output.model.split('/')
63
+ output.author = author
64
+ output.model = model
65
+ }
66
+
67
+ if (output.model.includes('@')) {
68
+ // @ indicates a specific provider
69
+ const [model, provider] = output.model.split('@')
70
+ output.model = model
71
+ output.provider = provider
72
+ }
73
+
74
+ // If there are no parentheses, then we can just return the model identifier
75
+ if (!parentheses) {
76
+ return output
77
+ }
78
+
79
+ // Defaults storage allows us to set defaults for settings if they are missing from the input
80
+ // The main use case is for output formats, where we want to set the format based on the schema
81
+ // but only if the format isnt already set. Since we only have access to a single expression at once,
82
+ // we need to store the defaults and apply them later.
83
+ const defaultStorage: Record<string, string | number | boolean> = {}
84
+
85
+ // Split by comma, each part is a new parameter that needs to be stored in the right
86
+ // place in the output object
87
+ // For each parathesis, we need to parse the contents
88
+ for (const parenthesis of parentheses) {
89
+ const contents = parenthesis.slice(1, -1)
90
+
91
+ const parts = contents.split(',')
92
+
93
+ for (const part of parts) {
94
+ // Not all parts will have a colon, if not, treat it as a boolean with a value of true.
95
+ const [key, value] = part
96
+ // Cheat we can do to make the parsing easier
97
+ .replace('<', ':<')
98
+ .replace('>', ':>')
99
+ .split(':')
100
+
101
+ if (!key) {
102
+ continue
103
+ }
104
+
105
+ let notAKnownParameter = false
106
+
107
+ switch (true) {
108
+ case defaultTools.includes(key):
109
+ output.tools = output.tools || {}
110
+ output.tools[key] = value || true
111
+ break
112
+ case systemConfiguration.includes(key):
113
+ output.systemConfig = {
114
+ ...output.systemConfig,
115
+ [key]: value,
116
+ }
117
+ break
118
+ case priorities.includes(key):
119
+ if (value) {
120
+ output.providerConstraints = output.providerConstraints || []
121
+ output.providerConstraints.push({
122
+ field: key,
123
+ value: value.replace('>', '').replace('<', ''),
124
+ type: value.startsWith('>') ? 'gt' : value.startsWith('<') ? 'lt' : 'eq',
125
+ })
126
+ } else {
127
+ output.priorities = [...(output.priorities || []), key]
128
+ }
129
+ break
130
+ case capabilities.includes(key):
131
+ output.capabilities = {
132
+ ...output.capabilities,
133
+ [key]: value || true,
134
+ }
135
+ break
136
+ case outputFormats.includes(key):
137
+ output.outputFormat = key == 'Code' && !!value ? `Code:${value}` : key
138
+ break
139
+ default:
140
+ notAKnownParameter = true
141
+ break
142
+ }
143
+
144
+ if (!notAKnownParameter) {
145
+ // No need to process any further
146
+ continue
147
+ }
148
+
149
+ // If it starts with a capital letter, then it is a Schema
150
+ if (key[0] === key[0].toUpperCase()) {
151
+ const schema = key
152
+
153
+ if (schema.includes('[]')) {
154
+ defaultStorage.outputFormat = 'ObjectArray'
155
+ } else {
156
+ defaultStorage.outputFormat = 'Object'
157
+ }
158
+
159
+ output.outputSchema = schema.replace('[]', '')
160
+ } else if (value?.includes('>') || value?.includes('<')) {
161
+ // This is most likely a benchmark constraint
162
+ output.providerConstraints = output.providerConstraints || []
163
+ output.providerConstraints.push({
164
+ field: key,
165
+ value: value.replace('>', '').replace('<', ''),
166
+ type: value.startsWith('>') ? 'gt' : 'lt',
167
+ })
168
+ } else {
169
+ output.tools = output.tools || {}
170
+ output.tools = {
171
+ ...output.tools,
172
+ [key]: value || true,
173
+ }
174
+ }
175
+ }
176
+ }
177
+
178
+ // Custom rules / requirements
179
+ // If someone has tools, they need to have the tools capability
180
+ if (Object.values(output.tools || {}).length > 0) {
181
+ if (!output.capabilities?.tools) {
182
+ output.capabilities = {
183
+ ...output.capabilities,
184
+ tools: true,
185
+ }
186
+ }
187
+ }
188
+
189
+ // Finally, apply the defaults
190
+ Object.entries(defaultStorage).forEach(([key, value]) => {
191
+ const keyToCheck = key as keyof ParsedModelIdentifier
192
+ if (output[keyToCheck] === undefined) {
193
+ // @ts-expect-error - We know these assignments are safe based on our defaultStorage logic
194
+ output[keyToCheck] = value
195
+ }
196
+ })
197
+
198
+ return output
199
+ }
200
+
201
+ export function constructModelIdentifier(parsed: ParsedModelIdentifier): string {
202
+ const modelAlias = aliases[parsed.model || ''] || parsed.model || ''
203
+ let identifier = `${modelAlias}`
204
+
205
+ if (parsed.provider) {
206
+ identifier += `@${parsed.provider}`
207
+ }
208
+
209
+ const args: string[] = []
210
+
211
+ const formatArgument = (key: string, value: string | number | boolean) => {
212
+ // If the value is a boolean, then we can just return the key
213
+ if (typeof value === 'boolean') {
214
+ return value ? key : ''
215
+ }
216
+
217
+ console.log(key, value)
218
+
219
+ // Otherwise, we need to format the value
220
+ return `${key}:${value}`
221
+ }
222
+
223
+ const keysToFormat = ['capabilities', 'tools', 'systemConfig'] as const
224
+
225
+ keysToFormat.forEach((key) => {
226
+ if (parsed[key]) {
227
+ Object.entries(parsed[key]).forEach(([key, value]) => {
228
+ args.push(formatArgument(key, value as string | number | boolean))
229
+ })
230
+ }
231
+ })
232
+
233
+ if (parsed.priorities) {
234
+ parsed.priorities.forEach((priority) => {
235
+ args.push(priority)
236
+ })
237
+ }
238
+
239
+ // Provider constraints are a bit more complex as they are stored as { field: string, value: string }[]
240
+ if (parsed.providerConstraints) {
241
+ parsed.providerConstraints.forEach((constraint) => {
242
+ args.push(`${constraint.field}${constraint.type === 'gt' ? '>' : constraint.type === 'lt' ? '<' : ':'}${constraint.value}`)
243
+ })
244
+ }
245
+
246
+ if (args.length) {
247
+ identifier += `(${args.join(',')})`
248
+ }
249
+
250
+ return identifier
251
+ }
252
+
253
+ export function modelToIdentifier(model: Model): string {
254
+ return constructModelIdentifier({
255
+ model: model.slug.split('/')[1],
256
+ provider: model.slug.split('/')[0],
257
+ })
258
+ }
259
+
260
+ // Its a model, but with a provider attached
261
+ type ResolvedModel = Model & {
262
+ provider: Provider
263
+ }
264
+
265
+ // A function that takes a model and a provider and returns a boolean
266
+ type FilterChainCallback = (model: InternalModel, provider: Provider) => boolean
267
+
268
+ export function filterModels(
269
+ modelIdentifier: string,
270
+ modelsToFilter?: InternalModel[],
271
+ ): {
272
+ models: ResolvedModel[]
273
+ parsed: ParsedModelIdentifier
274
+ } {
275
+ const parsed = parse(modelIdentifier)
276
+
277
+ const modelAndProviders = []
278
+
279
+ let modelsToFilterMixin = modelsToFilter
280
+
281
+ if (metaModels.find((m) => m.name === parsed.model) && !modelsToFilter?.length) {
282
+ const metaModelChildren = metaModels.find((m) => m.name === parsed.model)?.models
283
+ modelsToFilterMixin = allModels.models.filter((m) => metaModelChildren?.includes(m.slug.split('/')[1])) as InternalModel[]
284
+
285
+ // Because the parser has no model knowledge, it thinks the meta model name is the model name
286
+ // Which will always return false.
287
+ delete parsed.model
288
+ } else if (modelsToFilter?.length) {
289
+ modelsToFilterMixin = modelsToFilter as InternalModel[]
290
+ } else {
291
+ modelsToFilterMixin = allModels.models as InternalModel[]
292
+ }
293
+
294
+ const filterChain: FilterChainCallback[] = []
295
+
296
+ if (parsed.model) {
297
+ filterChain.push(function modelFilter(model) {
298
+ // Wildcard search for any model that matches everything else.
299
+ if (parsed.model == '*') {
300
+ return true
301
+ }
302
+
303
+ // Return true if we're looking for claude-3.7-sonnet
304
+ // Fixes the issue where we need :thinking to be supported
305
+ if (parsed.model === 'claude-3.7-sonnet' && model.slug.includes('claude-3.7-sonnet') && !model.slug.includes('beta')) {
306
+ return true
307
+ }
308
+
309
+ return model.slug.split('/')[1] === parsed.model
310
+ })
311
+ }
312
+
313
+ // We're using named functions here so we can console.log the filter chain
314
+ // and see what filter is being applied and when
315
+ if (parsed.provider) {
316
+ filterChain.push(function providerFilter(model, provider) {
317
+ return provider?.slug === parsed.provider
318
+ })
319
+ }
320
+
321
+ if (parsed?.providerConstraints?.length) {
322
+ // Since the provider isnt defined, we need to filter based on the provider constraints
323
+ filterChain.push(function providerConstraintFilter(model, provider) {
324
+ return (
325
+ parsed?.providerConstraints?.every((constraint) => {
326
+ if (!provider) {
327
+ return false
328
+ }
329
+
330
+ let fieldToCheck = constraint.field as keyof typeof provider
331
+
332
+ if (constraint.field === 'cost') {
333
+ fieldToCheck = 'outputCost'
334
+ }
335
+
336
+ switch (constraint.type) {
337
+ case 'gt':
338
+ return Number(provider[fieldToCheck]) > parseFloat(constraint.value)
339
+ case 'lt':
340
+ return Number(provider[fieldToCheck]) < parseFloat(constraint.value)
341
+ case 'eq':
342
+ return Number(provider[fieldToCheck]) === parseFloat(constraint.value)
343
+ default:
344
+ return false
345
+ }
346
+ }) || false
347
+ )
348
+ })
349
+ }
350
+
351
+ if (parsed.capabilities) {
352
+ filterChain.push(function capabilitiesFilter(model, provider) {
353
+ return Object.entries(parsed?.capabilities || {}).every(([key, value]) => {
354
+ return provider?.supportedParameters?.includes(camelCase(key))
355
+ })
356
+ })
357
+ }
358
+
359
+ for (const model of modelsToFilterMixin as InternalModel[]) {
360
+ for (const provider of model.providers || []) {
361
+ if (filterChain.every((f) => f(model, provider))) {
362
+ modelAndProviders.push({
363
+ model,
364
+ provider: provider,
365
+ })
366
+ }
367
+ }
368
+ }
369
+
370
+ const orderBy = (fields: string[]) => (a: any, b: any) =>
371
+ fields
372
+ .map((o) => {
373
+ let dir = 1
374
+ if (o[0] === '-') {
375
+ dir = -1
376
+ o = o.substring(1)
377
+ }
378
+
379
+ // Support for dot notation to access nested properties
380
+ const getNestedValue = (obj: any, path: string): any => {
381
+ return path.split('.').reduce((prev, curr) => (prev && prev[curr] !== undefined ? prev[curr] : undefined), obj)
382
+ }
383
+
384
+ const aVal = getNestedValue(a, o)
385
+ const bVal = getNestedValue(b, o)
386
+
387
+ return aVal > bVal ? dir : aVal < bVal ? -dir : 0
388
+ })
389
+ .reduce((p: number, n: number) => (p ? p : n), 0)
390
+
391
+ let sortingStrategy = orderBy(parsed?.priorities?.map((f) => `provider.${f}`) || [])
392
+
393
+ // Re-join back on model, replacing the providers with the filtered providers
394
+ return {
395
+ models: modelAndProviders
396
+ .map((x) => ({
397
+ ...x.model,
398
+ provider: x.provider,
399
+ }))
400
+ .map((x) => {
401
+ delete x.providers
402
+ delete x.endpoint
403
+ return x
404
+ })
405
+ .sort(sortingStrategy) as ResolvedModel[],
406
+ parsed,
407
+ }
408
+ }
409
+
410
+ /**
411
+ * Helper function to get the first model that matches a model identifier
412
+ * @param modelIdentifier
413
+ * @returns ResolvedModel
414
+ */
415
+ export function getModel(modelIdentifier: string, augments: Record<string, string | number | boolean | string[]> = {}) {
416
+ // Inject the augments into the model string inside the parentheses
417
+ // Keeping the content of the parentheses intact
418
+ const parentheses = modelIdentifier.match(/\(([^)]+)\)/)
419
+
420
+ const augmentsString: string[] = []
421
+
422
+ Object.entries(augments).forEach(([key, value]) => {
423
+ if (key == 'seed') augmentsString.push(`seed:${value}`)
424
+ else if (key == 'temperature') augmentsString.push(`temperature:${value}`)
425
+ else if (key == 'topP') augmentsString.push(`topP:${value}`)
426
+ else if (key == 'topK') augmentsString.push(`topK:${value}`)
427
+ else if (key == 'thread') augmentsString.push(`thread:${value}`)
428
+ else if (key == 'requiredCapabilities') {
429
+ for (const capability of value as string[]) {
430
+ augmentsString.push(capability)
431
+ }
432
+ } else augmentsString.push(`${key}:${value}`)
433
+ })
434
+
435
+ if (parentheses) {
436
+ modelIdentifier = modelIdentifier.replace(parentheses[0], `(${parentheses[1]},${augmentsString.filter(Boolean).join(',')})`)
437
+ } else {
438
+ if (augmentsString.length) {
439
+ modelIdentifier += `(${augmentsString.join(',')})`
440
+ }
441
+ }
442
+
443
+ const parsed = parse(modelIdentifier)
444
+
445
+ const { models } = filterModels(modelIdentifier)
446
+ return {
447
+ ...models[0],
448
+ parsed: {
449
+ ...parsed,
450
+ ...augments,
451
+ },
452
+ }
453
+ }
454
+
455
+ export function getModels(modelIdentifier: string) {
456
+ // Split the modelIdentifier by comma, ignoring commas inside parentheses
457
+ let result = []
458
+ let segment = ''
459
+ let depth = 0
460
+
461
+ for (const char of modelIdentifier) {
462
+ if (char === '(') depth++
463
+ else if (char === ')') depth--
464
+ else if (char === ',' && depth === 0) {
465
+ result.push(segment.trim())
466
+ segment = ''
467
+ continue
468
+ }
469
+ segment += char
470
+ }
471
+
472
+ if (segment.trim()) result.push(segment.trim())
473
+
474
+ // Resolve each segment
475
+ return result.map((r) => getModel(r)) as (Model & { parsed: ParsedModelIdentifier })[]
476
+ }
477
+
478
+ // TODO: Move this to a database or another source of truth
479
+ // This isnt a good place for this data.
480
+ const metaModels = [
481
+ {
482
+ name: 'frontier',
483
+ models: ['gemini-2.0-flash-001', 'deepseek-r1'],
484
+ },
485
+ ]
@@ -0,0 +1,79 @@
1
+ import camelCase from 'camelcase'
2
+
3
+ const allProviders = [
4
+ 'OpenAI',
5
+ 'Anthropic',
6
+ 'Google',
7
+ 'Google AI Studio',
8
+ 'Amazon Bedrock',
9
+ 'Groq',
10
+ 'SambaNova',
11
+ 'Cohere',
12
+ 'Mistral',
13
+ 'Together',
14
+ 'Together 2',
15
+ 'Fireworks',
16
+ 'DeepInfra',
17
+ 'Lepton',
18
+ 'Novita',
19
+ 'Avian',
20
+ 'Lambda',
21
+ 'Azure',
22
+ 'Modal',
23
+ 'AnyScale',
24
+ 'Replicate',
25
+ 'Perplexity',
26
+ 'Recursal',
27
+ 'OctoAI',
28
+ 'DeepSeek',
29
+ 'Infermatic',
30
+ 'AI21',
31
+ 'Featherless',
32
+ 'Inflection',
33
+ 'xAI',
34
+ 'Cloudflare',
35
+ 'SF Compute',
36
+ 'Minimax',
37
+ 'Nineteen',
38
+ 'Liquid',
39
+ 'Stealth',
40
+ 'NCompass',
41
+ 'InferenceNet',
42
+ 'Friendli',
43
+ 'AionLabs',
44
+ 'Alibaba',
45
+ 'Nebius',
46
+ 'Chutes',
47
+ 'Kluster',
48
+ 'Crusoe',
49
+ 'Targon',
50
+ 'Ubicloud',
51
+ 'Parasail',
52
+ 'Phala',
53
+ 'Cent-ML',
54
+ 'Venice',
55
+ 'OpenInference',
56
+ 'Atoma',
57
+ '01.AI',
58
+ 'HuggingFace',
59
+ 'Mancer',
60
+ 'Mancer 2',
61
+ 'Hyperbolic',
62
+ 'Hyperbolic 2',
63
+ 'Lynn 2',
64
+ 'Lynn',
65
+ 'Reflection',
66
+ ]
67
+
68
+ export const getProviderName = (provider: string) => {
69
+ // Reverse a camelCase string into the provider's name
70
+
71
+ switch (provider) {
72
+ case 'vertex':
73
+ return 'Google'
74
+ case 'google':
75
+ return 'Google AI Studio'
76
+ default:
77
+ return allProviders.find((p) => camelCase(p) === provider)
78
+ }
79
+ }
package/src/types.ts ADDED
@@ -0,0 +1,135 @@
1
+ export type Endpoint = {
2
+ id: string
3
+ name: string
4
+ contextLength: number
5
+ model: Model
6
+ modelVariantSlug: string
7
+ modelVariantPermaslug: string
8
+ providerName: string
9
+ providerInfo: ProviderInfo
10
+ providerDisplayName: string
11
+ providerModelId: string
12
+ providerGroup: string
13
+ isCloaked: boolean
14
+ quantization: null
15
+ variant: string
16
+ isSelfHosted: boolean
17
+ canAbort: boolean
18
+ maxPromptTokens: null
19
+ maxCompletionTokens: number
20
+ maxPromptImages: null
21
+ maxTokensPerImage: null
22
+ supportedParameters: string[]
23
+ isByok: boolean
24
+ moderationRequired: boolean
25
+ dataPolicy: DataPolicy
26
+ pricing: Pricing
27
+ isHidden: boolean
28
+ isDeranked: boolean
29
+ isDisabled: boolean
30
+ supportsToolParameters: boolean
31
+ supportsReasoning: boolean
32
+ supportsMultipart: boolean
33
+ limitRpm: number
34
+ limitRpd: null
35
+ hasCompletions: boolean
36
+ hasChatCompletions: boolean
37
+ features: Features
38
+ providerRegion: null
39
+ }
40
+
41
+ export type Model = {
42
+ slug: string
43
+ hfSlug?: string | null
44
+ updatedAt: string
45
+ createdAt: string
46
+ hfUpdatedAt: null
47
+ name: string
48
+ shortName: string
49
+ author: string
50
+ description: string
51
+ modelVersionGroupId: string
52
+ contextLength: number
53
+ inputModalities: string[]
54
+ outputModalities: string[]
55
+ hasTextOutput: boolean
56
+ group: string
57
+ instructType: null
58
+ defaultSystem: null
59
+ defaultStops: any[]
60
+ hidden: boolean
61
+ router: null
62
+ warningMessage: null
63
+ permaslug: string
64
+ reasoningConfig: null
65
+ endpoint?: Endpoint | Provider
66
+ sorting?: Sorting
67
+ providers?: Provider[]
68
+ provider?: Provider
69
+ }
70
+
71
+ export type DataPolicy = {
72
+ termsOfServiceUrl: string
73
+ privacyPolicyUrl: string
74
+ training: boolean
75
+ }
76
+
77
+ export type Features = {}
78
+
79
+ export type Pricing = {
80
+ prompt: string
81
+ completion: string
82
+ image: string
83
+ request: string
84
+ inputCacheRead: string
85
+ inputCacheWrite: string
86
+ webSearch: string
87
+ internalReasoning: string
88
+ }
89
+
90
+ export type ProviderInfo = {
91
+ name: string
92
+ displayName: string
93
+ baseUrl: string
94
+ dataPolicy: DataPolicy
95
+ headquarters: string
96
+ hasChatCompletions: boolean
97
+ hasCompletions: boolean
98
+ isAbortable: boolean
99
+ moderationRequired: boolean
100
+ group: string
101
+ editors: any[]
102
+ owners: any[]
103
+ isMultipartSupported: boolean
104
+ statusPageUrl: null
105
+ byokEnabled: boolean
106
+ isPrimaryProvider: boolean
107
+ icon: Icon
108
+ }
109
+
110
+ export type Icon = {
111
+ url: string
112
+ }
113
+
114
+ export type Provider = {
115
+ name: string
116
+ slug: string
117
+ quantization: string | null
118
+ context: number
119
+ maxCompletionTokens: number
120
+ pricing: Pricing
121
+ supportedParameters: string[]
122
+ inputCost: number
123
+ outputCost: number
124
+ throughput: number
125
+ latency: number
126
+ }
127
+
128
+ export type Sorting = {
129
+ topWeekly: number
130
+ newest: number
131
+ throughputHighToLow: number
132
+ latencyLowToHigh: number
133
+ pricingLowToHigh: number
134
+ pricingHighToLow: number
135
+ }
@@ -0,0 +1,11 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import { parse } from '../src/parser'
3
+
4
+ describe('parser', () => {
5
+ it('should parse a model', () => {
6
+ const model = parse('gemini')
7
+ expect(model.author).toBe('google')
8
+ expect(model.model).toBe('gemini-2.0-flash-001')
9
+ expect(model.capabilities || []).toEqual([])
10
+ })
11
+ })