@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/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 req
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
- content.push(anthropic::ResponseContent::Thinking {
292
- thinking: reasoning.clone(),
293
- });
441
+ if profile.supports_reasoning() {
442
+ content.push(anthropic::ResponseContent::Thinking {
443
+ thinking: reasoning.clone(),
444
+ });
445
+ }
294
446
  }
295
- if let Some(text) = choice.message.content.as_ref().filter(|s| !s.is_empty()) {
296
- content.push(anthropic::ResponseContent::Text { text: text.clone() });
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(req, "model", BackendProfile::OpenaiGeneric).unwrap();
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 = anthropic_to_openai(req, "model", BackendProfile::Chutes).unwrap();
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).unwrap_err();
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 = anthropic_to_openai(req, "model", BackendProfile::Chutes).unwrap();
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).unwrap();
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(resp, "fallback", BackendProfile::OpenaiGeneric).unwrap_err();
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
  }
@@ -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"