@oar-sh/oar 0.9.16 → 0.9.18
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/.github/extensions/web-relay/skills/tool-activity.mjs +5 -4
- package/CHANGELOG.md +412 -0
- package/README.md +15 -13
- package/package.json +2 -1
- package/server/claude-api-worker/claude-api-backend.mjs +345 -0
- package/server/claude-api-worker/claude-api-session-worker.mjs +29 -0
- package/server/claude-worker/claude-attachments.mjs +7 -4
- package/server/claude-worker/claude-cli-pinned-env.mjs +150 -0
- package/server/claude-worker/claude-remote-relay-tool.mjs +3 -3
- package/server/claude-worker/claude-sdk-adapter.mjs +199 -15
- package/server/claude-worker/claude-session-process.mjs +276 -33
- package/server/claude-worker/claude-session-worker-main.mjs +269 -0
- package/server/claude-worker/claude-session-worker.mjs +2 -253
- package/server/claude-worker/claude-subscription-key-check.mjs +64 -0
- package/server/claude-worker/claude-turn-failure.mjs +8 -0
- package/server/claude-worker/claude-turn-publisher.mjs +394 -13
- package/server/claude-worker/claude-usage-totals.mjs +161 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +56 -1
- package/server/copilot-worker/copilot-remote-relay-tool.mjs +2 -2
- package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +1 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +80 -14
- package/server/cursor-worker/cursor-remote-relay-tool.mjs +3 -3
- package/server/cursor-worker/cursor-turn-runner.mjs +4 -4
- package/server/cursor-worker/sdk-message-normalizer.mjs +2 -2
- package/server/db-schema.mjs +96 -1
- package/server/deepseek-worker/deepseek-claude-backend.mjs +317 -0
- package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
- package/server/grok-worker/acp-client.mjs +1 -1
- package/server/grok-worker/grok-turn-runner.mjs +4 -4
- package/server/grok-worker/sdk-message-normalizer.mjs +1 -1
- package/server/mcp/oar-mcp-server.mjs +11 -5
- 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/migrations/0031-queue-provider.mjs +143 -0
- package/server/migrations/0032-runtime-session-billing-switch.mjs +94 -0
- package/server/migrations/0033-claude-api-usage-checkpoints.mjs +138 -0
- package/server/migrations/0034-claude-api-usage-totals.mjs +112 -0
- package/server/public/app/activity-replay-state.mjs +4 -3
- package/server/public/app/agent-sessions-settings-ui.js +1 -1
- package/server/public/app/agent-sessions-settings.mjs +1 -1
- package/server/public/app/api-client.js +105 -0
- package/server/public/app/bootstrap.js +243 -29
- package/server/public/app/claude-api-settings-ui.js +362 -0
- package/server/public/app/claude-billing-toggle-ui.js +224 -0
- package/server/public/app/claude-billing-toggle.mjs +112 -0
- package/server/public/app/context-tier-options.mjs +10 -2
- package/server/public/app/context-usage-view.mjs +1 -1
- package/server/public/app/conversation-provider-indicator.mjs +18 -0
- package/server/public/app/conversation-view.js +31 -3
- package/server/public/app/deepseek-settings-ui.js +342 -0
- package/server/public/app/inbox-ui.js +14 -3
- package/server/public/app/inbox-view.mjs +1 -1
- package/server/public/app/journal-view.js +156 -11
- package/server/public/app/model-selector-options.mjs +28 -1
- package/server/public/app/model-vision-support.mjs +2 -0
- package/server/public/app/new-conversation-model-choice.mjs +21 -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 +8 -2
- package/server/public/app/relay-error-ctas.mjs +58 -0
- package/server/public/app/relay-schedules-ui.js +1 -1
- package/server/public/app/settings-modal.js +22 -0
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +28 -1
- package/server/public/app/tool-activity-emoji.mjs +4 -3
- package/server/public/app/usage-limit-ui.js +136 -5
- package/server/public/index.html +596 -35
- package/server/relay-tools.md +4 -4
- package/server/repositories/conversation-group-repository.mjs +14 -7
- package/server/repositories/message-repository.mjs +7 -0
- package/server/repositories/session-repository.mjs +8 -1
- package/server/routes/ask-user-routes.mjs +1 -1
- package/server/routes/conversation-group-routes.mjs +3 -2
- package/server/routes/deepseek-usage-import-routes.mjs +72 -0
- package/server/routes/messages-routes.mjs +831 -36
- package/server/routes/relay-ask-routes.mjs +3 -3
- package/server/routes/relay-event-routes.mjs +2 -2
- package/server/routes/relay-schedule-routes.mjs +1 -1
- package/server/routes/remote-relay-routes.mjs +3 -3
- package/server/routes/sessions-routes.mjs +670 -63
- package/server/server-runtime.mjs +476 -26
- package/server/services/about-service.mjs +2 -0
- package/server/services/background-work-view.mjs +2 -1
- package/server/services/claude-api-settings-service.mjs +575 -0
- package/server/services/claude-api-usage-service.mjs +361 -0
- package/server/services/claude-billing-swap-service.mjs +274 -0
- package/server/services/claude-billing-switch-service.mjs +343 -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-claude-api.mjs +445 -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 +436 -0
- package/server/services/provider-model-selection.mjs +4 -2
- package/server/services/provider-reasoning-effort.mjs +24 -2
- package/server/services/relay-ask-service.mjs +1 -1
- package/server/services/relay-event-service.mjs +3 -2
- package/server/services/relay-schedule-service.mjs +1 -1
- package/server/services/remote-relay-caller-context.mjs +7 -0
- package/server/services/remote-relay-dispatcher.mjs +84 -14
- package/server/services/remote-relay-registry-service.mjs +1 -1
- package/server/services/session-worker-launch-service.mjs +294 -84
- package/server/services/session-worker-registry-service.mjs +28 -1
- package/server/services/session-worker-websocket-service.mjs +17 -0
- package/server/services/usage-limit-pause-service.mjs +55 -4
- package/server/services/worker-secret-redaction.mjs +320 -0
- package/shared/anthropic-api.mjs +202 -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 +5 -3
- package/shared/relay-asks.mjs +11 -5
- package/shared/relay-events.mjs +2 -2
- package/shared/relay-schedules.mjs +2 -2
- package/shared/remote-relay-contract.mjs +29 -9
- package/shared/remote-relay-tool-core.mjs +23 -6
- package/shared/steer-settle-failure.mjs +2 -0
- package/shared/worker-runtime/api-client.mjs +2 -2
- package/shared/worker-runtime/direct-openai-image.mjs +128 -0
- package/shared/worker-runtime/heartbeat.mjs +5 -0
- package/shared/worker-runtime/worker-log-file.mjs +60 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +6 -0
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The Claude billing switch: a Claude chat moves between the host's Claude
|
|
5
|
+
* subscription (`claude`) and the API key saved in Settings → Providers →
|
|
6
|
+
* Claude API (`claude-api`), and keeps its Claude Code session: both run the
|
|
7
|
+
* same CLI on the same `~/.claude`.
|
|
8
|
+
*
|
|
9
|
+
* The chat's billing is `runtime_sessions.provider_type`. A switch, in one
|
|
10
|
+
* transaction:
|
|
11
|
+
* - checks the target: the provider usable (a key for the API, the Claude
|
|
12
|
+
* provider on for the subscription), the monthly cap, the chat's model in
|
|
13
|
+
* the target's list, and the history rule of decision 23;
|
|
14
|
+
* - writes the billing, when and by whom;
|
|
15
|
+
* - re-stamps the chat's rows that have not started (`queue.provider`), so
|
|
16
|
+
* they run on the new billing; a row already running finishes on its own;
|
|
17
|
+
* - to the API key, releases a turn held at the subscription's usage limit;
|
|
18
|
+
* - leaves one note in the transcript (a second switch before the next turn
|
|
19
|
+
* rewrites it);
|
|
20
|
+
* - and, for an agent's `send`, enqueues its message (`enqueue`).
|
|
21
|
+
*
|
|
22
|
+
* The worker swap itself is the delivery's (claude-billing-swap-service.mjs):
|
|
23
|
+
* the next row of the new billing is not handed to a worker of the old kind,
|
|
24
|
+
* which is replaced once it is idle.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { randomUUID } from 'node:crypto';
|
|
28
|
+
|
|
29
|
+
import { claudeBaseModelId, isClaudeLongContextModelId } from '../../shared/model-id.mjs';
|
|
30
|
+
|
|
31
|
+
export const CLAUDE_BILLING_PROVIDERS = Object.freeze(['claude', 'claude-api']);
|
|
32
|
+
export const CLAUDE_BILLING_NOTE_KIND = 'billing-switch';
|
|
33
|
+
export const CLAUDE_BILLING_SWITCHED_BY = Object.freeze(['user', 'agent', 'usage-limit']);
|
|
34
|
+
|
|
35
|
+
// Decision 23: whether a chat with turns on the other billing may move to
|
|
36
|
+
// this one. Set from the WP3 live check (a subscription session's history on
|
|
37
|
+
// the key, and back); 'blocked' disables the switch for such chats.
|
|
38
|
+
export const CLAUDE_BILLING_HISTORY_RULE = Object.freeze({
|
|
39
|
+
'claude-api': 'allowed',
|
|
40
|
+
claude: 'allowed',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export const CLAUDE_BILLING_LABELS = Object.freeze({
|
|
44
|
+
claude: 'Subscription',
|
|
45
|
+
'claude-api': 'API key',
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export function normalizeClaudeBilling(value) {
|
|
49
|
+
const billing = String(value || '').trim().toLowerCase();
|
|
50
|
+
return CLAUDE_BILLING_PROVIDERS.includes(billing) ? billing : '';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function otherClaudeBilling(billing) {
|
|
54
|
+
return normalizeClaudeBilling(billing) === 'claude' ? 'claude-api' : 'claude';
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The transcript note for a switch. */
|
|
58
|
+
export function buildClaudeBillingNoteText({ to, by = 'user' } = {}) {
|
|
59
|
+
const who = by === 'agent' ? ' (switched by an agent)' : (by === 'usage-limit' ? ' (switched at the usage limit)' : '');
|
|
60
|
+
if (normalizeClaudeBilling(to) === 'claude-api') {
|
|
61
|
+
return `💳 Billing: this chat runs on the Claude API key from the next turn${who}. `
|
|
62
|
+
+ 'The first turn on the key has no prompt cache and costs more.';
|
|
63
|
+
}
|
|
64
|
+
return `💳 Billing: this chat runs on the Claude subscription from the next turn${who}.`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function refusal(status, code, error, extra = {}) {
|
|
68
|
+
return { ok: false, status, code, error, ...extra };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function modelListFor(settings) {
|
|
72
|
+
return new Set([
|
|
73
|
+
String(settings?.model || '').trim(),
|
|
74
|
+
...(Array.isArray(settings?.availableModels) ? settings.availableModels : []),
|
|
75
|
+
...(Array.isArray(settings?.models) ? settings.models : []),
|
|
76
|
+
].map((value) => String(value || '').trim().toLowerCase()).filter(Boolean));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createClaudeBillingSwitchService({
|
|
80
|
+
db,
|
|
81
|
+
stmts,
|
|
82
|
+
emit = () => {},
|
|
83
|
+
getClaudeProviderSettings = () => null,
|
|
84
|
+
getClaudeApiProviderSettings = () => null,
|
|
85
|
+
// The monthly cap: `{ reached, error? }`, from the runtime's
|
|
86
|
+
// getClaudeApiCapState seam (the claim check asks the same one; WP5 fills
|
|
87
|
+
// it in). Not reached by default.
|
|
88
|
+
getClaudeApiCapState = () => ({ reached: false }),
|
|
89
|
+
historyRule = CLAUDE_BILLING_HISTORY_RULE,
|
|
90
|
+
// Releases a turn held at the subscription's usage limit
|
|
91
|
+
// (usage-limit-pause-service.mjs); runs inside the switch's transaction.
|
|
92
|
+
releaseUsageLimitPause = null,
|
|
93
|
+
// The runtime row → what the worker swap waits for (claude-billing-swap-
|
|
94
|
+
// service.mjs, pendingFor), or null.
|
|
95
|
+
getPending = () => null,
|
|
96
|
+
onQueueChanged = () => {},
|
|
97
|
+
now = () => Date.now(),
|
|
98
|
+
logger = console,
|
|
99
|
+
} = {}) {
|
|
100
|
+
if (!db || !stmts) throw new Error('createClaudeBillingSwitchService requires db and stmts');
|
|
101
|
+
|
|
102
|
+
const runtimeColumns = new Set(db.prepare(`PRAGMA table_info(runtime_sessions)`).all().map((column) => column.name));
|
|
103
|
+
const queueColumns = new Set(db.prepare(`PRAGMA table_info(queue)`).all().map((column) => column.name));
|
|
104
|
+
const messageColumns = new Set(db.prepare(`PRAGMA table_info(messages)`).all().map((column) => column.name));
|
|
105
|
+
const recordsSwitch = runtimeColumns.has('billing_switched_at') && runtimeColumns.has('billing_switched_by');
|
|
106
|
+
|
|
107
|
+
const getRuntime = db.prepare(`SELECT * FROM runtime_sessions WHERE conversation_id = ? LIMIT 1`);
|
|
108
|
+
const setBilling = recordsSwitch
|
|
109
|
+
? db.prepare(`
|
|
110
|
+
UPDATE runtime_sessions
|
|
111
|
+
SET provider_type = ?, billing_switched_at = ?, billing_switched_by = ?, last_used_at = ?
|
|
112
|
+
WHERE id = ?
|
|
113
|
+
`)
|
|
114
|
+
: db.prepare(`UPDATE runtime_sessions SET provider_type = ?, last_used_at = ? WHERE id = ?`);
|
|
115
|
+
const setModel = db.prepare(`UPDATE runtime_sessions SET provider_model = ?, model = ? WHERE id = ?`);
|
|
116
|
+
const restampRows = queueColumns.has('provider')
|
|
117
|
+
? db.prepare(`UPDATE queue SET provider = ? WHERE conversation_id = ? AND status IN ('pending', 'parked')`)
|
|
118
|
+
: null;
|
|
119
|
+
const countUnstartedRows = db.prepare(`
|
|
120
|
+
SELECT COUNT(*) AS n FROM queue WHERE conversation_id = ? AND status IN ('pending', 'parked')
|
|
121
|
+
`);
|
|
122
|
+
// The turns that ran on a billing other than `?`: replies with a recorded
|
|
123
|
+
// provider. A reply from before it was recorded (or a relay note) names
|
|
124
|
+
// none and does not count: an old chat is judged by the turns it has run
|
|
125
|
+
// since, and a history the API still refuses meets the turn's own refusal.
|
|
126
|
+
const countOtherBillingTurns = messageColumns.has('executed_provider')
|
|
127
|
+
? db.prepare(`
|
|
128
|
+
SELECT COUNT(*) AS n FROM messages
|
|
129
|
+
WHERE conversation_id = ? AND role = 'assistant'
|
|
130
|
+
AND executed_provider IS NOT NULL AND LOWER(executed_provider) != ?
|
|
131
|
+
`)
|
|
132
|
+
: null;
|
|
133
|
+
const latestMessage = messageColumns.has('kind')
|
|
134
|
+
? db.prepare(`SELECT id, role, kind, timestamp FROM messages WHERE conversation_id = ? ORDER BY timestamp DESC, rowid DESC LIMIT 1`)
|
|
135
|
+
: null;
|
|
136
|
+
const rewriteNote = db.prepare(`UPDATE messages SET text = ?, timestamp = ? WHERE id = ?`);
|
|
137
|
+
|
|
138
|
+
function readSettings(read) {
|
|
139
|
+
try {
|
|
140
|
+
const settings = read();
|
|
141
|
+
return settings && typeof settings === 'object' ? settings : {};
|
|
142
|
+
} catch {
|
|
143
|
+
return {};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function chatModel(runtime) {
|
|
148
|
+
return String(runtime?.provider_model || runtime?.model || '').trim();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Whether the chat may move to `to` now: `{ ok: true, runtime, from, to,
|
|
153
|
+
* model }`, or a refusal `{ ok: false, status, code, error }`. `model` is a
|
|
154
|
+
* model asked for with the switch, checked in place of the chat's own.
|
|
155
|
+
*/
|
|
156
|
+
function checkSwitch({ conversationId, to, model = '' } = {}) {
|
|
157
|
+
const convId = String(conversationId || '').trim();
|
|
158
|
+
const target = normalizeClaudeBilling(to);
|
|
159
|
+
if (!target) return refusal(400, 'CLAUDE_BILLING_INVALID', 'Billing must be "claude" (subscription) or "claude-api" (API key)');
|
|
160
|
+
const runtime = convId ? (getRuntime.get(convId) || null) : null;
|
|
161
|
+
if (!runtime) return refusal(404, 'CONVERSATION_NOT_FOUND', 'Conversation not found');
|
|
162
|
+
const from = normalizeClaudeBilling(runtime.provider_type);
|
|
163
|
+
if (!from) {
|
|
164
|
+
return refusal(409, 'CLAUDE_BILLING_NOT_CLAUDE', 'Only a Claude chat can switch between the subscription and the API key', {
|
|
165
|
+
provider: String(runtime.provider_type || '').trim().toLowerCase() || 'github',
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
if (from === target) return { ok: true, runtime, from, to: target, model: chatModel(runtime), changed: false };
|
|
169
|
+
const settings = target === 'claude-api'
|
|
170
|
+
? readSettings(getClaudeApiProviderSettings)
|
|
171
|
+
: readSettings(getClaudeProviderSettings);
|
|
172
|
+
if (target === 'claude-api' && settings.enabled !== true) {
|
|
173
|
+
return refusal(409, 'CLAUDE_API_NOT_CONFIGURED', settings.configured === true
|
|
174
|
+
? 'Claude API is switched off. Switch it on in Settings → Providers → Claude API.'
|
|
175
|
+
: 'Claude API has no key. Save one in Settings → Providers → Claude API.');
|
|
176
|
+
}
|
|
177
|
+
if (target === 'claude' && settings.enabled !== true) {
|
|
178
|
+
return refusal(409, 'CLAUDE_NOT_CONFIGURED', 'The Claude provider is switched off. Switch it on in Settings → Providers → Claude.');
|
|
179
|
+
}
|
|
180
|
+
if (target === 'claude-api') {
|
|
181
|
+
let cap = null;
|
|
182
|
+
// A cap that cannot be read is taken as reached: the key's bill is at stake.
|
|
183
|
+
try { cap = getClaudeApiCapState() || null; } catch { cap = { reached: true }; }
|
|
184
|
+
if (cap?.reached === true) {
|
|
185
|
+
return refusal(409, 'CLAUDE_API_CAP_REACHED', String(cap.error || '').trim()
|
|
186
|
+
|| "This month's Claude API spend has reached the cap set in Settings → Providers → Claude API.");
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const wanted = String(model || '').trim() || chatModel(runtime);
|
|
190
|
+
if (wanted) {
|
|
191
|
+
const list = modelListFor(settings);
|
|
192
|
+
const exact = list.has(wanted.toLowerCase());
|
|
193
|
+
// A `[1m]` model needs its long-context variant listed; a plain id is
|
|
194
|
+
// also taken by its base.
|
|
195
|
+
const listed = exact || (!isClaudeLongContextModelId(wanted) && list.has(claudeBaseModelId(wanted).toLowerCase()));
|
|
196
|
+
if (!listed) {
|
|
197
|
+
return refusal(409, 'CLAUDE_BILLING_MODEL_UNAVAILABLE',
|
|
198
|
+
`${wanted} is not in the ${target === 'claude-api' ? 'Claude API' : 'Claude'} model list. Pick a model it has, then switch.`,
|
|
199
|
+
{ model: wanted });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (String(historyRule?.[target] || 'allowed') === 'blocked' && countOtherBillingTurns) {
|
|
203
|
+
const turns = Number(countOtherBillingTurns.get(runtime.conversation_id, target)?.n || 0);
|
|
204
|
+
if (turns > 0) {
|
|
205
|
+
return refusal(409, 'CLAUDE_BILLING_HISTORY', target === 'claude-api'
|
|
206
|
+
? 'This chat already has subscription turns, and the API refuses that history. Start a new Claude API chat.'
|
|
207
|
+
: 'This chat already has API key turns, and the subscription refuses that history. Start a new Claude chat.');
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return { ok: true, runtime, from, to: target, model: wanted, changed: true };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** What the composer's toggle and the usage-limit note read about a chat's billing. */
|
|
214
|
+
function billingState(conversationId) {
|
|
215
|
+
const runtime = getRuntime.get(String(conversationId || '').trim()) || null;
|
|
216
|
+
const provider = normalizeClaudeBilling(runtime?.provider_type);
|
|
217
|
+
if (!provider) return null;
|
|
218
|
+
const to = otherClaudeBilling(provider);
|
|
219
|
+
const check = checkSwitch({ conversationId, to });
|
|
220
|
+
let pending = null;
|
|
221
|
+
try { pending = getPending(runtime) || null; } catch { pending = null; }
|
|
222
|
+
return {
|
|
223
|
+
provider,
|
|
224
|
+
pending,
|
|
225
|
+
switchedAt: runtime?.billing_switched_at || null,
|
|
226
|
+
switchedBy: runtime?.billing_switched_by || null,
|
|
227
|
+
switchable: {
|
|
228
|
+
to,
|
|
229
|
+
allowed: check.ok === true,
|
|
230
|
+
code: check.ok ? null : check.code,
|
|
231
|
+
reason: check.ok ? null : check.error,
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Switch the chat's billing. `enqueue({ runtime, billing })` runs inside
|
|
238
|
+
* the transaction after the switch (an agent's message), also when the
|
|
239
|
+
* chat already has that billing; a throw undoes the switch. A switch to
|
|
240
|
+
* the key releases a turn held at the subscription's usage limit.
|
|
241
|
+
*/
|
|
242
|
+
function switchBilling({ conversationId, to, by = 'user', model = '', enqueue = null, deferAnnounce = false } = {}) {
|
|
243
|
+
const who = CLAUDE_BILLING_SWITCHED_BY.includes(by) ? by : 'user';
|
|
244
|
+
let outcome = null;
|
|
245
|
+
let note = null;
|
|
246
|
+
let released = null;
|
|
247
|
+
let enqueued = null;
|
|
248
|
+
const run = db.transaction(() => {
|
|
249
|
+
const check = checkSwitch({ conversationId, to, model });
|
|
250
|
+
if (!check.ok) {
|
|
251
|
+
outcome = check;
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const at = new Date(now()).toISOString();
|
|
255
|
+
const { runtime } = check;
|
|
256
|
+
if (check.changed) {
|
|
257
|
+
if (recordsSwitch) setBilling.run(check.to, at, who, at, runtime.id);
|
|
258
|
+
else setBilling.run(check.to, at, runtime.id);
|
|
259
|
+
if (String(model || '').trim() && check.model !== chatModel(runtime)) setModel.run(check.model, check.model, runtime.id);
|
|
260
|
+
restampRows?.run(check.to, runtime.conversation_id);
|
|
261
|
+
note = writeNote(runtime.conversation_id, check.to, who, at);
|
|
262
|
+
if (check.to === 'claude-api' && typeof releaseUsageLimitPause === 'function') {
|
|
263
|
+
released = releaseUsageLimitPause({ conversationId: runtime.conversation_id, by: 'billing-switch' }) || null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (typeof enqueue === 'function') enqueued = enqueue({ runtime, billing: check.to }) ?? null;
|
|
267
|
+
outcome = {
|
|
268
|
+
ok: true,
|
|
269
|
+
changed: check.changed,
|
|
270
|
+
conversationId: runtime.conversation_id,
|
|
271
|
+
provider: check.to,
|
|
272
|
+
from: check.from,
|
|
273
|
+
at,
|
|
274
|
+
queuedRows: Number(countUnstartedRows.get(runtime.conversation_id)?.n || 0),
|
|
275
|
+
};
|
|
276
|
+
});
|
|
277
|
+
run();
|
|
278
|
+
if (!outcome?.ok) return outcome;
|
|
279
|
+
const result = {
|
|
280
|
+
...outcome,
|
|
281
|
+
noteMessageId: note?.messageId || null,
|
|
282
|
+
released: released ? { messageId: released.messageId || null } : null,
|
|
283
|
+
enqueued,
|
|
284
|
+
};
|
|
285
|
+
// Called inside a caller's transaction (the send route's persist step),
|
|
286
|
+
// the switch is not committed yet: the caller announces it once it is.
|
|
287
|
+
if (deferAnnounce) return { ...result, announce: () => announce(outcome, note, released, who) };
|
|
288
|
+
announce(outcome, note, released, who);
|
|
289
|
+
return result;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// What the clients and the queue hear of a committed switch.
|
|
293
|
+
function announce(outcome, note, released, who) {
|
|
294
|
+
try { released?.announce?.(); } catch {}
|
|
295
|
+
if (note) {
|
|
296
|
+
try {
|
|
297
|
+
emit(note.created ? 'assistant_message' : 'message_updated', {
|
|
298
|
+
conversationId: outcome.conversationId,
|
|
299
|
+
messageId: note.messageId,
|
|
300
|
+
message: {
|
|
301
|
+
id: note.messageId,
|
|
302
|
+
role: 'assistant',
|
|
303
|
+
text: note.text,
|
|
304
|
+
kind: CLAUDE_BILLING_NOTE_KIND,
|
|
305
|
+
model: null,
|
|
306
|
+
mode: null,
|
|
307
|
+
attachments: [],
|
|
308
|
+
timestamp: note.timestamp,
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
} catch {}
|
|
312
|
+
}
|
|
313
|
+
if (outcome.changed) {
|
|
314
|
+
try {
|
|
315
|
+
logger.log?.(`[claude-billing] conv=${outcome.conversationId.slice(0, 8)} ${outcome.from} → ${outcome.provider} by ${who}; ${outcome.queuedRows} queued row(s) re-stamped`);
|
|
316
|
+
} catch {}
|
|
317
|
+
try { emit('conversation_billing', { conversationId: outcome.conversationId, billing: billingState(outcome.conversationId) }); } catch {}
|
|
318
|
+
try { onQueueChanged('claude-billing-switch'); } catch {}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// One note per run of switches: one written since the chat's last message
|
|
323
|
+
// is rewritten in place, so a switch there and back leaves one line.
|
|
324
|
+
function writeNote(conversationId, to, by, at) {
|
|
325
|
+
const text = buildClaudeBillingNoteText({ to, by });
|
|
326
|
+
const latest = latestMessage?.get(conversationId) || null;
|
|
327
|
+
if (latest && latest.role === 'assistant' && latest.kind === CLAUDE_BILLING_NOTE_KIND) {
|
|
328
|
+
rewriteNote.run(text, at, latest.id);
|
|
329
|
+
return { messageId: latest.id, text, timestamp: at, created: false };
|
|
330
|
+
}
|
|
331
|
+
const messageId = randomUUID();
|
|
332
|
+
stmts.insertMsg.run(messageId, conversationId, 'assistant', text, null, null, null, at, null, null, null);
|
|
333
|
+
stmts.setMessageKind?.run?.(CLAUDE_BILLING_NOTE_KIND, messageId);
|
|
334
|
+
stmts.updateConvTime?.run?.(at, conversationId);
|
|
335
|
+
return { messageId, text, timestamp: at, created: true };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
checkSwitch,
|
|
340
|
+
billingState,
|
|
341
|
+
switchBilling,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
@@ -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
|
+
}
|