Skip to content

Commit d96f212

Browse files
authored
fix(aigateway): accept inline system messages (#7)
* fix(aigateway): accept inline system messages Amp-Thread-ID: https://ampcode.com/threads/T-01a01df2-9278-74b9-8a9a-6adf5e63cd9b * fix(deps): update h2 to patched version Amp-Thread-ID: https://ampcode.com/threads/T-01a01ef6-dcd3-776b-a477-082d417a84fc Co-authored-by: Chenyu Lu <chenyu@arcbox.dev> * fix(aigw-anthropic): normalize inline system messages Amp-Thread-ID: https://ampcode.com/threads/T-01a01ef6-dcd3-776b-a477-082d417a84fc Co-authored-by: Chenyu Lu <chenyu@arcbox.dev>
1 parent 4e1d944 commit d96f212

5 files changed

Lines changed: 311 additions & 26 deletions

File tree

‎Cargo.lock‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎providers/aigw-anthropic/src/client.rs‎

Lines changed: 187 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ use crate::rate_limit::{ApiResponse, RateLimitInfo};
1414
use crate::streaming::parse_sse_stream;
1515
use crate::transport::Transport;
1616
use crate::types::{
17-
ApiErrorResponse, CountTokensRequest, CountTokensResponse, MessagesRequest, MessagesResponse,
18-
ModelListResponse, StreamEvent,
17+
ApiErrorResponse, ContentBlock, CountTokensRequest, CountTokensResponse, Message,
18+
MessageContent, MessagesRequest, MessagesResponse, ModelListResponse, Role, StreamEvent,
19+
SystemPrompt, TextBlock, TypedContentBlock,
1920
};
2021

2122
/// Anthropic API client.
@@ -66,7 +67,9 @@ impl Client {
6667
&self,
6768
req: &MessagesRequest,
6869
) -> Result<ApiResponse<MessagesResponse>, Error> {
69-
self.post_json("/v1/messages", req).await
70+
let mut req = req.clone();
71+
normalize_inline_system_messages(&mut req.messages, &mut req.system)?;
72+
self.post_json("/v1/messages", &req).await
7073
}
7174

7275
/// Send a streaming messages request, returning a stream of events.
@@ -77,7 +80,9 @@ impl Client {
7780
&self,
7881
req: &MessagesRequest,
7982
) -> Result<ApiResponse<impl Stream<Item = Result<StreamEvent, Error>> + Send>, Error> {
80-
let response = self.send_post("/v1/messages", req).await?;
83+
let mut req = req.clone();
84+
normalize_inline_system_messages(&mut req.messages, &mut req.system)?;
85+
let response = self.send_post("/v1/messages", &req).await?;
8186

8287
let rate_limit = RateLimitInfo::from_headers(response.headers());
8388

@@ -98,7 +103,9 @@ impl Client {
98103
&self,
99104
req: &CountTokensRequest,
100105
) -> Result<ApiResponse<CountTokensResponse>, Error> {
101-
self.post_json("/v1/messages/count_tokens", req).await
106+
let mut req = req.clone();
107+
normalize_inline_system_messages(&mut req.messages, &mut req.system)?;
108+
self.post_json("/v1/messages/count_tokens", &req).await
102109
}
103110

104111
/// List available models.
@@ -219,6 +226,71 @@ impl Client {
219226
}
220227
}
221228

229+
fn normalize_inline_system_messages(
230+
messages: &mut Vec<Message>,
231+
system: &mut Option<SystemPrompt>,
232+
) -> Result<(), Error> {
233+
let mut conversation = Vec::with_capacity(messages.len());
234+
let mut inline_system = Vec::new();
235+
let mut found_inline_system = false;
236+
237+
for message in std::mem::take(messages) {
238+
if message.role != Role::System {
239+
conversation.push(message);
240+
continue;
241+
}
242+
243+
found_inline_system = true;
244+
match message.content {
245+
MessageContent::Text(text) => inline_system.push(TextBlock {
246+
r#type: "text".to_owned(),
247+
text,
248+
cache_control: None,
249+
}),
250+
MessageContent::Blocks(blocks) => {
251+
for block in blocks {
252+
match block {
253+
ContentBlock::Typed(TypedContentBlock::Text {
254+
text,
255+
cache_control,
256+
}) => inline_system.push(TextBlock {
257+
r#type: "text".to_owned(),
258+
text,
259+
cache_control,
260+
}),
261+
_ => {
262+
return Err(Error::Json(
263+
<serde_json::Error as serde::ser::Error>::custom(
264+
"inline system messages may only contain text blocks",
265+
),
266+
));
267+
}
268+
}
269+
}
270+
}
271+
}
272+
}
273+
274+
*messages = conversation;
275+
if !found_inline_system {
276+
return Ok(());
277+
}
278+
279+
let mut system_blocks = match system.take() {
280+
Some(SystemPrompt::Text(text)) => vec![TextBlock {
281+
r#type: "text".to_owned(),
282+
text,
283+
cache_control: None,
284+
}],
285+
Some(SystemPrompt::Blocks(blocks)) => blocks,
286+
None => Vec::new(),
287+
};
288+
system_blocks.extend(inline_system);
289+
*system = Some(SystemPrompt::Blocks(system_blocks));
290+
291+
Ok(())
292+
}
293+
222294
#[cfg(test)]
223295
mod tests {
224296
use futures_util::StreamExt;
@@ -229,7 +301,10 @@ mod tests {
229301

230302
use super::*;
231303
use crate::transport::TransportConfig;
232-
use crate::types::{Message, MessageContent, MessagesRequest, Role};
304+
use crate::types::{
305+
ContentBlock, Message, MessageContent, MessagesRequest, Role, SystemPrompt,
306+
TypedContentBlock,
307+
};
233308

234309
fn transport(base_url: String) -> Transport {
235310
Transport::new(TransportConfig {
@@ -310,6 +385,11 @@ mod tests {
310385
String::from_utf8(buf).unwrap()
311386
}
312387

388+
fn request_body_json(request: &str) -> serde_json::Value {
389+
let (_, body) = request.split_once("\r\n\r\n").unwrap();
390+
serde_json::from_str(body).unwrap()
391+
}
392+
313393
fn http_response(status: &str, content_type: &str, body: &str) -> String {
314394
format!(
315395
"HTTP/1.1 {status}\r\ncontent-type: {content_type}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
@@ -348,16 +428,64 @@ mod tests {
348428
spawn_server(http_response("200 OK", "application/json", body)).await;
349429
let client = Client::new(transport(base_url)).unwrap();
350430

351-
let resp = client.messages(&messages_request()).await.unwrap();
431+
let mut req = messages_request();
432+
req.system = Some(SystemPrompt::Text("top-level instructions".into()));
433+
req.messages.insert(
434+
0,
435+
Message {
436+
role: Role::System,
437+
content: MessageContent::Blocks(vec![ContentBlock::Typed(
438+
TypedContentBlock::Text {
439+
text: "block instructions".into(),
440+
cache_control: None,
441+
},
442+
)]),
443+
},
444+
);
445+
req.messages.insert(
446+
0,
447+
Message {
448+
role: Role::System,
449+
content: MessageContent::Text("inline instructions".into()),
450+
},
451+
);
452+
453+
let resp = client.messages(&req).await.unwrap();
352454
assert_eq!(resp.body.id, "msg_01XFDUDYJgAACzvnptvVoYEL");
353455
assert_eq!(resp.body.usage.input_tokens, 10);
354456

355-
let raw = req_rx.await.unwrap().to_lowercase();
356-
assert!(raw.contains("post /v1/messages http/1.1"));
357-
assert!(raw.contains("x-api-key: sk-ant-test-key"));
358-
assert!(raw.contains("anthropic-version: 2023-06-01"));
359-
assert!(raw.contains("content-type: application/json"));
360-
assert!(raw.contains("claude-sonnet-4-20250514"));
457+
let raw = req_rx.await.unwrap();
458+
let lowercase = raw.to_lowercase();
459+
assert!(lowercase.contains("post /v1/messages http/1.1"));
460+
assert!(lowercase.contains("x-api-key: sk-ant-test-key"));
461+
assert!(lowercase.contains("anthropic-version: 2023-06-01"));
462+
assert!(lowercase.contains("content-type: application/json"));
463+
assert!(lowercase.contains("claude-sonnet-4-20250514"));
464+
465+
let sent = request_body_json(&raw);
466+
assert_eq!(
467+
sent["system"],
468+
serde_json::json!([
469+
{ "type": "text", "text": "top-level instructions" },
470+
{ "type": "text", "text": "inline instructions" },
471+
{ "type": "text", "text": "block instructions" }
472+
])
473+
);
474+
assert_eq!(
475+
sent["messages"],
476+
serde_json::json!([{ "role": "user", "content": "Hello" }])
477+
);
478+
}
479+
480+
#[test]
481+
fn inline_system_role_cannot_be_serialized_directly() {
482+
let error = serde_json::to_value(Message {
483+
role: Role::System,
484+
content: MessageContent::Text("instructions".into()),
485+
})
486+
.unwrap_err();
487+
488+
assert!(error.to_string().contains("inbound-only"));
361489
}
362490

363491
#[tokio::test]
@@ -415,17 +543,39 @@ mod tests {
415543

416544
let req = crate::types::CountTokensRequest::builder()
417545
.model("claude-sonnet-4-20250514")
418-
.messages(vec![Message {
419-
role: Role::User,
420-
content: MessageContent::Text("Hello".into()),
421-
}])
546+
.system(SystemPrompt::Text("top-level instructions".into()))
547+
.messages(vec![
548+
Message {
549+
role: Role::System,
550+
content: MessageContent::Text("inline instructions".into()),
551+
},
552+
Message {
553+
role: Role::User,
554+
content: MessageContent::Text("Hello".into()),
555+
},
556+
])
422557
.build();
423558

424559
let resp = client.count_tokens(&req).await.unwrap();
425560
assert_eq!(resp.body.input_tokens, 42);
426561

427-
let raw = req_rx.await.unwrap().to_lowercase();
428-
assert!(raw.contains("post /v1/messages/count_tokens http/1.1"));
562+
let raw = req_rx.await.unwrap();
563+
assert!(
564+
raw.to_lowercase()
565+
.contains("post /v1/messages/count_tokens http/1.1")
566+
);
567+
let sent = request_body_json(&raw);
568+
assert_eq!(
569+
sent["system"],
570+
serde_json::json!([
571+
{ "type": "text", "text": "top-level instructions" },
572+
{ "type": "text", "text": "inline instructions" }
573+
])
574+
);
575+
assert_eq!(
576+
sent["messages"],
577+
serde_json::json!([{ "role": "user", "content": "Hello" }])
578+
);
429579
}
430580

431581
#[tokio::test]
@@ -521,12 +671,19 @@ mod tests {
521671
"event: message_stop\n",
522672
"data: {\"type\":\"message_stop\"}\n\n",
523673
);
524-
let (base_url, _) =
674+
let (base_url, req_rx) =
525675
spawn_server(http_response("200 OK", "text/event-stream", events)).await;
526676
let client = Client::new(transport(base_url)).unwrap();
527677

528678
let mut req = messages_request();
529679
req.stream = Some(true);
680+
req.messages.insert(
681+
0,
682+
Message {
683+
role: Role::System,
684+
content: MessageContent::Text("streaming instructions".into()),
685+
},
686+
);
530687

531688
let resp = client.messages_stream(&req).await.unwrap();
532689
assert_eq!(resp.rate_limit.requests_limit, None);
@@ -555,6 +712,16 @@ mod tests {
555712
}
556713
other => panic!("expected ContentBlockDelta, got: {other:?}"),
557714
}
715+
716+
let sent = request_body_json(&req_rx.await.unwrap());
717+
assert_eq!(
718+
sent["system"],
719+
serde_json::json!([{ "type": "text", "text": "streaming instructions" }])
720+
);
721+
assert_eq!(
722+
sent["messages"],
723+
serde_json::json!([{ "role": "user", "content": "Hello" }])
724+
);
558725
}
559726

560727
#[tokio::test]

‎providers/aigw-anthropic/src/translate/native_bridge.rs‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,18 @@ pub fn messages_request_to_canonical(req: MessagesRequest) -> Result<ChatRequest
122122
});
123123
}
124124
}
125+
AnthropicRole::System => {
126+
if let Some(content) = user_blocks_to_content(blocks) {
127+
messages.push(Message {
128+
role: Role::System,
129+
content: Some(content),
130+
name: None,
131+
tool_call_id: None,
132+
tool_calls: None,
133+
extra: Default::default(),
134+
});
135+
}
136+
}
125137
},
126138
}
127139
}
@@ -172,6 +184,7 @@ const fn canonical_role(role: AnthropicRole) -> Role {
172184
match role {
173185
AnthropicRole::User => Role::User,
174186
AnthropicRole::Assistant => Role::Assistant,
187+
AnthropicRole::System => Role::System,
175188
}
176189
}
177190

@@ -1199,6 +1212,42 @@ mod tests {
11991212
assert_eq!(canonical.messages[1].role, Role::User);
12001213
}
12011214

1215+
#[test]
1216+
fn request_deserializes_inline_system_messages() {
1217+
let json = r#"{
1218+
"model": "claude-sonnet-4-20250514",
1219+
"max_tokens": 1024,
1220+
"system": "top-level instructions",
1221+
"messages": [
1222+
{ "role": "system", "content": "inline instructions" },
1223+
{
1224+
"role": "system",
1225+
"content": [{ "type": "text", "text": "block instructions" }]
1226+
},
1227+
{ "role": "user", "content": "hi" }
1228+
]
1229+
}"#;
1230+
let req: MessagesRequest = serde_json::from_str(json).unwrap();
1231+
let canonical = messages_request_to_canonical(req).unwrap();
1232+
1233+
assert_eq!(
1234+
canonical
1235+
.messages
1236+
.iter()
1237+
.map(|message| message.role.clone())
1238+
.collect::<Vec<_>>(),
1239+
[Role::System, Role::System, Role::System, Role::User]
1240+
);
1241+
assert!(matches!(
1242+
&canonical.messages[1].content,
1243+
Some(MessageContent::Text(text)) if text == "inline instructions"
1244+
));
1245+
assert!(matches!(
1246+
&canonical.messages[2].content,
1247+
Some(MessageContent::Text(text)) if text == "block instructions"
1248+
));
1249+
}
1250+
12021251
// ── chat_response_to_messages ────────────────────────────────────────
12031252

12041253
fn assistant_choice(msg: Message, finish: CanonicalFinishReason) -> Choice {

0 commit comments

Comments
 (0)