@goodandready/dsh-model-sync 0.5.11 → 0.5.12

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/README.md CHANGED
@@ -137,23 +137,31 @@ dsh plugin --profile web add @goodandready/dsh-model-sync
137
137
  ```yaml
138
138
  dsh-model-sync:
139
139
  enabled: true
140
- syncIntervalMinutes: 60
141
- autoReconcile: true
142
- enableBalanceChecks: true
140
+ scheduleEnabled: false
141
+ intervalMinutes: 60
142
+ autoApply: false
143
+ requestTimeoutMs: 15000
144
+ retryAttempts: 3
145
+ concurrency: 4
143
146
  providers:
144
- commandcode:
147
+ - provider: commandcode
145
148
  enabled: true
146
- clinebot:
149
+ autoApply: false
150
+ - provider: clinebot
147
151
  enabled: true
152
+ autoApply: false
148
153
  ```
149
154
 
150
155
  | Parameter | Type | Default | Description |
151
156
  |---|---|---|---|
152
157
  | `enabled` | `boolean` | `true` | Master switch for background synchronization |
153
- | `syncIntervalMinutes` | `number` | `60` | Polling frequency for catalog refreshes |
154
- | `autoReconcile` | `boolean` | `true` | Automatically apply discovered models to active catalog |
155
- | `enableBalanceChecks` | `boolean` | `true` | Query billing endpoints where supported |
156
- | `providers.<id>.enabled`| `boolean` | `true` | Enable or disable discovery for specific provider |
158
+ | `scheduleEnabled` | `boolean` | `false` | Enable periodic background synchronization schedule |
159
+ | `intervalMinutes` | `number` | `60` | Polling frequency for catalog refreshes in minutes |
160
+ | `autoApply` | `boolean` | `false` | Automatically apply discovered models to active catalog |
161
+ | `requestTimeoutMs`| `number` | `15000` | HTTP request timeout for catalog fetching (ms) |
162
+ | `retryAttempts` | `number` | `3` | Maximum retry attempts for transient network failures |
163
+ | `concurrency` | `number` | `4` | Maximum parallel provider synchronization workers |
164
+ | `providers` | `array` | `[]` | Per-provider configuration list (`provider`, `enabled`, `autoApply`) |
157
165
 
158
166
  ---
159
167
 
package/README.ru.md CHANGED
@@ -137,23 +137,31 @@ dsh plugin --profile web add @goodandready/dsh-model-sync
137
137
  ```yaml
138
138
  dsh-model-sync:
139
139
  enabled: true
140
- syncIntervalMinutes: 60
141
- autoReconcile: true
142
- enableBalanceChecks: true
140
+ scheduleEnabled: false
141
+ intervalMinutes: 60
142
+ autoApply: false
143
+ requestTimeoutMs: 15000
144
+ retryAttempts: 3
145
+ concurrency: 4
143
146
  providers:
144
- commandcode:
147
+ - provider: commandcode
145
148
  enabled: true
146
- clinebot:
149
+ autoApply: false
150
+ - provider: clinebot
147
151
  enabled: true
152
+ autoApply: false
148
153
  ```
149
154
 
150
155
  | Параметр | Тип | По умолчанию | Описание |
151
156
  |---|---|---|---|
152
157
  | `enabled` | `boolean` | `true` | Главный переключатель фоновой синхронизации |
153
- | `syncIntervalMinutes` | `number` | `60` | Периодичность опроса провайдеров (в минутах) |
154
- | `autoReconcile` | `boolean` | `true` | Автоматически применять обнаруженные модели к активному каталогу |
155
- | `enableBalanceChecks` | `boolean` | `true` | Опрашивать биллинговые эндпоинты при наличии поддержки |
156
- | `providers.<id>.enabled`| `boolean` | `true` | Включение или отключение обнаружения для конкретного провайдера |
158
+ | `scheduleEnabled` | `boolean` | `false` | Включение периодического фонового расписания синхронизации |
159
+ | `intervalMinutes` | `number` | `60` | Периодичность опроса провайдеров (в минутах) |
160
+ | `autoApply` | `boolean` | `false` | Автоматически применять обнаруженные модели к активному каталогу |
161
+ | `requestTimeoutMs`| `number` | `15000` | Таймаут HTTP-запросов синхронизации каталогов (в миллисекундах) |
162
+ | `retryAttempts` | `number` | `3` | Максимальное число повторных попыток при сетевых сбоях |
163
+ | `concurrency` | `number` | `4` | Максимальное количество параллельных задач синхронизации |
164
+ | `providers` | `array` | `[]` | Список индивидуальных настроек провайдеров (`provider`, `enabled`, `autoApply`) |
157
165
 
158
166
  ---
159
167
 
package/README.zh.md CHANGED
@@ -137,23 +137,31 @@ dsh plugin --profile web add @goodandready/dsh-model-sync
137
137
  ```yaml
138
138
  dsh-model-sync:
139
139
  enabled: true
140
- syncIntervalMinutes: 60
141
- autoReconcile: true
142
- enableBalanceChecks: true
140
+ scheduleEnabled: false
141
+ intervalMinutes: 60
142
+ autoApply: false
143
+ requestTimeoutMs: 15000
144
+ retryAttempts: 3
145
+ concurrency: 4
143
146
  providers:
144
- commandcode:
147
+ - provider: commandcode
145
148
  enabled: true
146
- clinebot:
149
+ autoApply: false
150
+ - provider: clinebot
147
151
  enabled: true
152
+ autoApply: false
148
153
  ```
149
154
 
150
155
  | 参数名称 | 类型 | 默认值 | 功能说明 |
151
156
  |---|---|---|---|
152
157
  | `enabled` | `boolean` | `true` | 后台自动同步主开关 |
153
- | `syncIntervalMinutes` | `number` | `60` | 目录更新轮询间隔(分钟) |
154
- | `autoReconcile` | `boolean` | `true` | 是否自动将新发现的模型生效至可用列表 |
155
- | `enableBalanceChecks` | `boolean` | `true` | 在受支持的服务商处查询余额 |
156
- | `providers.<id>.enabled`| `boolean` | `true` | 单独开启或关闭指定服务商的同步 |
158
+ | `scheduleEnabled` | `boolean` | `false` | 启用周期性后台定时同步任务 |
159
+ | `intervalMinutes` | `number` | `60` | 目录更新轮询间隔(分钟) |
160
+ | `autoApply` | `boolean` | `false` | 是否自动将新发现的模型生效至可用列表 |
161
+ | `requestTimeoutMs`| `number` | `15000` | 目录拉取 HTTP 请求超时时间(毫秒) |
162
+ | `retryAttempts` | `number` | `3` | 瞬态网络故障的最大重试次数 |
163
+ | `concurrency` | `number` | `4` | 最大并发服务商同步任务数 |
164
+ | `providers` | `array` | `[]` | 服务商独立配置列表(`provider`、`enabled`、`autoApply`) |
157
165
 
158
166
  ---
159
167
 
package/lib/client.js CHANGED
@@ -108,14 +108,24 @@ window.__ModuleLoader__.load({
108
108
 
109
109
  function ensureStyles() {
110
110
  if (typeof document === 'undefined' || !document.head) return
111
+ const pluginId = '@goodandready/dsh-model-sync'
112
+ const styleId = 'dsh-model-sync-styles'
111
113
  const cssId = 'dsh-model-sync/settings.module.css'
112
- if (document.querySelector('style[data-plugin-css="' + cssId + '"]') || document.querySelector('style[data-dsh-plugin="dsh-model-sync"]')) return
113
- const tag = document.createElement('style')
114
+ let tag = document.getElementById(styleId) || document.querySelector('style[data-plugin="' + pluginId + '"]') || document.querySelector('style[data-dsh-plugin="' + pluginId + '"]') || document.querySelector('style[data-plugin="dsh-model-sync"]') || document.querySelector('style[data-dsh-plugin="dsh-model-sync"]')
115
+ if (tag) {
116
+ if (tag.textContent !== CSS) tag.textContent = CSS
117
+ tag.setAttribute('data-plugin', pluginId)
118
+ tag.setAttribute('data-dsh-plugin', pluginId)
119
+ return tag
120
+ }
121
+ tag = document.createElement('style')
122
+ tag.id = styleId
114
123
  tag.textContent = CSS
115
- tag.setAttribute('data-plugin', 'dsh-model-sync')
116
- tag.setAttribute('data-dsh-plugin', 'dsh-model-sync')
124
+ tag.setAttribute('data-plugin', pluginId)
125
+ tag.setAttribute('data-dsh-plugin', pluginId)
117
126
  tag.setAttribute('data-plugin-css', cssId)
118
127
  document.head.appendChild(tag)
128
+ return tag
119
129
  }
120
130
 
121
131
  const en = {
@@ -266,6 +276,25 @@ window.__ModuleLoader__.load({
266
276
  updateFailed: 'Update failed: ',
267
277
  checkUpdateFailed: 'Failed to check for updates',
268
278
  targetProvider: 'Provider',
279
+ reliabilitySettings: 'Reliability & Engine configuration',
280
+ requestTimeoutMs: 'Request timeout (ms)',
281
+ retryAttempts: 'Retry attempts',
282
+ retryBaseDelayMs: 'Retry base delay (ms)',
283
+ retryMaxDelayMs: 'Retry max delay (ms)',
284
+ concurrency: 'Worker concurrency',
285
+ circuitBreakerFailures: 'Circuit breaker failure limit',
286
+ circuitBreakerCooldownMinutes: 'Circuit breaker cooldown (min)',
287
+ ttlMinutes: 'Catalog cache TTL (min, 0=off)',
288
+ lifecycleRetentionRuns: 'Lifecycle retention runs',
289
+ saveReliability: 'Save reliability settings',
290
+ providerSettings: 'Provider configuration',
291
+ enableProvider: 'Enable synchronization',
292
+ providerAutoApply: 'Auto-apply for this provider',
293
+ customEndpoint: 'Custom API endpoint',
294
+ saveProvider: 'Save provider settings',
295
+ saveFailed: 'Save rejected by settings service',
296
+ scopeReadOnly: 'Settings are read-only in this environment',
297
+ firstSeen: 'First seen',
269
298
  }
270
299
 
271
300
  const zh = {
@@ -416,6 +445,25 @@ window.__ModuleLoader__.load({
416
445
  updateFailed: '更新失败: ',
417
446
  checkUpdateFailed: '检查更新失败',
418
447
  targetProvider: '服务商',
448
+ reliabilitySettings: '可靠性与引擎参数设置',
449
+ requestTimeoutMs: '请求超时时间(毫秒)',
450
+ retryAttempts: '重试次数',
451
+ retryBaseDelayMs: '重试基础延迟(毫秒)',
452
+ retryMaxDelayMs: '重试最大延迟(毫秒)',
453
+ concurrency: '并发任务数',
454
+ circuitBreakerFailures: '熔断器失败阈值',
455
+ circuitBreakerCooldownMinutes: '熔断冷却时间(分钟)',
456
+ ttlMinutes: '目录缓存 TTL(分钟,0为禁用)',
457
+ lifecycleRetentionRuns: '生命周期保留轮次',
458
+ saveReliability: '保存可靠性设置',
459
+ providerSettings: '服务商配置',
460
+ enableProvider: '启用同步',
461
+ providerAutoApply: '自动应用该服务商变更',
462
+ customEndpoint: '自定义 API 端点',
463
+ saveProvider: '保存服务商设置',
464
+ saveFailed: '设置服务拒绝保存',
465
+ scopeReadOnly: '当前环境为只读设置',
466
+ firstSeen: '首次发现',
419
467
  }
420
468
 
421
469
  const API = '/dsh-model-sync'
@@ -444,6 +492,41 @@ window.__ModuleLoader__.load({
444
492
  )
445
493
  }
446
494
 
495
+ function createTranslator(ctx, props, baseDict) {
496
+ const slotT = typeof props?.t === 'function' ? props.t : null
497
+ const bindT = typeof ctx?.locale?.bind === 'function' ? ctx.locale.bind(NS) : null
498
+ return new Proxy(baseDict, {
499
+ get(target, prop) {
500
+ if (typeof prop !== 'string') return target[prop]
501
+ if (slotT) {
502
+ try {
503
+ const res = slotT(prop)
504
+ if (res && res !== prop && res !== `${NS}.${prop}`) return res
505
+ } catch (_err) {
506
+ // fallback
507
+ }
508
+ }
509
+ if (bindT) {
510
+ try {
511
+ const res = bindT(prop)
512
+ if (res && res !== prop && res !== `${NS}.${prop}`) return res
513
+ } catch (_err) {
514
+ // fallback
515
+ }
516
+ }
517
+ if (typeof ctx?.locale?.t === 'function') {
518
+ try {
519
+ const res = ctx.locale.t(`${NS}.${prop}`) || ctx.locale.t(prop)
520
+ if (res && res !== prop && res !== `${NS}.${prop}`) return res
521
+ } catch (_err) {
522
+ // fallback
523
+ }
524
+ }
525
+ return target[prop] ?? prop
526
+ }
527
+ })
528
+ }
529
+
447
530
 
448
531
  function UpdaterSection({ t, api }) {
449
532
  const [updaterStatus, setUpdaterStatus] = React.useState(null)
@@ -522,9 +605,10 @@ window.__ModuleLoader__.load({
522
605
  )
523
606
  }
524
607
 
525
- function ModelSyncSection({ ctx, includeIntro = true }) {
608
+ function ModelSyncSection({ ctx, props, includeIntro = true }) {
526
609
  const locale = useLocale(ctx)
527
- const t = locale === 'zh' ? zh : en
610
+ const baseDict = locale === 'zh' ? zh : en
611
+ const t = React.useMemo(() => createTranslator(ctx, props, baseDict), [ctx, props, locale])
528
612
  const [status, setStatus] = React.useState(null)
529
613
  const [provider, setProvider] = React.useState('')
530
614
  const [dryRun, setDryRun] = React.useState(true)
@@ -581,6 +665,7 @@ window.__ModuleLoader__.load({
581
665
  )
582
666
  const snapStatus = snapshot?.status || 'loading'
583
667
  const stored = snapshot?.value || {}
668
+ const isWritable = snapshot?.writable !== false && scope?.writable !== false
584
669
  const [schedDraft, setSchedDraft] = React.useState(null)
585
670
  const [webhookDraft, setWebhookDraft] = React.useState(null)
586
671
  const [webhookMsg, setWebhookMsg] = React.useState('')
@@ -589,22 +674,70 @@ window.__ModuleLoader__.load({
589
674
  const [schedMsg, setSchedMsg] = React.useState('')
590
675
  const [schedErr, setSchedErr] = React.useState('')
591
676
 
677
+ const [reliabilityDraft, setReliabilityDraft] = React.useState(null)
678
+ const [reliabilityMsg, setReliabilityMsg] = React.useState('')
679
+ const [reliabilityErr, setReliabilityErr] = React.useState('')
680
+
681
+ const [providerConfigDraft, setProviderConfigDraft] = React.useState({})
682
+ const [providerConfigOpen, setProviderConfigOpen] = React.useState('')
683
+ const [providerConfigMsg, setProviderConfigMsg] = React.useState('')
684
+ const [providerConfigErr, setProviderConfigErr] = React.useState('')
685
+
592
686
  React.useEffect(() => {
593
- if (snapStatus === 'ready' && schedDraft === null) {
594
- setSchedDraft({ scheduleEnabled: stored.scheduleEnabled ?? false, intervalMinutes: stored.intervalMinutes ?? 60, autoApply: stored.autoApply ?? false })
687
+ if (snapStatus === 'ready') {
688
+ if (schedDraft === null) {
689
+ setSchedDraft({
690
+ scheduleEnabled: stored.scheduleEnabled ?? false,
691
+ intervalMinutes: stored.intervalMinutes ?? 60,
692
+ autoApply: stored.autoApply ?? false,
693
+ })
694
+ }
695
+ if (webhookDraft === null) {
696
+ setWebhookDraft({
697
+ diffWebhookUrl: stored.diffWebhookUrl ?? '',
698
+ diffWebhookSecretRef: stored.diffWebhookSecretRef ?? '',
699
+ diffWebhookSecret: stored.diffWebhookSecret ?? stored.diffWebhookSecretRef ?? '',
700
+ notifyOnModelAdded: stored.notifyOnModelAdded ?? true,
701
+ notifyOnModelDeprecated: stored.notifyOnModelDeprecated ?? true,
702
+ notifyOnPriceChanged: stored.notifyOnPriceChanged ?? true,
703
+ })
704
+ }
705
+ if (reliabilityDraft === null) {
706
+ setReliabilityDraft({
707
+ requestTimeoutMs: stored.requestTimeoutMs ?? 15000,
708
+ retryAttempts: stored.retryAttempts ?? 3,
709
+ retryBaseDelayMs: stored.retryBaseDelayMs ?? 250,
710
+ retryMaxDelayMs: stored.retryMaxDelayMs ?? 4000,
711
+ concurrency: stored.concurrency ?? 4,
712
+ circuitBreakerFailures: stored.circuitBreakerFailures ?? 3,
713
+ circuitBreakerCooldownMinutes: stored.circuitBreakerCooldownMinutes ?? 5,
714
+ ttlMinutes: stored.ttlMinutes ?? 0,
715
+ lifecycleRetentionRuns: stored.lifecycleRetentionRuns ?? 20,
716
+ })
717
+ }
595
718
  }
596
- }, [snapStatus, stored, schedDraft])
719
+ }, [snapStatus, stored, schedDraft, webhookDraft, reliabilityDraft])
597
720
 
598
721
  const saveWebhook = async () => {
599
722
  if (!scope || !webhookDraft) return
723
+ if (!isWritable) {
724
+ setWebhookErr(t.scopeReadOnly || 'Settings are read-only')
725
+ return
726
+ }
600
727
  setBusy(true); setWebhookErr(''); setWebhookMsg('')
601
728
  try {
602
- await scope.set('diffWebhookUrl', webhookDraft.diffWebhookUrl || '')
603
- await scope.set('diffWebhookSecretRef', webhookDraft.diffWebhookSecretRef || webhookDraft.diffWebhookSecret || '')
604
- await scope.set('diffWebhookSecret', webhookDraft.diffWebhookSecret || '')
605
- await scope.set('notifyOnModelAdded', !!webhookDraft.notifyOnModelAdded)
606
- await scope.set('notifyOnModelDeprecated', !!webhookDraft.notifyOnModelDeprecated)
607
- await scope.set('notifyOnPriceChanged', !!webhookDraft.notifyOnPriceChanged)
729
+ const results = await Promise.all([
730
+ scope.set('diffWebhookUrl', webhookDraft.diffWebhookUrl || ''),
731
+ scope.set('diffWebhookSecretRef', webhookDraft.diffWebhookSecretRef || webhookDraft.diffWebhookSecret || ''),
732
+ scope.set('diffWebhookSecret', webhookDraft.diffWebhookSecret || ''),
733
+ scope.set('notifyOnModelAdded', !!webhookDraft.notifyOnModelAdded),
734
+ scope.set('notifyOnModelDeprecated', !!webhookDraft.notifyOnModelDeprecated),
735
+ scope.set('notifyOnPriceChanged', !!webhookDraft.notifyOnPriceChanged),
736
+ ])
737
+ if (results.some((ok) => ok === false)) {
738
+ setWebhookErr(t.saveFailed || 'Save rejected by settings service')
739
+ return
740
+ }
608
741
  setWebhookMsg(t.webhookSaved)
609
742
  } catch (e) {
610
743
  setWebhookErr(String(e?.message ?? e))
@@ -641,11 +774,21 @@ window.__ModuleLoader__.load({
641
774
 
642
775
  const saveScheduler = async () => {
643
776
  if (!scope || !schedDraft) return
777
+ if (!isWritable) {
778
+ setSchedErr(t.scopeReadOnly || 'Settings are read-only')
779
+ return
780
+ }
644
781
  setBusy(true); setSchedErr(''); setSchedMsg('')
645
782
  try {
646
- await scope.set('scheduleEnabled', !!schedDraft.scheduleEnabled)
647
- await scope.set('intervalMinutes', Number(schedDraft.intervalMinutes) || 60)
648
- await scope.set('autoApply', !!schedDraft.autoApply)
783
+ const results = await Promise.all([
784
+ scope.set('scheduleEnabled', !!schedDraft.scheduleEnabled),
785
+ scope.set('intervalMinutes', Number(schedDraft.intervalMinutes) || 60),
786
+ scope.set('autoApply', !!schedDraft.autoApply),
787
+ ])
788
+ if (results.some((ok) => ok === false)) {
789
+ setSchedErr(t.saveFailed || 'Save rejected by settings service')
790
+ return
791
+ }
649
792
  setSchedMsg(t.schedulerSaved)
650
793
  load()
651
794
  } catch (e) {
@@ -655,6 +798,97 @@ window.__ModuleLoader__.load({
655
798
  }
656
799
  }
657
800
 
801
+ const saveReliability = async () => {
802
+ if (!scope || !reliabilityDraft) return
803
+ if (!isWritable) {
804
+ setReliabilityErr(t.scopeReadOnly || 'Settings are read-only')
805
+ return
806
+ }
807
+ setBusy(true); setReliabilityErr(''); setReliabilityMsg('')
808
+ try {
809
+ const results = await Promise.all([
810
+ scope.set('requestTimeoutMs', Number(reliabilityDraft.requestTimeoutMs) || 15000),
811
+ scope.set('retryAttempts', Number(reliabilityDraft.retryAttempts) || 3),
812
+ scope.set('retryBaseDelayMs', Number(reliabilityDraft.retryBaseDelayMs) || 250),
813
+ scope.set('retryMaxDelayMs', Number(reliabilityDraft.retryMaxDelayMs) || 4000),
814
+ scope.set('concurrency', Number(reliabilityDraft.concurrency) || 4),
815
+ scope.set('circuitBreakerFailures', Number(reliabilityDraft.circuitBreakerFailures) || 3),
816
+ scope.set('circuitBreakerCooldownMinutes', Number(reliabilityDraft.circuitBreakerCooldownMinutes) || 5),
817
+ scope.set('ttlMinutes', Number(reliabilityDraft.ttlMinutes) || 0),
818
+ scope.set('lifecycleRetentionRuns', Number(reliabilityDraft.lifecycleRetentionRuns) || 20),
819
+ ])
820
+ if (results.some((ok) => ok === false)) {
821
+ setReliabilityErr(t.saveFailed || 'Save rejected by settings service')
822
+ return
823
+ }
824
+ setReliabilityMsg(t.schedulerSaved)
825
+ } catch (e) {
826
+ setReliabilityErr(String(e?.message ?? e))
827
+ } finally {
828
+ setBusy(false)
829
+ }
830
+ }
831
+
832
+ const openProviderConfig = (row) => {
833
+ if (providerConfigOpen === row.provider) {
834
+ setProviderConfigOpen('')
835
+ return
836
+ }
837
+ setProviderConfigOpen(row.provider)
838
+ setProviderConfigMsg('')
839
+ setProviderConfigErr('')
840
+ const pList = Array.isArray(stored.providers) ? stored.providers : []
841
+ const pConf = pList.find((p) => p.provider === row.provider) || {}
842
+ const aRegistry = stored.adapterRegistry && typeof stored.adapterRegistry === 'object' ? stored.adapterRegistry : {}
843
+ const aConf = aRegistry[row.provider] || {}
844
+ setProviderConfigDraft({
845
+ enabled: pConf.enabled !== false,
846
+ autoApply: !!pConf.autoApply,
847
+ endpoint: aConf.endpoint || '',
848
+ })
849
+ }
850
+
851
+ const saveProviderConfig = async (providerName) => {
852
+ if (!scope) return
853
+ if (!isWritable) {
854
+ setProviderConfigErr(t.scopeReadOnly || 'Settings are read-only')
855
+ return
856
+ }
857
+ setBusy(true); setProviderConfigErr(''); setProviderConfigMsg('')
858
+ try {
859
+ const pList = Array.isArray(stored.providers) ? [...stored.providers] : []
860
+ const existingIdx = pList.findIndex((p) => p.provider === providerName)
861
+ const updatedEntry = {
862
+ ...(existingIdx >= 0 ? pList[existingIdx] : {}),
863
+ provider: providerName,
864
+ enabled: !!providerConfigDraft.enabled,
865
+ autoApply: !!providerConfigDraft.autoApply,
866
+ }
867
+ if (existingIdx >= 0) pList[existingIdx] = updatedEntry
868
+ else pList.push(updatedEntry)
869
+
870
+ const aRegistry = { ...(stored.adapterRegistry && typeof stored.adapterRegistry === 'object' ? stored.adapterRegistry : {}) }
871
+ aRegistry[providerName] = {
872
+ ...(aRegistry[providerName] || {}),
873
+ endpoint: (providerConfigDraft.endpoint || '').trim(),
874
+ }
875
+
876
+ const results = await Promise.all([
877
+ scope.set('providers', pList),
878
+ scope.set('adapterRegistry', aRegistry),
879
+ ])
880
+ if (results.some((ok) => ok === false)) {
881
+ setProviderConfigErr(t.saveFailed || 'Save rejected by settings service')
882
+ return
883
+ }
884
+ setProviderConfigMsg(t.schedulerSaved)
885
+ } catch (e) {
886
+ setProviderConfigErr(String(e?.message ?? e))
887
+ } finally {
888
+ setBusy(false)
889
+ }
890
+ }
891
+
658
892
  const doTry = (targetProvider, targetModel) => {
659
893
  const key = `${targetProvider}:${targetModel}`
660
894
  setTryState((current) => ({ ...current, [key]: { loading: true } }))
@@ -820,14 +1054,18 @@ window.__ModuleLoader__.load({
820
1054
 
821
1055
  React.useEffect(() => { ensureStyles(); load() }, [])
822
1056
 
823
- const execute = (targetProvider) => {
1057
+ const execute = (targetProvider, options = {}) => {
1058
+ const effectiveDryRun = options.dryRun !== undefined ? options.dryRun : dryRun
824
1059
  setBusy(true); setError(''); setPreview(null)
825
- fetch(`${API}/run`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ provider: targetProvider, dryRun, removeMissing }) })
1060
+ fetch(`${API}/run`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ provider: targetProvider, dryRun: effectiveDryRun, removeMissing }) })
826
1061
  .then(async (r) => { const body = await r.json(); if (!r.ok) throw new Error(body?.error?.message ?? `HTTP ${r.status}`); return body })
827
1062
  .then((body) => {
828
1063
  setStatus(body)
829
- if (dryRun && body.results) setPreview(body.results)
830
- else setPreview(null)
1064
+ if (effectiveDryRun && body.results) setPreview(body.results)
1065
+ else {
1066
+ setPreview(null)
1067
+ load()
1068
+ }
831
1069
  })
832
1070
  .catch((e) => setError(String(e?.message ?? e)))
833
1071
  .finally(() => setBusy(false))
@@ -915,6 +1153,7 @@ window.__ModuleLoader__.load({
915
1153
  })
916
1154
  }
917
1155
 
1156
+ const splitLines = (value) => [...new Set(String(value ?? '').split(/\r?\n/).map((item) => item.trim()).filter(Boolean))]
918
1157
  const splitValues = (value) => [...new Set(String(value ?? '').split(/[,\n]+/).map((item) => item.trim()).filter(Boolean))]
919
1158
  const capabilityValues = (value) => Object.fromEntries(splitValues(value).map((key) => [key, true]))
920
1159
  const validatePatterns = (patterns) => {
@@ -925,8 +1164,8 @@ window.__ModuleLoader__.load({
925
1164
  }
926
1165
 
927
1166
  const savePolicy = () => {
928
- const includeList = splitValues(policyDraft.include)
929
- const excludeList = splitValues(policyDraft.exclude)
1167
+ const includeList = splitLines(policyDraft.include)
1168
+ const excludeList = splitLines(policyDraft.exclude)
930
1169
  const badInclude = validatePatterns(includeList)
931
1170
  if (badInclude) { setError((t.invalidPattern || 'Invalid regex pattern: %s').replace('%s', badInclude)); return }
932
1171
  const badExclude = validatePatterns(excludeList)
@@ -1007,7 +1246,11 @@ window.__ModuleLoader__.load({
1007
1246
  }) : filteredChoicesBase
1008
1247
  const quickFilteredChoices = searchedChoices.filter((m) => {
1009
1248
  if (quickFilter.context100k && (!m.contextWindow || m.contextWindow < 100000)) return false
1010
- if (quickFilter.cheap && (!m.pricing?.inputPerToken || (m.pricing.inputPerToken * 1000000) > 1.0)) return false
1249
+ if (quickFilter.cheap) {
1250
+ const price = m.pricing?.inputPerToken
1251
+ if (typeof price !== 'number' || Number.isNaN(price)) return false
1252
+ if ((price * 1000000) > 1.0) return false
1253
+ }
1011
1254
  return true
1012
1255
  })
1013
1256
  return [...quickFilteredChoices].sort((a, b) => {
@@ -1017,7 +1260,11 @@ window.__ModuleLoader__.load({
1017
1260
  return pa - pb
1018
1261
  }
1019
1262
  if (sortBy === 'context') return (b.contextWindow ?? 0) - (a.contextWindow ?? 0)
1020
- if (sortBy === 'date') return (b.firstSeenAt ?? 0) - (a.firstSeenAt ?? 0)
1263
+ if (sortBy === 'date') {
1264
+ const da = a.firstSeenAt ? new Date(a.firstSeenAt).getTime() : (a.createdAt ? new Date(a.createdAt).getTime() : 0)
1265
+ const db = b.firstSeenAt ? new Date(b.firstSeenAt).getTime() : (b.createdAt ? new Date(b.createdAt).getTime() : 0)
1266
+ return db - da
1267
+ }
1021
1268
  return (a.name || a.id || '').localeCompare(b.name || b.id || '')
1022
1269
  })
1023
1270
  }, [choices, capabilityFilter, debouncedSearchQuery, quickFilter, sortBy])
@@ -1101,6 +1348,90 @@ window.__ModuleLoader__.load({
1101
1348
  )
1102
1349
  }
1103
1350
 
1351
+ const renderReliability = () => {
1352
+ return React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, width: '100%' } },
1353
+ snapStatus === 'loading' ? React.createElement('small', null, t.loading) : null,
1354
+ snapStatus === 'unavailable' ? React.createElement('small', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t.scopeUnavailable) : null,
1355
+ reliabilityDraft ? React.createElement('div', { style: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 10, marginTop: 4 } },
1356
+ React.createElement('label', { style: { display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12 } },
1357
+ React.createElement('span', null, t.requestTimeoutMs + ':'),
1358
+ React.createElement('input', {
1359
+ type: 'number',
1360
+ value: reliabilityDraft.requestTimeoutMs,
1361
+ onChange: (e) => setReliabilityDraft((d) => ({ ...d, requestTimeoutMs: Number(e.target.value) || 0 })),
1362
+ disabled: busy || !isWritable,
1363
+ className: 'dms-input',
1364
+ })
1365
+ ),
1366
+ React.createElement('label', { style: { display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12 } },
1367
+ React.createElement('span', null, t.retryAttempts + ':'),
1368
+ React.createElement('input', {
1369
+ type: 'number',
1370
+ value: reliabilityDraft.retryAttempts,
1371
+ onChange: (e) => setReliabilityDraft((d) => ({ ...d, retryAttempts: Number(e.target.value) || 0 })),
1372
+ disabled: busy || !isWritable,
1373
+ className: 'dms-input',
1374
+ })
1375
+ ),
1376
+ React.createElement('label', { style: { display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12 } },
1377
+ React.createElement('span', null, t.concurrency + ':'),
1378
+ React.createElement('input', {
1379
+ type: 'number',
1380
+ value: reliabilityDraft.concurrency,
1381
+ onChange: (e) => setReliabilityDraft((d) => ({ ...d, concurrency: Number(e.target.value) || 0 })),
1382
+ disabled: busy || !isWritable,
1383
+ className: 'dms-input',
1384
+ })
1385
+ ),
1386
+ React.createElement('label', { style: { display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12 } },
1387
+ React.createElement('span', null, t.circuitBreakerFailures + ':'),
1388
+ React.createElement('input', {
1389
+ type: 'number',
1390
+ value: reliabilityDraft.circuitBreakerFailures,
1391
+ onChange: (e) => setReliabilityDraft((d) => ({ ...d, circuitBreakerFailures: Number(e.target.value) || 0 })),
1392
+ disabled: busy || !isWritable,
1393
+ className: 'dms-input',
1394
+ })
1395
+ ),
1396
+ React.createElement('label', { style: { display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12 } },
1397
+ React.createElement('span', null, t.circuitBreakerCooldownMinutes + ':'),
1398
+ React.createElement('input', {
1399
+ type: 'number',
1400
+ value: reliabilityDraft.circuitBreakerCooldownMinutes,
1401
+ onChange: (e) => setReliabilityDraft((d) => ({ ...d, circuitBreakerCooldownMinutes: Number(e.target.value) || 0 })),
1402
+ disabled: busy || !isWritable,
1403
+ className: 'dms-input',
1404
+ })
1405
+ ),
1406
+ React.createElement('label', { style: { display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12 } },
1407
+ React.createElement('span', null, t.ttlMinutes + ':'),
1408
+ React.createElement('input', {
1409
+ type: 'number',
1410
+ value: reliabilityDraft.ttlMinutes,
1411
+ onChange: (e) => setReliabilityDraft((d) => ({ ...d, ttlMinutes: Number(e.target.value) || 0 })),
1412
+ disabled: busy || !isWritable,
1413
+ className: 'dms-input',
1414
+ })
1415
+ ),
1416
+ React.createElement('label', { style: { display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12 } },
1417
+ React.createElement('span', null, t.lifecycleRetentionRuns + ':'),
1418
+ React.createElement('input', {
1419
+ type: 'number',
1420
+ value: reliabilityDraft.lifecycleRetentionRuns,
1421
+ onChange: (e) => setReliabilityDraft((d) => ({ ...d, lifecycleRetentionRuns: Number(e.target.value) || 0 })),
1422
+ disabled: busy || !isWritable,
1423
+ className: 'dms-input',
1424
+ })
1425
+ ),
1426
+ React.createElement('div', { style: { gridColumn: '1 / -1', display: 'flex', alignItems: 'center', gap: 8, marginTop: 4 } },
1427
+ button(t.saveReliability, saveReliability, 'primary'),
1428
+ reliabilityMsg ? React.createElement('small', { style: { color: 'var(--dsw-alias-state-success-primary)' } }, reliabilityMsg) : null,
1429
+ reliabilityErr ? React.createElement('small', { style: { color: 'var(--dsw-alias-state-error-primary)' } }, reliabilityErr) : null
1430
+ )
1431
+ ) : null
1432
+ )
1433
+ }
1434
+
1104
1435
  const renderAliases = () => {
1105
1436
  const aliasMap = status?.aliases ?? {}
1106
1437
  const entries = Object.entries(aliasMap)
@@ -1241,7 +1572,7 @@ window.__ModuleLoader__.load({
1241
1572
  )
1242
1573
  }),
1243
1574
  React.createElement('div', { style: { marginTop: 8 } },
1244
- button(t.previewApply, () => execute(provider || undefined), 'primary'),
1575
+ button(t.previewApply, () => execute(provider || undefined, { dryRun: false }), 'primary'),
1245
1576
  button(t.previewDismiss, () => setPreview(null))
1246
1577
  )
1247
1578
  ) : null,
@@ -1270,9 +1601,51 @@ window.__ModuleLoader__.load({
1270
1601
  ' ', healthBadge, ' ', staleBadge
1271
1602
  ),
1272
1603
  button(t.selectModels, () => openSelection(row)),
1273
- button(t.policy, () => openPolicy(row))
1604
+ button(t.policy, () => openPolicy(row)),
1605
+ button(t.providerSettings || 'Configure', () => openProviderConfig(row))
1274
1606
  )
1275
1607
 
1608
+ const providerConfigEditor = providerConfigOpen === row.provider ? React.createElement('div', { className: 'dms-editor' },
1609
+ React.createElement('strong', null, `${t.providerSettings}: ${row.provider}`),
1610
+ React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: 8, margin: '8px 0' } },
1611
+ React.createElement('label', { style: { display: 'flex', alignItems: 'center', gap: 7, fontSize: 12, cursor: 'pointer' } },
1612
+ React.createElement('input', {
1613
+ type: 'checkbox',
1614
+ checked: !!providerConfigDraft.enabled,
1615
+ onChange: (e) => setProviderConfigDraft((d) => ({ ...d, enabled: e.target.checked })),
1616
+ disabled: busy || !isWritable
1617
+ }),
1618
+ t.enableProvider
1619
+ ),
1620
+ React.createElement('label', { style: { display: 'flex', alignItems: 'center', gap: 7, fontSize: 12, cursor: 'pointer' } },
1621
+ React.createElement('input', {
1622
+ type: 'checkbox',
1623
+ checked: !!providerConfigDraft.autoApply,
1624
+ onChange: (e) => setProviderConfigDraft((d) => ({ ...d, autoApply: e.target.checked })),
1625
+ disabled: busy || !isWritable
1626
+ }),
1627
+ t.providerAutoApply
1628
+ ),
1629
+ React.createElement('label', { style: { display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12 } },
1630
+ React.createElement('span', null, t.customEndpoint + ':'),
1631
+ React.createElement('input', {
1632
+ type: 'text',
1633
+ placeholder: 'https://api.example.com/v1',
1634
+ value: providerConfigDraft.endpoint || '',
1635
+ onChange: (e) => setProviderConfigDraft((d) => ({ ...d, endpoint: e.target.value })),
1636
+ disabled: busy || !isWritable,
1637
+ className: 'dms-input'
1638
+ })
1639
+ ),
1640
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 4 } },
1641
+ button(t.saveProvider, () => saveProviderConfig(row.provider), 'primary'),
1642
+ button(t.cancel, () => setProviderConfigOpen('')),
1643
+ providerConfigMsg ? React.createElement('small', { style: { color: 'var(--dsw-alias-state-success-primary)' } }, providerConfigMsg) : null,
1644
+ providerConfigErr ? React.createElement('small', { style: { color: 'var(--dsw-alias-state-error-primary)' } }, providerConfigErr) : null
1645
+ )
1646
+ )
1647
+ ) : null
1648
+
1276
1649
  const selectionEditor = selectionProvider === row.provider ? React.createElement('div', { className: 'dms-editor' },
1277
1650
  React.createElement('strong', null, `${t.selectModels}: ${selectionProvider}`),
1278
1651
  React.createElement('div', { style: { display: 'flex', gap: 8, margin: '8px 0', flexWrap: 'wrap' } },
@@ -1382,7 +1755,7 @@ window.__ModuleLoader__.load({
1382
1755
  )
1383
1756
  ) : null
1384
1757
 
1385
- return React.createElement('div', { key: row.provider, className: 'dms-provider-wrap' }, providerCard, selectionEditor, policyEditor)
1758
+ return React.createElement('div', { key: row.provider, className: 'dms-provider-wrap' }, providerCard, providerConfigEditor, selectionEditor, policyEditor)
1386
1759
  }),
1387
1760
  React.createElement('div', { className: 'dms-section-card dms-section' },
1388
1761
  React.createElement('div', { className: 'dms-section-title' }, React.createElement('strong', null, t.modelAliases)),
@@ -1408,6 +1781,10 @@ window.__ModuleLoader__.load({
1408
1781
  React.createElement('div', { className: 'dms-section-title' }, React.createElement('strong', null, t.schedulerSettings)),
1409
1782
  renderScheduler()
1410
1783
  ),
1784
+ React.createElement('div', { className: 'dms-section-card dms-section' },
1785
+ React.createElement('div', { className: 'dms-section-title' }, React.createElement('strong', null, t.reliabilitySettings)),
1786
+ renderReliability()
1787
+ ),
1411
1788
  React.createElement('div', { className: 'dms-section-card dms-section' },
1412
1789
  React.createElement('div', { className: 'dms-section-title' }, React.createElement('strong', null, t.history)),
1413
1790
  renderHistory()
@@ -1419,9 +1796,10 @@ window.__ModuleLoader__.load({
1419
1796
  )
1420
1797
  }
1421
1798
 
1422
- function ModelSyncCard({ ctx, view }) {
1799
+ function ModelSyncCard({ ctx, view, ...props }) {
1423
1800
  const locale = useLocale(ctx)
1424
- const t = locale === 'zh' ? zh : en
1801
+ const baseDict = locale === 'zh' ? zh : en
1802
+ const t = React.useMemo(() => createTranslator(ctx, props, baseDict), [ctx, props, locale])
1425
1803
  const page = view === 'page'
1426
1804
  const [open, setOpen] = React.useState(!!page)
1427
1805
  const [cardStatus, setCardStatus] = React.useState(null)
@@ -1455,12 +1833,18 @@ window.__ModuleLoader__.load({
1455
1833
  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' })
1456
1834
  )
1457
1835
  ),
1458
- (page || open) ? React.createElement('div', { className: 'dms-card-body' }, React.createElement(ErrorBoundary, null, React.createElement(ModelSyncSection, { ctx, includeIntro: false }))) : null
1836
+ (page || open) ? React.createElement('div', { className: 'dms-card-body' }, React.createElement(ErrorBoundary, null, React.createElement(ModelSyncSection, { ctx, props, includeIntro: false }))) : null
1459
1837
  )
1460
1838
  }
1461
1839
 
1462
1840
  function apply(ctx) {
1463
1841
  ctx.effect(() => ctx.locale.register(NS, { en, zh }), 'dsh-model-sync: dictionaries')
1842
+ ctx.effect(() => {
1843
+ const tag = ensureStyles()
1844
+ return () => {
1845
+ if (tag && tag.parentNode) tag.parentNode.removeChild(tag)
1846
+ }
1847
+ }, 'dsh-model-sync: styles')
1464
1848
  const trySlot = (name, register) => {
1465
1849
  try {
1466
1850
  if (typeof ctx.slots?.inject === 'function') ctx.slots.inject(name, register)
package/lib/models.js CHANGED
@@ -277,6 +277,27 @@ function normalizePricing(row) {
277
277
  return pricing
278
278
  }
279
279
 
280
+ function normalizeDate(val) {
281
+ if (val === undefined || val === null || val === '') return undefined
282
+ if (typeof val === 'number' && Number.isFinite(val)) {
283
+ const ms = val < 1e11 ? val * 1000 : val
284
+ const d = new Date(ms)
285
+ return Number.isNaN(d.getTime()) ? undefined : d.toISOString()
286
+ }
287
+ if (typeof val === 'string' && val.trim()) {
288
+ const s = val.trim()
289
+ if (/^\d+$/.test(s)) {
290
+ const n = Number(s)
291
+ const ms = n < 1e11 ? n * 1000 : n
292
+ const d = new Date(ms)
293
+ return Number.isNaN(d.getTime()) ? undefined : d.toISOString()
294
+ }
295
+ const d = new Date(s)
296
+ return Number.isNaN(d.getTime()) ? undefined : d.toISOString()
297
+ }
298
+ return undefined
299
+ }
300
+
280
301
  export function normalizeModel(provider, row) {
281
302
  if (typeof row === 'string') {
282
303
  row = { id: row }
@@ -297,10 +318,14 @@ export function normalizeModel(provider, row) {
297
318
  const lifecycle = normalizeLifecycle(row)
298
319
  const input = normalizeInput(row)
299
320
  const compat = normalizeCompat(effectiveProvider, row, id)
321
+ const firstSeenAt = normalizeDate(row.firstSeenAt)
322
+ const createdAt = normalizeDate(row.createdAt ?? row.created)
300
323
  return {
301
324
  provider: effectiveProvider || provider,
302
325
  id,
303
326
  name,
327
+ ...(firstSeenAt === undefined ? {} : { firstSeenAt }),
328
+ ...(createdAt === undefined ? {} : { createdAt }),
304
329
  ...(contextWindow === undefined ? {} : { contextWindow }),
305
330
  ...(maxTokens === undefined ? {} : { maxTokens }),
306
331
  ...(row.description ? { description: String(row.description) } : {}),
package/lib/reconcile.js CHANGED
@@ -22,6 +22,8 @@ export function reconcileModels(provider, current, discovered, {
22
22
  const merged = {
23
23
  ...row,
24
24
  ...fresh,
25
+ ...(row.firstSeenAt || fresh.firstSeenAt ? { firstSeenAt: row.firstSeenAt ?? fresh.firstSeenAt } : {}),
26
+ ...(row.createdAt || fresh.createdAt ? { createdAt: row.createdAt ?? fresh.createdAt } : {}),
25
27
  ...(row.capabilities || fresh.capabilities ? {
26
28
  capabilities: { ...(fresh.capabilities ?? {}), ...(row.capabilities ?? {}) },
27
29
  } : {}),
@@ -326,6 +326,19 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
326
326
  const current = profileOf(settings, row.provider).models ?? row.models
327
327
  const fullModels = normalizeModels(row.provider, discovered.models)
328
328
  const lifecycleBefore = normalizeModels(row.provider, config.modelCatalogs?.[row.provider] ?? current)
329
+ const existingMap = new Map(lifecycleBefore.map((m) => [m.id, m]))
330
+ const nowIso = new Date(startedAt).toISOString()
331
+ for (const model of fullModels) {
332
+ const prev = existingMap.get(model.id)
333
+ if (prev?.firstSeenAt) {
334
+ model.firstSeenAt = prev.firstSeenAt
335
+ } else if (!model.firstSeenAt) {
336
+ model.firstSeenAt = nowIso
337
+ }
338
+ if (prev?.createdAt && !model.createdAt) {
339
+ model.createdAt = prev.createdAt
340
+ }
341
+ }
329
342
  const lifecycle = updateLifecycle(config.modelLifecycle?.[row.provider], lifecycleBefore, fullModels, {
330
343
  runNumber: lifecycleRun,
331
344
  ...lifecycleConfig,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-model-sync",
3
- "version": "0.5.11",
3
+ "version": "0.5.12",
4
4
  "description": "Automatic model catalog synchronization for API-key DeepSeek Harness providers.",
5
5
  "keywords": [
6
6
  "deepseek-harness",