@oar-sh/oar 0.9.23 → 0.9.25
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/.github/extensions/web-relay/skills/reasoning-stream.mjs +3 -1
- package/CHANGELOG.md +307 -0
- package/README.md +12 -11
- package/bin/oar.js +54 -4
- package/package.json +11 -7
- 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-preview-tool.mjs +5 -2
- package/server/claude-worker/claude-remote-relay-tool.mjs +20 -2
- package/server/claude-worker/claude-sdk-adapter.mjs +4 -1
- package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +13 -8
- package/server/cursor-worker/cursor-sdk-adapter.mjs +5 -3
- package/server/cursor-worker/sdk-message-normalizer.mjs +13 -0
- package/server/db-schema.mjs +19 -0
- package/server/grok-worker/sdk-message-normalizer.mjs +3 -0
- package/server/migrations/0039-media-index.mjs +167 -0
- package/server/migrations/0040-image-session-defaults.mjs +93 -0
- package/server/public/app/agent-sdk-ui.js +349 -0
- package/server/public/app/api-client.js +149 -8
- package/server/public/app/ask-user-view.js +2 -1
- package/server/public/app/attachments-view.js +66 -9
- package/server/public/app/bootstrap.js +289 -94
- package/server/public/app/chat-actions-menu-state.mjs +1 -0
- package/server/public/app/context-media-tab.js +143 -0
- package/server/public/app/conversation-view.js +90 -5
- package/server/public/app/image-composer-ui.js +518 -0
- package/server/public/app/image-turn-placeholder.mjs +233 -0
- package/server/public/app/journal-view.js +12 -2
- package/server/public/app/media-gallery-model.mjs +202 -0
- package/server/public/app/media-gallery-ui.js +524 -0
- package/server/public/app/media-view.js +79 -0
- package/server/public/app/model-refresh-ui.mjs +413 -0
- package/server/public/app/no-conversation-state.mjs +5 -2
- package/server/public/app/openai-image-composer.mjs +492 -0
- package/server/public/app/openai-image-cost-ui.js +426 -0
- package/server/public/app/openai-image-spend.mjs +443 -0
- package/server/public/app/plan-usage-view.mjs +38 -6
- package/server/public/app/provider-readiness.mjs +67 -4
- package/server/public/app/relay-error-ctas.mjs +5 -0
- package/server/public/app/session-history-ui.js +2 -0
- package/server/public/app/settings-modal.js +171 -0
- package/server/public/app/socket-handlers.js +47 -0
- package/server/public/app/tab-strip-fade.mjs +1 -1
- package/server/public/app/viewer-gallery.mjs +23 -1
- package/server/public/index.html +478 -9
- package/server/relay-tools.md +1 -0
- package/server/repositories/image-conversation-repository.mjs +69 -0
- package/server/repositories/media-repository.mjs +305 -0
- package/server/routes/agent-sdk-routes.mjs +73 -0
- package/server/routes/media-routes.mjs +361 -0
- package/server/routes/messages-routes.mjs +1467 -343
- package/server/routes/sessions-routes.mjs +596 -123
- package/server/server-runtime.mjs +414 -81
- 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 +23 -5
- package/server/services/deepseek-settings-service.mjs +58 -20
- package/server/services/earlier-database-service.mjs +4 -2
- package/server/services/generated-image-cleanup-service.mjs +2 -1
- package/server/services/generated-image-files.mjs +110 -0
- package/server/services/image-input-paths.mjs +389 -0
- package/server/services/image-operation-service.mjs +6 -2
- package/server/services/media-extract.mjs +653 -0
- package/server/services/media-index-service.mjs +221 -0
- package/server/services/model-refresh-scheduler.mjs +153 -0
- package/server/services/model-variant-catalog-service.mjs +141 -9
- package/server/services/oar-cli-helpers.mjs +72 -9
- package/server/services/oar-state-migration-service.mjs +2 -12
- package/server/services/oar-state-root.mjs +17 -0
- package/server/services/openai-image-preview-turns.mjs +330 -0
- package/server/services/openai-image-spend-service.mjs +516 -0
- package/server/services/openai-image-stream.mjs +170 -0
- package/server/services/plan-usage-openai-image.mjs +148 -0
- package/server/services/provider-model-refresh.mjs +326 -0
- package/server/services/provider-model-state.mjs +395 -0
- package/server/services/relay-singleton-guard.mjs +72 -10
- package/server/services/remote-relay-caller-context.mjs +6 -0
- package/server/services/remote-relay-dispatcher.mjs +386 -22
- package/server/services/remote-relay-registry-service.mjs +3 -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/token-usage-service.mjs +15 -0
- package/server/services/update-install-service.mjs +2 -2
- package/server/services/worker-launch-note-service.mjs +14 -2
- package/server/services/worker-recycle-service.mjs +145 -11
- package/shared/image-header.mjs +90 -0
- package/shared/model-refresh-schedule.mjs +120 -0
- package/shared/openai-image-options.mjs +299 -0
- package/shared/openai-image-presets.mjs +175 -0
- package/shared/openai-image-previews.mjs +40 -0
- package/shared/openai-image-prices.mjs +291 -0
- package/shared/openai-image-spend.mjs +90 -0
- package/shared/remote-relay-contract.mjs +126 -4
- package/shared/worker-runtime/direct-openai-image.mjs +98 -48
|
@@ -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,12 +27,30 @@ 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
|
-
// catalog and its refresh state, the restart orchestrator's one row
|
|
33
|
-
//
|
|
34
|
-
// messages
|
|
35
|
-
|
|
48
|
+
// catalog and its refresh state, the restart orchestrator's one row, the
|
|
49
|
+
// media index's extractor version (migration 0039; its items and queue
|
|
50
|
+
// follow messages and stay empty without them). (The shadow tables of the
|
|
51
|
+
// message search are left out too: they follow messages.) Any other table
|
|
52
|
+
// with a row makes the start refuse.
|
|
53
|
+
export const STARTUP_TABLES = Object.freeze(['model_variants', 'model_selector_state', 'relay_restart_orchestrator', 'media_index_meta']);
|
|
36
54
|
|
|
37
55
|
/** A database's files: the main file, its -wal and its -shm. */
|
|
38
56
|
export function sideFiles(name) {
|
|
@@ -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,7 +9,7 @@ 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
15
|
// The copies the install-time rescue of 0.9.14 to 0.9.22 made, in the data folder.
|
|
@@ -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
|
});
|
|
@@ -25,7 +25,8 @@ function parseMessageAttachments(rawAttachments, { parseAttachments, hydrateAtta
|
|
|
25
25
|
return attachments.map((entry) => hydrateAttachment(entry)).filter(Boolean);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
/** The file of a generated-image attachment under `root`, or null for one that would leave its folder. */
|
|
29
|
+
export function resolveGeneratedImagePath({
|
|
29
30
|
root,
|
|
30
31
|
sessionId,
|
|
31
32
|
relativePath,
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
|
|
5
|
+
import { IMAGE_HEADER_BYTES, readImageHeader } from '../../shared/image-header.mjs';
|
|
6
|
+
import { resolveGeneratedImagePath } from './generated-image-cleanup-service.mjs';
|
|
7
|
+
|
|
8
|
+
// A generated image's attachment as GET /api/conversation/:id serves it: its
|
|
9
|
+
// absolute file on this relay (`generatedImage.path`) and its pixel size, so
|
|
10
|
+
// another relay's agent can name the file in a later send. The path is worked
|
|
11
|
+
// out on every read, never stored: checkouts and state folders move.
|
|
12
|
+
|
|
13
|
+
// PNG and WebP headers sit in the first bytes; a JPEG's frame header comes
|
|
14
|
+
// after its EXIF/ICC blocks, so only a JPEG that needs more reads further.
|
|
15
|
+
const FIRST_READ_BYTES = 4096;
|
|
16
|
+
|
|
17
|
+
// The conversation page is polled: a file read once is not read again while
|
|
18
|
+
// its size and time stay the same.
|
|
19
|
+
const HEADER_CACHE_MAX = 500;
|
|
20
|
+
const headerCache = new Map();
|
|
21
|
+
|
|
22
|
+
function readFirstBytes(filePath, length) {
|
|
23
|
+
let fd = null;
|
|
24
|
+
try {
|
|
25
|
+
fd = fs.openSync(filePath, 'r');
|
|
26
|
+
const buffer = Buffer.alloc(length);
|
|
27
|
+
const read = fs.readSync(fd, buffer, 0, length, 0);
|
|
28
|
+
return buffer.subarray(0, read);
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
} finally {
|
|
32
|
+
if (fd !== null) {
|
|
33
|
+
try { fs.closeSync(fd); } catch {}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function imageHeaderOf(filePath, stat) {
|
|
39
|
+
const cached = headerCache.get(filePath);
|
|
40
|
+
if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) return cached.header;
|
|
41
|
+
const first = readFirstBytes(filePath, Math.min(FIRST_READ_BYTES, stat.size));
|
|
42
|
+
let header = first ? readImageHeader(first) : null;
|
|
43
|
+
if (!header && first && first[0] === 0xff && first[1] === 0xd8 && stat.size > first.length) {
|
|
44
|
+
const more = readFirstBytes(filePath, Math.min(IMAGE_HEADER_BYTES, stat.size));
|
|
45
|
+
header = more ? readImageHeader(more) : null;
|
|
46
|
+
}
|
|
47
|
+
if (headerCache.size >= HEADER_CACHE_MAX) headerCache.delete(headerCache.keys().next().value);
|
|
48
|
+
headerCache.set(filePath, { size: stat.size, mtimeMs: stat.mtimeMs, header });
|
|
49
|
+
return header;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function positiveInt(value) {
|
|
53
|
+
return Number.isInteger(value) && value > 0 ? value : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function withFile(attachment, root) {
|
|
57
|
+
const { path: _stored, ...generatedImage } = attachment.generatedImage;
|
|
58
|
+
const bare = { ...attachment, generatedImage };
|
|
59
|
+
const filePath = resolveGeneratedImagePath({
|
|
60
|
+
root,
|
|
61
|
+
sessionId: generatedImage.sessionId,
|
|
62
|
+
relativePath: generatedImage.relativePath,
|
|
63
|
+
});
|
|
64
|
+
if (!filePath) return bare;
|
|
65
|
+
let stat = null;
|
|
66
|
+
try {
|
|
67
|
+
stat = fs.statSync(filePath, { throwIfNoEntry: false }) || null;
|
|
68
|
+
} catch {}
|
|
69
|
+
if (!stat?.isFile()) return bare;
|
|
70
|
+
const width = positiveInt(attachment.width);
|
|
71
|
+
const height = positiveInt(attachment.height);
|
|
72
|
+
// New attachments carry their size; older ones are read.
|
|
73
|
+
const header = width && height ? null : imageHeaderOf(filePath, stat);
|
|
74
|
+
return {
|
|
75
|
+
...bare,
|
|
76
|
+
...(header ? { width: header.width, height: header.height } : {}),
|
|
77
|
+
...(!attachment.type && header?.mime ? { type: header.mime } : {}),
|
|
78
|
+
generatedImage: { ...generatedImage, path: filePath },
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* `messages` with each generated-image attachment given its file path and
|
|
84
|
+
* pixel size, when the file is there. Messages without one come back as they
|
|
85
|
+
* are; a missing or unreadable file leaves the attachment without a path.
|
|
86
|
+
* Never throws.
|
|
87
|
+
*/
|
|
88
|
+
export function withGeneratedImageFiles(messages, { resolveSessionStateRoot } = {}) {
|
|
89
|
+
if (!Array.isArray(messages) || typeof resolveSessionStateRoot !== 'function') return messages;
|
|
90
|
+
let root = '';
|
|
91
|
+
try {
|
|
92
|
+
root = String(resolveSessionStateRoot() || '').trim();
|
|
93
|
+
} catch {}
|
|
94
|
+
if (!root) return messages;
|
|
95
|
+
return messages.map((message) => {
|
|
96
|
+
const attachments = Array.isArray(message?.attachments) ? message.attachments : null;
|
|
97
|
+
if (!attachments || !attachments.some((entry) => entry?.generatedImage && typeof entry.generatedImage === 'object')) return message;
|
|
98
|
+
return {
|
|
99
|
+
...message,
|
|
100
|
+
attachments: attachments.map((entry) => {
|
|
101
|
+
if (!entry?.generatedImage || typeof entry.generatedImage !== 'object') return entry;
|
|
102
|
+
try {
|
|
103
|
+
return withFile(entry, root);
|
|
104
|
+
} catch {
|
|
105
|
+
return entry;
|
|
106
|
+
}
|
|
107
|
+
}),
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
}
|