@goodandready/dsh-model-sync 0.3.12 → 0.3.13

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/lib/models.js CHANGED
@@ -1,282 +1,300 @@
1
- const FIELD_ALIASES = Object.freeze({
2
- contextWindow: [
3
- 'contextWindow', 'context_window', 'contextLength', 'context_length',
4
- 'maxContextTokens', 'max_context_tokens', 'maxContextLength', 'max_context_length',
5
- 'inputTokenLimit', 'input_token_limit',
6
- ],
7
- maxTokens: [
8
- 'maxTokens', 'max_tokens', 'max_output_tokens', 'maxOutputTokens',
9
- 'outputTokenLimit', 'output_token_limit',
10
- ],
11
- })
12
-
13
- const CAPABILITY_ALIASES = Object.freeze({
14
- vision: ['vision', 'supportsVision', 'supports_vision', 'imageInput', 'image_input'],
15
- tools: ['tools', 'toolUse', 'tool_use', 'functionCalling', 'function_calling', 'supportsTools', 'supports_tools'],
16
- reasoning: ['reasoning', 'supportsReasoning', 'supports_reasoning', 'thinking', 'extendedThinking', 'extended_thinking'],
17
- embeddings: ['embeddings', 'embedding', 'supportsEmbeddings', 'supports_embeddings'],
18
- })
19
-
20
- const CAPABILITY_TOKENS = Object.freeze({
21
- vision: new Set(['vision', 'image', 'imageinput', 'multimodal']),
22
- tools: new Set(['tools', 'tooluse', 'toolchoice', 'functioncalling', 'functioncall']),
23
- reasoning: new Set(['reasoning', 'reasoningeffort', 'thinking', 'extendedthinking']),
24
- embeddings: new Set(['embedding', 'embeddings']),
25
- })
26
-
27
- const ID_ALIASES = Object.freeze(['aliases', 'alias', 'previousIds', 'previous_ids', 'previousId', 'previous_id'])
28
-
29
- const PRICING_ALIASES = Object.freeze({
30
- inputPerToken: ['inputPerToken', 'input_per_token', 'inputPricePerToken', 'input_price_per_token', 'inputTokenPrice', 'input_token_price'],
31
- outputPerToken: ['outputPerToken', 'output_per_token', 'outputPricePerToken', 'output_price_per_token', 'outputTokenPrice', 'output_token_price'],
32
- cacheReadPerToken: ['cacheReadPerToken', 'cache_read_per_token', 'cacheReadPricePerToken', 'cache_read_price_per_token', 'cachedInputPerToken', 'cached_input_per_token'],
33
- cacheWritePerToken: ['cacheWritePerToken', 'cache_write_per_token', 'cacheWritePricePerToken', 'cache_write_price_per_token'],
34
- })
35
-
36
- function numericValue(value, { allowZero = false } = {}) {
37
- if (typeof value !== 'number' && typeof value !== 'string') return undefined
38
- if (typeof value === 'string' && value.trim() === '') return undefined
39
- const clean = typeof value === 'string' ? value.trim().replace(/,/g, '') : value
40
- let number = Number(clean)
41
- if (typeof clean === 'string' && !Number.isFinite(number)) {
42
- const match = clean.match(/^(\d+(?:\.\d+)?)\s*([kmgt])(i?b)?$/i)
43
- if (match) {
44
- const base = Number(match[1])
45
- const unit = match[2].toLowerCase()
46
- const isBinary = Boolean(match[3] && match[3].toLowerCase().startsWith('i'))
47
- const mult = unit === 'k' ? (isBinary ? 1024 : 1000)
48
- : unit === 'm' ? (isBinary ? 1048576 : 1000000)
49
- : unit === 'g' ? (isBinary ? 1073741824 : 1000000000)
50
- : 1
51
- number = Math.round(base * mult)
52
- }
53
- }
54
- if (!Number.isFinite(number) || (allowZero ? number < 0 : number <= 0)) return undefined
55
- return number
56
- }
57
-
58
- function firstNumber(row, fields, options) {
59
- for (const field of fields) {
60
- const value = numericValue(row?.[field], options)
61
- if (value !== undefined) return value
62
- }
63
- return undefined
64
- }
65
-
66
- function booleanValue(value) {
67
- if (value === true || value === false) return value
68
- if (typeof value !== 'string') return undefined
69
- if (value.trim().toLowerCase() === 'true') return true
70
- if (value.trim().toLowerCase() === 'false') return false
71
- return undefined
72
- }
73
-
74
- function capabilityToken(value) {
75
- if (typeof value !== 'string') return undefined
76
- const token = value.trim().toLowerCase().replaceAll(/[_-]/g, '')
77
- return Object.entries(CAPABILITY_TOKENS).find(([, tokens]) => tokens.has(token))?.[0]
78
- }
79
-
80
- function normalizeCapabilities(row) {
81
- const result = {}
82
- const assign = (key, value) => {
83
- const normalized = booleanValue(value)
84
- if (normalized !== undefined) result[key] = normalized
85
- }
86
-
87
- for (const [key, fields] of Object.entries(CAPABILITY_ALIASES)) {
88
- for (const field of fields) {
89
- if (row?.[field] !== undefined) assign(key, row[field])
90
- }
91
- }
92
-
93
- const sources = [row?.capabilities, row?.supportedCapabilities, row?.supported_capabilities]
94
- for (const source of sources) {
95
- if (Array.isArray(source)) {
96
- for (const token of source) {
97
- const key = capabilityToken(token)
98
- if (key) result[key] = true
99
- }
100
- } else if (source && typeof source === 'object') {
101
- for (const [key, fields] of Object.entries(CAPABILITY_ALIASES)) {
102
- for (const field of fields) {
103
- if (source[field] !== undefined) assign(key, source[field])
104
- }
105
- }
106
- for (const [token, value] of Object.entries(source)) {
107
- const key = capabilityToken(token)
108
- if (key) assign(key, value)
109
- }
110
- }
111
- }
112
-
113
- const modalitySources = [row?.modalities, row?.inputModalities, row?.input_modalities]
114
- for (const source of modalitySources) {
115
- if (!Array.isArray(source)) continue
116
- for (const token of source) {
117
- const key = capabilityToken(token)
118
- if (key) result[key] = true
119
- }
120
- }
121
-
122
- const parameterSources = [row?.supportedParameters, row?.supported_parameters]
123
- for (const source of parameterSources) {
124
- if (!Array.isArray(source)) continue
125
- for (const token of source) {
126
- const key = capabilityToken(token)
127
- if (key) result[key] = true
128
- }
129
- }
130
- return Object.keys(result).length > 0 ? result : undefined
131
- }
132
-
133
- function normalizeTags(row) {
134
- const source = row?.tags ?? row?.labels
135
- if (!Array.isArray(source)) return undefined
136
- const tags = [...new Set(source
137
- .filter((tag) => typeof tag === 'string' && tag.trim())
138
- .map((tag) => tag.trim())
139
- .slice(0, 50))]
140
- return tags.length > 0 ? tags : undefined
141
- }
142
-
143
- function normalizeAliases(row, id) {
144
- const aliases = []
145
- for (const field of ID_ALIASES) {
146
- const source = row?.[field]
147
- const values = Array.isArray(source) ? source : source === undefined || source === null ? [] : [source]
148
- for (const value of values) {
149
- if (typeof value !== 'string') continue
150
- const alias = value.trim()
151
- if (alias && alias !== id && !aliases.includes(alias)) aliases.push(alias)
152
- }
153
- }
154
- return aliases.slice(0, 20)
155
- }
156
-
157
- function normalizeLifecycle(row) {
158
- const source = row?.lifecycle && typeof row.lifecycle === 'object' ? row.lifecycle : {}
159
- const deprecated = booleanValue(source.deprecated ?? row?.deprecated ?? row?.isDeprecated)
160
- const date = source.deprecationDate ?? source.deprecation_date ?? source.expirationDate ?? source.expiration_date ?? source.expiresAt ?? source.expires_at ?? row?.deprecationDate ?? row?.deprecation_date ?? row?.expirationDate ?? row?.expiration_date ?? row?.expiresAt ?? row?.expires_at
161
- const deprecationDate = typeof date === 'string' && date.trim() ? date.trim() : undefined
162
- if (deprecated === undefined && deprecationDate === undefined) return undefined
163
- return {
164
- ...(deprecated === undefined ? {} : { deprecated }),
165
- ...(deprecationDate === undefined ? {} : { deprecationDate }),
166
- }
167
- }
168
-
169
- function normalizePricing(row) {
170
- const source = row?.pricing ?? row?.price ?? row?.cost
171
- if (!source || typeof source !== 'object') return undefined
172
- const inputPerToken = firstNumber(source, PRICING_ALIASES.inputPerToken, { allowZero: true })
173
- const outputPerToken = firstNumber(source, PRICING_ALIASES.outputPerToken, { allowZero: true })
174
- const cacheReadPerToken = firstNumber(source, PRICING_ALIASES.cacheReadPerToken, { allowZero: true })
175
- const cacheWritePerToken = firstNumber(source, PRICING_ALIASES.cacheWritePerToken, { allowZero: true })
176
- if (inputPerToken === undefined && outputPerToken === undefined && cacheReadPerToken === undefined && cacheWritePerToken === undefined) return undefined
177
- const pricing = {
178
- ...(inputPerToken === undefined ? {} : { inputPerToken }),
179
- ...(outputPerToken === undefined ? {} : { outputPerToken }),
180
- ...(cacheReadPerToken === undefined ? {} : { cacheReadPerToken }),
181
- ...(cacheWritePerToken === undefined ? {} : { cacheWritePerToken }),
182
- }
183
- if (typeof source.currency === 'string' && source.currency.trim()) pricing.currency = source.currency.trim()
184
- if (typeof source.unit === 'string' && source.unit.trim()) pricing.unit = source.unit.trim()
185
- return pricing
186
- }
187
-
188
- export function normalizeModel(provider, row) {
189
- if (typeof row === 'string') {
190
- row = { id: row }
191
- }
192
- if (!row || typeof row !== 'object') return null
193
- const id = String(row.id ?? row.slug ?? row.model ?? '').trim()
194
- if (!id) return null
195
- const name = String(row.name ?? row.displayName ?? row.display_name ?? id).trim() || id
196
- const contextWindow = firstNumber(row, FIELD_ALIASES.contextWindow)
197
- const maxTokens = firstNumber(row, FIELD_ALIASES.maxTokens)
198
- const capabilities = normalizeCapabilities(row)
199
- const pricing = normalizePricing(row)
200
- const tags = normalizeTags(row)
201
- const aliases = normalizeAliases(row, id)
202
- const lifecycle = normalizeLifecycle(row)
203
- return {
204
- provider,
205
- id,
206
- name,
207
- ...(contextWindow === undefined ? {} : { contextWindow }),
208
- ...(maxTokens === undefined ? {} : { maxTokens }),
209
- ...(row.description ? { description: String(row.description) } : {}),
210
- ...(capabilities === undefined ? {} : { capabilities }),
211
- ...(pricing === undefined ? {} : { pricing }),
212
- ...(tags === undefined ? {} : { tags }),
213
- ...(aliases.length === 0 ? {} : { aliases }),
214
- ...(lifecycle === undefined ? {} : { lifecycle }),
215
- }
216
- }
217
-
218
- export function normalizeModels(provider, rows) {
219
- const out = []
220
- const seen = new Set()
221
- for (const row of rows ?? []) {
222
- const model = normalizeModel(provider, row)
223
- if (!model || seen.has(model.id)) continue
224
- seen.add(model.id)
225
- out.push(model)
226
- }
227
- return out
228
- }
229
-
230
- export function stableStringify(value) {
231
- if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
232
- if (value && typeof value === 'object') {
233
- return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`
234
- }
235
- return JSON.stringify(value)
236
- }
237
-
238
- export function diffModels(previous, next) {
239
- const sortModels = (rows) => normalizeModels('', rows).sort((left, right) => left.id.localeCompare(right.id))
240
- const beforeRows = sortModels(previous)
241
- const afterRows = sortModels(next)
242
- const before = new Map(beforeRows.map((row) => [row.id, row]))
243
- const after = new Map(afterRows.map((row) => [row.id, row]))
244
- const added = []
245
- const removed = []
246
- const renamed = []
247
- const changed = []
248
- const renamedBefore = new Set()
249
- for (const [id, model] of after) {
250
- if (!before.has(id)) continue
251
- if (stableStringify(before.get(id)) !== stableStringify(model)) {
252
- changed.push({ before: before.get(id), after: model })
253
- }
254
- }
255
- for (const [id, model] of after) {
256
- if (before.has(id)) continue
257
- const match = beforeRows.find((candidate) => {
258
- if (renamedBefore.has(candidate.id)) return false
259
- return model.aliases?.includes(candidate.id) || candidate.aliases?.includes(model.id)
260
- })
261
- if (match) {
262
- renamed.push({ before: match, after: model })
263
- renamedBefore.add(match.id)
264
- } else {
265
- added.push(model)
266
- }
267
- }
268
- for (const [id, model] of before) {
269
- if (!after.has(id) && !renamedBefore.has(id)) removed.push(model)
270
- }
271
- added.sort((left, right) => left.id.localeCompare(right.id))
272
- removed.sort((left, right) => left.id.localeCompare(right.id))
273
- renamed.sort((left, right) => left.before.id.localeCompare(right.before.id))
274
- changed.sort((left, right) => left.before.id.localeCompare(right.before.id))
275
- return {
276
- added,
277
- removed,
278
- renamed,
279
- changed,
280
- hasChanges: added.length > 0 || removed.length > 0 || renamed.length > 0 || changed.length > 0,
281
- }
282
- }
1
+ const FIELD_ALIASES = Object.freeze({
2
+ contextWindow: [
3
+ 'contextWindow', 'context_window', 'contextLength', 'context_length',
4
+ 'maxContextTokens', 'max_context_tokens', 'maxContextLength', 'max_context_length',
5
+ 'inputTokenLimit', 'input_token_limit',
6
+ ],
7
+ maxTokens: [
8
+ 'maxTokens', 'max_tokens', 'max_output_tokens', 'maxOutputTokens',
9
+ 'outputTokenLimit', 'output_token_limit',
10
+ ],
11
+ })
12
+
13
+ const CAPABILITY_ALIASES = Object.freeze({
14
+ vision: ['vision', 'supportsVision', 'supports_vision', 'imageInput', 'image_input'],
15
+ tools: ['tools', 'toolUse', 'tool_use', 'functionCalling', 'function_calling', 'supportsTools', 'supports_tools'],
16
+ reasoning: ['reasoning', 'supportsReasoning', 'supports_reasoning', 'thinking', 'extendedThinking', 'extended_thinking'],
17
+ embeddings: ['embeddings', 'embedding', 'supportsEmbeddings', 'supports_embeddings'],
18
+ code: ['code', 'supportsCode', 'supports_code', 'coding', 'codeSpecialized', 'code_specialized'],
19
+ })
20
+
21
+ const CAPABILITY_TOKENS = Object.freeze({
22
+ vision: new Set(['vision', 'image', 'imageinput', 'multimodal', 'visioninput']),
23
+ tools: new Set(['tools', 'tooluse', 'toolchoice', 'functioncalling', 'functioncall', 'functions']),
24
+ reasoning: new Set(['reasoning', 'reasoningeffort', 'thinking', 'extendedthinking', 'thought', 'cot']),
25
+ embeddings: new Set(['embedding', 'embeddings']),
26
+ code: new Set(['code', 'coding', 'codespecialized', 'codegen']),
27
+ })
28
+
29
+ const ID_ALIASES = Object.freeze(['aliases', 'alias', 'previousIds', 'previous_ids', 'previousId', 'previous_id'])
30
+
31
+ const PRICING_ALIASES = Object.freeze({
32
+ inputPerToken: ['inputPerToken', 'input_per_token', 'inputPricePerToken', 'input_price_per_token', 'inputTokenPrice', 'input_token_price'],
33
+ outputPerToken: ['outputPerToken', 'output_per_token', 'outputPricePerToken', 'output_price_per_token', 'outputTokenPrice', 'output_token_price'],
34
+ cacheReadPerToken: ['cacheReadPerToken', 'cache_read_per_token', 'cacheReadPricePerToken', 'cache_read_price_per_token', 'cachedInputPerToken', 'cached_input_per_token'],
35
+ cacheWritePerToken: ['cacheWritePerToken', 'cache_write_per_token', 'cacheWritePricePerToken', 'cache_write_price_per_token'],
36
+ })
37
+
38
+ function numericValue(value, { allowZero = false } = {}) {
39
+ if (typeof value !== 'number' && typeof value !== 'string') return undefined
40
+ if (typeof value === 'string' && value.trim() === '') return undefined
41
+ const clean = typeof value === 'string' ? value.trim().replace(/,/g, '') : value
42
+ let number = Number(clean)
43
+ if (typeof clean === 'string' && !Number.isFinite(number)) {
44
+ const match = clean.match(/^(\d+(?:\.\d+)?)\s*([kmgt])(i?b)?$/i)
45
+ if (match) {
46
+ const base = Number(match[1])
47
+ const unit = match[2].toLowerCase()
48
+ const isBinary = Boolean(match[3] && match[3].toLowerCase().startsWith('i'))
49
+ const mult = unit === 'k' ? (isBinary ? 1024 : 1000)
50
+ : unit === 'm' ? (isBinary ? 1048576 : 1000000)
51
+ : unit === 'g' ? (isBinary ? 1073741824 : 1000000000)
52
+ : 1
53
+ number = Math.round(base * mult)
54
+ }
55
+ }
56
+ if (!Number.isFinite(number) || (allowZero ? number < 0 : number <= 0)) return undefined
57
+ return number
58
+ }
59
+
60
+ function firstNumber(row, fields, options) {
61
+ for (const field of fields) {
62
+ const value = numericValue(row?.[field], options)
63
+ if (value !== undefined) return value
64
+ }
65
+ return undefined
66
+ }
67
+
68
+ function booleanValue(value) {
69
+ if (value === true || value === false) return value
70
+ if (typeof value !== 'string') return undefined
71
+ if (value.trim().toLowerCase() === 'true') return true
72
+ if (value.trim().toLowerCase() === 'false') return false
73
+ return undefined
74
+ }
75
+
76
+ function capabilityToken(value) {
77
+ if (typeof value !== 'string') return undefined
78
+ const token = value.trim().toLowerCase().replaceAll(/[_-]/g, '')
79
+ return Object.entries(CAPABILITY_TOKENS).find(([, tokens]) => tokens.has(token))?.[0]
80
+ }
81
+
82
+ function normalizeCapabilities(row) {
83
+ const result = {}
84
+ const assign = (key, value) => {
85
+ const normalized = booleanValue(value)
86
+ if (normalized !== undefined) result[key] = normalized
87
+ }
88
+
89
+ for (const [key, fields] of Object.entries(CAPABILITY_ALIASES)) {
90
+ for (const field of fields) {
91
+ if (row?.[field] !== undefined) assign(key, row[field])
92
+ }
93
+ }
94
+
95
+ const sources = [row?.capabilities, row?.supportedCapabilities, row?.supported_capabilities]
96
+ for (const source of sources) {
97
+ if (Array.isArray(source)) {
98
+ for (const token of source) {
99
+ const key = capabilityToken(token)
100
+ if (key) result[key] = true
101
+ }
102
+ } else if (source && typeof source === 'object') {
103
+ for (const [key, fields] of Object.entries(CAPABILITY_ALIASES)) {
104
+ for (const field of fields) {
105
+ if (source[field] !== undefined) assign(key, source[field])
106
+ }
107
+ }
108
+ for (const [token, value] of Object.entries(source)) {
109
+ const key = capabilityToken(token)
110
+ if (key) assign(key, value)
111
+ }
112
+ }
113
+ }
114
+
115
+ const modalitySources = [row?.modalities, row?.inputModalities, row?.input_modalities]
116
+ for (const source of modalitySources) {
117
+ if (!Array.isArray(source)) continue
118
+ for (const token of source) {
119
+ const key = capabilityToken(token)
120
+ if (key) result[key] = true
121
+ }
122
+ }
123
+
124
+ const parameterSources = [row?.supportedParameters, row?.supported_parameters]
125
+ for (const source of parameterSources) {
126
+ if (!Array.isArray(source)) continue
127
+ for (const token of source) {
128
+ const key = capabilityToken(token)
129
+ if (key) result[key] = true
130
+ }
131
+ }
132
+
133
+ // Model ID & name heuristic markers for modern models
134
+ const idStr = String(row?.id ?? row?.slug ?? row?.model ?? '').toLowerCase()
135
+ if (!result.reasoning && (/(^|[_-])(o1|o3|r1|qwq|deepseek-r1|thinking)($|[_-])/i.test(idStr) || idStr.includes('-r1') || idStr.includes('reasoning') || idStr.includes('thinking'))) {
136
+ result.reasoning = true
137
+ }
138
+ if (!result.code && (/(^|[_-])(coder|code|starcoder|codellama)($|[_-])/i.test(idStr) || idStr.includes('coder') || idStr.includes('code-'))) {
139
+ result.code = true
140
+ }
141
+ if (!result.vision && (/(^|[_-])(vision|vl|multimodal|4v|4o|flash)($|[_-])/i.test(idStr) || idStr.includes('-vl') || idStr.includes('-vision'))) {
142
+ // Only set vision if clearly indicated by vision/vl tags
143
+ if (/(^|[_-])(vision|vl)($|[_-])/i.test(idStr) || idStr.includes('-vl') || idStr.includes('-vision')) {
144
+ result.vision = true
145
+ }
146
+ }
147
+
148
+ return Object.keys(result).length > 0 ? result : undefined
149
+ }
150
+
151
+ function normalizeTags(row) {
152
+ const source = row?.tags ?? row?.labels
153
+ if (!Array.isArray(source)) return undefined
154
+ const tags = [...new Set(source
155
+ .filter((tag) => typeof tag === 'string' && tag.trim())
156
+ .map((tag) => tag.trim())
157
+ .slice(0, 50))]
158
+ return tags.length > 0 ? tags : undefined
159
+ }
160
+
161
+ function normalizeAliases(row, id) {
162
+ const aliases = []
163
+ for (const field of ID_ALIASES) {
164
+ const source = row?.[field]
165
+ const values = Array.isArray(source) ? source : source === undefined || source === null ? [] : [source]
166
+ for (const value of values) {
167
+ if (typeof value !== 'string') continue
168
+ const alias = value.trim()
169
+ if (alias && alias !== id && !aliases.includes(alias)) aliases.push(alias)
170
+ }
171
+ }
172
+ return aliases.slice(0, 20)
173
+ }
174
+
175
+ function normalizeLifecycle(row) {
176
+ const source = row?.lifecycle && typeof row.lifecycle === 'object' ? row.lifecycle : {}
177
+ const deprecated = booleanValue(source.deprecated ?? row?.deprecated ?? row?.isDeprecated)
178
+ const date = source.deprecationDate ?? source.deprecation_date ?? source.expirationDate ?? source.expiration_date ?? source.expiresAt ?? source.expires_at ?? row?.deprecationDate ?? row?.deprecation_date ?? row?.expirationDate ?? row?.expiration_date ?? row?.expiresAt ?? row?.expires_at
179
+ const deprecationDate = typeof date === 'string' && date.trim() ? date.trim() : undefined
180
+ if (deprecated === undefined && deprecationDate === undefined) return undefined
181
+ return {
182
+ ...(deprecated === undefined ? {} : { deprecated }),
183
+ ...(deprecationDate === undefined ? {} : { deprecationDate }),
184
+ }
185
+ }
186
+
187
+ function normalizePricing(row) {
188
+ const source = row?.pricing ?? row?.price ?? row?.cost
189
+ if (!source || typeof source !== 'object') return undefined
190
+ const inputPerToken = firstNumber(source, PRICING_ALIASES.inputPerToken, { allowZero: true })
191
+ const outputPerToken = firstNumber(source, PRICING_ALIASES.outputPerToken, { allowZero: true })
192
+ const cacheReadPerToken = firstNumber(source, PRICING_ALIASES.cacheReadPerToken, { allowZero: true })
193
+ const cacheWritePerToken = firstNumber(source, PRICING_ALIASES.cacheWritePerToken, { allowZero: true })
194
+ if (inputPerToken === undefined && outputPerToken === undefined && cacheReadPerToken === undefined && cacheWritePerToken === undefined) return undefined
195
+ const pricing = {
196
+ ...(inputPerToken === undefined ? {} : { inputPerToken }),
197
+ ...(outputPerToken === undefined ? {} : { outputPerToken }),
198
+ ...(cacheReadPerToken === undefined ? {} : { cacheReadPerToken }),
199
+ ...(cacheWritePerToken === undefined ? {} : { cacheWritePerToken }),
200
+ }
201
+ if (typeof source.currency === 'string' && source.currency.trim()) pricing.currency = source.currency.trim()
202
+ if (typeof source.unit === 'string' && source.unit.trim()) pricing.unit = source.unit.trim()
203
+ return pricing
204
+ }
205
+
206
+ export function normalizeModel(provider, row) {
207
+ if (typeof row === 'string') {
208
+ row = { id: row }
209
+ }
210
+ if (!row || typeof row !== 'object') return null
211
+ const id = String(row.id ?? row.slug ?? row.model ?? '').trim()
212
+ if (!id) return null
213
+ const name = String(row.name ?? row.displayName ?? row.display_name ?? id).trim() || id
214
+ const contextWindow = firstNumber(row, FIELD_ALIASES.contextWindow)
215
+ const maxTokens = firstNumber(row, FIELD_ALIASES.maxTokens)
216
+ const capabilities = normalizeCapabilities(row)
217
+ const pricing = normalizePricing(row)
218
+ const tags = normalizeTags(row)
219
+ const aliases = normalizeAliases(row, id)
220
+ const lifecycle = normalizeLifecycle(row)
221
+ return {
222
+ provider,
223
+ id,
224
+ name,
225
+ ...(contextWindow === undefined ? {} : { contextWindow }),
226
+ ...(maxTokens === undefined ? {} : { maxTokens }),
227
+ ...(row.description ? { description: String(row.description) } : {}),
228
+ ...(capabilities === undefined ? {} : { capabilities }),
229
+ ...(pricing === undefined ? {} : { pricing }),
230
+ ...(tags === undefined ? {} : { tags }),
231
+ ...(aliases.length === 0 ? {} : { aliases }),
232
+ ...(lifecycle === undefined ? {} : { lifecycle }),
233
+ }
234
+ }
235
+
236
+ export function normalizeModels(provider, rows) {
237
+ const out = []
238
+ const seen = new Set()
239
+ for (const row of rows ?? []) {
240
+ const model = normalizeModel(provider, row)
241
+ if (!model || seen.has(model.id)) continue
242
+ seen.add(model.id)
243
+ out.push(model)
244
+ }
245
+ return out
246
+ }
247
+
248
+ export function stableStringify(value) {
249
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
250
+ if (value && typeof value === 'object') {
251
+ return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`
252
+ }
253
+ return JSON.stringify(value)
254
+ }
255
+
256
+ export function diffModels(previous, next) {
257
+ const sortModels = (rows) => normalizeModels('', rows).sort((left, right) => left.id.localeCompare(right.id))
258
+ const beforeRows = sortModels(previous)
259
+ const afterRows = sortModels(next)
260
+ const before = new Map(beforeRows.map((row) => [row.id, row]))
261
+ const after = new Map(afterRows.map((row) => [row.id, row]))
262
+ const added = []
263
+ const removed = []
264
+ const renamed = []
265
+ const changed = []
266
+ const renamedBefore = new Set()
267
+ for (const [id, model] of after) {
268
+ if (!before.has(id)) continue
269
+ if (stableStringify(before.get(id)) !== stableStringify(model)) {
270
+ changed.push({ before: before.get(id), after: model })
271
+ }
272
+ }
273
+ for (const [id, model] of after) {
274
+ if (before.has(id)) continue
275
+ const match = beforeRows.find((candidate) => {
276
+ if (renamedBefore.has(candidate.id)) return false
277
+ return model.aliases?.includes(candidate.id) || candidate.aliases?.includes(model.id)
278
+ })
279
+ if (match) {
280
+ renamed.push({ before: match, after: model })
281
+ renamedBefore.add(match.id)
282
+ } else {
283
+ added.push(model)
284
+ }
285
+ }
286
+ for (const [id, model] of before) {
287
+ if (!after.has(id) && !renamedBefore.has(id)) removed.push(model)
288
+ }
289
+ added.sort((left, right) => left.id.localeCompare(right.id))
290
+ removed.sort((left, right) => left.id.localeCompare(right.id))
291
+ renamed.sort((left, right) => left.before.id.localeCompare(right.before.id))
292
+ changed.sort((left, right) => left.before.id.localeCompare(right.before.id))
293
+ return {
294
+ added,
295
+ removed,
296
+ renamed,
297
+ changed,
298
+ hasChanges: added.length > 0 || removed.length > 0 || renamed.length > 0 || changed.length > 0,
299
+ }
300
+ }
package/lib/policy.js CHANGED
@@ -1,4 +1,4 @@
1
- const CAPABILITY_KEYS = new Set(['vision', 'tools', 'reasoning', 'embeddings'])
1
+ const CAPABILITY_KEYS = new Set(['vision', 'tools', 'reasoning', 'embeddings', 'code'])
2
2
  const MAX_PATTERNS = 100
3
3
  const MAX_PATTERN_LENGTH = 256
4
4
 
@@ -105,6 +105,7 @@ export async function retryWithBackoff(operation, {
105
105
  attempts = 3,
106
106
  baseDelayMs = 250,
107
107
  maxDelayMs = 4000,
108
+ jitter = true,
108
109
  signal,
109
110
  onRetry,
110
111
  } = {}) {
@@ -117,11 +118,14 @@ export async function retryWithBackoff(operation, {
117
118
  } catch (error) {
118
119
  lastError = error
119
120
  if (attempt >= total || !isRetryableError(error)) throw error
120
- const defaultDelayMs = Math.min(Math.max(0, Number(maxDelayMs) || 0), Math.max(0, Number(baseDelayMs) || 0) * (2 ** (attempt - 1)))
121
+ const exponential = Math.max(0, Number(baseDelayMs) || 0) * (2 ** (attempt - 1))
122
+ // Full jitter: randomized between [0.5, 1.0] of calculated exponential backoff
123
+ const jitterFactor = jitter ? (0.5 + Math.random() * 0.5) : 1
124
+ const calculatedDelayMs = Math.min(Math.max(0, Number(maxDelayMs) || 0), Math.round(exponential * jitterFactor))
121
125
  const retryAfterMs = Number(error?.retryAfterMs)
122
126
  const delayMs = (Number.isFinite(retryAfterMs) && retryAfterMs > 0)
123
127
  ? Math.min(Math.max(0, Number(maxDelayMs) || 0), retryAfterMs)
124
- : defaultDelayMs
128
+ : calculatedDelayMs
125
129
  onRetry?.({ attempt, nextAttempt: attempt + 1, delayMs, error })
126
130
  await delay(delayMs, signal)
127
131
  }