@mmmbuto/anthmorph 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.
@@ -0,0 +1,456 @@
1
+ #!/bin/sh
2
+ set -eu
3
+
4
+ ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
5
+ STATE_DIR=${ANTHMORPH_STATE_DIR:-$ROOT_DIR/.anthmorph}
6
+ CONFIG_FILE=$STATE_DIR/config.env
7
+ PID_FILE=$STATE_DIR/anthmorph.pid
8
+ LOG_FILE=$STATE_DIR/anthmorph.log
9
+ BIN_PATH_DEFAULT=$ROOT_DIR/target/release/anthmorph
10
+
11
+ mkdir -p "$STATE_DIR"
12
+
13
+ usage() {
14
+ cat <<USAGE
15
+ usage:
16
+ scripts/anthmorphctl init chutes [options]
17
+ scripts/anthmorphctl init minimax [options]
18
+ scripts/anthmorphctl init openai --backend-url URL --model MODEL [options]
19
+ scripts/anthmorphctl set key-env ENV_NAME
20
+ scripts/anthmorphctl set key VALUE --save
21
+ scripts/anthmorphctl unset key
22
+ scripts/anthmorphctl start
23
+ scripts/anthmorphctl stop
24
+ scripts/anthmorphctl restart
25
+ scripts/anthmorphctl status
26
+ scripts/anthmorphctl logs
27
+ scripts/anthmorphctl print-config
28
+ scripts/anthmorphctl help
29
+
30
+ options for init:
31
+ --port PORT
32
+ --backend-url URL
33
+ --model MODEL
34
+ --reasoning-model MODEL
35
+ --ingress-api-key VALUE
36
+ --allow-origin ORIGIN
37
+ --key-env ENV_NAME
38
+ --api-key VALUE --save-key
39
+
40
+ notes:
41
+ - by default the API key is not saved; use set key-env or --key-env
42
+ - set key VALUE --save stores the key in the local config file
43
+ - config lives in .anthmorph/config.env
44
+ USAGE
45
+ }
46
+
47
+ ensure_config() {
48
+ if [ ! -f "$CONFIG_FILE" ]; then
49
+ echo "missing config: run 'scripts/anthmorphctl init ...' first" >&2
50
+ exit 1
51
+ fi
52
+ }
53
+
54
+ load_config() {
55
+ ensure_config
56
+ # shellcheck disable=SC1090
57
+ . "$CONFIG_FILE"
58
+ }
59
+
60
+ save_config() {
61
+ tmp_file=$CONFIG_FILE.tmp
62
+ {
63
+ printf 'BACKEND_PROFILE=%s\n' "${BACKEND_PROFILE:-}"
64
+ printf 'BACKEND_URL=%s\n' "${BACKEND_URL:-}"
65
+ printf 'MODEL=%s\n' "${MODEL:-}"
66
+ printf 'REASONING_MODEL=%s\n' "${REASONING_MODEL:-}"
67
+ printf 'PORT=%s\n' "${PORT:-3000}"
68
+ printf 'INGRESS_API_KEY=%s\n' "${INGRESS_API_KEY:-}"
69
+ printf 'ALLOWED_ORIGINS=%s\n' "${ALLOWED_ORIGINS:-}"
70
+ printf 'API_KEY_ENV=%s\n' "${API_KEY_ENV:-}"
71
+ printf 'API_KEY=%s\n' "${API_KEY:-}"
72
+ printf 'BIN_PATH=%s\n' "${BIN_PATH:-$BIN_PATH_DEFAULT}"
73
+ } > "$tmp_file"
74
+ mv "$tmp_file" "$CONFIG_FILE"
75
+ }
76
+
77
+ is_running() {
78
+ [ -f "$PID_FILE" ] || return 1
79
+ pid=$(cat "$PID_FILE" 2>/dev/null || true)
80
+ [ -n "$pid" ] || return 1
81
+ kill -0 "$pid" 2>/dev/null
82
+ }
83
+
84
+ resolve_api_key() {
85
+ if [ -n "${API_KEY:-}" ]; then
86
+ printf '%s' "$API_KEY"
87
+ return 0
88
+ fi
89
+
90
+ if [ -n "${API_KEY_ENV:-}" ]; then
91
+ eval "resolved=\${$API_KEY_ENV-}"
92
+ if [ -n "${resolved:-}" ]; then
93
+ printf '%s' "$resolved"
94
+ return 0
95
+ fi
96
+ fi
97
+
98
+ return 1
99
+ }
100
+
101
+ health_on_port() {
102
+ curl -fsS "http://127.0.0.1:$1/health" 2>/dev/null
103
+ }
104
+
105
+ join_allow_origin() {
106
+ value=$1
107
+ if [ -z "${ALLOWED_ORIGINS:-}" ]; then
108
+ ALLOWED_ORIGINS=$value
109
+ else
110
+ ALLOWED_ORIGINS=${ALLOWED_ORIGINS},$value
111
+ fi
112
+ }
113
+
114
+ parse_common_init_args() {
115
+ SAVE_KEY=0
116
+ while [ "$#" -gt 0 ]; do
117
+ case "$1" in
118
+ --port)
119
+ PORT=$2
120
+ shift 2
121
+ ;;
122
+ --backend-url)
123
+ BACKEND_URL=$2
124
+ shift 2
125
+ ;;
126
+ --model)
127
+ MODEL=$2
128
+ shift 2
129
+ ;;
130
+ --reasoning-model)
131
+ REASONING_MODEL=$2
132
+ shift 2
133
+ ;;
134
+ --ingress-api-key)
135
+ INGRESS_API_KEY=$2
136
+ shift 2
137
+ ;;
138
+ --allow-origin)
139
+ join_allow_origin "$2"
140
+ shift 2
141
+ ;;
142
+ --key-env)
143
+ API_KEY_ENV=$2
144
+ shift 2
145
+ ;;
146
+ --api-key)
147
+ API_KEY=$2
148
+ shift 2
149
+ ;;
150
+ --save-key)
151
+ SAVE_KEY=1
152
+ shift
153
+ ;;
154
+ *)
155
+ echo "unknown option: $1" >&2
156
+ exit 1
157
+ ;;
158
+ esac
159
+ done
160
+
161
+ if [ "$SAVE_KEY" -ne 1 ]; then
162
+ API_KEY=
163
+ fi
164
+ }
165
+
166
+ init_cmd() {
167
+ preset=${1:-}
168
+ [ -n "$preset" ] || {
169
+ echo "missing preset" >&2
170
+ usage
171
+ exit 1
172
+ }
173
+ shift
174
+
175
+ BACKEND_PROFILE=
176
+ BACKEND_URL=
177
+ MODEL=
178
+ REASONING_MODEL=
179
+ PORT=3000
180
+ INGRESS_API_KEY=
181
+ ALLOWED_ORIGINS=
182
+ API_KEY_ENV=
183
+ API_KEY=
184
+ BIN_PATH=$BIN_PATH_DEFAULT
185
+
186
+ case "$preset" in
187
+ chutes)
188
+ BACKEND_PROFILE=chutes
189
+ BACKEND_URL=https://llm.chutes.ai/v1
190
+ MODEL=Qwen/Qwen3-Coder-Next-TEE
191
+ API_KEY_ENV=CHUTES_API_KEY
192
+ ;;
193
+ minimax)
194
+ BACKEND_PROFILE=openai-generic
195
+ BACKEND_URL=${MINIMAX_BASE_URL:-https://api.minimax.io/v1}
196
+ MODEL=${MINIMAX_MODEL:-MiniMax-M2.5}
197
+ API_KEY_ENV=MINIMAX_API_KEY
198
+ ;;
199
+ openai)
200
+ BACKEND_PROFILE=openai-generic
201
+ ;;
202
+ *)
203
+ echo "unsupported preset: $preset" >&2
204
+ exit 1
205
+ ;;
206
+ esac
207
+
208
+ parse_common_init_args "$@"
209
+
210
+ if [ -z "$BACKEND_URL" ] || [ -z "$MODEL" ] || [ -z "$BACKEND_PROFILE" ]; then
211
+ echo "backend profile, backend url and model are required" >&2
212
+ exit 1
213
+ fi
214
+
215
+ if [ -n "$API_KEY" ] && [ -n "$API_KEY_ENV" ]; then
216
+ API_KEY_ENV=
217
+ fi
218
+
219
+ save_config
220
+ echo "saved config to $CONFIG_FILE"
221
+ }
222
+
223
+ set_cmd() {
224
+ ensure_config
225
+ load_config
226
+ field=${1:-}
227
+ [ -n "$field" ] || {
228
+ echo "missing field" >&2
229
+ exit 1
230
+ }
231
+ shift
232
+
233
+ case "$field" in
234
+ key-env)
235
+ API_KEY_ENV=${1:-}
236
+ [ -n "$API_KEY_ENV" ] || {
237
+ echo "missing env name" >&2
238
+ exit 1
239
+ }
240
+ API_KEY=
241
+ ;;
242
+ key)
243
+ value=${1:-}
244
+ [ -n "$value" ] || {
245
+ echo "missing key value" >&2
246
+ exit 1
247
+ }
248
+ shift
249
+ save_flag=${1:-}
250
+ if [ "$save_flag" = "--save" ]; then
251
+ API_KEY=$value
252
+ API_KEY_ENV=
253
+ else
254
+ echo "use: scripts/anthmorphctl set key VALUE --save" >&2
255
+ exit 1
256
+ fi
257
+ ;;
258
+ *)
259
+ echo "unsupported field: $field" >&2
260
+ exit 1
261
+ ;;
262
+ esac
263
+
264
+ save_config
265
+ echo "updated $field"
266
+ }
267
+
268
+ unset_cmd() {
269
+ ensure_config
270
+ load_config
271
+ field=${1:-}
272
+ case "$field" in
273
+ key)
274
+ API_KEY=
275
+ API_KEY_ENV=
276
+ ;;
277
+ *)
278
+ echo "unsupported unset field: $field" >&2
279
+ exit 1
280
+ ;;
281
+ esac
282
+ save_config
283
+ echo "cleared $field"
284
+ }
285
+
286
+ start_cmd() {
287
+ load_config
288
+
289
+ if is_running; then
290
+ echo "already running (pid $(cat "$PID_FILE"))"
291
+ return 0
292
+ fi
293
+
294
+ if health_on_port "$PORT" >/dev/null; then
295
+ echo "port $PORT is already serving an AnthMorph-compatible health endpoint" >&2
296
+ echo "use a different port with 'scripts/anthmorphctl init ... --port PORT'" >&2
297
+ exit 1
298
+ fi
299
+
300
+ if [ ! -x "$BIN_PATH" ]; then
301
+ (cd "$ROOT_DIR" && cargo build --release --quiet)
302
+ fi
303
+
304
+ api_key=$(resolve_api_key || true)
305
+ if [ -z "$api_key" ]; then
306
+ echo "missing API key: configure API_KEY_ENV or save an API key" >&2
307
+ exit 1
308
+ fi
309
+
310
+ set -- "$BIN_PATH" \
311
+ --port "$PORT" \
312
+ --backend-profile "$BACKEND_PROFILE" \
313
+ --backend-url "$BACKEND_URL" \
314
+ --model "$MODEL" \
315
+ --api-key "$api_key"
316
+
317
+ if [ -n "${REASONING_MODEL:-}" ]; then
318
+ set -- "$@" --reasoning-model "$REASONING_MODEL"
319
+ fi
320
+ if [ -n "${INGRESS_API_KEY:-}" ]; then
321
+ set -- "$@" --ingress-api-key "$INGRESS_API_KEY"
322
+ fi
323
+
324
+ old_ifs=${IFS:- }
325
+ IFS=,
326
+ for origin in ${ALLOWED_ORIGINS:-}; do
327
+ [ -n "$origin" ] || continue
328
+ set -- "$@" --allow-origin "$origin"
329
+ done
330
+ IFS=$old_ifs
331
+
332
+ nohup "$@" >>"$LOG_FILE" 2>&1 &
333
+ pid=$!
334
+ echo "$pid" > "$PID_FILE"
335
+ sleep 1
336
+
337
+ if kill -0 "$pid" 2>/dev/null; then
338
+ echo "started anthmorph (pid $pid)"
339
+ else
340
+ echo "failed to start; see $LOG_FILE" >&2
341
+ rm -f "$PID_FILE"
342
+ exit 1
343
+ fi
344
+ }
345
+
346
+ stop_cmd() {
347
+ if ! is_running; then
348
+ rm -f "$PID_FILE"
349
+ echo "not running"
350
+ return 0
351
+ fi
352
+
353
+ pid=$(cat "$PID_FILE")
354
+ kill "$pid" 2>/dev/null || true
355
+ for _ in 1 2 3 4 5 6 7 8 9 10; do
356
+ if ! kill -0 "$pid" 2>/dev/null; then
357
+ rm -f "$PID_FILE"
358
+ echo "stopped anthmorph"
359
+ return 0
360
+ fi
361
+ sleep 1
362
+ done
363
+ kill -9 "$pid" 2>/dev/null || true
364
+ rm -f "$PID_FILE"
365
+ echo "killed anthmorph"
366
+ }
367
+
368
+ status_cmd() {
369
+ load_config
370
+ if is_running; then
371
+ pid=$(cat "$PID_FILE")
372
+ health=$(health_on_port "$PORT" || true)
373
+ echo "status: running"
374
+ echo "pid: $pid"
375
+ echo "health: ${health:-unreachable}"
376
+ else
377
+ health=$(health_on_port "$PORT" || true)
378
+ if [ -n "$health" ]; then
379
+ echo "status: external-process-on-port"
380
+ echo "health: $health"
381
+ else
382
+ echo "status: stopped"
383
+ fi
384
+ fi
385
+ echo "profile: ${BACKEND_PROFILE:-}"
386
+ echo "backend_url: ${BACKEND_URL:-}"
387
+ echo "model: ${MODEL:-}"
388
+ echo "port: ${PORT:-}"
389
+ if [ -n "${API_KEY_ENV:-}" ]; then
390
+ key_source=env:$API_KEY_ENV
391
+ elif [ -n "${API_KEY:-}" ]; then
392
+ key_source=saved
393
+ else
394
+ key_source=missing
395
+ fi
396
+ echo "key_source: $key_source"
397
+ echo "log_file: $LOG_FILE"
398
+ echo "config_file: $CONFIG_FILE"
399
+ }
400
+
401
+ logs_cmd() {
402
+ if [ -f "$LOG_FILE" ]; then
403
+ tail -n 80 "$LOG_FILE"
404
+ else
405
+ echo "log file not found: $LOG_FILE" >&2
406
+ exit 1
407
+ fi
408
+ }
409
+
410
+ print_config_cmd() {
411
+ ensure_config
412
+ sed '/^API_KEY=/s/=.*/=<hidden>/;/^INGRESS_API_KEY=/s/=.*/=<hidden>/' "$CONFIG_FILE"
413
+ }
414
+
415
+ cmd=${1:-help}
416
+ case "$cmd" in
417
+ init)
418
+ shift
419
+ init_cmd "$@"
420
+ ;;
421
+ set)
422
+ shift
423
+ set_cmd "$@"
424
+ ;;
425
+ unset)
426
+ shift
427
+ unset_cmd "$@"
428
+ ;;
429
+ start)
430
+ start_cmd
431
+ ;;
432
+ stop)
433
+ stop_cmd
434
+ ;;
435
+ restart)
436
+ stop_cmd
437
+ start_cmd
438
+ ;;
439
+ status)
440
+ status_cmd
441
+ ;;
442
+ logs)
443
+ logs_cmd
444
+ ;;
445
+ print-config)
446
+ print_config_cmd
447
+ ;;
448
+ help|-h|--help)
449
+ usage
450
+ ;;
451
+ *)
452
+ echo "unknown command: $cmd" >&2
453
+ usage >&2
454
+ exit 1
455
+ ;;
456
+ esac
@@ -0,0 +1,23 @@
1
+ const { spawnSync } = require("node:child_process");
2
+ const path = require("node:path");
3
+
4
+ const root = path.resolve(__dirname, "..");
5
+
6
+ function hasCargo() {
7
+ const probe = spawnSync("cargo", ["--version"], { stdio: "ignore" });
8
+ return probe.status === 0;
9
+ }
10
+
11
+ if (!hasCargo()) {
12
+ console.log("[anthmorph] cargo not found; skipping Rust build");
13
+ process.exit(0);
14
+ }
15
+
16
+ const build = spawnSync("cargo", ["build", "--release", "--quiet"], {
17
+ cwd: root,
18
+ stdio: "inherit",
19
+ });
20
+
21
+ if (build.status !== 0) {
22
+ process.exit(build.status || 1);
23
+ }
@@ -0,0 +1,72 @@
1
+ #!/bin/sh
2
+ set -eu
3
+
4
+ BACKEND=${1:?usage: ./scripts/smoke_test.sh <chutes|alibaba|minimax>}
5
+ PORT=${SMOKE_PORT:-3101}
6
+ PROMPT=${SMOKE_PROMPT:-Reply with exactly: anthmorph-smoke-ok}
7
+ LOG_FILE=${TMPDIR:-/tmp}/anthmorph-smoke-${BACKEND}-${PORT}.log
8
+ RESPONSE_FILE=${TMPDIR:-/tmp}/anthmorph-smoke-${BACKEND}-${PORT}.json
9
+
10
+ case "$BACKEND" in
11
+ chutes)
12
+ PROFILE=chutes
13
+ BACKEND_URL=${CHUTES_BASE_URL:-https://llm.chutes.ai/v1}
14
+ MODEL=${CHUTES_MODEL:-Qwen/Qwen3-Coder-Next-TEE}
15
+ API_KEY=${CHUTES_API_KEY:?CHUTES_API_KEY is required}
16
+ ;;
17
+ alibaba)
18
+ PROFILE=openai-generic
19
+ BACKEND_URL=${ALIBABA_BASE_URL:-https://coding-intl.dashscope.aliyuncs.com/v1}
20
+ MODEL=${ALIBABA_MODEL:-qwen3-coder-plus}
21
+ API_KEY=${ALIBABA_CODE_API_KEY:?ALIBABA_CODE_API_KEY is required}
22
+ ;;
23
+ minimax)
24
+ PROFILE=openai-generic
25
+ BACKEND_URL=${MINIMAX_BASE_URL:-https://api.minimax.io/v1}
26
+ MODEL=${MINIMAX_MODEL:-MiniMax-M2.5}
27
+ API_KEY=${MINIMAX_API_KEY:?MINIMAX_API_KEY is required}
28
+ ;;
29
+ *)
30
+ echo "unsupported backend: $BACKEND" >&2
31
+ exit 2
32
+ ;;
33
+ esac
34
+
35
+ cleanup() {
36
+ if [ -n "${SERVER_PID:-}" ] && kill -0 "$SERVER_PID" 2>/dev/null; then
37
+ kill "$SERVER_PID" 2>/dev/null || true
38
+ wait "$SERVER_PID" 2>/dev/null || true
39
+ fi
40
+ }
41
+ trap cleanup EXIT INT TERM
42
+
43
+ if [ ! -x ./target/debug/anthmorph ]; then
44
+ cargo build --quiet
45
+ fi
46
+
47
+ ./target/debug/anthmorph --port "$PORT" --backend-profile "$PROFILE" --backend-url "$BACKEND_URL" --model "$MODEL" --api-key "$API_KEY" >"$LOG_FILE" 2>&1 &
48
+ SERVER_PID=$!
49
+
50
+ READY=0
51
+ for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30; do
52
+ if curl -fsS "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then
53
+ READY=1
54
+ break
55
+ fi
56
+ sleep 1
57
+ done
58
+
59
+ if [ "$READY" -ne 1 ]; then
60
+ echo "server did not become ready; log follows:" >&2
61
+ cat "$LOG_FILE" >&2
62
+ exit 1
63
+ fi
64
+
65
+ PAYLOAD=$(cat <<EOF
66
+ {"model":"claude-sonnet-4","max_tokens":128,"messages":[{"role":"user","content":"$PROMPT"}]}
67
+ EOF
68
+ )
69
+
70
+ curl -fsS "http://127.0.0.1:$PORT/v1/messages" -H 'content-type: application/json' -d "$PAYLOAD" >"$RESPONSE_FILE"
71
+
72
+ cat "$RESPONSE_FILE"
package/src/config.rs ADDED
@@ -0,0 +1,39 @@
1
+ use clap::ValueEnum;
2
+ use std::str::FromStr;
3
+
4
+ #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
5
+ pub enum BackendProfile {
6
+ Chutes,
7
+ OpenaiGeneric,
8
+ }
9
+
10
+ impl BackendProfile {
11
+ pub fn as_str(self) -> &'static str {
12
+ match self {
13
+ BackendProfile::Chutes => "chutes",
14
+ BackendProfile::OpenaiGeneric => "openai_generic",
15
+ }
16
+ }
17
+
18
+ pub fn supports_top_k(self) -> bool {
19
+ matches!(self, BackendProfile::Chutes)
20
+ }
21
+
22
+ pub fn supports_reasoning(self) -> bool {
23
+ matches!(self, BackendProfile::Chutes)
24
+ }
25
+ }
26
+
27
+ impl FromStr for BackendProfile {
28
+ type Err = String;
29
+
30
+ fn from_str(value: &str) -> Result<Self, Self::Err> {
31
+ match value.trim().to_ascii_lowercase().as_str() {
32
+ "chutes" => Ok(Self::Chutes),
33
+ "openai_generic" | "openai-generic" | "openai" | "generic" => Ok(Self::OpenaiGeneric),
34
+ other => Err(format!(
35
+ "unsupported backend profile '{other}', expected 'chutes' or 'openai_generic'"
36
+ )),
37
+ }
38
+ }
39
+ }
package/src/error.rs ADDED
@@ -0,0 +1,54 @@
1
+ use thiserror::Error;
2
+
3
+ #[derive(Error, Debug)]
4
+ pub enum ProxyError {
5
+ #[error("HTTP error: {0}")]
6
+ Http(#[from] reqwest::Error),
7
+
8
+ #[error("Upstream error: {0}")]
9
+ Upstream(String),
10
+
11
+ #[error("Transform error: {0}")]
12
+ Transform(String),
13
+
14
+ #[error("Serialization error: {0}")]
15
+ Serialization(#[from] serde_json::Error),
16
+
17
+ #[error("IO error: {0}")]
18
+ Io(#[from] std::io::Error),
19
+ }
20
+
21
+ impl axum::response::IntoResponse for ProxyError {
22
+ fn into_response(self) -> axum::response::Response {
23
+ use axum::http::StatusCode;
24
+
25
+ let (status, error_type) = match &self {
26
+ ProxyError::Http(_) => (StatusCode::BAD_GATEWAY, "api_error"),
27
+ ProxyError::Upstream(msg) => {
28
+ if msg.contains("401") || msg.contains("403") {
29
+ (StatusCode::UNAUTHORIZED, "authentication_error")
30
+ } else if msg.contains("429") {
31
+ (StatusCode::TOO_MANY_REQUESTS, "rate_limit_error")
32
+ } else if msg.contains("404") {
33
+ (StatusCode::NOT_FOUND, "not_found_error")
34
+ } else {
35
+ (StatusCode::BAD_GATEWAY, "api_error")
36
+ }
37
+ }
38
+ ProxyError::Transform(_) => (StatusCode::BAD_REQUEST, "invalid_request_error"),
39
+ ProxyError::Serialization(_) => (StatusCode::BAD_REQUEST, "invalid_request_error"),
40
+ ProxyError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, "api_error"),
41
+ };
42
+
43
+ let payload = serde_json::json!({
44
+ "type": "error",
45
+ "error": {
46
+ "type": error_type,
47
+ "message": self.to_string()
48
+ }
49
+ });
50
+ (status, axum::Json(payload)).into_response()
51
+ }
52
+ }
53
+
54
+ pub type ProxyResult<T> = Result<T, ProxyError>;