@oar-sh/oar 0.9.15 → 0.9.17
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/polling/polling-loop.mjs +14 -87
- package/CHANGELOG.md +305 -0
- package/README.md +12 -11
- package/package.json +2 -1
- package/server/claude-worker/claude-attachments.mjs +7 -4
- package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
- package/server/claude-worker/claude-sdk-adapter.mjs +167 -9
- package/server/claude-worker/claude-session-process.mjs +131 -33
- package/server/claude-worker/claude-session-worker-main.mjs +266 -0
- package/server/claude-worker/claude-session-worker.mjs +2 -253
- package/server/claude-worker/claude-turn-failure.mjs +8 -0
- package/server/claude-worker/claude-turn-publisher.mjs +282 -13
- package/server/claude-worker/claude-usage-totals.mjs +108 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +55 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
- package/server/copilot-worker/copilot-sdk-session-process.mjs +73 -7
- package/server/db-schema.mjs +115 -0
- package/server/deepseek-worker/deepseek-claude-backend.mjs +433 -0
- package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
- package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
- package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
- package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
- package/server/migrations/0022-relay-schedules.mjs +142 -0
- package/server/migrations/0023-relay-asks.mjs +141 -0
- package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
- package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
- package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
- package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
- package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
- package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
- package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
- package/server/public/app/api-client.js +53 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/bootstrap.js +162 -16
- package/server/public/app/context-tier-options.mjs +4 -0
- package/server/public/app/context-usage-view.mjs +17 -0
- package/server/public/app/conversation-group-order.mjs +35 -0
- package/server/public/app/conversation-provider-indicator.mjs +7 -0
- package/server/public/app/conversation-view.js +2 -0
- package/server/public/app/deepseek-settings-ui.js +342 -0
- package/server/public/app/inbox-ui.js +367 -0
- package/server/public/app/inbox-view.mjs +370 -0
- package/server/public/app/journal-view.js +54 -5
- package/server/public/app/model-selector-options.mjs +11 -0
- package/server/public/app/model-vision-support.mjs +2 -0
- package/server/public/app/no-conversation-state.mjs +1 -1
- package/server/public/app/plan-usage-view.mjs +165 -13
- package/server/public/app/provider-readiness.mjs +4 -1
- package/server/public/app/relay-error-ctas.mjs +8 -0
- package/server/public/app/relay-event-subscriptions-ui.js +4 -2
- package/server/public/app/relay-event-view.mjs +33 -7
- package/server/public/app/relay-schedule-view.mjs +119 -0
- package/server/public/app/relay-schedules-ui.js +172 -0
- package/server/public/app/settings-modal.js +11 -0
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +37 -3
- package/server/public/app/store.js +18 -5
- package/server/public/index.html +567 -35
- package/server/relay-tools.md +18 -3
- package/server/repositories/conversation-group-repository.mjs +110 -17
- package/server/repositories/remote-relay-repository.mjs +20 -2
- package/server/repositories/session-repository.mjs +7 -1
- package/server/routes/ask-user-routes.mjs +57 -3
- package/server/routes/cache-routes.mjs +6 -0
- package/server/routes/conversation-group-routes.mjs +19 -3
- package/server/routes/deepseek-usage-import-routes.mjs +72 -0
- package/server/routes/messages-routes.mjs +195 -18
- package/server/routes/relay-ask-routes.mjs +387 -0
- package/server/routes/relay-event-routes.mjs +3 -1
- package/server/routes/relay-schedule-routes.mjs +57 -0
- package/server/routes/sessions-routes.mjs +932 -97
- package/server/server-runtime.mjs +203 -11
- package/server/services/about-service.mjs +1 -0
- package/server/services/cache-rebuild-service.mjs +19 -2
- package/server/services/claude-session-leftovers.mjs +226 -0
- package/server/services/conversation-preferences-service.mjs +17 -0
- package/server/services/deepseek-balance-service.mjs +102 -0
- package/server/services/deepseek-settings-service.mjs +380 -0
- package/server/services/deepseek-usage-import.mjs +471 -0
- package/server/services/model-variant-catalog-service.mjs +1 -0
- package/server/services/plan-usage-contract.mjs +100 -5
- package/server/services/plan-usage-deepseek.mjs +931 -0
- package/server/services/plan-usage-service.mjs +431 -0
- package/server/services/provider-model-selection.mjs +1 -0
- package/server/services/provider-reasoning-effort.mjs +15 -2
- package/server/services/relay-ask-link-service.mjs +174 -0
- package/server/services/relay-ask-service.mjs +604 -0
- package/server/services/relay-event-service.mjs +589 -73
- package/server/services/relay-schedule-service.mjs +596 -0
- package/server/services/remote-relay-caller-context.mjs +89 -20
- package/server/services/remote-relay-dispatcher.mjs +958 -31
- package/server/services/session-worker-launch-service.mjs +233 -82
- package/server/services/worker-secret-redaction.mjs +318 -0
- package/shared/context-window-fallbacks.mjs +3 -0
- package/shared/deepseek-api.mjs +137 -0
- package/shared/deepseek-models.mjs +114 -0
- package/shared/deepseek-pricing.mjs +146 -0
- package/shared/host-claude-session-env.mjs +101 -0
- package/shared/model-id.mjs +7 -0
- package/shared/preview-tool-core.mjs +1 -1
- package/shared/provider-routing.mjs +2 -2
- package/shared/relay-asks.mjs +145 -0
- package/shared/relay-events.mjs +268 -18
- package/shared/relay-schedules.mjs +128 -0
- package/shared/remote-relay-contract.mjs +554 -26
- package/shared/remote-relay-tool-core.mjs +16 -5
- package/shared/steer-settle-failure.mjs +1 -0
- package/shared/worker-runtime/direct-openai-image.mjs +128 -0
- package/shared/worker-runtime/worker-log-file.mjs +60 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// What a Claude Code session leaves in its config directory besides the
|
|
2
|
+
// transcript, removed when the relay deletes the conversation.
|
|
3
|
+
//
|
|
4
|
+
// Only for a config directory the relay owns (DeepSeek's `deepseek-claude`
|
|
5
|
+
// in the data folder), never the host's ~/.claude: the user's own CLI keeps
|
|
6
|
+
// its sessions there. The relay stops the worker before it deletes anything,
|
|
7
|
+
// and a stopped CLI is killed without its exit cleanup, so it leaves (the
|
|
8
|
+
// bundled CLI, checked 2026-10-25):
|
|
9
|
+
// - `session-env/<session id>/`, the session's hook environment files;
|
|
10
|
+
// - `sessions/<pid>.json`, the process's registration (`{ pid, sessionId,
|
|
11
|
+
// cwd, startedAt, ... }`), and `sessions/<pid>.<sha256>.key`, its
|
|
12
|
+
// messaging key;
|
|
13
|
+
// - `shell-snapshots/snapshot-<shell>-<ms>-<random>.sh`, the shell snapshot
|
|
14
|
+
// the process took at its start, whose name carries no session id: it is
|
|
15
|
+
// matched by the time in its name against the session's CLI starts;
|
|
16
|
+
// - `projects/<slug(cwd)>/` with an empty `memory/`, once its last session
|
|
17
|
+
// is gone.
|
|
18
|
+
// What belongs to another session stays: a registration of a live process,
|
|
19
|
+
// a snapshot that also lies in the start window of another session's CLI,
|
|
20
|
+
// and a project folder that still holds anything but an empty `memory/`.
|
|
21
|
+
//
|
|
22
|
+
// A CLI that exits on its own removes its registration but not always its
|
|
23
|
+
// snapshot: the registration goes in a synchronous `exit` handler, the
|
|
24
|
+
// snapshot only in the CLI's asynchronous cleanup, which a stop does not
|
|
25
|
+
// wait for (a worker restart left the first CLI's snapshot behind with no
|
|
26
|
+
// registration to match it). So the relay records every CLI start of a
|
|
27
|
+
// session itself (recordClaudeCliStart, from the worker's report at the
|
|
28
|
+
// CLI's first init) in `oar-cli-starts.json` in the config dir, and the
|
|
29
|
+
// delete matches snapshots against those starts as well as against the
|
|
30
|
+
// registrations. Every recorded start of another session protects its
|
|
31
|
+
// window, as a registration does.
|
|
32
|
+
|
|
33
|
+
import nodeFs from 'node:fs';
|
|
34
|
+
import nodePath from 'node:path';
|
|
35
|
+
|
|
36
|
+
const SESSION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
37
|
+
const REGISTRATION_PATTERN = /^(\d{1,10})\.json$/;
|
|
38
|
+
const SNAPSHOT_PATTERN = /^snapshot-[a-z]+-(\d{10,16})-[A-Za-z0-9]+(?:-[A-Za-z0-9-]+)?\.sh$/;
|
|
39
|
+
// The snapshot is taken while the CLI starts, around its registration.
|
|
40
|
+
const SNAPSHOT_WINDOW_BEFORE_MS = 60_000;
|
|
41
|
+
const SNAPSHOT_WINDOW_AFTER_MS = 120_000;
|
|
42
|
+
const MAX_ENTRIES = 2_000;
|
|
43
|
+
// The relay's record of CLI starts: one file, keyed by session id inside it.
|
|
44
|
+
const CLI_STARTS_FILE = 'oar-cli-starts.json';
|
|
45
|
+
const MAX_STARTS_PER_SESSION = 200;
|
|
46
|
+
const MAX_SESSIONS_RECORDED = 5_000;
|
|
47
|
+
|
|
48
|
+
function defaultIsPidAlive(pid) {
|
|
49
|
+
try {
|
|
50
|
+
process.kill(pid, 0);
|
|
51
|
+
return true;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
return error?.code === 'EPERM';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readDir(fs, dir) {
|
|
58
|
+
try {
|
|
59
|
+
return fs.readdirSync(dir).slice(0, MAX_ENTRIES);
|
|
60
|
+
} catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function remove(fs, target, removed) {
|
|
66
|
+
try {
|
|
67
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
68
|
+
removed.push(target);
|
|
69
|
+
} catch {}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The registrations in `sessions/`: `{ pid, sessionId, startedAt, file }`. */
|
|
73
|
+
function readRegistrations(fs, path, sessionsDir) {
|
|
74
|
+
const registrations = [];
|
|
75
|
+
for (const name of readDir(fs, sessionsDir)) {
|
|
76
|
+
const match = REGISTRATION_PATTERN.exec(name);
|
|
77
|
+
if (!match) continue;
|
|
78
|
+
let parsed = null;
|
|
79
|
+
try {
|
|
80
|
+
parsed = JSON.parse(fs.readFileSync(path.join(sessionsDir, name), 'utf8'));
|
|
81
|
+
} catch {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const startedAt = Number(parsed?.startedAt);
|
|
85
|
+
registrations.push({
|
|
86
|
+
pid: Number(match[1]),
|
|
87
|
+
sessionId: String(parsed?.sessionId ?? ''),
|
|
88
|
+
startedAt: Number.isFinite(startedAt) ? startedAt : null,
|
|
89
|
+
file: name,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return registrations;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** `{ [sessionId]: [startedAt ms, ...] }` from the relay's record, or `{}`. */
|
|
96
|
+
function readCliStarts(fs, file) {
|
|
97
|
+
try {
|
|
98
|
+
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
99
|
+
const sessions = parsed?.sessions && typeof parsed.sessions === 'object' ? parsed.sessions : {};
|
|
100
|
+
const starts = {};
|
|
101
|
+
for (const [id, list] of Object.entries(sessions)) {
|
|
102
|
+
if (!SESSION_ID_PATTERN.test(id) || !Array.isArray(list)) continue;
|
|
103
|
+
starts[id] = list.map(Number).filter(Number.isFinite);
|
|
104
|
+
}
|
|
105
|
+
return starts;
|
|
106
|
+
} catch {
|
|
107
|
+
return {};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function writeCliStarts(fs, file, starts) {
|
|
112
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
113
|
+
fs.writeFileSync(tmp, JSON.stringify({ version: 1, sessions: starts }), { encoding: 'utf8', mode: 0o600 });
|
|
114
|
+
fs.renameSync(tmp, file);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Note that a CLI started for `nativeSessionId` at `startedAt` (ms), under
|
|
119
|
+
* the relay's own config dir. Best-effort; returns whether it was noted.
|
|
120
|
+
*/
|
|
121
|
+
export function recordClaudeCliStart({ configRoot, nativeSessionId, startedAt, fs = nodeFs, path = nodePath } = {}) {
|
|
122
|
+
const root = String(configRoot || '').trim();
|
|
123
|
+
const sessionId = String(nativeSessionId || '').trim();
|
|
124
|
+
const at = Number(startedAt);
|
|
125
|
+
if (!root || !SESSION_ID_PATTERN.test(sessionId) || !Number.isFinite(at) || at <= 0) return false;
|
|
126
|
+
try {
|
|
127
|
+
const resolvedRoot = path.resolve(root);
|
|
128
|
+
fs.mkdirSync(resolvedRoot, { recursive: true });
|
|
129
|
+
const file = path.join(resolvedRoot, CLI_STARTS_FILE);
|
|
130
|
+
const starts = readCliStarts(fs, file);
|
|
131
|
+
const list = starts[sessionId] || [];
|
|
132
|
+
if (!list.includes(at)) list.push(at);
|
|
133
|
+
starts[sessionId] = list.slice(-MAX_STARTS_PER_SESSION);
|
|
134
|
+
// A record that outgrows its cap drops the sessions started longest ago.
|
|
135
|
+
const ids = Object.keys(starts);
|
|
136
|
+
if (ids.length > MAX_SESSIONS_RECORDED) {
|
|
137
|
+
const newest = (id) => Math.max(...starts[id], 0);
|
|
138
|
+
for (const id of ids.sort((a, b) => newest(a) - newest(b)).slice(0, ids.length - MAX_SESSIONS_RECORDED)) delete starts[id];
|
|
139
|
+
}
|
|
140
|
+
writeCliStarts(fs, file, starts);
|
|
141
|
+
return true;
|
|
142
|
+
} catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function inStartWindow(time, startedAt) {
|
|
148
|
+
return startedAt !== null && time >= startedAt - SNAPSHOT_WINDOW_BEFORE_MS && time <= startedAt + SNAPSHOT_WINDOW_AFTER_MS;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Remove what the session `nativeSessionId` left under `configRoot`, and
|
|
153
|
+
* `projectDirPath` when nothing but an empty `memory/` is left in it.
|
|
154
|
+
* Returns the paths removed. Best-effort: a path that will not go stays.
|
|
155
|
+
*/
|
|
156
|
+
export function removeClaudeSessionLeftovers({
|
|
157
|
+
configRoot,
|
|
158
|
+
nativeSessionId,
|
|
159
|
+
projectDirPath = '',
|
|
160
|
+
fs = nodeFs,
|
|
161
|
+
path = nodePath,
|
|
162
|
+
isPidAlive = defaultIsPidAlive,
|
|
163
|
+
} = {}) {
|
|
164
|
+
const removed = [];
|
|
165
|
+
const root = String(configRoot || '').trim();
|
|
166
|
+
const sessionId = String(nativeSessionId || '').trim();
|
|
167
|
+
if (!root || !SESSION_ID_PATTERN.test(sessionId)) return removed;
|
|
168
|
+
const resolvedRoot = path.resolve(root);
|
|
169
|
+
|
|
170
|
+
const sessionEnv = path.join(resolvedRoot, 'session-env', sessionId);
|
|
171
|
+
if (fs.existsSync(sessionEnv)) remove(fs, sessionEnv, removed);
|
|
172
|
+
|
|
173
|
+
const sessionsDir = path.join(resolvedRoot, 'sessions');
|
|
174
|
+
const registrations = readRegistrations(fs, path, sessionsDir);
|
|
175
|
+
const stale = registrations.filter((entry) => entry.sessionId === sessionId && !isPidAlive(entry.pid));
|
|
176
|
+
const others = registrations.filter((entry) => !stale.includes(entry));
|
|
177
|
+
const files = readDir(fs, sessionsDir);
|
|
178
|
+
for (const entry of stale) {
|
|
179
|
+
remove(fs, path.join(sessionsDir, entry.file), removed);
|
|
180
|
+
const keyPrefix = `${entry.pid}.`;
|
|
181
|
+
for (const name of files) {
|
|
182
|
+
if (name.startsWith(keyPrefix) && name.endsWith('.key')) remove(fs, path.join(sessionsDir, name), removed);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// The session's CLI starts: its dead registrations and every start the
|
|
187
|
+
// relay recorded. Another session's: its registrations and its recorded
|
|
188
|
+
// starts, whose windows are never touched.
|
|
189
|
+
const startsFile = path.join(resolvedRoot, CLI_STARTS_FILE);
|
|
190
|
+
const recorded = readCliStarts(fs, startsFile);
|
|
191
|
+
const ownStarts = [...stale.map((entry) => entry.startedAt), ...(recorded[sessionId] || [])];
|
|
192
|
+
const otherStarts = [
|
|
193
|
+
...others.map((entry) => entry.startedAt),
|
|
194
|
+
...Object.entries(recorded).filter(([id]) => id !== sessionId).flatMap(([, list]) => list),
|
|
195
|
+
];
|
|
196
|
+
const snapshotsDir = path.join(resolvedRoot, 'shell-snapshots');
|
|
197
|
+
for (const name of readDir(fs, snapshotsDir)) {
|
|
198
|
+
const match = SNAPSHOT_PATTERN.exec(name);
|
|
199
|
+
if (!match) continue;
|
|
200
|
+
const takenAt = Number(match[1]);
|
|
201
|
+
if (!ownStarts.some((startedAt) => inStartWindow(takenAt, startedAt))) continue;
|
|
202
|
+
if (otherStarts.some((startedAt) => inStartWindow(takenAt, startedAt))) continue;
|
|
203
|
+
remove(fs, path.join(snapshotsDir, name), removed);
|
|
204
|
+
}
|
|
205
|
+
if (recorded[sessionId]) {
|
|
206
|
+
delete recorded[sessionId];
|
|
207
|
+
try {
|
|
208
|
+
writeCliStarts(fs, startsFile, recorded);
|
|
209
|
+
} catch {}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const projectDir = String(projectDirPath || '').trim() ? path.resolve(projectDirPath) : '';
|
|
213
|
+
if (projectDir && path.dirname(projectDir) === path.join(resolvedRoot, 'projects')) {
|
|
214
|
+
const entries = readDir(fs, projectDir);
|
|
215
|
+
const isEmptyDir = (dir) => {
|
|
216
|
+
try {
|
|
217
|
+
return fs.statSync(dir).isDirectory() && fs.readdirSync(dir).length === 0;
|
|
218
|
+
} catch {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
const onlyEmptyMemory = entries.every((name) => name === 'memory' && isEmptyDir(path.join(projectDir, name)));
|
|
223
|
+
if (fs.existsSync(projectDir) && onlyEmptyMemory) remove(fs, projectDir, removed);
|
|
224
|
+
}
|
|
225
|
+
return removed;
|
|
226
|
+
}
|
|
@@ -41,6 +41,9 @@ export function persistConversationPreferences({
|
|
|
41
41
|
// fallback is the relay default (on / summarized).
|
|
42
42
|
thinkingEnabled = undefined,
|
|
43
43
|
thinkingDisplay = undefined,
|
|
44
|
+
// The owner's "agents in this conversation may start sessions" (a boolean;
|
|
45
|
+
// `undefined` leaves it alone). The route decides who may write it.
|
|
46
|
+
mayCreateSessions = undefined,
|
|
44
47
|
updatedAt = new Date().toISOString(),
|
|
45
48
|
createIfMissing = false,
|
|
46
49
|
createTitle = 'Session',
|
|
@@ -56,6 +59,7 @@ export function persistConversationPreferences({
|
|
|
56
59
|
const enabled = writesThinkingEnabled ? parseThinkingEnabled(thinkingEnabled) : DEFAULT_THINKING_ENABLED;
|
|
57
60
|
const writesThinkingDisplay = thinkingDisplay !== undefined;
|
|
58
61
|
const display = writesThinkingDisplay ? parseThinkingDisplay(thinkingDisplay) : null;
|
|
62
|
+
const writesMayCreateSessions = mayCreateSessions !== undefined;
|
|
59
63
|
if (!db || !stmts || !convId || !mode) {
|
|
60
64
|
return {
|
|
61
65
|
ok: false,
|
|
@@ -66,6 +70,7 @@ export function persistConversationPreferences({
|
|
|
66
70
|
autoCompactWindow: null,
|
|
67
71
|
thinkingEnabled: DEFAULT_THINKING_ENABLED,
|
|
68
72
|
thinkingDisplay: DEFAULT_THINKING_DISPLAY,
|
|
73
|
+
mayCreateSessions: false,
|
|
69
74
|
updatedAt,
|
|
70
75
|
};
|
|
71
76
|
}
|
|
@@ -99,6 +104,10 @@ export function persistConversationPreferences({
|
|
|
99
104
|
extraSets.push('thinking_display = ?');
|
|
100
105
|
extraValues.push(display);
|
|
101
106
|
}
|
|
107
|
+
if (writesMayCreateSessions) {
|
|
108
|
+
extraSets.push('may_create_sessions = ?');
|
|
109
|
+
extraValues.push(mayCreateSessions === true ? 1 : 0);
|
|
110
|
+
}
|
|
102
111
|
try {
|
|
103
112
|
if (extraSets.length) {
|
|
104
113
|
db.prepare(`
|
|
@@ -136,12 +145,20 @@ export function persistConversationPreferences({
|
|
|
136
145
|
thinkingDisplay: writesThinkingDisplay
|
|
137
146
|
? display
|
|
138
147
|
: parseThinkingDisplay(existing?.thinking_display),
|
|
148
|
+
mayCreateSessions: writesMayCreateSessions
|
|
149
|
+
? mayCreateSessions === true
|
|
150
|
+
: readMayCreateSessions(existing),
|
|
139
151
|
updatedAt,
|
|
140
152
|
};
|
|
141
153
|
});
|
|
142
154
|
return writePreferences();
|
|
143
155
|
}
|
|
144
156
|
|
|
157
|
+
/** The owner's "agents in this conversation may start sessions" of a conversations row. */
|
|
158
|
+
export function readMayCreateSessions(row) {
|
|
159
|
+
return Number(row?.may_create_sessions) === 1;
|
|
160
|
+
}
|
|
161
|
+
|
|
145
162
|
export function persistConversationModelPreference({
|
|
146
163
|
db,
|
|
147
164
|
stmts,
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The DeepSeek account balance for the usage card: `GET /user/balance` with
|
|
4
|
+
// the saved key and base URL (shared/deepseek-api.mjs), only while DeepSeek
|
|
5
|
+
// is on. An answer is kept for a minute, a failure for 15 seconds, and a
|
|
6
|
+
// changed key or endpoint is read afresh. Never throws: a failure is an
|
|
7
|
+
// answer of its own, `{ ok: false, code, error }`, whose text never holds the
|
|
8
|
+
// key, with the last good answer beside it as `previous`.
|
|
9
|
+
|
|
10
|
+
import { createDeepSeekApiClient } from '../../shared/deepseek-api.mjs';
|
|
11
|
+
|
|
12
|
+
const DEFAULT_TTL_MS = 60_000;
|
|
13
|
+
const DEFAULT_ERROR_TTL_MS = 15_000;
|
|
14
|
+
// The usage modal waits for its slowest read, so this one is kept short.
|
|
15
|
+
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
16
|
+
|
|
17
|
+
function redact(text, apiKey) {
|
|
18
|
+
const value = String(text ?? '');
|
|
19
|
+
const key = String(apiKey ?? '').trim();
|
|
20
|
+
return key ? value.split(key).join('[redacted]') : value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createDeepSeekBalanceService({
|
|
24
|
+
getSettings = () => null,
|
|
25
|
+
fetchImpl = globalThis.fetch,
|
|
26
|
+
createClient = createDeepSeekApiClient,
|
|
27
|
+
now = () => Date.now(),
|
|
28
|
+
ttlMs = DEFAULT_TTL_MS,
|
|
29
|
+
errorTtlMs = DEFAULT_ERROR_TTL_MS,
|
|
30
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
31
|
+
} = {}) {
|
|
32
|
+
let cache = null; // { identity, entry, expiresAt }
|
|
33
|
+
let lastGood = null; // { identity, entry }
|
|
34
|
+
let inflight = null; // { identity, promise }
|
|
35
|
+
|
|
36
|
+
function readSettings() {
|
|
37
|
+
try {
|
|
38
|
+
return getSettings() || null;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function read(settings) {
|
|
45
|
+
const fetchedAt = new Date(now()).toISOString();
|
|
46
|
+
if (!settings.apiKey) {
|
|
47
|
+
return { ok: false, code: 'not_configured', error: 'DeepSeek API key is not configured', fetchedAt };
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const client = createClient({ baseUrl: settings.baseUrl, apiKey: settings.apiKey, fetchImpl, timeoutMs });
|
|
51
|
+
const { isAvailable, balances } = await client.getBalance();
|
|
52
|
+
return { ok: true, isAvailable: isAvailable === true, balances: Array.isArray(balances) ? balances : [], fetchedAt };
|
|
53
|
+
} catch (error) {
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
code: String(error?.code || 'error'),
|
|
57
|
+
error: redact(error?.message || error || 'DeepSeek balance check failed', settings.apiKey).slice(0, 300),
|
|
58
|
+
fetchedAt,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The balance, or null while DeepSeek is off. `{ force: true }` skips the
|
|
65
|
+
* cache (a fresh read is still shared with one already running).
|
|
66
|
+
*/
|
|
67
|
+
async function getBalance({ force = false } = {}) {
|
|
68
|
+
const settings = readSettings();
|
|
69
|
+
if (settings?.enabled !== true) return null;
|
|
70
|
+
const identity = `${settings.baseUrl || ''}\n${settings.apiKey || ''}`;
|
|
71
|
+
const answer = (entry, cached) => (entry.ok || lastGood?.identity !== identity
|
|
72
|
+
? { ...entry, cached }
|
|
73
|
+
: { ...entry, cached, previous: lastGood.entry });
|
|
74
|
+
if (!force && cache && cache.identity === identity && cache.expiresAt > now()) {
|
|
75
|
+
return answer(cache.entry, true);
|
|
76
|
+
}
|
|
77
|
+
if (!inflight || inflight.identity !== identity) {
|
|
78
|
+
const promise = read(settings).then((entry) => {
|
|
79
|
+
cache = { identity, entry, expiresAt: now() + (entry.ok ? ttlMs : errorTtlMs) };
|
|
80
|
+
if (entry.ok) lastGood = { identity, entry };
|
|
81
|
+
return entry;
|
|
82
|
+
}).finally(() => {
|
|
83
|
+
if (inflight?.promise === promise) inflight = null;
|
|
84
|
+
});
|
|
85
|
+
inflight = { identity, promise };
|
|
86
|
+
}
|
|
87
|
+
return answer(await inflight.promise, false);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The last good answer for the saved key and endpoint, without reading
|
|
92
|
+
* anew; null when there is none (or DeepSeek is off).
|
|
93
|
+
*/
|
|
94
|
+
function peekBalance() {
|
|
95
|
+
const settings = readSettings();
|
|
96
|
+
if (settings?.enabled !== true) return null;
|
|
97
|
+
const identity = `${settings.baseUrl || ''}\n${settings.apiKey || ''}`;
|
|
98
|
+
return lastGood?.identity === identity ? lastGood.entry : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { getBalance, peekBalance };
|
|
102
|
+
}
|