@mmmbuto/anthmorph 0.1.2 → 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/prebuilt/anthmorph +0 -0
- 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 +46 -1
- package/src/proxy.rs +432 -47
- package/src/transform.rs +364 -42
- 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()
|
|
@@ -61,32 +163,28 @@ pub fn anthropic_to_openai(
|
|
|
61
163
|
let mut openai_messages = Vec::new();
|
|
62
164
|
|
|
63
165
|
if let Some(system) = req.system {
|
|
64
|
-
match system {
|
|
65
|
-
anthropic::SystemPrompt::Single(text) =>
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
tool_call_id: None,
|
|
82
|
-
});
|
|
83
|
-
}
|
|
84
|
-
}
|
|
166
|
+
let system_text = match system {
|
|
167
|
+
anthropic::SystemPrompt::Single(text) => text,
|
|
168
|
+
anthropic::SystemPrompt::Multiple(messages) => messages
|
|
169
|
+
.into_iter()
|
|
170
|
+
.map(|msg| msg.text)
|
|
171
|
+
.collect::<Vec<_>>()
|
|
172
|
+
.join("\n\n"),
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
if !system_text.is_empty() {
|
|
176
|
+
openai_messages.push(openai::Message {
|
|
177
|
+
role: "system".to_string(),
|
|
178
|
+
content: Some(openai::MessageContent::Text(system_text)),
|
|
179
|
+
name: None,
|
|
180
|
+
tool_calls: None,
|
|
181
|
+
tool_call_id: None,
|
|
182
|
+
});
|
|
85
183
|
}
|
|
86
184
|
}
|
|
87
185
|
|
|
88
186
|
for msg in req.messages {
|
|
89
|
-
openai_messages.extend(convert_message(msg, profile)?);
|
|
187
|
+
openai_messages.extend(convert_message(msg, profile, compat_mode)?);
|
|
90
188
|
}
|
|
91
189
|
|
|
92
190
|
let tools = req.tools.and_then(|tools| {
|
|
@@ -135,6 +233,7 @@ pub fn anthropic_to_openai(
|
|
|
135
233
|
fn convert_message(
|
|
136
234
|
msg: anthropic::Message,
|
|
137
235
|
profile: BackendProfile,
|
|
236
|
+
compat_mode: CompatMode,
|
|
138
237
|
) -> ProxyResult<Vec<openai::Message>> {
|
|
139
238
|
let mut result = Vec::new();
|
|
140
239
|
|
|
@@ -163,6 +262,11 @@ fn convert_message(
|
|
|
163
262
|
image_url: openai::ImageUrl { url: data_url },
|
|
164
263
|
});
|
|
165
264
|
}
|
|
265
|
+
anthropic::ContentBlock::Document { source } => {
|
|
266
|
+
current_content_parts.push(openai::ContentPart::Text {
|
|
267
|
+
data: compat_document_marker(&source),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
166
270
|
anthropic::ContentBlock::ToolUse { id, name, input } => {
|
|
167
271
|
tool_calls.push(openai::ToolCall {
|
|
168
272
|
id,
|
|
@@ -200,12 +304,50 @@ fn convert_message(
|
|
|
200
304
|
});
|
|
201
305
|
}
|
|
202
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
|
+
}
|
|
203
313
|
return Err(ProxyError::Transform(format!(
|
|
204
314
|
"assistant thinking blocks are not supported by backend profile {} (received {} chars)",
|
|
205
315
|
profile.as_str(),
|
|
206
316
|
thinking.len()
|
|
207
317
|
)));
|
|
208
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
|
+
}
|
|
209
351
|
anthropic::ContentBlock::Other => {}
|
|
210
352
|
}
|
|
211
353
|
}
|
|
@@ -272,6 +414,7 @@ pub fn openai_to_anthropic(
|
|
|
272
414
|
resp: openai::OpenAIResponse,
|
|
273
415
|
fallback_model: &str,
|
|
274
416
|
profile: BackendProfile,
|
|
417
|
+
compat_mode: CompatMode,
|
|
275
418
|
) -> ProxyResult<anthropic::AnthropicResponse> {
|
|
276
419
|
let choice = resp
|
|
277
420
|
.choices
|
|
@@ -280,24 +423,40 @@ pub fn openai_to_anthropic(
|
|
|
280
423
|
|
|
281
424
|
let mut content = Vec::new();
|
|
282
425
|
|
|
426
|
+
let raw_content = choice.message.content.clone().unwrap_or_default();
|
|
427
|
+
let (embedded_reasoning, visible_text) = strip_think_tags(&raw_content);
|
|
428
|
+
|
|
283
429
|
if let Some(reasoning) = choice
|
|
284
430
|
.message
|
|
285
431
|
.reasoning_content
|
|
286
432
|
.as_ref()
|
|
287
433
|
.filter(|s| !s.is_empty())
|
|
288
434
|
{
|
|
289
|
-
if !profile.supports_reasoning() {
|
|
435
|
+
if !profile.supports_reasoning() && compat_mode.is_strict() {
|
|
290
436
|
return Err(ProxyError::Transform(format!(
|
|
291
437
|
"backend profile {} returned reasoning content that cannot be represented safely",
|
|
292
438
|
profile.as_str()
|
|
293
439
|
)));
|
|
294
440
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
441
|
+
if profile.supports_reasoning() {
|
|
442
|
+
content.push(anthropic::ResponseContent::Thinking {
|
|
443
|
+
thinking: reasoning.clone(),
|
|
444
|
+
});
|
|
445
|
+
}
|
|
298
446
|
}
|
|
299
|
-
|
|
300
|
-
|
|
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 });
|
|
301
460
|
}
|
|
302
461
|
|
|
303
462
|
if let Some(tool_calls) = &choice.message.tool_calls {
|
|
@@ -354,7 +513,7 @@ pub fn map_stop_reason(finish_reason: Option<&str>) -> Option<String> {
|
|
|
354
513
|
mod tests {
|
|
355
514
|
use super::*;
|
|
356
515
|
use crate::models::anthropic::{
|
|
357
|
-
AnthropicRequest, ContentBlock, Message, MessageContent, SystemPrompt, Tool,
|
|
516
|
+
AnthropicRequest, ContentBlock, Message, MessageContent, SystemMessage, SystemPrompt, Tool,
|
|
358
517
|
};
|
|
359
518
|
|
|
360
519
|
fn sample_request() -> AnthropicRequest {
|
|
@@ -376,6 +535,8 @@ mod tests {
|
|
|
376
535
|
input_schema: json!({"type":"object","properties":{"city":{"type":"string","format":"city"}}}),
|
|
377
536
|
tool_type: None,
|
|
378
537
|
}]),
|
|
538
|
+
thinking: None,
|
|
539
|
+
output_config: None,
|
|
379
540
|
stop_sequences: Some(vec!["STOP".to_string()]),
|
|
380
541
|
extra: serde_json::Map::new(),
|
|
381
542
|
}
|
|
@@ -384,14 +545,21 @@ mod tests {
|
|
|
384
545
|
#[test]
|
|
385
546
|
fn strips_top_k_for_generic_profile() {
|
|
386
547
|
let req = sample_request();
|
|
387
|
-
let transformed = anthropic_to_openai(
|
|
548
|
+
let transformed = anthropic_to_openai(
|
|
549
|
+
req,
|
|
550
|
+
"model",
|
|
551
|
+
BackendProfile::OpenaiGeneric,
|
|
552
|
+
CompatMode::Strict,
|
|
553
|
+
)
|
|
554
|
+
.unwrap();
|
|
388
555
|
assert_eq!(transformed.top_k, None);
|
|
389
556
|
}
|
|
390
557
|
|
|
391
558
|
#[test]
|
|
392
559
|
fn keeps_top_k_for_chutes_profile() {
|
|
393
560
|
let req = sample_request();
|
|
394
|
-
let transformed =
|
|
561
|
+
let transformed =
|
|
562
|
+
anthropic_to_openai(req, "model", BackendProfile::Chutes, CompatMode::Strict).unwrap();
|
|
395
563
|
assert_eq!(transformed.top_k, Some(40));
|
|
396
564
|
}
|
|
397
565
|
|
|
@@ -405,10 +573,49 @@ mod tests {
|
|
|
405
573
|
}]),
|
|
406
574
|
}];
|
|
407
575
|
|
|
408
|
-
let err = anthropic_to_openai(req, "model", BackendProfile::Chutes)
|
|
576
|
+
let err = anthropic_to_openai(req, "model", BackendProfile::Chutes, CompatMode::Strict)
|
|
577
|
+
.unwrap_err();
|
|
409
578
|
assert!(err.to_string().contains("thinking blocks"));
|
|
410
579
|
}
|
|
411
580
|
|
|
581
|
+
#[test]
|
|
582
|
+
fn collapses_multiple_system_prompts_into_single_openai_message() {
|
|
583
|
+
let req = AnthropicRequest {
|
|
584
|
+
model: "claude".to_string(),
|
|
585
|
+
messages: vec![Message {
|
|
586
|
+
role: "user".to_string(),
|
|
587
|
+
content: MessageContent::Text("hi".to_string()),
|
|
588
|
+
}],
|
|
589
|
+
system: Some(SystemPrompt::Multiple(vec![
|
|
590
|
+
SystemMessage {
|
|
591
|
+
text: "one".to_string(),
|
|
592
|
+
},
|
|
593
|
+
SystemMessage {
|
|
594
|
+
text: "two".to_string(),
|
|
595
|
+
},
|
|
596
|
+
])),
|
|
597
|
+
max_tokens: 64,
|
|
598
|
+
temperature: None,
|
|
599
|
+
top_p: None,
|
|
600
|
+
top_k: None,
|
|
601
|
+
stop_sequences: None,
|
|
602
|
+
stream: None,
|
|
603
|
+
tools: None,
|
|
604
|
+
thinking: None,
|
|
605
|
+
output_config: None,
|
|
606
|
+
extra: Default::default(),
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
let out =
|
|
610
|
+
anthropic_to_openai(req, "model", BackendProfile::Chutes, CompatMode::Strict).unwrap();
|
|
611
|
+
assert_eq!(out.messages[0].role, "system");
|
|
612
|
+
match out.messages[0].content.as_ref().unwrap() {
|
|
613
|
+
openai::MessageContent::Text(text) => assert_eq!(text, "one\n\ntwo"),
|
|
614
|
+
other => panic!("expected text system prompt, got {other:?}"),
|
|
615
|
+
}
|
|
616
|
+
assert_eq!(out.messages[1].role, "user");
|
|
617
|
+
}
|
|
618
|
+
|
|
412
619
|
#[test]
|
|
413
620
|
fn maps_reasoning_to_thinking_block_for_chutes() {
|
|
414
621
|
let resp = openai::OpenAIResponse {
|
|
@@ -428,7 +635,8 @@ mod tests {
|
|
|
428
635
|
},
|
|
429
636
|
};
|
|
430
637
|
|
|
431
|
-
let out = openai_to_anthropic(resp, "fallback", BackendProfile::Chutes)
|
|
638
|
+
let out = openai_to_anthropic(resp, "fallback", BackendProfile::Chutes, CompatMode::Strict)
|
|
639
|
+
.unwrap();
|
|
432
640
|
match &out.content[0] {
|
|
433
641
|
anthropic::ResponseContent::Thinking { thinking } => assert_eq!(thinking, "chain"),
|
|
434
642
|
other => panic!("expected thinking block, got {other:?}"),
|
|
@@ -454,7 +662,121 @@ mod tests {
|
|
|
454
662
|
},
|
|
455
663
|
};
|
|
456
664
|
|
|
457
|
-
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();
|
|
458
672
|
assert!(err.to_string().contains("reasoning content"));
|
|
459
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
|
+
}
|
|
460
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-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"
|