@mmmbuto/anthmorph 0.1.3 → 0.1.4
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 +34 -0
- package/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/README.md +48 -123
- package/bin/anthmorph +0 -0
- package/docs/CLAUDE_CODE_SETUP.md +78 -0
- package/docs/PACKAGING.md +59 -0
- package/docs/RELEASE.md +82 -0
- package/package.json +16 -4
- package/scripts/anthmorphctl +150 -8
- package/scripts/docker_build_linux.sh +11 -0
- package/scripts/docker_npm_dry_run.sh +25 -0
- package/scripts/docker_release_checks.sh +18 -0
- package/scripts/docker_rust_test.sh +35 -0
- package/scripts/docker_secret_scan.sh +11 -0
- package/scripts/postinstall.js +10 -1
- package/scripts/test_claude_code_patterns_real.sh +150 -0
- package/src/config.rs +33 -0
- package/src/main.rs +24 -5
- package/src/models/anthropic.rs +40 -0
- package/src/proxy.rs +374 -49
- package/src/transform.rs +312 -21
- package/scripts/smoke_test.sh +0 -72
- package/tests/real_backends.rs +0 -213
package/src/transform.rs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
use crate::config::BackendProfile;
|
|
1
|
+
use crate::config::{BackendProfile, CompatMode};
|
|
2
2
|
use crate::error::{ProxyError, ProxyResult};
|
|
3
3
|
use crate::models::{anthropic, openai};
|
|
4
4
|
use serde_json::{json, Value};
|
|
@@ -34,24 +34,126 @@ fn extract_tool_choice(
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
fn has_thinking(req: &anthropic::AnthropicRequest) -> bool {
|
|
38
|
+
if let Some(thinking) = &req.thinking {
|
|
39
|
+
return !thinking.thinking_type.eq_ignore_ascii_case("disabled");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
req.extra
|
|
43
|
+
.get("thinking")
|
|
44
|
+
.and_then(|v| v.get("type"))
|
|
45
|
+
.and_then(Value::as_str)
|
|
46
|
+
.map(|value| !value.eq_ignore_ascii_case("disabled"))
|
|
47
|
+
.is_some()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
fn flatten_json_text(value: &Value) -> Vec<String> {
|
|
51
|
+
match value {
|
|
52
|
+
Value::String(text) => vec![text.clone()],
|
|
53
|
+
Value::Array(items) => items.iter().flat_map(flatten_json_text).collect(),
|
|
54
|
+
Value::Object(obj) => {
|
|
55
|
+
let mut parts = Vec::new();
|
|
56
|
+
if let Some(text) = obj.get("text").and_then(Value::as_str) {
|
|
57
|
+
parts.push(text.to_string());
|
|
58
|
+
}
|
|
59
|
+
if let Some(query) = obj.get("query").and_then(Value::as_str) {
|
|
60
|
+
parts.push(format!("query: {query}"));
|
|
61
|
+
}
|
|
62
|
+
if let Some(url) = obj.get("url").and_then(Value::as_str) {
|
|
63
|
+
parts.push(format!("url: {url}"));
|
|
64
|
+
}
|
|
65
|
+
if let Some(file_id) = obj.get("file_id").and_then(Value::as_str) {
|
|
66
|
+
parts.push(format!("file_id: {file_id}"));
|
|
67
|
+
}
|
|
68
|
+
if let Some(content) = obj.get("content") {
|
|
69
|
+
parts.extend(flatten_json_text(content));
|
|
70
|
+
}
|
|
71
|
+
parts
|
|
72
|
+
}
|
|
73
|
+
_ => Vec::new(),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
fn compat_document_marker(source: &Value) -> String {
|
|
78
|
+
let source_type = source
|
|
79
|
+
.get("type")
|
|
80
|
+
.and_then(Value::as_str)
|
|
81
|
+
.unwrap_or("unknown");
|
|
82
|
+
|
|
83
|
+
match source_type {
|
|
84
|
+
"base64" => {
|
|
85
|
+
let media_type = source
|
|
86
|
+
.get("media_type")
|
|
87
|
+
.and_then(Value::as_str)
|
|
88
|
+
.unwrap_or("application/octet-stream");
|
|
89
|
+
format!("[document attachment omitted: {media_type}]")
|
|
90
|
+
}
|
|
91
|
+
"file" => {
|
|
92
|
+
let file_id = source
|
|
93
|
+
.get("file_id")
|
|
94
|
+
.and_then(Value::as_str)
|
|
95
|
+
.unwrap_or("unknown");
|
|
96
|
+
format!("[document file reference: {file_id}]")
|
|
97
|
+
}
|
|
98
|
+
"url" => {
|
|
99
|
+
let url = source
|
|
100
|
+
.get("url")
|
|
101
|
+
.and_then(Value::as_str)
|
|
102
|
+
.unwrap_or("unknown");
|
|
103
|
+
format!("[document url: {url}]")
|
|
104
|
+
}
|
|
105
|
+
_ => "[document omitted]".to_string(),
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
fn strip_think_tags(text: &str) -> (Vec<String>, String) {
|
|
110
|
+
let mut reasoning = Vec::new();
|
|
111
|
+
let mut visible = String::new();
|
|
112
|
+
let mut rest = text;
|
|
113
|
+
|
|
114
|
+
while let Some(start) = rest.find("<think>") {
|
|
115
|
+
visible.push_str(&rest[..start]);
|
|
116
|
+
let after_open = &rest[start + "<think>".len()..];
|
|
117
|
+
if let Some(end) = after_open.find("</think>") {
|
|
118
|
+
let think_text = after_open[..end].trim();
|
|
119
|
+
if !think_text.is_empty() {
|
|
120
|
+
reasoning.push(think_text.to_string());
|
|
121
|
+
}
|
|
122
|
+
rest = &after_open[end + "</think>".len()..];
|
|
123
|
+
} else {
|
|
124
|
+
let think_text = after_open.trim();
|
|
125
|
+
if !think_text.is_empty() {
|
|
126
|
+
reasoning.push(think_text.to_string());
|
|
127
|
+
}
|
|
128
|
+
rest = "";
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
visible.push_str(rest);
|
|
134
|
+
(reasoning, visible)
|
|
135
|
+
}
|
|
136
|
+
|
|
37
137
|
pub fn anthropic_to_openai(
|
|
38
138
|
req: anthropic::AnthropicRequest,
|
|
39
139
|
model: &str,
|
|
40
140
|
profile: BackendProfile,
|
|
141
|
+
compat_mode: CompatMode,
|
|
41
142
|
) -> ProxyResult<openai::OpenAIRequest> {
|
|
143
|
+
let thinking_requested = has_thinking(&req);
|
|
144
|
+
let _thinking_budget = req.thinking.as_ref().and_then(|cfg| cfg.budget_tokens);
|
|
145
|
+
let _requested_effort = req
|
|
146
|
+
.output_config
|
|
147
|
+
.as_ref()
|
|
148
|
+
.and_then(|cfg| cfg.effort.as_deref());
|
|
149
|
+
|
|
42
150
|
if req.max_tokens == 0 {
|
|
43
151
|
return Err(ProxyError::Transform(
|
|
44
152
|
"max_tokens must be greater than zero".to_string(),
|
|
45
153
|
));
|
|
46
154
|
}
|
|
47
155
|
|
|
48
|
-
if
|
|
49
|
-
.extra
|
|
50
|
-
.get("thinking")
|
|
51
|
-
.and_then(|v| v.get("type"))
|
|
52
|
-
.is_some()
|
|
53
|
-
&& !profile.supports_reasoning()
|
|
54
|
-
{
|
|
156
|
+
if thinking_requested && !profile.supports_reasoning() && compat_mode.is_strict() {
|
|
55
157
|
return Err(ProxyError::Transform(format!(
|
|
56
158
|
"thinking is not supported by backend profile {}",
|
|
57
159
|
profile.as_str()
|
|
@@ -82,7 +184,7 @@ pub fn anthropic_to_openai(
|
|
|
82
184
|
}
|
|
83
185
|
|
|
84
186
|
for msg in req.messages {
|
|
85
|
-
openai_messages.extend(convert_message(msg, profile)?);
|
|
187
|
+
openai_messages.extend(convert_message(msg, profile, compat_mode)?);
|
|
86
188
|
}
|
|
87
189
|
|
|
88
190
|
let tools = req.tools.and_then(|tools| {
|
|
@@ -131,6 +233,7 @@ pub fn anthropic_to_openai(
|
|
|
131
233
|
fn convert_message(
|
|
132
234
|
msg: anthropic::Message,
|
|
133
235
|
profile: BackendProfile,
|
|
236
|
+
compat_mode: CompatMode,
|
|
134
237
|
) -> ProxyResult<Vec<openai::Message>> {
|
|
135
238
|
let mut result = Vec::new();
|
|
136
239
|
|
|
@@ -159,6 +262,11 @@ fn convert_message(
|
|
|
159
262
|
image_url: openai::ImageUrl { url: data_url },
|
|
160
263
|
});
|
|
161
264
|
}
|
|
265
|
+
anthropic::ContentBlock::Document { source } => {
|
|
266
|
+
current_content_parts.push(openai::ContentPart::Text {
|
|
267
|
+
data: compat_document_marker(&source),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
162
270
|
anthropic::ContentBlock::ToolUse { id, name, input } => {
|
|
163
271
|
tool_calls.push(openai::ToolCall {
|
|
164
272
|
id,
|
|
@@ -196,12 +304,50 @@ fn convert_message(
|
|
|
196
304
|
});
|
|
197
305
|
}
|
|
198
306
|
anthropic::ContentBlock::Thinking { thinking } => {
|
|
307
|
+
if !compat_mode.is_strict() {
|
|
308
|
+
current_content_parts.push(openai::ContentPart::Text {
|
|
309
|
+
data: format!("[assistant thinking omitted]\n{thinking}"),
|
|
310
|
+
});
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
199
313
|
return Err(ProxyError::Transform(format!(
|
|
200
314
|
"assistant thinking blocks are not supported by backend profile {} (received {} chars)",
|
|
201
315
|
profile.as_str(),
|
|
202
316
|
thinking.len()
|
|
203
317
|
)));
|
|
204
318
|
}
|
|
319
|
+
anthropic::ContentBlock::ServerToolUse { name, input } => {
|
|
320
|
+
let tool_name = name.unwrap_or_else(|| "server_tool".to_string());
|
|
321
|
+
let rendered_input = input
|
|
322
|
+
.map(|value| serde_json::to_string(&value).unwrap_or_default())
|
|
323
|
+
.filter(|value| !value.is_empty())
|
|
324
|
+
.unwrap_or_else(|| "{}".to_string());
|
|
325
|
+
current_content_parts.push(openai::ContentPart::Text {
|
|
326
|
+
data: format!(
|
|
327
|
+
"[server tool use omitted: {} {}]",
|
|
328
|
+
tool_name, rendered_input
|
|
329
|
+
),
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
anthropic::ContentBlock::SearchResult { query, content } => {
|
|
333
|
+
let mut parts = Vec::new();
|
|
334
|
+
if let Some(query) = query {
|
|
335
|
+
parts.push(format!("query: {query}"));
|
|
336
|
+
}
|
|
337
|
+
for value in content {
|
|
338
|
+
parts.extend(flatten_json_text(&value));
|
|
339
|
+
}
|
|
340
|
+
let rendered = parts
|
|
341
|
+
.into_iter()
|
|
342
|
+
.filter(|part| !part.trim().is_empty())
|
|
343
|
+
.collect::<Vec<_>>()
|
|
344
|
+
.join("\n");
|
|
345
|
+
if !rendered.is_empty() {
|
|
346
|
+
current_content_parts.push(openai::ContentPart::Text {
|
|
347
|
+
data: format!("[search result]\n{rendered}"),
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
205
351
|
anthropic::ContentBlock::Other => {}
|
|
206
352
|
}
|
|
207
353
|
}
|
|
@@ -268,6 +414,7 @@ pub fn openai_to_anthropic(
|
|
|
268
414
|
resp: openai::OpenAIResponse,
|
|
269
415
|
fallback_model: &str,
|
|
270
416
|
profile: BackendProfile,
|
|
417
|
+
compat_mode: CompatMode,
|
|
271
418
|
) -> ProxyResult<anthropic::AnthropicResponse> {
|
|
272
419
|
let choice = resp
|
|
273
420
|
.choices
|
|
@@ -276,24 +423,40 @@ pub fn openai_to_anthropic(
|
|
|
276
423
|
|
|
277
424
|
let mut content = Vec::new();
|
|
278
425
|
|
|
426
|
+
let raw_content = choice.message.content.clone().unwrap_or_default();
|
|
427
|
+
let (embedded_reasoning, visible_text) = strip_think_tags(&raw_content);
|
|
428
|
+
|
|
279
429
|
if let Some(reasoning) = choice
|
|
280
430
|
.message
|
|
281
431
|
.reasoning_content
|
|
282
432
|
.as_ref()
|
|
283
433
|
.filter(|s| !s.is_empty())
|
|
284
434
|
{
|
|
285
|
-
if !profile.supports_reasoning() {
|
|
435
|
+
if !profile.supports_reasoning() && compat_mode.is_strict() {
|
|
286
436
|
return Err(ProxyError::Transform(format!(
|
|
287
437
|
"backend profile {} returned reasoning content that cannot be represented safely",
|
|
288
438
|
profile.as_str()
|
|
289
439
|
)));
|
|
290
440
|
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
441
|
+
if profile.supports_reasoning() {
|
|
442
|
+
content.push(anthropic::ResponseContent::Thinking {
|
|
443
|
+
thinking: reasoning.clone(),
|
|
444
|
+
});
|
|
445
|
+
}
|
|
294
446
|
}
|
|
295
|
-
|
|
296
|
-
|
|
447
|
+
|
|
448
|
+
if choice.message.reasoning_content.is_none() && !embedded_reasoning.is_empty() {
|
|
449
|
+
if profile.supports_reasoning() {
|
|
450
|
+
for reasoning in embedded_reasoning {
|
|
451
|
+
content.push(anthropic::ResponseContent::Thinking {
|
|
452
|
+
thinking: reasoning,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if !visible_text.trim().is_empty() {
|
|
459
|
+
content.push(anthropic::ResponseContent::Text { text: visible_text });
|
|
297
460
|
}
|
|
298
461
|
|
|
299
462
|
if let Some(tool_calls) = &choice.message.tool_calls {
|
|
@@ -372,6 +535,8 @@ mod tests {
|
|
|
372
535
|
input_schema: json!({"type":"object","properties":{"city":{"type":"string","format":"city"}}}),
|
|
373
536
|
tool_type: None,
|
|
374
537
|
}]),
|
|
538
|
+
thinking: None,
|
|
539
|
+
output_config: None,
|
|
375
540
|
stop_sequences: Some(vec!["STOP".to_string()]),
|
|
376
541
|
extra: serde_json::Map::new(),
|
|
377
542
|
}
|
|
@@ -380,14 +545,21 @@ mod tests {
|
|
|
380
545
|
#[test]
|
|
381
546
|
fn strips_top_k_for_generic_profile() {
|
|
382
547
|
let req = sample_request();
|
|
383
|
-
let transformed = anthropic_to_openai(
|
|
548
|
+
let transformed = anthropic_to_openai(
|
|
549
|
+
req,
|
|
550
|
+
"model",
|
|
551
|
+
BackendProfile::OpenaiGeneric,
|
|
552
|
+
CompatMode::Strict,
|
|
553
|
+
)
|
|
554
|
+
.unwrap();
|
|
384
555
|
assert_eq!(transformed.top_k, None);
|
|
385
556
|
}
|
|
386
557
|
|
|
387
558
|
#[test]
|
|
388
559
|
fn keeps_top_k_for_chutes_profile() {
|
|
389
560
|
let req = sample_request();
|
|
390
|
-
let transformed =
|
|
561
|
+
let transformed =
|
|
562
|
+
anthropic_to_openai(req, "model", BackendProfile::Chutes, CompatMode::Strict).unwrap();
|
|
391
563
|
assert_eq!(transformed.top_k, Some(40));
|
|
392
564
|
}
|
|
393
565
|
|
|
@@ -401,7 +573,8 @@ mod tests {
|
|
|
401
573
|
}]),
|
|
402
574
|
}];
|
|
403
575
|
|
|
404
|
-
let err = anthropic_to_openai(req, "model", BackendProfile::Chutes)
|
|
576
|
+
let err = anthropic_to_openai(req, "model", BackendProfile::Chutes, CompatMode::Strict)
|
|
577
|
+
.unwrap_err();
|
|
405
578
|
assert!(err.to_string().contains("thinking blocks"));
|
|
406
579
|
}
|
|
407
580
|
|
|
@@ -428,10 +601,13 @@ mod tests {
|
|
|
428
601
|
stop_sequences: None,
|
|
429
602
|
stream: None,
|
|
430
603
|
tools: None,
|
|
604
|
+
thinking: None,
|
|
605
|
+
output_config: None,
|
|
431
606
|
extra: Default::default(),
|
|
432
607
|
};
|
|
433
608
|
|
|
434
|
-
let out =
|
|
609
|
+
let out =
|
|
610
|
+
anthropic_to_openai(req, "model", BackendProfile::Chutes, CompatMode::Strict).unwrap();
|
|
435
611
|
assert_eq!(out.messages[0].role, "system");
|
|
436
612
|
match out.messages[0].content.as_ref().unwrap() {
|
|
437
613
|
openai::MessageContent::Text(text) => assert_eq!(text, "one\n\ntwo"),
|
|
@@ -459,7 +635,8 @@ mod tests {
|
|
|
459
635
|
},
|
|
460
636
|
};
|
|
461
637
|
|
|
462
|
-
let out = openai_to_anthropic(resp, "fallback", BackendProfile::Chutes)
|
|
638
|
+
let out = openai_to_anthropic(resp, "fallback", BackendProfile::Chutes, CompatMode::Strict)
|
|
639
|
+
.unwrap();
|
|
463
640
|
match &out.content[0] {
|
|
464
641
|
anthropic::ResponseContent::Thinking { thinking } => assert_eq!(thinking, "chain"),
|
|
465
642
|
other => panic!("expected thinking block, got {other:?}"),
|
|
@@ -485,7 +662,121 @@ mod tests {
|
|
|
485
662
|
},
|
|
486
663
|
};
|
|
487
664
|
|
|
488
|
-
let err = openai_to_anthropic(
|
|
665
|
+
let err = openai_to_anthropic(
|
|
666
|
+
resp,
|
|
667
|
+
"fallback",
|
|
668
|
+
BackendProfile::OpenaiGeneric,
|
|
669
|
+
CompatMode::Strict,
|
|
670
|
+
)
|
|
671
|
+
.unwrap_err();
|
|
489
672
|
assert!(err.to_string().contains("reasoning content"));
|
|
490
673
|
}
|
|
674
|
+
|
|
675
|
+
#[test]
|
|
676
|
+
fn compat_mode_downgrades_assistant_thinking_history() {
|
|
677
|
+
let mut req = sample_request();
|
|
678
|
+
req.messages = vec![Message {
|
|
679
|
+
role: "assistant".to_string(),
|
|
680
|
+
content: MessageContent::Blocks(vec![ContentBlock::Thinking {
|
|
681
|
+
thinking: "hidden".to_string(),
|
|
682
|
+
}]),
|
|
683
|
+
}];
|
|
684
|
+
|
|
685
|
+
let out = anthropic_to_openai(
|
|
686
|
+
req,
|
|
687
|
+
"model",
|
|
688
|
+
BackendProfile::OpenaiGeneric,
|
|
689
|
+
CompatMode::Compat,
|
|
690
|
+
)
|
|
691
|
+
.unwrap();
|
|
692
|
+
|
|
693
|
+
let assistant = out
|
|
694
|
+
.messages
|
|
695
|
+
.iter()
|
|
696
|
+
.find(|message| message.role == "assistant")
|
|
697
|
+
.expect("assistant message");
|
|
698
|
+
|
|
699
|
+
match assistant.content.as_ref() {
|
|
700
|
+
Some(openai::MessageContent::Text(_)) | Some(openai::MessageContent::Parts(_)) => {}
|
|
701
|
+
other => panic!("expected downgraded assistant content, got {other:?}"),
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
#[test]
|
|
706
|
+
fn compat_mode_degrades_documents_and_search_results_into_text() {
|
|
707
|
+
let req = AnthropicRequest {
|
|
708
|
+
model: "claude".to_string(),
|
|
709
|
+
messages: vec![Message {
|
|
710
|
+
role: "user".to_string(),
|
|
711
|
+
content: MessageContent::Blocks(vec![
|
|
712
|
+
ContentBlock::Document {
|
|
713
|
+
source: json!({
|
|
714
|
+
"type": "url",
|
|
715
|
+
"url": "https://example.com/file.pdf"
|
|
716
|
+
}),
|
|
717
|
+
},
|
|
718
|
+
ContentBlock::SearchResult {
|
|
719
|
+
query: Some("weather".to_string()),
|
|
720
|
+
content: vec![json!({"type": "text", "text": "Sunny and 68F"})],
|
|
721
|
+
},
|
|
722
|
+
]),
|
|
723
|
+
}],
|
|
724
|
+
system: None,
|
|
725
|
+
stream: Some(true),
|
|
726
|
+
max_tokens: 64,
|
|
727
|
+
temperature: None,
|
|
728
|
+
top_p: None,
|
|
729
|
+
top_k: None,
|
|
730
|
+
tools: None,
|
|
731
|
+
thinking: None,
|
|
732
|
+
output_config: None,
|
|
733
|
+
stop_sequences: None,
|
|
734
|
+
extra: Default::default(),
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
let out = anthropic_to_openai(
|
|
738
|
+
req,
|
|
739
|
+
"model",
|
|
740
|
+
BackendProfile::OpenaiGeneric,
|
|
741
|
+
CompatMode::Compat,
|
|
742
|
+
)
|
|
743
|
+
.unwrap();
|
|
744
|
+
let rendered = serde_json::to_value(&out.messages[0]).unwrap().to_string();
|
|
745
|
+
assert!(rendered.contains("document url"));
|
|
746
|
+
assert!(rendered.contains("Sunny and 68F"));
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
#[test]
|
|
750
|
+
fn generic_compat_strips_embedded_think_tags() {
|
|
751
|
+
let resp = openai::OpenAIResponse {
|
|
752
|
+
id: Some("id1".to_string()),
|
|
753
|
+
model: Some("backend".to_string()),
|
|
754
|
+
choices: vec![openai::Choice {
|
|
755
|
+
message: openai::ChoiceMessage {
|
|
756
|
+
content: Some("<think>hidden chain</think>visible answer".to_string()),
|
|
757
|
+
tool_calls: None,
|
|
758
|
+
reasoning_content: None,
|
|
759
|
+
},
|
|
760
|
+
finish_reason: Some("stop".to_string()),
|
|
761
|
+
}],
|
|
762
|
+
usage: openai::Usage {
|
|
763
|
+
prompt_tokens: 10,
|
|
764
|
+
completion_tokens: 5,
|
|
765
|
+
},
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
let out = openai_to_anthropic(
|
|
769
|
+
resp,
|
|
770
|
+
"fallback",
|
|
771
|
+
BackendProfile::OpenaiGeneric,
|
|
772
|
+
CompatMode::Compat,
|
|
773
|
+
)
|
|
774
|
+
.unwrap();
|
|
775
|
+
|
|
776
|
+
assert_eq!(out.content.len(), 1);
|
|
777
|
+
match &out.content[0] {
|
|
778
|
+
anthropic::ResponseContent::Text { text } => assert_eq!(text, "visible answer"),
|
|
779
|
+
other => panic!("expected visible text only, got {other:?}"),
|
|
780
|
+
}
|
|
781
|
+
}
|
|
491
782
|
}
|
package/scripts/smoke_test.sh
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
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.5-397B-A17B-TEE,zai-org/GLM-5-TEE,deepseek-ai/DeepSeek-V3.2-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"
|