Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions bindings/typescript/src/generated/UniversalParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,12 @@ conversation_reference: Array<ConversationReference> | null,
* **Providers:** OpenAI, Anthropic
*/
service_tier: string | null,
/**
* Stable cache-bucketing key for prompt caching.
*
* **Providers:** OpenAI Chat Completions, OpenAI Responses
*/
prompt_cache_key: string | null,
/**
* Stream the response as server-sent events.
*
Expand Down
11 changes: 10 additions & 1 deletion bindings/typescript/src/generated/anthropic/InputContentBlock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,18 @@ import type { Source } from "./Source";
*
* A content block that represents a file to be uploaded to the container
* Files uploaded via this block will be available in the container's input directory.
*
* System instructions that appear mid-conversation.
*
* Use this block to provide or update system-level instructions at a specific
* point in the conversation, rather than only via the top-level `system` parameter.
*/
export type InputContentBlock = {
/**
* Create a cache control breakpoint at this content block.
*/
cache_control: CacheControlEphemeral | null, citations: Citations | null, text: string | null, type: InputContentBlockType, source: Source | null, context: string | null, title: string | null, content: InputContentBlockContent | null, signature: string | null, thinking: string | null, data: string | null, caller: Caller | null, id: string | null, input: unknown, name: string | null, is_error: boolean | null, tool_use_id: string | null, file_id: string | null, };
cache_control: CacheControlEphemeral | null, citations: Citations | null, text: string | null, type: InputContentBlockType, source: Source | null, context: string | null, title: string | null,
/**
* System instruction text blocks.
*/
content: InputContentBlockContent | null, signature: string | null, thinking: string | null, data: string | null, caller: Caller | null, id: string | null, input: unknown, name: string | null, is_error: boolean | null, tool_use_id: string | null, file_id: string | null, };
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.

export type InputContentBlockType = "bash_code_execution_tool_result" | "code_execution_tool_result" | "container_upload" | "document" | "image" | "redacted_thinking" | "search_result" | "server_tool_use" | "text" | "text_editor_code_execution_tool_result" | "thinking" | "tool_result" | "tool_search_tool_result" | "tool_use" | "web_fetch_tool_result" | "web_search_tool_result";
export type InputContentBlockType = "bash_code_execution_tool_result" | "code_execution_tool_result" | "container_upload" | "document" | "image" | "mid_conv_system" | "redacted_thinking" | "search_result" | "server_tool_use" | "text" | "text_editor_code_execution_tool_result" | "thinking" | "tool_result" | "tool_search_tool_result" | "tool_use" | "web_fetch_tool_result" | "web_search_tool_result";
2 changes: 1 addition & 1 deletion bindings/typescript/src/generated/anthropic/MessageRole.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.

export type MessageRole = "assistant" | "user";
export type MessageRole = "assistant" | "system" | "user";
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.

export type ToolResultErrorCode = "execution_time_exceeded" | "file_not_found" | "invalid_tool_input" | "max_uses_exceeded" | "output_file_too_large" | "query_too_long" | "request_too_large" | "too_many_requests" | "unavailable" | "unsupported_content_type" | "url_not_accessible" | "url_not_allowed" | "url_too_long";
export type ToolResultErrorCode = "execution_time_exceeded" | "file_not_found" | "invalid_tool_input" | "max_uses_exceeded" | "output_file_too_large" | "query_too_long" | "request_too_large" | "too_many_requests" | "unavailable" | "unsupported_content_type" | "url_not_accessible" | "url_not_allowed" | "url_not_in_prior_context" | "url_too_long";
64 changes: 64 additions & 0 deletions crates/coverage-report/src/requests_expected_differences.json
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,70 @@
{ "pattern": "params.top_p", "reason": "OpenAI reasoning models do not support top_p, so it is stripped for request compatibility" }
]
},
{
"testCase": "promptCacheKeyParam",
"source": "*",
"target": "Anthropic",
"fields": [
{ "pattern": "params.prompt_cache_key", "reason": "prompt_cache_key is OpenAI-specific and Anthropic does not support an equivalent cache bucketing key" }
]
},
{
"testCase": "promptCacheKeyParam",
"source": "*",
"target": "Bedrock Anthropic",
"fields": [
{ "pattern": "params.prompt_cache_key", "reason": "prompt_cache_key is OpenAI-specific and Bedrock Anthropic does not support an equivalent cache bucketing key" }
]
},
{
"testCase": "promptCacheKeyParam",
"source": "*",
"target": "Vertex Anthropic",
"fields": [
{ "pattern": "params.prompt_cache_key", "reason": "prompt_cache_key is OpenAI-specific and Vertex Anthropic does not support an equivalent cache bucketing key" }
]
},
{
"testCase": "promptCacheKeyParam",
"source": "*",
"target": "Google",
"fields": [
{ "pattern": "params.prompt_cache_key", "reason": "prompt_cache_key is OpenAI-specific and Google does not support an equivalent cache bucketing key" }
]
},
{
"testCase": "openaiPromptCacheKeyParam",
"source": "*",
"target": "Anthropic",
"fields": [
{ "pattern": "params.prompt_cache_key", "reason": "prompt_cache_key is OpenAI-specific and Anthropic does not support an equivalent cache bucketing key" }
]
},
{
"testCase": "openaiPromptCacheKeyParam",
"source": "*",
"target": "Bedrock Anthropic",
"fields": [
{ "pattern": "params.prompt_cache_key", "reason": "prompt_cache_key is OpenAI-specific and Bedrock Anthropic does not support an equivalent cache bucketing key" }
]
},
{
"testCase": "openaiPromptCacheKeyParam",
"source": "*",
"target": "Vertex Anthropic",
"fields": [
{ "pattern": "params.prompt_cache_key", "reason": "prompt_cache_key is OpenAI-specific and Vertex Anthropic does not support an equivalent cache bucketing key" }
]
},
{
"testCase": "openaiPromptCacheKeyParam",
"source": "*",
"target": "Google",
"fields": [
{ "pattern": "params.prompt_cache_key", "reason": "prompt_cache_key is OpenAI-specific and Google does not support an equivalent cache bucketing key" }
]
},
{
"testCase": "webSearchToolParam",
"source": "Anthropic",
Expand Down
1 change: 1 addition & 0 deletions crates/lingua/src/providers/anthropic/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ impl ProviderAdapter for AnthropicAdapter {
store: None,
conversation_reference: None,
service_tier: typed_params.service_tier,
prompt_cache_key: None,
logprobs: None,
top_logprobs: None,
extras: Default::default(),
Expand Down
192 changes: 192 additions & 0 deletions crates/lingua/src/providers/anthropic/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,139 @@ fn infer_media_type_from_reference(reference: &str) -> Option<String> {
}
}

fn anthropic_text_provider_options<C, T>(
cache_control: Option<C>,
citations: Option<T>,
) -> Result<Option<ProviderOptions>, ConvertError>
where
C: serde::Serialize,
T: serde::Serialize,
{
let mut options = serde_json::Map::new();
if let Some(cache_control) = cache_control {
options.insert(
"cache_control".into(),
serde_json::to_value(cache_control).map_err(|e| {
ConvertError::JsonSerializationFailed {
field: "cache_control".to_string(),
error: e.to_string(),
}
})?,
);
}
if let Some(citations) = citations {
options.insert(
"citations".into(),
serde_json::to_value(citations).map_err(|e| ConvertError::JsonSerializationFailed {
field: "citations".to_string(),
error: e.to_string(),
})?,
);
}

Ok((!options.is_empty()).then_some(ProviderOptions { options }))
}

fn anthropic_user_text_part<C, T>(
text: String,
cache_control: Option<C>,
citations: Option<T>,
) -> Result<UserContentPart, ConvertError>
where
C: serde::Serialize,
T: serde::Serialize,
{
Ok(UserContentPart::Text(TextContentPart {
text,
encrypted_content: None,
provider_options: anthropic_text_provider_options(cache_control, citations)?,
}))
}

fn anthropic_mid_conv_system_parts(
content: generated::InputContentBlockContent,
) -> Result<Vec<UserContentPart>, ConvertError> {
match content {
generated::InputContentBlockContent::String(text) => {
Ok(vec![UserContentPart::Text(TextContentPart {
text,
encrypted_content: None,
provider_options: None,
})])
}
generated::InputContentBlockContent::BlockArray(blocks) => {
let mut parts = Vec::new();
for block in blocks {
if let Some(text) = block.text {
parts.push(anthropic_user_text_part(
text,
block.cache_control,
block.citations,
)?);
}
if let Some(content) = block.content {
for text_block in content {
parts.push(anthropic_user_text_part(
text_block.text,
text_block.cache_control,
text_block.citations,
)?);
}
}
}
Ok(parts)
}
generated::InputContentBlockContent::RequestWebSearchToolResultError(_) => {
Err(ConvertError::ContentConversionFailed {
reason: "web search tool result errors cannot be used as Anthropic system content"
.to_string(),
})
}
}
}

fn anthropic_system_message_content(
content: generated::MessageContent,
) -> Result<UserContent, ConvertError> {
match content {
generated::MessageContent::String(text) => Ok(UserContent::String(text)),
generated::MessageContent::InputContentBlockArray(blocks) => {
let mut parts = Vec::new();
for block in blocks {
match block.input_content_block_type {
generated::InputContentBlockType::Text => {
if let Some(text) = block.text {
parts.push(anthropic_user_text_part(
text,
block.cache_control,
block.citations,
)?);
}
}
generated::InputContentBlockType::MidConvSystem => {
if let Some(content) = block.content {
parts.extend(anthropic_mid_conv_system_parts(content)?);
}
}
other => {
return Err(ConvertError::ContentConversionFailed {
reason: format!(
"unsupported Anthropic system content block type: {other:?}"
),
});
}
}
}

if parts.is_empty() {
Ok(UserContent::String(String::new()))
} else {
Ok(UserContent::Array(parts))
}
}
}
}

fn normalize_anthropic_tool_schema_value(value: &mut Value) {
match value {
Value::Object(map) => {
Expand Down Expand Up @@ -275,6 +408,9 @@ impl TryFromLLM<generated::InputMessage> for Message {
}

match input_msg.role {
generated::MessageRole::System => Ok(Message::System {
content: anthropic_system_message_content(input_msg.content)?,
}),
Comment on lines +411 to +413
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve mid-conversation system placement

When an Anthropic request includes a new role: "system" / mid_conv_system message after a user turn, this imports it as an ordinary Message::System. The Anthropic exporter later calls extract_system_messages and removes all Message::System values from their original positions into the top-level system field, so any path that re-emits from universal without the raw Anthropic extras changes a mid-conversation instruction into a leading system prompt or merges it with the initial prompt. Since this commit adds support for mid-conversation system blocks, the import needs a placement-preserving representation or the exporter needs to emit Anthropic's mid-conversation shape.

Useful? React with 👍 / 👎.

generated::MessageRole::User => {
let content = match input_msg.content {
generated::MessageContent::String(text) => UserContent::String(text),
Expand Down Expand Up @@ -1693,6 +1829,62 @@ mod tests {
);
}

#[test]
fn test_anthropic_system_role_imports_to_system_message() {
let input: generated::InputMessage = serde_json::from_value(json!({
"role": "system",
"content": "Follow the project style guide."
}))
.unwrap();

let message = <Message as TryFromLLM<generated::InputMessage>>::try_from(input).unwrap();

match message {
Message::System {
content: UserContent::String(text),
} => assert_eq!(text, "Follow the project style guide."),
other => panic!("expected system message, got {other:?}"),
}
}

#[test]
fn test_anthropic_mid_conv_system_imports_to_system_message() {
let input: generated::InputMessage = serde_json::from_value(json!({
"role": "system",
"content": [
{
"type": "mid_conv_system",
"content": [
{
"type": "text",
"text": "Use the updated policy.",
"cache_control": { "type": "ephemeral" }
}
]
}
]
}))
.unwrap();

let message = <Message as TryFromLLM<generated::InputMessage>>::try_from(input).unwrap();

match message {
Message::System {
content: UserContent::Array(parts),
} => {
assert_eq!(parts.len(), 1);
match &parts[0] {
UserContentPart::Text(text) => {
assert_eq!(text.text, "Use the updated policy.");
assert!(text.provider_options.is_some());
}
other => panic!("expected text part, got {other:?}"),
}
}
other => panic!("expected system message with array content, got {other:?}"),
}
}

#[test]
fn test_json_schema_response_format_to_anthropic_is_lossy_for_unsupported_keywords() {
let config = ResponseFormatConfig {
Expand Down
Loading
Loading