Skip to content
Draft
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
143 changes: 142 additions & 1 deletion crates/lingua/src/processing/import.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,11 @@ fn try_parse_mixed_role_messages_for_import(data: &Value) -> Option<Vec<Message>
let mut messages = Vec::new();

for item in items {
if let Some(message) = parse_lenient_assistant_message_with_tool_calls(item) {
messages.push(message);
continue;
}

let mut parsed_messages = try_parsers_in_order(item, &provider_parsers).or_else(|| {
let wrapped_item = Value::Array(vec![item.clone()]);
try_parsers_in_order(&wrapped_item, &provider_parsers)
Expand Down Expand Up @@ -312,6 +317,12 @@ enum LenientAssistantContentPartCompat {
#[serde(default)]
encrypted_content: Option<String>,
},
#[serde(rename = "thinking")]
Thinking {
thinking: String,
#[serde(default)]
signature: Option<String>,
},
#[serde(rename = "tool_call", alias = "tool-call", alias = "toolCall")]
ToolCall {
#[serde(alias = "toolCallId")]
Expand All @@ -336,6 +347,44 @@ enum LenientAssistantContentPartCompat {
},
}

#[derive(Debug, Clone, Deserialize)]
struct LenientAssistantMessageCompat {
role: AssistantRoleCompat,
#[serde(default)]
content: Option<LenientAssistantContentCompat>,
#[serde(default)]
tool_calls: Vec<LenientOpenAiToolCallCompat>,
#[serde(default)]
reasoning: Option<Value>,
#[serde(default)]
reasoning_signature: Option<String>,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "lowercase")]
enum AssistantRoleCompat {
Assistant,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
enum LenientAssistantContentCompat {
String(String),
Array(Vec<LenientAssistantContentPartCompat>),
}

#[derive(Debug, Clone, Deserialize)]
struct LenientOpenAiToolCallCompat {
id: String,
function: LenientOpenAiFunctionCallCompat,
}

#[derive(Debug, Clone, Deserialize)]
struct LenientOpenAiFunctionCallCompat {
name: String,
arguments: Value,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(tag = "type")]
enum LenientToolContentPartCompat {
Expand Down Expand Up @@ -449,7 +498,15 @@ fn parse_tool_call_arguments(value: Option<Value>) -> Option<ToolCallArguments>
}

fn try_parse_lenient_assistant_content_part(item: &Value) -> Option<AssistantContentPart> {
match serde_json::from_value::<LenientAssistantContentPartCompat>(item.clone()).ok()? {
lenient_assistant_content_part_to_universal(
serde_json::from_value::<LenientAssistantContentPartCompat>(item.clone()).ok()?,
)
}

fn lenient_assistant_content_part_to_universal(
part: LenientAssistantContentPartCompat,
) -> Option<AssistantContentPart> {
match part {
LenientAssistantContentPartCompat::Text { text } => {
Some(AssistantContentPart::Text(TextContentPart {
text,
Expand All @@ -465,6 +522,13 @@ fn try_parse_lenient_assistant_content_part(item: &Value) -> Option<AssistantCon
text,
encrypted_content,
}),
LenientAssistantContentPartCompat::Thinking {
thinking,
signature,
} => Some(AssistantContentPart::Reasoning {
text: thinking,
encrypted_content: signature,
}),
LenientAssistantContentPartCompat::ToolCall {
tool_call_id,
tool_name,
Expand Down Expand Up @@ -492,6 +556,83 @@ fn try_parse_lenient_assistant_content_part(item: &Value) -> Option<AssistantCon
}
}

fn openai_tool_call_arguments(value: Value) -> ToolCallArguments {
match value {
Value::Object(map) => ToolCallArguments::Valid(map),
Value::String(text) => match serde_json::from_str::<Value>(&text) {
Ok(Value::Object(map)) => ToolCallArguments::Valid(map),
Ok(other) => ToolCallArguments::Invalid(other.to_string()),
Err(_) => ToolCallArguments::Invalid(text),
},
other => ToolCallArguments::Invalid(other.to_string()),
}
}

fn openai_tool_call_to_assistant_part(
tool_call: LenientOpenAiToolCallCompat,
) -> AssistantContentPart {
AssistantContentPart::ToolCall {
tool_call_id: tool_call.id,
tool_name: tool_call.function.name,
arguments: openai_tool_call_arguments(tool_call.function.arguments),
encrypted_content: None,
provider_options: None,
provider_executed: None,
}
}

fn parse_lenient_assistant_message_with_tool_calls(item: &Value) -> Option<Message> {
let parsed = LenientAssistantMessageCompat::deserialize(item).ok()?;
match parsed.role {
AssistantRoleCompat::Assistant => {}
}
if parsed.tool_calls.is_empty()
|| parsed.reasoning.is_some()
|| parsed.reasoning_signature.is_some()
{
return None;
}

let mut parts = Vec::new();
if let Some(content) = parsed.content {
match content {
LenientAssistantContentCompat::String(text) => {
if !text.is_empty() {
parts.push(AssistantContentPart::Text(TextContentPart {
text,
encrypted_content: None,
cache_control: None,
provider_options: None,
}));
}
}
LenientAssistantContentCompat::Array(content_parts) => {
parts.extend(
content_parts
.into_iter()
.filter_map(lenient_assistant_content_part_to_universal),
);
}
}
}

parts.extend(
parsed
.tool_calls
.into_iter()
.map(openai_tool_call_to_assistant_part),
);

if parts.is_empty() {
return None;
}

Some(Message::Assistant {
content: AssistantContent::Array(parts),
id: None,
})
}

fn try_parse_lenient_tool_content_part(item: &Value) -> Option<ToolContentPart> {
match serde_json::from_value::<LenientToolContentPartCompat>(item.clone()).ok()? {
LenientToolContentPartCompat::ToolResult {
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

100 changes: 100 additions & 0 deletions scripts/show-import-thinking-tool-calls-issue.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#!/bin/bash

# Show the assistant-thinking + sibling-tool-calls import behavior.
#
# On a broken importer, this prints an assistant message with reasoning only and
# then fails because the sibling tool_call fields are absent. On a fixed importer,
# it prints one assistant message containing both the reasoning part and tool_call
# part.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
TEST_FILE="$REPO_ROOT/crates/lingua/tests/show_import_thinking_tool_calls_issue.rs"

cleanup() {
rm -f "$TEST_FILE"
}
trap cleanup EXIT

if [ -f "$TEST_FILE" ]; then
echo "Refusing to overwrite existing test file: $TEST_FILE" >&2
exit 1
fi

if ! command -v cargo >/dev/null 2>&1; then
echo "cargo is required to run this script" >&2
exit 1
fi

CARGO_CMD=(cargo)
if [ -n "${CARGO_TOOLCHAIN:-}" ]; then
CARGO_CMD=(cargo "+$CARGO_TOOLCHAIN")
elif command -v rustup >/dev/null 2>&1 && rustup toolchain list | grep -q '^1\.95\.0'; then
CARGO_CMD=(cargo +1.95.0)
fi

cat > "$TEST_FILE" <<'RS'
use lingua::processing::{import_messages_from_spans, Span};
use lingua::serde_json;

#[test]
fn show_import_thinking_tool_calls_issue() {
let spans_json = r#"
[
{
"output": [
{
"role": "assistant",
"content": [
{
"type": "thinking",
"thinking": "Need to query structured data before answering.",
"signature": "reasoning_signature_123"
}
],
"tool_calls": [
{
"id": "call_structured_data_123",
"type": "function",
"function": {
"name": "StructuredDataQueryTool",
"arguments": {
"query": "select count(*) from anonymized_table",
"limit": 10
}
}
}
]
}
]
}
]
"#;

let spans: Vec<Span> = serde_json::from_str(spans_json).expect("fixture should parse");
let messages = import_messages_from_spans(spans);
let pretty = serde_json::to_string_pretty(&messages).expect("messages should serialize");

println!("\nImported Lingua messages:\n{}\n", pretty);

for expected in [
"Need to query structured data before answering.",
"reasoning_signature_123",
"call_structured_data_123",
"StructuredDataQueryTool",
"select count(*) from anonymized_table",
"\"limit\": 10",
] {
assert!(
pretty.contains(expected),
"missing expected text: {expected}\n\nOn the broken importer, sibling tool_calls are dropped before the UI sees them.\n\nImported messages:\n{pretty}"
);
}
}
RS

cd "$REPO_ROOT"
echo "Running: ${CARGO_CMD[*]} test -p lingua --test show_import_thinking_tool_calls_issue -- --nocapture"
"${CARGO_CMD[@]}" test -p lingua --test show_import_thinking_tool_calls_issue -- --nocapture
Loading