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
20 changes: 9 additions & 11 deletions aimux-core/src/generate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -741,7 +741,7 @@ pub(crate) async fn generate_text_from_language_model_prompt(
GenerateContent::ToolCall(call) => {
let parsed = tool_calls
.iter()
.find(|parsed| parsed.tool_call_id == call.tool_call_id)
.find(|parsed| parsed.tool_call_id() == call.tool_call_id)
.expect("every tool call was parsed above");
rm.tool_call(parsed);
}
Expand Down Expand Up @@ -776,7 +776,7 @@ pub(crate) async fn generate_text_from_language_model_prompt(
GenerateContent::ToolApprovalRequest(approval) => {
let call = tool_calls
.iter()
.find(|call| call.tool_call_id == approval.tool_call_id)
.find(|call| call.tool_call_id() == approval.tool_call_id)
.ok_or_else(|| AiMuxError::ToolCallNotFoundForApproval {
tool_call_id: approval.tool_call_id.clone(),
approval_id: approval.approval_id.clone(),
Expand All @@ -789,7 +789,9 @@ pub(crate) async fn generate_text_from_language_model_prompt(
signature: None,
});
}
GenerateContent::ToolResult(result) => rm.tool_result(result.clone()),
// Provider output becomes step content: mark it provider-executed
// (A7 E.3; convert-language-model-content.ts:113).
GenerateContent::ToolResult(result) => rm.tool_result(result.clone().into_core()),
}
}

Expand Down Expand Up @@ -1204,7 +1206,7 @@ pub async fn stream_text(
&messages,
operation_instructions.as_deref(),
).await;
tool_calls.insert(parsed.tool_call_id.clone(), parsed.clone());
tool_calls.insert(parsed.tool_call_id().to_string(), parsed.clone());
yield Ok(TextStreamPart::ToolCall(parsed));
}
Ok(StreamPart::File(file)) => {
Expand Down Expand Up @@ -1413,15 +1415,11 @@ pub fn generate_text_result_to_chat_completion(
.tool_calls
.iter()
.map(|tool_call| ChatCompletionToolCall {
id: tool_call.tool_call_id.clone(),
id: tool_call.tool_call_id().to_string(),
tool_type: "function".to_string(),
function: ChatCompletionFunction {
name: tool_call.tool_name.clone(),
arguments: parsed_tool_call_arguments(
&tool_call.input,
tool_call.invalid,
tool_call.error.as_ref(),
),
name: tool_call.tool_name().to_string(),
arguments: parsed_tool_call_arguments(tool_call),
},
})
.collect(),
Expand Down
5 changes: 3 additions & 2 deletions aimux-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,9 @@ pub mod prelude {
pub use crate::speech_model::{SpeechCallOptions, SpeechModel, SpeechResult, generate_speech};
pub use crate::stream_part::{StreamPart, TextStreamPart};
pub use crate::tool::{
FunctionTool, FunctionToolInputExample, ProviderTool, RawToolCall, Tool, ToolCall,
ToolResult,
DynamicToolCall, FunctionTool, FunctionToolInputExample, ProviderExecutedMark,
ProviderTool, RawArguments, RawToolCall, StaticToolCall, Tool, ToolCall, ToolCallValidity,
ToolResult, ToolResultSource,
};
pub use crate::transcription_model::{
AudioChunk, InputAudioFormat, TranscriptionCallOptions, TranscriptionModel,
Expand Down
29 changes: 9 additions & 20 deletions aimux-core/src/openai_output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ pub fn to_chat_completion(result: &GenerateResult, model: &str) -> ChatCompletio
let arguments = if call.input.trim().is_empty() {
"{}".to_string()
} else {
call.input.clone()
call.input.to_string()
};
tool_calls.push(ChatCompletionToolCall {
id: call.tool_call_id.clone(),
Expand Down Expand Up @@ -702,21 +702,15 @@ impl StreamState {
}
TextStreamPart::ToolInputEnd { .. } => {}

TextStreamPart::ToolCall(ToolCall {
tool_call_id,
tool_name,
input,
invalid,
error,
..
}) => {
TextStreamPart::ToolCall(call) => {
let tool_call_id = &call.tool_call_id().to_string();
let tool_name = &call.tool_name().to_string();
// Complete tool call (e.g. from non-streaming-style providers).
// A provider may carry all input on its start frame and emit no
// deltas. In that case the final call is the first point where
// the OpenAI adapter can forward those arguments.
if self.tool_call_opened.contains(tool_call_id) {
let full_arguments =
parsed_tool_call_arguments(input, *invalid, error.as_ref());
let full_arguments = parsed_tool_call_arguments(call);
let missing_arguments = self
.tool_calls
.get(tool_call_id)
Expand Down Expand Up @@ -761,7 +755,7 @@ impl StreamState {
index,
id: tool_call_id.clone(),
name: tool_name.clone(),
arguments: parsed_tool_call_arguments(input, *invalid, error.as_ref()),
arguments: parsed_tool_call_arguments(call),
},
);
self.tool_call_order.push(tool_call_id.clone());
Expand Down Expand Up @@ -1028,13 +1022,8 @@ fn now_unix() -> u64 {
///
/// Older errors without raw argument text fall back to serializing the
/// parsed input as JSON.
pub(crate) fn parsed_tool_call_arguments(
input: &Value,
invalid: Option<bool>,
error: Option<&AiMuxError>,
) -> String {
if invalid == Some(true)
&& let Some(error) = error
pub(crate) fn parsed_tool_call_arguments(call: &ToolCall) -> String {
if let Some(error) = call.error()
&& let Some(raw) = raw_tool_call_text(error)
{
// Blank text still fails validation against a schema with required
Expand All @@ -1049,7 +1038,7 @@ pub(crate) fn parsed_tool_call_arguments(
}
// `Value` Display is compact JSON, the `JSON.stringify` equivalent —
// correct for every shape, `null` included.
input.to_string()
call.input().to_string()
}

/// Generate a short random ID (24 hex chars, similar to OpenAI's chatcmpl IDs).
Expand Down
105 changes: 59 additions & 46 deletions aimux-core/src/parse_tool_call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use ts_rs::TS;
use crate::content::ContentPart;
use crate::error::AiMuxError;
use crate::result::{GenerateContent, TextContent};
use crate::tool::{RawToolCall, Tool, ToolCall};
use crate::tool::{DynamicToolCall, RawToolCall, StaticToolCall, Tool, ToolCall, ToolCallValidity};

/// Context supplied to a one-shot tool-call repair callback.
#[derive(Debug, Clone)]
Expand Down Expand Up @@ -111,7 +111,7 @@ pub async fn parse_tool_call(
Err(AiMuxError::NoSuchTool {
tool_name: tool_call.tool_name.clone(),
available_tools: None,
tool_input: Some(tool_call.input.clone()),
tool_input: Some(tool_call.input.to_string()),
})
};
return match parsed {
Expand Down Expand Up @@ -196,7 +196,7 @@ fn parse_and_validate_tool_call(
}
return Err(AiMuxError::NoSuchTool {
tool_name: tool_call.tool_name.clone(),
tool_input: Some(tool_call.input.clone()),
tool_input: Some(tool_call.input.to_string()),
available_tools: Some(
tools
.iter()
Expand All @@ -216,7 +216,7 @@ fn parse_and_validate_tool_call(
let validator = jsonschema::validator_for(&function_tool.input_schema).map_err(|error| {
AiMuxError::InvalidToolInput {
tool_name: tool_call.tool_name.clone(),
tool_input: tool_call.input.clone(),
tool_input: tool_call.input.to_string(),
cause: format!("input schema is invalid: {error}"),
}
})?;
Expand All @@ -226,7 +226,7 @@ fn parse_and_validate_tool_call(
.validate(&input)
.map_err(|error| AiMuxError::InvalidToolInput {
tool_name: tool_call.tool_name.clone(),
tool_input: tool_call.input.clone(),
tool_input: tool_call.input.to_string(),
cause: format!("Type validation failed: Value: {input}.\nError message: {error}"),
})?;
Ok((input, None))
Expand All @@ -240,7 +240,7 @@ fn parse_json_input(tool_call: &RawToolCall) -> Result<Value, AiMuxError> {
let value: Value =
serde_json::from_str(&tool_call.input).map_err(|error| AiMuxError::InvalidToolInput {
tool_name: tool_call.tool_name.clone(),
tool_input: tool_call.input.clone(),
tool_input: tool_call.input.to_string(),
cause: format!(
"JSON parsing failed: Text: {}.\nError message: {error}",
tool_call.input
Expand All @@ -249,7 +249,7 @@ fn parse_json_input(tool_call: &RawToolCall) -> Result<Value, AiMuxError> {
if contains_forbidden_prototype(&value) {
return Err(AiMuxError::InvalidToolInput {
tool_name: tool_call.tool_name.clone(),
tool_input: tool_call.input.clone(),
tool_input: tool_call.input.to_string(),
cause: format!(
"JSON parsing failed: Text: {}.\nError message: Object contains forbidden prototype property",
tool_call.input
Expand Down Expand Up @@ -282,15 +282,23 @@ fn contains_forbidden_prototype(value: &Value) -> bool {
}

fn parsed_tool_call(tool_call: RawToolCall, input: Value, dynamic: Option<bool>) -> ToolCall {
ToolCall {
tool_call_id: tool_call.tool_call_id,
tool_name: tool_call.tool_name,
input,
provider_executed: tool_call.provider_executed,
dynamic,
provider_metadata: tool_call.provider_metadata,
invalid: None,
error: None,
if dynamic == Some(true) {
ToolCall::Dynamic(DynamicToolCall {
tool_call_id: tool_call.tool_call_id,
tool_name: tool_call.tool_name,
input,
provider_executed: tool_call.provider_executed,
provider_metadata: tool_call.provider_metadata,
validity: ToolCallValidity::Valid,
})
} else {
ToolCall::Static(StaticToolCall {
tool_call_id: tool_call.tool_call_id,
tool_name: tool_call.tool_name,
input,
provider_executed: tool_call.provider_executed,
provider_metadata: tool_call.provider_metadata,
})
}
}

Expand All @@ -302,12 +310,15 @@ fn invalid_tool_call(tool_call: RawToolCall, error: AiMuxError) -> ToolCall {
// structured input into the next turn's transcript, so an unknown tool
// called with perfectly good arguments must still parse here.
let input = serde_json::from_str(&tool_call.input)
.unwrap_or_else(|_| Value::String(tool_call.input.clone()));
ToolCall {
invalid: Some(true),
error: Some(error),
..parsed_tool_call(tool_call, input, Some(true))
}
.unwrap_or_else(|_| Value::String(tool_call.input.to_string()));
ToolCall::Dynamic(DynamicToolCall {
tool_call_id: tool_call.tool_call_id,
tool_name: tool_call.tool_name,
input,
provider_executed: tool_call.provider_executed,
provider_metadata: tool_call.provider_metadata,
validity: ToolCallValidity::Invalid { error },
})
}

// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -378,31 +389,32 @@ pub(crate) fn raw_tool_call_text(error: &AiMuxError) -> Option<String> {
/// That only matters for re-deriving the original error, which is read off the
/// call instead.
fn raw_from_invalid(tool_call: &ToolCall) -> Result<(RawToolCall, AiMuxError), AiMuxError> {
if tool_call.invalid != Some(true) {
if !tool_call.is_invalid() {
return Err(AiMuxError::InvalidArgument(format!(
"tool call '{}' is not invalid; nothing to repair",
tool_call.tool_call_id
tool_call.tool_call_id()
)));
}
let error = tool_call.error.clone().ok_or_else(|| {
let error = tool_call.error().cloned().ok_or_else(|| {
AiMuxError::InvalidArgument(format!(
"invalid tool call '{}' carries no error",
tool_call.tool_call_id
tool_call.tool_call_id()
))
})?;
Ok((
RawToolCall {
tool_call_id: tool_call.tool_call_id.clone(),
tool_name: tool_call.tool_name.clone(),
tool_call_id: tool_call.tool_call_id().to_string(),
tool_name: tool_call.tool_name().to_string(),
// An invalid call keeps unparsable argument text as a JSON string
// (see `invalid_tool_call`); that text is the raw input.
input: raw_tool_call_text(&error).unwrap_or_else(|| match &tool_call.input {
input: (raw_tool_call_text(&error).unwrap_or_else(|| match tool_call.input() {
Value::String(text) => text.clone(),
input => input.to_string(),
}),
provider_executed: tool_call.provider_executed,
dynamic: tool_call.dynamic,
provider_metadata: tool_call.provider_metadata.clone(),
}))
.into(),
provider_executed: tool_call.provider_executed(),
dynamic: Some(true),
provider_metadata: tool_call.provider_metadata().cloned(),
},
error,
))
Expand Down Expand Up @@ -572,25 +584,23 @@ pub fn apply_tool_call_repair_to_result(
.map_err(|error| AiMuxError::InvalidArgument(format!("result.toolCalls: {error}")))?;

let repaired = apply_tool_call_repair(&original, tools, reply)?;
if repaired.tool_call_id != tool_call_id
if repaired.tool_call_id() != tool_call_id
&& calls.iter().enumerate().any(|(index, call)| {
index != target_index
&& call.get("toolCallId").and_then(Value::as_str)
== Some(repaired.tool_call_id.as_str())
&& call.get("toolCallId").and_then(Value::as_str) == Some(repaired.tool_call_id())
})
{
return Err(AiMuxError::InvalidArgument(format!(
"result: repaired tool call id '{}' already exists",
repaired.tool_call_id
repaired.tool_call_id()
)));
}
calls[target_index] = serde_json::to_value(&repaired)
.map_err(|error| AiMuxError::InvalidArgument(format!("tool call: {error}")))?;

// The replayed transcript must agree with `toolCalls`, or the next turn
// sends the model the unrepaired arguments.
let replay_input =
crate::response_messages::response_tool_call_input(&repaired.input, repaired.invalid);
let replay_input = crate::response_messages::response_tool_call_input(&repaired);
for part in target
.get_mut("responseMessages")
.and_then(Value::as_array_mut)
Expand All @@ -611,9 +621,12 @@ pub fn apply_tool_call_repair_to_result(
// the same entry as `toolCalls`.
part.insert(
"toolCallId".to_string(),
repaired.tool_call_id.clone().into(),
repaired.tool_call_id().to_string().into(),
);
part.insert(
"toolName".to_string(),
repaired.tool_name().to_string().into(),
);
part.insert("toolName".to_string(), repaired.tool_name.clone().into());
part.insert("input".to_string(), replay_input.clone());
}

Expand All @@ -634,14 +647,14 @@ pub fn apply_tool_call_repair_to_result(
GenerateContent::ToolApprovalRequest(approval) => &mut approval.tool_call,
_ => continue,
};
if call.tool_call_id != tool_call_id {
if call.tool_call_id() != tool_call_id {
continue;
}
*call = repaired.clone();
*entry = serde_json::to_value(&content)
.map_err(|error| AiMuxError::InvalidArgument(format!("content: {error}")))?;
}
if repaired.tool_call_id != tool_call_id {
if repaired.tool_call_id() != tool_call_id {
for part in target
.get_mut("responseMessages")
.and_then(Value::as_array_mut)
Expand All @@ -663,7 +676,7 @@ pub fn apply_tool_call_repair_to_result(
if approved_id != tool_call_id {
continue;
}
approved_id.clone_from(&repaired.tool_call_id);
*approved_id = repaired.tool_call_id().to_string();
*part = serde_json::to_value(&approval).map_err(|error| {
AiMuxError::InvalidArgument(format!("response message: {error}"))
})?;
Expand Down Expand Up @@ -697,7 +710,7 @@ mod tests {
RawToolCall {
tool_call_id: id.to_string(),
tool_name: "weather".to_string(),
input: input.to_string(),
input: input.to_string().into(),
provider_executed: None,
dynamic: None,
provider_metadata: None,
Expand All @@ -716,7 +729,7 @@ mod tests {
None,
)
.await;
assert_eq!(call.invalid, Some(true));
assert!(call.is_invalid());
let mut builder = ResponseMessageBuilder::default();
builder.tool_call(&call);
if with_approval {
Expand Down
Loading
Loading