clawmoney 0.15.4 → 0.15.6

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.
@@ -134,10 +134,34 @@ function applyProxyFromConfig(config) {
134
134
  logger.info(`[provider] using config.yaml proxy=${config.proxy}`);
135
135
  }
136
136
  // ── Request handler ──
137
+ // Flatten a Claude/OpenAI message `content` field into a plain string.
138
+ // Content may be either a string (OpenAI-style) or an array of content
139
+ // blocks (Claude Code / real Anthropic API shape: [{type:"text",text:"..."}]).
140
+ // String(array) would produce "[object Object],[object Object]" which the
141
+ // model then echoes back as garbage — hence the explicit block walk.
142
+ function extractMessageText(content) {
143
+ if (content == null)
144
+ return "";
145
+ if (typeof content === "string")
146
+ return content;
147
+ if (Array.isArray(content)) {
148
+ const parts = [];
149
+ for (const block of content) {
150
+ if (block && typeof block === "object") {
151
+ const b = block;
152
+ if (b.type === "text" && typeof b.text === "string" && b.text) {
153
+ parts.push(b.text);
154
+ }
155
+ }
156
+ }
157
+ return parts.join("\n");
158
+ }
159
+ return "";
160
+ }
137
161
  function messagesToPrompt(messages) {
138
- return messages.map((m) => String(m.content ?? "")).join("\n");
162
+ return messages.map((m) => extractMessageText(m.content)).join("\n");
139
163
  }
140
- async function executeRelayRequest(request, config) {
164
+ async function executeRelayRequest(request, config, sendChunk) {
141
165
  const { request_id, max_budget_usd } = request;
142
166
  const cliType = request.cli_type ?? config.relay.cli_type;
143
167
  const model = request.model ?? config.relay.model;
@@ -148,7 +172,7 @@ async function executeRelayRequest(request, config) {
148
172
  ? messagesToPrompt(request.messages)
149
173
  : request.prompt ?? "";
150
174
  const lastUserMsg = request.messages
151
- ? [...request.messages].reverse().find((m) => m.role === "user")?.content ?? ""
175
+ ? extractMessageText([...request.messages].reverse().find((m) => m.role === "user")?.content)
152
176
  : prompt;
153
177
  const turns = request.messages
154
178
  ? request.messages.filter((m) => m.role === "user").length
@@ -193,6 +217,12 @@ async function executeRelayRequest(request, config) {
193
217
  prompt,
194
218
  model,
195
219
  maxTokens: max_budget_usd ? undefined : 4096,
220
+ // Forward each raw Anthropic SSE frame to the Hub in real time
221
+ // so the end client sees tokens as they're generated (instead of
222
+ // waiting for the whole response to arrive). Only claude-api has
223
+ // true pass-through streaming today — codex/gemini/antigravity
224
+ // still buffer the full response upstream and emit a single frame.
225
+ onRawEvent: sendChunk,
196
226
  });
197
227
  }
198
228
  const elapsedMs = Date.now() - startMs;
@@ -306,7 +336,20 @@ export function runRelayProvider(cliOverride) {
306
336
  }
307
337
  activeTasks.add(request.request_id);
308
338
  logger.info(`Processing relay request=${request.request_id} (active=${activeTasks.size}/${config.relay.concurrency})`);
309
- executeRelayRequest(request, config)
339
+ // Per-request SSE chunk forwarder. Each raw Anthropic SSE frame is sent
340
+ // to the Hub as its own WS event so the Hub can relay it straight to the
341
+ // buyer — drops TTFT from "whole response" to "first-token-from-upstream".
342
+ // WS sends are fire-and-forget here; the final relay_response still
343
+ // carries the fully aggregated content as a fallback for Hubs that
344
+ // haven't wired up chunk forwarding yet.
345
+ const sendChunk = (sse) => {
346
+ wsClient.send({
347
+ event: "relay_stream_chunk",
348
+ request_id: request.request_id,
349
+ sse,
350
+ });
351
+ };
352
+ executeRelayRequest(request, config, sendChunk)
310
353
  .then((response) => {
311
354
  const sent = wsClient.send(response);
312
355
  if (sent) {
@@ -1,10 +1,15 @@
1
+ export interface RelayContentBlock {
2
+ type: string;
3
+ text?: string;
4
+ }
5
+ export type RelayMessageContent = string | RelayContentBlock[] | null;
1
6
  export interface RelayRequest {
2
7
  event: "relay_request";
3
8
  request_id: string;
4
9
  prompt?: string;
5
10
  messages?: Array<{
6
11
  role: string;
7
- content: string;
12
+ content: RelayMessageContent;
8
13
  }>;
9
14
  cli_type?: string;
10
15
  session_id?: string;
@@ -46,7 +51,12 @@ export interface RelayResponse {
46
51
  error?: string;
47
52
  session_window?: RelayResponseSessionWindow;
48
53
  }
49
- export type RelayOutgoingEvent = RelayResponse;
54
+ export interface RelayStreamChunkEvent {
55
+ event: "relay_stream_chunk";
56
+ request_id: string;
57
+ sse: string;
58
+ }
59
+ export type RelayOutgoingEvent = RelayResponse | RelayStreamChunkEvent;
50
60
  export interface ParsedOutput {
51
61
  text: string;
52
62
  sessionId: string;
@@ -27,5 +27,6 @@ export interface CallClaudeApiOptions {
27
27
  prompt: string;
28
28
  model: string;
29
29
  maxTokens?: number;
30
+ onRawEvent?: (rawFrame: string) => void;
30
31
  }
31
32
  export declare function callClaudeApi(opts: CallClaudeApiOptions): Promise<ParsedOutput>;
@@ -820,7 +820,10 @@ async function doCallClaudeApi(opts) {
820
820
  // Stream parser — real Claude Code's main path uses stream:true; see
821
821
  // body construction above. parseClaudeSseResponse aggregates text
822
822
  // deltas + usage until message_stop, matching SDK semantics.
823
- const parsed = await parseClaudeSseResponse(resp, opts.model);
823
+ // When opts.onRawEvent is set, each SSE frame is also forwarded
824
+ // verbatim so the Hub can stream it through to the end client in
825
+ // real time instead of waiting for the whole response.
826
+ const parsed = await parseClaudeSseResponse(resp, opts.model, opts.onRawEvent);
824
827
  recordSpendFromUsage(parsed, opts.model);
825
828
  return parsed;
826
829
  }
@@ -911,7 +914,7 @@ function recordSpendFromUsage(parsed, model) {
911
914
  * event: error (upstream error — throw)
912
915
  * data: {"type":"error","error":{"type":"overloaded_error","message":"..."}}
913
916
  */
914
- async function parseClaudeSseResponse(resp, fallbackModel) {
917
+ async function parseClaudeSseResponse(resp, fallbackModel, onRawFrame) {
915
918
  const reader = resp.body?.getReader();
916
919
  if (!reader) {
917
920
  throw new Error("Claude streamGenerateContent returned no body");
@@ -925,6 +928,10 @@ async function parseClaudeSseResponse(resp, fallbackModel) {
925
928
  let cacheCreation = 0;
926
929
  let cacheRead = 0;
927
930
  let streamError;
931
+ // Accumulates one SSE frame (everything between blank lines) so we can
932
+ // emit the full `event: X\ndata: Y\n\n` block via onRawFrame. SSE frames
933
+ // are terminated by an empty line per the spec.
934
+ let frameLines = [];
928
935
  const processChunk = (jsonStr) => {
929
936
  const trimmed = jsonStr.trim();
930
937
  if (!trimmed)
@@ -992,6 +999,22 @@ async function parseClaudeSseResponse(resp, fallbackModel) {
992
999
  break;
993
1000
  }
994
1001
  };
1002
+ const flushFrame = () => {
1003
+ if (frameLines.length === 0)
1004
+ return;
1005
+ // Forward the raw SSE frame verbatim so consumers see it exactly as
1006
+ // Anthropic emitted it (including the event: name line, which Claude
1007
+ // Code's SDK parser uses as the dispatch key).
1008
+ if (onRawFrame) {
1009
+ onRawFrame(frameLines.join("\n") + "\n\n");
1010
+ }
1011
+ for (const line of frameLines) {
1012
+ if (line.startsWith("data:")) {
1013
+ processChunk(line.slice(5));
1014
+ }
1015
+ }
1016
+ frameLines = [];
1017
+ };
995
1018
  while (true) {
996
1019
  const { value, done } = await reader.read();
997
1020
  if (done)
@@ -1001,19 +1024,18 @@ async function parseClaudeSseResponse(resp, fallbackModel) {
1001
1024
  while ((newlineIdx = buffer.indexOf("\n")) >= 0) {
1002
1025
  const line = buffer.slice(0, newlineIdx).replace(/\r$/, "");
1003
1026
  buffer = buffer.slice(newlineIdx + 1);
1004
- if (!line)
1005
- continue;
1006
- // SSE dispatches on `data: ...` lines. `event: ...` names are
1007
- // informational (the chunk JSON's `type` field is authoritative).
1008
- if (line.startsWith("data:")) {
1009
- processChunk(line.slice(5));
1027
+ if (line === "") {
1028
+ // Blank line = end of SSE frame.
1029
+ flushFrame();
1030
+ }
1031
+ else {
1032
+ frameLines.push(line);
1010
1033
  }
1011
1034
  }
1012
1035
  }
1013
- // Flush trailing line (rare most servers end with a \n\n).
1014
- if (buffer.startsWith("data:")) {
1015
- processChunk(buffer.slice(5));
1016
- }
1036
+ // Flush any trailing frame without a final blank line. Rare, but SSE
1037
+ // allows a stream to end without a terminating \n\n.
1038
+ flushFrame();
1017
1039
  if (streamError) {
1018
1040
  throw new Error(`Anthropic stream error: ${streamError.type ?? "unknown"} — ${streamError.message ?? ""}`);
1019
1041
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawmoney",
3
- "version": "0.15.4",
3
+ "version": "0.15.6",
4
4
  "description": "ClawMoney CLI -- Earn rewards with your AI agent",
5
5
  "type": "module",
6
6
  "bin": {