machine-bridge-mcp 0.2.4 → 0.3.3
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/CHANGELOG.md +60 -0
- package/README.md +148 -176
- package/SECURITY.md +84 -0
- package/docs/ARCHITECTURE.md +102 -0
- package/package.json +20 -9
- package/src/local/cli.mjs +289 -298
- package/src/local/daemon.mjs +376 -84
- package/src/local/log.mjs +33 -11
- package/src/local/self-test.mjs +173 -186
- package/src/local/service.mjs +43 -5
- package/src/local/shell.mjs +68 -14
- package/src/local/state.mjs +240 -51
- package/src/worker/index.ts +664 -424
- package/wrangler.jsonc +2 -2
- package/src/local/api-server.mjs +0 -368
package/wrangler.jsonc
CHANGED
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
],
|
|
22
22
|
"observability": {
|
|
23
23
|
"enabled": true,
|
|
24
|
-
"head_sampling_rate": 1
|
|
24
|
+
"head_sampling_rate": 0.1
|
|
25
25
|
},
|
|
26
26
|
"vars": {
|
|
27
|
-
"MBM_WORKER_MAX_BODY_BYTES": "
|
|
27
|
+
"MBM_WORKER_MAX_BODY_BYTES": "8388608",
|
|
28
28
|
"MBM_ALLOWED_ORIGINS": ""
|
|
29
29
|
}
|
|
30
30
|
}
|
package/src/local/api-server.mjs
DELETED
|
@@ -1,368 +0,0 @@
|
|
|
1
|
-
import http from "node:http";
|
|
2
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
-
import { createLogger } from "./log.mjs";
|
|
4
|
-
|
|
5
|
-
export const DEFAULT_API_HOST = "127.0.0.1";
|
|
6
|
-
export const DEFAULT_API_PORT = 8765;
|
|
7
|
-
export const DEFAULT_API_MODEL = "chatgpt-mcp";
|
|
8
|
-
export const DEFAULT_API_MAX_BODY_BYTES = 32 * 1024 * 1024;
|
|
9
|
-
export const DEFAULT_SAMPLING_TIMEOUT_MS = 180_000;
|
|
10
|
-
|
|
11
|
-
const CHAT_COMPLETIONS_PATH = "/v1/chat/completions";
|
|
12
|
-
|
|
13
|
-
export function parseApiPort(value, fallback = DEFAULT_API_PORT) {
|
|
14
|
-
if (value === undefined || value === null || value === true || value === "") return fallback;
|
|
15
|
-
const port = Number(value);
|
|
16
|
-
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`Invalid API port: ${value}`);
|
|
17
|
-
return port;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function normalizeApiHost(value, fallback = DEFAULT_API_HOST) {
|
|
21
|
-
if (value === undefined || value === null || value === true || value === "") return fallback;
|
|
22
|
-
const host = String(value).trim();
|
|
23
|
-
if (!host) return fallback;
|
|
24
|
-
if (/[\\/\s]/.test(host)) throw new Error(`Invalid API host: ${value}`);
|
|
25
|
-
return host;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export async function startLocalApiServer(options = {}) {
|
|
29
|
-
const logger = options.logger || createLogger({ component: "api", quiet: options.quiet });
|
|
30
|
-
const host = normalizeApiHost(options.host);
|
|
31
|
-
const port = parseApiPort(options.port);
|
|
32
|
-
const apiKey = String(options.apiKey || "");
|
|
33
|
-
const model = String(options.model || DEFAULT_API_MODEL);
|
|
34
|
-
const workerUrl = String(options.workerUrl || "").replace(/\/+$/, "");
|
|
35
|
-
const daemonSecret = String(options.daemonSecret || "");
|
|
36
|
-
const maxBodyBytes = Number(options.maxBodyBytes || DEFAULT_API_MAX_BODY_BYTES);
|
|
37
|
-
const samplingTimeoutMs = Number(options.samplingTimeoutMs || DEFAULT_SAMPLING_TIMEOUT_MS);
|
|
38
|
-
|
|
39
|
-
if (!apiKey) throw new Error("Local API key is missing");
|
|
40
|
-
|
|
41
|
-
const server = http.createServer((req, res) => {
|
|
42
|
-
void handleRequest(req, res, { logger, apiKey, model, workerUrl, daemonSecret, maxBodyBytes, samplingTimeoutMs });
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
server.keepAliveTimeout = 65_000;
|
|
46
|
-
server.headersTimeout = 70_000;
|
|
47
|
-
server.requestTimeout = 0;
|
|
48
|
-
|
|
49
|
-
await new Promise((resolve, reject) => {
|
|
50
|
-
const onError = error => {
|
|
51
|
-
server.off("listening", onListening);
|
|
52
|
-
reject(withPortHint(error, port));
|
|
53
|
-
};
|
|
54
|
-
const onListening = () => {
|
|
55
|
-
server.off("error", onError);
|
|
56
|
-
resolve();
|
|
57
|
-
};
|
|
58
|
-
server.once("error", onError);
|
|
59
|
-
server.once("listening", onListening);
|
|
60
|
-
server.listen({ host, port });
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
const address = server.address();
|
|
64
|
-
const actualPort = typeof address === "object" && address ? address.port : port;
|
|
65
|
-
const urlHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
66
|
-
const baseUrl = `http://${urlHost}:${actualPort}/v1`;
|
|
67
|
-
logger.success("local OpenAI-compatible API started", { baseUrl, model, backend: "chatgpt-mcp", mcpBridgeConfigured: Boolean(workerUrl && daemonSecret) });
|
|
68
|
-
|
|
69
|
-
return {
|
|
70
|
-
server,
|
|
71
|
-
host,
|
|
72
|
-
port: actualPort,
|
|
73
|
-
baseUrl,
|
|
74
|
-
url: `http://${urlHost}:${actualPort}`,
|
|
75
|
-
apiKey,
|
|
76
|
-
model,
|
|
77
|
-
close() {
|
|
78
|
-
return new Promise(resolve => server.close(() => resolve()));
|
|
79
|
-
},
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
async function handleRequest(req, res, context) {
|
|
84
|
-
const requestId = randomUUID().slice(0, 8);
|
|
85
|
-
const started = Date.now();
|
|
86
|
-
const url = new URL(req.url || "/", "http://127.0.0.1");
|
|
87
|
-
setCorsHeaders(res);
|
|
88
|
-
|
|
89
|
-
try {
|
|
90
|
-
if (req.method === "OPTIONS") return sendEmpty(res, 204);
|
|
91
|
-
if (req.method === "GET" && url.pathname === "/health") {
|
|
92
|
-
return sendJson(res, 200, {
|
|
93
|
-
ok: true,
|
|
94
|
-
service: "machine-bridge-mcp-local-api",
|
|
95
|
-
backend: "chatgpt-mcp",
|
|
96
|
-
api_key_sha256: sha256String(context.apiKey),
|
|
97
|
-
mcp_bridge_configured: Boolean(context.workerUrl && context.daemonSecret),
|
|
98
|
-
model: context.model,
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
if (!isAuthorized(req, context.apiKey)) return sendOpenAiError(res, 401, "invalid_api_key", "Missing or invalid local API key.");
|
|
103
|
-
|
|
104
|
-
if (req.method === "GET" && url.pathname === "/v1/models") {
|
|
105
|
-
context.logger.info("request completed", { requestId, method: req.method, path: url.pathname, status: 200, durationMs: Date.now() - started });
|
|
106
|
-
return sendJson(res, 200, modelsPayload(context));
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
if (req.method === "POST" && ["/v1/responses", "/v1/completions", "/v1/embeddings"].includes(url.pathname)) {
|
|
110
|
-
return sendOpenAiError(res, 501, "unsupported_endpoint", "Only /v1/chat/completions is available through the ChatGPT MCP-backed local API. Embeddings, legacy completions, and Responses API are not exposed by MCP sampling.");
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
if (req.method === "POST" && url.pathname === CHAT_COMPLETIONS_PATH) {
|
|
114
|
-
if (!context.workerUrl || !context.daemonSecret) {
|
|
115
|
-
return sendOpenAiError(res, 503, "mcp_bridge_not_configured", "Local API is not connected to a Remote MCP bridge yet. Run `machine-mcp` normally so the Worker URL and MCP daemon secret exist, then reconnect ChatGPT to the printed MCP Server URL.");
|
|
116
|
-
}
|
|
117
|
-
const payload = await readJsonBody(req, context.maxBodyBytes);
|
|
118
|
-
const { params: samplingRequest, modelHint } = samplingRequestFromOpenAiChat(payload, context.model);
|
|
119
|
-
context.logger.info("MCP sampling request started", { requestId, path: url.pathname, modelHint: modelHint || null });
|
|
120
|
-
const result = await requestMcpSampling(context, samplingRequest);
|
|
121
|
-
const text = extractSamplingText(result);
|
|
122
|
-
const model = String(result?.model || modelHint || context.model);
|
|
123
|
-
const finishReason = finishReasonFromSampling(result);
|
|
124
|
-
if (payload.stream === true) sendChatCompletionStream(res, { text, model, finishReason });
|
|
125
|
-
else sendJson(res, 200, chatCompletionPayload({ text, model, finishReason }));
|
|
126
|
-
context.logger.info("MCP sampling request completed", { requestId, path: url.pathname, status: res.statusCode, durationMs: Date.now() - started });
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
return sendOpenAiError(res, 404, "not_found", `Unknown local API endpoint: ${url.pathname}`);
|
|
131
|
-
} catch (error) {
|
|
132
|
-
context.logger.error("request failed", safeErrorLogFields(error, { requestId, path: url.pathname, durationMs: Date.now() - started }));
|
|
133
|
-
if (!res.headersSent) {
|
|
134
|
-
if (error?.code === "BODY_TOO_LARGE") return sendOpenAiError(res, 413, "request_too_large", error.message);
|
|
135
|
-
if (error instanceof ApiError) return sendOpenAiError(res, error.status, error.code, error.message);
|
|
136
|
-
return sendOpenAiError(res, 502, "mcp_sampling_error", error.message || "Local API request failed.");
|
|
137
|
-
}
|
|
138
|
-
res.destroy(error);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function samplingRequestFromOpenAiChat(payload, advertisedModel) {
|
|
143
|
-
if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new ApiError(400, "invalid_request_error", "Request body must be a JSON object.");
|
|
144
|
-
if (!Array.isArray(payload.messages)) throw new ApiError(400, "invalid_request_error", "messages must be an array.");
|
|
145
|
-
const requestedModel = typeof payload.model === "string" && payload.model.trim() ? payload.model.trim() : "";
|
|
146
|
-
const modelHint = requestedModel && requestedModel !== advertisedModel ? requestedModel : "";
|
|
147
|
-
const messages = [];
|
|
148
|
-
const systemParts = [];
|
|
149
|
-
for (const message of payload.messages) {
|
|
150
|
-
const role = normalizeRole(message?.role);
|
|
151
|
-
const text = contentToText(message?.content);
|
|
152
|
-
if (!text) continue;
|
|
153
|
-
if (role === "system") systemParts.push(text);
|
|
154
|
-
else messages.push({ role, content: { type: "text", text } });
|
|
155
|
-
}
|
|
156
|
-
if (!messages.length) throw new ApiError(400, "invalid_request_error", "No user/assistant message content was provided.");
|
|
157
|
-
const params = {
|
|
158
|
-
messages,
|
|
159
|
-
systemPrompt: systemParts.join("\n\n") || undefined,
|
|
160
|
-
maxTokens: clampInt(payload.max_tokens ?? payload.max_completion_tokens ?? payload.max_output_tokens, 1024, 1, 128000),
|
|
161
|
-
stopSequences: Array.isArray(payload.stop) ? payload.stop.map(String) : typeof payload.stop === "string" ? [payload.stop] : undefined,
|
|
162
|
-
};
|
|
163
|
-
if (typeof payload.temperature === "number" && Number.isFinite(payload.temperature)) params.temperature = payload.temperature;
|
|
164
|
-
if (modelHint) params.modelPreferences = { hints: [{ name: modelHint }] };
|
|
165
|
-
return { params: removeUndefined(params), modelHint };
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
async function requestMcpSampling(context, samplingRequest) {
|
|
169
|
-
let response;
|
|
170
|
-
try {
|
|
171
|
-
response = await fetch(`${context.workerUrl}/api/mcp/sampling`, {
|
|
172
|
-
method: "POST",
|
|
173
|
-
headers: {
|
|
174
|
-
"content-type": "application/json",
|
|
175
|
-
"X-Bridge-Token": context.daemonSecret,
|
|
176
|
-
},
|
|
177
|
-
body: JSON.stringify({ ...samplingRequest, timeout_ms: context.samplingTimeoutMs }),
|
|
178
|
-
signal: AbortSignal.timeout(context.samplingTimeoutMs + 5_000),
|
|
179
|
-
});
|
|
180
|
-
} catch (error) {
|
|
181
|
-
if (error?.name === "TimeoutError" || error?.name === "AbortError") {
|
|
182
|
-
throw new ApiError(504, "mcp_sampling_timeout", "Timed out waiting for the Worker and connected MCP client to complete sampling/createMessage.");
|
|
183
|
-
}
|
|
184
|
-
throw error;
|
|
185
|
-
}
|
|
186
|
-
const body = await response.json().catch(() => null);
|
|
187
|
-
if (!response.ok) {
|
|
188
|
-
throw new ApiError(response.status, String(body?.error || "mcp_sampling_error"), String(body?.message || `MCP sampling request failed with HTTP ${response.status}`));
|
|
189
|
-
}
|
|
190
|
-
return body?.result ?? body;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function extractSamplingText(result) {
|
|
194
|
-
const content = result?.content;
|
|
195
|
-
if (typeof content === "string") return content;
|
|
196
|
-
if (content?.type === "text" && typeof content.text === "string") return content.text;
|
|
197
|
-
if (Array.isArray(content)) return content.map(item => item?.type === "text" ? item.text : "").filter(Boolean).join("\n");
|
|
198
|
-
if (typeof result?.text === "string") return result.text;
|
|
199
|
-
return JSON.stringify(result ?? {});
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function finishReasonFromSampling(result) {
|
|
203
|
-
const reason = String(result?.stopReason || "").toLowerCase();
|
|
204
|
-
if (reason === "maxtokens" || reason === "max_tokens" || reason === "length") return "length";
|
|
205
|
-
if (reason === "tooluse" || reason === "tool_use") return "tool_calls";
|
|
206
|
-
return "stop";
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function chatCompletionPayload({ text, model, finishReason }) {
|
|
210
|
-
return {
|
|
211
|
-
id: `chatcmpl-${randomUUID()}`,
|
|
212
|
-
object: "chat.completion",
|
|
213
|
-
created: Math.floor(Date.now() / 1000),
|
|
214
|
-
model,
|
|
215
|
-
choices: [{ index: 0, message: { role: "assistant", content: text }, finish_reason: finishReason }],
|
|
216
|
-
usage: null,
|
|
217
|
-
};
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
function sendChatCompletionStream(res, { text, model, finishReason }) {
|
|
221
|
-
res.statusCode = 200;
|
|
222
|
-
res.setHeader("content-type", "text/event-stream; charset=utf-8");
|
|
223
|
-
res.setHeader("cache-control", "no-cache");
|
|
224
|
-
const id = `chatcmpl-${randomUUID()}`;
|
|
225
|
-
const created = Math.floor(Date.now() / 1000);
|
|
226
|
-
const first = { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { role: "assistant", content: text }, finish_reason: null }] };
|
|
227
|
-
const done = { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: {}, finish_reason: finishReason }] };
|
|
228
|
-
res.write(`data: ${JSON.stringify(first)}\n\n`);
|
|
229
|
-
res.write(`data: ${JSON.stringify(done)}\n\n`);
|
|
230
|
-
res.write("data: [DONE]\n\n");
|
|
231
|
-
res.end();
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
function modelsPayload(context) {
|
|
235
|
-
return {
|
|
236
|
-
object: "list",
|
|
237
|
-
data: [{ id: context.model, object: "model", created: 0, owned_by: "chatgpt-mcp" }],
|
|
238
|
-
};
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
function normalizeRole(role) {
|
|
242
|
-
if (role === "assistant") return "assistant";
|
|
243
|
-
if (role === "system" || role === "developer") return "system";
|
|
244
|
-
return "user";
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
function contentToText(content) {
|
|
248
|
-
if (content === undefined || content === null) return "";
|
|
249
|
-
if (typeof content === "string") return content;
|
|
250
|
-
if (Array.isArray(content)) return content.map(contentPartToText).filter(Boolean).join("\n");
|
|
251
|
-
return contentPartToText(content);
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
function contentPartToText(content) {
|
|
255
|
-
if (content === undefined || content === null) return "";
|
|
256
|
-
if (typeof content === "string") return content;
|
|
257
|
-
if (Array.isArray(content)) return content.map(contentPartToText).filter(Boolean).join("\n");
|
|
258
|
-
if (typeof content === "object") {
|
|
259
|
-
if ((content.type === "text" || content.type === "input_text") && typeof content.text === "string") return content.text;
|
|
260
|
-
if (typeof content.text === "string") return content.text;
|
|
261
|
-
if (typeof content.content === "string") return content.content;
|
|
262
|
-
if (content.type === "text" && typeof content.value === "string") return content.value;
|
|
263
|
-
if (typeof content.type === "string" && content.type) {
|
|
264
|
-
throw new ApiError(400, "unsupported_content", `Only text message content is supported by this MCP-backed local chat API; unsupported content part: ${content.type}.`);
|
|
265
|
-
}
|
|
266
|
-
return "";
|
|
267
|
-
}
|
|
268
|
-
return String(content);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
function removeUndefined(value) {
|
|
272
|
-
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
function readJsonBody(req, maxBytes) {
|
|
276
|
-
return readBody(req, maxBytes).then(buffer => {
|
|
277
|
-
try {
|
|
278
|
-
const text = buffer.toString("utf8");
|
|
279
|
-
return text.trim() ? JSON.parse(text) : {};
|
|
280
|
-
} catch {
|
|
281
|
-
throw new ApiError(400, "invalid_json", "Request body is not valid JSON.");
|
|
282
|
-
}
|
|
283
|
-
});
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
function readBody(req, maxBytes) {
|
|
287
|
-
return new Promise((resolve, reject) => {
|
|
288
|
-
const chunks = [];
|
|
289
|
-
let total = 0;
|
|
290
|
-
req.on("data", chunk => {
|
|
291
|
-
total += chunk.length;
|
|
292
|
-
if (total > maxBytes) {
|
|
293
|
-
const error = new Error(`Request body exceeds ${maxBytes} bytes`);
|
|
294
|
-
error.code = "BODY_TOO_LARGE";
|
|
295
|
-
req.destroy(error);
|
|
296
|
-
reject(error);
|
|
297
|
-
return;
|
|
298
|
-
}
|
|
299
|
-
chunks.push(chunk);
|
|
300
|
-
});
|
|
301
|
-
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
302
|
-
req.on("error", reject);
|
|
303
|
-
});
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
function sha256String(value) {
|
|
307
|
-
return createHash("sha256").update(String(value)).digest("hex");
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
function isAuthorized(req, expectedKey) {
|
|
311
|
-
const auth = String(req.headers.authorization || "");
|
|
312
|
-
if (auth.startsWith("Bearer ") && auth.slice(7) === expectedKey) return true;
|
|
313
|
-
const apiKey = req.headers["x-api-key"];
|
|
314
|
-
return typeof apiKey === "string" && apiKey === expectedKey;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
function sendOpenAiError(res, status, code, message) {
|
|
318
|
-
return sendJson(res, status, { error: { message, type: "invalid_request_error", param: null, code } });
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
function sendJson(res, status, payload) {
|
|
322
|
-
if (!res.headersSent) {
|
|
323
|
-
res.statusCode = status;
|
|
324
|
-
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
325
|
-
}
|
|
326
|
-
res.end(`${JSON.stringify(payload)}\n`);
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
function sendEmpty(res, status) {
|
|
330
|
-
res.statusCode = status;
|
|
331
|
-
res.end();
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
function setCorsHeaders(res) {
|
|
335
|
-
res.setHeader("access-control-allow-origin", "*");
|
|
336
|
-
res.setHeader("access-control-allow-methods", "GET,POST,OPTIONS");
|
|
337
|
-
res.setHeader("access-control-allow-headers", "authorization,content-type,x-api-key,openai-beta,openai-organization,openai-project");
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
function safeErrorLogFields(error, fields) {
|
|
341
|
-
if (error instanceof ApiError) return { ...fields, status: error.status, code: error.code };
|
|
342
|
-
if (error?.code === "BODY_TOO_LARGE") return { ...fields, status: 413, code: "request_too_large" };
|
|
343
|
-
return { ...fields, error: error?.name || "Error" };
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
function withPortHint(error, port) {
|
|
347
|
-
if (error?.code === "EADDRINUSE") {
|
|
348
|
-
error.message = `Local API port ${port} is already in use. Re-run with \`machine-mcp --api-port <free_port>\` or \`machine-mcp api --api-port <free_port>\`.`;
|
|
349
|
-
}
|
|
350
|
-
if (error?.code === "EACCES") {
|
|
351
|
-
error.message = `Local API port ${port} is not permitted. Re-run with \`machine-mcp --api-port <free_port>\` or \`machine-mcp api --api-port <free_port>\`.`;
|
|
352
|
-
}
|
|
353
|
-
return error;
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
function clampInt(value, fallback, min, max) {
|
|
357
|
-
const number = Number(value);
|
|
358
|
-
if (!Number.isFinite(number)) return fallback;
|
|
359
|
-
return Math.min(Math.max(Math.floor(number), min), max);
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
class ApiError extends Error {
|
|
363
|
-
constructor(status, code, message) {
|
|
364
|
-
super(message);
|
|
365
|
-
this.status = status;
|
|
366
|
-
this.code = code;
|
|
367
|
-
}
|
|
368
|
-
}
|