opencode-cache-engine 0.3.6 → 0.4.1

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.
@@ -0,0 +1,512 @@
1
+ // cache-policy-core.mjs
2
+ //
3
+ // Pure policy registry + resolver for CacheEngine (v0.4.0).
4
+ //
5
+ // This module is the structured policy-resolution layer described by
6
+ // docs/cache-policy-inventory.md. It separates four concerns that used to be
7
+ // entangled in a single model-name-to-behavior branch:
8
+ //
9
+ // 1. creator / family classification
10
+ // 2. baseline cache policy (documented facts)
11
+ // 3. model-specific overlays (CacheEngine code behaviors, NOT implied by family)
12
+ // 4. transport capabilities (e.g. OpenRouter affinity), kept separate from
13
+ // creator cache semantics
14
+ //
15
+ // It performs NO network calls and NO runtime documentation lookups. Every
16
+ // registry entry is traceable to docs/cache-policy-inventory.md via
17
+ // `inventoryRef`. Inheritance is always explicit (`inheritsFrom`); "newer means
18
+ // same behavior" is never an unconditional rule.
19
+ //
20
+ // As of v0.4.1 the runtime hook layer (cache-engine.ts) consumes
21
+ // resolveRuntimePolicy() as its single source of policy classification.
22
+ // detectPolicy() is retained as the compatibility classifier for the legacy
23
+ // POLICY_* strings.
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Model normalization (shared with the legacy classifier)
27
+ // ---------------------------------------------------------------------------
28
+
29
+ // Normalize a model-like object into a searchable haystack. Accepts both the
30
+ // full OpenCode Model ({providerID, id, api:{id,npm}, name}) and slim test
31
+ // objects ({providerID, modelID/apiID}).
32
+ export function modelSignals(model) {
33
+ const m = model && typeof model === "object" ? model : {}
34
+ const api = m.api && typeof m.api === "object" ? m.api : {}
35
+ const providerID = String(m.providerID ?? m.provider ?? "")
36
+ const apiID = String(m.modelID ?? api.id ?? m.id ?? m.apiID ?? "")
37
+ const modelID = String(m.id ?? "")
38
+ const npm = String(api.npm ?? m.npm ?? "")
39
+ const name = String(m.name ?? "")
40
+ const slug = `${apiID} ${modelID}`.trim()
41
+ return { providerID, apiID, modelID, npm, name, slug: slug.toLowerCase() }
42
+ }
43
+
44
+ // OpenAI-ish context is required before we apply GPT-5.6 options, so we never
45
+ // send GPT-5.6-only fields to a non-OpenAI endpoint merely because a model
46
+ // string contains "gpt-5.6". A slug that explicitly starts with openai/ or
47
+ // azure/ (typical for openrouter/azure/openai-compatible routes) also counts
48
+ // because the upstream IS OpenAI. A bare openai-compatible provider with no
49
+ // such slug does NOT count: we must not guess.
50
+ export function isOpenAIish(s) {
51
+ const { providerID, slug, npm } = s
52
+ const p = providerID.toLowerCase()
53
+ if (p === "openai" || p === "azure") return true
54
+ if (slug.startsWith("openai/") || slug.startsWith("azure/")) return true
55
+ if (/@ai-sdk\/openai|@ai-sdk\/azure/.test(npm)) return true
56
+ return false
57
+ }
58
+
59
+ // Candidate ids for exact/alias lookup. Includes the raw apiID/modelID, the
60
+ // lower-cased forms, and a single stripped transport/vendor prefix
61
+ // (e.g. "openai/gpt-5.6-luna" -> "gpt-5.6-luna", "xiaomi/mimo-v2.6-flash" ->
62
+ // "mimo-v2.6-flash"). Prefix stripping is a lookup convenience only and never
63
+ // implies cache semantics.
64
+ function candidateIds(s) {
65
+ const raw = [s.apiID, s.modelID].filter(Boolean).map((v) => String(v).toLowerCase())
66
+ const out = new Set()
67
+ for (const v of raw) {
68
+ out.add(v)
69
+ const stripped = v.replace(/^[a-z0-9._-]+\//, "")
70
+ if (stripped) out.add(stripped)
71
+ }
72
+ return [...out]
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Baseline policies (documented cache-policy facts, not code behavior)
77
+ // ---------------------------------------------------------------------------
78
+
79
+ export const BASELINES = {
80
+ "openai.gpt56.cache": {
81
+ id: "openai.gpt56.cache",
82
+ creator: "openai",
83
+ appliesTo: "GPT-5.6 and later (OpenAI-documented generation boundary)",
84
+ automatic: true,
85
+ defaultMode: "implicit",
86
+ supportsExplicitBreakpoints: true,
87
+ minCacheTokens: 1024,
88
+ ttl: "30m",
89
+ cacheKeyOptional: true,
90
+ cacheWriteBilled: true,
91
+ usageFields: [
92
+ "input_tokens_details.cached_tokens",
93
+ "input_tokens_details.cache_write_tokens",
94
+ ],
95
+ inventoryRef: "§1 OpenAI",
96
+ },
97
+ "deepseek.kv-cache": {
98
+ id: "deepseek.kv-cache",
99
+ creator: "deepseek",
100
+ appliesTo: "DeepSeek provider-wide (documented default for all users)",
101
+ automatic: true,
102
+ defaultMode: "implicit",
103
+ supportsExplicitBreakpoints: false,
104
+ minCacheTokens: null,
105
+ ttl: null,
106
+ cacheKeyOptional: false,
107
+ cacheWriteBilled: false,
108
+ usageFields: ["prompt_cache_hit_tokens", "prompt_cache_miss_tokens"],
109
+ inventoryRef: "§2 DeepSeek",
110
+ },
111
+ "zai.implicit-cache": {
112
+ id: "zai.implicit-cache",
113
+ creator: "z.ai",
114
+ appliesTo: "Z.AI service-wide implicit context caching",
115
+ automatic: true,
116
+ defaultMode: "implicit",
117
+ supportsExplicitBreakpoints: false,
118
+ minCacheTokens: null,
119
+ ttl: null,
120
+ cacheKeyOptional: false,
121
+ cacheWriteBilled: false,
122
+ usageFields: ["prompt_tokens_details.cached_tokens"],
123
+ inventoryRef: "§3 Z.AI GLM",
124
+ },
125
+ "xiaomi.implicit-cache": {
126
+ id: "xiaomi.implicit-cache",
127
+ creator: "xiaomi",
128
+ appliesTo: "Xiaomi MiMo provider-managed implicit caching",
129
+ automatic: true,
130
+ defaultMode: "implicit",
131
+ supportsExplicitBreakpoints: false,
132
+ minCacheTokens: null,
133
+ ttl: null,
134
+ cacheKeyOptional: false,
135
+ cacheWriteBilled: false,
136
+ usageFields: ["prompt_tokens_details.cached_tokens", "cache_read_input_tokens"],
137
+ inventoryRef: "§4 Xiaomi MiMo",
138
+ },
139
+ "neutral.none": {
140
+ id: "neutral.none",
141
+ creator: "unknown",
142
+ appliesTo: "No registered cache policy; no model-specific optimization implied",
143
+ automatic: false,
144
+ defaultMode: null,
145
+ supportsExplicitBreakpoints: false,
146
+ minCacheTokens: null,
147
+ ttl: null,
148
+ cacheKeyOptional: false,
149
+ cacheWriteBilled: false,
150
+ usageFields: [],
151
+ inventoryRef: "§6 Compatibility Matrix",
152
+ },
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Model-specific overlays (CacheEngine code behaviors)
157
+ //
158
+ // An overlay is a concrete CacheEngine mutation. It is attached to a family
159
+ // ONLY by explicit registration; being classified into a creator/family never
160
+ // implies an overlay. This is what keeps "is GLM" from automatically meaning
161
+ // "<env> relocation".
162
+ // ---------------------------------------------------------------------------
163
+
164
+ export const OVERLAYS = {
165
+ "gpt56.prompt-cache-options": {
166
+ id: "gpt56.prompt-cache-options",
167
+ family: "gpt-5.6",
168
+ hook: "chat.params",
169
+ behavior: "inject missing promptCacheKey + promptCacheOptions(implicit, 30m)",
170
+ inventoryRef: "§1 OpenAI",
171
+ },
172
+ "glm53.env-relocation": {
173
+ id: "glm53.env-relocation",
174
+ family: "glm-5.3",
175
+ hook: "experimental.chat.system.transform",
176
+ behavior: "relocate the identifiable <env> block to the system tail",
177
+ inventoryRef: "§3 Z.AI GLM",
178
+ },
179
+ "mimo26.env-relocation": {
180
+ id: "mimo26.env-relocation",
181
+ family: "mimo-v2.6",
182
+ hook: "experimental.chat.system.transform",
183
+ behavior: "relocate the identifiable <env> block to the system tail",
184
+ inventoryRef: "§4 Xiaomi MiMo",
185
+ },
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Transport capabilities (routing), deliberately independent of cache policy
190
+ // ---------------------------------------------------------------------------
191
+
192
+ export const TRANSPORTS = {
193
+ openrouter: {
194
+ id: "openrouter",
195
+ kind: "openrouter",
196
+ sessionAffinityHeader: "x-session-id",
197
+ stickyRouting: true,
198
+ inventoryRef: "§5 OpenRouter transport",
199
+ },
200
+ }
201
+
202
+ function resolveTransport(s) {
203
+ const p = String(s.providerID ?? "").toLowerCase()
204
+ if (p === "openrouter") return { ...TRANSPORTS.openrouter }
205
+ if (!p) {
206
+ return { id: "unknown", kind: "unknown", sessionAffinityHeader: null, stickyRouting: false, inventoryRef: "§5 OpenRouter transport" }
207
+ }
208
+ return { id: p, kind: "direct", sessionAffinityHeader: null, stickyRouting: false, inventoryRef: "§5 OpenRouter transport" }
209
+ }
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // Runtime capability descriptors
213
+ //
214
+ // The runtime consumes `resolvePolicy(...).runtime` for gating. `policy` is the
215
+ // legacy telemetry/state string, so telemetry stays byte-identical. Every
216
+ // capability is explicit per registry entry: classification into a creator or
217
+ // family never implies a mutation. `legacy: false` entries always resolve to
218
+ // NEUTRAL_RUNTIME, so a future-looking model gains nothing until the registry
219
+ // explicitly says so.
220
+ // ---------------------------------------------------------------------------
221
+
222
+ const NEUTRAL_RUNTIME = Object.freeze({
223
+ policy: "neutral",
224
+ isNeutral: true,
225
+ gptCacheMetadata: false,
226
+ envRelocation: null,
227
+ thinkingIntegrity: false,
228
+ cacheRatio: null,
229
+ providerChange: null,
230
+ prefixDiagnostics: false,
231
+ openRouterAffinity: false,
232
+ })
233
+
234
+ const rt = (policy, overrides = {}) => ({
235
+ ...NEUTRAL_RUNTIME,
236
+ ...overrides,
237
+ policy,
238
+ isNeutral: policy === "neutral",
239
+ })
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // Registry
243
+ //
244
+ // Entries are evaluated in array order, which encodes detection priority and
245
+ // preserves the legacy classifier's precedence (gpt-5.6 > glm-5.3 > mimo-v2.6 >
246
+ // deepseek). `legacy: true` entries reproduce the pre-v0.4.0 detectPolicy()
247
+ // behavior exactly. `legacy: false` entries (gpt-6, Pro UltraSpeed) are
248
+ // available to the new resolver only, so runtime behavior is unchanged until
249
+ // wiring is approved.
250
+ // ---------------------------------------------------------------------------
251
+
252
+ export const POLICY_REGISTRY = [
253
+ {
254
+ id: "openai.gpt-5.6",
255
+ creator: "openai",
256
+ family: "gpt-5.6",
257
+ kind: "family",
258
+ pattern: /gpt-5\.6(?![\d.])/i,
259
+ requiresOpenAIish: true,
260
+ exactIds: ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.6-cyber"],
261
+ baseline: "openai.gpt56.cache",
262
+ overlays: ["gpt56.prompt-cache-options"],
263
+ legacy: true,
264
+ runtime: rt("gpt56", { gptCacheMetadata: true }),
265
+ inventoryRef: "§1 OpenAI",
266
+ },
267
+ {
268
+ id: "openai.gpt-6",
269
+ creator: "openai",
270
+ family: "gpt-6",
271
+ kind: "family",
272
+ pattern: /gpt-6(?![\d.])/i,
273
+ requiresOpenAIish: true,
274
+ exactIds: ["gpt-6-astra", "gpt-6-sol", "gpt-6-luna"],
275
+ baseline: "openai.gpt56.cache",
276
+ inheritsFrom: "gpt-5.6",
277
+ overlays: [],
278
+ legacy: false,
279
+ runtime: rt("neutral"),
280
+ note: "Documented inheritance of the GPT-5.6-and-later baseline. No CacheEngine overlay is registered for gpt-6 yet, so the runtime stays neutral.",
281
+ inventoryRef: "§1 OpenAI",
282
+ },
283
+ {
284
+ id: "zai.glm-5.3",
285
+ creator: "z.ai",
286
+ family: "glm-5.3",
287
+ kind: "family",
288
+ pattern: /glm-5\.3(?![\d.])/i,
289
+ exactIds: ["glm-5.3", "glm-5.3-flash", "glm-5.3-flashx"],
290
+ baseline: "zai.implicit-cache",
291
+ overlays: ["glm53.env-relocation"],
292
+ legacy: true,
293
+ runtime: rt("glm53", {
294
+ envRelocation: "glm",
295
+ thinkingIntegrity: true,
296
+ cacheRatio: "glm",
297
+ providerChange: "glm",
298
+ openRouterAffinity: true,
299
+ }),
300
+ inventoryRef: "§3 Z.AI GLM",
301
+ },
302
+ {
303
+ id: "xiaomi.mimo-v2.6",
304
+ creator: "xiaomi",
305
+ family: "mimo-v2.6",
306
+ kind: "family",
307
+ pattern: /mimo-v2\.6-(flash|pro)(?![\w-])/i,
308
+ exactIds: ["mimo-v2.6-flash", "mimo-v2.6-pro"],
309
+ baseline: "xiaomi.implicit-cache",
310
+ overlays: ["mimo26.env-relocation"],
311
+ legacy: true,
312
+ runtime: rt("mimo26", {
313
+ envRelocation: "mimo",
314
+ cacheRatio: "mimo",
315
+ providerChange: "mimo",
316
+ prefixDiagnostics: true,
317
+ openRouterAffinity: true,
318
+ }),
319
+ inventoryRef: "§4 Xiaomi MiMo",
320
+ },
321
+ {
322
+ id: "xiaomi.mimo-v2.6-pro-ultraspeed",
323
+ creator: "xiaomi",
324
+ family: "mimo-v2.6",
325
+ kind: "exact",
326
+ exactIds: ["mimo-v2.6-pro-ultraspeed"],
327
+ baseline: "xiaomi.implicit-cache",
328
+ overlays: [],
329
+ legacy: false,
330
+ runtime: rt("neutral"),
331
+ policyStatus: "documented-series-member-without-registered-overlay",
332
+ note: "Documented as a Pro mode in the same V2.6 series, but the inventory does not establish identical cache controls and CacheEngine registers no overlay for it.",
333
+ inventoryRef: "§4 Xiaomi MiMo",
334
+ },
335
+ {
336
+ id: "deepseek.baseline",
337
+ creator: "deepseek",
338
+ family: "deepseek",
339
+ kind: "creator",
340
+ pattern: /deepseek/i,
341
+ providerPattern: /deepseek/i,
342
+ baseline: "deepseek.kv-cache",
343
+ overlays: [],
344
+ legacy: true,
345
+ runtime: rt("deepseek"),
346
+ inventoryRef: "§2 DeepSeek",
347
+ },
348
+ ]
349
+
350
+ // ---------------------------------------------------------------------------
351
+ // Explicit aliases identified by the inventory
352
+ // ---------------------------------------------------------------------------
353
+
354
+ // `legacy` records whether the pre-v0.4.0 classifier already matched this alias.
355
+ // Only legacy aliases carry runtime capabilities; newer documented aliases are
356
+ // resolved for information but stay runtime-neutral (no new optimization).
357
+ export const MODEL_ALIASES = {
358
+ "gpt-5.6": { canonicalId: "gpt-5.6-sol", family: "gpt-5.6", creator: "openai", legacy: true, inventoryRef: "§1 OpenAI" },
359
+ "gpt-daybreak-blue-latest": { canonicalId: "gpt-5.6-sol", family: "gpt-5.6", creator: "openai", legacy: false, inventoryRef: "§1 OpenAI" },
360
+ "gpt-daybreak-red-latest": { canonicalId: "gpt-5.6-cyber", family: "gpt-5.6", creator: "openai", legacy: false, inventoryRef: "§1 OpenAI" },
361
+ "deepseek-v4-flash": { canonicalId: "deepseek-flash", family: "deepseek", creator: "deepseek", legacy: true, status: "retired-legacy-id", inventoryRef: "§2 DeepSeek" },
362
+ "deepseek-chat": { canonicalId: null, family: "deepseek", creator: "deepseek", legacy: true, status: "retired", inventoryRef: "§2 DeepSeek" },
363
+ "deepseek-reasoner": { canonicalId: null, family: "deepseek", creator: "deepseek", legacy: true, status: "retired", inventoryRef: "§2 DeepSeek" },
364
+ }
365
+
366
+ // ---------------------------------------------------------------------------
367
+ // Resolution
368
+ // ---------------------------------------------------------------------------
369
+
370
+ function neutralResult(reason, transport) {
371
+ return {
372
+ creator: "unknown",
373
+ family: "neutral",
374
+ baseline: BASELINES["neutral.none"],
375
+ overlays: [],
376
+ runtime: NEUTRAL_RUNTIME,
377
+ transport,
378
+ matchType: "neutral",
379
+ matchReason: reason,
380
+ matchedId: null,
381
+ inventoryRef: null,
382
+ note: null,
383
+ }
384
+ }
385
+
386
+ function overlaysFor(ids) {
387
+ return (ids ?? []).map((id) => OVERLAYS[id]).filter(Boolean)
388
+ }
389
+
390
+ // Only legacy entries carry runtime capabilities. A non-legacy entry (gpt-6,
391
+ // Pro UltraSpeed) resolves for information but stays neutral at runtime.
392
+ function runtimeForEntry(entry) {
393
+ return entry.legacy === false ? NEUTRAL_RUNTIME : entry.runtime ?? NEUTRAL_RUNTIME
394
+ }
395
+
396
+ function resultFromEntry(entry, matchType, matchReason, matchedId, transport) {
397
+ return {
398
+ creator: entry.creator,
399
+ family: entry.family,
400
+ baseline: BASELINES[entry.baseline] ?? null,
401
+ overlays: overlaysFor(entry.overlays),
402
+ runtime: runtimeForEntry(entry),
403
+ transport,
404
+ matchType,
405
+ matchReason,
406
+ matchedId: matchedId ?? null,
407
+ inventoryRef: entry.inventoryRef ?? null,
408
+ note: entry.note ?? null,
409
+ }
410
+ }
411
+
412
+ function resultFromFamily(family, creator, matchType, matchReason, matchedId, inventoryRef, note, transport, aliasLegacy) {
413
+ const entry = POLICY_REGISTRY.find((e) => e.family === family && e.kind !== "exact")
414
+ // An alias is runtime-active only when both the alias and its target family
415
+ // were recognized before v0.4.0.
416
+ const active = aliasLegacy !== false && (!entry || entry.legacy !== false)
417
+ return {
418
+ creator,
419
+ family,
420
+ baseline: entry ? BASELINES[entry.baseline] ?? null : null,
421
+ overlays: entry ? overlaysFor(entry.overlays) : [],
422
+ runtime: active && entry ? entry.runtime ?? NEUTRAL_RUNTIME : NEUTRAL_RUNTIME,
423
+ transport,
424
+ matchType,
425
+ matchReason,
426
+ matchedId: matchedId ?? null,
427
+ inventoryRef: inventoryRef ?? null,
428
+ note: note ?? null,
429
+ }
430
+ }
431
+
432
+ // Structured resolver. Returns creator, family, baseline, overlays, transport,
433
+ // matchType, and matchReason. Unknown models resolve to a neutral result with
434
+ // an empty overlay list and no model-specific optimization.
435
+ export function resolvePolicy(model) {
436
+ if (!model || typeof model !== "object") {
437
+ return neutralResult("neutral:invalid-model", resolveTransport({}))
438
+ }
439
+ const s = modelSignals(model)
440
+ const transport = resolveTransport(s)
441
+ if (!s.slug) return neutralResult("neutral:no-model-identity", transport)
442
+
443
+ const ids = candidateIds(s)
444
+
445
+ // 1. Explicit aliases (inventory-identified). An alias inherits its target
446
+ // family's context gate, so e.g. an OpenAI alias on a non-OpenAI gateway does
447
+ // not gain a cache policy it would not otherwise have.
448
+ for (const id of ids) {
449
+ const alias = MODEL_ALIASES[id]
450
+ if (!alias) continue
451
+ const familyEntry = POLICY_REGISTRY.find((e) => e.family === alias.family && e.kind !== "exact")
452
+ if (familyEntry?.requiresOpenAIish && !isOpenAIish(s)) continue
453
+ const reason = `alias:${id}->${alias.canonicalId ?? alias.family}`
454
+ return resultFromFamily(alias.family, alias.creator, "exact", reason, id, alias.inventoryRef, alias.status ?? null, transport, alias.legacy)
455
+ }
456
+
457
+ // 2. Exact model ids (documented models).
458
+ for (const entry of POLICY_REGISTRY) {
459
+ if (!entry.exactIds || entry.exactIds.length === 0) continue
460
+ const hit = ids.find((id) => entry.exactIds.includes(id))
461
+ if (hit) return resultFromEntry(entry, "exact", `exact-id:${hit}`, hit, transport)
462
+ }
463
+
464
+ // 3. Model family / range patterns.
465
+ for (const entry of POLICY_REGISTRY) {
466
+ if (entry.kind !== "family" || !entry.pattern) continue
467
+ if (!entry.pattern.test(s.slug)) continue
468
+ if (entry.requiresOpenAIish && !isOpenAIish(s)) continue
469
+ return resultFromEntry(entry, "family", `family-pattern:${entry.id}`, null, transport)
470
+ }
471
+
472
+ // 4. Creator baseline.
473
+ for (const entry of POLICY_REGISTRY) {
474
+ if (entry.kind !== "creator") continue
475
+ const slugHit = entry.pattern ? entry.pattern.test(s.slug) : false
476
+ const providerHit = entry.providerPattern ? entry.providerPattern.test(s.providerID) : false
477
+ if (slugHit || providerHit) return resultFromEntry(entry, "creator", `creator-baseline:${entry.id}`, null, transport)
478
+ }
479
+
480
+ return neutralResult("neutral:no-match", transport)
481
+ }
482
+
483
+ // Convenience accessor for the runtime: the legacy policy string + explicit
484
+ // capability flags. This is the single source the runtime gates on; it is
485
+ // guaranteed equal to the pre-v0.4.0 detectPolicy() classification.
486
+ export function resolveRuntimePolicy(model) {
487
+ return resolvePolicy(model).runtime
488
+ }
489
+
490
+ // Compatibility classification used by detectPolicy(). Reproduces the
491
+ // pre-v0.4.0 behavior exactly: it considers only `legacy` registry entries,
492
+ // excludes newer-generation/alias/exact-overlay additions, and returns a family
493
+ // string (or "neutral"). Callers map the family to a POLICY_* constant.
494
+ export function resolveLegacyFamily(model) {
495
+ if (!model || typeof model !== "object") return "neutral"
496
+ const s = modelSignals(model)
497
+ if (!s.slug) return "neutral"
498
+ for (const entry of POLICY_REGISTRY) {
499
+ if (!entry.legacy) continue
500
+ if (entry.kind === "family") {
501
+ if (!entry.pattern.test(s.slug)) continue
502
+ if (entry.requiresOpenAIish && !isOpenAIish(s)) continue
503
+ return entry.family
504
+ }
505
+ if (entry.kind === "creator") {
506
+ const slugHit = entry.pattern ? entry.pattern.test(s.slug) : false
507
+ const providerHit = entry.providerPattern ? entry.providerPattern.test(s.providerID) : false
508
+ if (slugHit || providerHit) return entry.family
509
+ }
510
+ }
511
+ return "neutral"
512
+ }