aether-code 0.31.0 → 0.32.1

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/src/api.js CHANGED
@@ -1,323 +1,323 @@
1
- // API client.
2
-
3
- import fs from "node:fs";
4
- import path from "node:path";
5
- import { fileURLToPath } from "node:url";
6
- import { getConfig } from "./config.js";
7
-
8
- // Resolve our own version once for the User-Agent header — read from
9
- // package.json so it can't drift (the file previously sent three different
10
- // hardcoded versions across three calls).
11
- function readVersion() {
12
- try {
13
- const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
14
- return JSON.parse(fs.readFileSync(pkgPath, "utf8")).version || "0";
15
- } catch {
16
- return "0";
17
- }
18
- }
19
- const USER_AGENT = `aether-code/${readVersion()}`;
20
-
21
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
22
-
23
- /**
24
- * fetch() with bounded exponential-backoff retries. Retries on thrown network
25
- * errors (DNS/reset/offline blip) and transient 5xx; never retries 4xx
26
- * (auth/credit/validation — a retry won't help). Body is always a small JSON
27
- * string, so resending is safe. For the streaming endpoint only the initial
28
- * connection is retried.
29
- */
30
- export async function fetchWithRetry(url, options, { retries = 2, baseDelay = 500, onRetry } = {}) {
31
- let attempt = 0;
32
- while (true) {
33
- try {
34
- const res = await fetch(url, options);
35
- if (res.status >= 500 && attempt < retries) {
36
- // Release the connection back to the pool before retrying — an
37
- // unconsumed body keeps the socket open (undici won't reuse it).
38
- res.body?.cancel().catch(() => {});
39
- attempt++;
40
- if (onRetry) onRetry(attempt, `HTTP ${res.status}`);
41
- await sleep(baseDelay * 2 ** (attempt - 1));
42
- continue;
43
- }
44
- return res;
45
- } catch (e) {
46
- if (attempt < retries) {
47
- attempt++;
48
- if (onRetry) onRetry(attempt, e.message);
49
- await sleep(baseDelay * 2 ** (attempt - 1));
50
- continue;
51
- }
52
- throw e;
53
- }
54
- }
55
- }
56
-
57
- function defaultOnRetry(attempt, why) {
58
- process.stderr.write(` ⟳ connection issue (${why}) — retry ${attempt}…\n`);
59
- }
60
-
61
- /**
62
- * Free balance + plan check via /api/v1/me. Doesn't charge credits.
63
- */
64
- export async function fetchBalance() {
65
- const { apiKey, baseUrl } = getConfig();
66
- if (!apiKey) {
67
- throw new AetherError(
68
- "No API key. Set AETHER_API_KEY or run `aether config set <key>`.",
69
- "NO_API_KEY",
70
- 0,
71
- );
72
- }
73
- let res;
74
- try {
75
- res = await fetchWithRetry(`${baseUrl}/api/v1/me`, {
76
- method: "GET",
77
- headers: {
78
- Accept: "application/json",
79
- Authorization: `Bearer ${apiKey}`,
80
- "User-Agent": USER_AGENT,
81
- },
82
- }, { onRetry: defaultOnRetry });
83
- } catch (e) {
84
- throw new AetherError(`Network error: ${e.message}`, "NETWORK", 0);
85
- }
86
- let data = null;
87
- try { data = await res.json(); } catch { /* non-JSON */ }
88
- if (!res.ok) {
89
- const code = data?.code || `HTTP_${res.status}`;
90
- const msg = data?.error || `${res.status} ${res.statusText}`;
91
- throw new AetherError(msg, code, res.status, data);
92
- }
93
- return data; // { plan, role, planCredits, topupCredits, balance, isSuspended, rate }
94
- }
95
-
96
- export class AetherError extends Error {
97
- constructor(message, code, status, data) {
98
- super(message);
99
- this.name = "AetherError";
100
- this.code = code;
101
- this.status = status;
102
- this.data = data;
103
- }
104
- }
105
-
106
- /**
107
- * Streaming agent turn. Calls /api/v1/agent/stream and invokes the supplied
108
- * callbacks as events arrive. Returns the assembled assistant message + final
109
- * usage/credit info once the stream ends.
110
- *
111
- * Event handlers (all optional):
112
- * onDelta(text) — text fragment from the model
113
- * onToolCallDelta(part) — partial tool call: { index, id?, name?, args_delta? }
114
- * onFinish(reason) — per-choice finish reason ("stop" | "tool_calls" | "length")
115
- *
116
- * Returns:
117
- * { message: { role:"assistant", content, tool_calls }, finish_reason,
118
- * creditsCharged, balanceAfter, usage }
119
- */
120
- export async function agentTurnStream({
121
- messages,
122
- tools,
123
- maxTokens,
124
- temperature,
125
- model,
126
- onDelta,
127
- onToolCallDelta,
128
- onFinish,
129
- }) {
130
- const { apiKey, baseUrl } = getConfig();
131
- if (!apiKey) {
132
- throw new AetherError(
133
- "No API key. Set AETHER_API_KEY or run `aether config set <key>`.",
134
- "NO_API_KEY",
135
- 0,
136
- );
137
- }
138
-
139
- let res;
140
- try {
141
- res = await fetchWithRetry(`${baseUrl}/api/v1/agent/stream`, {
142
- method: "POST",
143
- headers: {
144
- "Content-Type": "application/json",
145
- Accept: "text/event-stream",
146
- Authorization: `Bearer ${apiKey}`,
147
- "User-Agent": USER_AGENT,
148
- },
149
- body: JSON.stringify({
150
- messages,
151
- tools,
152
- max_tokens: maxTokens,
153
- temperature,
154
- ...(model ? { model } : {}),
155
- }),
156
- }, { onRetry: defaultOnRetry });
157
- } catch (e) {
158
- throw new AetherError(`Network error: ${e.message}`, "NETWORK", 0);
159
- }
160
-
161
- // Non-streaming JSON error response (auth, credit, validation failures)
162
- if (!res.ok) {
163
- let data = null;
164
- try { data = await res.json(); } catch { /* non-JSON */ }
165
- const code = data?.code || `HTTP_${res.status}`;
166
- const msg = data?.error || `${res.status} ${res.statusText}`;
167
- throw new AetherError(msg, code, res.status, data);
168
- }
169
- if (!res.body) throw new AetherError("Response has no body", "NO_BODY", res.status);
170
-
171
- // Accumulate streaming state
172
- let textBuf = "";
173
- const toolBuf = new Map(); // index -> { id, name, args }
174
- let finishReason = "stop";
175
- let creditsCharged = 0;
176
- let balanceAfter = null;
177
- let usage = { prompt_tokens: 0, completion_tokens: 0 };
178
- let streamError = null;
179
-
180
- const reader = res.body.getReader();
181
- const decoder = new TextDecoder();
182
- let buffer = "";
183
-
184
- // Dispatch a single parsed SSE "data:" line.
185
- const handleLine = (line) => {
186
- if (!line.startsWith("data:")) return;
187
- const json = line.slice(5).trim(); // .trim() also drops a CRLF trailing \r
188
- if (!json) return;
189
- let evt;
190
- try { evt = JSON.parse(json); } catch { return; }
191
- if (evt.kind === "delta") {
192
- textBuf += evt.content;
193
- if (onDelta) onDelta(evt.content);
194
- } else if (evt.kind === "tool_call_delta") {
195
- const slot = toolBuf.get(evt.index) || { id: undefined, name: undefined, args: "" };
196
- if (evt.id) slot.id = evt.id;
197
- if (evt.name) slot.name = evt.name;
198
- if (evt.args_delta) slot.args += evt.args_delta;
199
- toolBuf.set(evt.index, slot);
200
- if (onToolCallDelta) onToolCallDelta(evt);
201
- } else if (evt.kind === "finish") {
202
- finishReason = evt.reason;
203
- if (onFinish) onFinish(evt.reason);
204
- } else if (evt.kind === "done") {
205
- creditsCharged = evt.creditsCharged ?? 0;
206
- balanceAfter = evt.balanceAfter ?? null;
207
- usage = evt.usage ?? usage;
208
- } else if (evt.kind === "error") {
209
- streamError = evt.error;
210
- }
211
- };
212
-
213
- // Watchdog: if the open stream stalls (no bytes) for too long, abort instead
214
- // of hanging the CLI forever.
215
- const READ_TIMEOUT_MS = 120_000;
216
- const readOnce = () => {
217
- let timer;
218
- const timeout = new Promise((_, reject) => {
219
- timer = setTimeout(
220
- () => reject(new AetherError("Stream stalled — no data for 120s", "STREAM_TIMEOUT", 0)),
221
- READ_TIMEOUT_MS,
222
- );
223
- });
224
- return Promise.race([reader.read(), timeout]).finally(() => clearTimeout(timer));
225
- };
226
-
227
- try {
228
- while (true) {
229
- const { value, done } = await readOnce();
230
- if (done) break;
231
- buffer += decoder.decode(value, { stream: true });
232
- const lines = buffer.split("\n");
233
- buffer = lines.pop() ?? "";
234
- for (const line of lines) handleLine(line);
235
- }
236
- // Flush any multibyte bytes the decoder is holding, then process a final
237
- // line that arrived without a trailing newline (else the "done" event with
238
- // credits/usage is silently dropped).
239
- buffer += decoder.decode();
240
- if (buffer.trim()) handleLine(buffer);
241
- } catch (e) {
242
- reader.cancel().catch(() => {});
243
- throw e;
244
- } finally {
245
- try { reader.releaseLock(); } catch { /* already released / pending — ignore */ }
246
- }
247
-
248
- if (streamError) {
249
- throw new AetherError(streamError, "STREAM_ERROR", 0);
250
- }
251
-
252
- // Assemble final tool_calls in stable index order
253
- const tool_calls = [...toolBuf.entries()]
254
- .sort((a, b) => a[0] - b[0])
255
- .map(([, slot]) => ({
256
- id: slot.id || `call_${Math.random().toString(36).slice(2, 10)}`,
257
- type: "function",
258
- function: { name: slot.name || "", arguments: slot.args || "" },
259
- }));
260
-
261
- return {
262
- message: {
263
- role: "assistant",
264
- content: textBuf || null,
265
- tool_calls: tool_calls.length > 0 ? tool_calls : undefined,
266
- },
267
- finish_reason: finishReason,
268
- creditsCharged,
269
- balanceAfter,
270
- usage,
271
- };
272
- }
273
-
274
- /**
275
- * Non-streaming variant — kept as a fallback for environments where SSE is
276
- * problematic (corporate proxies, weird client setups). The CLI defaults to
277
- * streaming.
278
- */
279
- export async function agentTurn({ messages, tools, maxTokens, temperature, model }) {
280
- const { apiKey, baseUrl } = getConfig();
281
- if (!apiKey) {
282
- throw new AetherError(
283
- "No API key. Set AETHER_API_KEY env var or run `aether-cli config set <key>`.",
284
- "NO_API_KEY",
285
- 0,
286
- );
287
- }
288
-
289
- let res;
290
- try {
291
- res = await fetchWithRetry(`${baseUrl}/api/v1/agent`, {
292
- method: "POST",
293
- headers: {
294
- "Content-Type": "application/json",
295
- Authorization: `Bearer ${apiKey}`,
296
- "User-Agent": USER_AGENT,
297
- },
298
- body: JSON.stringify({
299
- messages,
300
- tools,
301
- max_tokens: maxTokens,
302
- temperature,
303
- ...(model ? { model } : {}),
304
- }),
305
- }, { onRetry: defaultOnRetry });
306
- } catch (e) {
307
- throw new AetherError(`Network error: ${e.message}`, "NETWORK", 0);
308
- }
309
-
310
- let data = null;
311
- try {
312
- data = await res.json();
313
- } catch {
314
- /* non-JSON */
315
- }
316
-
317
- if (!res.ok) {
318
- const code = (data && data.code) || `HTTP_${res.status}`;
319
- const msg = (data && data.error) || `${res.status} ${res.statusText}`;
320
- throw new AetherError(msg, code, res.status, data);
321
- }
322
- return data;
323
- }
1
+ // API client.
2
+
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { getConfig } from "./config.js";
7
+
8
+ // Resolve our own version once for the User-Agent header — read from
9
+ // package.json so it can't drift (the file previously sent three different
10
+ // hardcoded versions across three calls).
11
+ function readVersion() {
12
+ try {
13
+ const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json");
14
+ return JSON.parse(fs.readFileSync(pkgPath, "utf8")).version || "0";
15
+ } catch {
16
+ return "0";
17
+ }
18
+ }
19
+ const USER_AGENT = `aether-code/${readVersion()}`;
20
+
21
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
22
+
23
+ /**
24
+ * fetch() with bounded exponential-backoff retries. Retries on thrown network
25
+ * errors (DNS/reset/offline blip) and transient 5xx; never retries 4xx
26
+ * (auth/credit/validation — a retry won't help). Body is always a small JSON
27
+ * string, so resending is safe. For the streaming endpoint only the initial
28
+ * connection is retried.
29
+ */
30
+ export async function fetchWithRetry(url, options, { retries = 2, baseDelay = 500, onRetry } = {}) {
31
+ let attempt = 0;
32
+ while (true) {
33
+ try {
34
+ const res = await fetch(url, options);
35
+ if (res.status >= 500 && attempt < retries) {
36
+ // Release the connection back to the pool before retrying — an
37
+ // unconsumed body keeps the socket open (undici won't reuse it).
38
+ res.body?.cancel().catch(() => {});
39
+ attempt++;
40
+ if (onRetry) onRetry(attempt, `HTTP ${res.status}`);
41
+ await sleep(baseDelay * 2 ** (attempt - 1));
42
+ continue;
43
+ }
44
+ return res;
45
+ } catch (e) {
46
+ if (attempt < retries) {
47
+ attempt++;
48
+ if (onRetry) onRetry(attempt, e.message);
49
+ await sleep(baseDelay * 2 ** (attempt - 1));
50
+ continue;
51
+ }
52
+ throw e;
53
+ }
54
+ }
55
+ }
56
+
57
+ function defaultOnRetry(attempt, why) {
58
+ process.stderr.write(` ⟳ connection issue (${why}) — retry ${attempt}…\n`);
59
+ }
60
+
61
+ /**
62
+ * Free balance + plan check via /api/v1/me. Doesn't charge credits.
63
+ */
64
+ export async function fetchBalance() {
65
+ const { apiKey, baseUrl } = getConfig();
66
+ if (!apiKey) {
67
+ throw new AetherError(
68
+ "No API key. Set AETHER_API_KEY or run `aether config set <key>`.",
69
+ "NO_API_KEY",
70
+ 0,
71
+ );
72
+ }
73
+ let res;
74
+ try {
75
+ res = await fetchWithRetry(`${baseUrl}/api/v1/me`, {
76
+ method: "GET",
77
+ headers: {
78
+ Accept: "application/json",
79
+ Authorization: `Bearer ${apiKey}`,
80
+ "User-Agent": USER_AGENT,
81
+ },
82
+ }, { onRetry: defaultOnRetry });
83
+ } catch (e) {
84
+ throw new AetherError(`Network error: ${e.message}`, "NETWORK", 0);
85
+ }
86
+ let data = null;
87
+ try { data = await res.json(); } catch { /* non-JSON */ }
88
+ if (!res.ok) {
89
+ const code = data?.code || `HTTP_${res.status}`;
90
+ const msg = data?.error || `${res.status} ${res.statusText}`;
91
+ throw new AetherError(msg, code, res.status, data);
92
+ }
93
+ return data; // { plan, role, planCredits, topupCredits, balance, isSuspended, rate }
94
+ }
95
+
96
+ export class AetherError extends Error {
97
+ constructor(message, code, status, data) {
98
+ super(message);
99
+ this.name = "AetherError";
100
+ this.code = code;
101
+ this.status = status;
102
+ this.data = data;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Streaming agent turn. Calls /api/v1/agent/stream and invokes the supplied
108
+ * callbacks as events arrive. Returns the assembled assistant message + final
109
+ * usage/credit info once the stream ends.
110
+ *
111
+ * Event handlers (all optional):
112
+ * onDelta(text) — text fragment from the model
113
+ * onToolCallDelta(part) — partial tool call: { index, id?, name?, args_delta? }
114
+ * onFinish(reason) — per-choice finish reason ("stop" | "tool_calls" | "length")
115
+ *
116
+ * Returns:
117
+ * { message: { role:"assistant", content, tool_calls }, finish_reason,
118
+ * creditsCharged, balanceAfter, usage }
119
+ */
120
+ export async function agentTurnStream({
121
+ messages,
122
+ tools,
123
+ maxTokens,
124
+ temperature,
125
+ model,
126
+ onDelta,
127
+ onToolCallDelta,
128
+ onFinish,
129
+ }) {
130
+ const { apiKey, baseUrl } = getConfig();
131
+ if (!apiKey) {
132
+ throw new AetherError(
133
+ "No API key. Set AETHER_API_KEY or run `aether config set <key>`.",
134
+ "NO_API_KEY",
135
+ 0,
136
+ );
137
+ }
138
+
139
+ let res;
140
+ try {
141
+ res = await fetchWithRetry(`${baseUrl}/api/v1/agent/stream`, {
142
+ method: "POST",
143
+ headers: {
144
+ "Content-Type": "application/json",
145
+ Accept: "text/event-stream",
146
+ Authorization: `Bearer ${apiKey}`,
147
+ "User-Agent": USER_AGENT,
148
+ },
149
+ body: JSON.stringify({
150
+ messages,
151
+ tools,
152
+ max_tokens: maxTokens,
153
+ temperature,
154
+ ...(model ? { model } : {}),
155
+ }),
156
+ }, { onRetry: defaultOnRetry });
157
+ } catch (e) {
158
+ throw new AetherError(`Network error: ${e.message}`, "NETWORK", 0);
159
+ }
160
+
161
+ // Non-streaming JSON error response (auth, credit, validation failures)
162
+ if (!res.ok) {
163
+ let data = null;
164
+ try { data = await res.json(); } catch { /* non-JSON */ }
165
+ const code = data?.code || `HTTP_${res.status}`;
166
+ const msg = data?.error || `${res.status} ${res.statusText}`;
167
+ throw new AetherError(msg, code, res.status, data);
168
+ }
169
+ if (!res.body) throw new AetherError("Response has no body", "NO_BODY", res.status);
170
+
171
+ // Accumulate streaming state
172
+ let textBuf = "";
173
+ const toolBuf = new Map(); // index -> { id, name, args }
174
+ let finishReason = "stop";
175
+ let creditsCharged = 0;
176
+ let balanceAfter = null;
177
+ let usage = { prompt_tokens: 0, completion_tokens: 0 };
178
+ let streamError = null;
179
+
180
+ const reader = res.body.getReader();
181
+ const decoder = new TextDecoder();
182
+ let buffer = "";
183
+
184
+ // Dispatch a single parsed SSE "data:" line.
185
+ const handleLine = (line) => {
186
+ if (!line.startsWith("data:")) return;
187
+ const json = line.slice(5).trim(); // .trim() also drops a CRLF trailing \r
188
+ if (!json) return;
189
+ let evt;
190
+ try { evt = JSON.parse(json); } catch { return; }
191
+ if (evt.kind === "delta") {
192
+ textBuf += evt.content;
193
+ if (onDelta) onDelta(evt.content);
194
+ } else if (evt.kind === "tool_call_delta") {
195
+ const slot = toolBuf.get(evt.index) || { id: undefined, name: undefined, args: "" };
196
+ if (evt.id) slot.id = evt.id;
197
+ if (evt.name) slot.name = evt.name;
198
+ if (evt.args_delta) slot.args += evt.args_delta;
199
+ toolBuf.set(evt.index, slot);
200
+ if (onToolCallDelta) onToolCallDelta(evt);
201
+ } else if (evt.kind === "finish") {
202
+ finishReason = evt.reason;
203
+ if (onFinish) onFinish(evt.reason);
204
+ } else if (evt.kind === "done") {
205
+ creditsCharged = evt.creditsCharged ?? 0;
206
+ balanceAfter = evt.balanceAfter ?? null;
207
+ usage = evt.usage ?? usage;
208
+ } else if (evt.kind === "error") {
209
+ streamError = evt.error;
210
+ }
211
+ };
212
+
213
+ // Watchdog: if the open stream stalls (no bytes) for too long, abort instead
214
+ // of hanging the CLI forever.
215
+ const READ_TIMEOUT_MS = 120_000;
216
+ const readOnce = () => {
217
+ let timer;
218
+ const timeout = new Promise((_, reject) => {
219
+ timer = setTimeout(
220
+ () => reject(new AetherError("Stream stalled — no data for 120s", "STREAM_TIMEOUT", 0)),
221
+ READ_TIMEOUT_MS,
222
+ );
223
+ });
224
+ return Promise.race([reader.read(), timeout]).finally(() => clearTimeout(timer));
225
+ };
226
+
227
+ try {
228
+ while (true) {
229
+ const { value, done } = await readOnce();
230
+ if (done) break;
231
+ buffer += decoder.decode(value, { stream: true });
232
+ const lines = buffer.split("\n");
233
+ buffer = lines.pop() ?? "";
234
+ for (const line of lines) handleLine(line);
235
+ }
236
+ // Flush any multibyte bytes the decoder is holding, then process a final
237
+ // line that arrived without a trailing newline (else the "done" event with
238
+ // credits/usage is silently dropped).
239
+ buffer += decoder.decode();
240
+ if (buffer.trim()) handleLine(buffer);
241
+ } catch (e) {
242
+ reader.cancel().catch(() => {});
243
+ throw e;
244
+ } finally {
245
+ try { reader.releaseLock(); } catch { /* already released / pending — ignore */ }
246
+ }
247
+
248
+ if (streamError) {
249
+ throw new AetherError(streamError, "STREAM_ERROR", 0);
250
+ }
251
+
252
+ // Assemble final tool_calls in stable index order
253
+ const tool_calls = [...toolBuf.entries()]
254
+ .sort((a, b) => a[0] - b[0])
255
+ .map(([, slot]) => ({
256
+ id: slot.id || `call_${Math.random().toString(36).slice(2, 10)}`,
257
+ type: "function",
258
+ function: { name: slot.name || "", arguments: slot.args || "" },
259
+ }));
260
+
261
+ return {
262
+ message: {
263
+ role: "assistant",
264
+ content: textBuf || null,
265
+ tool_calls: tool_calls.length > 0 ? tool_calls : undefined,
266
+ },
267
+ finish_reason: finishReason,
268
+ creditsCharged,
269
+ balanceAfter,
270
+ usage,
271
+ };
272
+ }
273
+
274
+ /**
275
+ * Non-streaming variant — kept as a fallback for environments where SSE is
276
+ * problematic (corporate proxies, weird client setups). The CLI defaults to
277
+ * streaming.
278
+ */
279
+ export async function agentTurn({ messages, tools, maxTokens, temperature, model }) {
280
+ const { apiKey, baseUrl } = getConfig();
281
+ if (!apiKey) {
282
+ throw new AetherError(
283
+ "No API key. Set AETHER_API_KEY env var or run `aether-cli config set <key>`.",
284
+ "NO_API_KEY",
285
+ 0,
286
+ );
287
+ }
288
+
289
+ let res;
290
+ try {
291
+ res = await fetchWithRetry(`${baseUrl}/api/v1/agent`, {
292
+ method: "POST",
293
+ headers: {
294
+ "Content-Type": "application/json",
295
+ Authorization: `Bearer ${apiKey}`,
296
+ "User-Agent": USER_AGENT,
297
+ },
298
+ body: JSON.stringify({
299
+ messages,
300
+ tools,
301
+ max_tokens: maxTokens,
302
+ temperature,
303
+ ...(model ? { model } : {}),
304
+ }),
305
+ }, { onRetry: defaultOnRetry });
306
+ } catch (e) {
307
+ throw new AetherError(`Network error: ${e.message}`, "NETWORK", 0);
308
+ }
309
+
310
+ let data = null;
311
+ try {
312
+ data = await res.json();
313
+ } catch {
314
+ /* non-JSON */
315
+ }
316
+
317
+ if (!res.ok) {
318
+ const code = (data && data.code) || `HTTP_${res.status}`;
319
+ const msg = (data && data.error) || `${res.status} ${res.statusText}`;
320
+ throw new AetherError(msg, code, res.status, data);
321
+ }
322
+ return data;
323
+ }