@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/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()
@@ -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
- openai_messages.push(openai::Message {
67
- role: "system".to_string(),
68
- content: Some(openai::MessageContent::Text(text)),
69
- name: None,
70
- tool_calls: None,
71
- tool_call_id: None,
72
- });
73
- }
74
- anthropic::SystemPrompt::Multiple(messages) => {
75
- for msg in messages {
76
- openai_messages.push(openai::Message {
77
- role: "system".to_string(),
78
- content: Some(openai::MessageContent::Text(msg.text)),
79
- name: None,
80
- tool_calls: None,
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
- content.push(anthropic::ResponseContent::Thinking {
296
- thinking: reasoning.clone(),
297
- });
441
+ if profile.supports_reasoning() {
442
+ content.push(anthropic::ResponseContent::Thinking {
443
+ thinking: reasoning.clone(),
444
+ });
445
+ }
298
446
  }
299
- if let Some(text) = choice.message.content.as_ref().filter(|s| !s.is_empty()) {
300
- 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 });
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(req, "model", BackendProfile::OpenaiGeneric).unwrap();
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 = anthropic_to_openai(req, "model", BackendProfile::Chutes).unwrap();
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).unwrap_err();
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).unwrap();
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(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();
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
  }
@@ -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"