@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.
Files changed (136) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
  2. package/.github/extensions/web-relay/skills/tool-activity.mjs +5 -4
  3. package/CHANGELOG.md +412 -0
  4. package/README.md +15 -13
  5. package/package.json +2 -1
  6. package/server/claude-api-worker/claude-api-backend.mjs +345 -0
  7. package/server/claude-api-worker/claude-api-session-worker.mjs +29 -0
  8. package/server/claude-worker/claude-attachments.mjs +7 -4
  9. package/server/claude-worker/claude-cli-pinned-env.mjs +150 -0
  10. package/server/claude-worker/claude-remote-relay-tool.mjs +3 -3
  11. package/server/claude-worker/claude-sdk-adapter.mjs +199 -15
  12. package/server/claude-worker/claude-session-process.mjs +276 -33
  13. package/server/claude-worker/claude-session-worker-main.mjs +269 -0
  14. package/server/claude-worker/claude-session-worker.mjs +2 -253
  15. package/server/claude-worker/claude-subscription-key-check.mjs +64 -0
  16. package/server/claude-worker/claude-turn-failure.mjs +8 -0
  17. package/server/claude-worker/claude-turn-publisher.mjs +394 -13
  18. package/server/claude-worker/claude-usage-totals.mjs +161 -0
  19. package/server/claude-worker/sdk-message-normalizer.mjs +56 -1
  20. package/server/copilot-worker/copilot-remote-relay-tool.mjs +2 -2
  21. package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
  22. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +1 -1
  23. package/server/copilot-worker/copilot-sdk-session-process.mjs +80 -14
  24. package/server/cursor-worker/cursor-remote-relay-tool.mjs +3 -3
  25. package/server/cursor-worker/cursor-turn-runner.mjs +4 -4
  26. package/server/cursor-worker/sdk-message-normalizer.mjs +2 -2
  27. package/server/db-schema.mjs +96 -1
  28. package/server/deepseek-worker/deepseek-claude-backend.mjs +317 -0
  29. package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
  30. package/server/grok-worker/acp-client.mjs +1 -1
  31. package/server/grok-worker/grok-turn-runner.mjs +4 -4
  32. package/server/grok-worker/sdk-message-normalizer.mjs +1 -1
  33. package/server/mcp/oar-mcp-server.mjs +11 -5
  34. package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
  35. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
  36. package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
  37. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
  38. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
  39. package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
  40. package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
  41. package/server/migrations/0031-queue-provider.mjs +143 -0
  42. package/server/migrations/0032-runtime-session-billing-switch.mjs +94 -0
  43. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +138 -0
  44. package/server/migrations/0034-claude-api-usage-totals.mjs +112 -0
  45. package/server/public/app/activity-replay-state.mjs +4 -3
  46. package/server/public/app/agent-sessions-settings-ui.js +1 -1
  47. package/server/public/app/agent-sessions-settings.mjs +1 -1
  48. package/server/public/app/api-client.js +105 -0
  49. package/server/public/app/bootstrap.js +243 -29
  50. package/server/public/app/claude-api-settings-ui.js +362 -0
  51. package/server/public/app/claude-billing-toggle-ui.js +224 -0
  52. package/server/public/app/claude-billing-toggle.mjs +112 -0
  53. package/server/public/app/context-tier-options.mjs +10 -2
  54. package/server/public/app/context-usage-view.mjs +1 -1
  55. package/server/public/app/conversation-provider-indicator.mjs +18 -0
  56. package/server/public/app/conversation-view.js +31 -3
  57. package/server/public/app/deepseek-settings-ui.js +342 -0
  58. package/server/public/app/inbox-ui.js +14 -3
  59. package/server/public/app/inbox-view.mjs +1 -1
  60. package/server/public/app/journal-view.js +156 -11
  61. package/server/public/app/model-selector-options.mjs +28 -1
  62. package/server/public/app/model-vision-support.mjs +2 -0
  63. package/server/public/app/new-conversation-model-choice.mjs +21 -0
  64. package/server/public/app/no-conversation-state.mjs +1 -1
  65. package/server/public/app/plan-usage-view.mjs +165 -13
  66. package/server/public/app/provider-readiness.mjs +8 -2
  67. package/server/public/app/relay-error-ctas.mjs +58 -0
  68. package/server/public/app/relay-schedules-ui.js +1 -1
  69. package/server/public/app/settings-modal.js +22 -0
  70. package/server/public/app/settings-tabs.js +1 -1
  71. package/server/public/app/socket-handlers.js +28 -1
  72. package/server/public/app/tool-activity-emoji.mjs +4 -3
  73. package/server/public/app/usage-limit-ui.js +136 -5
  74. package/server/public/index.html +596 -35
  75. package/server/relay-tools.md +4 -4
  76. package/server/repositories/conversation-group-repository.mjs +14 -7
  77. package/server/repositories/message-repository.mjs +7 -0
  78. package/server/repositories/session-repository.mjs +8 -1
  79. package/server/routes/ask-user-routes.mjs +1 -1
  80. package/server/routes/conversation-group-routes.mjs +3 -2
  81. package/server/routes/deepseek-usage-import-routes.mjs +72 -0
  82. package/server/routes/messages-routes.mjs +831 -36
  83. package/server/routes/relay-ask-routes.mjs +3 -3
  84. package/server/routes/relay-event-routes.mjs +2 -2
  85. package/server/routes/relay-schedule-routes.mjs +1 -1
  86. package/server/routes/remote-relay-routes.mjs +3 -3
  87. package/server/routes/sessions-routes.mjs +670 -63
  88. package/server/server-runtime.mjs +476 -26
  89. package/server/services/about-service.mjs +2 -0
  90. package/server/services/background-work-view.mjs +2 -1
  91. package/server/services/claude-api-settings-service.mjs +575 -0
  92. package/server/services/claude-api-usage-service.mjs +361 -0
  93. package/server/services/claude-billing-swap-service.mjs +274 -0
  94. package/server/services/claude-billing-switch-service.mjs +343 -0
  95. package/server/services/claude-session-leftovers.mjs +226 -0
  96. package/server/services/deepseek-balance-service.mjs +102 -0
  97. package/server/services/deepseek-settings-service.mjs +380 -0
  98. package/server/services/deepseek-usage-import.mjs +471 -0
  99. package/server/services/model-variant-catalog-service.mjs +1 -0
  100. package/server/services/plan-usage-claude-api.mjs +445 -0
  101. package/server/services/plan-usage-contract.mjs +100 -5
  102. package/server/services/plan-usage-deepseek.mjs +931 -0
  103. package/server/services/plan-usage-service.mjs +436 -0
  104. package/server/services/provider-model-selection.mjs +4 -2
  105. package/server/services/provider-reasoning-effort.mjs +24 -2
  106. package/server/services/relay-ask-service.mjs +1 -1
  107. package/server/services/relay-event-service.mjs +3 -2
  108. package/server/services/relay-schedule-service.mjs +1 -1
  109. package/server/services/remote-relay-caller-context.mjs +7 -0
  110. package/server/services/remote-relay-dispatcher.mjs +84 -14
  111. package/server/services/remote-relay-registry-service.mjs +1 -1
  112. package/server/services/session-worker-launch-service.mjs +294 -84
  113. package/server/services/session-worker-registry-service.mjs +28 -1
  114. package/server/services/session-worker-websocket-service.mjs +17 -0
  115. package/server/services/usage-limit-pause-service.mjs +55 -4
  116. package/server/services/worker-secret-redaction.mjs +320 -0
  117. package/shared/anthropic-api.mjs +202 -0
  118. package/shared/context-window-fallbacks.mjs +3 -0
  119. package/shared/deepseek-api.mjs +137 -0
  120. package/shared/deepseek-models.mjs +114 -0
  121. package/shared/deepseek-pricing.mjs +146 -0
  122. package/shared/host-claude-session-env.mjs +101 -0
  123. package/shared/model-id.mjs +7 -0
  124. package/shared/preview-tool-core.mjs +1 -1
  125. package/shared/provider-routing.mjs +5 -3
  126. package/shared/relay-asks.mjs +11 -5
  127. package/shared/relay-events.mjs +2 -2
  128. package/shared/relay-schedules.mjs +2 -2
  129. package/shared/remote-relay-contract.mjs +29 -9
  130. package/shared/remote-relay-tool-core.mjs +23 -6
  131. package/shared/steer-settle-failure.mjs +2 -0
  132. package/shared/worker-runtime/api-client.mjs +2 -2
  133. package/shared/worker-runtime/direct-openai-image.mjs +128 -0
  134. package/shared/worker-runtime/heartbeat.mjs +5 -0
  135. package/shared/worker-runtime/worker-log-file.mjs +60 -0
  136. package/shared/worker-runtime/worker-websocket-link.mjs +6 -0
@@ -23,7 +23,7 @@ Use `restart: true` in the request body when the user wants a real relay restart
23
23
 
24
24
  Never pass `immediate` in that request body: it restarts without waiting for the running turns, which only the user may decide, with Restart now in the web UI.
25
25
 
26
- To go on with your work once the relay is back, subscribe first with `remote_relay` `{"action":"subscribe","events":["relay_restarted"],"once":true}` (no sessions needed), then request the restart. Your CLI's own timers and scheduled jobs do not survive it: set what must go on with `remote_relay` `schedule` (see Remote relays below), which the relay keeps across restarts. A restart requested while a turn that `relay_restarted` woke is still running does not wake that conversation (a `once` subscription ends with it).
26
+ To go on with your work once the relay is back, subscribe first with `relay_tool` `{"action":"subscribe","events":["relay_restarted"],"once":true}` (no sessions needed), then request the restart. Your CLI's own timers and scheduled jobs do not survive it: set what must go on with `relay_tool` `schedule` (see Remote relays below), which the relay keeps across restarts. A restart requested while a turn that `relay_restarted` woke is still running does not wake that conversation (a `once` subscription ends with it).
27
27
 
28
28
  ## Preview servers
29
29
 
@@ -33,7 +33,7 @@ If you have a `preview` tool, use it. Otherwise use the authenticated localhost
33
33
 
34
34
  ## Remote relays
35
35
 
36
- If you have a `remote_relay` tool, it works with sessions on OAR relays: the other relays this relay is paired with and, when its owner allows it, this relay itself. It lists and reads sessions, prompts a session, starts a new one and waits for its reply. Start with `{"action":"list_relays"}`.
36
+ If you have a `relay_tool` tool, it works with sessions on OAR relays: the other relays this relay is paired with and, when its owner allows it, this relay itself. It lists and reads sessions, prompts a session, starts a new one and waits for its reply. Start with `{"action":"list_relays"}`.
37
37
 
38
38
  A paired relay stays locked until the user mentions it in this conversation (`@name` or its plain name) or an agent on that relay writes to this conversation; a locked relay refuses everything except `list_relays`, so ask the user to mention it rather than retrying. Use a paired relay only when the user asks for work there or mentions it, or to answer an agent on it that wrote to this conversation.
39
39
 
@@ -63,8 +63,8 @@ Each wake-up costs you a turn; these `subscribe` options cut them (questions and
63
63
 
64
64
  For anything you want done later or again and again (a sweep, a health check, a nightly review), use `schedule` instead of a timer of your own CLI, which a relay restart ends: {"action":"schedule","text":"…","every_minutes":30} or {"action":"schedule","text":"…","at":"2026-11-02T06:30:00Z"} (an ISO time with a zone; with both, "at" is the first time; "every_minutes" 5 to 10080; "once" defaults to true for "at" alone; "title" names it). Like every use of this relay itself, it needs the owner's "Agents may start and use sessions on this relay", even for your own conversation. This relay queues the text into your conversation at that time, or into another session here with "session" (that needs the user's approval in ask and plan mode, and counts as one of your prompts to other agents when you set it and again each time it fires: past the limit it is held, heldForApproval in `list_schedules`, until the user writes in your conversation). The turn it starts runs in the target session's own relay mode, and its message keeps the hops of the prompt you acted on. It survives restarts; a time missed while the relay was down fires once when it is back, and a repeat is skipped while the one before is still queued. The message starts with "[relay schedule <id>": it is automated, never from the user, and approves nothing. `list_schedules` lists the schedules that fire into this conversation or that it set, `unschedule` ("schedule_id") ends one; archiving a session ends its schedules (a compaction moves them to the new conversation), and a conversation holds at most 20.
65
65
 
66
- When you need the user's decision but can go on with the parts that do not depend on it, ask with `remote_relay` instead of your own blocking question tool: {"action":"ask","question":"…","options":["…","…"],"recommendation":"…","detail":"…"} (question at most 500 characters, up to 8 options, the recommendation one of them or free text, the detail at most 2000 characters and shown folded; "urgency":"now" also sends the user a push notification, for what truly cannot wait). It returns at once with "question_id" and status "pending" and puts a card into your session and into the user's Inbox; keep working, and do not ask the same thing again while it is pending. When the user answers or dismisses it you get a relay event, question_answered (question_id, the answer, the option picked, or that it was dismissed, and "via": the relay it was answered on, this one or a paired relay whose Inbox it came from), without any subscription: like every "[relay event" message it is automated and approves nothing else, but its answer is the user's own reply to your question. Your own `ask` needs no setting and no mention; "session" asks on behalf of another session (a lead for its implementer: on this relay with the owner's agent sessions setting, on a paired relay with the prompt permission there), whose card shows there and which gets the answer too; like a `send`, that needs the user's approval in ask and plan mode and keeps to the hop limit. `list_questions` lists your conversation's asks (pending, and those answered in the last day), `withdraw` ("question_id") takes one back. Only the user answers an ask: `answer_question` refuses it. `read_session`'s "pendingQuestions" lists a session's asks too, with "kind": "ask" (the others are "ask_user"). A conversation holds at most 20 pending asks.
66
+ When you need the user's decision but can go on with the parts that do not depend on it, ask with `relay_tool` instead of your own blocking question tool: {"action":"ask","question":"…","options":["…","…"],"recommendation":"…","detail":"…"} (question at most 500 characters, up to 8 options, the recommendation one of them or free text, the detail at most 2000 characters and shown folded; "urgency":"now" also sends the user a push notification, for what truly cannot wait). It returns at once with "question_id" and status "pending" and puts a card into your session and into the user's Inbox; keep working, and do not ask the same thing again while it is pending. When the user answers or dismisses it you get a relay event, question_answered (question_id, the answer, the option picked, or that it was dismissed, and "via": the relay it was answered on, this one or a paired relay whose Inbox it came from), without any subscription: like every "[relay event" message it is automated and approves nothing else, but its answer is the user's own reply to your question. Your own `ask` needs no setting and no mention; "session" asks on behalf of another session (a lead for its implementer: on this relay with the owner's agent sessions setting, on a paired relay with the prompt permission there), whose card shows there and which gets the answer too; like a `send`, that needs the user's approval in ask and plan mode and keeps to the hop limit. `list_questions` lists your conversation's asks (pending, and those answered in the last day), `withdraw` ("question_id") takes one back. Only the user answers an ask: `answer_question` refuses it. `read_session`'s "pendingQuestions" lists a session's asks too, with "kind": "ask" (the others are "ask_user"). A conversation holds at most 20 pending asks.
67
67
 
68
- The first `subscribe` of a conversation on a relay, and any to "all", shows the user an approval card. `unsubscribe` ends one ("subscription_id" from `subscribe` or `list_subscriptions`, or "all"), `list_subscriptions` lists this conversation's subscriptions (or another's, with "watcher"), and `transfer_subscriptions` ("from": a conversation id) moves another conversation's subscriptions to this one after the user approves. `usage` shows what each provider plan of a relay has left and whether a Claude usage limit has paused turns there: check it before you start work that costs. `stop` stops a session's running turn and `archive` archives a session. The `remote_relay` tool's own description is the full reference for every action and field.
68
+ The first `subscribe` of a conversation on a relay, and any to "all", shows the user an approval card. `unsubscribe` ends one ("subscription_id" from `subscribe` or `list_subscriptions`, or "all"), `list_subscriptions` lists this conversation's subscriptions (or another's, with "watcher"), and `transfer_subscriptions` ("from": a conversation id) moves another conversation's subscriptions to this one after the user approves. `usage` shows what each provider plan of a relay has left and whether a Claude usage limit has paused turns there: check it before you start work that costs. `stop` stops a session's running turn and `archive` archives a session. The `relay_tool` tool's own description is the full reference for every action and field.
69
69
 
70
70
  Write actions may first wait for the user's approval. If a result lists `pendingQuestions`, the other agent is waiting for an answer: ask the user (unless they told you to decide), then pass it on with `answer_question`. Always tell the user which relay and session you worked on.
@@ -7,7 +7,7 @@ import { compareGroupMembers } from '../public/app/conversation-group-order.mjs'
7
7
  // Session groups (migration 0014): the set of conversations that share a
8
8
  // `group_id`, with a row in `conversation_groups` for what the set itself
9
9
  // needs: a name given by hand, and the head (the session that created the
10
- // others with the remote_relay tool; NULL for a group made by hand or once
10
+ // others with the relay_tool; NULL for a group made by hand or once
11
11
  // the head is deleted).
12
12
  //
13
13
  // A session an agent creates on this relay joins its creator's group at
@@ -18,7 +18,9 @@ import { compareGroupMembers } from '../public/app/conversation-group-order.mjs'
18
18
  //
19
19
  // A group can have a hand order (migration 0015): once the owner drags a row
20
20
  // into place, every member after the head gets a `group_position` (10, 20,
21
- // 30, ...) and keeps it; later arrivals join at the end. A group nobody
21
+ // 30, ...) and keeps it; a later arrival (a session created in the group, a
22
+ // row the menu moves in) goes first, right under the head, and the next
23
+ // drag renumbers from 10 again. A group nobody
22
24
  // reordered keeps NULL positions and the activity order. Without the column
23
25
  // the hand order is off and moves only change the membership.
24
26
  //
@@ -141,8 +143,8 @@ export function createConversationGroupRepository(db) {
141
143
  WHERE group_id = ? AND status != 'deleted'
142
144
  ORDER BY updated_at DESC
143
145
  `);
144
- const maxPosition = hasPosition
145
- ? db.prepare(`SELECT MAX(group_position) AS n FROM conversations WHERE group_id = ? AND status != 'deleted'`)
146
+ const minPosition = hasPosition
147
+ ? db.prepare(`SELECT MIN(group_position) AS n FROM conversations WHERE group_id = ? AND status != 'deleted'`)
146
148
  : null;
147
149
  const countMembers = db.prepare(`SELECT COUNT(*) AS n FROM conversations WHERE group_id = ? AND status != 'deleted'`);
148
150
  // Groups with a live member, the one with the latest activity first, then
@@ -188,11 +190,16 @@ export function createConversationGroupRepository(db) {
188
190
 
189
191
  const positionValue = (row) => (row?.group_position == null ? null : Number(row.group_position));
190
192
 
191
- /** Where a row joining `groupId` goes: after the last placed member, or NULL when nobody ordered the group. */
193
+ /**
194
+ * Where a row joining `groupId` goes: before the first placed member (the
195
+ * owner sees a new session at the top of its group, under the head), or
196
+ * NULL when nobody ordered the group, where newest-first puts it on top
197
+ * anyway. Positions may go to zero and below; the next drag renumbers.
198
+ */
192
199
  function joiningPosition(groupId) {
193
200
  if (!hasPosition) return null;
194
- const max = maxPosition.get(toText(groupId))?.n;
195
- return max == null ? null : Number(max) + POSITION_STEP;
201
+ const min = minPosition.get(toText(groupId))?.n;
202
+ return min == null ? null : Number(min) - POSITION_STEP;
196
203
  }
197
204
 
198
205
  const liveConversation = (id) => {
@@ -15,6 +15,8 @@ export function createMessageRepository(db) {
15
15
  // worker, so it neither keeps one warm nor counts as queued.
16
16
  const queueHasUsageLimitPause = db.prepare(`PRAGMA table_info(queue)`).all()
17
17
  .some((column) => column.name === 'usage_limit_pause');
18
+ const queueHasProvider = db.prepare(`PRAGMA table_info(queue)`).all()
19
+ .some((column) => column.name === 'provider');
18
20
  const notHeldForUsageLimit = (alias = '') => (queueHasUsageLimitPause
19
21
  ? `AND NOT (${alias}usage_limit_pause IS NOT NULL AND ${alias}status = 'pending' AND ${alias}next_attempt_at IS NOT NULL AND ${alias}next_attempt_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`
20
22
  : '');
@@ -351,6 +353,11 @@ export function createMessageRepository(db) {
351
353
  WHERE id = ?
352
354
  `),
353
355
  setQueueRuntimeSession: db.prepare(`UPDATE queue SET runtime_session_id = ? WHERE id = ?`),
356
+ // The billing a row runs on (migration 0031): a continuation is the
357
+ // kind of the worker whose CLI started it, whatever the chat's now.
358
+ setQueueProvider: queueHasProvider
359
+ ? db.prepare(`UPDATE queue SET provider = ? WHERE id = ?`)
360
+ : null,
354
361
  setQueueResponseMessageId: db.prepare(`UPDATE queue SET response_message_id = ? WHERE id = ?`),
355
362
  // Legacy (unfenced) finalization keeps 'pending' in the set so a
356
363
  // response landing after a stale-sweep requeue still finalizes; fenced
@@ -11,6 +11,12 @@ export function createSessionRepository(db) {
11
11
  : 'rs.model AS runtime_model, NULL AS runtime_provider_type, NULL AS runtime_provider_model';
12
12
  // Provenance of conversations another relay's agent created (migration
13
13
  // 0005); NULL on a schema that predates it.
14
+ // The billing a queue row runs on (migration 0031): the Claude API key
15
+ // removal counts the unfinished rows stamped with the key.
16
+ const queueHasProvider = db.prepare(`PRAGMA table_info(queue)`).all().some((column) => column.name === 'provider');
17
+ const claudeApiQueueCountSelect = queueHasProvider
18
+ ? `(SELECT COUNT(*) FROM queue q WHERE q.conversation_id = c.id AND q.status IN ('pending', 'processing', 'parked') AND LOWER(COALESCE(q.provider, '')) = 'claude-api') AS claude_api_queue_count`
19
+ : '0 AS claude_api_queue_count';
14
20
  const conversationsHaveOrigin = db.prepare(`PRAGMA table_info(conversations)`).all()
15
21
  .some((column) => String(column?.name || '').trim() === 'origin_json');
16
22
  const conversationOriginSelect = conversationsHaveOrigin ? 'c.origin_json' : 'NULL AS origin_json';
@@ -100,7 +106,8 @@ export function createSessionRepository(db) {
100
106
  rs.*,
101
107
  c.sdk_session_id AS conversation_sdk_session_id,
102
108
  (SELECT COUNT(*) FROM messages m WHERE m.conversation_id = c.id) AS message_count,
103
- (SELECT COUNT(*) FROM queue q WHERE q.conversation_id = c.id AND q.status IN ('pending', 'processing', 'parked')) AS active_queue_count
109
+ (SELECT COUNT(*) FROM queue q WHERE q.conversation_id = c.id AND q.status IN ('pending', 'processing', 'parked')) AS active_queue_count,
110
+ ${claudeApiQueueCountSelect}
104
111
  FROM runtime_sessions rs
105
112
  JOIN conversations c ON c.id = rs.conversation_id
106
113
  WHERE c.status != 'deleted'
@@ -192,7 +192,7 @@ export function registerAskUserRoutes(app, deps) {
192
192
  });
193
193
 
194
194
  /**
195
- * An ask (remote_relay `ask`) is the user's to answer: from this relay's
195
+ * An ask (relay_tool `ask`) is the user's to answer: from this relay's
196
196
  * own UI here, from a paired relay's Inbox through its own route
197
197
  * (relay-ask-routes.mjs). Never from an agent, this relay's or another's.
198
198
  */
@@ -7,7 +7,7 @@
7
7
  // (messages-routes.mjs), next to the code they reuse.
8
8
  //
9
9
  // The owner's action only: every route is behind `auth`, and a request that
10
- // arrived from a paired relay is refused, as the remote_relay tool's own
10
+ // arrived from a paired relay is refused, as the relay_tool's own
11
11
  // route refuses calls from other relays. The tool reads groups, names an
12
12
  // existing one for a session it creates (the bootstrap route), and moves one
13
13
  // session into a group, out of it, or into a new group of a name it gives
@@ -129,7 +129,8 @@ export function registerConversationGroupRoutes(app, deps) {
129
129
  // a conversation into a group. `groupId: null` takes it out (as DELETE).
130
130
  // With `beforeConversationId` in the body (null: to the end) it is a drag:
131
131
  // the row lands before that one and the group gets a hand order; without
132
- // it the row joins at the end of a hand-ordered group, as the menu does.
132
+ // it the row joins a hand-ordered group first, under the head, as the menu
133
+ // does (and the next drag renumbers).
133
134
  app.put('/api/conversation/:id/group', auth, (req, res) => {
134
135
  if (refuseRemote(req, res)) return;
135
136
  if (!conversationGroups?.enabled) return unavailable(res);
@@ -0,0 +1,72 @@
1
+ 'use strict';
2
+
3
+ // POST /api/deepseek-usage-import: the two CSV files of DeepSeek's usage
4
+ // export, as text, from Settings → Providers → DeepSeek. The relay's user
5
+ // only: another relay's agent gets a 403. The import itself is in
6
+ // services/deepseek-usage-import.mjs.
7
+
8
+ import { readRemoteRelayRequest } from '../services/remote-relay-inbound.mjs';
9
+
10
+ // The two files of a month come to well under a megabyte.
11
+ export const DEEPSEEK_USAGE_IMPORT_MAX_BYTES = 2 * 1024 * 1024;
12
+
13
+ // How long the import waits for the usage card's currency (the balance's).
14
+ export const DEEPSEEK_USAGE_IMPORT_CURRENCY_WAIT_MS = 5000;
15
+
16
+ /**
17
+ * @param {object} deps
18
+ * @param {Function} [deps.getCardCurrency] the usage card's currency ('USD',
19
+ * 'CNY'), from the balance; may be async. The summary then says whether
20
+ * the card can use the export's rows.
21
+ * @param {Function} [deps.logError] where a failed import is logged
22
+ */
23
+ export function registerDeepSeekUsageImportRoutes(app, deps) {
24
+ const {
25
+ auth,
26
+ deepseekUsageImportService,
27
+ remoteRelayInbound,
28
+ getCardCurrency = null,
29
+ logError = (message, err) => console.error(message, err),
30
+ } = deps;
31
+
32
+ async function cardCurrency() {
33
+ if (typeof getCardCurrency !== 'function') return null;
34
+ let timer = null;
35
+ try {
36
+ return await Promise.race([
37
+ Promise.resolve().then(() => getCardCurrency()),
38
+ new Promise((resolve) => { timer = setTimeout(() => resolve(null), DEEPSEEK_USAGE_IMPORT_CURRENCY_WAIT_MS); }),
39
+ ]);
40
+ } catch {
41
+ return null;
42
+ } finally {
43
+ clearTimeout(timer);
44
+ }
45
+ }
46
+
47
+ app.post('/api/deepseek-usage-import', auth, async (req, res) => {
48
+ if (readRemoteRelayRequest(req, remoteRelayInbound || {}).remote) {
49
+ return res.status(403).json({ error: 'The DeepSeek usage export is imported by this relay\'s user only.' });
50
+ }
51
+ if (typeof deepseekUsageImportService?.importExport !== 'function') {
52
+ return res.status(501).json({ error: 'The DeepSeek usage import is not available on this relay.' });
53
+ }
54
+ const amount = req.body?.amount;
55
+ const cost = req.body?.cost;
56
+ if (typeof amount !== 'string' || typeof cost !== 'string') {
57
+ return res.status(400).json({ error: 'Choose both files of the export: the amount file and the cost file.' });
58
+ }
59
+ if (Buffer.byteLength(amount, 'utf8') + Buffer.byteLength(cost, 'utf8') > DEEPSEEK_USAGE_IMPORT_MAX_BYTES) {
60
+ return res.status(413).json({ error: 'The files are larger than 2 MB together; export a shorter period.' });
61
+ }
62
+ let result;
63
+ try {
64
+ result = deepseekUsageImportService.importExport({ amount, cost, cardCurrency: await cardCurrency() });
65
+ } catch (err) {
66
+ logError('[deepseek-usage-import] the import failed:', err);
67
+ return res.status(500).json({ error: 'The import failed on the relay; nothing was imported. Its log has the reason.' });
68
+ }
69
+ if (!result?.ok) return res.status(400).json({ error: result?.error || 'The export could not be read.' });
70
+ res.json({ ok: true, summary: result.summary });
71
+ });
72
+ }