mycomesh-consumer 0.1.0
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/README.md +150 -0
- package/bin/mycomesh-consumer.mjs +5 -0
- package/bin/mycomesh.mjs +5 -0
- package/package.json +26 -0
- package/src/cli.mjs +654 -0
package/README.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# mycomesh-consumer
|
|
2
|
+
|
|
3
|
+
Node.js CLI for consumers calling a MycoMesh OpenAI-compatible Consumer edge.
|
|
4
|
+
The commands remain stateless: wallet funding and V5 Session activation happen
|
|
5
|
+
in the browser, while this package sends API requests to the local loopback
|
|
6
|
+
edge or an explicitly configured HTTPS Gateway.
|
|
7
|
+
|
|
8
|
+
The package is published as `mycomesh-consumer`. `mycomesh-consumer` and the
|
|
9
|
+
short `mycomesh` command are equivalent.
|
|
10
|
+
|
|
11
|
+
## Requirements
|
|
12
|
+
|
|
13
|
+
- Node.js 20 or newer
|
|
14
|
+
- A MycoMesh Consumer base URL (the default is the local loopback edge)
|
|
15
|
+
- A consumer API key for authenticated endpoints
|
|
16
|
+
- For paid V5 inference, an active Session ID opened by the same wallet in the
|
|
17
|
+
local Web dApp
|
|
18
|
+
|
|
19
|
+
For local development, install it directly from the repository:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
npm install --global ./packages/mycomesh-cli
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
For an npm install from the public registry:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
npm install --global mycomesh-consumer
|
|
29
|
+
mycomesh-consumer health
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
This installs only the stateless Consumer CLI. It does not install or start
|
|
33
|
+
Bridge, Relay, Proxy, or Codex services. Provider operators should install the
|
|
34
|
+
separate `mycomesh-provider` package.
|
|
35
|
+
|
|
36
|
+
For a one-shot command without a global install:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
npx --yes --package=mycomesh-consumer mycomesh-consumer health
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Configuration
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
export MYCOMESH_BASE_URL=http://127.0.0.1:8110/v1
|
|
46
|
+
export MYCOMESH_API_KEY=your-consumer-key
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`--base-url` and `--api-key` override their corresponding environment
|
|
50
|
+
variables. A base URL may be the service root or end in `/v1`.
|
|
51
|
+
Remote base URLs must use HTTPS. Loopback HTTP remains available for local
|
|
52
|
+
development; `--allow-insecure-http` is an explicit test-only escape hatch.
|
|
53
|
+
Requests default to a 300-second deadline. Set `MYCOMESH_TIMEOUT_SECONDS` or
|
|
54
|
+
`--timeout`; successful JSON bodies are capped at 32 MiB while SSE remains
|
|
55
|
+
streamed with backpressure.
|
|
56
|
+
|
|
57
|
+
Prefer `MYCOMESH_API_KEY` over `--api-key`: command-line arguments may be stored
|
|
58
|
+
in shell history or visible to other local processes. The CLI never writes the
|
|
59
|
+
key to disk and redacts it from HTTP error output.
|
|
60
|
+
|
|
61
|
+
For the local network, start `make consumer-up`, open the local Playground,
|
|
62
|
+
connect a browser wallet, fund V5 escrow and approve the one-time `openSession`
|
|
63
|
+
transaction. Copy the active Session ID from the local status/Playground, then
|
|
64
|
+
set:
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
export MYCOMESH_BASE_URL=http://127.0.0.1:8110/v1
|
|
68
|
+
export MYCOMESH_API_KEY='replace-with-local-consumer-key'
|
|
69
|
+
export MYCOMESH_SESSION_ID='0x...replace-with-active-v5-session-id'
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The Session ID is not a replacement for the API key. Both values must belong to
|
|
73
|
+
the same local Consumer wallet. The CLI does not connect a wallet, move funds,
|
|
74
|
+
or open a V5 Session; the browser wallet flow does that once. A public Gateway
|
|
75
|
+
can still be used explicitly with `--base-url`, but it is never the default.
|
|
76
|
+
|
|
77
|
+
## Commands
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
mycomesh health
|
|
81
|
+
mycomesh models
|
|
82
|
+
|
|
83
|
+
mycomesh responses \
|
|
84
|
+
--input "Summarize this text" \
|
|
85
|
+
--model mycomesh-codex-standard-v1 \
|
|
86
|
+
--max-output-tokens 500
|
|
87
|
+
|
|
88
|
+
mycomesh chat \
|
|
89
|
+
--message "Explain this function" \
|
|
90
|
+
--system "Be concise." \
|
|
91
|
+
--model mycomesh-codex-standard-v1 \
|
|
92
|
+
--max-completion-tokens 500
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The CLI validates `MYCOMESH_SESSION_ID` and adds the required
|
|
96
|
+
`mycomesh_session` object automatically. Use `--session-id 0x...` to override it
|
|
97
|
+
for one command. A bare OpenAI-shaped request without either value is useful
|
|
98
|
+
only with a Gateway whose operator has explicitly configured another billing
|
|
99
|
+
path.
|
|
100
|
+
|
|
101
|
+
Use `--stream` to request SSE. SSE bytes are forwarded to stdout as they arrive,
|
|
102
|
+
without rewriting event boundaries:
|
|
103
|
+
|
|
104
|
+
```sh
|
|
105
|
+
mycomesh responses \
|
|
106
|
+
--input "Write a short status update" \
|
|
107
|
+
--max-output-tokens 500 \
|
|
108
|
+
--stream
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## JSON Input
|
|
112
|
+
|
|
113
|
+
Pass an inline object, read a file, or read stdin explicitly:
|
|
114
|
+
|
|
115
|
+
```sh
|
|
116
|
+
mycomesh responses --json '{"model":"my-model","input":"hello"}'
|
|
117
|
+
mycomesh chat --json @request.json
|
|
118
|
+
generate-request | mycomesh responses --json -
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
When `MYCOMESH_SESSION_ID` or `--session-id` is set, the CLI overwrites any JSON
|
|
122
|
+
session field with that validated value. A client constructing the complete raw
|
|
123
|
+
request body instead may include the extension directly:
|
|
124
|
+
|
|
125
|
+
```json
|
|
126
|
+
{
|
|
127
|
+
"mycomesh_session": {
|
|
128
|
+
"session_id": "0x..."
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Piped stdin is treated as JSON even without `--json`:
|
|
134
|
+
|
|
135
|
+
```sh
|
|
136
|
+
printf '%s' '{"model":"my-model","messages":[{"role":"user","content":"hello"}]}' \
|
|
137
|
+
| mycomesh chat
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Explicit flags such as `--model`, `--input`, `--message`, `--system`, and
|
|
141
|
+
`--stream` override the corresponding JSON fields. Successful JSON responses
|
|
142
|
+
are pretty-printed. SSE and other response types are copied to stdout. HTTP and
|
|
143
|
+
input errors are written to stderr and return a nonzero exit status.
|
|
144
|
+
|
|
145
|
+
## Development
|
|
146
|
+
|
|
147
|
+
```sh
|
|
148
|
+
cd packages/mycomesh-cli
|
|
149
|
+
npm test
|
|
150
|
+
```
|
package/bin/mycomesh.mjs
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mycomesh-consumer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Stateless MycoMesh Consumer CLI for the local loopback edge or an explicit HTTPS Gateway.",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"bin": {
|
|
10
|
+
"mycomesh": "./bin/mycomesh.mjs",
|
|
11
|
+
"mycomesh-consumer": "./bin/mycomesh-consumer.mjs"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin/mycomesh.mjs",
|
|
15
|
+
"bin/mycomesh-consumer.mjs",
|
|
16
|
+
"src/cli.mjs",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=20"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"test": "node --test"
|
|
24
|
+
},
|
|
25
|
+
"license": "UNLICENSED"
|
|
26
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
import { once } from "node:events";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
const CLI_VERSION = "0.1.0";
|
|
5
|
+
// The npm client is local-first. A public Gateway is an explicit override;
|
|
6
|
+
// the default remains the loopback Consumer edge so a blocked domain cannot
|
|
7
|
+
// strand the Codex client.
|
|
8
|
+
const DEFAULT_BASE_URL = "http://127.0.0.1:8110/v1";
|
|
9
|
+
const DEFAULT_TIMEOUT_SECONDS = 300;
|
|
10
|
+
const MAX_TIMEOUT_SECONDS = 3600;
|
|
11
|
+
const MAX_INPUT_BYTES = 16 * 1024 * 1024;
|
|
12
|
+
const MAX_ERROR_BYTES = 64 * 1024;
|
|
13
|
+
const MAX_JSON_RESPONSE_BYTES = 32 * 1024 * 1024;
|
|
14
|
+
const COMMANDS = new Set(["health", "models", "responses", "chat"]);
|
|
15
|
+
|
|
16
|
+
const HELP = `Usage: mycomesh <command> [options] [input]
|
|
17
|
+
|
|
18
|
+
Consumer commands:
|
|
19
|
+
health Check gateway health
|
|
20
|
+
models List available models
|
|
21
|
+
responses [input] Create a Responses API request
|
|
22
|
+
chat [message] Create a Chat Completions API request
|
|
23
|
+
|
|
24
|
+
Connection options:
|
|
25
|
+
--base-url <url> Consumer root or /v1 URL (default: local loopback)
|
|
26
|
+
(env: MYCOMESH_BASE_URL)
|
|
27
|
+
--api-key <key> Bearer API key (env: MYCOMESH_API_KEY)
|
|
28
|
+
--timeout <seconds> Request deadline, up to 3600 seconds
|
|
29
|
+
(env: MYCOMESH_TIMEOUT_SECONDS; default: 300)
|
|
30
|
+
--allow-insecure-http Allow cleartext HTTP to a non-loopback test gateway
|
|
31
|
+
|
|
32
|
+
Session options:
|
|
33
|
+
--session-id <bytes32> Use an already-opened V5 Session
|
|
34
|
+
(env: MYCOMESH_SESSION_ID)
|
|
35
|
+
|
|
36
|
+
Request body options:
|
|
37
|
+
--json <json|@file|-> JSON object, JSON file, or JSON from stdin
|
|
38
|
+
--model <model> Set the request model
|
|
39
|
+
--stream Request an SSE response
|
|
40
|
+
--no-stream Disable streaming from a JSON base body
|
|
41
|
+
|
|
42
|
+
Responses options:
|
|
43
|
+
--input <text> Set the Responses API input
|
|
44
|
+
--max-output-tokens <n> Set max_output_tokens
|
|
45
|
+
|
|
46
|
+
Chat options:
|
|
47
|
+
--message <text> Set the user message
|
|
48
|
+
--system <text> Prepend a system message
|
|
49
|
+
--max-tokens <n> Set max_tokens
|
|
50
|
+
--max-completion-tokens <n>
|
|
51
|
+
Set max_completion_tokens
|
|
52
|
+
|
|
53
|
+
Other options:
|
|
54
|
+
-h, --help Show this help
|
|
55
|
+
-v, --version Show the CLI version
|
|
56
|
+
|
|
57
|
+
When --json is omitted, piped stdin is read as a JSON request object. Explicit
|
|
58
|
+
request options override fields loaded from JSON.`;
|
|
59
|
+
|
|
60
|
+
class CliError extends Error {
|
|
61
|
+
constructor(message, exitCode = 1) {
|
|
62
|
+
super(message);
|
|
63
|
+
this.name = "CliError";
|
|
64
|
+
this.exitCode = exitCode;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
class HttpError extends CliError {
|
|
69
|
+
constructor(status, statusText, body) {
|
|
70
|
+
const label = [status, statusText].filter(Boolean).join(" ");
|
|
71
|
+
super(`HTTP ${label}${body ? `\n${body}` : ""}`);
|
|
72
|
+
this.name = "HttpError";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function main(argv, dependencies = {}) {
|
|
77
|
+
const env = dependencies.env ?? process.env;
|
|
78
|
+
const stdin = dependencies.stdin ?? process.stdin;
|
|
79
|
+
const stdout = dependencies.stdout ?? process.stdout;
|
|
80
|
+
const stderr = dependencies.stderr ?? process.stderr;
|
|
81
|
+
const fetchImpl = dependencies.fetch ?? globalThis.fetch;
|
|
82
|
+
const maxJsonResponseBytes =
|
|
83
|
+
dependencies.maxJsonResponseBytes ?? MAX_JSON_RESPONSE_BYTES;
|
|
84
|
+
let apiKey = env.MYCOMESH_API_KEY || "";
|
|
85
|
+
let requestTimeout;
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const parsed = parseArguments(argv, env);
|
|
89
|
+
apiKey = parsed.apiKey;
|
|
90
|
+
|
|
91
|
+
if (parsed.help) {
|
|
92
|
+
stdout.write(`${HELP}\n`);
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
if (parsed.version) {
|
|
96
|
+
stdout.write(`${CLI_VERSION}\n`);
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
if (typeof fetchImpl !== "function") {
|
|
100
|
+
throw new CliError("Node.js 20 or newer is required");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const request = await buildRequest(parsed, stdin);
|
|
104
|
+
requestTimeout = createRequestTimeout(parsed.timeoutSeconds);
|
|
105
|
+
const response = await sendRequest(
|
|
106
|
+
request,
|
|
107
|
+
fetchImpl,
|
|
108
|
+
requestTimeout.signal,
|
|
109
|
+
parsed.timeoutSeconds,
|
|
110
|
+
);
|
|
111
|
+
if (!response.ok) {
|
|
112
|
+
const errorBody = await readLimitedText(response.body, MAX_ERROR_BYTES);
|
|
113
|
+
throw new HttpError(response.status, response.statusText, errorBody.trim());
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
await writeResponse(response, stdout, maxJsonResponseBytes);
|
|
117
|
+
return 0;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
const exitCode = error instanceof CliError ? error.exitCode : 1;
|
|
120
|
+
const message = requestTimeout?.signal.aborted
|
|
121
|
+
? `request timed out after ${requestTimeout.timeoutSeconds} seconds`
|
|
122
|
+
: error instanceof Error
|
|
123
|
+
? error.message
|
|
124
|
+
: String(error);
|
|
125
|
+
stderr.write(`mycomesh: ${redact(message, apiKey)}\n`);
|
|
126
|
+
return exitCode;
|
|
127
|
+
} finally {
|
|
128
|
+
requestTimeout?.cancel();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function parseArguments(argv, env = process.env) {
|
|
133
|
+
const parsed = {
|
|
134
|
+
command: undefined,
|
|
135
|
+
baseUrl: env.MYCOMESH_BASE_URL || DEFAULT_BASE_URL,
|
|
136
|
+
apiKey: env.MYCOMESH_API_KEY || "",
|
|
137
|
+
timeoutSeconds: requestTimeoutSeconds(
|
|
138
|
+
env.MYCOMESH_TIMEOUT_SECONDS || String(DEFAULT_TIMEOUT_SECONDS),
|
|
139
|
+
"MYCOMESH_TIMEOUT_SECONDS",
|
|
140
|
+
),
|
|
141
|
+
allowInsecureHttp: false,
|
|
142
|
+
sessionId: env.MYCOMESH_SESSION_ID || "",
|
|
143
|
+
sessionIdExplicit: false,
|
|
144
|
+
jsonSource: undefined,
|
|
145
|
+
model: undefined,
|
|
146
|
+
input: undefined,
|
|
147
|
+
message: undefined,
|
|
148
|
+
system: undefined,
|
|
149
|
+
stream: undefined,
|
|
150
|
+
maxOutputTokens: undefined,
|
|
151
|
+
maxTokens: undefined,
|
|
152
|
+
maxCompletionTokens: undefined,
|
|
153
|
+
positionals: [],
|
|
154
|
+
help: false,
|
|
155
|
+
version: false,
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
159
|
+
const token = argv[index];
|
|
160
|
+
if (token === "--") {
|
|
161
|
+
parsed.positionals.push(...argv.slice(index + 1));
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
if (token === "-h" || token === "--help") {
|
|
165
|
+
parsed.help = true;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (token === "-v" || token === "--version") {
|
|
169
|
+
parsed.version = true;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (token === "--stream") {
|
|
173
|
+
parsed.stream = true;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (token === "--no-stream") {
|
|
177
|
+
parsed.stream = false;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (token === "--allow-insecure-http") {
|
|
181
|
+
parsed.allowInsecureHttp = true;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (token.startsWith("--")) {
|
|
185
|
+
const separator = token.indexOf("=");
|
|
186
|
+
const name = separator === -1 ? token : token.slice(0, separator);
|
|
187
|
+
let value = separator === -1 ? undefined : token.slice(separator + 1);
|
|
188
|
+
if (value === undefined) {
|
|
189
|
+
index += 1;
|
|
190
|
+
value = argv[index];
|
|
191
|
+
}
|
|
192
|
+
if (value === undefined) {
|
|
193
|
+
throw new CliError(`${name} requires a value`, 2);
|
|
194
|
+
}
|
|
195
|
+
switch (name) {
|
|
196
|
+
case "--base-url":
|
|
197
|
+
parsed.baseUrl = value;
|
|
198
|
+
break;
|
|
199
|
+
case "--api-key":
|
|
200
|
+
parsed.apiKey = value;
|
|
201
|
+
break;
|
|
202
|
+
case "--timeout":
|
|
203
|
+
parsed.timeoutSeconds = requestTimeoutSeconds(value, name);
|
|
204
|
+
break;
|
|
205
|
+
case "--session-id":
|
|
206
|
+
parsed.sessionId = value;
|
|
207
|
+
parsed.sessionIdExplicit = true;
|
|
208
|
+
break;
|
|
209
|
+
case "--json":
|
|
210
|
+
parsed.jsonSource = value;
|
|
211
|
+
break;
|
|
212
|
+
case "--model":
|
|
213
|
+
parsed.model = value;
|
|
214
|
+
break;
|
|
215
|
+
case "--input":
|
|
216
|
+
parsed.input = value;
|
|
217
|
+
break;
|
|
218
|
+
case "--message":
|
|
219
|
+
parsed.message = value;
|
|
220
|
+
break;
|
|
221
|
+
case "--system":
|
|
222
|
+
parsed.system = value;
|
|
223
|
+
break;
|
|
224
|
+
case "--max-output-tokens":
|
|
225
|
+
parsed.maxOutputTokens = positiveInteger(value, name);
|
|
226
|
+
break;
|
|
227
|
+
case "--max-tokens":
|
|
228
|
+
parsed.maxTokens = positiveInteger(value, name);
|
|
229
|
+
break;
|
|
230
|
+
case "--max-completion-tokens":
|
|
231
|
+
parsed.maxCompletionTokens = positiveInteger(value, name);
|
|
232
|
+
break;
|
|
233
|
+
default:
|
|
234
|
+
throw new CliError(`unknown option: ${name}`, 2);
|
|
235
|
+
}
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (token.startsWith("-") && token !== "-") {
|
|
239
|
+
throw new CliError(`unknown option: ${token}`, 2);
|
|
240
|
+
}
|
|
241
|
+
if (parsed.command === undefined) {
|
|
242
|
+
parsed.command = token;
|
|
243
|
+
} else {
|
|
244
|
+
parsed.positionals.push(token);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (!parsed.command && !parsed.help && !parsed.version) {
|
|
249
|
+
throw new CliError("a command is required; run mycomesh --help", 2);
|
|
250
|
+
}
|
|
251
|
+
if (parsed.command && !COMMANDS.has(parsed.command)) {
|
|
252
|
+
throw new CliError(`unknown command: ${parsed.command}`, 2);
|
|
253
|
+
}
|
|
254
|
+
if (
|
|
255
|
+
parsed.sessionId &&
|
|
256
|
+
(parsed.command === "responses" || parsed.command === "chat")
|
|
257
|
+
) {
|
|
258
|
+
parsed.sessionId = sessionId(parsed.sessionId, "--session-id");
|
|
259
|
+
}
|
|
260
|
+
validateCommandOptions(parsed);
|
|
261
|
+
return parsed;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function buildRequest(parsed, stdin) {
|
|
265
|
+
const url = endpointUrl(
|
|
266
|
+
parsed.baseUrl,
|
|
267
|
+
parsed.command,
|
|
268
|
+
parsed.allowInsecureHttp,
|
|
269
|
+
);
|
|
270
|
+
const headers = {
|
|
271
|
+
accept: "application/json",
|
|
272
|
+
"user-agent": `mycomesh-cli/${CLI_VERSION}`,
|
|
273
|
+
};
|
|
274
|
+
if (parsed.apiKey) {
|
|
275
|
+
headers.authorization = `Bearer ${parsed.apiKey}`;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (parsed.command === "health" || parsed.command === "models") {
|
|
279
|
+
return { url, method: "GET", headers };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const body = await requestBody(parsed, stdin);
|
|
283
|
+
headers["content-type"] = "application/json";
|
|
284
|
+
if (body.stream === true) {
|
|
285
|
+
headers.accept = "text/event-stream, application/json";
|
|
286
|
+
}
|
|
287
|
+
return {
|
|
288
|
+
url,
|
|
289
|
+
method: "POST",
|
|
290
|
+
headers,
|
|
291
|
+
body: JSON.stringify(body),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function requestBody(parsed, stdin) {
|
|
296
|
+
let body = {};
|
|
297
|
+
if (parsed.jsonSource !== undefined) {
|
|
298
|
+
body = await parseJsonSource(parsed.jsonSource, stdin);
|
|
299
|
+
} else if (stdin && stdin.isTTY !== true) {
|
|
300
|
+
const piped = await readLimitedText(stdin, MAX_INPUT_BYTES);
|
|
301
|
+
if (piped.trim()) {
|
|
302
|
+
body = parseJsonObject(piped, "stdin");
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (parsed.model !== undefined) {
|
|
307
|
+
body.model = parsed.model;
|
|
308
|
+
}
|
|
309
|
+
if (parsed.stream !== undefined) {
|
|
310
|
+
body.stream = parsed.stream;
|
|
311
|
+
}
|
|
312
|
+
if (parsed.sessionId) {
|
|
313
|
+
body.mycomesh_session = { session_id: parsed.sessionId };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const positionalInput = parsed.positionals.length
|
|
317
|
+
? parsed.positionals.join(" ")
|
|
318
|
+
: undefined;
|
|
319
|
+
if (parsed.command === "responses") {
|
|
320
|
+
const input = parsed.input ?? positionalInput;
|
|
321
|
+
if (input !== undefined) {
|
|
322
|
+
body.input = input;
|
|
323
|
+
}
|
|
324
|
+
if (parsed.maxOutputTokens !== undefined) {
|
|
325
|
+
body.max_output_tokens = parsed.maxOutputTokens;
|
|
326
|
+
}
|
|
327
|
+
if (body.input === undefined) {
|
|
328
|
+
throw new CliError(
|
|
329
|
+
"responses requires input in JSON, --input, or a positional argument",
|
|
330
|
+
2,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
} else {
|
|
334
|
+
const message = parsed.message ?? positionalInput;
|
|
335
|
+
if (message !== undefined) {
|
|
336
|
+
const messages = [];
|
|
337
|
+
if (parsed.system !== undefined) {
|
|
338
|
+
messages.push({ role: "system", content: parsed.system });
|
|
339
|
+
}
|
|
340
|
+
messages.push({ role: "user", content: message });
|
|
341
|
+
body.messages = messages;
|
|
342
|
+
} else if (parsed.system !== undefined) {
|
|
343
|
+
const messages = Array.isArray(body.messages) ? body.messages : [];
|
|
344
|
+
body.messages = [
|
|
345
|
+
{ role: "system", content: parsed.system },
|
|
346
|
+
...messages,
|
|
347
|
+
];
|
|
348
|
+
}
|
|
349
|
+
if (parsed.maxTokens !== undefined) {
|
|
350
|
+
body.max_tokens = parsed.maxTokens;
|
|
351
|
+
}
|
|
352
|
+
if (parsed.maxCompletionTokens !== undefined) {
|
|
353
|
+
body.max_completion_tokens = parsed.maxCompletionTokens;
|
|
354
|
+
}
|
|
355
|
+
if (!Array.isArray(body.messages) || body.messages.length === 0) {
|
|
356
|
+
throw new CliError(
|
|
357
|
+
"chat requires messages in JSON, --message, or a positional argument",
|
|
358
|
+
2,
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return body;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function parseJsonSource(source, stdin) {
|
|
366
|
+
if (source === "-") {
|
|
367
|
+
const text = await readLimitedText(stdin, MAX_INPUT_BYTES);
|
|
368
|
+
return parseJsonObject(text, "stdin");
|
|
369
|
+
}
|
|
370
|
+
if (source.startsWith("@")) {
|
|
371
|
+
const path = source.slice(1);
|
|
372
|
+
if (!path) {
|
|
373
|
+
throw new CliError("--json @file requires a file path", 2);
|
|
374
|
+
}
|
|
375
|
+
let contents;
|
|
376
|
+
try {
|
|
377
|
+
contents = await readFile(path);
|
|
378
|
+
} catch (error) {
|
|
379
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
380
|
+
throw new CliError(`could not read JSON file: ${detail}`, 2);
|
|
381
|
+
}
|
|
382
|
+
if (contents.byteLength > MAX_INPUT_BYTES) {
|
|
383
|
+
throw new CliError(`JSON input exceeds ${MAX_INPUT_BYTES} bytes`, 2);
|
|
384
|
+
}
|
|
385
|
+
return parseJsonObject(contents.toString("utf8"), path);
|
|
386
|
+
}
|
|
387
|
+
if (Buffer.byteLength(source, "utf8") > MAX_INPUT_BYTES) {
|
|
388
|
+
throw new CliError(`JSON input exceeds ${MAX_INPUT_BYTES} bytes`, 2);
|
|
389
|
+
}
|
|
390
|
+
return parseJsonObject(source, "--json");
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function parseJsonObject(text, source) {
|
|
394
|
+
let value;
|
|
395
|
+
try {
|
|
396
|
+
value = JSON.parse(text);
|
|
397
|
+
} catch (error) {
|
|
398
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
399
|
+
throw new CliError(`invalid JSON from ${source}: ${detail}`, 2);
|
|
400
|
+
}
|
|
401
|
+
if (value === null || Array.isArray(value) || typeof value !== "object") {
|
|
402
|
+
throw new CliError(`JSON from ${source} must be an object`, 2);
|
|
403
|
+
}
|
|
404
|
+
return { ...value };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function endpointUrl(baseUrl, command, allowInsecureHttp = false) {
|
|
408
|
+
let url;
|
|
409
|
+
try {
|
|
410
|
+
url = new URL(baseUrl);
|
|
411
|
+
} catch {
|
|
412
|
+
throw new CliError("base URL must be an absolute HTTP or HTTPS URL", 2);
|
|
413
|
+
}
|
|
414
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
415
|
+
throw new CliError("base URL must use HTTP or HTTPS", 2);
|
|
416
|
+
}
|
|
417
|
+
if (url.username || url.password) {
|
|
418
|
+
throw new CliError("base URL must not contain credentials", 2);
|
|
419
|
+
}
|
|
420
|
+
if (
|
|
421
|
+
url.protocol === "http:" &&
|
|
422
|
+
!allowInsecureHttp &&
|
|
423
|
+
!isLoopbackHostname(url.hostname)
|
|
424
|
+
) {
|
|
425
|
+
throw new CliError(
|
|
426
|
+
"remote base URLs must use HTTPS; use --allow-insecure-http only for testing",
|
|
427
|
+
2,
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
if (url.search || url.hash) {
|
|
431
|
+
throw new CliError("base URL must not contain a query string or fragment", 2);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
let path = url.pathname.replace(/\/+$/, "");
|
|
435
|
+
if (command === "health") {
|
|
436
|
+
path = path.replace(/\/v1$/, "");
|
|
437
|
+
url.pathname = `${path}/health`;
|
|
438
|
+
} else {
|
|
439
|
+
if (!path.endsWith("/v1")) {
|
|
440
|
+
path = `${path}/v1`;
|
|
441
|
+
}
|
|
442
|
+
const resource = command === "chat" ? "chat/completions" : command;
|
|
443
|
+
url.pathname = `${path}/${resource}`;
|
|
444
|
+
}
|
|
445
|
+
return url;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function isLoopbackHostname(hostname) {
|
|
449
|
+
const normalized = hostname.toLowerCase().replace(/\.$/, "");
|
|
450
|
+
if (normalized === "localhost" || normalized === "[::1]") {
|
|
451
|
+
return true;
|
|
452
|
+
}
|
|
453
|
+
if (!/^127(?:\.[0-9]{1,3}){3}$/.test(normalized)) {
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
return normalized.split(".").every((part) => Number(part) <= 255);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function sendRequest(request, fetchImpl, signal, timeoutSeconds) {
|
|
460
|
+
try {
|
|
461
|
+
return await fetchImpl(request.url, {
|
|
462
|
+
method: request.method,
|
|
463
|
+
headers: request.headers,
|
|
464
|
+
body: request.body,
|
|
465
|
+
redirect: "error",
|
|
466
|
+
signal,
|
|
467
|
+
});
|
|
468
|
+
} catch (error) {
|
|
469
|
+
if (signal.aborted) {
|
|
470
|
+
throw new CliError(`request timed out after ${timeoutSeconds} seconds`);
|
|
471
|
+
}
|
|
472
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
473
|
+
throw new CliError(`request failed: ${detail}`);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function writeResponse(response, output, maxJsonResponseBytes) {
|
|
478
|
+
const contentType = (response.headers.get("content-type") || "")
|
|
479
|
+
.split(";", 1)[0]
|
|
480
|
+
.trim()
|
|
481
|
+
.toLowerCase();
|
|
482
|
+
|
|
483
|
+
if (contentType === "text/event-stream") {
|
|
484
|
+
await pipeBody(response.body, output);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (contentType === "application/json" || contentType.endsWith("+json")) {
|
|
488
|
+
const text = await readLimitedText(response.body, maxJsonResponseBytes, {
|
|
489
|
+
rejectOnLimit: true,
|
|
490
|
+
label: "JSON response",
|
|
491
|
+
});
|
|
492
|
+
if (!text.trim()) {
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
let value;
|
|
496
|
+
try {
|
|
497
|
+
value = JSON.parse(text);
|
|
498
|
+
} catch {
|
|
499
|
+
throw new CliError("server returned malformed JSON");
|
|
500
|
+
}
|
|
501
|
+
output.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
await pipeBody(response.body, output);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async function pipeBody(body, output) {
|
|
508
|
+
if (!body) {
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
for await (const chunk of body) {
|
|
512
|
+
if (!output.write(chunk)) {
|
|
513
|
+
await once(output, "drain");
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
async function readLimitedText(
|
|
519
|
+
stream,
|
|
520
|
+
limit,
|
|
521
|
+
{ rejectOnLimit = false, label = "response" } = {},
|
|
522
|
+
) {
|
|
523
|
+
if (!stream) {
|
|
524
|
+
return "";
|
|
525
|
+
}
|
|
526
|
+
const chunks = [];
|
|
527
|
+
let size = 0;
|
|
528
|
+
let truncated = false;
|
|
529
|
+
for await (const rawChunk of stream) {
|
|
530
|
+
const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk);
|
|
531
|
+
const remaining = limit - size;
|
|
532
|
+
if (chunk.byteLength > remaining) {
|
|
533
|
+
if (remaining > 0) {
|
|
534
|
+
chunks.push(chunk.subarray(0, remaining));
|
|
535
|
+
}
|
|
536
|
+
truncated = true;
|
|
537
|
+
break;
|
|
538
|
+
}
|
|
539
|
+
chunks.push(chunk);
|
|
540
|
+
size += chunk.byteLength;
|
|
541
|
+
}
|
|
542
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
543
|
+
if (truncated && rejectOnLimit) {
|
|
544
|
+
throw new CliError(`${label} exceeds ${limit} bytes`);
|
|
545
|
+
}
|
|
546
|
+
return truncated ? `${text}\n[response truncated]` : text;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function createRequestTimeout(timeoutSeconds) {
|
|
550
|
+
const controller = new AbortController();
|
|
551
|
+
const timer = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
|
|
552
|
+
timer.unref?.();
|
|
553
|
+
return {
|
|
554
|
+
signal: controller.signal,
|
|
555
|
+
timeoutSeconds,
|
|
556
|
+
cancel() {
|
|
557
|
+
clearTimeout(timer);
|
|
558
|
+
},
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function requestTimeoutSeconds(value, option) {
|
|
563
|
+
const timeout = positiveInteger(String(value), option);
|
|
564
|
+
if (timeout > MAX_TIMEOUT_SECONDS) {
|
|
565
|
+
throw new CliError(
|
|
566
|
+
`${option} must not exceed ${MAX_TIMEOUT_SECONDS} seconds`,
|
|
567
|
+
2,
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
return timeout;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function positiveInteger(value, option) {
|
|
574
|
+
if (!/^[1-9][0-9]*$/.test(value)) {
|
|
575
|
+
throw new CliError(`${option} must be a positive integer`, 2);
|
|
576
|
+
}
|
|
577
|
+
const number = Number(value);
|
|
578
|
+
if (!Number.isSafeInteger(number)) {
|
|
579
|
+
throw new CliError(`${option} is too large`, 2);
|
|
580
|
+
}
|
|
581
|
+
return number;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function sessionId(value, option) {
|
|
585
|
+
if (!/^0x[0-9a-fA-F]{64}$/.test(value)) {
|
|
586
|
+
throw new CliError(`${option} must be a 32-byte 0x-prefixed hex value`, 2);
|
|
587
|
+
}
|
|
588
|
+
return value.toLowerCase();
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function validateCommandOptions(parsed) {
|
|
592
|
+
if (parsed.help || parsed.version || !parsed.command) {
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
const hasBodyOptions =
|
|
596
|
+
parsed.jsonSource !== undefined ||
|
|
597
|
+
parsed.model !== undefined ||
|
|
598
|
+
parsed.stream !== undefined ||
|
|
599
|
+
parsed.input !== undefined ||
|
|
600
|
+
parsed.message !== undefined ||
|
|
601
|
+
parsed.system !== undefined ||
|
|
602
|
+
parsed.maxOutputTokens !== undefined ||
|
|
603
|
+
parsed.maxTokens !== undefined ||
|
|
604
|
+
parsed.maxCompletionTokens !== undefined ||
|
|
605
|
+
parsed.positionals.length > 0;
|
|
606
|
+
if ((parsed.command === "health" || parsed.command === "models") && hasBodyOptions) {
|
|
607
|
+
throw new CliError(`${parsed.command} does not accept request body options`, 2);
|
|
608
|
+
}
|
|
609
|
+
if (
|
|
610
|
+
(parsed.command === "health" || parsed.command === "models") &&
|
|
611
|
+
parsed.sessionIdExplicit
|
|
612
|
+
) {
|
|
613
|
+
throw new CliError(`${parsed.command} does not accept --session-id`, 2);
|
|
614
|
+
}
|
|
615
|
+
if (
|
|
616
|
+
parsed.command === "responses" &&
|
|
617
|
+
(parsed.message !== undefined ||
|
|
618
|
+
parsed.system !== undefined ||
|
|
619
|
+
parsed.maxTokens !== undefined ||
|
|
620
|
+
parsed.maxCompletionTokens !== undefined)
|
|
621
|
+
) {
|
|
622
|
+
throw new CliError("chat options cannot be used with responses", 2);
|
|
623
|
+
}
|
|
624
|
+
if (
|
|
625
|
+
parsed.command === "chat" &&
|
|
626
|
+
(parsed.input !== undefined || parsed.maxOutputTokens !== undefined)
|
|
627
|
+
) {
|
|
628
|
+
throw new CliError("responses options cannot be used with chat", 2);
|
|
629
|
+
}
|
|
630
|
+
if (parsed.input !== undefined && parsed.positionals.length > 0) {
|
|
631
|
+
throw new CliError("use either --input or positional input, not both", 2);
|
|
632
|
+
}
|
|
633
|
+
if (parsed.message !== undefined && parsed.positionals.length > 0) {
|
|
634
|
+
throw new CliError("use either --message or a positional message, not both", 2);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function redact(value, secret) {
|
|
639
|
+
let text = String(value);
|
|
640
|
+
if (!secret) {
|
|
641
|
+
return text;
|
|
642
|
+
}
|
|
643
|
+
const variants = new Set([
|
|
644
|
+
secret,
|
|
645
|
+
encodeURIComponent(secret),
|
|
646
|
+
JSON.stringify(secret).slice(1, -1),
|
|
647
|
+
]);
|
|
648
|
+
for (const variant of variants) {
|
|
649
|
+
if (variant) {
|
|
650
|
+
text = text.split(variant).join("[REDACTED]");
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
return text;
|
|
654
|
+
}
|