@oar-sh/oar 0.9.16 → 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 +220 -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-sdk-adapter.mjs +167 -9
- package/server/claude-worker/claude-session-process.mjs +127 -29
- 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 +71 -5
- package/server/db-schema.mjs +63 -0
- package/server/deepseek-worker/deepseek-claude-backend.mjs +433 -0
- package/server/deepseek-worker/deepseek-session-worker.mjs +37 -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/bootstrap.js +101 -13
- package/server/public/app/context-tier-options.mjs +4 -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 +14 -3
- package/server/public/app/journal-view.js +31 -3
- 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/settings-modal.js +11 -0
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +10 -0
- package/server/public/index.html +408 -33
- package/server/repositories/conversation-group-repository.mjs +13 -6
- package/server/routes/conversation-group-routes.mjs +2 -1
- package/server/routes/deepseek-usage-import-routes.mjs +72 -0
- package/server/routes/messages-routes.mjs +192 -15
- package/server/routes/sessions-routes.mjs +364 -55
- package/server/server-runtime.mjs +123 -10
- package/server/services/about-service.mjs +1 -0
- package/server/services/claude-session-leftovers.mjs +226 -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/remote-relay-dispatcher.mjs +2 -1
- 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/remote-relay-contract.mjs +1 -1
- 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
|
@@ -46,6 +46,7 @@ import { createPreviewScopeResolver, registerPreviewRoutes } from './routes/prev
|
|
|
46
46
|
import { registerRemoteRelayRoutes } from './routes/remote-relay-routes.mjs';
|
|
47
47
|
import { registerRelayEventRoutes } from './routes/relay-event-routes.mjs';
|
|
48
48
|
import { registerRelayScheduleRoutes } from './routes/relay-schedule-routes.mjs';
|
|
49
|
+
import { registerDeepSeekUsageImportRoutes } from './routes/deepseek-usage-import-routes.mjs';
|
|
49
50
|
import { registerRelayAskRoutes } from './routes/relay-ask-routes.mjs';
|
|
50
51
|
import { createRemoteRelayRepository } from './repositories/remote-relay-repository.mjs';
|
|
51
52
|
import { createConversationGroupRepository } from './repositories/conversation-group-repository.mjs';
|
|
@@ -64,6 +65,12 @@ import { createClaudeCloudCredentials } from '../shared/claude-cloud/credentials
|
|
|
64
65
|
import { createClaudeCloudClient } from '../shared/claude-cloud/api-client.mjs';
|
|
65
66
|
import { resolveClaudeCloudBaseUrl } from '../shared/claude-cloud/base-url.mjs';
|
|
66
67
|
import { createClaudeCloudSettingsService } from './services/claude-cloud-settings-service.mjs';
|
|
68
|
+
import { createDeepSeekSettingsService } from './services/deepseek-settings-service.mjs';
|
|
69
|
+
import { createWorkerPostRedaction } from './services/worker-secret-redaction.mjs';
|
|
70
|
+
import { createDeepSeekBalanceService } from './services/deepseek-balance-service.mjs';
|
|
71
|
+
import { deepseekCardCurrency } from './services/plan-usage-deepseek.mjs';
|
|
72
|
+
import { createDeepSeekUsageImportService } from './services/deepseek-usage-import.mjs';
|
|
73
|
+
import { DEEPSEEK_DEFAULT_AUTO_COMPACT_WINDOW, DEFAULT_DEEPSEEK_MODEL, deepseekModelLabel } from '../shared/deepseek-models.mjs';
|
|
67
74
|
import { createClaudeCloudSessionService } from './services/claude-cloud-session-service.mjs';
|
|
68
75
|
import { createClaudeCloudRepoService, listRecentCloudSourcesFromStatements } from './services/claude-cloud-repo-service.mjs';
|
|
69
76
|
import { createClaudeAccountUsageService } from './services/claude-account-usage-service.mjs';
|
|
@@ -138,7 +145,7 @@ import { createRelayCliLauncherService } from './services/relay-cli-launcher-ser
|
|
|
138
145
|
import { createSessionWorkerRegistry } from './services/session-worker-registry-service.mjs';
|
|
139
146
|
import { createSessionWorkerSupervisor } from './services/session-worker-supervisor-service.mjs';
|
|
140
147
|
import { createSessionWorkerProcessInspector } from './services/session-worker-process-service.mjs';
|
|
141
|
-
import { applyClaudeCloudProviderEnvironment, applyClaudeProviderEnvironment, applyCopilotSdkProviderEnvironment, applyCursorProviderEnvironment, applyGrokProviderEnvironment, applyOpenAIProviderEnvironment, copilotSdkEngineUnavailableReason, killTmuxSession, launchSessionCli } from './services/session-worker-launch-service.mjs';
|
|
148
|
+
import { applyClaudeCloudProviderEnvironment, applyClaudeProviderEnvironment, applyCopilotSdkProviderEnvironment, applyCursorProviderEnvironment, applyDeepSeekProviderEnvironment, applyGrokProviderEnvironment, applyOpenAIProviderEnvironment, copilotSdkEngineUnavailableReason, killTmuxSession, launchSessionCli } from './services/session-worker-launch-service.mjs';
|
|
142
149
|
import { createSshTunnelManager } from './services/ssh-tunnel-manager-service.mjs';
|
|
143
150
|
import { createCloudflaredTunnelManager } from './services/cloudflared-tunnel-service.mjs';
|
|
144
151
|
import {
|
|
@@ -2830,6 +2837,58 @@ const claudeCloudRepoService = createClaudeCloudRepoService({
|
|
|
2830
2837
|
isEnabled: () => claudeCloudSettingsService.getSettings().enabled === true,
|
|
2831
2838
|
listRecentCloudSources: () => listRecentCloudSourcesFromStatements(stmts),
|
|
2832
2839
|
});
|
|
2840
|
+
// What a session worker posts loses every key a provider saved in Settings
|
|
2841
|
+
// (worker-secret-redaction.mjs). Built here, at the start, so it reads the
|
|
2842
|
+
// saved keys now, and the DeepSeek save below notes each key it writes: one
|
|
2843
|
+
// changed before any worker has posted is still redacted.
|
|
2844
|
+
const workerPostRedaction = createWorkerPostRedaction({ readSetting: readAppSettingValue });
|
|
2845
|
+
// DeepSeek: one API key per relay, its models and its endpoint. The key stays
|
|
2846
|
+
// in the settings table; GET routes answer `configured`, never the key.
|
|
2847
|
+
const deepseekSettingsService = createDeepSeekSettingsService({
|
|
2848
|
+
readSetting: readAppSettingValue,
|
|
2849
|
+
writeSetting: (key, value) => {
|
|
2850
|
+
stmts.upsertAppSetting.run(key, String(value), new Date().toISOString());
|
|
2851
|
+
workerPostRedaction.noteSavedSetting(key, value);
|
|
2852
|
+
},
|
|
2853
|
+
deleteSetting: (key) => stmts.deleteAppSetting.run(key),
|
|
2854
|
+
listProviderCandidates: () => stmts.listRuntimeSessionProviderCandidates?.all?.() || [],
|
|
2855
|
+
});
|
|
2856
|
+
function getDeepSeekProviderSettings() {
|
|
2857
|
+
return deepseekSettingsService.getSettings();
|
|
2858
|
+
}
|
|
2859
|
+
// The DeepSeek balance for Check Usage: read with the saved key only while
|
|
2860
|
+
// DeepSeek is on, kept for a minute.
|
|
2861
|
+
const deepseekBalanceService = createDeepSeekBalanceService({ getSettings: getDeepSeekProviderSettings });
|
|
2862
|
+
// DeepSeek's own usage export, imported in Settings (or with
|
|
2863
|
+
// `npm run deepseek:import`): the usage card shows its figures for the days
|
|
2864
|
+
// it covers, for the saved key only, and the estimate after.
|
|
2865
|
+
const deepseekUsageImportService = createDeepSeekUsageImportService({
|
|
2866
|
+
db,
|
|
2867
|
+
getApiKey: () => getDeepSeekProviderSettings()?.apiKey || '',
|
|
2868
|
+
});
|
|
2869
|
+
// Where DeepSeek workers keep their Claude Code state (sessions, transcripts,
|
|
2870
|
+
// settings): a directory of the relay's own, so no Claude login is in reach
|
|
2871
|
+
// of a CLI that talks to DeepSeek and their sessions stay out of Claude's.
|
|
2872
|
+
const DEEPSEEK_CLAUDE_CONFIG_DIR = path.join(DATA_DIR, 'deepseek-claude');
|
|
2873
|
+
|
|
2874
|
+
/**
|
|
2875
|
+
* What a DeepSeek session's commits and PRs say: the DeepSeek tab's mode
|
|
2876
|
+
* (OAR or off), the folder override on top, the DeepSeek model's name in the
|
|
2877
|
+
* trailer. A folder set to vanilla gets OAR's: Claude Code's own lines would
|
|
2878
|
+
* credit Claude for DeepSeek's work.
|
|
2879
|
+
*/
|
|
2880
|
+
function deepseekAttributionForConversation(conv, modelId) {
|
|
2881
|
+
const root = String(conv?.runtime_workspace_root_path || conv?.configured_workspace_root_path || '').trim()
|
|
2882
|
+
|| currentWorkspaceRootPath();
|
|
2883
|
+
const resolved = resolveClaudeAttributionMode({
|
|
2884
|
+
providerMode: getDeepSeekProviderSettings().attribution,
|
|
2885
|
+
folderMode: workspaceRootAttributionMode(root),
|
|
2886
|
+
});
|
|
2887
|
+
return buildClaudeAttributionSettings({
|
|
2888
|
+
mode: resolved === 'vanilla' ? 'oar' : resolved,
|
|
2889
|
+
modelLabel: deepseekModelLabel(modelId),
|
|
2890
|
+
});
|
|
2891
|
+
}
|
|
2833
2892
|
// The account's live usage for the Check Usage modal. The Claude Cloud switch
|
|
2834
2893
|
// is the consent to use the Claude CLI's login, so nothing is read while it
|
|
2835
2894
|
// is off.
|
|
@@ -3058,7 +3117,7 @@ function buildSessionWorkerLaunchEnvForSession(targetSessionId) {
|
|
|
3058
3117
|
const providerType = String(runtimeSession?.provider_type || 'github').trim().toLowerCase();
|
|
3059
3118
|
// Clear every provider's variables first (the appliers' kind-guarded deletes
|
|
3060
3119
|
// keep the chained clears order-independent), then apply only the bound one.
|
|
3061
|
-
const cleared = applyClaudeCloudProviderEnvironment(applyCopilotSdkProviderEnvironment(applyGrokProviderEnvironment(applyCursorProviderEnvironment(applyClaudeProviderEnvironment(applyOpenAIProviderEnvironment(sessionWorkerLaunchEnv))))));
|
|
3120
|
+
const cleared = applyDeepSeekProviderEnvironment(applyClaudeCloudProviderEnvironment(applyCopilotSdkProviderEnvironment(applyGrokProviderEnvironment(applyCursorProviderEnvironment(applyClaudeProviderEnvironment(applyOpenAIProviderEnvironment(sessionWorkerLaunchEnv)))))));
|
|
3062
3121
|
// A Claude Cloud conversation gets its own worker and nothing else: it must
|
|
3063
3122
|
// never reach the Copilot CLI fall-through at the end of this function. The
|
|
3064
3123
|
// environment names the worker and the model, no credential.
|
|
@@ -3088,6 +3147,19 @@ function buildSessionWorkerLaunchEnvForSession(targetSessionId) {
|
|
|
3088
3147
|
dataDir: DATA_DIR,
|
|
3089
3148
|
});
|
|
3090
3149
|
}
|
|
3150
|
+
// A DeepSeek conversation gets its own worker and must never reach the
|
|
3151
|
+
// Copilot fall-through below. Without a key the applier throws, and the
|
|
3152
|
+
// conversation gets the launch note.
|
|
3153
|
+
if (providerType === 'deepseek') {
|
|
3154
|
+
const deepseekSettings = getDeepSeekProviderSettings();
|
|
3155
|
+
return applyDeepSeekProviderEnvironment(cleared, {
|
|
3156
|
+
enabled: true,
|
|
3157
|
+
model: String(runtimeSession?.provider_model || runtimeSession?.model || deepseekSettings.model).trim(),
|
|
3158
|
+
apiKey: deepseekSettings.apiKey,
|
|
3159
|
+
baseUrl: deepseekSettings.baseUrl,
|
|
3160
|
+
configDir: DEEPSEEK_CLAUDE_CONFIG_DIR,
|
|
3161
|
+
});
|
|
3162
|
+
}
|
|
3091
3163
|
if (providerType === 'grok') {
|
|
3092
3164
|
const grokSettings = getGrokProviderSettings();
|
|
3093
3165
|
const grokModel = String(runtimeSession?.provider_model || runtimeSession?.model || grokSettings.model).trim();
|
|
@@ -3316,12 +3388,13 @@ async function stopSessionWorkerForProviderRebind(sdkSessionId) {
|
|
|
3316
3388
|
|
|
3317
3389
|
async function reconcileUnstartedConversationProviders({ enabled, model, provider = 'openai' } = {}) {
|
|
3318
3390
|
const normalizedProvider = String(provider || 'openai').trim().toLowerCase();
|
|
3319
|
-
const managedProvider = ['claude', 'cursor', 'grok'].includes(normalizedProvider) ? normalizedProvider : 'openai';
|
|
3391
|
+
const managedProvider = ['claude', 'cursor', 'grok', 'deepseek'].includes(normalizedProvider) ? normalizedProvider : 'openai';
|
|
3320
3392
|
const managedDefaultModel = {
|
|
3321
3393
|
openai: DEFAULT_OPENAI_MODEL,
|
|
3322
3394
|
claude: DEFAULT_CLAUDE_MODEL,
|
|
3323
3395
|
cursor: DEFAULT_CURSOR_MODEL,
|
|
3324
3396
|
grok: DEFAULT_GROK_MODEL,
|
|
3397
|
+
deepseek: DEFAULT_DEEPSEEK_MODEL,
|
|
3325
3398
|
}[managedProvider];
|
|
3326
3399
|
const providerType = enabled === true ? managedProvider : 'github';
|
|
3327
3400
|
const providerModel = enabled === true
|
|
@@ -3395,7 +3468,9 @@ async function reconcileUnstartedConversationProviders({ enabled, model, provide
|
|
|
3395
3468
|
? getCursorProviderSettings().configured === true
|
|
3396
3469
|
: (currentProvider === 'grok'
|
|
3397
3470
|
? getGrokProviderSettings().enabled === true
|
|
3398
|
-
: (currentProvider
|
|
3471
|
+
: (currentProvider === 'deepseek'
|
|
3472
|
+
? getDeepSeekProviderSettings().configured === true
|
|
3473
|
+
: (currentProvider !== 'openai' || getOpenAIProviderSettings().configured === true))));
|
|
3399
3474
|
if (providerWasUpdated && canRestorePreviousProvider) {
|
|
3400
3475
|
stmts.updateRuntimeSessionProvider.run(
|
|
3401
3476
|
currentProvider,
|
|
@@ -5984,6 +6059,14 @@ const readContextFromSessionEvents = contextSnapshotService.readContextFromSessi
|
|
|
5984
6059
|
// Claude-provider sessions live under the Agent SDK's project layout rather than
|
|
5985
6060
|
// ~/.copilot/session-state, so their browsable session folder resolves separately.
|
|
5986
6061
|
const claudeSessionRootResolver = createClaudeSessionRootResolver({ fs, path });
|
|
6062
|
+
// DeepSeek chats run Claude Code with a config dir of their own: only that
|
|
6063
|
+
// root, never the host's ~/.claude.
|
|
6064
|
+
const deepseekSessionRootResolver = createClaudeSessionRootResolver({
|
|
6065
|
+
fs,
|
|
6066
|
+
path,
|
|
6067
|
+
env: { CLAUDE_CONFIG_DIR: DEEPSEEK_CLAUDE_CONFIG_DIR },
|
|
6068
|
+
homedir: () => '',
|
|
6069
|
+
});
|
|
5987
6070
|
const cursorSessionRootResolver = createCursorSessionRootResolver({ fs, path, serverDir: __dirname, dataDir: DATA_DIR });
|
|
5988
6071
|
|
|
5989
6072
|
// ─── Express + Socket.io Setup ────────────────────────────────────────────────
|
|
@@ -6371,8 +6454,12 @@ async function requestSessionWorkerSocketDelivery({ sessionId, pid, reason = 'wo
|
|
|
6371
6454
|
// without a new queue column or push channel. null = Auto.
|
|
6372
6455
|
...((() => {
|
|
6373
6456
|
const conv = out.conversationId ? stmts.getConvAnyStatus.get(out.conversationId) : null;
|
|
6457
|
+
const deliveredProvider = String(out.providerType || '').trim().toLowerCase();
|
|
6374
6458
|
return {
|
|
6375
|
-
|
|
6459
|
+
// DeepSeek's window is 1M; its sessions compact at 400K unless the
|
|
6460
|
+
// conversation pins another window.
|
|
6461
|
+
autoCompactWindow: parseAutoCompactWindow(conv?.auto_compact_window)
|
|
6462
|
+
?? (deliveredProvider === 'deepseek' ? DEEPSEEK_DEFAULT_AUTO_COMPACT_WINDOW : null),
|
|
6376
6463
|
// Per-conversation thinking control, same delivery scoping. An
|
|
6377
6464
|
// explicit null on thinkingEnabled means Host default — the worker
|
|
6378
6465
|
// distinguishes it from an absent key (an older relay), so both
|
|
@@ -6384,7 +6471,14 @@ async function requestSessionWorkerSocketDelivery({ sessionId, pid, reason = 'wo
|
|
|
6384
6471
|
// attribution. Always present on such a delivery, so the worker can
|
|
6385
6472
|
// tell "left as it was" (absent) from "vanilla" (null).
|
|
6386
6473
|
...((() => {
|
|
6387
|
-
|
|
6474
|
+
// A DeepSeek session runs Claude Code on the host like a Claude
|
|
6475
|
+
// one: the same two settings, with DeepSeek's attribution.
|
|
6476
|
+
if (deliveredProvider === 'deepseek') {
|
|
6477
|
+
return {
|
|
6478
|
+
attribution: deepseekAttributionForConversation(conv, out.providerModel || out.model),
|
|
6479
|
+
claudeBackgroundCommandLimitMs: claudeBackgroundCommandLimitMinutesToMs(getClaudeBackgroundCommandLimitMinutes()),
|
|
6480
|
+
};
|
|
6481
|
+
}
|
|
6388
6482
|
if (deliveredProvider !== 'claude' && deliveredProvider !== 'claude-cloud') return {};
|
|
6389
6483
|
return {
|
|
6390
6484
|
attribution: claudeAttributionForConversation(conv, out.providerModel || out.model, {
|
|
@@ -6740,6 +6834,9 @@ app.use((req, _res, next) => {
|
|
|
6740
6834
|
stripRequestPathPrefix(req, remotePath);
|
|
6741
6835
|
next();
|
|
6742
6836
|
});
|
|
6837
|
+
// What a session worker posts loses every key a provider saved in Settings
|
|
6838
|
+
// before any route stores, broadcasts or logs it (worker-secret-redaction.mjs).
|
|
6839
|
+
app.use(workerPostRedaction);
|
|
6743
6840
|
function isApiRequestPath(requestPath) {
|
|
6744
6841
|
return /^\/api(?:\/|$)/.test(String(requestPath || ''));
|
|
6745
6842
|
}
|
|
@@ -7074,6 +7171,10 @@ const sharedRouteDeps = {
|
|
|
7074
7171
|
getClaudeCloudProviderSettings,
|
|
7075
7172
|
claudeCloudSettingsService,
|
|
7076
7173
|
claudeCloudSessionService,
|
|
7174
|
+
getDeepSeekProviderSettings,
|
|
7175
|
+
deepseekSettingsService,
|
|
7176
|
+
deepseekBalanceService,
|
|
7177
|
+
deepseekUsageImportService,
|
|
7077
7178
|
claudeCloudRepoService,
|
|
7078
7179
|
// The login's state for the workers' nudge route (never the token).
|
|
7079
7180
|
claudeCloudLoginStatus: { describe: () => claudeCloudCredentials.describe() },
|
|
@@ -7198,6 +7299,8 @@ const sharedRouteDeps = {
|
|
|
7198
7299
|
relayRestartOrchestrator,
|
|
7199
7300
|
resolveSessionStateRoot,
|
|
7200
7301
|
resolveClaudeSessionRoot: claudeSessionRootResolver.resolveClaudeSessionRoot,
|
|
7302
|
+
resolveDeepSeekSessionRoot: deepseekSessionRootResolver.resolveClaudeSessionRoot,
|
|
7303
|
+
deepseekClaudeConfigDir: DEEPSEEK_CLAUDE_CONFIG_DIR,
|
|
7201
7304
|
resolveCursorSessionRoot: cursorSessionRootResolver.resolveCursorSessionRoot,
|
|
7202
7305
|
getTurnCeilingMinutes,
|
|
7203
7306
|
setTurnCeilingMinutes,
|
|
@@ -7271,6 +7374,14 @@ registerRelayScheduleRoutes(app, {
|
|
|
7271
7374
|
relayScheduleService,
|
|
7272
7375
|
remoteRelayInbound,
|
|
7273
7376
|
});
|
|
7377
|
+
registerDeepSeekUsageImportRoutes(app, {
|
|
7378
|
+
auth,
|
|
7379
|
+
deepseekUsageImportService,
|
|
7380
|
+
remoteRelayInbound,
|
|
7381
|
+
// The usage card's currency (the balance's), so the import's answer can
|
|
7382
|
+
// say whether the card uses the export's rows.
|
|
7383
|
+
getCardCurrency: async () => deepseekCardCurrency(await deepseekBalanceService.getBalance()),
|
|
7384
|
+
});
|
|
7274
7385
|
registerRelayAskRoutes(app, {
|
|
7275
7386
|
auth,
|
|
7276
7387
|
db,
|
|
@@ -7552,10 +7663,12 @@ function ensureRuntimeSessionBinding(
|
|
|
7552
7663
|
// default below and bind a GitHub (Copilot CLI) session.
|
|
7553
7664
|
: (normalizedRequestedProviderType === 'claude-cloud'
|
|
7554
7665
|
? 'claude-cloud'
|
|
7555
|
-
: (normalizedRequestedProviderType === '
|
|
7556
|
-
? '
|
|
7557
|
-
: (
|
|
7558
|
-
|
|
7666
|
+
: (normalizedRequestedProviderType === 'deepseek'
|
|
7667
|
+
? 'deepseek'
|
|
7668
|
+
: (normalizedRequestedProviderType === 'github'
|
|
7669
|
+
? 'github'
|
|
7670
|
+
: (openAISettings?.enabled ? 'openai' : 'github')))))));
|
|
7671
|
+
const resolvedProviderModel = (resolvedProviderType === 'openai' || resolvedProviderType === 'claude' || resolvedProviderType === 'cursor' || resolvedProviderType === 'grok' || resolvedProviderType === 'claude-cloud' || resolvedProviderType === 'deepseek')
|
|
7559
7672
|
? (String(providerModel || normalizedModel || '').trim() || null)
|
|
7560
7673
|
: null;
|
|
7561
7674
|
try {
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|