@oar-sh/oar 0.9.22 → 0.9.24
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/CHANGELOG.md +258 -0
- package/README.md +9 -9
- package/bin/oar.js +82 -4
- package/package.json +16 -15
- package/server/agent-sdk-loader.mjs +116 -0
- package/server/agent-sdks/claude/entry.mjs +4 -0
- package/server/agent-sdks/claude/package-lock.json +1438 -0
- package/server/agent-sdks/claude/package.json +13 -0
- package/server/agent-sdks/cursor/entry-sqlite.mjs +2 -0
- package/server/agent-sdks/cursor/entry.mjs +3 -0
- package/server/agent-sdks/cursor/package-lock.json +171 -0
- package/server/agent-sdks/cursor/package.json +11 -0
- package/server/agent-sdks/koffi/entry.cjs +2 -0
- package/server/agent-sdks/koffi/package-lock.json +368 -0
- package/server/agent-sdks/koffi/package.json +10 -0
- package/server/claude-worker/claude-cli-pinned-env.mjs +4 -1
- package/server/claude-worker/claude-preview-tool.mjs +5 -2
- package/server/claude-worker/claude-remote-relay-tool.mjs +5 -2
- package/server/claude-worker/claude-sdk-adapter.mjs +4 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +7 -4
- package/server/cursor-worker/cursor-sdk-adapter.mjs +5 -3
- package/server/public/app/agent-sdk-ui.js +349 -0
- package/server/public/app/api-client.js +91 -8
- package/server/public/app/bootstrap.js +204 -69
- package/server/public/app/model-refresh-ui.mjs +413 -0
- package/server/public/app/no-conversation-state.mjs +5 -2
- package/server/public/app/provider-readiness.mjs +67 -4
- package/server/public/app/settings-modal.js +167 -0
- package/server/public/app/socket-handlers.js +21 -0
- package/server/public/index.html +95 -2
- package/server/routes/agent-sdk-routes.mjs +73 -0
- package/server/routes/messages-routes.mjs +102 -37
- package/server/routes/sessions-routes.mjs +236 -110
- package/server/server-runtime.mjs +416 -121
- package/server/services/about-service.mjs +26 -6
- package/server/services/agent-sdk-launch-gate.mjs +124 -0
- package/server/services/agent-sdk-paths.mjs +307 -0
- package/server/services/agent-sdk-store.mjs +1211 -0
- package/server/services/claude-api-settings-service.mjs +54 -17
- package/server/services/cli-install-service.mjs +3 -2
- package/server/services/copilot-model-discovery-service.mjs +7 -1
- package/server/services/database-file.mjs +17 -1
- package/server/services/deepseek-settings-service.mjs +58 -20
- package/server/services/earlier-database-service.mjs +5 -3
- package/server/services/file-serving.mjs +13 -0
- package/server/services/model-refresh-scheduler.mjs +153 -0
- package/server/services/model-variant-catalog-service.mjs +141 -9
- package/server/services/native-addon-build-service.mjs +224 -0
- package/server/services/oar-cli-helpers.mjs +76 -10
- package/server/services/oar-state-migration-service.mjs +2 -12
- package/server/services/oar-state-root.mjs +17 -0
- package/server/services/provider-model-refresh.mjs +326 -0
- package/server/services/provider-model-state.mjs +395 -0
- package/server/services/relay-console-log.mjs +2 -3
- package/server/services/relay-singleton-guard.mjs +72 -10
- package/server/services/remote-relay-loopback.mjs +3 -3
- package/server/services/sdk-session-sync-service.mjs +1 -1
- package/server/services/session-worker-launch-service.mjs +13 -0
- package/server/services/session-worker-supervisor-service.mjs +19 -1
- package/server/services/session-worker-websocket-service.mjs +15 -0
- package/server/services/update-install-service.mjs +34 -3
- package/server/services/worker-launch-note-service.mjs +14 -2
- package/server/services/worker-recycle-service.mjs +145 -11
- package/shared/model-refresh-schedule.mjs +120 -0
- package/shared/worker-runtime/direct-openai-image.mjs +8 -4
- package/server/services/package-data-rescue-preinstall.mjs +0 -22
- package/server/services/package-data-rescue.mjs +0 -139
- package/server/tty-console-bootstrap.mjs +0 -75
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
//
|
|
8
8
|
// One key per relay, stored like the DeepSeek and Cursor keys in the settings
|
|
9
9
|
// table and never sent to a browser. Next to it: the switch, the default
|
|
10
|
-
// model, the discovered models and
|
|
11
|
-
// optional monthly spend cap. Commit attribution, the background command
|
|
10
|
+
// model, the discovered models and which of them are on for the composer
|
|
11
|
+
// (provider-model-state.mjs), and an optional monthly spend cap. Commit attribution, the background command
|
|
12
12
|
// limit, the effort levels and the compaction window are the Claude tab's, so
|
|
13
13
|
// they are not stored here.
|
|
14
14
|
//
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
isClaudeLongContextModelId,
|
|
26
26
|
isSafeClaudeModelId,
|
|
27
27
|
} from '../../shared/model-id.mjs';
|
|
28
|
+
import { createProviderModelStateStore } from './provider-model-state.mjs';
|
|
28
29
|
import { DEFAULT_CLAUDE_REASONING_EFFORTS, withClaudeUltracodeTier } from './provider-reasoning-effort.mjs';
|
|
29
30
|
|
|
30
31
|
export const CLAUDE_API_PROVIDER_TYPE = 'claude-api';
|
|
@@ -34,6 +35,8 @@ export const CLAUDE_API_KEY_SETTING_KEY = 'claude_api_key';
|
|
|
34
35
|
export const CLAUDE_API_ENABLED_SETTING_KEY = 'claude_api_enabled';
|
|
35
36
|
export const CLAUDE_API_MODEL_SETTING_KEY = 'claude_api_model';
|
|
36
37
|
export const CLAUDE_API_MODELS_SETTING_KEY = 'claude_api_models';
|
|
38
|
+
// The allowlist before 0.9.24: read once to build the model map, then left
|
|
39
|
+
// as it is so an older version still finds it.
|
|
37
40
|
export const CLAUDE_API_ENABLED_MODELS_SETTING_KEY = 'claude_api_enabled_models';
|
|
38
41
|
export const CLAUDE_API_MONTHLY_CAP_SETTING_KEY = 'claude_api_monthly_cap_usd';
|
|
39
42
|
// The derived default a new key replaced, kept until that key's models are
|
|
@@ -180,8 +183,8 @@ function normalizeStoredModel(entry) {
|
|
|
180
183
|
}
|
|
181
184
|
|
|
182
185
|
/**
|
|
183
|
-
* Reads `{ apiKey?, enabled?, model?, enabledModels?,
|
|
184
|
-
* remove? }` from a request body. The key is never echoed in an error, and a
|
|
186
|
+
* Reads `{ apiKey?, enabled?, model?, enabledModels?, autoEnableNew?,
|
|
187
|
+
* monthlyCapUsd?, remove? }` from a request body. The key is never echoed in an error, and a
|
|
185
188
|
* Claude login token or an Admin key is refused by its prefix.
|
|
186
189
|
*/
|
|
187
190
|
export function parseClaudeApiSettingsUpdateRequest(body = {}) {
|
|
@@ -212,6 +215,14 @@ export function parseClaudeApiSettingsUpdateRequest(body = {}) {
|
|
|
212
215
|
if (!Array.isArray(payload.enabledModels)) return { ok: false, error: 'enabledModels must be a list' };
|
|
213
216
|
update.enabledModels = uniqueModels(payload.enabledModels);
|
|
214
217
|
}
|
|
218
|
+
if (hasOwn(payload, 'shownModels')) {
|
|
219
|
+
if (!Array.isArray(payload.shownModels)) return { ok: false, error: 'shownModels must be a list' };
|
|
220
|
+
update.shownModels = uniqueModels(payload.shownModels);
|
|
221
|
+
}
|
|
222
|
+
if (hasOwn(payload, 'autoEnableNew')) {
|
|
223
|
+
if (typeof payload.autoEnableNew !== 'boolean') return { ok: false, error: 'autoEnableNew must be true or false' };
|
|
224
|
+
update.autoEnableNew = payload.autoEnableNew;
|
|
225
|
+
}
|
|
215
226
|
if (hasOwn(payload, 'monthlyCapUsd')) {
|
|
216
227
|
const cap = parseClaudeApiMonthlyCap(payload.monthlyCapUsd);
|
|
217
228
|
if (!cap.ok) return { ok: false, error: cap.error };
|
|
@@ -240,7 +251,11 @@ export function createClaudeApiSettingsService({
|
|
|
240
251
|
// Where OAR_ANTHROPIC_API_BASE_URL is read (a loopback fake API in tests).
|
|
241
252
|
env = process.env,
|
|
242
253
|
now = () => new Date(),
|
|
254
|
+
// (result) after each discovery: the model refresh ledger's clock.
|
|
255
|
+
onModelsRefreshed = () => {},
|
|
243
256
|
} = {}) {
|
|
257
|
+
const modelState = createProviderModelStateStore({ readSetting, writeSetting, now });
|
|
258
|
+
|
|
244
259
|
function readClaudeSettings() {
|
|
245
260
|
try {
|
|
246
261
|
const settings = getClaudeProviderSettings();
|
|
@@ -321,10 +336,13 @@ export function createClaudeApiSettingsService({
|
|
|
321
336
|
const model = stored && isUsableModelId(stored) ? stored : defaultModelFor(offered, claudeSettings, carried);
|
|
322
337
|
const availableModels = uniqueModels([model, ...offered]);
|
|
323
338
|
const availableKeys = new Set(availableModels.map((id) => id.toLowerCase()));
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
339
|
+
// The default model always stays on; the others as the model map says.
|
|
340
|
+
const enabled = modelState.resolve(CLAUDE_API_PROVIDER_TYPE, {
|
|
341
|
+
availableModels,
|
|
342
|
+
defaultModel: model,
|
|
343
|
+
legacyAllowlist: () => uniqueModels(readJsonList(CLAUDE_API_ENABLED_MODELS_SETTING_KEY)),
|
|
344
|
+
});
|
|
345
|
+
const models = uniqueModels(enabled.models);
|
|
328
346
|
|
|
329
347
|
const detailsByBase = new Map(discovered.map((entry) => [entry.id.toLowerCase(), entry]));
|
|
330
348
|
const claudeEfforts = claudeSettings?.effortsByModel && typeof claudeSettings.effortsByModel === 'object'
|
|
@@ -381,6 +399,9 @@ export function createClaudeApiSettingsService({
|
|
|
381
399
|
models,
|
|
382
400
|
availableModels,
|
|
383
401
|
enabledModels: models,
|
|
402
|
+
autoEnableNew: enabled.autoEnableNew,
|
|
403
|
+
fallback: enabled.fallback,
|
|
404
|
+
unseenModels: enabled.unseen,
|
|
384
405
|
discovered: discovered.length > 0,
|
|
385
406
|
effortsByModel,
|
|
386
407
|
contextWindowsByModel,
|
|
@@ -463,9 +484,9 @@ export function createClaudeApiSettingsService({
|
|
|
463
484
|
activeQueueConversationCount: counts.queued,
|
|
464
485
|
};
|
|
465
486
|
}
|
|
487
|
+
// The model map stays: a key added again finds its models as they were.
|
|
466
488
|
deleteSetting(CLAUDE_API_KEY_SETTING_KEY);
|
|
467
489
|
deleteSetting(CLAUDE_API_MODELS_SETTING_KEY);
|
|
468
|
-
deleteSetting(CLAUDE_API_ENABLED_MODELS_SETTING_KEY);
|
|
469
490
|
deleteSetting(CLAUDE_API_CARRIED_MODEL_SETTING_KEY);
|
|
470
491
|
writeSetting(CLAUDE_API_ENABLED_SETTING_KEY, 'false', stamp);
|
|
471
492
|
return {
|
|
@@ -498,12 +519,16 @@ export function createClaudeApiSettingsService({
|
|
|
498
519
|
if (parsed.model) writeSetting(CLAUDE_API_MODEL_SETTING_KEY, parsed.model, stamp);
|
|
499
520
|
else deleteSetting(CLAUDE_API_MODEL_SETTING_KEY);
|
|
500
521
|
}
|
|
501
|
-
if (hasOwn(parsed, 'enabledModels')) {
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
522
|
+
if (hasOwn(parsed, 'enabledModels') || hasOwn(parsed, 'autoEnableNew')) {
|
|
523
|
+
// The models Select Models showed are set as ticked; an empty list
|
|
524
|
+
// leaves the default model alone, never all.
|
|
525
|
+
modelState.select(CLAUDE_API_PROVIDER_TYPE, {
|
|
526
|
+
enabledModels: parsed.enabledModels,
|
|
527
|
+
// The rows the browser drew; an older page sends none.
|
|
528
|
+
shownModels: parsed.shownModels || previous.availableModels,
|
|
529
|
+
defaultModel: getSettings().model,
|
|
530
|
+
autoEnableNew: parsed.autoEnableNew,
|
|
531
|
+
});
|
|
507
532
|
}
|
|
508
533
|
if (hasOwn(parsed, 'monthlyCapUsd')) {
|
|
509
534
|
if (parsed.monthlyCapUsd === null) deleteSetting(CLAUDE_API_MONTHLY_CAP_SETTING_KEY);
|
|
@@ -530,6 +555,12 @@ export function createClaudeApiSettingsService({
|
|
|
530
555
|
* new key's.
|
|
531
556
|
*/
|
|
532
557
|
async function refreshModels() {
|
|
558
|
+
const result = await discoverModels();
|
|
559
|
+
try { onModelsRefreshed(result); } catch { /* the clock is best-effort */ }
|
|
560
|
+
return result;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async function discoverModels() {
|
|
533
564
|
const settings = getSettings();
|
|
534
565
|
if (!settings.configured) return { ok: false, models: settings.availableModels, error: 'Claude API key is not configured' };
|
|
535
566
|
const redact = (text) => String(text ?? '').split(settings.apiKey).join('[redacted]');
|
|
@@ -560,7 +591,9 @@ export function createClaudeApiSettingsService({
|
|
|
560
591
|
writeSetting?.(CLAUDE_API_MODELS_SETTING_KEY, JSON.stringify(models), now().toISOString());
|
|
561
592
|
// The key's own list now decides the default.
|
|
562
593
|
deleteSetting?.(CLAUDE_API_CARRIED_MODEL_SETTING_KEY);
|
|
563
|
-
|
|
594
|
+
const availableModels = getSettings().availableModels;
|
|
595
|
+
const { added } = modelState.reconcile(CLAUDE_API_PROVIDER_TYPE, { availableModels });
|
|
596
|
+
return { ok: true, models: availableModels, newModels: added, error: null };
|
|
564
597
|
} catch (error) {
|
|
565
598
|
return {
|
|
566
599
|
ok: false,
|
|
@@ -571,5 +604,9 @@ export function createClaudeApiSettingsService({
|
|
|
571
604
|
}
|
|
572
605
|
}
|
|
573
606
|
|
|
574
|
-
|
|
607
|
+
function markModelsSeen() {
|
|
608
|
+
return modelState.markSeen(CLAUDE_API_PROVIDER_TYPE);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
return { getSettings, describe, update, refreshModels, contextWindowFor, markModelsSeen };
|
|
575
612
|
}
|
|
@@ -70,8 +70,9 @@ const GENERIC_VERSION_PATTERN = /(\d+\.\d+(?:\.\d+)*)/;
|
|
|
70
70
|
/**
|
|
71
71
|
* The frozen descriptor table: the only place an install command exists.
|
|
72
72
|
*
|
|
73
|
-
* Cursor is deliberately absent — `@cursor/sdk` is
|
|
74
|
-
*
|
|
73
|
+
* Cursor is deliberately absent — `@cursor/sdk` is an npm package the relay
|
|
74
|
+
* installs on demand (agent-sdk-store.mjs) and the relay never invokes a
|
|
75
|
+
* `cursor-agent` binary, so an install button there would
|
|
75
76
|
* install a CLI nothing runs (plan §2.4). Adding it back later is a data change
|
|
76
77
|
* here, not a code change.
|
|
77
78
|
*
|
|
@@ -44,6 +44,9 @@ export function createCopilotModelDiscoveryService({
|
|
|
44
44
|
updateModelCatalog,
|
|
45
45
|
timeoutMs = DEFAULT_COPILOT_MODEL_DISCOVERY_TIMEOUT_MS,
|
|
46
46
|
bootDelayMs = DEFAULT_COPILOT_MODEL_DISCOVERY_BOOT_DELAY_MS,
|
|
47
|
+
// (result, reason) after each refresh that got past the CLI check: the
|
|
48
|
+
// model refresh ledger's clock (the boot refresh included).
|
|
49
|
+
onRefreshed = () => {},
|
|
47
50
|
logger = console,
|
|
48
51
|
} = {}) {
|
|
49
52
|
if (typeof createClient !== 'function' || typeof updateModelCatalog !== 'function') {
|
|
@@ -151,7 +154,10 @@ export function createCopilotModelDiscoveryService({
|
|
|
151
154
|
}
|
|
152
155
|
activeRefresh = null;
|
|
153
156
|
}
|
|
154
|
-
})()
|
|
157
|
+
})().then((result) => {
|
|
158
|
+
try { onRefreshed(result, reason); } catch { /* the clock is best-effort */ }
|
|
159
|
+
return result;
|
|
160
|
+
});
|
|
155
161
|
return activeRefresh;
|
|
156
162
|
}
|
|
157
163
|
|
|
@@ -27,7 +27,23 @@ const CHECK_DIR_PREFIX = '.database-file-check-';
|
|
|
27
27
|
// one's work, not no rows: every start writes these settings (seen on a
|
|
28
28
|
// real start of 0.9.19 on an empty folder). A later boot-time key goes here
|
|
29
29
|
// deliberately; any other key makes the start refuse.
|
|
30
|
-
|
|
30
|
+
// Since 0.9.24 a start also writes each provider's model map as it finds
|
|
31
|
+
// it (provider-model-state.mjs) and the last model refresh. A version that
|
|
32
|
+
// runs on copilot.db writes none of them; a start of this one, which the
|
|
33
|
+
// browser test uses as its stand-in, writes several.
|
|
34
|
+
export const STARTUP_SETTING_KEYS = Object.freeze([
|
|
35
|
+
'push_vapid_public_key',
|
|
36
|
+
'push_vapid_private_key',
|
|
37
|
+
'relay_last_exit',
|
|
38
|
+
'copilot_model_state',
|
|
39
|
+
'openai_model_state',
|
|
40
|
+
'claude_model_state',
|
|
41
|
+
'cursor_model_state',
|
|
42
|
+
'grok_model_state',
|
|
43
|
+
'deepseek_model_state',
|
|
44
|
+
'claude_api_model_state',
|
|
45
|
+
'model_refresh_last',
|
|
46
|
+
]);
|
|
31
47
|
// And the tables such a start fills that hold no one's work: the model
|
|
32
48
|
// catalog and its refresh state, the restart orchestrator's one row. (The
|
|
33
49
|
// shadow tables of the message search are left out too: they follow
|
|
@@ -4,10 +4,11 @@
|
|
|
4
4
|
//
|
|
5
5
|
// One API key per relay, stored like the OpenAI and Cursor keys in the
|
|
6
6
|
// settings table and never sent to the browser. Next to it: the switch, the
|
|
7
|
-
// default model, the discovered models and
|
|
8
|
-
// composer, an optional base URL, and the commit
|
|
9
|
-
// chats. Discovery asks DeepSeek's `GET /models`
|
|
10
|
-
// when it has not run or failed, the built-in list
|
|
7
|
+
// default model, the discovered models and which of them are on for the
|
|
8
|
+
// composer (provider-model-state.mjs), an optional base URL, and the commit
|
|
9
|
+
// attribution of DeepSeek chats. Discovery asks DeepSeek's `GET /models`
|
|
10
|
+
// (shared/deepseek-api.mjs); when it has not run or failed, the built-in list
|
|
11
|
+
// stands in.
|
|
11
12
|
|
|
12
13
|
import { createDeepSeekApiClient, normalizeDeepSeekBaseUrl } from '../../shared/deepseek-api.mjs';
|
|
13
14
|
import {
|
|
@@ -22,11 +23,14 @@ import {
|
|
|
22
23
|
} from '../../shared/deepseek-models.mjs';
|
|
23
24
|
import { DEEPSEEK_PRICE_CURRENCIES } from '../../shared/deepseek-pricing.mjs';
|
|
24
25
|
import { isSafeDeepSeekModelId } from '../../shared/model-id.mjs';
|
|
26
|
+
import { createProviderModelStateStore } from './provider-model-state.mjs';
|
|
25
27
|
|
|
26
28
|
export const DEEPSEEK_API_KEY_SETTING_KEY = 'deepseek_api_key';
|
|
27
29
|
export const DEEPSEEK_ENABLED_SETTING_KEY = 'deepseek_enabled';
|
|
28
30
|
export const DEEPSEEK_MODEL_SETTING_KEY = 'deepseek_model';
|
|
29
31
|
export const DEEPSEEK_MODELS_SETTING_KEY = 'deepseek_models';
|
|
32
|
+
// The allowlist before 0.9.24: read once to build the model map, then left
|
|
33
|
+
// as it is so an older version still finds it.
|
|
30
34
|
export const DEEPSEEK_ENABLED_MODELS_SETTING_KEY = 'deepseek_enabled_models';
|
|
31
35
|
export const DEEPSEEK_BASE_URL_SETTING_KEY = 'deepseek_base_url';
|
|
32
36
|
export const DEEPSEEK_ATTRIBUTION_SETTING_KEY = 'deepseek_attribution';
|
|
@@ -95,9 +99,9 @@ function parseMonthlyBudgetCurrency(value) {
|
|
|
95
99
|
}
|
|
96
100
|
|
|
97
101
|
/**
|
|
98
|
-
* Reads `{ apiKey?, enabled?, model?, enabledModels?,
|
|
99
|
-
* monthlyBudget?, monthlyBudgetCurrency?,
|
|
100
|
-
* startingBalanceEdited?, remove? }` from a
|
|
102
|
+
* Reads `{ apiKey?, enabled?, model?, enabledModels?, autoEnableNew?,
|
|
103
|
+
* baseUrl?, attribution?, monthlyBudget?, monthlyBudgetCurrency?,
|
|
104
|
+
* startingBalance?, startingBalanceEdited?, remove? }` from a
|
|
101
105
|
* request body. The
|
|
102
106
|
* key is never echoed in an error.
|
|
103
107
|
*/
|
|
@@ -124,6 +128,14 @@ export function parseDeepSeekSettingsUpdateRequest(body = {}) {
|
|
|
124
128
|
if (!Array.isArray(payload.enabledModels)) return { ok: false, error: 'enabledModels must be a list' };
|
|
125
129
|
update.enabledModels = uniqueModels(payload.enabledModels);
|
|
126
130
|
}
|
|
131
|
+
if (hasOwn(payload, 'shownModels')) {
|
|
132
|
+
if (!Array.isArray(payload.shownModels)) return { ok: false, error: 'shownModels must be a list' };
|
|
133
|
+
update.shownModels = uniqueModels(payload.shownModels);
|
|
134
|
+
}
|
|
135
|
+
if (hasOwn(payload, 'autoEnableNew')) {
|
|
136
|
+
if (typeof payload.autoEnableNew !== 'boolean') return { ok: false, error: 'autoEnableNew must be true or false' };
|
|
137
|
+
update.autoEnableNew = payload.autoEnableNew;
|
|
138
|
+
}
|
|
127
139
|
if (hasOwn(payload, 'baseUrl')) {
|
|
128
140
|
const normalized = normalizeDeepSeekBaseUrl(payload.baseUrl === null ? '' : payload.baseUrl);
|
|
129
141
|
if (!normalized.ok) return { ok: false, error: normalized.error };
|
|
@@ -168,7 +180,11 @@ export function createDeepSeekSettingsService({
|
|
|
168
180
|
listProviderCandidates = () => [],
|
|
169
181
|
fetchImpl = globalThis.fetch,
|
|
170
182
|
now = () => new Date(),
|
|
183
|
+
// (result) after each discovery: the model refresh ledger's clock.
|
|
184
|
+
onModelsRefreshed = () => {},
|
|
171
185
|
} = {}) {
|
|
186
|
+
const modelState = createProviderModelStateStore({ readSetting, writeSetting, now });
|
|
187
|
+
|
|
172
188
|
function readList(settingKey) {
|
|
173
189
|
try {
|
|
174
190
|
const parsed = JSON.parse(readSetting(settingKey) || '[]');
|
|
@@ -220,10 +236,13 @@ export function createDeepSeekSettingsService({
|
|
|
220
236
|
const model = readModel();
|
|
221
237
|
const discovered = readList(DEEPSEEK_MODELS_SETTING_KEY);
|
|
222
238
|
const availableModels = uniqueModels([model, ...(discovered.length ? discovered : DEFAULT_DEEPSEEK_MODELS)]);
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
239
|
+
// The default model always stays on; the others as the model map says.
|
|
240
|
+
const enabled = modelState.resolve(DEEPSEEK_PROVIDER_TYPE, {
|
|
241
|
+
availableModels,
|
|
242
|
+
defaultModel: model,
|
|
243
|
+
legacyAllowlist: () => readList(DEEPSEEK_ENABLED_MODELS_SETTING_KEY),
|
|
244
|
+
});
|
|
245
|
+
const models = uniqueModels(enabled.models);
|
|
227
246
|
return {
|
|
228
247
|
configured: !!apiKey,
|
|
229
248
|
enabled: !!apiKey && (enabledSetting === '' || enabledSetting === 'true'),
|
|
@@ -231,6 +250,9 @@ export function createDeepSeekSettingsService({
|
|
|
231
250
|
model,
|
|
232
251
|
models,
|
|
233
252
|
availableModels,
|
|
253
|
+
autoEnableNew: enabled.autoEnableNew,
|
|
254
|
+
fallback: enabled.fallback,
|
|
255
|
+
unseenModels: enabled.unseen,
|
|
234
256
|
discovered: discovered.length > 0,
|
|
235
257
|
baseUrl: readBaseUrl(),
|
|
236
258
|
attribution: readAttribution(),
|
|
@@ -291,9 +313,9 @@ export function createDeepSeekSettingsService({
|
|
|
291
313
|
activeQueueConversationCount: counts.queued,
|
|
292
314
|
};
|
|
293
315
|
}
|
|
316
|
+
// The model map stays: a key added again finds its models as they were.
|
|
294
317
|
deleteSetting(DEEPSEEK_API_KEY_SETTING_KEY);
|
|
295
318
|
deleteSetting(DEEPSEEK_MODELS_SETTING_KEY);
|
|
296
|
-
deleteSetting(DEEPSEEK_ENABLED_MODELS_SETTING_KEY);
|
|
297
319
|
writeSetting(DEEPSEEK_ENABLED_SETTING_KEY, 'false', stamp);
|
|
298
320
|
return { ok: true, removed: true, keyChanged: previous.configured, discover: false, settings: describe() };
|
|
299
321
|
}
|
|
@@ -315,12 +337,16 @@ export function createDeepSeekSettingsService({
|
|
|
315
337
|
}
|
|
316
338
|
if (hasOwn(parsed, 'enabled') || keyChanged) writeSetting(DEEPSEEK_ENABLED_SETTING_KEY, nextEnabled ? 'true' : 'false', stamp);
|
|
317
339
|
if (hasOwn(parsed, 'model')) writeSetting(DEEPSEEK_MODEL_SETTING_KEY, parsed.model, stamp);
|
|
318
|
-
if (hasOwn(parsed, 'enabledModels')) {
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
340
|
+
if (hasOwn(parsed, 'enabledModels') || hasOwn(parsed, 'autoEnableNew')) {
|
|
341
|
+
// The models Select Models showed are set as ticked; an empty list
|
|
342
|
+
// leaves the default model alone, never all.
|
|
343
|
+
modelState.select(DEEPSEEK_PROVIDER_TYPE, {
|
|
344
|
+
enabledModels: parsed.enabledModels,
|
|
345
|
+
// The rows the browser drew; an older page sends none.
|
|
346
|
+
shownModels: parsed.shownModels || previous.availableModels,
|
|
347
|
+
defaultModel: readModel(),
|
|
348
|
+
autoEnableNew: parsed.autoEnableNew,
|
|
349
|
+
});
|
|
324
350
|
}
|
|
325
351
|
if (hasOwn(parsed, 'attribution')) writeSetting(DEEPSEEK_ATTRIBUTION_SETTING_KEY, parsed.attribution, stamp);
|
|
326
352
|
if (hasOwn(parsed, 'monthlyBudget')) {
|
|
@@ -361,6 +387,12 @@ export function createDeepSeekSettingsService({
|
|
|
361
387
|
* failure keeps what was stored, so the pickers never empty out.
|
|
362
388
|
*/
|
|
363
389
|
async function refreshModels() {
|
|
390
|
+
const result = await discoverModels();
|
|
391
|
+
try { onModelsRefreshed(result); } catch { /* the clock is best-effort */ }
|
|
392
|
+
return result;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async function discoverModels() {
|
|
364
396
|
const settings = getSettings();
|
|
365
397
|
if (!settings.configured) return { ok: false, models: settings.availableModels, error: 'DeepSeek API key is not configured' };
|
|
366
398
|
try {
|
|
@@ -370,11 +402,17 @@ export function createDeepSeekSettingsService({
|
|
|
370
402
|
return { ok: false, models: settings.availableModels, error: 'DeepSeek listed no DeepSeek models' };
|
|
371
403
|
}
|
|
372
404
|
writeSetting?.(DEEPSEEK_MODELS_SETTING_KEY, JSON.stringify(models), now().toISOString());
|
|
373
|
-
|
|
405
|
+
const availableModels = getSettings().availableModels;
|
|
406
|
+
const { added } = modelState.reconcile(DEEPSEEK_PROVIDER_TYPE, { availableModels });
|
|
407
|
+
return { ok: true, models: availableModels, newModels: added, error: null };
|
|
374
408
|
} catch (error) {
|
|
375
409
|
return { ok: false, models: settings.availableModels, error: error?.message || 'DeepSeek model discovery failed', code: error?.code || null };
|
|
376
410
|
}
|
|
377
411
|
}
|
|
378
412
|
|
|
379
|
-
|
|
413
|
+
function markModelsSeen() {
|
|
414
|
+
return modelState.markSeen(DEEPSEEK_PROVIDER_TYPE);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return { getSettings, describe, update, refreshModels, markModelsSeen };
|
|
380
418
|
}
|
|
@@ -9,10 +9,10 @@ import {
|
|
|
9
9
|
} from './database-file.mjs';
|
|
10
10
|
import { resolveOarRoot } from './oar-state-migration-service.mjs';
|
|
11
11
|
import { resolveStateLayout } from './oar-state-layout.mjs';
|
|
12
|
-
import { defaultIsProcessAlive, lockHolderMayRun, processStartedAt as defaultProcessStartedAt } from './relay-singleton-guard.mjs';
|
|
12
|
+
import { defaultIsProcessAlive, lockHolderMayRun, processImageName, processStartedAt as defaultProcessStartedAt } from './relay-singleton-guard.mjs';
|
|
13
13
|
|
|
14
14
|
const SHA256_NAME = /^[a-f0-9]{64}$/;
|
|
15
|
-
// The install-time rescue
|
|
15
|
+
// The copies the install-time rescue of 0.9.14 to 0.9.22 made, in the data folder.
|
|
16
16
|
export const RESCUE_DIR_NAME = 'rescued-package-data';
|
|
17
17
|
// What was looked at already: never offered again unless it changed.
|
|
18
18
|
export const CHECKED_FILE = 'earlier-databases.json';
|
|
@@ -94,6 +94,8 @@ function adopt({
|
|
|
94
94
|
platform = process.platform,
|
|
95
95
|
isProcessAlive = defaultIsProcessAlive,
|
|
96
96
|
processStartedAt = (pid) => defaultProcessStartedAt(pid, { platform }),
|
|
97
|
+
// What a pid runs, when its start cannot be told (Windows without koffi).
|
|
98
|
+
imageNameOf = processImageName,
|
|
97
99
|
bootTime = Date.now() - os.uptime() * 1000,
|
|
98
100
|
now = () => new Date(),
|
|
99
101
|
clock = Date.now,
|
|
@@ -234,7 +236,7 @@ function adopt({
|
|
|
234
236
|
fsImpl,
|
|
235
237
|
rename,
|
|
236
238
|
exists,
|
|
237
|
-
lockMayRun: (lock) => lockHolderMayRun({ lock, isProcessAlive, startedAtOf: processStartedAt, bootTime }),
|
|
239
|
+
lockMayRun: (lock) => lockHolderMayRun({ lock, isProcessAlive, startedAtOf: processStartedAt, bootTime, platform, imageNameOf }),
|
|
238
240
|
normalize,
|
|
239
241
|
progress,
|
|
240
242
|
});
|
|
@@ -60,6 +60,19 @@ export function withFileVersion(url, version) {
|
|
|
60
60
|
return appendUrlParam(String(url || ''), 'v', String(version || ''));
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* One of the relay's own files (sw.js, the socket.io client) through
|
|
65
|
+
* res.sendFile, rooted at its folder. Given a bare absolute path, express 5's
|
|
66
|
+
* send refuses any path with a dot folder in it (dotfiles 'ignore' looks at
|
|
67
|
+
* every segment without a root): a 404 for an install under ~/.oar, nvm or
|
|
68
|
+
* ~/.npm-global. With the folder as root only the file name is looked at.
|
|
69
|
+
*/
|
|
70
|
+
export function sendOwnFile(res, absolutePath, next) {
|
|
71
|
+
res.sendFile(path.basename(absolutePath), { root: path.dirname(absolutePath) }, (error) => {
|
|
72
|
+
if (error) next(error);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
63
76
|
/**
|
|
64
77
|
* `cacheControl` replaces the no-store default for content whose URL names
|
|
65
78
|
* its bytes (an upload addressed by its hash): such an answer may be kept.
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The background "Check for new models" (Settings → General → Models).
|
|
4
|
+
//
|
|
5
|
+
// A minute after boot, then every quarter of an hour, the scheduler asks
|
|
6
|
+
// which providers are due and refreshes those through the same helper as the
|
|
7
|
+
// Select Models Refresh button (provider-model-refresh.mjs), with reason
|
|
8
|
+
// `background`. A provider is due when the check is on, the provider is not
|
|
9
|
+
// left out, it is enabled and set up, its last successful refresh (by
|
|
10
|
+
// anything: the button, a save, a login, the boot) is at least the interval
|
|
11
|
+
// old, and its last failure at least an hour old. A provider never refreshed
|
|
12
|
+
// is due at the first tick, which is how an overdue provider is checked after
|
|
13
|
+
// boot. Time and timers are injected so a test can drive it with a fake clock.
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
MODEL_REFRESH_FAILURE_RETRY_MS,
|
|
17
|
+
MODEL_REFRESH_FIRST_TICK_MS,
|
|
18
|
+
MODEL_REFRESH_PROVIDERS,
|
|
19
|
+
MODEL_REFRESH_TICK_MS,
|
|
20
|
+
} from '../../shared/model-refresh-schedule.mjs';
|
|
21
|
+
import { modelRefreshErrorSummary } from './provider-model-refresh.mjs';
|
|
22
|
+
|
|
23
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
24
|
+
|
|
25
|
+
function timeOf(value) {
|
|
26
|
+
const ms = Date.parse(String(value || ''));
|
|
27
|
+
return Number.isFinite(ms) ? ms : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function notInFuture(ms, nowMs) {
|
|
31
|
+
return ms !== null && ms > nowMs ? null : ms;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The providers due at `nowMs`, in the canonical order. `settings` is
|
|
36
|
+
* `{ auto, intervalHours, excluded }`, `last` the ledger's entries, `table`
|
|
37
|
+
* the provider table (buildProviderRefreshTable).
|
|
38
|
+
*/
|
|
39
|
+
export function dueModelRefreshProviders({ settings = {}, last = {}, table = {}, nowMs = Date.now() } = {}) {
|
|
40
|
+
if (settings.auto === false) return [];
|
|
41
|
+
const intervalMs = Math.max(1, Number(settings.intervalHours) || 24) * HOUR_MS;
|
|
42
|
+
const excluded = new Set(Array.isArray(settings.excluded) ? settings.excluded : []);
|
|
43
|
+
const due = [];
|
|
44
|
+
for (const { id } of MODEL_REFRESH_PROVIDERS) {
|
|
45
|
+
const entry = table[id];
|
|
46
|
+
if (!entry || excluded.has(id) || typeof entry.refresh !== 'function') continue;
|
|
47
|
+
let setUp = false;
|
|
48
|
+
try {
|
|
49
|
+
setUp = entry.isSetUp?.() === true;
|
|
50
|
+
} catch {
|
|
51
|
+
setUp = false;
|
|
52
|
+
}
|
|
53
|
+
if (!setUp) continue;
|
|
54
|
+
// A stamp later than now (a clock set back, a hand edit) cannot be
|
|
55
|
+
// trusted: the provider is due rather than skipped until then.
|
|
56
|
+
const lastOk = notInFuture(timeOf(last[id]?.lastOkAt), nowMs);
|
|
57
|
+
const lastFailure = notInFuture(timeOf(last[id]?.lastFailureAt), nowMs);
|
|
58
|
+
if (lastOk !== null && nowMs - lastOk < intervalMs) continue;
|
|
59
|
+
if (lastFailure !== null && nowMs - lastFailure < MODEL_REFRESH_FAILURE_RETRY_MS) continue;
|
|
60
|
+
due.push(id);
|
|
61
|
+
}
|
|
62
|
+
return due;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createModelRefreshScheduler({
|
|
66
|
+
// () => { auto, intervalHours, excluded }
|
|
67
|
+
getSettings = () => ({ auto: true, intervalHours: 24, excluded: [] }),
|
|
68
|
+
// () => { <provider>: { lastOkAt, lastFailureAt } } (the ledger)
|
|
69
|
+
getLast = () => ({}),
|
|
70
|
+
// () => the provider table (buildProviderRefreshTable)
|
|
71
|
+
getTable = () => ({}),
|
|
72
|
+
// ({ providers, reason }) => Promise<{ results, changed }>
|
|
73
|
+
refreshProviders = async () => ({ results: {}, changed: false }),
|
|
74
|
+
now = () => Date.now(),
|
|
75
|
+
setTimeoutFn = setTimeout,
|
|
76
|
+
clearTimeoutFn = clearTimeout,
|
|
77
|
+
firstTickMs = MODEL_REFRESH_FIRST_TICK_MS,
|
|
78
|
+
tickMs = MODEL_REFRESH_TICK_MS,
|
|
79
|
+
logger = console,
|
|
80
|
+
} = {}) {
|
|
81
|
+
let timer = null;
|
|
82
|
+
let running = null;
|
|
83
|
+
let disposed = false;
|
|
84
|
+
// Set by the routes layer, which owns the models_updated payload.
|
|
85
|
+
let onModelsChanged = () => {};
|
|
86
|
+
|
|
87
|
+
function schedule(delayMs) {
|
|
88
|
+
if (disposed) return;
|
|
89
|
+
if (timer) clearTimeoutFn(timer);
|
|
90
|
+
timer = setTimeoutFn(() => {
|
|
91
|
+
timer = null;
|
|
92
|
+
void tick().finally(() => schedule(tickMs));
|
|
93
|
+
}, Math.max(0, Number(delayMs) || 0));
|
|
94
|
+
timer?.unref?.();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** One look: refreshes what is due. Never rejects; one run at a time. */
|
|
98
|
+
async function tick() {
|
|
99
|
+
if (disposed) return { providers: [], results: {}, changed: false };
|
|
100
|
+
if (running) return running;
|
|
101
|
+
// Cleared in a .finally of its own: a run with nothing due settles
|
|
102
|
+
// before the assignment would otherwise have happened.
|
|
103
|
+
running = (async () => {
|
|
104
|
+
let providers = [];
|
|
105
|
+
try {
|
|
106
|
+
providers = dueModelRefreshProviders({ settings: getSettings(), last: getLast(), table: getTable(), nowMs: now() });
|
|
107
|
+
if (!providers.length) return { providers, results: {}, changed: false };
|
|
108
|
+
logger.log?.(`[model-refresh] background model refresh: ${providers.join(', ')}`);
|
|
109
|
+
const { results = {}, changed = false } = await refreshProviders({ providers, reason: 'background' }) || {};
|
|
110
|
+
const failed = providers.filter((id) => results[id] && results[id].ok !== true && results[id].skipped !== true);
|
|
111
|
+
if (failed.length) {
|
|
112
|
+
// The provider and the kind of failure only: an answer's body may
|
|
113
|
+
// echo what was sent with the request.
|
|
114
|
+
logger.warn?.(`[model-refresh] background model refresh failed for ${failed.map((id) => `${id} (${modelRefreshErrorSummary(results[id].error)})`).join(', ')}`);
|
|
115
|
+
}
|
|
116
|
+
if (changed && !disposed) {
|
|
117
|
+
try {
|
|
118
|
+
onModelsChanged();
|
|
119
|
+
} catch (error) {
|
|
120
|
+
logger.warn?.(`[model-refresh] models-changed listener failed: ${error?.message || error}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { providers, results, changed };
|
|
124
|
+
} catch (error) {
|
|
125
|
+
logger.warn?.(`[model-refresh] background model refresh failed: ${modelRefreshErrorSummary(error?.message || error)}`);
|
|
126
|
+
return { providers, results: {}, changed: false };
|
|
127
|
+
}
|
|
128
|
+
})().finally(() => {
|
|
129
|
+
running = null;
|
|
130
|
+
});
|
|
131
|
+
return running;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function start() {
|
|
135
|
+
if (disposed || timer) return;
|
|
136
|
+
schedule(firstTickMs);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function setOnModelsChanged(listener) {
|
|
140
|
+
onModelsChanged = typeof listener === 'function' ? listener : () => {};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function dispose() {
|
|
144
|
+
disposed = true;
|
|
145
|
+
if (timer) {
|
|
146
|
+
clearTimeoutFn(timer);
|
|
147
|
+
timer = null;
|
|
148
|
+
}
|
|
149
|
+
try { await running; } catch { /* tick() never rejects */ }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { start, tick, dispose, setOnModelsChanged, isStarted: () => timer !== null };
|
|
153
|
+
}
|