@goodandready/dsh-model-sync 0.5.0 → 0.5.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.
@@ -110,13 +110,16 @@ export function resolveAlias(aliasName, {
110
110
  // 3. Strategy resolution (@fast, @cheap, @best-code or custom.strategy)
111
111
  let strategy = custom?.strategy
112
112
  const lower = clean.toLowerCase()
113
- if (!strategy) {
113
+ if (!strategy && isVirtualAlias(clean)) {
114
114
  if (lower === '@fast') strategy = 'fastest'
115
115
  else if (lower === '@cheap') strategy = 'cheapest'
116
116
  else if (lower === '@best-code') strategy = 'best-code'
117
117
  }
118
118
 
119
119
  if (strategy) {
120
+ if (!VIRTUAL_STRATEGIES.includes(strategy)) {
121
+ throw Object.assign(new Error(`Invalid strategy: ${strategy}`), { code: 'INVALID_STRATEGY' })
122
+ }
120
123
  const available = []
121
124
  for (const [provider, models] of Object.entries(modelCatalogs)) {
122
125
  if (isCircuitOpen(provider)) continue
package/lib/client.js CHANGED
@@ -5,6 +5,14 @@ window.__ModuleLoader__.load({
5
5
  var exports = module.exports
6
6
  const React = require('react')
7
7
 
8
+ let ChevronIcon = null
9
+ try {
10
+ const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
11
+ ChevronIcon = primitives && primitives.IconChevronDownOutline14
12
+ } catch {
13
+ /* Optional core chevron primitive not available; SVG fallback used */
14
+ }
15
+
8
16
  const CSS = '.dms-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none;margin:0;padding:0;overflow:hidden;box-sizing:border-box}' +
9
17
  '.dms-card-open{background:var(--dsw-alias-bg-layer-3);border-color:var(--dsw-alias-border-l2)}' +
10
18
  '.dms-card-header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;display:flex;align-items:center;gap:12px;padding:14px 16px}' +
@@ -1425,9 +1433,11 @@ window.__ModuleLoader__.load({
1425
1433
  React.createElement('span', { className: 'dms-card-description' }, t.desc)
1426
1434
  ),
1427
1435
  cardBadge ? React.createElement('span', { style: { background: 'var(--dsw-alias-state-business-primary)', color: 'white', padding: '2px 6px', borderRadius: 6, fontSize: 11, marginLeft: 8 } }, `+${cardBadge}`) : null,
1428
- React.createElement('svg', { width: 14, height: 14, className: 'dms-card-chevron' + (open ? ' dms-card-chevron-open' : ''), viewBox: '0 0 14 14', fill: 'none', 'aria-hidden': 'true' },
1429
- React.createElement('path', { d: 'M11.8486 5.5L11.4238 5.92383L8.69727 8.65137C8.44157 8.90706 8.21562 9.13382 8.01172 9.29785C7.79912 9.46883 7.55595 9.61756 7.25 9.66602C7.08435 9.69222 6.91565 9.69222 6.75 9.66602C6.44405 9.61756 6.20088 9.46883 5.98828 9.29785C5.78438 9.13382 5.55843 8.90706 5.30273 8.65137L2.57617 5.92383L2.15137 5.5L3 4.65137L3.42383 5.07617L6.15137 7.80273C6.42595 8.07732 6.59876 8.24849 6.74023 8.3623C6.87291 8.46904 6.92272 8.47813 6.9375 8.48047C6.97895 8.48703 7.02105 8.48703 7.0625 8.48047C7.07728 8.47813 7.12709 8.46904 7.25977 8.3623C7.40124 8.24849 7.57405 8.07732 7.84863 7.80273L10.5762 5.07617L11 4.65137L11.8486 5.5', fill: 'currentColor' })
1430
- )
1436
+ ChevronIcon
1437
+ ? React.createElement(ChevronIcon, { width: 14, height: 14, className: 'dms-card-chevron' + (open ? ' dms-card-chevron-open' : ''), 'aria-hidden': 'true' })
1438
+ : React.createElement('svg', { width: 14, height: 14, className: 'dms-card-chevron' + (open ? ' dms-card-chevron-open' : ''), viewBox: '0 0 14 14', fill: 'none', 'aria-hidden': 'true' },
1439
+ React.createElement('path', { d: 'M11.8486 5.5L11.4238 5.92383L8.69727 8.65137C8.44157 8.90706 8.21562 9.13382 8.01172 9.29785C7.79912 9.46883 7.55595 9.61756 7.25 9.66602C7.08435 9.69222 6.91565 9.69222 6.75 9.66602C6.44405 9.61756 6.20088 9.46883 5.98828 9.29785C5.78438 9.13382 5.55843 8.90706 5.30273 8.65137L2.57617 5.92383L2.15137 5.5L3 4.65137L3.42383 5.07617L6.15137 7.80273C6.42595 8.07732 6.59876 8.24849 6.74023 8.3623C6.87291 8.46904 6.92272 8.47813 6.9375 8.48047C6.97895 8.48703 7.02105 8.48703 7.0625 8.48047C7.07728 8.47813 7.12709 8.46904 7.25977 8.3623C7.40124 8.24849 7.57405 8.07732 7.84863 7.80273L10.5762 5.07617L11 4.65137L11.8486 5.5', fill: 'currentColor' })
1440
+ )
1431
1441
  ),
1432
1442
  (page || open) ? React.createElement('div', { className: 'dms-card-body' }, React.createElement(ErrorBoundary, null, React.createElement(ModelSyncSection, { ctx, includeIntro: false }))) : null
1433
1443
  )
package/lib/http.js CHANGED
@@ -146,8 +146,26 @@ export function normalizeBatchTryRequest(body) {
146
146
  export function normalizeAliasRequest(body) {
147
147
  if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new Error('request body must be an object')
148
148
  if (typeof body.alias !== 'string' || !body.alias.trim()) throw new Error('alias is required')
149
- if (!/^[a-zA-Z0-9_\-.]{1,64}$/.test(body.alias.trim())) throw new Error('alias must be 1-64 alphanumeric characters or _-.')
149
+ if (!validateAliasName(body.alias)) throw new Error('alias must be 1-64 alphanumeric characters, @ or _-.')
150
150
  if (body.action === 'delete') return { action: 'delete', alias: body.alias.trim() }
151
+ if (Array.isArray(body.targets) && body.targets.length > 0) {
152
+ if (body.targets.length > 50) throw new Error('targets array is too long')
153
+ for (const t of body.targets) {
154
+ if (typeof t === 'string') {
155
+ if (!t || t.length > 256) throw new Error('target must be a short string')
156
+ } else if (t && typeof t === 'object') {
157
+ if (typeof t.provider !== 'string' || !t.provider || t.provider.length > 128) throw new Error('target provider must be a short string')
158
+ if (typeof t.model !== 'string' || !t.model || t.model.length > 256) throw new Error('target model must be a short string')
159
+ } else {
160
+ throw new Error('invalid target format')
161
+ }
162
+ }
163
+ return { action: 'set', alias: body.alias.trim(), targets: body.targets }
164
+ }
165
+ if (typeof body.strategy === 'string' && body.strategy.trim()) {
166
+ if (body.strategy.length > 64) throw new Error('strategy must be a short string')
167
+ return { action: 'set', alias: body.alias.trim(), strategy: body.strategy.trim() }
168
+ }
151
169
  if (typeof body.provider !== 'string' || !body.provider.trim() || body.provider.length > 128) throw new Error('provider must be a short string')
152
170
  if (typeof body.model !== 'string' || !body.model.trim() || body.model.length > 256) throw new Error('model must be a short string')
153
171
  return { action: 'set', alias: body.alias.trim(), provider: body.provider.trim(), model: body.model.trim() }
@@ -254,7 +272,8 @@ export function registerHttpApi(ctx, synchronizer, { getSchedulerStatus = () =>
254
272
  if (route === 'aliases' && req.method === 'GET') {
255
273
  return json(res, 200, { aliases: synchronizer.getAliases() })
256
274
  }
257
- if (route === 'aliases-resolve' && req.method === 'GET') {
275
+ if (route === 'aliases-resolve') {
276
+ if (req.method !== 'GET') return json(res, 405, { error: { code: 'method', message: 'GET only' } })
258
277
  try {
259
278
  const urlObj = new URL(req.url, 'http://127.0.0.1')
260
279
  const alias = urlObj.searchParams.get('alias')
@@ -1,4 +1,7 @@
1
1
  import { statusOfError } from './reliability.js'
2
+ import { historyEntry } from './history.js'
3
+ import { normalizeModels } from './models.js'
4
+ import { catalogPatch } from './reconcile.js'
2
5
 
3
6
  export const PIAI_NS = 'llm-pi-ai'
4
7
  export const DEFAULT_RELIABILITY = Object.freeze({
@@ -124,3 +127,38 @@ export function rotationRefs(settings, provider) {
124
127
  return []
125
128
  }
126
129
  }
130
+
131
+ export function refsFor(row, settings) {
132
+ const profile = profileOf(settings, row.provider)
133
+ const refs = [
134
+ row.apiKeyEnv,
135
+ profile.credentialRef,
136
+ ...(Array.isArray(profile.credentialRefs) ? profile.credentialRefs : []),
137
+ ...(Array.isArray(profile.apiKeyRefs) ? profile.apiKeyRefs : []),
138
+ ...rotationRefs(settings, row.provider),
139
+ ]
140
+ return [...new Set(refs.filter((ref) => typeof ref === 'string' && ref.trim()).map((ref) => ref.trim()))].slice(0, 32)
141
+ }
142
+
143
+ export async function performHistoryRollback({ ctx, config, providers = [], historyId, provider } = {}) {
144
+ if (typeof historyId !== 'string' || !historyId) throw Object.assign(new Error('historyId is required'), { code: 'INVALID_HISTORY' })
145
+ if (typeof provider !== 'string' || !provider) throw Object.assign(new Error('provider is required'), { code: 'INVALID_HISTORY' })
146
+ const entry = historyEntry(config.history, historyId)
147
+ if (!entry) throw Object.assign(new Error(`history entry not found: ${historyId}`), { code: 'HISTORY_NOT_FOUND' })
148
+ const snapshot = (entry.providers ?? []).find((row) => row.provider === provider)
149
+ if (!snapshot || !Array.isArray(snapshot.before)) throw Object.assign(new Error(`history snapshot not found: ${provider}`), { code: 'HISTORY_NOT_FOUND' })
150
+ if (!providers.some((row) => row.provider === provider && row.configured)) throw Object.assign(new Error(`provider is not configured: ${provider}`), { code: 'PROVIDER_NOT_CONFIGURED' })
151
+ const settings = settingsOf(ctx)
152
+ const section = settings?.get?.(PIAI_NS)
153
+ const descriptor = descriptorOf(settings, PIAI_NS)
154
+ if (!section || !descriptor || typeof settings.replace !== 'function') {
155
+ throw Object.assign(new Error('llm-pi-ai settings are not writable'), { code: 'SETTINGS_UNAVAILABLE' })
156
+ }
157
+ const current = normalizeModels(provider, profileOf(settings, provider).models ?? [])
158
+ const target = normalizeModels(provider, snapshot.before)
159
+ if (JSON.stringify(current) === JSON.stringify(target)) {
160
+ return { historyId, provider, models: target, applied: false, allowlistUntouched: true }
161
+ }
162
+ await replaceWithRetry(settings, PIAI_NS, (sec) => catalogPatch(sec, provider, target))
163
+ return { historyId, provider, models: target, applied: true, allowlistUntouched: true }
164
+ }
@@ -1,4 +1,4 @@
1
- import { resolveAlias as resolveSmartAlias, validateAliasName } from './alias-resolver.js'
1
+ import { resolveAlias as resolveSmartAlias, validateAliasName, VIRTUAL_STRATEGIES, isVirtualAlias } from './alias-resolver.js'
2
2
 
3
3
  export function createConfigTransfer({
4
4
  getConfig = () => ({}),
@@ -68,9 +68,17 @@ export function createConfigTransfer({
68
68
  for (const [k, v] of Object.entries(data.aliases)) {
69
69
  if (validateAliasName(k) && v && typeof v === 'object') {
70
70
  const entry = { updatedAt: Date.now() }
71
- if (Array.isArray(v.targets)) entry.targets = v.targets
72
- else if (v.strategy) entry.strategy = v.strategy
73
- else if (v.provider && v.model) {
71
+ if (Array.isArray(v.targets)) {
72
+ entry.targets = v.targets
73
+ } else if (v.strategy && typeof v.strategy === 'string') {
74
+ const strat = v.strategy.trim()
75
+ if (VIRTUAL_STRATEGIES.includes(strat)) {
76
+ entry.strategy = strat
77
+ entry.isVirtual = isVirtualAlias(k)
78
+ } else {
79
+ continue
80
+ }
81
+ } else if (v.provider && v.model) {
74
82
  entry.provider = v.provider
75
83
  entry.model = v.model
76
84
  }
@@ -110,8 +118,16 @@ export function createConfigTransfer({
110
118
 
111
119
  if (Array.isArray(targets) && targets.length > 0) {
112
120
  currentAliases[trimmedAlias] = { targets, updatedAt: Date.now() }
113
- } else if (strategy && typeof strategy === 'string') {
114
- currentAliases[trimmedAlias] = { strategy, updatedAt: Date.now() }
121
+ } else if (strategy !== undefined && strategy !== null && strategy !== '') {
122
+ const cleanStrategy = typeof strategy === 'string' ? strategy.trim() : ''
123
+ if (!VIRTUAL_STRATEGIES.includes(cleanStrategy)) {
124
+ throw Object.assign(new Error(`Invalid strategy: ${strategy}. Supported: ${VIRTUAL_STRATEGIES.join(', ')}`), { code: 'INVALID_STRATEGY' })
125
+ }
126
+ currentAliases[trimmedAlias] = {
127
+ strategy: cleanStrategy,
128
+ isVirtual: isVirtualAlias(trimmedAlias),
129
+ updatedAt: Date.now(),
130
+ }
115
131
  } else {
116
132
  if (typeof provider !== 'string' || !provider) throw new Error('provider is required')
117
133
  if (typeof model !== 'string' || !model) throw new Error('model is required')
@@ -6,7 +6,7 @@ import { reconcileModels, catalogPatch } from './reconcile.js'
6
6
  import { normalizeModels } from './models.js'
7
7
  import { filterModels, normalizePolicy, validatePolicy } from './policy.js'
8
8
  import { createTimeoutSignal, isRetryableError, mapWithConcurrency, retryWithBackoff, statusOfError } from './reliability.js'
9
- import { appendHistory, createHistoryEntry, historyEntry, listHistory } from './history.js'
9
+ import { appendHistory, createHistoryEntry, listHistory } from './history.js'
10
10
  import { lifecycleOptions, lifecycleStatus, selectableModels, updateLifecycle } from './lifecycle.js'
11
11
  import { appendNotification, listNotifications, createSyncReport, updateNotification } from './reporting.js'
12
12
 
@@ -24,6 +24,8 @@ import {
24
24
  adapterErrorType,
25
25
  replaceWithRetry,
26
26
  rotationRefs,
27
+ refsFor,
28
+ performHistoryRollback,
27
29
  } from './synchronizer-helpers.js'
28
30
  import { createConfigTransfer } from './synchronizer-transfer.js'
29
31
  import { createModelProber } from './synchronizer-prober.js'
@@ -50,18 +52,6 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
50
52
  return registry.select(profile, config.adapterRegistry?.[profile.provider])
51
53
  }
52
54
 
53
- function refsFor(row, settings) {
54
- const profile = profileOf(settings, row.provider)
55
- const refs = [
56
- row.apiKeyEnv,
57
- profile.credentialRef,
58
- ...(Array.isArray(profile.credentialRefs) ? profile.credentialRefs : []),
59
- ...(Array.isArray(profile.apiKeyRefs) ? profile.apiKeyRefs : []),
60
- ...rotationRefs(settings, row.provider),
61
- ]
62
- return [...new Set(refs.filter((ref) => typeof ref === 'string' && ref.trim()).map((ref) => ref.trim()))].slice(0, 32)
63
- }
64
-
65
55
  function circuitOpen(provider, reliability) {
66
56
  if (reliability.circuitBreakerFailures <= 0) return null
67
57
  const state = circuits.get(provider)
@@ -462,32 +452,8 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
462
452
  }
463
453
 
464
454
  const prober = createModelProber({ health })
465
- const tryModel = prober.tryModel
466
- const batchTryModels = prober.batchTryModels
467
455
 
468
- async function rollbackHistory({ historyId, provider } = {}) {
469
- if (typeof historyId !== 'string' || !historyId) throw Object.assign(new Error('historyId is required'), { code: 'INVALID_HISTORY' })
470
- if (typeof provider !== 'string' || !provider) throw Object.assign(new Error('provider is required'), { code: 'INVALID_HISTORY' })
471
- const config = getConfig() ?? {}
472
- const entry = historyEntry(config.history, historyId)
473
- if (!entry) throw Object.assign(new Error(`history entry not found: ${historyId}`), { code: 'HISTORY_NOT_FOUND' })
474
- const snapshot = (entry.providers ?? []).find((row) => row.provider === provider)
475
- if (!snapshot || !Array.isArray(snapshot.before)) throw Object.assign(new Error(`history snapshot not found: ${provider}`), { code: 'HISTORY_NOT_FOUND' })
476
- if (!providers().some((row) => row.provider === provider && row.configured)) throw Object.assign(new Error(`provider is not configured: ${provider}`), { code: 'PROVIDER_NOT_CONFIGURED' })
477
- const settings = settingsOf(ctx)
478
- const section = settings?.get?.(PIAI_NS)
479
- const descriptor = descriptorOf(settings, PIAI_NS)
480
- if (!section || !descriptor || typeof settings.replace !== 'function') {
481
- throw Object.assign(new Error('llm-pi-ai settings are not writable'), { code: 'SETTINGS_UNAVAILABLE' })
482
- }
483
- const current = normalizeModels(provider, profileOf(settings, provider).models ?? [])
484
- const target = normalizeModels(provider, snapshot.before)
485
- if (JSON.stringify(current) === JSON.stringify(target)) {
486
- return { historyId, provider, models: target, applied: false, allowlistUntouched: true }
487
- }
488
- await replaceWithRetry(settings, PIAI_NS, (section) => catalogPatch(section, provider, target))
489
- return { historyId, provider, models: target, applied: true, allowlistUntouched: true }
490
- }
456
+ const rollbackHistory = (opts) => performHistoryRollback({ ctx, config: getConfig() ?? {}, providers: providers(), ...opts })
491
457
 
492
458
  async function setModelPolicy(provider, policy) {
493
459
  if (typeof provider !== 'string' || !provider) throw new Error('provider is required')
@@ -567,12 +533,6 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
567
533
  isCircuitOpen: (p) => Boolean(circuits.get(p)?.blockedUntil > Date.now()),
568
534
  getHealthResults: () => lastHealth?.results ?? [],
569
535
  })
570
- const exportConfig = transfer.exportConfig
571
- const importConfig = transfer.importConfig
572
- const getAliases = transfer.getAliases
573
- const setAlias = transfer.setAlias
574
- const deleteAlias = transfer.deleteAlias
575
- const resolveAlias = transfer.resolveAlias
576
536
 
577
537
  return Object.freeze({
578
538
  listProviders: providers,
@@ -585,21 +545,15 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
585
545
  setModelPolicy,
586
546
  listPolicies,
587
547
  health,
588
- tryModel,
589
- batchTryModels,
590
- exportConfig,
591
- importConfig,
592
- getAliases,
593
- setAlias,
594
- deleteAlias,
595
- resolveAlias,
548
+ ...prober,
549
+ ...transfer,
596
550
  credentialDiagnostics,
597
551
  history,
598
552
  report: () => structuredClone(lastReport),
599
553
  notifications: notificationLedger,
600
554
  updateNotification: updateNotificationState,
601
555
  rollbackHistory,
602
- testWebhook: async ({ url, secret }) => sendWebhookNotification(url, secret, { event: model-sync.test, timestamp: Date.now(), test: true }),
556
+ testWebhook: async ({ url, secret }) => sendWebhookNotification(url, secret, { event: 'model-sync.test', timestamp: Date.now(), test: true }),
603
557
  status: () => ({ running: Boolean(running), lastRun, lastHealth, lastReport: structuredClone(lastReport), notifications: notificationLedger(), history: history(), policies: listPolicies(), lifecycle: structuredClone(getConfig()?.modelLifecycle ?? {}), aliases: structuredClone(getConfig()?.aliases ?? {}) }),
604
558
  })
605
559
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-model-sync",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Automatic model catalog synchronization for API-key DeepSeek Harness providers.",
5
5
  "keywords": [
6
6
  "deepseek-harness",