@goodandready/dsh-model-sync 0.3.4 → 0.3.6
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 +167 -77
- package/README.ru.md +152 -0
- package/README.zh.md +75 -0
- package/lib/adapter-registry.js +10 -3
- package/lib/client.js +12 -9
- package/lib/generic-adapter.js +3 -2
- package/lib/history.js +20 -2
- package/lib/http.js +13 -2
- package/lib/index.js +1 -1
- package/lib/inventory.js +3 -2
- package/lib/lifecycle.js +1 -1
- package/lib/models.js +24 -2
- package/lib/reconcile.js +2 -2
- package/lib/reliability.js +34 -2
- package/lib/scheduler.js +2 -2
- package/lib/synchronizer.js +43 -15
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,85 +1,175 @@
|
|
|
1
|
-
# @goodandready/dsh-model-sync
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
1
|
+
# 📦 @goodandready/dsh-model-sync
|
|
2
|
+
|
|
3
|
+
<div align="center">
|
|
4
|
+
|
|
5
|
+
<h3>Dynamic LLM Model Catalog Synchronization & Automated Balance Monitoring for DeepSeek Harness</h3>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="https://www.npmjs.com/package/@goodandready/dsh-model-sync"><img src="https://img.shields.io/npm/v/@goodandready/dsh-model-sync.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
|
|
9
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-10b981.svg?style=for-the-badge&color=10b981&labelColor=064e3b" alt="license"></a>
|
|
10
|
+
<a href="https://github.com/topics/dsh-plugin"><img src="https://img.shields.io/badge/DSH-Plugin-8b5cf6.svg?style=for-the-badge&labelColor=2e1065" alt="DSH Plugin"></a>
|
|
11
|
+
<a href="https://nodejs.org"><img src="https://img.shields.io/badge/Node-20%2B-f59e0b.svg?style=for-the-badge&labelColor=451a03" alt="Node version"></a>
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
<p align="center">
|
|
15
|
+
<a href="https://goodandready.app/"><img src="https://img.shields.io/badge/All_Author_Projects-goodandready.app-ff4500.svg?style=for-the-badge&logo=rocket&logoColor=white&labelColor=1a1a2e" alt="All Author Projects"></a>
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
<p align="center">
|
|
19
|
+
<a href="README.md"><b>🇬🇧 English</b></a> •
|
|
20
|
+
<a href="README.ru.md"><b>🇷🇺 Русский</b></a> •
|
|
21
|
+
<a href="README.zh.md"><b>🇨🇳 中文说明</b></a>
|
|
22
|
+
</p>
|
|
23
|
+
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## ⚡ Overview
|
|
29
|
+
|
|
30
|
+
**`dsh-model-sync`** keeps your **DeepSeek Harness** model catalog up-to-date with upstream AI providers.
|
|
31
|
+
|
|
32
|
+
Instead of manually editing YAML configs whenever a provider launches a new model, adjusts context window limits, or changes pricing, `dsh-model-sync` automatically discovers new releases, updates capability metadata (`vision`, `tools`, `reasoning`, `embeddings`), tracks account balances, and reconciles models on the fly without server restarts.
|
|
33
|
+
|
|
34
|
+
```mermaid
|
|
35
|
+
graph LR
|
|
36
|
+
subgraph Trigger [Scheduling & Manual Trigger]
|
|
37
|
+
Cron[⏰ Background Polling Scheduler] --> Engine[dsh-model-sync Core Engine]
|
|
38
|
+
WebUI[🖥️ Settings: Sync Now Button] --> Engine
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
subgraph Providers [25+ Upstream Providers]
|
|
42
|
+
Engine --> Registry{Adapter Registry}
|
|
43
|
+
Registry -->|Bearer Auth| P1[OpenAI / DeepSeek / OpenRouter / Groq]
|
|
44
|
+
Registry -->|x-api-key Auth| P2[Anthropic Claude / Custom Gateways]
|
|
45
|
+
Registry -->|query-key Auth| P3[Google Gemini]
|
|
46
|
+
Registry -->|Local Probe| P4[Local Ollama / vLLM / SGLang]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
subgraph Reconcile [Catalog Reconciler & Auditing]
|
|
50
|
+
P1 --> Normalizer[Model Normalizer & Capabilities Tagger]
|
|
51
|
+
P2 --> Normalizer
|
|
52
|
+
P3 --> Normalizer
|
|
53
|
+
P4 --> Normalizer
|
|
54
|
+
Normalizer --> Diff[Sync Diff Tracker: Added / Deprecated]
|
|
55
|
+
Diff --> Catalog[Active DSH Models Catalog]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
style Trigger fill:#1e1e2e,stroke:#89b4fa,stroke-width:2px,color:#cdd6f4
|
|
59
|
+
style Providers fill:#181825,stroke:#cba6f7,stroke-width:2px,color:#cdd6f4
|
|
60
|
+
style Reconcile fill:#11111b,stroke:#a6e3a1,stroke-width:2px,color:#cdd6f4
|
|
32
61
|
```
|
|
33
62
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## ✨ Key Features
|
|
66
|
+
|
|
67
|
+
* 🔄 **Automated Catalog Discovery**: Syncs model lists, aliases, context windows, and capability flags (`vision`, `tools`, `reasoning`, `embeddings`) across 25+ providers.
|
|
68
|
+
* 🌐 **25+ Built-in Provider Adapters**: Pre-configured discovery for OpenAI, Anthropic, Google, DeepSeek, xAI, OpenRouter, Groq, Mistral, Cerebras, Fireworks, HuggingFace, Moonshot, NVIDIA, Qwen, Together, Xiaomi MiMo, SiliconFlow, and local Ollama.
|
|
69
|
+
* 🔌 **Generic / Custom Adapter Support**: Connect arbitrary OpenAI-compatible `/v1/models` endpoints with configurable auth (`bearer`, `x-api-key`, `query-key`, `none`).
|
|
70
|
+
* 📊 **Balance & Quota Monitoring**: Queries upstream billing endpoints (where supported) to report account credit balances and prevent unexpected exhaustion.
|
|
71
|
+
* 📜 **Sync History & Diff Auditing**: Tracks all newly discovered models, deprecated IDs, and modified capabilities with timestamped diff logs in the Web UI.
|
|
72
|
+
* 🖥️ **Full Web GUI Panel (**Settings → Model Synchronization**)**:
|
|
73
|
+
* Live status cards per provider with active model counters;
|
|
74
|
+
* Instant "Sync Now" button for on-demand catalog refreshes;
|
|
75
|
+
* Provider enable/disable toggles and custom endpoint inputs.
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## 🛠️ Supported Discovery Providers
|
|
80
|
+
|
|
81
|
+
| Provider Identifier | Authentication Format | Capabilities Auto-Detection |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| `openai` | Bearer Token | `vision`, `tools`, `reasoning`, `embeddings` |
|
|
84
|
+
| `anthropic` | `x-api-key` header | `vision`, `tools`, `reasoning` |
|
|
85
|
+
| `google` | Query parameter / Key | `vision`, `tools`, `reasoning`, `embeddings` |
|
|
86
|
+
| `deepseek` | Bearer Token | `tools`, `reasoning` |
|
|
87
|
+
| `xai` | Bearer Token | `vision`, `tools`, `reasoning` |
|
|
88
|
+
| `openrouter` | Bearer Token | Multi-vendor model catalog with pricing & context limits |
|
|
89
|
+
| `groq` | Bearer Token | Ultra-fast inference catalog |
|
|
90
|
+
| `mistral` | Bearer Token | `tools`, `reasoning`, `vision` |
|
|
91
|
+
| `fireworks` | Bearer Token | Open-weights inference endpoints |
|
|
92
|
+
| `huggingface` | Bearer Token | Serverless Inference API catalog |
|
|
93
|
+
| `together` | Bearer Token | Open-source model catalog |
|
|
94
|
+
| `ollama` | Local HTTP (`none`) | Local offline model discovery (`/api/tags`) |
|
|
95
|
+
| `custom` | Configurable | Any custom OpenAI or Google compatible gateway |
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## 📦 Quick Installation
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
dsh plugin --profile web add @goodandready/dsh-model-sync
|
|
38
103
|
```
|
|
39
104
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
105
|
+
> [!IMPORTANT]
|
|
106
|
+
> Restart DSH Web UI after installation (`systemctl --user restart dsh-web`) to activate background catalog synchronization.
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## 🛠️ Enhancements & Fixes in v0.3.6
|
|
111
|
+
|
|
112
|
+
* 🔑 **Universal Credential Resolution (`credentialRef` & `apiKeyRef`)**:
|
|
113
|
+
* Added full support for modern DSH `credentialRef` and `apiKeyRef` references across inventory detection, provider adapters, and OpenAI-compatible generic routes.
|
|
114
|
+
* Providers configured via DSH Credentials Service are now accurately discovered and authenticated without requiring legacy `apiKeyEnv`.
|
|
115
|
+
* ♻️ **Lifecycle Cleanup for Deprecated Models**:
|
|
116
|
+
* Fixed an issue where models previously marked as `deprecated` would remain indefinitely in settings when removed by the upstream provider. When `removeMissing: true` is enabled, missing deprecated models now cleanly transition to `removed` status once the grace period expires.
|
|
117
|
+
* ⏰ **Accurate Scheduler Inactive Status**:
|
|
118
|
+
* Corrected `scheduler.status()` to return `nextRunAt: null` when background synchronization is disabled, avoiding misleading run times in the Web UI.
|
|
119
|
+
* 🛡️ **Web UI Resilience & Big Catalog Scalability**:
|
|
120
|
+
* Eliminated potential UI crashes during model sorting by safely handling missing model display names.
|
|
121
|
+
* Replaced spread operations on model arrays with iterative aggregations, preventing call stack overflow (`RangeError`) on massive catalogs (10,000+ models).
|
|
122
|
+
* Surfaced detailed probe error messages in the UI when testing individual models via the `▶` button.
|
|
123
|
+
* Localized cache expiration and offline status strings in Russian and English.
|
|
124
|
+
* ⚡ **Deterministic Model Diffing & HTTP Stream Safety**:
|
|
125
|
+
* Implemented stable key-sorted serialization in `diffModels` and `sameModel` to eliminate false-positive diffs caused by non-deterministic JSON key order.
|
|
126
|
+
* Added early stream destruction on oversized HTTP request bodies to prevent memory bloat.
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## 🚀 Enhancements in v0.3.5
|
|
131
|
+
|
|
132
|
+
* ⏱️ **Adaptive Rate Limit & Retry-After Handling**:
|
|
133
|
+
* Automatically parses HTTP headers `Retry-After` (in seconds or RFC dates) and `x-ratelimit-reset`.
|
|
134
|
+
* Exponential backoff prioritizes the exact delay requested by upstream providers, eliminating unnecessary throttling.
|
|
135
|
+
* Re-classified HTTP 429 errors under a dedicated `rate-limit` diagnostic label.
|
|
136
|
+
* 🔄 **Optimistic Concurrency & Conflict Auto-Recovery**:
|
|
137
|
+
* Added automated retry loop for `SETTINGS_CONFLICT` (HTTP 409). When concurrent settings updates occur in DSH, the plugin seamlessly re-fetches the latest revision and reapplies the catalog mutations.
|
|
138
|
+
* 🎯 **Advanced Web UI Picker & Filtering**:
|
|
139
|
+
* **Tokenized Smart Search**: Multi-word queries (`claude 3.5 sonnet`) match across model IDs, display names, tags, and descriptions simultaneously.
|
|
140
|
+
* **Quick Filter Chips**: One-click toggles for `Context ≥ 100k` and `Cheap ≤ $1/1M` in addition to capability toggles (`Vision`, `Tools`, `Reasoning`, `Embeddings`).
|
|
141
|
+
* **Batch Selection Controls**: One-click buttons to *Select all shown*, *Deselect all shown*, and *Invert selection*.
|
|
142
|
+
* **Chunked Rendering / Pagination**: Renders models in batches of 50 to maintain smooth 60fps UI performance even with catalogs containing 1,000+ models.
|
|
143
|
+
* **Deprecation Badges**: Models with scheduled retirement dates display an explicit countdown badge (`Deprecates in Nd`).
|
|
144
|
+
* 💾 **Storage & Metadata Optimizations**:
|
|
145
|
+
* Robust context limit parser supporting suffix multipliers (`128k`, `1M`, `128,000`).
|
|
146
|
+
* Automatic history compaction: historical snapshots older than the 5 latest sync runs have heavy before/after dumps pruned while retaining summary diffs, keeping `settings.yaml` ultra-compact.
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## ⚙️ Configuration Reference (`settings.yaml`)
|
|
151
|
+
|
|
152
|
+
```yaml
|
|
153
|
+
dsh-model-sync:
|
|
154
|
+
enabled: true
|
|
155
|
+
syncIntervalMinutes: 60
|
|
156
|
+
autoReconcile: true
|
|
157
|
+
providers:
|
|
158
|
+
openrouter:
|
|
159
|
+
enabled: true
|
|
160
|
+
keyEnv: OPENROUTER_API_KEY
|
|
161
|
+
deepseek:
|
|
162
|
+
enabled: true
|
|
163
|
+
keyEnv: DEEPSEEK_API_KEY
|
|
164
|
+
custom:
|
|
165
|
+
enabled: false
|
|
166
|
+
baseURL: http://127.0.0.1:8000/v1
|
|
167
|
+
authType: bearer
|
|
168
|
+
keyEnv: CUSTOM_API_KEY
|
|
46
169
|
```
|
|
47
170
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
A dry-run renders a per-provider diff preview (added, removed, and metadata-changed models). Use the explicit **Apply preview** action to write the catalog; stale-model removal still requires its confirmation.
|
|
51
|
-
|
|
52
|
-
The model picker exposes a capability filter based only on explicit normalized metadata (`vision`, `tools`, `reasoning`, and `embeddings`). Multiple selected capabilities use an all-of match, and filtering does not discard hidden selections.
|
|
53
|
-
|
|
54
|
-
The settings card uses the same spacing, typography, borders, and theme variables as the built-in DSH plugin cards in both light and dark themes.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
The plugin-owned `adapterRegistry` setting adds an opt-in declarative adapter without copying code. A provider entry can specify `endpoint`, `auth` (`bearer`, `x-api-key`, `query-key`, or `none`), `parser`, `modelsPath`, field paths, and explicit capability mappings. Endpoints must be HTTPS except loopback HTTP; credential-like static headers and URL query/fragment data are rejected. Existing built-in descriptors and OpenAI-compatible routes remain the default. Runtime integrations can register an explicit adapter implementation through the registry escape hatch, provided it implements `discover` and `health`.
|
|
58
|
-
|
|
59
|
-
The full catalog discovered by an applied run is cached in the plugin's own settings
|
|
60
|
-
separately from the model allowlist. After a restart, the model chooser can show the
|
|
61
|
-
full last-known catalog while the standard DSH picker still exposes only the selected
|
|
62
|
-
models. Dry-run remains read-only and does not persist this cache.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
`GET /dsh-model-sync/report` returns the latest stable aggregate report. It distinguishes success, partial success, failure, and empty runs, includes provider/model counts, and labels whether the source was manual or scheduled. `GET /dsh-model-sync/notifications` returns the bounded in-app notification ledger; repeated identical failures update an occurrence counter instead of creating noise. `POST /dsh-model-sync/notifications/read` and `POST /dsh-model-sync/notifications/acknowledge` accept `{ "id": "sync-notice-..." }` and persist the corresponding state for applied runs. Report and notification text is redacted before it leaves the synchronizer; no webhook or MCP dependency is required.
|
|
66
|
-
|
|
67
|
-
`GET /dsh-model-sync/credentials` returns bounded diagnostics for configured provider credential references. It calls the DSH credentials `describe` seam only, reports masked reference labels, source/configured state, rotation order, and the last safe request outcome. It never resolves or returns a credential value. `POST /dsh-model-sync/credentials/check` runs the health probe and returns the same diagnostics in one response; a failed reference is recorded per provider without hiding the remaining rotation entries.
|
|
68
|
-
|
|
69
|
-
`GET /dsh-model-sync/history` returns the bounded history of applied runs. Use
|
|
70
|
-
`?provider=<id>&details=true` for one provider's full snapshots and metadata diff.
|
|
71
|
-
`POST /dsh-model-sync/history/rollback` accepts `{ "historyId": "sync-…", "provider": "openai" }`
|
|
72
|
-
and restores only that provider's catalog; it never changes the model allowlist.
|
|
73
|
-
History is written only by non-dry-run synchronization, is retained according to
|
|
74
|
-
`historyLimit` (1–200, default 50), and redacts credential-shaped error values.
|
|
75
|
-
Rename detection requires an explicit provider `aliases` or `previousIds` field;
|
|
76
|
-
model-id similarity is never guessed.
|
|
171
|
+
---
|
|
77
172
|
|
|
173
|
+
## 📄 License
|
|
78
174
|
|
|
79
|
-
|
|
80
|
-
moves it to `removed` after the configured `staleGraceRuns` and an explicit
|
|
81
|
-
removal confirmation. Provider-declared `deprecated`/`deprecationDate`/
|
|
82
|
-
`expiration_date` metadata is retained as an audit status. Repeated transport or
|
|
83
|
-
credential failures do not advance lifecycle counters. The full catalog cache
|
|
84
|
-
retains stale entries until the user confirms removal; the model picker defaults
|
|
85
|
-
to active models while explicit selections may still name a retained model.
|
|
175
|
+
MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
|
package/README.ru.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# 📦 @goodandready/dsh-model-sync
|
|
2
|
+
|
|
3
|
+
<div align="center">
|
|
4
|
+
|
|
5
|
+
<h3>Динамическая синхронизация каталогов моделей и автоматический мониторинг баланса для DeepSeek Harness</h3>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="https://www.npmjs.com/package/@goodandready/dsh-model-sync"><img src="https://img.shields.io/npm/v/@goodandready/dsh-model-sync.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
|
|
9
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-10b981.svg?style=for-the-badge&color=10b981&labelColor=064e3b" alt="license"></a>
|
|
10
|
+
<a href="https://github.com/topics/dsh-plugin"><img src="https://img.shields.io/badge/DSH-Plugin-8b5cf6.svg?style=for-the-badge&labelColor=2e1065" alt="DSH Plugin"></a>
|
|
11
|
+
<a href="https://nodejs.org"><img src="https://img.shields.io/badge/Node-20%2B-f59e0b.svg?style=for-the-badge&labelColor=451a03" alt="Node version"></a>
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
<p align="center">
|
|
15
|
+
<a href="https://goodandready.app/"><img src="https://img.shields.io/badge/Все_проекты_автора-goodandready.app-ff4500.svg?style=for-the-badge&logo=rocket&logoColor=white&labelColor=1a1a2e" alt="Все проекты автора"></a>
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
<p align="center">
|
|
19
|
+
<a href="README.md"><b>🇬🇧 English</b></a> •
|
|
20
|
+
<a href="README.ru.md"><b>🇷🇺 Русский</b></a> •
|
|
21
|
+
<a href="README.zh.md"><b>🇨🇳 中文说明</b></a>
|
|
22
|
+
</p>
|
|
23
|
+
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## ⚡ Обзор
|
|
29
|
+
|
|
30
|
+
**`dsh-model-sync`** обеспечивает автоматическую актуализацию каталога моделей **DeepSeek Harness** напрямую от подключенных провайдеров.
|
|
31
|
+
|
|
32
|
+
Вместо ручного редактирования YAML-файлов при выходе новых моделей, смене лимитов контекста или цен, плагин автоматически обнаруживает релизы, обновляет флаги возможностей (`vision`, `tools`, `reasoning`, `embeddings`), отслеживает остатки баланса и обновляет каталог на лету без перезапуска сервера.
|
|
33
|
+
|
|
34
|
+
```mermaid
|
|
35
|
+
graph LR
|
|
36
|
+
subgraph Trigger [Планировщик и ручной запуск]
|
|
37
|
+
Cron[⏰ Фоновый планировщик опроса] --> Engine[Ядро dsh-model-sync]
|
|
38
|
+
WebUI[🖥️ Кнопка «Синхронизировать сейчас»] --> Engine
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
subgraph Providers [25+ Внешних провайдеров]
|
|
42
|
+
Engine --> Registry{Реестр адаптеров}
|
|
43
|
+
Registry -->|Авторизация Bearer| P1[OpenAI / DeepSeek / OpenRouter / Groq]
|
|
44
|
+
Registry -->|Заголовок x-api-key| P2[Anthropic Claude / Кастомные шлюзы]
|
|
45
|
+
Registry -->|Ключ query-key| P3[Google Gemini]
|
|
46
|
+
Registry -->|Локальный опрос| P4[Локальная Ollama / vLLM / SGLang]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
subgraph Reconcile [Сверка каталога и аудит]
|
|
50
|
+
P1 --> Normalizer[Нормализация и разметка возможностей]
|
|
51
|
+
P2 --> Normalizer
|
|
52
|
+
P3 --> Normalizer
|
|
53
|
+
P4 --> Normalizer
|
|
54
|
+
Normalizer --> Diff[Журнал изменений: Добавлено / Устарело]
|
|
55
|
+
Diff --> Catalog[Активный каталог моделей DSH]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
style Trigger fill:#1e1e2e,stroke:#89b4fa,stroke-width:2px,color:#cdd6f4
|
|
59
|
+
style Providers fill:#181825,stroke:#cba6f7,stroke-width:2px,color:#cdd6f4
|
|
60
|
+
style Reconcile fill:#11111b,stroke:#a6e3a1,stroke-width:2px,color:#cdd6f4
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## ✨ Ключевые возможности
|
|
66
|
+
|
|
67
|
+
* 🔄 **Автоматическое обнаружение моделей**: синхронизация списков моделей, контекстных окон и возможностей (`vision`, `tools`, `reasoning`, `embeddings`) для 25+ провайдеров.
|
|
68
|
+
* 🌐 **25+ встроенных адаптеров**: готовая поддержка OpenAI, Anthropic, Google, DeepSeek, xAI, OpenRouter, Groq, Mistral, Cerebras, Fireworks, HuggingFace, Moonshot, NVIDIA, Qwen, Together, Xiaomi MiMo, SiliconFlow и локальной Ollama.
|
|
69
|
+
* 🔌 **Универсальные кастомные адаптеры**: подключение любых сторонних OpenAI-совместимых эндпоинтов `/v1/models` (`bearer`, `x-api-key`, `query-key`, `none`).
|
|
70
|
+
* 📊 **Мониторинг баланса и квот**: опрос биллинга провайдеров (где доступно) для контроля остатка кредитов.
|
|
71
|
+
* 📜 **Журнал изменений (Diff Log)**: фиксация добавленных, удаленных и обновленных моделей с отметками времени.
|
|
72
|
+
* 🖥️ **Панель управления в Web GUI (**Настройки → Синхронизация моделей**)**:
|
|
73
|
+
* Статус-карточки со счетчиками активных моделей по каждому провайдеру;
|
|
74
|
+
* Кнопка «Синхронизировать сейчас» для мгновенного обновления;
|
|
75
|
+
* Переключатели провайдеров и ввод пользовательских адресов.
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## 📦 Быстрая установка
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
dsh plugin --profile web add @goodandready/dsh-model-sync
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## 🛠️ Улучшения и исправления в v0.3.6
|
|
88
|
+
|
|
89
|
+
* 🔑 **Универсальное разрешение учетных данных (`credentialRef` и `apiKeyRef`)**:
|
|
90
|
+
* Добавлена полная поддержка ссылок `credentialRef` и `apiKeyRef` в модулях инвентаря, реестре адаптеров и generic-маршрутах OpenAI-совместимых провайдеров.
|
|
91
|
+
* Провайдеры, настроенные через DSH Credentials Service, теперь корректно обнаруживаются и аутентифицируются без необходимости обязательного наличия `apiKeyEnv`.
|
|
92
|
+
* ♻️ **Очистка устаревших моделей в жизненном цикле**:
|
|
93
|
+
* Исправлена ошибка, из-за которой модели в статусе `deprecated` оставались в конфигурации бессрочно после их удаления провайдером. При флаге `removeMissing: true` такие модели теперь гарантированно переводятся в статус `removed` по истечении grace-периода.
|
|
94
|
+
* ⏰ **Точный статус неактивного планировщика**:
|
|
95
|
+
* Исправлен `scheduler.status()`: при отключённой фоновой синхронизации `nextRunAt` теперь возвращает `null`, исключая вводящие в заблуждение даты следующего запуска в Web UI.
|
|
96
|
+
* 🛡️ **Надежность и масштабируемость Web UI**:
|
|
97
|
+
* Защита от сбоев сортировки при отсутствии поля `name` у модели.
|
|
98
|
+
* Замена spread-операций на итеративную агрегацию при вычислении экстремумов контекста и цены, что предотвращает переполнение стека (`RangeError`) на огромных каталогах (10 000+ моделей).
|
|
99
|
+
* Детальный вывод текста ошибки при тестировании модели по кнопке `▶`.
|
|
100
|
+
* Локализация срока давности кеша в карточке провайдера.
|
|
101
|
+
* ⚡ **Детерминированное сравнение каталогов и безопасность потоков**:
|
|
102
|
+
* Реализована стабильная сортировка ключей перед сравнением моделей, исключающая фиктивные diff-записи из-за произвольного порядка ключей в JSON.
|
|
103
|
+
* Добавлено прерывание сетевого потока при превышении лимита размера тела HTTP-запроса для защиты от утечек памяти.
|
|
104
|
+
|
|
105
|
+
---
|
|
106
|
+
|
|
107
|
+
## 🚀 Новые возможности в v0.3.5
|
|
108
|
+
|
|
109
|
+
* ⏱️ **Адаптивная обработка Rate Limit и Retry-After**:
|
|
110
|
+
* Автоматический парсинг HTTP-заголовков `Retry-After` (в секундах и формате RFC-дат) и `x-ratelimit-reset`.
|
|
111
|
+
* При повторах задержка выставляется строго по требованию провайдера.
|
|
112
|
+
* Ошибки 429 классифицируются в отдельную категорию `rate-limit`.
|
|
113
|
+
* 🔄 **Автоматическое разрешение конфликтов настроек**:
|
|
114
|
+
* Добавлен цикл повторов для `SETTINGS_CONFLICT` (HTTP 409). При параллельных правках конфигурации DSH плагин автоматически перечитывает ревизию и накладывает изменения без ошибок.
|
|
115
|
+
* 🎯 **Оптимизация панели выбора моделей в Web UI**:
|
|
116
|
+
* **Токенизированный умный поиск**: Слова поискового запроса (`claude 3.5 sonnet`) сопоставляются независимо по ID, названиям, тегам и описаниям моделей.
|
|
117
|
+
* **Быстрые фильтр-чипы**: Фильтры `Контекст ≥ 100k` и `Дешевые ≤ $1/1M` в дополнение к фильтрам возможностей (`Зрение`, `Инструменты`, `Рассуждение`, `Эмбеддинги`).
|
|
118
|
+
* **Массовый выбор**: Кнопки *Выбрать все показанные*, *Снять выбор с показанных*, *Инвертировать выбор*.
|
|
119
|
+
* **Порционный рендеринг (пагинация)**: Отображение порциями по 50 моделей с кнопкой «Показать ещё» для плавной работы интерфейса в каталогах с 1000+ моделями.
|
|
120
|
+
* **Бейджи устаревания моделей**: Для моделей с датой вывода из эксплуатации выводится обратный отсчет (`Устареет через N дн.`).
|
|
121
|
+
* 💾 **Хранение и парсинг метаданных**:
|
|
122
|
+
* Корректный разбор числовых значений с запятыми и суффиксами (`128k`, `1M`, `128,000`).
|
|
123
|
+
* Автоматическая компактификация: снимки каталогов старше 5 последних запусков очищаются от тяжелых дампов, сохраняя diff-аудит и компактность `settings.yaml`.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## ⚙️ Пример конфигурации (`settings.yaml`)
|
|
128
|
+
|
|
129
|
+
```yaml
|
|
130
|
+
dsh-model-sync:
|
|
131
|
+
enabled: true
|
|
132
|
+
syncIntervalMinutes: 60
|
|
133
|
+
autoReconcile: true
|
|
134
|
+
providers:
|
|
135
|
+
openrouter:
|
|
136
|
+
enabled: true
|
|
137
|
+
keyEnv: OPENROUTER_API_KEY
|
|
138
|
+
deepseek:
|
|
139
|
+
enabled: true
|
|
140
|
+
keyEnv: DEEPSEEK_API_KEY
|
|
141
|
+
custom:
|
|
142
|
+
enabled: false
|
|
143
|
+
baseURL: http://127.0.0.1:8000/v1
|
|
144
|
+
authType: bearer
|
|
145
|
+
keyEnv: CUSTOM_API_KEY
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## 📄 Лицензия
|
|
151
|
+
|
|
152
|
+
MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# 📦 @goodandready/dsh-model-sync
|
|
2
|
+
|
|
3
|
+
<div align="center">
|
|
4
|
+
|
|
5
|
+
<h3>DeepSeek Harness 服务商模型目录动态同步与账户余额监控插件</h3>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="https://www.npmjs.com/package/@goodandready/dsh-model-sync"><img src="https://img.shields.io/npm/v/@goodandready/dsh-model-sync.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
|
|
9
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-10b981.svg?style=for-the-badge&color=10b981&labelColor=064e3b" alt="license"></a>
|
|
10
|
+
<a href="https://github.com/topics/dsh-plugin"><img src="https://img.shields.io/badge/DSH-Plugin-8b5cf6.svg?style=for-the-badge&labelColor=2e1065" alt="DSH Plugin"></a>
|
|
11
|
+
<a href="https://nodejs.org"><img src="https://img.shields.io/badge/Node-20%2B-f59e0b.svg?style=for-the-badge&labelColor=451a03" alt="Node version"></a>
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
<p align="center">
|
|
15
|
+
<a href="https://goodandready.app/"><img src="https://img.shields.io/badge/作者全部项目-goodandready.app-ff4500.svg?style=for-the-badge&logo=rocket&logoColor=white&labelColor=1a1a2e" alt="作者全部项目"></a>
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
<p align="center">
|
|
19
|
+
<a href="README.md"><b>🇬🇧 English</b></a> •
|
|
20
|
+
<a href="README.ru.md"><b>🇷🇺 Русский</b></a> •
|
|
21
|
+
<a href="README.zh.md"><b>🇨🇳 中文说明</b></a>
|
|
22
|
+
</p>
|
|
23
|
+
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## ⚡ 插件概览
|
|
29
|
+
|
|
30
|
+
**`dsh-model-sync`** 保持 **DeepSeek Harness** 模型选单与上游大模型服务商实时同步。
|
|
31
|
+
|
|
32
|
+
每当服务商上线新模型、扩充上下文长度或调整定价时,插件自动拉取最新清单,同步模型能力标签(`vision` 视觉、`tools` 工具调用、`reasoning` 深度思考、`embeddings` 嵌入),并在无需重启服务的前提下热重载目录。
|
|
33
|
+
|
|
34
|
+
```mermaid
|
|
35
|
+
graph LR
|
|
36
|
+
subgraph Trigger [调度与手动触发]
|
|
37
|
+
Cron[⏰ 后台定时轮询调度器] --> Engine[dsh-model-sync 核心引擎]
|
|
38
|
+
WebUI[🖥️ 设置面板: 立即同步按钮] --> Engine
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
subgraph Providers [25+ 上游服务商]
|
|
42
|
+
Engine --> Registry{适配器注册表}
|
|
43
|
+
Registry -->|Bearer 鉴权| P1[OpenAI / DeepSeek / OpenRouter / Groq]
|
|
44
|
+
Registry -->|x-api-key 鉴权| P2[Anthropic Claude / 自定义网关]
|
|
45
|
+
Registry -->|query-key 鉴权| P3[Google Gemini]
|
|
46
|
+
Registry -->|本地探针| P4[本地 Ollama / vLLM / SGLang]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
subgraph Reconcile [目录对齐与审计]
|
|
50
|
+
P1 --> Normalizer[模型归一化与能力特征打标]
|
|
51
|
+
P2 --> Normalizer
|
|
52
|
+
P3 --> Normalizer
|
|
53
|
+
P4 --> Normalizer
|
|
54
|
+
Normalizer --> Diff[差异审计流: 新增 / 废弃模型]
|
|
55
|
+
Diff --> Catalog[DSH 当前生效模型选单]
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
style Trigger fill:#1e1e2e,stroke:#89b4fa,stroke-width:2px,color:#cdd6f4
|
|
59
|
+
style Providers fill:#181825,stroke:#cba6f7,stroke-width:2px,color:#cdd6f4
|
|
60
|
+
style Reconcile fill:#11111b,stroke:#a6e3a1,stroke-width:2px,color:#cdd6f4
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## 📦 安装指南
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
dsh plugin --profile web add @goodandready/dsh-model-sync
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## 📄 开源协议
|
|
74
|
+
|
|
75
|
+
MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
|
package/lib/adapter-registry.js
CHANGED
|
@@ -121,7 +121,8 @@ export function normalizeAdapterConfig(config, profile = {}) {
|
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
async function requestSpecific(profile, descriptor, { resolveCredential, fetchImpl, signal }) {
|
|
124
|
-
const
|
|
124
|
+
const keyRef = profile.apiKeyEnv || profile.credentialRef || profile.apiKeyRef
|
|
125
|
+
const apiKey = keyRef ? await resolveCredential(keyRef) : ''
|
|
125
126
|
const url = new URL(descriptor.endpoint)
|
|
126
127
|
const headers = { Accept: 'application/json', ...(descriptor.headers ?? {}) }
|
|
127
128
|
if (descriptor.auth === 'bearer' && apiKey) headers.Authorization = 'Bearer ' + apiKey
|
|
@@ -133,8 +134,8 @@ async function requestSpecific(profile, descriptor, { resolveCredential, fetchIm
|
|
|
133
134
|
throw error
|
|
134
135
|
}
|
|
135
136
|
if (!response.ok) {
|
|
136
|
-
const error = catalogRequestError(response.status)
|
|
137
|
-
error.adapterCode = [401, 403].includes(response.status) ? 'auth' : 'http'
|
|
137
|
+
const error = catalogRequestError(response.status, response)
|
|
138
|
+
error.adapterCode = [401, 403].includes(response.status) ? 'auth' : response.status === 429 ? 'rate-limit' : 'http'
|
|
138
139
|
throw error
|
|
139
140
|
}
|
|
140
141
|
return response
|
|
@@ -144,6 +145,12 @@ async function fetchSpecific(profile, descriptor, options) {
|
|
|
144
145
|
let payload
|
|
145
146
|
try {
|
|
146
147
|
const response = await requestSpecific(profile, descriptor, options)
|
|
148
|
+
const declared = Number(response.headers?.get?.('content-length') ?? NaN)
|
|
149
|
+
if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
|
|
150
|
+
const error = new Error('model catalog response is too large')
|
|
151
|
+
error.adapterCode = 'schema'
|
|
152
|
+
throw error
|
|
153
|
+
}
|
|
147
154
|
const text = await response.text()
|
|
148
155
|
if (Buffer.byteLength(text, 'utf8') > MAX_RESPONSE_BYTES) {
|
|
149
156
|
const error = new Error('model catalog response is too large')
|
package/lib/client.js
CHANGED
|
@@ -41,10 +41,10 @@ window.__ModuleLoader__.load({
|
|
|
41
41
|
document.head.appendChild(tag)
|
|
42
42
|
}
|
|
43
43
|
const en = {
|
|
44
|
-
title: 'Model synchronization', showSettings: 'Show settings', hideSettings: 'Hide settings', desc: 'Refresh model catalogs for API-key providers. A dry-run is the default; applying changes is explicit.', loading: 'Loading…', refresh: 'Refresh status', refreshAll: 'Refresh all', run: 'Discover', apply: 'Apply changes', dryRun: 'Preview only (dry-run)', all: 'All API-key providers', ok: 'ready', dormant: 'not configured', running: 'running…', noProviders: 'No API-key providers found.', lastRun: 'Last run', previewTitle: 'Changes preview', previewNone: 'No changes detected.', previewAdded: 'Added', previewRemoved: 'Removed', previewChanged: 'Changed', previewApply: 'Apply preview', previewDismiss: 'Close preview', selectModels: 'Choose models', saveSelection: 'Save and apply selection', selectAtLeastOne: 'Select at least one model or use All models.', cancel: 'Cancel', selected: 'selected', allModels: 'All models', noModels: 'No models in the current catalog.', capabilityFilter: 'Filter by capabilities', capabilityCount: 'shown', noCapabilities: 'No capability metadata in this catalog.', pickerNote: 'The selection is applied to the DSH model picker immediately.', health: 'Check availability', healthOk: 'available', healthError: 'unavailable', healthDormant: 'not configured', policy: 'Manual model selection', savePolicy: 'Save policy', policyInclude: 'Include patterns (one per line)', policyExclude: 'Exclude patterns (one per line)', policyRequire: 'Required capabilities (comma-separated)', policyDeny: 'Denied capabilities (comma-separated)', policyNote: 'Patterns match model id, name, and tags. Apply changes to update the DSH picker.', scheduler: 'Scheduler', nextRun: 'next run', schedulerOff: 'off', schedulerOn: 'on', removeMissing: 'Confirm stale removal', stale: 'stale', deprecated: 'deprecated', history: 'Synchronization history', historyRun: 'Run', diffAdded: 'added', diffRemoved: 'removed', diffRenamed: 'renamed', diffChanged: 'metadata changed', rollback: 'Rollback catalog', rollbackConfirm: 'Rollback this provider catalog to the selected snapshot?', credentials: 'Credential diagnostics', checkCredentials: 'Check credentials', missingCredential: 'not configured', credentialReady: 'configured', report: 'Run report', success: 'success', partial: 'partial success', failure: 'failed', empty: 'no configured providers', notifications: 'Notifications', markRead: 'Mark read', acknowledge: 'Acknowledge', noNotifications: 'No notifications.',
|
|
44
|
+
title: 'Model synchronization', showSettings: 'Show settings', hideSettings: 'Hide settings', desc: 'Refresh model catalogs for API-key providers. A dry-run is the default; applying changes is explicit.', loading: 'Loading…', refresh: 'Refresh status', refreshAll: 'Refresh all', run: 'Discover', apply: 'Apply changes', dryRun: 'Preview only (dry-run)', all: 'All API-key providers', ok: 'ready', dormant: 'not configured', running: 'running…', noProviders: 'No API-key providers found.', lastRun: 'Last run', previewTitle: 'Changes preview', previewNone: 'No changes detected.', previewAdded: 'Added', previewRemoved: 'Removed', previewChanged: 'Changed', previewApply: 'Apply preview', previewDismiss: 'Close preview', selectModels: 'Choose models', saveSelection: 'Save and apply selection', selectAtLeastOne: 'Select at least one model or use All models.', cancel: 'Cancel', selected: 'selected', allModels: 'All models', noModels: 'No models in the current catalog.', capabilityFilter: 'Filter by capabilities', capabilityCount: 'shown', noCapabilities: 'No capability metadata in this catalog.', pickerNote: 'The selection is applied to the DSH model picker immediately.', health: 'Check availability', healthOk: 'available', healthError: 'unavailable', healthDormant: 'not configured', policy: 'Manual model selection', savePolicy: 'Save policy', policyInclude: 'Include patterns (one per line)', policyExclude: 'Exclude patterns (one per line)', policyRequire: 'Required capabilities (comma-separated)', policyDeny: 'Denied capabilities (comma-separated)', policyNote: 'Patterns match model id, name, and tags. Apply changes to update the DSH picker.', scheduler: 'Scheduler', nextRun: 'next run', schedulerOff: 'off', schedulerOn: 'on', removeMissing: 'Confirm stale removal', stale: 'stale', deprecated: 'deprecated', history: 'Synchronization history', historyRun: 'Run', diffAdded: 'added', diffRemoved: 'removed', diffRenamed: 'renamed', diffChanged: 'metadata changed', rollback: 'Rollback catalog', rollbackConfirm: 'Rollback this provider catalog to the selected snapshot?', credentials: 'Credential diagnostics', checkCredentials: 'Check credentials', missingCredential: 'not configured', credentialReady: 'configured', report: 'Run report', success: 'success', partial: 'partial success', failure: 'failed', empty: 'no configured providers', notifications: 'Notifications', markRead: 'Mark read', acknowledge: 'Acknowledge', noNotifications: 'No notifications.', selectAllFiltered: 'Select all shown', deselectAllFiltered: 'Deselect all shown', invertFiltered: 'Invert selection', filterContext100k: 'Context ≥ 100k', filterCheap: 'Cheap ≤ $1/1M', showMore: 'Show more (%d)', showingCount: 'Showing %d of %d models', deprecatesIn: 'Deprecates in %dd', cacheHoursAgo: 'cache %dh ago',
|
|
45
45
|
}
|
|
46
46
|
const ru = {
|
|
47
|
-
title: 'Синхронизация моделей', showSettings: 'Показать настройки', hideSettings: 'Скрыть настройки', desc: 'Обновляет каталоги моделей у провайдеров с API-ключом. По умолчанию только dry-run; применение запускается явно.', loading: 'Загрузка…', refresh: 'Обновить статус', refreshAll: 'Обновить все', run: 'Проверить', apply: 'Применить изменения', dryRun: 'Только просмотр (dry-run)', all: 'Все API-key провайдеры', ok: 'готов', dormant: 'не настроен', running: 'выполняется…', noProviders: 'API-key провайдеры не найдены.', lastRun: 'Последний запуск', previewTitle: 'Предпросмотр изменений', previewNone: 'Изменений не обнаружено.', previewAdded: 'Добавлено', previewRemoved: 'Удалено', previewChanged: 'Изменено', previewApply: 'Применить изменения', previewDismiss: 'Закрыть предпросмотр', selectModels: 'Выбрать модели', saveSelection: 'Сохранить и применить выбор', selectAtLeastOne: 'Выберите хотя бы одну модель или нажмите «Все модели».', cancel: 'Отмена', selected: 'выбрано', allModels: 'Все модели', noModels: 'В текущем каталоге нет моделей.', capabilityFilter: 'Фильтр по возможностям', capabilityCount: 'показано', noCapabilities: 'В этом каталоге нет данных о возможностях.', pickerNote: 'Выбор сразу применяется к пикеру моделей DSH.', health: 'Проверить доступность', healthOk: 'доступен', healthError: 'недоступен', healthDormant: 'не настроен', policy: 'Ручной выбор моделей', savePolicy: 'Сохранить политику', policyInclude: 'Включать (по одному шаблону в строке)', policyExclude: 'Исключать (по одному шаблону в строке)', policyRequire: 'Обязательные возможности (через запятую)', policyDeny: 'Запрещённые возможности (через запятую)', policyNote: 'Шаблоны ищутся в id, названии и тегах модели. Нажмите «Применить», чтобы обновить пикер DSH.', scheduler: 'Планировщик', nextRun: 'следующий запуск', schedulerOff: 'выключен', schedulerOn: 'включён', removeMissing: 'Подтвердить удаление устаревших', stale: 'устарела', deprecated: 'deprecated', history: 'История синхронизаций', historyRun: 'Запуск', diffAdded: 'добавлено', diffRemoved: 'удалено', diffRenamed: 'переименовано', diffChanged: 'изменена метаинформация', rollback: 'Откатить каталог', rollbackConfirm: 'Откатить каталог этого провайдера к выбранному снимку?', credentials: 'Диагностика ключей', checkCredentials: 'Проверить ключи', missingCredential: 'не настроен', credentialReady: 'настроен', report: 'Итог запуска', success: 'успех', partial: 'частичный успех', failure: 'ошибка', empty: 'нет настроенных провайдеров', notifications: 'Уведомления', markRead: 'Прочитано', acknowledge: 'Подтвердить', noNotifications: 'Новых уведомлений нет.',
|
|
47
|
+
title: 'Синхронизация моделей', showSettings: 'Показать настройки', hideSettings: 'Скрыть настройки', desc: 'Обновляет каталоги моделей у провайдеров с API-ключом. По умолчанию только dry-run; применение запускается явно.', loading: 'Загрузка…', refresh: 'Обновить статус', refreshAll: 'Обновить все', run: 'Проверить', apply: 'Применить изменения', dryRun: 'Только просмотр (dry-run)', all: 'Все API-key провайдеры', ok: 'готов', dormant: 'не настроен', running: 'выполняется…', noProviders: 'API-key провайдеры не найдены.', lastRun: 'Последний запуск', previewTitle: 'Предпросмотр изменений', previewNone: 'Изменений не обнаружено.', previewAdded: 'Добавлено', previewRemoved: 'Удалено', previewChanged: 'Изменено', previewApply: 'Применить изменения', previewDismiss: 'Закрыть предпросмотр', selectModels: 'Выбрать модели', saveSelection: 'Сохранить и применить выбор', selectAtLeastOne: 'Выберите хотя бы одну модель или нажмите «Все модели».', cancel: 'Отмена', selected: 'выбрано', allModels: 'Все модели', noModels: 'В текущем каталоге нет моделей.', capabilityFilter: 'Фильтр по возможностям', capabilityCount: 'показано', noCapabilities: 'В этом каталоге нет данных о возможностях.', pickerNote: 'Выбор сразу применяется к пикеру моделей DSH.', health: 'Проверить доступность', healthOk: 'доступен', healthError: 'недоступен', healthDormant: 'не настроен', policy: 'Ручной выбор моделей', savePolicy: 'Сохранить политику', policyInclude: 'Включать (по одному шаблону в строке)', policyExclude: 'Исключать (по одному шаблону в строке)', policyRequire: 'Обязательные возможности (через запятую)', policyDeny: 'Запрещённые возможности (через запятую)', policyNote: 'Шаблоны ищутся в id, названии и тегах модели. Нажмите «Применить», чтобы обновить пикер DSH.', scheduler: 'Планировщик', nextRun: 'следующий запуск', schedulerOff: 'выключен', schedulerOn: 'включён', removeMissing: 'Подтвердить удаление устаревших', stale: 'устарела', deprecated: 'deprecated', history: 'История синхронизаций', historyRun: 'Запуск', diffAdded: 'добавлено', diffRemoved: 'удалено', diffRenamed: 'переименовано', diffChanged: 'изменена метаинформация', rollback: 'Откатить каталог', rollbackConfirm: 'Откатить каталог этого провайдера к выбранному снимку?', credentials: 'Диагностика ключей', checkCredentials: 'Проверить ключи', missingCredential: 'не настроен', credentialReady: 'настроен', report: 'Итог запуска', success: 'успех', partial: 'частичный успех', failure: 'ошибка', empty: 'нет настроенных провайдеров', notifications: 'Уведомления', markRead: 'Прочитано', acknowledge: 'Подтвердить', noNotifications: 'Новых уведомлений нет.', selectAllFiltered: 'Выбрать все показанные', deselectAllFiltered: 'Снять выбор с показанных', invertFiltered: 'Инвертировать выбор', filterContext100k: 'Контекст ≥ 100k', filterCheap: 'Дешевые ≤ $1/1M', showMore: 'Показать ещё (%d)', showingCount: 'Показано %d из %d моделей', deprecatesIn: 'Устареет через %d дн.', cacheHoursAgo: 'кеш %dч назад',
|
|
48
48
|
}
|
|
49
49
|
const API = '/dsh-model-sync'
|
|
50
50
|
const CAPABILITY_LABELS = { vision: { en: 'Vision', ru: 'Зрение' }, tools: { en: 'Tools', ru: 'Инструменты' }, reasoning: { en: 'Reasoning', ru: 'Рассуждение' }, embeddings: { en: 'Embeddings', ru: 'Эмбеддинги' } }
|
|
@@ -58,8 +58,9 @@ window.__ModuleLoader__.load({
|
|
|
58
58
|
const locale = useLocale(ctx); const t = locale === 'ru' ? ru : en
|
|
59
59
|
const [status, setStatus] = React.useState(null); const [provider, setProvider] = React.useState(''); const [dryRun, setDryRun] = React.useState(true); const [removeMissing, setRemoveMissing] = React.useState(false); const [busy, setBusy] = React.useState(false); const [error, setError] = React.useState(''); const [credentialStatus, setCredentialStatus] = React.useState(null)
|
|
60
60
|
const [selectionProvider, setSelectionProvider] = React.useState(''); const [selectionDraft, setSelectionDraft] = React.useState([]); const [capabilityFilter, setCapabilityFilter] = React.useState([]); const [searchQuery, setSearchQuery] = React.useState(''); const [sortBy, setSortBy] = React.useState('name'); const [preview, setPreview] = React.useState(null); const [policyProvider, setPolicyProvider] = React.useState(''); const [policyDraft, setPolicyDraft] = React.useState({ include: '', exclude: '', requireCapabilities: '', denyCapabilities: '' })
|
|
61
|
+
const [pageSize, setPageSize] = React.useState(50); const [quickFilter, setQuickFilter] = React.useState({ context100k: false, cheap: false })
|
|
61
62
|
const [tryState, setTryState] = React.useState({})
|
|
62
|
-
const doTry = (provider, modelId) => { setTryState((s)=>({ ...s, [provider+':'+modelId]: { loading: true } })); fetch(`${API}/try`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ provider, model: modelId }) }).then(async r=>{ const b=await r.json(); if(!r.ok) throw new Error(b?.error?.message||`HTTP ${r.status}`); return b }).then(b=>setTryState((s)=>({ ...s, [provider+':'+modelId]: { ok: b.ok, latency: b.latencyMs, status: b.status } }))).catch(e=>setTryState((s)=>({ ...s, [provider+':'+modelId]: { error: String(e.message||e) } }))) }
|
|
63
|
+
const doTry = (provider, modelId) => { setTryState((s)=>({ ...s, [provider+':'+modelId]: { loading: true } })); fetch(`${API}/try`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ provider, model: modelId }) }).then(async r=>{ const b=await r.json(); if(!r.ok) throw new Error(b?.error?.message||`HTTP ${r.status}`); return b }).then(b=>setTryState((s)=>({ ...s, [provider+':'+modelId]: { ok: b.ok, latency: b.latencyMs, status: b.status, error: b.error } }))).catch(e=>setTryState((s)=>({ ...s, [provider+':'+modelId]: { error: String(e.message||e) } }))) }
|
|
63
64
|
const load = React.useCallback(() => { setError(''); return fetch(`${API}/status`).then((r) => r.json()).then(setStatus).catch((e) => setError(String(e?.message ?? e))) }, [])
|
|
64
65
|
const loadCredentials = React.useCallback(() => { setError(''); return fetch(`${API}/credentials`).then(async (r) => { const body = await r.json(); if (!r.ok) throw new Error(body?.error?.message ?? `HTTP ${r.status}`); return body }).then(setCredentialStatus).catch((e) => setError(String(e?.message ?? e))) }, [])
|
|
65
66
|
React.useEffect(() => { ensureStyles(); load(); loadCredentials(); const timer = setInterval(load, 15000); return () => clearInterval(timer) }, [load, loadCredentials])
|
|
@@ -67,8 +68,8 @@ window.__ModuleLoader__.load({
|
|
|
67
68
|
const checkHealth = (targetProvider) => { setBusy(true); setError(''); fetch(`${API}/health`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(targetProvider ? { provider: targetProvider } : {}) }).then(async (r) => { const body = await r.json(); if (!r.ok) throw new Error(body?.error?.message ?? `HTTP ${r.status}`); return body }).then(setStatus).catch((e) => setError(String(e?.message ?? e))).finally(() => setBusy(false)) }
|
|
68
69
|
const checkCredentials = (targetProvider) => { setBusy(true); setError(''); fetch(`${API}/credentials/check`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(targetProvider ? { provider: targetProvider } : {}) }).then(async (r) => { const body = await r.json(); if (!r.ok) throw new Error(body?.error?.message ?? `HTTP ${r.status}`); return body }).then((body) => { setCredentialStatus(body.credentials); setStatus((current) => ({ ...(current ?? {}), lastHealth: body.health })) }).catch((e) => setError(String(e?.message ?? e))).finally(() => setBusy(false)) }
|
|
69
70
|
const updateNotification = (id, action) => { setBusy(true); setError(''); fetch(API + '/notifications/' + action, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id }) }).then(async (r) => { const body = await r.json(); if (!r.ok) throw new Error(body?.error?.message ?? ('HTTP ' + r.status)); return body }).then(() => load()).catch((e) => setError(String(e?.message ?? e))).finally(() => setBusy(false)) }
|
|
70
|
-
const openSelection = (row) => { const available = row.availableModels ?? row.models ?? []; const defaults = row.selectableModels ?? available; closePolicy(); setSelectionProvider(row.provider); setSelectionDraft(row.selectedModels?.length ? row.selectedModels : defaults.map((model) => model.id)); setCapabilityFilter([]); setSearchQuery(''); setSortBy('name') }
|
|
71
|
-
const closeSelection = () => { setSelectionProvider(''); setSelectionDraft([]); setCapabilityFilter([]); setSearchQuery(''); setSortBy('name') }
|
|
71
|
+
const openSelection = (row) => { const available = row.availableModels ?? row.models ?? []; const defaults = row.selectableModels ?? available; closePolicy(); setSelectionProvider(row.provider); setSelectionDraft(row.selectedModels?.length ? row.selectedModels : defaults.map((model) => model.id)); setCapabilityFilter([]); setSearchQuery(''); setSortBy('name'); setPageSize(50); setQuickFilter({ context100k: false, cheap: false }) }
|
|
72
|
+
const closeSelection = () => { setSelectionProvider(''); setSelectionDraft([]); setCapabilityFilter([]); setSearchQuery(''); setSortBy('name'); setPageSize(50); setQuickFilter({ context100k: false, cheap: false }) }
|
|
72
73
|
const openPolicy = (row) => { const policy = status?.policies?.[row.provider] ?? row.policy ?? {}; closeSelection(); setPolicyProvider(row.provider); setPolicyDraft({ include: (policy.include ?? []).join('\n'), exclude: (policy.exclude ?? []).join('\n'), requireCapabilities: Object.keys(policy.requireCapabilities ?? {}).join(', '), denyCapabilities: Object.keys(policy.denyCapabilities ?? {}).join(', ') }) }
|
|
73
74
|
const closePolicy = () => { setPolicyProvider(''); setPolicyDraft({ include: '', exclude: '', requireCapabilities: '', denyCapabilities: '' }) }
|
|
74
75
|
const splitValues = (value) => [...new Set(String(value ?? '').split(/[,\n]+/).map((item) => item.trim()).filter(Boolean))]
|
|
@@ -78,8 +79,10 @@ window.__ModuleLoader__.load({
|
|
|
78
79
|
const rollback = (entry, targetProvider) => { if (!window.confirm(t.rollbackConfirm)) return; setBusy(true); setError(''); fetch(`${API}/history/rollback`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ historyId: entry.id, provider: targetProvider }) }).then(async (r) => { const body = await r.json(); if (!r.ok) throw new Error(body?.error?.message ?? `HTTP ${r.status}`); return body }).then(() => load()).catch((e) => setError(String(e?.message ?? e))).finally(() => setBusy(false)) }
|
|
79
80
|
const isNewModel = (model, row) => { const added = status?.lastRun?.results?.find(r=>r.provider===row.provider)?.diff?.added||[]; const isAdded = added.some(m=>m.id===model.id); if(!isAdded) return false; const finishedAt = status?.lastRun?.finishedAt; if(!finishedAt) return false; return (Date.now()-new Date(finishedAt).getTime()) < 7*24*60*60*1000; };
|
|
80
81
|
const rows = (status?.providers ?? []).filter((row) => row.configured); const selectedRow = rows.find((row) => row.provider === selectionProvider); const choices = selectedRow?.availableModels ?? selectedRow?.models ?? []; const filteredChoicesBase = filterModelsByCapabilities(choices, capabilityFilter);
|
|
81
|
-
const
|
|
82
|
-
const
|
|
82
|
+
const tokens = searchQuery.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
83
|
+
const searchedChoices = tokens.length ? filteredChoicesBase.filter((m) => { const haystack = (m.id + ' ' + (m.name||'') + ' ' + (m.aliases||[]).join(' ') + ' ' + (m.tags||[]).join(' ') + ' ' + (m.description||'')).toLowerCase(); return tokens.every((tok) => haystack.includes(tok)); }) : filteredChoicesBase;
|
|
84
|
+
const quickFilteredChoices = searchedChoices.filter((m) => { if (quickFilter.context100k && (!m.contextWindow || m.contextWindow < 100000)) return false; if (quickFilter.cheap && (!m.pricing?.inputPerToken || (m.pricing.inputPerToken * 1000000) > 1.0)) return false; return true; });
|
|
85
|
+
const filteredChoices = [...quickFilteredChoices].sort((a,b) => { if (sortBy==='price') { const pa=a.pricing?.inputPerToken??Infinity; const pb=b.pricing?.inputPerToken??Infinity; return pa-pb; } if (sortBy==='context') return (b.contextWindow??0)-(a.contextWindow??0); if (sortBy==='date') return (b.firstSeenAt??0)-(a.firstSeenAt??0); return (a.name || a.id || '').localeCompare(b.name || b.id || ''); }); const maxContext = filteredChoices.reduce((max, m) => Math.max(max, m.contextWindow || 0), 0); const minPrice = filteredChoices.reduce((min, m) => m.pricing?.inputPerToken !== undefined ? Math.min(min, m.pricing.inputPerToken) : min, Infinity); const availableCapabilities = [...new Set(choices.flatMap((model) => Object.entries(model?.capabilities ?? {}).filter(([, enabled]) => enabled === true).map(([key]) => key)))].sort(); const visibleChoices = filteredChoices.slice(0, pageSize); const button = (label, onClick) => React.createElement('button', { type: 'button', disabled: busy, onClick, className: 'dms-btn' }, label)
|
|
83
86
|
const renderNotifications = () => {
|
|
84
87
|
const entries = status?.notifications ?? []
|
|
85
88
|
if (entries.length === 0) return React.createElement('small', { style: { display: 'block', marginTop: 6 } }, t.noNotifications)
|
|
@@ -121,9 +124,9 @@ window.__ModuleLoader__.load({
|
|
|
121
124
|
const healthEntry = status?.lastHealth?.results?.find((item) => item.provider === row.provider)
|
|
122
125
|
const latencyText = healthEntry?.latencyMs ? ` · ${healthEntry.latencyMs}ms` : ''
|
|
123
126
|
const isOffline = latestRun?.status !== 'ok' && modelCount > 0
|
|
124
|
-
const offlineText = isOffline ?
|
|
127
|
+
const offlineText = isOffline ? ' · ' + t.cacheHoursAgo.replace('%d', Math.max(0, Math.round((Date.now() - (status?.lastRun?.finishedAt ? new Date(status.lastRun.finishedAt).getTime() : Date.now()))/3600000))) : ''
|
|
125
128
|
const providerCard = React.createElement('div', { className: 'dms-provider' }, React.createElement('span', null, `${row.provider}: ${row.configured ? t.ok : t.dormant} · ${modelSummary}${lifecycleText}${healthText}${latencyText}${offlineText}`), button(t.selectModels, () => openSelection(row)), button(t.policy, () => openPolicy(row)))
|
|
126
|
-
const selectionEditor = selectionProvider === row.provider ? React.createElement('div', { className: 'dms-editor' }, React.createElement('strong', null, `${t.selectModels}: ${selectionProvider}`), React.createElement('div', { style: { display: 'flex', gap: 8, margin: '8px 0', flexWrap: 'wrap' } }, React.createElement('input', { placeholder: 'Поиск id/name/tag', value: searchQuery, onChange: (e)=>setSearchQuery(e.target.value), style: { flex: '1 1 160px', padding: '6px 8px', borderRadius: 6, border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-2)' } }), React.createElement('select', { value: sortBy, onChange: (e)=>setSortBy(e.target.value), style: { padding: '6px 8px', borderRadius: 6 } }, React.createElement('option', {value:'name'}, 'Имя A→Z'), React.createElement('option', {value:'price'}, 'Цена ↑'), React.createElement('option', {value:'context'}, 'Контекст ↓'), React.createElement('option', {value:'date'}, 'Новые ↓'))), React.createElement('div', { style: { margin: '8px 0' } }, choices.length === 0 ? React.createElement('small', null, t.noModels) : React.createElement(React.Fragment, null, availableCapabilities.length > 0 ? React.createElement('div', { style: { marginBottom: 8 } }, React.createElement('strong', null, t.capabilityFilter), availableCapabilities.map((capability) => React.createElement('label', { key: capability, style: { display: 'inline-block', marginLeft: 8 } }, React.createElement('input', { type: 'checkbox', checked: capabilityFilter.includes(capability), onChange: (e) => setCapabilityFilter((current) => e.target.checked ? [...current, capability] : current.filter((item) => item !== capability)) }), ' ' + (CAPABILITY_LABELS[capability]?.[locale] ?? capability)))) : React.createElement('small', { style: { display: 'block', marginBottom: 8 } }, t.noCapabilities), React.createElement('small', { style: { display: 'block', marginBottom: 8 } }, filteredChoices.length + '/' + choices.length + ' ' + t.capabilityCount),
|
|
129
|
+
const selectionEditor = selectionProvider === row.provider ? React.createElement('div', { className: 'dms-editor' }, React.createElement('strong', null, `${t.selectModels}: ${selectionProvider}`), React.createElement('div', { style: { display: 'flex', gap: 8, margin: '8px 0', flexWrap: 'wrap' } }, React.createElement('input', { placeholder: 'Поиск id/name/tag', value: searchQuery, onChange: (e)=>setSearchQuery(e.target.value), style: { flex: '1 1 160px', padding: '6px 8px', borderRadius: 6, border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-2)' } }), React.createElement('select', { value: sortBy, onChange: (e)=>setSortBy(e.target.value), style: { padding: '6px 8px', borderRadius: 6 } }, React.createElement('option', {value:'name'}, 'Имя A→Z'), React.createElement('option', {value:'price'}, 'Цена ↑'), React.createElement('option', {value:'context'}, 'Контекст ↓'), React.createElement('option', {value:'date'}, 'Новые ↓'))), React.createElement('div', { style: { display: 'flex', gap: 6, margin: '6px 0', flexWrap: 'wrap' } }, React.createElement('button', { type: 'button', className: 'dms-btn', style: quickFilter.context100k ? { borderColor: 'var(--dsw-alias-brand-primary)', background: 'var(--dsw-alias-bg-layer-2)' } : {}, onClick: () => setQuickFilter((f) => ({ ...f, context100k: !f.context100k })) }, (quickFilter.context100k ? '✓ ' : '') + t.filterContext100k), React.createElement('button', { type: 'button', className: 'dms-btn', style: quickFilter.cheap ? { borderColor: 'var(--dsw-alias-brand-primary)', background: 'var(--dsw-alias-bg-layer-2)' } : {}, onClick: () => setQuickFilter((f) => ({ ...f, cheap: !f.cheap })) }, (quickFilter.cheap ? '✓ ' : '') + t.filterCheap)), React.createElement('div', { style: { margin: '8px 0' } }, choices.length === 0 ? React.createElement('small', null, t.noModels) : React.createElement(React.Fragment, null, availableCapabilities.length > 0 ? React.createElement('div', { style: { marginBottom: 8 } }, React.createElement('strong', null, t.capabilityFilter), availableCapabilities.map((capability) => React.createElement('label', { key: capability, style: { display: 'inline-block', marginLeft: 8 } }, React.createElement('input', { type: 'checkbox', checked: capabilityFilter.includes(capability), onChange: (e) => setCapabilityFilter((current) => e.target.checked ? [...current, capability] : current.filter((item) => item !== capability)) }), ' ' + (CAPABILITY_LABELS[capability]?.[locale] ?? capability)))) : React.createElement('small', { style: { display: 'block', marginBottom: 8 } }, t.noCapabilities), React.createElement('div', { style: { display: 'flex', gap: 6, margin: '6px 0', flexWrap: 'wrap' } }, button(t.selectAllFiltered, () => { const ids = new Set(selectionDraft); for (const m of filteredChoices) ids.add(m.id); setSelectionDraft([...ids]); }), button(t.deselectAllFiltered, () => { const filterSet = new Set(filteredChoices.map((m) => m.id)); setSelectionDraft(selectionDraft.filter((id) => !filterSet.has(id))); }), button(t.invertFiltered, () => { const current = new Set(selectionDraft); for (const m of filteredChoices) { if (current.has(m.id)) current.delete(m.id); else current.add(m.id); } setSelectionDraft([...current]); })), React.createElement('small', { style: { display: 'block', marginBottom: 8 } }, filteredChoices.length + '/' + choices.length + ' ' + t.capabilityCount), visibleChoices.map((model) => { const deprecationDays = (() => { const d = model.lifecycle?.deprecationDate; if (!d) return null; const diff = new Date(d).getTime() - Date.now(); return diff > 0 ? Math.ceil(diff / (1000 * 60 * 60 * 24)) : 0; })(); return React.createElement('label', { key: model.id, style: { display: 'block', margin: '4px 0', padding: '4px 6px', borderRadius: 6, background: isNewModel(model,row) ? 'var(--dsw-alias-bg-layer-2)' : 'transparent' } }, React.createElement('input', { type: 'checkbox', checked: selectionDraft.includes(model.id), onChange: (e) => setSelectionDraft((current) => e.target.checked ? [...current, model.id] : current.filter((id) => id !== model.id)) }), ` ${model.name ?? model.id} (${model.id})`, isNewModel(model,row) ? React.createElement('span', { style: { marginLeft: 6, fontSize: 11, padding: '1px 5px', borderRadius: 4, background: 'var(--dsw-alias-state-business-primary)', color: 'white' } }, 'NEW') : null, deprecationDays !== null ? React.createElement('span', { style: { marginLeft: 6, fontSize: 10, padding: '1px 5px', borderRadius: 4, background: 'var(--dsw-alias-state-warning-primary)', color: 'white' } }, deprecationDays > 0 ? t.deprecatesIn.replace('%d', deprecationDays) : t.deprecated) : null, (model.contextWindow && maxContext > 0 && model.contextWindow >= maxContext) ? React.createElement('span', { style: { marginLeft: 4, fontSize: 10, padding: '1px 4px', borderRadius: 3, background: 'var(--dsw-alias-state-warning-primary)', color: 'white' } }, '★') : null, (model.pricing && minPrice !== Infinity && model.pricing.inputPerToken === minPrice) ? React.createElement('span', { style: { marginLeft: 4, fontSize: 10, padding: '1px 4px', borderRadius: 3, background: 'var(--dsw-alias-state-success-primary)', color: 'white' } }, '$') : null, model.tags && model.tags.length ? React.createElement('small', { style: { marginLeft: 6, color: 'var(--dsw-alias-label-secondary)' } }, model.tags.join(', ')) : null, model.pricing ? React.createElement('small', { style: { marginLeft: 6, color: 'var(--dsw-alias-label-secondary)', fontSize: 11 } }, `in $${(model.pricing.inputPerToken*1000000).toFixed(2)}/1M` + (model.pricing.cacheReadPerToken!==undefined ? ` / cache $${(model.pricing.cacheReadPerToken*1000000).toFixed(2)}` : '') + (model.pricing.outputPerToken!==undefined ? ` / out $${(model.pricing.outputPerToken*1000000).toFixed(2)}` : '')) : null, model.description ? React.createElement('small', { title: model.description, style: { display: 'block', marginLeft: 22, color: 'var(--dsw-alias-label-secondary)', fontSize: 11 } }, model.description.slice(0,120)) : null, row.baseURL || row.api ? React.createElement('a', { href: (row.baseURL||'') + '/models/'+encodeURIComponent(model.id), target: '_blank', rel: 'noopener', style: { marginLeft: 8, fontSize: 11 } }, 'docs') : null, React.createElement('button', { type: 'button', onClick: (e)=>{e.preventDefault(); doTry(row.provider, model.id);}, style: { marginLeft: 8, fontSize: 11, padding: '1px 6px', borderRadius: 4 } }, (tryState[row.provider+':'+model.id] && tryState[row.provider+':'+model.id].loading) ? '…' : '▶'), tryState[row.provider+':'+model.id] && !tryState[row.provider+':'+model.id].loading ? React.createElement('small', { style: { marginLeft: 4, color: tryState[row.provider+':'+model.id].ok ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-state-error-primary)', fontSize: 11 } }, tryState[row.provider+':'+model.id].ok ? `${tryState[row.provider+':'+model.id].latency}ms` : (tryState[row.provider+':'+model.id].error||tryState[row.provider+':'+model.id].status)) : null); }), filteredChoices.length > pageSize ? React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, margin: '8px 0' } }, button(t.showMore.replace('%d', Math.min(50, filteredChoices.length - pageSize)), () => setPageSize((s) => s + 50)), React.createElement('small', { style: { color: 'var(--dsw-alias-label-secondary)' } }, t.showingCount.replace('%d', visibleChoices.length).replace('%d', filteredChoices.length))) : null)), React.createElement('small', null, t.pickerNote), React.createElement('div', { style: { marginTop: 8 } }, button(t.allModels, () => setSelectionDraft(choices.map((model) => model.id))), button(t.saveSelection, saveSelection), button(t.cancel, closeSelection))) : null
|
|
127
130
|
const policyEditor = policyProvider === row.provider ? React.createElement('div', { className: 'dms-editor' }, React.createElement('strong', null, `${t.policy}: ${policyProvider}`), React.createElement('label', { style: { display: 'block', marginTop: 8 } }, t.policyInclude, React.createElement('textarea', { rows: 3, value: policyDraft.include, onChange: (e) => setPolicyDraft((current) => ({ ...current, include: e.target.value })), style: { display: 'block', width: '100%' } })), React.createElement('label', { style: { display: 'block', marginTop: 8 } }, t.policyExclude, React.createElement('textarea', { rows: 3, value: policyDraft.exclude, onChange: (e) => setPolicyDraft((current) => ({ ...current, exclude: e.target.value })), style: { display: 'block', width: '100%' } })), React.createElement('label', { style: { display: 'block', marginTop: 8 } }, t.policyRequire, React.createElement('input', { value: policyDraft.requireCapabilities, onChange: (e) => setPolicyDraft((current) => ({ ...current, requireCapabilities: e.target.value })), style: { display: 'block', width: '100%' } })), React.createElement('label', { style: { display: 'block', marginTop: 8 } }, t.policyDeny, React.createElement('input', { value: policyDraft.denyCapabilities, onChange: (e) => setPolicyDraft((current) => ({ ...current, denyCapabilities: e.target.value })), style: { display: 'block', width: '100%' } })), React.createElement('small', null, t.policyNote), React.createElement('div', { style: { marginTop: 8 } }, button(t.savePolicy, savePolicy), button(t.cancel, closePolicy))) : null
|
|
128
131
|
return React.createElement('div', { key: row.provider, className: 'dms-provider-wrap' }, providerCard, selectionEditor, policyEditor)
|
|
129
132
|
}),
|
package/lib/generic-adapter.js
CHANGED
|
@@ -38,9 +38,10 @@ function requestHeaders(apiKey) {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
async function resolveGenericRequest(profile, { resolveCredential, fetchImpl, signal }) {
|
|
41
|
-
const
|
|
41
|
+
const keyRef = profile.apiKeyEnv || profile.credentialRef || profile.apiKeyRef
|
|
42
|
+
const apiKey = keyRef ? await resolveCredential(keyRef) : ''
|
|
42
43
|
const response = await fetchImpl(endpointFor(profile.baseURL), { headers: requestHeaders(apiKey), signal })
|
|
43
|
-
if (!response.ok) throw catalogRequestError(response.status)
|
|
44
|
+
if (!response.ok) throw catalogRequestError(response.status, response)
|
|
44
45
|
return response
|
|
45
46
|
}
|
|
46
47
|
|
package/lib/history.js
CHANGED
|
@@ -80,11 +80,29 @@ export function createHistoryEntry(result, { version = 1, maxModels = MAX_HISTOR
|
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
export
|
|
83
|
+
export const DEFAULT_SNAPSHOT_RETENTION = 5
|
|
84
|
+
|
|
85
|
+
export function compactHistoryEntry(entry) {
|
|
86
|
+
if (!entry || typeof entry !== 'object') return entry
|
|
87
|
+
const providers = (entry.providers ?? []).map((provider) => {
|
|
88
|
+
const copy = { ...provider }
|
|
89
|
+
delete copy.before
|
|
90
|
+
delete copy.after
|
|
91
|
+
return copy
|
|
92
|
+
})
|
|
93
|
+
return { ...entry, providers }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function appendHistory(history, entry, limit = DEFAULT_HISTORY_LIMIT, { retainSnapshots = DEFAULT_SNAPSHOT_RETENTION } = {}) {
|
|
84
97
|
const rows = Array.isArray(history) ? history.filter((row) => row && typeof row === 'object') : []
|
|
85
98
|
const next = [...rows, structuredClone(entry)]
|
|
86
99
|
next.sort((left, right) => Number(left.version ?? 0) - Number(right.version ?? 0))
|
|
87
|
-
|
|
100
|
+
const bounded = next.slice(-historyLimit(limit))
|
|
101
|
+
const cutoff = Math.max(0, bounded.length - Math.max(1, Number(retainSnapshots) || DEFAULT_SNAPSHOT_RETENTION))
|
|
102
|
+
for (let i = 0; i < cutoff; i++) {
|
|
103
|
+
bounded[i] = compactHistoryEntry(bounded[i])
|
|
104
|
+
}
|
|
105
|
+
return bounded
|
|
88
106
|
}
|
|
89
107
|
|
|
90
108
|
function briefModel(model) {
|
package/lib/http.js
CHANGED
|
@@ -19,14 +19,25 @@ function trusted(req) {
|
|
|
19
19
|
function readJson(req) {
|
|
20
20
|
return new Promise((resolve, reject) => {
|
|
21
21
|
let raw = ''
|
|
22
|
+
let failed = false
|
|
22
23
|
req.on('data', (chunk) => {
|
|
24
|
+
if (failed) return
|
|
23
25
|
raw += chunk
|
|
24
|
-
if (raw.length > 64 * 1024)
|
|
26
|
+
if (raw.length > 64 * 1024) {
|
|
27
|
+
failed = true
|
|
28
|
+
const err = new Error('request body is too large')
|
|
29
|
+
if (typeof req.destroy === 'function') req.destroy(err)
|
|
30
|
+
reject(err)
|
|
31
|
+
}
|
|
25
32
|
})
|
|
26
33
|
req.on('end', () => {
|
|
34
|
+
if (failed) return
|
|
27
35
|
try { resolve(JSON.parse(raw || '{}')) } catch (error) { reject(error) }
|
|
28
36
|
})
|
|
29
|
-
req.on('error',
|
|
37
|
+
req.on('error', (err) => {
|
|
38
|
+
if (failed) return
|
|
39
|
+
reject(err)
|
|
40
|
+
})
|
|
30
41
|
})
|
|
31
42
|
}
|
|
32
43
|
|
package/lib/index.js
CHANGED
|
@@ -142,9 +142,9 @@ export function apply(ctx, config) {
|
|
|
142
142
|
const scheduler = createSyncScheduler(synchronizer, { getConfig: () => liveConfig })
|
|
143
143
|
reconfigure = () => scheduler.reconfigure()
|
|
144
144
|
scheduler.start()
|
|
145
|
+
ctx.effect(() => () => scheduler.stop(), 'dsh-model-sync: scheduler cleanup')
|
|
145
146
|
ctx.effect(() => {
|
|
146
147
|
const registrations = registerHttpApi(ctx, synchronizer, { getSchedulerStatus: () => scheduler.status() })
|
|
147
|
-
ctx.effect(() => () => scheduler.stop(), 'dsh-model-sync: scheduler cleanup')
|
|
148
148
|
return () => {
|
|
149
149
|
for (const dispose of registrations) if (typeof dispose === 'function') dispose()
|
|
150
150
|
}
|
package/lib/inventory.js
CHANGED
|
@@ -52,7 +52,7 @@ function liveProviders(llm) {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
export function isApiKeyProvider(entry, profile) {
|
|
55
|
-
if (profile?.apiKeyEnv) return true
|
|
55
|
+
if (profile?.apiKeyEnv || profile?.credentialRef || profile?.apiKeyRef) return true
|
|
56
56
|
return entry?.declared === false && API_KEY_PROVIDER_SET.has(entry.provider)
|
|
57
57
|
}
|
|
58
58
|
|
|
@@ -74,7 +74,8 @@ export function listApiKeyProviders(llm, settings) {
|
|
|
74
74
|
declared: entry.declared === true,
|
|
75
75
|
configured: Object.prototype.hasOwnProperty.call(profiles, entry.provider),
|
|
76
76
|
live: live.has(entry.provider),
|
|
77
|
-
apiKeyEnv: profile.apiKeyEnv ?? '',
|
|
77
|
+
apiKeyEnv: profile.apiKeyEnv ?? profile.credentialRef ?? profile.apiKeyRef ?? '',
|
|
78
|
+
credentialRef: profile.credentialRef ?? profile.apiKeyRef ?? profile.apiKeyEnv ?? '',
|
|
78
79
|
baseURL: profile.baseURL ?? '',
|
|
79
80
|
api: profile.api ?? '',
|
|
80
81
|
models: normalizeModels(entry.provider, profile.models ?? []),
|
package/lib/lifecycle.js
CHANGED
|
@@ -92,7 +92,7 @@ export function updateLifecycle(previous, beforeModels, advertisedModels, {
|
|
|
92
92
|
record.firstMissingAt ||= now
|
|
93
93
|
record.lastObservedRun = runNumber
|
|
94
94
|
if (record.status !== 'deprecated') record.status = 'stale'
|
|
95
|
-
if (removeMissing && record.status === 'stale' && record.consecutiveMissing >= staleGraceRuns) {
|
|
95
|
+
if (removeMissing && (record.status === 'stale' || record.status === 'deprecated') && record.consecutiveMissing >= staleGraceRuns) {
|
|
96
96
|
record.status = 'removed'
|
|
97
97
|
record.removedAt = now
|
|
98
98
|
record.reason = `missing for ${record.consecutiveMissing} discovery runs`
|
package/lib/models.js
CHANGED
|
@@ -36,7 +36,21 @@ const PRICING_ALIASES = Object.freeze({
|
|
|
36
36
|
function numericValue(value, { allowZero = false } = {}) {
|
|
37
37
|
if (typeof value !== 'number' && typeof value !== 'string') return undefined
|
|
38
38
|
if (typeof value === 'string' && value.trim() === '') return undefined
|
|
39
|
-
const
|
|
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
|
+
}
|
|
40
54
|
if (!Number.isFinite(number) || (allowZero ? number < 0 : number <= 0)) return undefined
|
|
41
55
|
return number
|
|
42
56
|
}
|
|
@@ -213,6 +227,14 @@ export function normalizeModels(provider, rows) {
|
|
|
213
227
|
return out
|
|
214
228
|
}
|
|
215
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
|
+
|
|
216
238
|
export function diffModels(previous, next) {
|
|
217
239
|
const sortModels = (rows) => normalizeModels('', rows).sort((left, right) => left.id.localeCompare(right.id))
|
|
218
240
|
const beforeRows = sortModels(previous)
|
|
@@ -226,7 +248,7 @@ export function diffModels(previous, next) {
|
|
|
226
248
|
const renamedBefore = new Set()
|
|
227
249
|
for (const [id, model] of after) {
|
|
228
250
|
if (!before.has(id)) continue
|
|
229
|
-
if (
|
|
251
|
+
if (stableStringify(before.get(id)) !== stableStringify(model)) {
|
|
230
252
|
changed.push({ before: before.get(id), after: model })
|
|
231
253
|
}
|
|
232
254
|
}
|
package/lib/reconcile.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { diffModels, normalizeModels } from './models.js'
|
|
1
|
+
import { diffModels, normalizeModels, stableStringify } from './models.js'
|
|
2
2
|
|
|
3
3
|
function sameModel(left, right) {
|
|
4
|
-
return
|
|
4
|
+
return stableStringify(left) === stableStringify(right)
|
|
5
5
|
}
|
|
6
6
|
|
|
7
7
|
export function reconcileModels(provider, current, discovered, {
|
package/lib/reliability.js
CHANGED
|
@@ -16,9 +16,37 @@ export function isRetryableError(error) {
|
|
|
16
16
|
return status !== undefined && RETRYABLE_STATUSES.has(status)
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
export function
|
|
19
|
+
export function parseRetryAfter(response) {
|
|
20
|
+
if (!response?.headers?.get) return undefined
|
|
21
|
+
const retryAfter = response.headers.get('retry-after')
|
|
22
|
+
if (retryAfter) {
|
|
23
|
+
const seconds = Number(retryAfter)
|
|
24
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1000)
|
|
25
|
+
const dateMs = Date.parse(retryAfter)
|
|
26
|
+
if (Number.isFinite(dateMs)) {
|
|
27
|
+
const diff = dateMs - Date.now()
|
|
28
|
+
return diff > 0 ? diff : 0
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const reset = response.headers.get('x-ratelimit-reset')
|
|
32
|
+
if (reset) {
|
|
33
|
+
const seconds = Number(reset)
|
|
34
|
+
if (Number.isFinite(seconds) && seconds > 0) {
|
|
35
|
+
if (seconds > 1000000000) {
|
|
36
|
+
const diff = (seconds * 1000) - Date.now()
|
|
37
|
+
return diff > 0 ? diff : 0
|
|
38
|
+
}
|
|
39
|
+
return Math.round(seconds * 1000)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return undefined
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function catalogRequestError(status, response) {
|
|
20
46
|
const error = new Error(`model catalog request failed with HTTP ${status}`)
|
|
21
47
|
error.status = Number(status)
|
|
48
|
+
const retryAfterMs = parseRetryAfter(response)
|
|
49
|
+
if (retryAfterMs !== undefined) error.retryAfterMs = retryAfterMs
|
|
22
50
|
return error
|
|
23
51
|
}
|
|
24
52
|
|
|
@@ -89,7 +117,11 @@ export async function retryWithBackoff(operation, {
|
|
|
89
117
|
} catch (error) {
|
|
90
118
|
lastError = error
|
|
91
119
|
if (attempt >= total || !isRetryableError(error)) throw error
|
|
92
|
-
const
|
|
120
|
+
const defaultDelayMs = Math.min(Math.max(0, Number(maxDelayMs) || 0), Math.max(0, Number(baseDelayMs) || 0) * (2 ** (attempt - 1)))
|
|
121
|
+
const retryAfterMs = Number(error?.retryAfterMs)
|
|
122
|
+
const delayMs = (Number.isFinite(retryAfterMs) && retryAfterMs > 0)
|
|
123
|
+
? Math.min(Math.max(0, Number(maxDelayMs) || 0), retryAfterMs)
|
|
124
|
+
: defaultDelayMs
|
|
93
125
|
onRetry?.({ attempt, nextAttempt: attempt + 1, delayMs, error })
|
|
94
126
|
await delay(delayMs, signal)
|
|
95
127
|
}
|
package/lib/scheduler.js
CHANGED
|
@@ -128,14 +128,14 @@ export function createSyncScheduler(sync, {
|
|
|
128
128
|
const config = getConfig() ?? {}
|
|
129
129
|
resetStates(config)
|
|
130
130
|
const values = [...state.values()]
|
|
131
|
-
const nextAt = values.length > 0 ? Math.min(...values.map((row) => row.nextAt)) : null
|
|
131
|
+
const nextAt = (timer !== null && values.length > 0) ? Math.min(...values.map((row) => row.nextAt)) : null
|
|
132
132
|
const lastRunAt = values.filter((row) => row.lastRunAt).reduce((max, row) => Math.max(max, row.lastRunAt), 0) || null
|
|
133
133
|
return {
|
|
134
134
|
active: timer !== null,
|
|
135
135
|
running,
|
|
136
136
|
lastRunAt,
|
|
137
137
|
nextRunAt: nextAt,
|
|
138
|
-
providers: Object.fromEntries(values.map((row) => [row.provider ?? '*', { lastRunAt: row.lastRunAt, nextRunAt: row.nextAt, lastStatus: row.lastStatus, ...(row.lastError ? { lastError: row.lastError } : {}) }])),
|
|
138
|
+
providers: Object.fromEntries(values.map((row) => [row.provider ?? '*', { lastRunAt: row.lastRunAt, nextRunAt: timer !== null ? row.nextAt : null, lastStatus: row.lastStatus, ...(row.lastError ? { lastError: row.lastError } : {}) }])),
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
141
|
|
package/lib/synchronizer.js
CHANGED
|
@@ -67,15 +67,41 @@ function diagnosticMessage(error) {
|
|
|
67
67
|
|
|
68
68
|
function adapterErrorType(error) {
|
|
69
69
|
if (error?.adapterCode === 'auth') return 'auth'
|
|
70
|
+
if (error?.adapterCode === 'rate-limit') return 'rate-limit'
|
|
70
71
|
if (error?.adapterCode === 'schema') return 'schema'
|
|
71
72
|
if (error?.adapterCode === 'endpoint') return 'endpoint'
|
|
72
73
|
if (error?.code === 'CREDENTIALS_UNAVAILABLE' || error?.code === 'AUTH') return 'credential'
|
|
73
74
|
const status = statusOfError(error)
|
|
74
75
|
if (status === 401 || status === 403) return 'auth'
|
|
76
|
+
if (status === 429) return 'rate-limit'
|
|
75
77
|
if (status) return 'http'
|
|
76
78
|
return 'endpoint'
|
|
77
79
|
}
|
|
78
80
|
|
|
81
|
+
async function replaceWithRetry(settings, ns, patchFn, attempts = 3) {
|
|
82
|
+
let lastError
|
|
83
|
+
for (let i = 0; i < attempts; i++) {
|
|
84
|
+
const section = settings?.get?.(ns)
|
|
85
|
+
const descriptor = descriptorOf(settings, ns)
|
|
86
|
+
if (!section || !descriptor || typeof settings.replace !== 'function') {
|
|
87
|
+
throw Object.assign(new Error(`${ns} settings are not writable`), { code: 'SETTINGS_UNAVAILABLE' })
|
|
88
|
+
}
|
|
89
|
+
const next = patchFn(structuredClone(section))
|
|
90
|
+
try {
|
|
91
|
+
await settings.replace(ns, next, descriptor.revision)
|
|
92
|
+
return next
|
|
93
|
+
} catch (err) {
|
|
94
|
+
lastError = err
|
|
95
|
+
const isConflict = err?.code === 'SETTINGS_CONFLICT' || err?.status === 409 || /conflict|revision/i.test(err?.message || '')
|
|
96
|
+
if (isConflict && i < attempts - 1) {
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
throw err
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
throw lastError
|
|
103
|
+
}
|
|
104
|
+
|
|
79
105
|
function rotationRefs(settings, provider) {
|
|
80
106
|
try {
|
|
81
107
|
const rows = settings?.get?.('dsh-key-rotation')?.providers
|
|
@@ -417,16 +443,13 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
|
|
|
417
443
|
const resultsToApply = results.filter((row) => row.status === 'ok' && row.changed && (!dryRun || providerAutoApply(row.provider)))
|
|
418
444
|
if (resultsToApply.length > 0) {
|
|
419
445
|
const settings = settingsOf(ctx)
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
next.providers = catalogPatch(next, row.provider, row.next).providers
|
|
428
|
-
}
|
|
429
|
-
await settings.replace(PIAI_NS, next, descriptor.revision)
|
|
446
|
+
await replaceWithRetry(settings, PIAI_NS, (section) => {
|
|
447
|
+
let next = section
|
|
448
|
+
for (const row of resultsToApply) {
|
|
449
|
+
next = catalogPatch(next, row.provider, row.next)
|
|
450
|
+
}
|
|
451
|
+
return next
|
|
452
|
+
})
|
|
430
453
|
result.applied = true
|
|
431
454
|
}
|
|
432
455
|
if (!dryRun || resultsToApply.length > 0) {
|
|
@@ -495,7 +518,14 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
|
|
|
495
518
|
// Use health as lightweight probe; latency is the signal
|
|
496
519
|
const result = await health({ provider })
|
|
497
520
|
const entry = result?.results?.find((r) => r.provider === provider)
|
|
498
|
-
return {
|
|
521
|
+
return {
|
|
522
|
+
provider,
|
|
523
|
+
model,
|
|
524
|
+
ok: entry?.status === 'ok',
|
|
525
|
+
latencyMs: Date.now() - start,
|
|
526
|
+
status: entry?.status ?? 'unknown',
|
|
527
|
+
...(entry?.status !== 'ok' ? { error: entry?.message || entry?.status || 'probe failed' } : {}),
|
|
528
|
+
}
|
|
499
529
|
}
|
|
500
530
|
|
|
501
531
|
async function rollbackHistory({ historyId, provider } = {}) {
|
|
@@ -518,8 +548,7 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
|
|
|
518
548
|
if (JSON.stringify(current) === JSON.stringify(target)) {
|
|
519
549
|
return { historyId, provider, models: target, applied: false, allowlistUntouched: true }
|
|
520
550
|
}
|
|
521
|
-
|
|
522
|
-
await settings.replace(PIAI_NS, next, descriptor.revision)
|
|
551
|
+
await replaceWithRetry(settings, PIAI_NS, (section) => catalogPatch(section, provider, target))
|
|
523
552
|
return { historyId, provider, models: target, applied: true, allowlistUntouched: true }
|
|
524
553
|
}
|
|
525
554
|
|
|
@@ -565,7 +594,6 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
|
|
|
565
594
|
const nextModels = selectedModels.length
|
|
566
595
|
? selectedModels.map((id) => availableById.get(id))
|
|
567
596
|
: (safeAvailable.length > 0 ? safeAvailable : current)
|
|
568
|
-
const next = catalogPatch(section, provider, nextModels)
|
|
569
597
|
const previousModelSelections = structuredClone(config.modelSelections ?? {})
|
|
570
598
|
const modelSelections = { ...previousModelSelections, [provider]: selectedModels }
|
|
571
599
|
|
|
@@ -573,7 +601,7 @@ export function createModelSynchronizer(ctx, { getConfig = () => ({}), saveConfi
|
|
|
573
601
|
// allowlist back so the two namespaces cannot be left disagreeing.
|
|
574
602
|
await saveConfig({ modelSelections })
|
|
575
603
|
try {
|
|
576
|
-
await settings
|
|
604
|
+
await replaceWithRetry(settings, PIAI_NS, (section) => catalogPatch(section, provider, nextModels))
|
|
577
605
|
} catch (error) {
|
|
578
606
|
try {
|
|
579
607
|
await saveConfig({ modelSelections: previousModelSelections })
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-model-sync",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
4
4
|
"description": "Automatic model catalog synchronization for API-key DeepSeek Harness providers.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
@@ -53,4 +53,4 @@
|
|
|
53
53
|
"@deepseek-ai/schemastery": "^3.18.1",
|
|
54
54
|
"@deepseek-ai/dsh-llm": "^0.1.0-rc.6"
|
|
55
55
|
}
|
|
56
|
-
}
|
|
56
|
+
}
|