language_model: Add OpenAI Responses API custom tool support (#60638)

# Objective

Allow zed's language model stack to express OpenAI Responses API custom
tools (freeform text-input tools with an optional lark/regex grammar),
so downstream consumers can offer tools like a freeform `apply_patch` to
GPT models.
## Solution

- `LanguageModelRequestTool` now carries a Function-vs-Custom input
variant; `LanguageModelCustomToolFormat` models text/grammar formats.
- `LanguageModelToolUse.input` becomes a typed
`LanguageModelToolUseInput::{Json, Text}`. Serialization is tagged so
persisted Text inputs round-trip losslessly; legacy plain JSON values
still deserialize as `Json`.
- `open_ai` gains the custom tool wire types (tool definition,
`custom_tool_call`/`custom_tool_call_output` input items with
string-or-content-part outputs, output item, and
`custom_tool_call_input` delta/done stream events). The Responses event
mapper accumulates raw text deltas into `ToolUse` events, and history
replay derives custom-vs-function tool results from the matching
`ToolUse` by id.
- All non-OpenAI providers and the Chat Completions path error
explicitly when a request contains custom tools — no silent drops or
empty-schema coercions.

Release Notes:

- N/A
This commit is contained in:
Ben Kunkle authored and GitHub committed 2026-07-09 12:05:50 +00:00
1 parent 55b776183d
commit 3e976e89bc
31 files changed
+1652 -501

No files matched your search

+3 -1
View File
@@ -281,7 +281,9 @@ impl DbThread {
name: tool_use.name.into(),
raw_input: serde_json::to_string(&tool_use.input)
.unwrap_or_default(),
input: tool_use.input,
input: language_model::LanguageModelToolUseInput::Json(
tool_use.input,
),
is_input_complete: true,
thought_signature: None,
},
+89 -49
View File
@@ -689,7 +689,7 @@ async fn test_prompt_caching(cx: &mut TestAppContext) {
id: "tool_1".into(),
name: EchoTool::NAME.into(),
raw_input: json!({"text": "test"}).to_string(),
input: json!({"text": "test"}),
input: language_model::LanguageModelToolUseInput::Json(json!({"text": "test"})),
is_input_complete: true,
thought_signature: None,
};
@@ -840,17 +840,25 @@ async fn test_streaming_tool_calls(cx: &mut TestAppContext) {
assert_eq!(last_tool_use.name.as_ref(), "word_list");
if tool_call.status == acp::ToolCallStatus::Pending {
if !last_tool_use.is_input_complete
&& last_tool_use.input.get("g").is_none()
&& last_tool_use
.input
.as_json()
.and_then(|input| input.get("g"))
.is_none()
{
saw_partial_tool_use = true;
}
} else {
last_tool_use
.input
.as_json()
.expect("tool input should be JSON")
.get("a")
.expect("'a' has streamed because input is now complete");
last_tool_use
.input
.as_json()
.expect("tool input should be JSON")
.get("g")
.expect("'g' has streamed because input is now complete");
}
@@ -884,7 +892,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) {
id: "tool_id_1".into(),
name: ToolRequiringPermission::NAME.into(),
raw_input: "{}".into(),
input: json!({}),
input: language_model::LanguageModelToolUseInput::Json(json!({})),
is_input_complete: true,
thought_signature: None,
},
@@ -894,7 +902,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) {
id: "tool_id_2".into(),
name: ToolRequiringPermission::NAME.into(),
raw_input: "{}".into(),
input: json!({}),
input: language_model::LanguageModelToolUseInput::Json(json!({})),
is_input_complete: true,
thought_signature: None,
},
@@ -951,7 +959,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) {
id: "tool_id_3".into(),
name: ToolRequiringPermission::NAME.into(),
raw_input: "{}".into(),
input: json!({}),
input: language_model::LanguageModelToolUseInput::Json(json!({})),
is_input_complete: true,
thought_signature: None,
},
@@ -990,7 +998,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) {
id: "tool_id_4".into(),
name: ToolRequiringPermission::NAME.into(),
raw_input: "{}".into(),
input: json!({}),
input: language_model::LanguageModelToolUseInput::Json(json!({})),
is_input_complete: true,
thought_signature: None,
},
@@ -1029,7 +1037,7 @@ async fn test_tool_hallucination(cx: &mut TestAppContext) {
id: "tool_id_1".into(),
name: "nonexistent_tool".into(),
raw_input: "{}".into(),
input: json!({}),
input: language_model::LanguageModelToolUseInput::Json(json!({})),
is_input_complete: true,
thought_signature: None,
},
@@ -1533,7 +1541,7 @@ async fn test_mcp_tools(cx: &mut TestAppContext) {
id: "tool_1".into(),
name: "echo".into(),
raw_input: json!({"text": "test"}).to_string(),
input: json!({"text": "test"}),
input: language_model::LanguageModelToolUseInput::Json(json!({"text": "test"})),
is_input_complete: true,
thought_signature: None,
},
@@ -1577,7 +1585,7 @@ async fn test_mcp_tools(cx: &mut TestAppContext) {
id: "tool_2".into(),
name: "test_server_echo".into(),
raw_input: json!({"text": "mcp"}).to_string(),
input: json!({"text": "mcp"}),
input: language_model::LanguageModelToolUseInput::Json(json!({"text": "mcp"})),
is_input_complete: true,
thought_signature: None,
},
@@ -1587,7 +1595,7 @@ async fn test_mcp_tools(cx: &mut TestAppContext) {
id: "tool_3".into(),
name: "echo".into(),
raw_input: json!({"text": "native"}).to_string(),
input: json!({"text": "native"}),
input: language_model::LanguageModelToolUseInput::Json(json!({"text": "native"})),
is_input_complete: true,
thought_signature: None,
},
@@ -1697,7 +1705,7 @@ async fn test_mcp_tool_names_are_sanitized_for_providers(cx: &mut TestAppContext
id: "tool_1".into(),
name: "snake_case_PascalCase".into(),
raw_input: json!({}).to_string(),
input: json!({}),
input: language_model::LanguageModelToolUseInput::Json(json!({})),
is_input_complete: true,
thought_signature: None,
},
@@ -1786,7 +1794,7 @@ async fn test_mcp_tool_multi_content_response(cx: &mut TestAppContext) {
id: "tool_1".into(),
name: "screenshot".into(),
raw_input: json!({}).to_string(),
input: json!({}),
input: language_model::LanguageModelToolUseInput::Json(json!({})),
is_input_complete: true,
thought_signature: None,
},
@@ -1936,7 +1944,9 @@ async fn test_mcp_tool_result_displayed_when_server_disconnected(cx: &mut TestAp
name: "issue_read".into(),
raw_input: json!({"issue_url": "https://github.com/zed-industries/zed/issues/47404"})
.to_string(),
input: json!({"issue_url": "https://github.com/zed-industries/zed/issues/47404"}),
input: language_model::LanguageModelToolUseInput::Json(
json!({"issue_url": "https://github.com/zed-industries/zed/issues/47404"}),
),
is_input_complete: true,
thought_signature: None,
},
@@ -2351,7 +2361,9 @@ async fn test_terminal_tool_cancellation_captures_output(cx: &mut TestAppContext
id: "terminal_tool_1".into(),
name: TerminalTool::NAME.into(),
raw_input: r#"{"command": "sleep 1000", "cd": "."}"#.into(),
input: json!({"command": "sleep 1000", "cd": "."}),
input: language_model::LanguageModelToolUseInput::Json(
json!({"command": "sleep 1000", "cd": "."}),
),
is_input_complete: true,
thought_signature: None,
},
@@ -2446,7 +2458,7 @@ async fn test_cancellation_aware_tool_responds_to_cancellation(cx: &mut TestAppC
id: "cancellation_aware_1".into(),
name: "cancellation_aware".into(),
raw_input: r#"{}"#.into(),
input: json!({}),
input: language_model::LanguageModelToolUseInput::Json(json!({})),
is_input_complete: true,
thought_signature: None,
},
@@ -2632,7 +2644,9 @@ async fn test_truncate_while_terminal_tool_running(cx: &mut TestAppContext) {
id: "terminal_tool_1".into(),
name: TerminalTool::NAME.into(),
raw_input: r#"{"command": "sleep 1000", "cd": "."}"#.into(),
input: json!({"command": "sleep 1000", "cd": "."}),
input: language_model::LanguageModelToolUseInput::Json(
json!({"command": "sleep 1000", "cd": "."}),
),
is_input_complete: true,
thought_signature: None,
},
@@ -2697,7 +2711,9 @@ async fn test_cancel_multiple_concurrent_terminal_tools(cx: &mut TestAppContext)
id: "terminal_tool_1".into(),
name: TerminalTool::NAME.into(),
raw_input: r#"{"command": "sleep 1000", "cd": "."}"#.into(),
input: json!({"command": "sleep 1000", "cd": "."}),
input: language_model::LanguageModelToolUseInput::Json(
json!({"command": "sleep 1000", "cd": "."}),
),
is_input_complete: true,
thought_signature: None,
},
@@ -2707,7 +2723,9 @@ async fn test_cancel_multiple_concurrent_terminal_tools(cx: &mut TestAppContext)
id: "terminal_tool_2".into(),
name: TerminalTool::NAME.into(),
raw_input: r#"{"command": "sleep 2000", "cd": "."}"#.into(),
input: json!({"command": "sleep 2000", "cd": "."}),
input: language_model::LanguageModelToolUseInput::Json(
json!({"command": "sleep 2000", "cd": "."}),
),
is_input_complete: true,
thought_signature: None,
},
@@ -2811,7 +2829,9 @@ async fn test_terminal_tool_stopped_via_terminal_card_button(cx: &mut TestAppCon
id: "terminal_tool_1".into(),
name: TerminalTool::NAME.into(),
raw_input: r#"{"command": "sleep 1000", "cd": "."}"#.into(),
input: json!({"command": "sleep 1000", "cd": "."}),
input: language_model::LanguageModelToolUseInput::Json(
json!({"command": "sleep 1000", "cd": "."}),
),
is_input_complete: true,
thought_signature: None,
},
@@ -2907,7 +2927,9 @@ async fn test_terminal_tool_timeout_expires(cx: &mut TestAppContext) {
id: "terminal_tool_1".into(),
name: TerminalTool::NAME.into(),
raw_input: r#"{"command": "sleep 1000", "cd": ".", "timeout_ms": 100}"#.into(),
input: json!({"command": "sleep 1000", "cd": ".", "timeout_ms": 100}),
input: language_model::LanguageModelToolUseInput::Json(
json!({"command": "sleep 1000", "cd": ".", "timeout_ms": 100}),
),
is_input_complete: true,
thought_signature: None,
},
@@ -3376,7 +3398,7 @@ async fn test_cumulative_token_usage(cx: &mut TestAppContext) {
id: "tool_1".into(),
name: EchoTool::NAME.into(),
raw_input: json!({"text": "hello"}).to_string(),
input: json!({"text": "hello"}),
input: language_model::LanguageModelToolUseInput::Json(json!({"text": "hello"})),
is_input_complete: true,
thought_signature: None,
},
@@ -3786,7 +3808,7 @@ async fn test_building_request_with_pending_tools(cx: &mut TestAppContext) {
id: "tool_id_1".into(),
name: ToolRequiringPermission::NAME.into(),
raw_input: "{}".into(),
input: json!({}),
input: language_model::LanguageModelToolUseInput::Json(json!({})),
is_input_complete: true,
thought_signature: None,
};
@@ -3794,7 +3816,7 @@ async fn test_building_request_with_pending_tools(cx: &mut TestAppContext) {
id: "tool_id_2".into(),
name: EchoTool::NAME.into(),
raw_input: json!({"text": "test"}).to_string(),
input: json!({"text": "test"}),
input: language_model::LanguageModelToolUseInput::Json(json!({"text": "test"})),
is_input_complete: true,
thought_signature: None,
};
@@ -3992,7 +4014,7 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) {
id: "1".into(),
name: EchoTool::NAME.into(),
raw_input: input.to_string(),
input,
input: language_model::LanguageModelToolUseInput::Json(input),
is_input_complete: false,
thought_signature: None,
},
@@ -4005,7 +4027,7 @@ async fn test_tool_updates_to_completion(cx: &mut TestAppContext) {
id: "1".into(),
name: "echo".into(),
raw_input: input.to_string(),
input,
input: language_model::LanguageModelToolUseInput::Json(input),
is_input_complete: true,
thought_signature: None,
},
@@ -4175,7 +4197,7 @@ async fn test_send_retry_finishes_tool_calls_on_error(cx: &mut TestAppContext) {
id: "tool_1".into(),
name: EchoTool::NAME.into(),
raw_input: json!({"text": "test"}).to_string(),
input: json!({"text": "test"}),
input: language_model::LanguageModelToolUseInput::Json(json!({"text": "test"})),
is_input_complete: true,
thought_signature: None,
};
@@ -4323,7 +4345,7 @@ async fn test_streaming_tool_completes_when_llm_stream_ends_without_final_input(
id: "tool_1".into(),
name: "streaming_echo".into(),
raw_input: r#"{"text": "partial"}"#.into(),
input: json!({"text": "partial"}),
input: language_model::LanguageModelToolUseInput::Json(json!({"text": "partial"})),
is_input_complete: false,
thought_signature: None,
};
@@ -4428,7 +4450,7 @@ async fn test_streaming_tool_json_parse_error_is_forwarded_to_running_tool(
id: "tool_1".into(),
name: StreamingJsonErrorContextTool::NAME.into(),
raw_input: r#"{"text": "partial"#.into(),
input: json!({"text": "partial"}),
input: language_model::LanguageModelToolUseInput::Json(json!({"text": "partial"})),
is_input_complete: false,
thought_signature: None,
};
@@ -5228,7 +5250,9 @@ async fn test_subagent_tool_call_end_to_end(cx: &mut TestAppContext) {
id: "subagent_1".into(),
name: SpawnAgentTool::NAME.into(),
raw_input: serde_json::to_string(&subagent_tool_input).unwrap(),
input: serde_json::to_value(&subagent_tool_input).unwrap(),
input: language_model::LanguageModelToolUseInput::Json(
serde_json::to_value(&subagent_tool_input).unwrap(),
),
is_input_complete: true,
thought_signature: None,
};
@@ -5362,7 +5386,9 @@ async fn test_subagent_tool_output_does_not_include_thinking(cx: &mut TestAppCon
id: "subagent_1".into(),
name: SpawnAgentTool::NAME.into(),
raw_input: serde_json::to_string(&subagent_tool_input).unwrap(),
input: serde_json::to_value(&subagent_tool_input).unwrap(),
input: language_model::LanguageModelToolUseInput::Json(
serde_json::to_value(&subagent_tool_input).unwrap(),
),
is_input_complete: true,
thought_signature: None,
};
@@ -5509,7 +5535,9 @@ async fn test_subagent_tool_call_cancellation_during_task_prompt(cx: &mut TestAp
id: "subagent_1".into(),
name: SpawnAgentTool::NAME.into(),
raw_input: serde_json::to_string(&subagent_tool_input).unwrap(),
input: serde_json::to_value(&subagent_tool_input).unwrap(),
input: language_model::LanguageModelToolUseInput::Json(
serde_json::to_value(&subagent_tool_input).unwrap(),
),
is_input_complete: true,
thought_signature: None,
};
@@ -5638,7 +5666,9 @@ async fn test_subagent_tool_resume_session(cx: &mut TestAppContext) {
id: "subagent_1".into(),
name: SpawnAgentTool::NAME.into(),
raw_input: serde_json::to_string(&subagent_tool_input).unwrap(),
input: serde_json::to_value(&subagent_tool_input).unwrap(),
input: language_model::LanguageModelToolUseInput::Json(
serde_json::to_value(&subagent_tool_input).unwrap(),
),
is_input_complete: true,
thought_signature: None,
};
@@ -5699,7 +5729,9 @@ async fn test_subagent_tool_resume_session(cx: &mut TestAppContext) {
id: "subagent_2".into(),
name: SpawnAgentTool::NAME.into(),
raw_input: serde_json::to_string(&resume_tool_input).unwrap(),
input: serde_json::to_value(&resume_tool_input).unwrap(),
input: language_model::LanguageModelToolUseInput::Json(
serde_json::to_value(&resume_tool_input).unwrap(),
),
is_input_complete: true,
thought_signature: None,
};
@@ -6285,7 +6317,9 @@ async fn test_subagent_context_window_warning(cx: &mut TestAppContext) {
id: "subagent_1".into(),
name: SpawnAgentTool::NAME.into(),
raw_input: serde_json::to_string(&subagent_tool_input).unwrap(),
input: serde_json::to_value(&subagent_tool_input).unwrap(),
input: language_model::LanguageModelToolUseInput::Json(
serde_json::to_value(&subagent_tool_input).unwrap(),
),
is_input_complete: true,
thought_signature: None,
};
@@ -6410,7 +6444,9 @@ async fn test_subagent_no_context_window_warning_when_already_at_warning(cx: &mu
id: "subagent_1".into(),
name: SpawnAgentTool::NAME.into(),
raw_input: serde_json::to_string(&subagent_tool_input).unwrap(),
input: serde_json::to_value(&subagent_tool_input).unwrap(),
input: language_model::LanguageModelToolUseInput::Json(
serde_json::to_value(&subagent_tool_input).unwrap(),
),
is_input_complete: true,
thought_signature: None,
};
@@ -6476,7 +6512,9 @@ async fn test_subagent_no_context_window_warning_when_already_at_warning(cx: &mu
id: "subagent_2".into(),
name: SpawnAgentTool::NAME.into(),
raw_input: serde_json::to_string(&resume_tool_input).unwrap(),
input: serde_json::to_value(&resume_tool_input).unwrap(),
input: language_model::LanguageModelToolUseInput::Json(
serde_json::to_value(&resume_tool_input).unwrap(),
),
is_input_complete: true,
thought_signature: None,
};
@@ -6583,7 +6621,9 @@ async fn test_subagent_error_propagation(cx: &mut TestAppContext) {
id: "subagent_1".into(),
name: SpawnAgentTool::NAME.into(),
raw_input: serde_json::to_string(&subagent_tool_input).unwrap(),
input: serde_json::to_value(&subagent_tool_input).unwrap(),
input: language_model::LanguageModelToolUseInput::Json(
serde_json::to_value(&subagent_tool_input).unwrap(),
),
is_input_complete: true,
thought_signature: None,
};
@@ -7372,7 +7412,7 @@ async fn test_always_allow_resolves_pending_authorizations(cx: &mut TestAppConte
id: id.into(),
name: ToolRequiringPermission::NAME.into(),
raw_input: "{}".into(),
input: json!({}),
input: language_model::LanguageModelToolUseInput::Json(json!({})),
is_input_complete: true,
thought_signature: None,
},
@@ -7451,7 +7491,7 @@ async fn test_external_settings_edit_resolves_pending_authorization(cx: &mut Tes
id: "tool_id_1".into(),
name: ToolRequiringPermission::NAME.into(),
raw_input: "{}".into(),
input: json!({}),
input: language_model::LanguageModelToolUseInput::Json(json!({})),
is_input_complete: true,
thought_signature: None,
},
@@ -7522,7 +7562,7 @@ async fn test_external_deny_rule_resolves_pending_authorization(cx: &mut TestApp
id: "tool_id_1".into(),
name: ToolRequiringPermission::NAME.into(),
raw_input: "{}".into(),
input: json!({}),
input: language_model::LanguageModelToolUseInput::Json(json!({})),
is_input_complete: true,
thought_signature: None,
},
@@ -7598,7 +7638,7 @@ async fn test_unrelated_settings_change_does_not_resolve_pending_authorization(
id: "tool_id_1".into(),
name: ToolRequiringPermission::NAME.into(),
raw_input: "{}".into(),
input: json!({}),
input: language_model::LanguageModelToolUseInput::Json(json!({})),
is_input_complete: true,
thought_signature: None,
},
@@ -7668,7 +7708,7 @@ async fn test_always_allow_does_not_resolve_unrelated_tool_authorization(cx: &mu
id: id.into(),
name: name.into(),
raw_input: "{}".into(),
input: json!({}),
input: language_model::LanguageModelToolUseInput::Json(json!({})),
is_input_complete: true,
thought_signature: None,
},
@@ -7765,7 +7805,7 @@ async fn test_queued_message_ends_turn_at_boundary(cx: &mut TestAppContext) {
id: "tool_1".into(),
name: "echo".into(),
raw_input: r#"{"text": "hello"}"#.into(),
input: json!({"text": "hello"}),
input: language_model::LanguageModelToolUseInput::Json(json!({"text": "hello"})),
is_input_complete: true,
thought_signature: None,
},
@@ -7847,7 +7887,7 @@ async fn test_queued_message_does_not_end_turn_without_boundary_flag(cx: &mut Te
id: "tool_1".into(),
name: "echo".into(),
raw_input: r#"{"text": "hello"}"#.into(),
input: json!({"text": "hello"}),
input: language_model::LanguageModelToolUseInput::Json(json!({"text": "hello"})),
is_input_complete: true,
thought_signature: None,
},
@@ -7914,7 +7954,7 @@ async fn test_streaming_tool_error_breaks_stream_loop_immediately(cx: &mut TestA
id: "call_1".into(),
name: StreamingFailingEchoTool::NAME.into(),
raw_input: "hello".into(),
input: json!({}),
input: language_model::LanguageModelToolUseInput::Json(json!({})),
is_input_complete: false,
thought_signature: None,
};
@@ -7996,7 +8036,7 @@ async fn test_streaming_tool_error_waits_for_prior_tools_to_complete(cx: &mut Te
id: "call_1".into(),
name: StreamingEchoTool::NAME.into(),
raw_input: "hello".into(),
input: json!({ "text": "hello" }),
input: language_model::LanguageModelToolUseInput::Json(json!({ "text": "hello" })),
is_input_complete: false,
thought_signature: None,
},
@@ -8005,7 +8045,7 @@ async fn test_streaming_tool_error_waits_for_prior_tools_to_complete(cx: &mut Te
id: "call_1".into(),
name: StreamingEchoTool::NAME.into(),
raw_input: "hello world".into(),
input: json!({ "text": "hello world" }),
input: language_model::LanguageModelToolUseInput::Json(json!({ "text": "hello world" })),
is_input_complete: true,
thought_signature: None,
};
@@ -8015,7 +8055,7 @@ async fn test_streaming_tool_error_waits_for_prior_tools_to_complete(cx: &mut Te
let second_tool_use = LanguageModelToolUse {
name: StreamingFailingEchoTool::NAME.into(),
raw_input: "hello".into(),
input: json!({ "text": "hello" }),
input: language_model::LanguageModelToolUseInput::Json(json!({ "text": "hello" })),
is_input_complete: false,
thought_signature: None,
id: "call_2".into(),
@@ -8144,7 +8184,7 @@ async fn test_mid_turn_model_and_settings_refresh(cx: &mut TestAppContext) {
id: "tool_1".into(),
name: "echo".into(),
raw_input: r#"{"text":"hello"}"#.into(),
input: json!({"text": "hello"}),
input: language_model::LanguageModelToolUseInput::Json(json!({"text": "hello"})),
is_input_complete: true,
thought_signature: None,
},
+47 -29
View File
@@ -607,7 +607,7 @@ impl AgentMessage {
"{}\n",
MarkdownCodeBlock {
tag: "json",
text: &format!("{:#}", tool_use.input)
text: &format!("{:#}", tool_use.input.to_display_json())
}
));
}
@@ -1593,7 +1593,7 @@ impl Thread {
.unbounded_send(Ok(ThreadEvent::ToolCall(
acp::ToolCall::new(tool_use.id.to_string(), tool_use.name.to_string())
.status(status)
.raw_input(tool_use.input.clone()),
.raw_input(tool_use.input.to_display_json()),
)))
.ok();
let mut fields = acp::ToolCallUpdateFields::new()
@@ -1606,15 +1606,12 @@ impl Thread {
return;
};
let title = tool.initial_title(tool_use.input.clone(), cx);
let Ok(input) = tool_use.input.clone().into_json() else {
return;
};
let title = tool.initial_title(input.clone(), cx);
let kind = tool.kind();
stream.send_tool_call(
&tool_use.id,
&tool_use.name,
title,
kind,
tool_use.input.clone(),
);
stream.send_tool_call(&tool_use.id, &tool_use.name, title, kind, input.clone());
if let Some(content) = replay_content {
stream.update_tool_call_fields(
@@ -1635,8 +1632,7 @@ impl Thread {
self.sandbox_grants.clone(),
Some(cx.weak_entity()),
);
tool.replay(tool_use.input.clone(), output, tool_event_stream, cx)
.log_err();
tool.replay(input, output, tool_event_stream, cx).log_err();
}
stream.update_tool_call_fields(
@@ -3418,7 +3414,9 @@ impl Thread {
let mut title = SharedString::from(&tool_use.name);
let mut kind = acp::ToolKind::Other;
if let Some(tool) = tool.as_ref() {
title = tool.initial_title(tool_use.input.clone(), cx);
if let Ok(input) = tool_use.input.clone().into_json() {
title = tool.initial_title(input, cx);
}
kind = tool.kind();
}
@@ -3435,16 +3433,33 @@ impl Thread {
}));
};
// Agent tools are JSON-schema tools. Custom text-tool deltas are rejected
// before considering partial-vs-complete input for these local tools.
let input = match tool_use.input.clone().into_json() {
Ok(input) => input,
Err(error) => {
return Some(Task::ready(LanguageModelToolResult {
content: vec![LanguageModelToolResultContent::Text(Arc::from(
error.to_string(),
))],
tool_use_id: tool_use.id,
tool_name: tool_use.name,
is_error: true,
output: None,
}));
}
};
if !tool_use.is_input_complete {
if tool.supports_input_streaming() {
let running_turn = self.running_turn.as_mut()?;
if let Some(sender) = running_turn.streaming_tool_inputs.get_mut(&tool_use.id) {
sender.send_partial(tool_use.input);
sender.send_partial(input);
return None;
}
let (mut sender, tool_input) = ToolInputSender::channel();
sender.send_partial(tool_use.input);
sender.send_partial(input);
running_turn
.streaming_tool_inputs
.insert(tool_use.id.clone(), sender);
@@ -3471,12 +3486,12 @@ impl Thread {
.streaming_tool_inputs
.remove(&tool_use.id)
{
sender.send_full(tool_use.input);
sender.send_full(input);
return None;
}
log::debug!("Running tool {}", tool_use.name);
let tool_input = ToolInput::ready(tool_use.input);
let tool_input = ToolInput::ready(input);
Some(self.run_tool(
tool,
tool_input,
@@ -3600,7 +3615,7 @@ impl Thread {
id: tool_use_id,
name: tool_name,
raw_input: raw_input.to_string(),
input: serde_json::json!({}),
input: language_model::LanguageModelToolUseInput::Json(serde_json::json!({})),
is_input_complete: true,
thought_signature: None,
};
@@ -3676,7 +3691,7 @@ impl Thread {
&tool_use.name,
title,
kind,
tool_use.input.clone(),
tool_use.input.to_display_json(),
);
last_message
.content
@@ -3687,7 +3702,7 @@ impl Thread {
acp::ToolCallUpdateFields::new()
.title(title.as_str())
.kind(kind)
.raw_input(tool_use.input.clone()),
.raw_input(tool_use.input.to_display_json()),
None,
);
}
@@ -3927,12 +3942,12 @@ impl Thread {
.iter()
.filter_map(|(tool_name, tool)| {
log::trace!("Including tool: {}", tool_name);
Some(LanguageModelRequestTool {
name: tool_name.to_string(),
description: tool.description().to_string(),
input_schema: tool.input_schema(model.tool_input_format()).log_err()?,
use_input_streaming: tool.supports_input_streaming(),
})
Some(LanguageModelRequestTool::function(
tool_name.to_string(),
tool.description().to_string(),
tool.input_schema(model.tool_input_format()).log_err()?,
tool.supports_input_streaming(),
))
})
.collect::<Vec<_>>()
} else {
@@ -7870,7 +7885,7 @@ mod tests {
id: registered_tool_use_id.clone(),
name: ReplayImageTool::NAME.into(),
raw_input: "null".to_string(),
input: json!(null),
input: language_model::LanguageModelToolUseInput::Json(json!(null)),
is_input_complete: true,
thought_signature: None,
};
@@ -7878,7 +7893,7 @@ mod tests {
id: missing_tool_use_id.clone(),
name: "missing_image_tool".into(),
raw_input: "{}".to_string(),
input: json!({}),
input: language_model::LanguageModelToolUseInput::Json(json!({})),
is_input_complete: true,
thought_signature: None,
};
@@ -8185,7 +8200,10 @@ mod tests {
assert_eq!(tool_use.raw_input, raw_input.to_string());
assert!(tool_use.is_input_complete);
// Should fall back to empty object for invalid JSON
assert_eq!(tool_use.input, json!({}));
assert_eq!(
tool_use.input,
language_model::LanguageModelToolUseInput::Json(json!({}))
);
}
_ => panic!("Expected ToolUse content"),
}
+6 -6
View File
@@ -153,12 +153,12 @@ macro_rules! tools {
/// A list of all built-in tools
pub fn built_in_tools() -> impl Iterator<Item = LanguageModelRequestTool> {
fn language_model_tool<T: AgentTool>() -> LanguageModelRequestTool {
LanguageModelRequestTool {
name: T::NAME.to_string(),
description: T::description().to_string(),
input_schema: T::input_schema(LanguageModelToolSchemaFormat::JsonSchema).to_value(),
use_input_streaming: T::supports_input_streaming(),
}
LanguageModelRequestTool::function(
T::NAME.to_string(),
T::description().to_string(),
T::input_schema(LanguageModelToolSchemaFormat::JsonSchema).to_value(),
T::supports_input_streaming(),
)
}
[
$(
+6 -2
View File
@@ -503,7 +503,9 @@ impl EditToolTest {
if tool_use.is_input_complete
&& tool_use.name.as_ref() == EditFileTool::NAME =>
{
let input: EditFileToolInput = serde_json::from_value(tool_use.input)
let input: EditFileToolInput = tool_use
.input
.parse()
.context("Failed to parse tool input as EditFileToolInput")?;
return Ok(input);
}
@@ -607,7 +609,9 @@ fn tool_use(
id: LanguageModelToolUseId::from(id.into()),
name: name.into(),
raw_input: serde_json::to_string_pretty(&input).unwrap(),
input: serde_json::to_value(input).unwrap(),
input: language_model::LanguageModelToolUseInput::Json(
serde_json::to_value(input).unwrap(),
),
is_input_complete: true,
thought_signature: None,
})
@@ -327,7 +327,9 @@ async fn extract_tool_use(
Ok(LanguageModelCompletionEvent::ToolUse(tool_use))
if tool_use.is_input_complete && tool_use.name.as_ref() == TerminalTool::NAME =>
{
let input: TerminalToolInput = serde_json::from_value(tool_use.input)
let input: TerminalToolInput = tool_use
.input
.parse()
.context("Failed to parse tool input as TerminalToolInput")?;
return Ok(input);
}
+6 -2
View File
@@ -323,7 +323,9 @@ impl WriteToolTest {
if tool_use.is_input_complete
&& tool_use.name.as_ref() == WriteFileTool::NAME =>
{
let input: WriteFileToolInput = serde_json::from_value(tool_use.input)
let input: WriteFileToolInput = tool_use
.input
.parse()
.context("Failed to parse tool input as WriteFileToolInput")?;
return Ok(input);
}
@@ -410,7 +412,9 @@ fn tool_use(
id: LanguageModelToolUseId::from(id.into()),
name: name.into(),
raw_input: serde_json::to_string_pretty(&input).unwrap(),
input: serde_json::to_value(input).unwrap(),
input: language_model::LanguageModelToolUseInput::Json(
serde_json::to_value(input).unwrap(),
),
is_input_complete: true,
thought_signature: None,
})
+17 -19
View File
@@ -525,18 +525,18 @@ impl CodegenAlternative {
messages.push(user_message);
let tools = vec![
LanguageModelRequestTool {
name: REWRITE_SECTION_TOOL_NAME.to_string(),
description: "Replaces text in <rewrite_this></rewrite_this> tags with your replacement_text.".to_string(),
input_schema: language_model::tool_schema::root_schema_for::<RewriteSectionInput>(tool_input_format).to_value(),
use_input_streaming: false,
},
LanguageModelRequestTool {
name: FAILURE_MESSAGE_TOOL_NAME.to_string(),
description: "Use this tool to provide a message to the user when you're unable to complete a task.".to_string(),
input_schema: language_model::tool_schema::root_schema_for::<FailureMessageInput>(tool_input_format).to_value(),
use_input_streaming: false,
},
LanguageModelRequestTool::function(
REWRITE_SECTION_TOOL_NAME.to_string(),
"Replaces text in <rewrite_this></rewrite_this> tags with your replacement_text.".to_string(),
language_model::tool_schema::root_schema_for::<RewriteSectionInput>(tool_input_format).to_value(),
false,
),
LanguageModelRequestTool::function(
FAILURE_MESSAGE_TOOL_NAME.to_string(),
"Use this tool to provide a message to the user when you're unable to complete a task.".to_string(),
language_model::tool_schema::root_schema_for::<FailureMessageInput>(tool_input_format).to_value(),
false,
),
];
LanguageModelRequest {
@@ -1179,9 +1179,7 @@ impl CodegenAlternative {
let mut chars_read_by_tool_id = chars_read_by_tool_id.lock();
match tool_use.name.as_ref() {
REWRITE_SECTION_TOOL_NAME => {
let Ok(input) =
serde_json::from_value::<RewriteSectionInput>(tool_use.input)
else {
let Ok(input) = tool_use.input.parse::<RewriteSectionInput>() else {
return None;
};
let chars_read_so_far =
@@ -1198,9 +1196,7 @@ impl CodegenAlternative {
})
}
FAILURE_MESSAGE_TOOL_NAME => {
let Ok(mut input) =
serde_json::from_value::<FailureMessageInput>(tool_use.input)
else {
let Ok(mut input) = tool_use.input.parse::<FailureMessageInput>() else {
return None;
};
Some(ToolUseOutput::Failure(std::mem::take(&mut input.message)))
@@ -2011,7 +2007,9 @@ mod tests {
id: id.into(),
name: REWRITE_SECTION_TOOL_NAME.into(),
raw_input: serde_json::to_string(&input).unwrap(),
input: serde_json::to_value(&input).unwrap(),
input: language_model::LanguageModelToolUseInput::Json(
serde_json::to_value(&input).unwrap(),
),
is_input_complete: is_complete,
thought_signature: None,
})
+85 -65
View File
@@ -3,9 +3,9 @@ use collections::HashMap;
use futures::{Stream, StreamExt};
use language_model_core::{
CompactionContent, LanguageModelCompletionError, LanguageModelCompletionEvent,
LanguageModelProviderName, LanguageModelRequest, LanguageModelToolChoice,
LanguageModelToolResultContent, LanguageModelToolUse, MessageContent, Role, StopReason,
TokenUsage,
LanguageModelProviderName, LanguageModelRequest, LanguageModelRequestToolInput,
LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse,
LanguageModelToolUseInput, MessageContent, Role, StopReason, TokenUsage,
util::{fix_streamed_json, parse_tool_arguments},
};
use std::pin::Pin;
@@ -68,7 +68,7 @@ fn mark_last_cacheable_content(content: &mut [RequestContent], cache_control: Ca
}
}
fn to_anthropic_content(content: MessageContent) -> Option<RequestContent> {
fn to_anthropic_content(content: MessageContent) -> Result<Option<RequestContent>> {
match content {
MessageContent::Text(text) => {
let text = if text.chars().last().is_some_and(|c| c.is_whitespace()) {
@@ -77,12 +77,12 @@ fn to_anthropic_content(content: MessageContent) -> Option<RequestContent> {
text
};
if !text.is_empty() {
Some(RequestContent::Text {
Ok(Some(RequestContent::Text {
text,
cache_control: None,
})
}))
} else {
None
Ok(None)
}
}
MessageContent::Thinking {
@@ -92,36 +92,41 @@ fn to_anthropic_content(content: MessageContent) -> Option<RequestContent> {
if let Some(signature) = signature
&& !thinking.is_empty()
{
Some(RequestContent::Thinking {
Ok(Some(RequestContent::Thinking {
thinking,
signature,
cache_control: None,
})
}))
} else {
None
Ok(None)
}
}
MessageContent::RedactedThinking(data) => {
if !data.is_empty() {
Some(RequestContent::RedactedThinking { data })
Ok(Some(RequestContent::RedactedThinking { data }))
} else {
None
Ok(None)
}
}
MessageContent::Image(image) => Some(RequestContent::Image {
MessageContent::Image(image) => Ok(Some(RequestContent::Image {
source: ImageSource {
source_type: "base64".to_string(),
media_type: "image/png".to_string(),
data: image.source.to_string(),
},
cache_control: None,
}),
MessageContent::ToolUse(tool_use) => Some(RequestContent::ToolUse {
id: tool_use.id.to_string(),
name: tool_use.name.to_string(),
input: tool_use.input,
cache_control: None,
}),
})),
MessageContent::ToolUse(tool_use) => match tool_use.input {
LanguageModelToolUseInput::Json(input) => Ok(Some(RequestContent::ToolUse {
id: tool_use.id.to_string(),
name: tool_use.name.to_string(),
input,
cache_control: None,
})),
LanguageModelToolUseInput::Text(_) => Err(anyhow::anyhow!(
"Anthropic does not support custom tool calls"
)),
},
MessageContent::ToolResult(tool_result) => {
let content = match tool_result.content.as_slice() {
[LanguageModelToolResultContent::Text(text)] => {
@@ -147,24 +152,24 @@ fn to_anthropic_content(content: MessageContent) -> Option<RequestContent> {
ToolResultContent::Multipart(parts)
}
};
Some(RequestContent::ToolResult {
Ok(Some(RequestContent::ToolResult {
tool_use_id: tool_result.tool_use_id.to_string(),
is_error: tool_result.is_error,
content,
cache_control: None,
})
}))
}
MessageContent::Compaction(CompactionContent::Summary { content }) => {
Some(RequestContent::Compaction {
Ok(Some(RequestContent::Compaction {
content,
cache_control: None,
})
}))
}
// Encrypted compaction blocks come from other providers, and a
// Pending block is a streaming-only UI signal; neither is replayed.
MessageContent::Compaction(
CompactionContent::Encrypted { .. } | CompactionContent::Pending,
) => None,
) => Ok(None),
}
}
@@ -175,7 +180,7 @@ pub fn into_anthropic(
max_output_tokens: u64,
mode: AnthropicModelMode,
cache_mode: AnthropicPromptCacheMode,
) -> crate::Request {
) -> Result<crate::Request> {
let mut new_messages: Vec<Message> = Vec::new();
let mut system_message = String::new();
let mut any_message_wants_cache = false;
@@ -189,11 +194,12 @@ pub fn into_anthropic(
match message.role {
Role::User | Role::Assistant => {
let mut anthropic_message_content: Vec<RequestContent> = message
.content
.into_iter()
.filter_map(to_anthropic_content)
.collect();
let mut anthropic_message_content = Vec::new();
for content in message.content {
if let Some(content) = to_anthropic_content(content)? {
anthropic_message_content.push(content);
}
}
let anthropic_role = match message.role {
Role::User => crate::Role::User,
Role::Assistant => crate::Role::Assistant,
@@ -261,21 +267,29 @@ pub fn into_anthropic(
let mut tools: Vec<Tool> = request
.tools
.into_iter()
.map(|tool| Tool {
name: tool.name,
description: tool.description,
input_schema: tool.input_schema,
eager_input_streaming: tool.use_input_streaming,
cache_control: None,
.map(|tool| match tool.input {
LanguageModelRequestToolInput::Function {
input_schema,
use_input_streaming,
} => Ok(Tool {
name: tool.name,
description: tool.description,
input_schema,
eager_input_streaming: use_input_streaming,
cache_control: None,
}),
LanguageModelRequestToolInput::Custom { .. } => {
Err(anyhow::anyhow!("Anthropic does not support custom tools"))
}
})
.collect();
.collect::<Result<_>>()?;
if let Some(cache_control) = long_lived_cache
&& let Some(last_tool) = tools.last_mut()
{
last_tool.cache_control = Some(cache_control);
}
crate::Request {
Ok(crate::Request {
model,
messages: new_messages,
max_tokens: max_output_tokens,
@@ -339,7 +353,7 @@ pub fn into_anthropic(
trigger: Some(CompactionTrigger::InputTokens { value }),
}],
}),
}
})
}
pub struct AnthropicEventMapper {
@@ -451,7 +465,7 @@ impl AnthropicEventMapper {
name: tool_use.name.clone().into(),
is_input_complete: false,
raw_input: tool_use.input_json.clone(),
input,
input: LanguageModelToolUseInput::Json(input),
thought_signature: None,
},
))];
@@ -469,7 +483,7 @@ impl AnthropicEventMapper {
id: tool_use.id.into(),
name: tool_use.name.into(),
is_input_complete: true,
input,
input: LanguageModelToolUseInput::Json(input),
raw_input: tool_use.input_json.clone(),
thought_signature: None,
},
@@ -597,12 +611,12 @@ mod tests {
intent: None,
stop: vec![],
temperature: None,
tools: vec![language_model_core::LanguageModelRequestTool {
name: "do_thing".into(),
description: "Does a thing.".into(),
input_schema: serde_json::json!({"type": "object"}),
use_input_streaming: false,
}],
tools: vec![language_model_core::LanguageModelRequestTool::function(
"do_thing".into(),
"Does a thing.".into(),
serde_json::json!({"type": "object"}),
false,
)],
tool_choice: None,
thinking_allowed: true,
thinking_effort: None,
@@ -617,7 +631,8 @@ mod tests {
4096,
AnthropicModelMode::Default,
AnthropicPromptCacheMode::Automatic,
);
)
.unwrap();
// No message content block should carry cache_control anymore; the
// conversation breakpoint is set via top-level automatic caching.
@@ -703,12 +718,12 @@ mod tests {
intent: None,
stop: vec![],
temperature: None,
tools: vec![language_model_core::LanguageModelRequestTool {
name: "do_thing".into(),
description: "Does a thing.".into(),
input_schema: serde_json::json!({"type": "object"}),
use_input_streaming: false,
}],
tools: vec![language_model_core::LanguageModelRequestTool::function(
"do_thing".into(),
"Does a thing.".into(),
serde_json::json!({"type": "object"}),
false,
)],
tool_choice: None,
thinking_allowed: true,
thinking_effort: None,
@@ -723,7 +738,8 @@ mod tests {
4096,
AnthropicModelMode::Default,
AnthropicPromptCacheMode::Legacy,
);
)
.unwrap();
assert!(anthropic_request.cache_control.is_none());
assert!(matches!(
@@ -781,7 +797,8 @@ mod tests {
128_000,
AnthropicModelMode::AdaptiveThinking,
AnthropicPromptCacheMode::Automatic,
);
)
.unwrap();
assert_eq!(
anthropic_request
@@ -813,12 +830,12 @@ mod tests {
intent: None,
stop: vec![],
temperature: None,
tools: vec![language_model_core::LanguageModelRequestTool {
name: "do_thing".into(),
description: "Does a thing.".into(),
input_schema: serde_json::json!({"type": "object"}),
use_input_streaming: false,
}],
tools: vec![language_model_core::LanguageModelRequestTool::function(
"do_thing".into(),
"Does a thing.".into(),
serde_json::json!({"type": "object"}),
false,
)],
tool_choice: None,
thinking_allowed: true,
thinking_effort: None,
@@ -833,7 +850,8 @@ mod tests {
4096,
AnthropicModelMode::Default,
AnthropicPromptCacheMode::Automatic,
);
)
.unwrap();
assert!(anthropic_request.cache_control.is_none());
assert!(matches!(
@@ -879,6 +897,7 @@ mod tests {
},
AnthropicPromptCacheMode::Automatic,
)
.unwrap()
}
#[test]
@@ -977,7 +996,8 @@ mod tests {
4096,
AnthropicModelMode::Default,
AnthropicPromptCacheMode::Disabled,
);
)
.unwrap();
assert_eq!(
serde_json::to_value(&anthropic_request.context_management).unwrap(),
+74 -57
View File
@@ -2,8 +2,9 @@ use anyhow::Result;
use futures::{Stream, StreamExt};
use language_model_core::{
LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelRequest,
LanguageModelToolChoice, LanguageModelToolUse, LanguageModelToolUseId, MessageContent, Role,
StopReason, TokenUsage,
LanguageModelRequestToolInput, LanguageModelToolChoice, LanguageModelToolUse,
LanguageModelToolUseId, LanguageModelToolUseInput, MessageContent, Role, StopReason,
TokenUsage,
};
use std::pin::Pin;
use std::sync::Arc;
@@ -20,20 +21,18 @@ pub fn into_google(
mut request: LanguageModelRequest,
model_id: String,
mode: GoogleModelMode,
) -> crate::GenerateContentRequest {
fn map_content(content: Vec<MessageContent>) -> Vec<Part> {
content
.into_iter()
.flat_map(|content| match content {
) -> Result<crate::GenerateContentRequest> {
fn map_content(content: Vec<MessageContent>) -> Result<Vec<Part>> {
let mut mapped_parts = Vec::new();
for content in content {
match content {
MessageContent::Text(text) => {
if !text.is_empty() {
vec![Part::TextPart(TextPart {
mapped_parts.push(Part::TextPart(TextPart {
text,
thought: false,
thought_signature: None,
})]
} else {
vec![]
}));
}
}
MessageContent::Thinking {
@@ -41,39 +40,37 @@ pub fn into_google(
signature: Some(signature),
} => {
if !signature.is_empty() {
vec![Part::TextPart(TextPart {
mapped_parts.push(Part::TextPart(TextPart {
text,
thought: true,
thought_signature: Some(signature),
})]
} else {
vec![]
}));
}
}
MessageContent::Thinking { .. } => {
vec![]
}
MessageContent::RedactedThinking(_) | MessageContent::Compaction(_) => vec![],
MessageContent::Thinking { .. } => {}
MessageContent::RedactedThinking(_) | MessageContent::Compaction(_) => {}
MessageContent::Image(image) => {
vec![Part::InlineDataPart(InlineDataPart {
mapped_parts.push(Part::InlineDataPart(InlineDataPart {
inline_data: GenerativeContentBlob {
mime_type: "image/png".to_string(),
data: image.source.to_string(),
},
})]
}));
}
MessageContent::ToolUse(tool_use) => {
// Normalize empty string signatures to None
let thought_signature = tool_use.thought_signature.filter(|s| !s.is_empty());
let LanguageModelToolUseInput::Json(input) = tool_use.input else {
anyhow::bail!("Google AI does not support custom tool calls");
};
vec![Part::FunctionCallPart(crate::FunctionCallPart {
mapped_parts.push(Part::FunctionCallPart(crate::FunctionCallPart {
function_call: crate::FunctionCall {
name: tool_use.name.to_string(),
args: tool_use.input,
args: input,
id: Some(tool_use.id.to_string()),
},
thought_signature,
})]
}));
}
MessageContent::ToolResult(tool_result) => {
let mut text_output = String::new();
@@ -98,7 +95,7 @@ pub fn into_google(
} else {
text_output
};
let mut parts = vec![Part::FunctionResponsePart(crate::FunctionResponsePart {
mapped_parts.push(Part::FunctionResponsePart(crate::FunctionResponsePart {
function_response: crate::FunctionResponse {
name: tool_result.tool_name.to_string(),
// The API expects a valid JSON object
@@ -107,12 +104,12 @@ pub fn into_google(
}),
id: Some(tool_result.tool_use_id.to_string()),
},
})];
parts.extend(images.into_iter().map(Part::InlineDataPart));
parts
}));
mapped_parts.extend(images.into_iter().map(Part::InlineDataPart));
}
})
.collect()
}
}
Ok(mapped_parts)
}
let thinking_config = thinking_config_for_request(&request, &model_id, mode);
@@ -124,33 +121,59 @@ pub fn into_google(
{
let message = request.messages.remove(0);
Some(SystemInstruction {
parts: map_content(message.content),
parts: map_content(message.content)?,
})
} else {
None
};
crate::GenerateContentRequest {
let tools = if request.tools.is_empty() {
None
} else {
Some(vec![crate::Tool {
function_declarations: request
.tools
.into_iter()
.map(|tool| match tool.input {
LanguageModelRequestToolInput::Function { input_schema, .. } => {
Ok(FunctionDeclaration {
name: tool.name,
description: tool.description,
parameters: input_schema,
})
}
LanguageModelRequestToolInput::Custom { .. } => {
Err(anyhow::anyhow!("Google AI does not support custom tools"))
}
})
.collect::<Result<_>>()?,
}])
};
Ok(crate::GenerateContentRequest {
model: ModelName { model_id },
system_instruction: system_instructions,
contents: request
.messages
.into_iter()
.filter_map(|message| {
let parts = map_content(message.content);
.map(|message| {
let parts = map_content(message.content)?;
if parts.is_empty() {
None
Ok(None)
} else {
Some(Content {
Ok(Some(Content {
parts,
role: match message.role {
Role::User => crate::Role::User,
Role::Assistant => crate::Role::Model,
Role::System => crate::Role::User, // Google AI doesn't have a system role
},
})
}))
}
})
.collect::<Result<Vec<_>>>()?
.into_iter()
.flatten()
.collect(),
generation_config: Some(GenerationConfig {
candidate_count: Some(1),
@@ -162,19 +185,7 @@ pub fn into_google(
top_k: None,
}),
safety_settings: None,
tools: (!request.tools.is_empty()).then(|| {
vec![crate::Tool {
function_declarations: request
.tools
.into_iter()
.map(|tool| FunctionDeclaration {
name: tool.name,
description: tool.description,
parameters: tool.input_schema,
})
.collect(),
}]
}),
tools,
tool_config: request.tool_choice.map(|choice| ToolConfig {
function_calling_config: FunctionCallingConfig {
mode: match choice {
@@ -185,7 +196,7 @@ pub fn into_google(
allowed_function_names: None,
},
}),
}
})
}
fn thinking_config_for_request(
@@ -404,7 +415,9 @@ impl GoogleEventMapper {
name,
is_input_complete: true,
raw_input: function_call_part.function_call.args.to_string(),
input: function_call_part.function_call.args,
input: LanguageModelToolUseInput::Json(
function_call_part.function_call.args,
),
thought_signature,
},
)));
@@ -493,7 +506,8 @@ mod tests {
GoogleModelMode::Thinking {
budget_tokens: None,
},
);
)
.unwrap();
let thinking_config = request.generation_config.unwrap().thinking_config.unwrap();
assert_eq!(thinking_config.include_thoughts, Some(true));
@@ -515,7 +529,8 @@ mod tests {
GoogleModelMode::Thinking {
budget_tokens: None,
},
);
)
.unwrap();
let thinking_config = request.generation_config.unwrap().thinking_config.unwrap();
assert_eq!(thinking_config.thinking_budget, Some(0));
@@ -533,7 +548,8 @@ mod tests {
GoogleModelMode::Thinking {
budget_tokens: None,
},
);
)
.unwrap();
let thinking_config = request.generation_config.unwrap().thinking_config.unwrap();
assert_eq!(thinking_config.thinking_level, Some(ThinkingLevel::Minimal));
@@ -561,7 +577,8 @@ mod tests {
GoogleModelMode::Thinking {
budget_tokens: None,
},
);
)
.unwrap();
let Part::TextPart(text_part) = &request.contents[0].parts[0] else {
panic!("expected text part");
@@ -5,7 +5,7 @@ mod role;
pub mod tool_schema;
pub mod util;
use anyhow::{Result, anyhow};
use anyhow::{Context as _, Result, anyhow};
use cloud_llm_client::CompletionRequestStatus;
use http_client::{StatusCode, http};
use schemars::JsonSchema;
@@ -351,13 +351,101 @@ pub struct LanguageModelToolUse {
pub id: LanguageModelToolUseId,
pub name: Arc<str>,
pub raw_input: String,
pub input: serde_json::Value,
pub input: LanguageModelToolUseInput,
pub is_input_complete: bool,
/// Thought signature the model sent us. Some models require that this
/// signature be preserved and sent back in conversation history for validation.
pub thought_signature: Option<String>,
}
#[derive(Debug, PartialEq, Eq, Hash, Clone)]
pub enum LanguageModelToolUseInput {
Json(serde_json::Value),
Text(String),
}
impl Serialize for LanguageModelToolUseInput {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeStruct;
let mut state = serializer.serialize_struct("LanguageModelToolUseInput", 2)?;
match self {
Self::Json(input) => {
state.serialize_field("type", "json")?;
state.serialize_field("value", input)?;
}
Self::Text(input) => {
state.serialize_field("type", "text")?;
state.serialize_field("value", input)?;
}
}
state.end()
}
}
impl<'de> Deserialize<'de> for LanguageModelToolUseInput {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
if let Some(object) = value.as_object()
&& object.len() == 2
&& let Some(input_type) = object.get("type").and_then(|value| value.as_str())
&& let Some(input) = object.get("value")
{
return match input_type {
"json" => Ok(Self::Json(input.clone())),
"text" => input
.as_str()
.map(|input| Self::Text(input.to_string()))
.ok_or_else(|| serde::de::Error::custom("text tool input must be a string")),
_ => Ok(Self::Json(value)),
};
}
Ok(Self::Json(value))
}
}
impl LanguageModelToolUseInput {
pub fn as_json(&self) -> Option<&serde_json::Value> {
match self {
Self::Json(input) => Some(input),
Self::Text(_) => None,
}
}
/// Typed parsing for JSON tool inputs; freeform (Text) inputs always error.
///
/// Callers wanting the raw value should use [`Self::as_json`] or [`Self::into_json`].
pub fn parse<T: serde::de::DeserializeOwned>(&self) -> Result<T> {
match self {
Self::Json(input) => {
serde_json::from_value(input.clone()).context("failed to parse JSON tool input")
}
Self::Text(_) => Err(anyhow!("custom tool text input cannot be parsed as JSON")),
}
}
pub fn into_json(self) -> Result<serde_json::Value> {
match self {
Self::Json(input) => Ok(input),
Self::Text(_) => Err(anyhow!("custom tool text input cannot be used as JSON")),
}
}
pub fn to_display_json(&self) -> serde_json::Value {
match self {
Self::Json(input) => input.clone(),
Self::Text(input) => serde_json::Value::String(input.clone()),
}
}
}
#[derive(Debug, Clone)]
pub struct LanguageModelEffortLevel {
pub name: SharedString,
@@ -624,7 +712,7 @@ mod tests {
id: LanguageModelToolUseId::from("test_id"),
name: "test_tool".into(),
raw_input: json!({"arg": "value"}).to_string(),
input: json!({"arg": "value"}),
input: LanguageModelToolUseInput::Json(json!({"arg": "value"})),
is_input_complete: true,
thought_signature: Some("test_signature".to_string()),
};
@@ -652,9 +740,97 @@ mod tests {
assert_eq!(tool_use.id, LanguageModelToolUseId::from("test_id"));
assert_eq!(tool_use.name.as_ref(), "test_tool");
assert_eq!(
tool_use.input,
LanguageModelToolUseInput::Json(json!({"arg": "value"}))
);
assert_eq!(tool_use.thought_signature, None);
}
#[test]
fn test_language_model_tool_use_input_round_trips_json() {
use serde_json::json;
let input = LanguageModelToolUseInput::Json(json!({"arg": "value"}));
let serialized = serde_json::to_value(&input).unwrap();
assert_eq!(
serialized,
json!({
"type": "json",
"value": {"arg": "value"}
})
);
let deserialized: LanguageModelToolUseInput = serde_json::from_value(serialized).unwrap();
assert_eq!(deserialized, input);
}
#[test]
fn test_language_model_tool_use_input_round_trips_text() {
use serde_json::json;
let input = LanguageModelToolUseInput::Text("raw custom input".to_string());
let serialized = serde_json::to_value(&input).unwrap();
assert_eq!(
serialized,
json!({
"type": "text",
"value": "raw custom input"
})
);
let deserialized: LanguageModelToolUseInput = serde_json::from_value(serialized).unwrap();
assert_eq!(deserialized, input);
}
#[test]
fn test_language_model_tool_use_input_parse() {
use serde_json::json;
#[derive(Debug, Deserialize, PartialEq)]
struct TestInput {
arg: String,
}
let parsed: TestInput = LanguageModelToolUseInput::Json(json!({"arg": "value"}))
.parse()
.unwrap();
assert_eq!(
parsed,
TestInput {
arg: "value".to_string()
}
);
let error = LanguageModelToolUseInput::Text("raw custom input".to_string())
.parse::<TestInput>()
.unwrap_err();
assert!(
error
.to_string()
.contains("custom tool text input cannot be parsed as JSON")
);
}
#[test]
fn test_language_model_tool_use_input_deserializes_legacy_plain_json_as_json() {
use serde_json::json;
let deserialized: LanguageModelToolUseInput =
serde_json::from_value(json!({"arg": "value"})).unwrap();
assert_eq!(
deserialized,
LanguageModelToolUseInput::Json(json!({"arg": "value"}))
);
let deserialized: LanguageModelToolUseInput =
serde_json::from_value(json!("legacy string argument")).unwrap();
assert_eq!(
deserialized,
LanguageModelToolUseInput::Json(json!("legacy string argument"))
);
}
#[test]
fn test_language_model_tool_use_round_trip_with_signature() {
use serde_json::json;
@@ -663,7 +839,7 @@ mod tests {
id: LanguageModelToolUseId::from("round_trip_id"),
name: "round_trip_tool".into(),
raw_input: json!({"key": "value"}).to_string(),
input: json!({"key": "value"}),
input: LanguageModelToolUseInput::Json(json!({"key": "value"})),
is_input_complete: true,
thought_signature: Some("round_trip_sig".to_string()),
};
@@ -684,7 +860,7 @@ mod tests {
id: LanguageModelToolUseId::from("no_sig_id"),
name: "no_sig_tool".into(),
raw_input: json!({"arg": "value"}).to_string(),
input: json!({"arg": "value"}),
input: LanguageModelToolUseInput::Json(json!({"arg": "value"})),
is_input_complete: true,
thought_signature: None,
};
+68 -3
View File
@@ -3,7 +3,9 @@ use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::role::Role;
use crate::{LanguageModelToolUse, LanguageModelToolUseId, SharedString};
use crate::{
LanguageModelToolUse, LanguageModelToolUseId, LanguageModelToolUseInput, SharedString,
};
/// Dimensions of a `LanguageModelImage`
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -346,8 +348,52 @@ impl LanguageModelRequestMessage {
pub struct LanguageModelRequestTool {
pub name: String,
pub description: String,
pub input_schema: serde_json::Value,
pub use_input_streaming: bool,
pub input: LanguageModelRequestToolInput,
}
impl LanguageModelRequestTool {
pub fn function(
name: String,
description: String,
input_schema: serde_json::Value,
use_input_streaming: bool,
) -> Self {
Self {
name,
description,
input: LanguageModelRequestToolInput::Function {
input_schema,
use_input_streaming,
},
}
}
}
#[derive(Debug, PartialEq, Hash, Clone, Serialize, Deserialize)]
pub enum LanguageModelRequestToolInput {
Function {
input_schema: serde_json::Value,
use_input_streaming: bool,
},
Custom {
format: Option<LanguageModelCustomToolFormat>,
},
}
#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize)]
pub enum LanguageModelCustomToolFormat {
Text,
Grammar {
syntax: LanguageModelCustomToolGrammarSyntax,
definition: String,
},
}
#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LanguageModelCustomToolGrammarSyntax {
Lark,
Regex,
}
#[derive(Debug, PartialEq, Hash, Clone, Serialize, Deserialize)]
@@ -389,6 +435,25 @@ pub struct LanguageModelRequest {
pub compact_at_tokens: Option<u64>,
}
impl LanguageModelRequest {
pub fn contains_custom_tool_input(&self) -> bool {
self.tools
.iter()
.any(|tool| matches!(tool.input, LanguageModelRequestToolInput::Custom { .. }))
|| self.messages.iter().any(|message| {
message.content.iter().any(|content| {
matches!(
content,
MessageContent::ToolUse(LanguageModelToolUse {
input: LanguageModelToolUseInput::Text(_),
..
})
)
})
})
}
}
#[derive(
Clone, Copy, Default, Debug, Serialize, Deserialize, PartialEq, Eq, schemars::JsonSchema,
)]
@@ -528,14 +528,17 @@ impl LanguageModel for AnthropicModel {
> {
let has_tools = !request.tools.is_empty();
let request_id = self.model.request_id(has_tools).to_string();
let mut request = into_anthropic(
let mut request = match into_anthropic(
request,
request_id,
self.model.default_temperature,
self.model.max_output_tokens,
self.model.mode.clone(),
AnthropicPromptCacheMode::Automatic,
);
) {
Ok(request) => request,
Err(error) => return async move { Err(error.into()) }.boxed(),
};
if !self.model.supports_speed {
request.speed = None;
}
@@ -333,14 +333,17 @@ impl LanguageModel for AnthropicCompatibleLanguageModel {
> {
let has_tools = !request.tools.is_empty();
let request_id = self.model.request_id(has_tools).to_string();
let mut request = into_anthropic(
let mut request = match into_anthropic(
request,
request_id,
self.model.default_temperature,
self.model.max_output_tokens,
self.model.mode.clone(),
self.cache_mode,
);
) {
Ok(request) => request,
Err(error) => return async move { Err(error.into()) }.boxed(),
};
if !self.model.supports_speed {
request.speed = None;
}
+47 -15
View File
@@ -937,6 +937,13 @@ impl LanguageModel for BedrockModel {
LanguageModelCompletionError,
>,
> {
if request.contains_custom_tool_input() {
return async move {
Err(anyhow::anyhow!("Bedrock does not support custom tools").into())
}
.boxed();
}
let (region, allow_global, guardrail_identifier, guardrail_version) =
cx.read_entity(&self.state, |state, _cx| {
let (gid, gv) = state.get_guardrail_config();
@@ -1477,7 +1484,7 @@ impl LanguageModel for BedrockMantleModel {
}
MantleProtocol::ChatCompletions => {
let reasoning_effort = mantle_selected_reasoning_effort(&request, &self.model);
let request = into_open_ai(
let request = match into_open_ai(
request,
&model_id,
self.model.supports_tools(),
@@ -1486,7 +1493,10 @@ impl LanguageModel for BedrockMantleModel {
ChatCompletionMaxTokensParameter::MaxCompletionTokens,
reasoning_effort,
false,
);
) {
Ok(request) => request,
Err(error) => return async move { Err(error.into()) }.boxed(),
};
let completions = self.stream_completion(request, cx);
async move {
let mapper = OpenAiEventMapper::new();
@@ -1526,6 +1536,10 @@ pub fn into_bedrock(
guardrail_identifier: Option<String>,
guardrail_version: Option<String>,
) -> Result<bedrock::Request> {
if request.contains_custom_tool_input() {
anyhow::bail!("Bedrock does not support custom tools");
}
let mut new_messages: Vec<BedrockMessage> = Vec::new();
let mut system_message = String::new();
@@ -1588,12 +1602,19 @@ pub fn into_bedrock(
}
MessageContent::ToolUse(tool_use) => {
messages_contain_tool_content = true;
let input = if tool_use.input.is_null() {
// Bedrock API requires valid JsonValue, not null, for tool use input
value_to_aws_document(&serde_json::json!({}))
} else {
value_to_aws_document(&tool_use.input)
};
let input =
if let language_model::LanguageModelToolUseInput::Json(input) =
&tool_use.input
{
if input.is_null() {
// Bedrock API requires valid JsonValue, not null, for tool use input
value_to_aws_document(&serde_json::json!({}))
} else {
value_to_aws_document(input)
}
} else {
value_to_aws_document(&serde_json::json!({}))
};
BedrockToolUseBlock::builder()
.name(tool_use.name.to_string())
.tool_use_id(tool_use.id.to_string())
@@ -1727,19 +1748,25 @@ pub fn into_bedrock(
request
.tools
.iter()
.filter_map(|tool| {
Some(BedrockTool::ToolSpec(
.map(|tool| {
let language_model::LanguageModelRequestToolInput::Function {
input_schema, ..
} = &tool.input
else {
anyhow::bail!("Bedrock does not support custom tools");
};
Ok(BedrockTool::ToolSpec(
BedrockToolSpec::builder()
.name(tool.name.clone())
.description(tool.description.clone())
.input_schema(BedrockToolInputSchema::Json(value_to_aws_document(
&tool.input_schema,
input_schema,
)))
.build()
.log_err()?,
.context("failed to build Bedrock tool spec")?,
))
})
.collect()
.collect::<Result<_>>()?
} else {
Vec::new()
};
@@ -1894,7 +1921,10 @@ pub fn map_to_language_model_completion_events(
name: tool_use.name.clone().into(),
is_input_complete: false,
raw_input: tool_use.input_json.clone(),
input,
input:
language_model::LanguageModelToolUseInput::Json(
input,
),
thought_signature: None,
},
)))
@@ -1959,7 +1989,9 @@ pub fn map_to_language_model_completion_events(
name: tool_use.name.into(),
is_input_complete: true,
raw_input: tool_use.input_json,
input,
input: language_model::LanguageModelToolUseInput::Json(
input,
),
thought_signature: None,
},
))
@@ -368,7 +368,7 @@ impl LanguageModel for CopilotChatLanguageModel {
AnthropicModelMode::Default
},
AnthropicPromptCacheMode::Legacy,
);
)?;
anthropic_request.temperature = None;
@@ -423,7 +423,10 @@ impl LanguageModel for CopilotChatLanguageModel {
if self.model.supports_response() {
let location = intent_to_chat_location(request.intent);
let responses_request = into_copilot_responses(&self.model, request);
let responses_request = match into_copilot_responses(&self.model, request) {
Ok(request) => request,
Err(error) => return async move { Err(error.into()) }.boxed(),
};
let request_limiter = self.request_limiter.clone();
let future = cx.spawn(async move |cx| {
let request = CopilotChat::stream_response(
@@ -567,7 +570,9 @@ pub fn map_to_language_model_completion_events(
id: entry.id.clone().into(),
name: entry.name.as_str().into(),
is_input_complete: false,
input,
input: language_model::LanguageModelToolUseInput::Json(
input,
),
raw_input: entry.arguments.clone(),
thought_signature: entry.thought_signature.clone(),
},
@@ -629,7 +634,10 @@ pub fn map_to_language_model_completion_events(
id: tool_call.id.into(),
name: tool_call.name.as_str().into(),
is_input_complete: true,
input,
input:
language_model::LanguageModelToolUseInput::Json(
input,
),
raw_input: tool_call.arguments,
thought_signature: tool_call.thought_signature,
},
@@ -734,7 +742,7 @@ impl CopilotResponsesEventMapper {
id: call_id.into(),
name: name.as_str().into(),
is_input_complete: true,
input,
input: language_model::LanguageModelToolUseInput::Json(input),
raw_input: arguments.clone(),
thought_signature,
},
@@ -1054,12 +1062,15 @@ fn into_copilot_chat(
let mut tool_calls = Vec::new();
for content in &message.content {
if let MessageContent::ToolUse(tool_use) = content {
let input = tool_use.input.as_json().ok_or_else(|| {
anyhow!("Copilot Chat does not support custom tool calls")
})?;
tool_calls.push(ToolCall {
id: tool_use.id.to_string(),
content: ToolCallContent::Function {
function: FunctionContent {
name: tool_use.name.to_string(),
arguments: serde_json::to_string(&tool_use.input)?,
arguments: serde_json::to_string(input)?,
thought_signature: tool_use.thought_signature.clone(),
},
},
@@ -1120,14 +1131,21 @@ fn into_copilot_chat(
let tools = request
.tools
.iter()
.map(|tool| Tool::Function {
function: Function {
name: tool.name.clone(),
description: tool.description.clone(),
parameters: tool.input_schema.clone(),
},
.map(|tool| match &tool.input {
language_model::LanguageModelRequestToolInput::Function { input_schema, .. } => {
Ok(Tool::Function {
function: Function {
name: tool.name.clone(),
description: tool.description.clone(),
parameters: input_schema.clone(),
},
})
}
language_model::LanguageModelRequestToolInput::Custom { .. } => Err(anyhow::anyhow!(
"Copilot Chat does not support custom tools"
)),
})
.collect::<Vec<_>>();
.collect::<Result<Vec<_>>>()?;
Ok(CopilotChatRequest {
n: 1,
@@ -1188,7 +1206,7 @@ fn intent_to_chat_location(intent: Option<CompletionIntent>) -> ChatLocation {
fn into_copilot_responses(
model: &CopilotChatModel,
request: LanguageModelRequest,
) -> copilot_responses::Request {
) -> Result<copilot_responses::Request> {
use copilot_responses as responses;
let LanguageModelRequest {
@@ -1356,13 +1374,20 @@ fn into_copilot_responses(
let converted_tools: Vec<responses::ToolDefinition> = tools
.into_iter()
.map(|tool| responses::ToolDefinition::Function {
name: tool.name,
description: Some(tool.description),
parameters: Some(tool.input_schema),
strict: None,
.map(|tool| match tool.input {
language_model::LanguageModelRequestToolInput::Function { input_schema, .. } => {
Ok(responses::ToolDefinition::Function {
name: tool.name,
description: Some(tool.description),
parameters: Some(input_schema),
strict: None,
})
}
language_model::LanguageModelRequestToolInput::Custom { .. } => Err(anyhow::anyhow!(
"Copilot Chat does not support custom tools"
)),
})
.collect();
.collect::<Result<_>>()?;
let mapped_tool_choice = tool_choice.map(|choice| match choice {
LanguageModelToolChoice::Auto => responses::ToolChoice::Auto,
@@ -1370,7 +1395,7 @@ fn into_copilot_responses(
LanguageModelToolChoice::None => responses::ToolChoice::None,
});
responses::Request {
Ok(responses::Request {
model: model.id().to_string(),
input: input_items,
stream: model.uses_streaming(),
@@ -1393,7 +1418,7 @@ fn into_copilot_responses(
copilot_responses::ResponseIncludable::ReasoningEncryptedContent,
]),
store: false,
}
})
}
#[cfg(test)]
@@ -1671,7 +1696,7 @@ mod tests {
..Default::default()
};
let serialized = serde_json::to_value(into_copilot_responses(&model, request))
let serialized = serde_json::to_value(into_copilot_responses(&model, request).unwrap())
.expect("serialized request");
let input = serialized["input"].as_array().expect("input items");
+36 -15
View File
@@ -338,7 +338,10 @@ impl LanguageModel for DeepSeekLanguageModel {
LanguageModelCompletionError,
>,
> {
let request = into_deepseek(request, &self.model, self.max_output_tokens());
let request = match into_deepseek(request, &self.model, self.max_output_tokens()) {
Ok(request) => request,
Err(error) => return async move { Err(error.into()) }.boxed(),
};
let stream = self.stream_completion(request, cx);
async move {
@@ -353,7 +356,11 @@ pub fn into_deepseek(
request: LanguageModelRequest,
model: &deepseek::Model,
max_output_tokens: Option<u64>,
) -> deepseek::Request {
) -> Result<deepseek::Request> {
if request.contains_custom_tool_input() {
anyhow::bail!("DeepSeek does not support custom tools");
}
let thinking = deepseek_thinking(model, request.thinking_allowed);
let thinking_enabled = thinking
.as_ref()
@@ -392,13 +399,16 @@ pub fn into_deepseek(
MessageContent::Image(_) => {}
MessageContent::Compaction(_) => {}
MessageContent::ToolUse(tool_use) => {
let input = tool_use
.input
.as_json()
.ok_or_else(|| anyhow!("DeepSeek does not support custom tool calls"))?;
let tool_call = deepseek::ToolCall {
id: tool_use.id.to_string(),
content: deepseek::ToolCallContent::Function {
function: deepseek::FunctionContent {
name: tool_use.name.to_string(),
arguments: serde_json::to_string(&tool_use.input)
.unwrap_or_default(),
arguments: serde_json::to_string(input).unwrap_or_default(),
},
},
};
@@ -441,7 +451,7 @@ pub fn into_deepseek(
}
}
deepseek::Request {
Ok(deepseek::Request {
model: model.id().to_string(),
messages,
stream: true,
@@ -466,15 +476,26 @@ pub fn into_deepseek(
tools: request
.tools
.into_iter()
.map(|tool| deepseek::ToolDefinition::Function {
function: deepseek::FunctionDefinition {
name: tool.name,
description: Some(tool.description),
parameters: Some(tool.input_schema),
},
.map(|tool| {
let input_schema = match tool.input {
language_model::LanguageModelRequestToolInput::Function {
input_schema,
..
} => input_schema,
language_model::LanguageModelRequestToolInput::Custom { .. } => {
return Err(anyhow::anyhow!("DeepSeek does not support custom tools"));
}
};
Ok(deepseek::ToolDefinition::Function {
function: deepseek::FunctionDefinition {
name: tool.name,
description: Some(tool.description),
parameters: Some(input_schema),
},
})
})
.collect(),
}
.collect::<Result<_>>()?,
})
}
fn deepseek_thinking(
@@ -578,7 +599,7 @@ impl DeepSeekEventMapper {
id: entry.id.clone().into(),
name: entry.name.as_str().into(),
is_input_complete: false,
input,
input: language_model::LanguageModelToolUseInput::Json(input),
raw_input: entry.arguments.clone(),
thought_signature: None,
},
@@ -609,7 +630,7 @@ impl DeepSeekEventMapper {
id: tool_call.id.clone().into(),
name: tool_call.name.as_str().into(),
is_input_complete: true,
input,
input: language_model::LanguageModelToolUseInput::Json(input),
raw_input: tool_call.arguments.clone(),
thought_signature: None,
},
@@ -358,11 +358,14 @@ impl LanguageModel for GoogleLanguageModel {
LanguageModelCompletionError,
>,
> {
let request = into_google(
let request = match into_google(
request,
self.model.request_id().to_string(),
self.model.mode(),
);
) {
Ok(request) => request,
Err(error) => return async move { Err(error.into()) }.boxed(),
};
let request = self.stream_completion(request, cx);
let future = self.request_limiter.stream(async move {
let response = request.await.map_err(LanguageModelCompletionError::from)?;
@@ -650,7 +650,7 @@ impl LlamaCppLanguageModel {
fn to_llama_cpp_request(
&self,
request: LanguageModelRequest,
) -> llama_cpp::ChatCompletionRequest {
) -> Result<llama_cpp::ChatCompletionRequest> {
build_llama_cpp_request(
&self.name,
self.supports_images,
@@ -697,7 +697,11 @@ fn build_llama_cpp_request(
supports_images: bool,
capabilities: LiveCapabilities,
request: LanguageModelRequest,
) -> llama_cpp::ChatCompletionRequest {
) -> Result<llama_cpp::ChatCompletionRequest> {
if request.contains_custom_tool_input() {
anyhow::bail!("llama.cpp does not support custom tools");
}
let supports_tools = capabilities.supports_tools;
let supports_thinking = capabilities.supports_thinking;
let mut messages = Vec::new();
@@ -743,13 +747,15 @@ fn build_llama_cpp_request(
}
}
MessageContent::ToolUse(tool_use) => {
let input = tool_use.input.as_json().ok_or_else(|| {
anyhow::anyhow!("llama.cpp does not support custom tool calls")
})?;
let tool_call = llama_cpp::ToolCall {
id: tool_use.id.to_string(),
content: llama_cpp::ToolCallContent::Function {
function: llama_cpp::FunctionContent {
name: tool_use.name.to_string(),
arguments: serde_json::to_string(&tool_use.input)
.unwrap_or_default(),
arguments: serde_json::to_string(input).unwrap_or_default(),
},
},
};
@@ -811,14 +817,25 @@ fn build_llama_cpp_request(
request
.tools
.into_iter()
.map(|tool| llama_cpp::ToolDefinition::Function {
function: llama_cpp::FunctionDefinition {
name: tool.name,
description: Some(tool.description),
parameters: Some(tool.input_schema),
},
.map(|tool| {
let input_schema = match tool.input {
language_model::LanguageModelRequestToolInput::Function {
input_schema,
..
} => input_schema,
language_model::LanguageModelRequestToolInput::Custom { .. } => {
return Err(anyhow::anyhow!("llama.cpp does not support custom tools"));
}
};
Ok(llama_cpp::ToolDefinition::Function {
function: llama_cpp::FunctionDefinition {
name: tool.name,
description: Some(tool.description),
parameters: Some(input_schema),
},
})
})
.collect()
.collect::<Result<_>>()?
} else {
Vec::new()
};
@@ -834,7 +851,7 @@ fn build_llama_cpp_request(
})
};
llama_cpp::ChatCompletionRequest {
Ok(llama_cpp::ChatCompletionRequest {
model: model_name.to_string(),
messages,
stream: true,
@@ -853,7 +870,7 @@ fn build_llama_cpp_request(
stream_options: Some(llama_cpp::StreamOptions {
include_usage: true,
}),
}
})
}
impl LanguageModel for LlamaCppLanguageModel {
@@ -919,7 +936,10 @@ impl LanguageModel for LlamaCppLanguageModel {
LanguageModelCompletionError,
>,
> {
let request = self.to_llama_cpp_request(request);
let request = match self.to_llama_cpp_request(request) {
Ok(request) => request,
Err(error) => return async move { Err(error.into()) }.boxed(),
};
let completions = self.stream_completion(request, cx);
async move {
let mapper = LlamaCppEventMapper::new();
@@ -1019,7 +1039,9 @@ impl LlamaCppEventMapper {
id: tool_call.id.into(),
name: tool_call.name.into(),
is_input_complete: true,
input,
input: language_model::LanguageModelToolUseInput::Json(
input,
),
raw_input: tool_call.arguments,
thought_signature: None,
},
@@ -1829,7 +1851,8 @@ mod tests {
}],
..Default::default()
},
);
)
.unwrap();
assert_eq!(request.messages.len(), 1);
match &request.messages[0] {
@@ -1872,7 +1895,8 @@ mod tests {
}],
..Default::default()
},
);
)
.unwrap();
assert_eq!(request.messages.len(), 1);
match &request.messages[0] {
@@ -1882,7 +1906,7 @@ mod tests {
tool_calls,
} => {
assert_eq!(content, "answer");
assert_eq!(reasoning_content, &None);
assert!(reasoning_content.is_none());
assert!(tool_calls.is_empty());
}
message => panic!("unexpected message: {message:?}"),
@@ -1911,7 +1935,9 @@ mod tests {
id: "call_1".into(),
name: "weather".into(),
raw_input: r#"{"city":"Oslo"}"#.to_string(),
input: serde_json::json!({ "city": "Oslo" }),
input: language_model::LanguageModelToolUseInput::Json(
serde_json::json!({ "city": "Oslo" }),
),
is_input_complete: true,
thought_signature: None,
}),
@@ -1921,7 +1947,8 @@ mod tests {
}],
..Default::default()
},
);
)
.unwrap();
assert_eq!(request.messages.len(), 1);
match &request.messages[0] {
+34 -14
View File
@@ -338,7 +338,11 @@ impl LmStudioLanguageModel {
fn to_lmstudio_request(
&self,
request: LanguageModelRequest,
) -> lmstudio::ChatCompletionRequest {
) -> Result<lmstudio::ChatCompletionRequest> {
if request.contains_custom_tool_input() {
anyhow::bail!("LM Studio does not support custom tools");
}
let mut messages = Vec::new();
for message in request.messages {
@@ -365,13 +369,15 @@ impl LmStudioLanguageModel {
);
}
MessageContent::ToolUse(tool_use) => {
let input = tool_use.input.as_json().ok_or_else(|| {
anyhow!("LM Studio does not support custom tool calls")
})?;
let tool_call = lmstudio::ToolCall {
id: tool_use.id.to_string(),
content: lmstudio::ToolCallContent::Function {
function: lmstudio::FunctionContent {
name: tool_use.name.to_string(),
arguments: serde_json::to_string(&tool_use.input)
.unwrap_or_default(),
arguments: serde_json::to_string(input).unwrap_or_default(),
},
},
};
@@ -417,7 +423,7 @@ impl LmStudioLanguageModel {
}
}
lmstudio::ChatCompletionRequest {
Ok(lmstudio::ChatCompletionRequest {
model: self.model.name.clone(),
messages,
stream: true,
@@ -430,20 +436,31 @@ impl LmStudioLanguageModel {
tools: request
.tools
.into_iter()
.map(|tool| lmstudio::ToolDefinition::Function {
function: lmstudio::FunctionDefinition {
name: tool.name,
description: Some(tool.description),
parameters: Some(tool.input_schema),
},
.map(|tool| {
let input_schema = match tool.input {
language_model::LanguageModelRequestToolInput::Function {
input_schema,
..
} => input_schema,
language_model::LanguageModelRequestToolInput::Custom { .. } => {
return Err(anyhow::anyhow!("LM Studio does not support custom tools"));
}
};
Ok(lmstudio::ToolDefinition::Function {
function: lmstudio::FunctionDefinition {
name: tool.name,
description: Some(tool.description),
parameters: Some(input_schema),
},
})
})
.collect(),
.collect::<Result<_>>()?,
tool_choice: request.tool_choice.map(|choice| match choice {
LanguageModelToolChoice::Auto => lmstudio::ToolChoice::Auto,
LanguageModelToolChoice::Any => lmstudio::ToolChoice::Required,
LanguageModelToolChoice::None => lmstudio::ToolChoice::None,
}),
}
})
}
fn stream_completion(
@@ -533,7 +550,10 @@ impl LanguageModel for LmStudioLanguageModel {
LanguageModelCompletionError,
>,
> {
let request = self.to_lmstudio_request(request);
let request = match self.to_lmstudio_request(request) {
Ok(request) => request,
Err(error) => return async move { Err(error.into()) }.boxed(),
};
let completions = self.stream_completion(request, cx);
async move {
let mapper = LmStudioEventMapper::new();
@@ -637,7 +657,7 @@ impl LmStudioEventMapper {
id: tool_call.id.into(),
name: tool_call.name.into(),
is_input_complete: true,
input,
input: language_model::LanguageModelToolUseInput::Json(input),
raw_input: tool_call.arguments,
thought_signature: None,
},
+42 -18
View File
@@ -343,7 +343,10 @@ impl LanguageModel for MistralLanguageModel {
>,
> {
let (request, affinity) =
into_mistral(request, self.model.clone(), self.max_output_tokens());
match into_mistral(request, self.model.clone(), self.max_output_tokens()) {
Ok(request) => request,
Err(error) => return async move { Err(error.into()) }.boxed(),
};
let stream = self.stream_completion(request, affinity, cx);
async move {
@@ -359,7 +362,11 @@ pub fn into_mistral(
request: LanguageModelRequest,
model: mistral::Model,
max_output_tokens: Option<u64>,
) -> (mistral::Request, Option<String>) {
) -> Result<(mistral::Request, Option<String>)> {
if request.contains_custom_tool_input() {
anyhow::bail!("Mistral does not support custom tools");
}
let stream = true;
let mut messages = Vec::new();
@@ -452,13 +459,15 @@ pub fn into_mistral(
MessageContent::Image(_) => {}
MessageContent::Compaction(_) => {}
MessageContent::ToolUse(tool_use) => {
let input = tool_use.input.as_json().ok_or_else(|| {
anyhow!("Mistral does not support custom tool calls")
})?;
let tool_call = mistral::ToolCall {
id: tool_use.id.to_string(),
content: mistral::ToolCallContent::Function {
function: mistral::FunctionContent {
name: tool_use.name.to_string(),
arguments: serde_json::to_string(&tool_use.input)
.unwrap_or_default(),
arguments: serde_json::to_string(input).unwrap_or_default(),
},
},
};
@@ -516,7 +525,7 @@ pub fn into_mistral(
}
}
(
Ok((
mistral::Request {
model: model.id().to_string(),
messages,
@@ -550,17 +559,28 @@ pub fn into_mistral(
tools: request
.tools
.into_iter()
.map(|tool| mistral::ToolDefinition::Function {
function: mistral::FunctionDefinition {
name: tool.name,
description: Some(tool.description),
parameters: Some(tool.input_schema),
},
.map(|tool| {
let input_schema = match tool.input {
language_model::LanguageModelRequestToolInput::Function {
input_schema,
..
} => input_schema,
language_model::LanguageModelRequestToolInput::Custom { .. } => {
return Err(anyhow::anyhow!("Mistral does not support custom tools"));
}
};
Ok(mistral::ToolDefinition::Function {
function: mistral::FunctionDefinition {
name: tool.name,
description: Some(tool.description),
parameters: Some(input_schema),
},
})
})
.collect(),
.collect::<Result<_>>()?,
},
request.thread_id,
)
))
}
pub struct MistralEventMapper {
@@ -664,7 +684,7 @@ impl MistralEventMapper {
id: entry.id.clone().into(),
name: entry.name.as_str().into(),
is_input_complete: false,
input,
input: language_model::LanguageModelToolUseInput::Json(input),
raw_input: entry.arguments.clone(),
thought_signature: None,
},
@@ -721,7 +741,7 @@ impl MistralEventMapper {
id: tool_call.id.into(),
name: tool_call.name.into(),
is_input_complete: true,
input,
input: language_model::LanguageModelToolUseInput::Json(input),
raw_input: tool_call.arguments,
thought_signature: None,
},
@@ -813,7 +833,10 @@ mod tests {
assert_eq!(tool_use.id.to_string(), "real_id_123");
assert_eq!(tool_use.name.as_ref(), "read_file");
assert_eq!(tool_use.input, serde_json::json!({"path": "a.txt"}));
assert_eq!(
tool_use.input,
language_model::LanguageModelToolUseInput::Json(serde_json::json!({"path": "a.txt"}))
);
}
#[test]
@@ -854,7 +877,7 @@ mod tests {
};
let (mistral_request, affinity) =
into_mistral(request, mistral::Model::MistralSmallLatest, None);
into_mistral(request, mistral::Model::MistralSmallLatest, None).unwrap();
assert_eq!(mistral_request.model, "mistral-small-latest");
assert_eq!(mistral_request.temperature, Some(0.5));
@@ -890,7 +913,8 @@ mod tests {
compact_at_tokens: None,
};
let (mistral_request, _) = into_mistral(request, mistral::Model::MistralSmallLatest, None);
let (mistral_request, _) =
into_mistral(request, mistral::Model::MistralSmallLatest, None).unwrap();
assert_eq!(mistral_request.messages.len(), 1);
assert!(matches!(
+41 -11
View File
@@ -360,7 +360,11 @@ pub struct OllamaLanguageModel {
}
impl OllamaLanguageModel {
fn to_ollama_request(&self, request: LanguageModelRequest) -> ChatRequest {
fn to_ollama_request(&self, request: LanguageModelRequest) -> Result<ChatRequest> {
if request.contains_custom_tool_input() {
anyhow::bail!("Ollama does not support custom tools");
}
let supports_vision = self.model.supports_vision.unwrap_or(false);
let mut messages = Vec::with_capacity(request.messages.len());
@@ -422,7 +426,16 @@ impl OllamaLanguageModel {
id: tool_use.id.to_string(),
function: OllamaFunctionCall {
name: tool_use.name.to_string(),
arguments: tool_use.input,
arguments: match tool_use.input {
language_model::LanguageModelToolUseInput::Json(
input,
) => input,
language_model::LanguageModelToolUseInput::Text(_) => {
return Err(anyhow::anyhow!(
"Ollama does not support custom tool calls"
));
}
},
},
});
}
@@ -445,7 +458,7 @@ impl OllamaLanguageModel {
}),
}
}
ChatRequest {
Ok(ChatRequest {
model: self.model.name.clone(),
messages,
keep_alive: self.model.keep_alive.clone().unwrap_or_default(),
@@ -468,11 +481,15 @@ impl OllamaLanguageModel {
.supports_thinking
.map(|supports_thinking| supports_thinking && request.thinking_allowed),
tools: if self.model.supports_tools.unwrap_or(false) {
request.tools.into_iter().map(tool_into_ollama).collect()
request
.tools
.into_iter()
.map(tool_into_ollama)
.collect::<Result<_>>()?
} else {
vec![]
},
}
})
}
}
@@ -536,7 +553,10 @@ impl LanguageModel for OllamaLanguageModel {
LanguageModelCompletionError,
>,
> {
let request = self.to_ollama_request(request);
let request = match self.to_ollama_request(request) {
Ok(request) => request,
Err(error) => return async move { Err(error.into()) }.boxed(),
};
let http_client = self.http_client.clone();
let (api_key, api_url, extra_headers) = self.state.read_with(cx, |state, cx| {
@@ -621,7 +641,9 @@ fn map_to_language_model_completion_events(
id: LanguageModelToolUseId::from(id),
name: Arc::from(function.name),
raw_input: function.arguments.to_string(),
input: function.arguments,
input: language_model::LanguageModelToolUseInput::Json(
function.arguments,
),
is_input_complete: true,
thought_signature: None,
});
@@ -1130,14 +1152,22 @@ fn merge_settings_into_models(
}
}
fn tool_into_ollama(tool: LanguageModelRequestTool) -> ollama::OllamaTool {
ollama::OllamaTool::Function {
fn tool_into_ollama(tool: LanguageModelRequestTool) -> Result<ollama::OllamaTool> {
let input_schema = match tool.input {
language_model::LanguageModelRequestToolInput::Function { input_schema, .. } => {
input_schema
}
language_model::LanguageModelRequestToolInput::Custom { .. } => {
anyhow::bail!("Ollama does not support custom tools");
}
};
Ok(ollama::OllamaTool::Function {
function: OllamaFunctionTool {
name: tool.name,
description: Some(tool.description),
parameters: Some(tool.input_schema),
parameters: Some(input_schema),
},
}
})
}
#[cfg(test)]
@@ -553,7 +553,7 @@ impl LanguageModel for OpenAiLanguageModel {
}
.boxed()
} else {
let request = into_open_ai(
let request = match into_open_ai(
request,
self.model.id(),
self.model.supports_parallel_tool_calls(),
@@ -562,7 +562,10 @@ impl LanguageModel for OpenAiLanguageModel {
ChatCompletionMaxTokensParameter::MaxCompletionTokens,
None,
false,
);
) {
Ok(request) => request,
Err(error) => return async move { Err(error.into()) }.boxed(),
};
let completions = self.stream_completion(request, cx);
async move {
let mapper = OpenAiEventMapper::new();
@@ -424,7 +424,7 @@ impl LanguageModel for OpenAiCompatibleLanguageModel {
if self.model.capabilities.chat_completions {
let reasoning_effort = chat_completion_reasoning_effort(&request, &self.model);
let request = into_open_ai(
let request = match into_open_ai(
request,
&self.model.name,
self.model.capabilities.parallel_tool_calls,
@@ -433,7 +433,10 @@ impl LanguageModel for OpenAiCompatibleLanguageModel {
chat_completion_max_tokens_parameter(&self.model),
reasoning_effort,
self.model.capabilities.interleaved_reasoning,
);
) {
Ok(request) => request,
Err(error) => return async move { Err(error.into()) }.boxed(),
};
let completions = self.stream_completion(request, cx);
async move {
let mapper = OpenAiEventMapper::new();
@@ -688,7 +691,8 @@ mod tests {
chat_completion_max_tokens_parameter(&model),
reasoning_effort,
model.capabilities.interleaved_reasoning,
);
)
.unwrap();
let serialized = serde_json::to_value(request).unwrap();
assert_eq!(serialized["reasoning_effort"], json!("high"));
@@ -400,7 +400,11 @@ impl LanguageModel for OpenRouterLanguageModel {
LanguageModelCompletionError,
>,
> {
let openrouter_request = into_open_router(request, &self.model, self.max_output_tokens());
let openrouter_request =
match into_open_router(request, &self.model, self.max_output_tokens()) {
Ok(request) => request,
Err(error) => return async move { Err(error.into()) }.boxed(),
};
let request = self.stream_completion(openrouter_request, cx);
let future = self.request_limiter.stream(async move {
let response = request.await?;
@@ -414,7 +418,11 @@ pub fn into_open_router(
request: LanguageModelRequest,
model: &Model,
max_output_tokens: Option<u64>,
) -> open_router::Request {
) -> Result<open_router::Request> {
if request.contains_custom_tool_input() {
anyhow::bail!("OpenRouter does not support custom tools");
}
// Anthropic models via OpenRouter don't accept reasoning_details being echoed back
// in requests - it's an output-only field for them. However, Gemini models require
// the thought signatures to be echoed back for proper reasoning chain continuity.
@@ -472,13 +480,15 @@ pub fn into_open_router(
message_added_content = true;
}
MessageContent::ToolUse(tool_use) => {
let input = tool_use.input.as_json().ok_or_else(|| {
anyhow::anyhow!("OpenRouter does not support custom tool calls")
})?;
let tool_call = open_router::ToolCall {
id: tool_use.id.to_string(),
content: open_router::ToolCallContent::Function {
function: open_router::FunctionContent {
name: tool_use.name.to_string(),
arguments: serde_json::to_string(&tool_use.input)
.unwrap_or_default(),
arguments: serde_json::to_string(input).unwrap_or_default(),
thought_signature: tool_use.thought_signature.clone(),
},
},
@@ -551,7 +561,7 @@ pub fn into_open_router(
}
}
open_router::Request {
Ok(open_router::Request {
model: model.id().into(),
messages,
stream: true,
@@ -580,21 +590,32 @@ pub fn into_open_router(
tools: request
.tools
.into_iter()
.map(|tool| open_router::ToolDefinition::Function {
function: open_router::FunctionDefinition {
name: tool.name,
description: Some(tool.description),
parameters: Some(tool.input_schema),
},
.map(|tool| {
let input_schema = match tool.input {
language_model::LanguageModelRequestToolInput::Function {
input_schema,
..
} => input_schema,
language_model::LanguageModelRequestToolInput::Custom { .. } => {
return Err(anyhow::anyhow!("OpenRouter does not support custom tools"));
}
};
Ok(open_router::ToolDefinition::Function {
function: open_router::FunctionDefinition {
name: tool.name,
description: Some(tool.description),
parameters: Some(input_schema),
},
})
})
.collect(),
.collect::<Result<_>>()?,
tool_choice: request.tool_choice.map(|choice| match choice {
LanguageModelToolChoice::Auto => open_router::ToolChoice::Auto,
LanguageModelToolChoice::Any => open_router::ToolChoice::Required,
LanguageModelToolChoice::None => open_router::ToolChoice::None,
}),
provider: model.provider.clone(),
}
})
}
fn open_router_session_id(thread_id: Option<String>) -> Option<String> {
@@ -809,7 +830,7 @@ impl OpenRouterEventMapper {
id: entry.id.clone().into(),
name: entry.name.as_str().into(),
is_input_complete: false,
input,
input: language_model::LanguageModelToolUseInput::Json(input),
raw_input: entry.arguments.clone(),
thought_signature: entry.thought_signature.clone(),
},
@@ -832,7 +853,7 @@ impl OpenRouterEventMapper {
id: tool_call.id.clone().into(),
name: tool_call.name.as_str().into(),
is_input_complete: true,
input,
input: language_model::LanguageModelToolUseInput::Json(input),
raw_input: tool_call.arguments.clone(),
thought_signature: tool_call.thought_signature.clone(),
},
@@ -1114,7 +1135,7 @@ mod tests {
..Default::default()
};
let result = into_open_router(request, &model, None);
let result = into_open_router(request, &model, None).unwrap();
assert_eq!(
result.session_id.as_deref(),
@@ -1216,7 +1237,7 @@ mod tests {
compact_at_tokens: None,
};
let result = into_open_router(request, &model, None);
let result = into_open_router(request, &model, None).unwrap();
let system_cache = result.messages.iter().find_map(|m| {
if let open_router::RequestMessage::System { content } = m {
@@ -1355,7 +1376,7 @@ mod tests {
compact_at_tokens: None,
};
let result = into_open_router(request, &model, None);
let result = into_open_router(request, &model, None).unwrap();
for message in &result.messages {
let content = match message {
@@ -1418,7 +1439,7 @@ mod tests {
compact_at_tokens: None,
};
let result = into_open_router(request, &model, None);
let result = into_open_router(request, &model, None).unwrap();
for message in &result.messages {
let content = match message {
@@ -669,7 +669,7 @@ impl LanguageModel for OpenCodeLanguageModel {
} else {
anthropic::AnthropicModelMode::Default
};
let anthropic_request = into_anthropic(
let anthropic_request = match into_anthropic(
request,
self.model.id().to_string(),
1.0,
@@ -678,7 +678,10 @@ impl LanguageModel for OpenCodeLanguageModel {
.unwrap_or(8192),
mode,
anthropic::completion::AnthropicPromptCacheMode::Automatic,
);
) {
Ok(request) => request,
Err(error) => return async move { Err(error.into()) }.boxed(),
};
let stream =
self.stream_anthropic(anthropic_request, http_client, extra_headers, cx);
async move {
@@ -696,7 +699,7 @@ impl LanguageModel for OpenCodeLanguageModel {
} else {
None
};
let openai_request = into_open_ai(
let openai_request = match into_open_ai(
request,
self.model.id(),
true,
@@ -705,7 +708,10 @@ impl LanguageModel for OpenCodeLanguageModel {
ChatCompletionMaxTokensParameter::MaxCompletionTokens,
reasoning_effort,
self.model.interleaved_reasoning(),
);
) {
Ok(request) => request,
Err(error) => return async move { Err(error.into()) }.boxed(),
};
let stream =
self.stream_openai_chat(openai_request, http_client, extra_headers, cx);
async move {
@@ -744,7 +750,10 @@ impl LanguageModel for OpenCodeLanguageModel {
} else {
google_ai::GoogleModelMode::Default
};
let google_request = into_google(request, self.model.id().to_string(), mode);
let google_request = match into_google(request, self.model.id().to_string(), mode) {
Ok(request) => request,
Err(error) => return async move { Err(error.into()) }.boxed(),
};
let stream = self.stream_google(google_request, http_client, extra_headers, cx);
async move {
let mapper = GoogleEventMapper::new();
@@ -451,7 +451,7 @@ impl LanguageModel for VercelAiGatewayLanguageModel {
LanguageModelCompletionError,
>,
> {
let request = crate::provider::open_ai::into_open_ai(
let request = match crate::provider::open_ai::into_open_ai(
request,
&self.model.name,
self.model.capabilities.parallel_tool_calls,
@@ -460,7 +460,10 @@ impl LanguageModel for VercelAiGatewayLanguageModel {
crate::provider::open_ai::ChatCompletionMaxTokensParameter::MaxCompletionTokens,
None,
false,
);
) {
Ok(request) => request,
Err(error) => return async move { Err(error.into()) }.boxed(),
};
let completions = self.stream_open_ai(request, cx);
async move {
let mapper = crate::provider::open_ai::OpenAiEventMapper::new();
+5 -2
View File
@@ -413,7 +413,7 @@ impl LanguageModel for XAiLanguageModel {
>,
> {
let reasoning_effort = reasoning_effort_for_request(&request, &self.model);
let request = crate::provider::open_ai::into_open_ai(
let request = match crate::provider::open_ai::into_open_ai(
request,
self.model.id(),
self.model.supports_parallel_tool_calls(),
@@ -422,7 +422,10 @@ impl LanguageModel for XAiLanguageModel {
crate::provider::open_ai::ChatCompletionMaxTokensParameter::MaxCompletionTokens,
reasoning_effort,
false,
);
) {
Ok(request) => request,
Err(error) => return async move { Err(error.into()) }.boxed(),
};
let completions = self.stream_completion(request, cx);
async move {
let mapper = crate::provider::open_ai::OpenAiEventMapper::new();
@@ -462,7 +462,7 @@ impl<TP: CloudLlmTokenProvider + 'static> LanguageModel for CloudLanguageModel<T
.as_ref()
.and_then(|effort| anthropic::Effort::from_str(effort).ok());
let mut request = into_anthropic(
let mut request = match into_anthropic(
request,
self.model.id.to_string(),
1.0,
@@ -475,7 +475,10 @@ impl<TP: CloudLlmTokenProvider + 'static> LanguageModel for CloudLanguageModel<T
AnthropicModelMode::Default
},
AnthropicPromptCacheMode::Automatic,
);
) {
Ok(request) => request,
Err(error) => return async move { Err(error.into()) }.boxed(),
};
if enable_thinking && effort.is_some() {
request.thinking = Some(anthropic::Thinking::Adaptive {
@@ -592,7 +595,7 @@ impl<TP: CloudLlmTokenProvider + 'static> LanguageModel for CloudLanguageModel<T
cloud_llm_client::LanguageModelProvider::XAi => {
let http_client = self.http_client.clone();
let token_provider = self.token_provider.clone();
let request = into_open_ai(
let request = match into_open_ai(
request,
&self.model.id.0,
self.model.supports_parallel_tool_calls,
@@ -601,7 +604,10 @@ impl<TP: CloudLlmTokenProvider + 'static> LanguageModel for CloudLanguageModel<T
ChatCompletionMaxTokensParameter::MaxCompletionTokens,
None,
false,
);
) {
Ok(request) => request,
Err(error) => return async move { Err(error.into()) }.boxed(),
};
let auth_context = token_provider.auth_context(cx);
let future = self.request_limiter.stream(async move {
let PerformLlmCompletionResponse {
@@ -640,7 +646,11 @@ impl<TP: CloudLlmTokenProvider + 'static> LanguageModel for CloudLanguageModel<T
let http_client = self.http_client.clone();
let token_provider = self.token_provider.clone();
let request =
into_google(request, self.model.id.to_string(), GoogleModelMode::Default);
match into_google(request, self.model.id.to_string(), GoogleModelMode::Default)
{
Ok(request) => request,
Err(error) => return async move { Err(error.into()) }.boxed(),
};
let auth_context = token_provider.auth_context(cx);
let future = self.request_limiter.stream(async move {
let PerformLlmCompletionResponse {
+571 -90
View File
@@ -3,16 +3,19 @@ use collections::HashMap;
use futures::{Stream, StreamExt};
use language_model_core::{
CompactionContent, LanguageModelCompletionError, LanguageModelCompletionEvent,
LanguageModelImage, LanguageModelRequest, LanguageModelRequestMessage, LanguageModelToolChoice,
LanguageModelToolResultContent, LanguageModelToolUse, LanguageModelToolUseId, MessageContent,
Role, StopReason, TokenUsage,
LanguageModelCustomToolFormat, LanguageModelCustomToolGrammarSyntax, LanguageModelImage,
LanguageModelRequest, LanguageModelRequestMessage, LanguageModelRequestToolInput,
LanguageModelToolChoice, LanguageModelToolResultContent, LanguageModelToolUse,
LanguageModelToolUseId, LanguageModelToolUseInput, MessageContent, Role, StopReason,
TokenUsage,
util::{fix_streamed_json, parse_tool_arguments},
};
use std::pin::Pin;
use std::sync::Arc;
use crate::responses::{
ContextManagement, Request as ResponseRequest, ResponseCompactionItem, ResponseError,
ContextManagement, Request as ResponseRequest, ResponseCompactionItem,
ResponseCustomToolCallItem, ResponseCustomToolCallOutputItem, ResponseError,
ResponseFunctionCallItem, ResponseFunctionCallOutputContent, ResponseFunctionCallOutputItem,
ResponseIncludable, ResponseInputContent, ResponseInputItem, ResponseMessageItem,
ResponseOutputItem, ResponseOutputMessage, ResponseReasoningInputItem, ResponseReasoningItem,
@@ -52,7 +55,17 @@ pub fn into_open_ai(
max_tokens_parameter: ChatCompletionMaxTokensParameter,
reasoning_effort: Option<ReasoningEffort>,
interleaved_reasoning: bool,
) -> crate::Request {
) -> Result<crate::Request> {
if request
.tools
.iter()
.any(|tool| matches!(tool.input, LanguageModelRequestToolInput::Custom { .. }))
{
return Err(anyhow!(
"OpenAI Chat Completions does not support custom tools; use Responses API instead"
));
}
let stream = !model_id.starts_with("o1-");
let service_tier = service_tier_for(request.speed);
@@ -103,13 +116,18 @@ pub fn into_open_ai(
);
}
MessageContent::ToolUse(tool_use) => {
let LanguageModelToolUseInput::Json(input) = &tool_use.input else {
return Err(anyhow!(
"OpenAI Chat Completions cannot replay custom tool call `{}`",
tool_use.name
));
};
let tool_call = ToolCall {
id: tool_use.id.to_string(),
content: ToolCallContent::Function {
function: FunctionContent {
name: tool_use.name.to_string(),
arguments: serde_json::to_string(&tool_use.input)
.unwrap_or_default(),
arguments: serde_json::to_string(input).unwrap_or_default(),
},
},
};
@@ -152,7 +170,7 @@ pub fn into_open_ai(
}
}
crate::Request {
Ok(crate::Request {
model: model_id.into(),
messages,
stream,
@@ -184,14 +202,21 @@ pub fn into_open_ai(
tools: request
.tools
.into_iter()
.map(|tool| crate::ToolDefinition::Function {
function: FunctionDefinition {
name: tool.name,
description: Some(tool.description),
parameters: Some(tool.input_schema),
},
.map(|tool| match tool.input {
LanguageModelRequestToolInput::Function { input_schema, .. } => {
Ok(crate::ToolDefinition::Function {
function: FunctionDefinition {
name: tool.name,
description: Some(tool.description),
parameters: Some(input_schema),
},
})
}
LanguageModelRequestToolInput::Custom { .. } => Err(anyhow!(
"OpenAI Chat Completions does not support custom tools; use Responses API instead"
)),
})
.collect(),
.collect::<Result<_>>()?,
tool_choice: request.tool_choice.map(|choice| match choice {
LanguageModelToolChoice::Auto => crate::ToolChoice::Auto,
LanguageModelToolChoice::Any => crate::ToolChoice::Required,
@@ -199,7 +224,7 @@ pub fn into_open_ai(
}),
reasoning_effort,
service_tier,
}
})
}
pub fn into_open_ai_response(
@@ -232,22 +257,39 @@ pub fn into_open_ai_response(
let mut input_items = Vec::new();
let mut replayed_reasoning_item_indexes = HashMap::default();
let mut tool_use_kinds_by_id = HashMap::default();
for (index, message) in messages.into_iter().enumerate() {
append_message_to_response_items(
message,
index,
&mut replayed_reasoning_item_indexes,
&mut tool_use_kinds_by_id,
&mut input_items,
);
}
let tools: Vec<_> = tools
.into_iter()
.map(|tool| crate::responses::ToolDefinition::Function {
name: tool.name,
description: Some(tool.description),
parameters: Some(tool.input_schema),
strict: None,
.map(|tool| match tool.input {
LanguageModelRequestToolInput::Function { input_schema, .. } => {
crate::responses::ToolDefinition::Function {
name: tool.name,
description: Some(tool.description),
parameters: Some(input_schema),
strict: None,
}
}
LanguageModelRequestToolInput::Custom { format } => {
crate::responses::ToolDefinition::Custom {
name: tool.name,
description: if tool.description.is_empty() {
None
} else {
Some(tool.description)
},
format: format.map(custom_tool_format_into_open_ai),
}
}
})
.collect();
@@ -323,6 +365,7 @@ fn append_message_to_response_items(
message: LanguageModelRequestMessage,
index: usize,
replayed_reasoning_item_indexes: &mut HashMap<String, usize>,
tool_use_kinds_by_id: &mut HashMap<LanguageModelToolUseId, ReplayToolKind>,
input_items: &mut Vec<ResponseInputItem>,
) {
let mut content_parts: Vec<ResponseInputContent> = Vec::new();
@@ -386,11 +429,29 @@ fn append_message_to_response_items(
input_items,
);
let call_id = tool_use.id.to_string();
input_items.push(ResponseInputItem::FunctionCall(ResponseFunctionCallItem {
call_id,
name: tool_use.name.to_string(),
arguments: tool_use.raw_input,
}));
match tool_use.input {
LanguageModelToolUseInput::Json(_) => {
tool_use_kinds_by_id.insert(tool_use.id, ReplayToolKind::Function);
input_items.push(ResponseInputItem::FunctionCall(
ResponseFunctionCallItem {
call_id,
name: tool_use.name.to_string(),
arguments: tool_use.raw_input,
},
));
}
LanguageModelToolUseInput::Text(_) => {
tool_use_kinds_by_id.insert(tool_use.id, ReplayToolKind::Custom);
input_items.push(ResponseInputItem::CustomToolCall(
ResponseCustomToolCallItem {
id: None,
call_id,
name: tool_use.name.to_string(),
input: tool_use.raw_input,
},
));
}
}
}
MessageContent::ToolResult(tool_result) => {
flush_response_parts(
@@ -424,12 +485,24 @@ fn append_message_to_response_items(
ResponseFunctionCallOutputContent::List(parts)
}
};
input_items.push(ResponseInputItem::FunctionCallOutput(
ResponseFunctionCallOutputItem {
call_id: tool_result.tool_use_id.to_string(),
output,
},
));
match tool_use_kinds_by_id.get(&tool_result.tool_use_id) {
Some(ReplayToolKind::Custom) => {
input_items.push(ResponseInputItem::CustomToolCallOutput(
ResponseCustomToolCallOutputItem {
call_id: tool_result.tool_use_id.to_string(),
output,
},
));
}
Some(ReplayToolKind::Function) | None => {
input_items.push(ResponseInputItem::FunctionCallOutput(
ResponseFunctionCallOutputItem {
call_id: tool_result.tool_use_id.to_string(),
output,
},
));
}
}
}
}
}
@@ -443,6 +516,33 @@ fn append_message_to_response_items(
);
}
#[derive(Clone, Copy)]
enum ReplayToolKind {
Function,
Custom,
}
fn custom_tool_format_into_open_ai(
format: LanguageModelCustomToolFormat,
) -> crate::responses::CustomToolFormat {
match format {
LanguageModelCustomToolFormat::Text => crate::responses::CustomToolFormat::Text,
LanguageModelCustomToolFormat::Grammar { syntax, definition } => {
crate::responses::CustomToolFormat::Grammar {
syntax: match syntax {
LanguageModelCustomToolGrammarSyntax::Lark => {
crate::responses::CustomToolGrammarSyntax::Lark
}
LanguageModelCustomToolGrammarSyntax::Regex => {
crate::responses::CustomToolGrammarSyntax::Regex
}
},
definition,
}
}
}
}
fn append_reasoning_details_to_response_items(
reasoning_details: Option<&serde_json::Value>,
replayed_reasoning_item_indexes: &mut HashMap<String, usize>,
@@ -665,7 +765,7 @@ impl OpenAiEventMapper {
id: entry.id.clone().into(),
name: entry.name.as_str().into(),
is_input_complete: false,
input,
input: LanguageModelToolUseInput::Json(input),
raw_input: entry.arguments.clone(),
thought_signature: None,
},
@@ -688,7 +788,7 @@ impl OpenAiEventMapper {
id: tool_call.id.clone().into(),
name: tool_call.name.as_str().into(),
is_input_complete: true,
input,
input: LanguageModelToolUseInput::Json(input),
raw_input: tool_call.arguments.clone(),
thought_signature: None,
},
@@ -736,6 +836,7 @@ struct RawToolCall {
pub struct OpenAiResponseEventMapper {
function_calls_by_item: HashMap<String, PendingResponseFunctionCall>,
custom_tool_calls_by_item: HashMap<String, PendingResponseCustomToolCall>,
reasoning_items: Vec<ResponseReasoningInputItem>,
current_message_phase: Option<String>,
pending_stop_reason: Option<StopReason>,
@@ -748,10 +849,17 @@ struct PendingResponseFunctionCall {
arguments: String,
}
struct PendingResponseCustomToolCall {
call_id: String,
name: Arc<str>,
input: String,
}
impl OpenAiResponseEventMapper {
pub fn new() -> Self {
Self {
function_calls_by_item: HashMap::default(),
custom_tool_calls_by_item: HashMap::default(),
reasoning_items: Vec::new(),
current_message_phase: None,
pending_stop_reason: None,
@@ -805,6 +913,23 @@ impl OpenAiResponseEventMapper {
self.function_calls_by_item.insert(item_id, entry);
}
}
ResponseOutputItem::CustomToolCall(custom_tool_call) => {
if let Some(item_id) = custom_tool_call.id.clone() {
let call_id = custom_tool_call
.call_id
.clone()
.or_else(|| custom_tool_call.id.clone())
.unwrap_or_else(|| item_id.clone());
let entry = PendingResponseCustomToolCall {
call_id,
name: Arc::<str>::from(
custom_tool_call.name.clone().unwrap_or_default(),
),
input: custom_tool_call.input.clone(),
};
self.custom_tool_calls_by_item.insert(item_id, entry);
}
}
ResponseOutputItem::Compaction(_) => {
events.push(Ok(LanguageModelCompletionEvent::Compaction(
CompactionContent::Pending,
@@ -848,7 +973,7 @@ impl OpenAiResponseEventMapper {
id: LanguageModelToolUseId::from(entry.call_id.clone()),
name: entry.name.clone(),
is_input_complete: false,
input,
input: LanguageModelToolUseInput::Json(input),
raw_input: entry.arguments.clone(),
thought_signature: None,
},
@@ -873,7 +998,7 @@ impl OpenAiResponseEventMapper {
id: LanguageModelToolUseId::from(entry.call_id.clone()),
name: entry.name.clone(),
is_input_complete: true,
input,
input: LanguageModelToolUseInput::Json(input),
raw_input,
thought_signature: None,
},
@@ -892,6 +1017,30 @@ impl OpenAiResponseEventMapper {
Vec::new()
}
}
ResponsesStreamEvent::CustomToolCallInputDelta { item_id, delta, .. } => {
if let Some(entry) = self.custom_tool_calls_by_item.get_mut(&item_id) {
entry.input.push_str(&delta);
return vec![Ok(LanguageModelCompletionEvent::ToolUse(
LanguageModelToolUse {
id: LanguageModelToolUseId::from(entry.call_id.clone()),
name: entry.name.clone(),
is_input_complete: false,
input: LanguageModelToolUseInput::Text(entry.input.clone()),
raw_input: entry.input.clone(),
thought_signature: None,
},
))];
}
Vec::new()
}
ResponsesStreamEvent::CustomToolCallInputDone { item_id, input, .. } => {
if let Some(entry) = self.custom_tool_calls_by_item.get_mut(&item_id)
&& !input.is_empty()
{
entry.input = input;
}
self.finish_pending_custom_tool_call(&item_id, None)
}
ResponsesStreamEvent::Completed { response } => {
self.handle_completion(response, StopReason::EndTurn)
}
@@ -959,6 +1108,13 @@ impl OpenAiResponseEventMapper {
ResponsesStreamEvent::OutputItemDone { item, .. } => match item {
ResponseOutputItem::Reasoning(reasoning) => self.capture_reasoning_item(&reasoning),
ResponseOutputItem::Message(message) => self.capture_message_phase(&message),
ResponseOutputItem::CustomToolCall(custom_tool_call) => {
if let Some(item_id) = custom_tool_call.id.as_ref() {
self.finish_pending_custom_tool_call(item_id, Some(&custom_tool_call))
} else {
Vec::new()
}
}
ResponseOutputItem::Compaction(compaction) => {
vec![Ok(LanguageModelCompletionEvent::Compaction(
CompactionContent::Encrypted {
@@ -1015,48 +1171,109 @@ impl OpenAiResponseEventMapper {
) -> Vec<Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
let mut events = Vec::new();
for item in output {
if let ResponseOutputItem::FunctionCall(function_call) = item {
let Some(call_id) = function_call
.call_id
.clone()
.or_else(|| function_call.id.clone())
else {
log::error!(
"Function call item missing both call_id and id: {:?}",
function_call
);
continue;
};
let name: Arc<str> = Arc::from(function_call.name.clone().unwrap_or_default());
let arguments = &function_call.arguments;
self.pending_stop_reason = Some(StopReason::ToolUse);
match parse_tool_arguments(arguments) {
Ok(input) => {
events.push(Ok(LanguageModelCompletionEvent::ToolUse(
LanguageModelToolUse {
match item {
ResponseOutputItem::FunctionCall(function_call) => {
let Some(call_id) = function_call
.call_id
.clone()
.or_else(|| function_call.id.clone())
else {
log::error!(
"Function call item missing both call_id and id: {:?}",
function_call
);
continue;
};
let name: Arc<str> = Arc::from(function_call.name.clone().unwrap_or_default());
let arguments = &function_call.arguments;
self.pending_stop_reason = Some(StopReason::ToolUse);
match parse_tool_arguments(arguments) {
Ok(input) => {
events.push(Ok(LanguageModelCompletionEvent::ToolUse(
LanguageModelToolUse {
id: LanguageModelToolUseId::from(call_id.clone()),
name: name.clone(),
is_input_complete: true,
input: LanguageModelToolUseInput::Json(input),
raw_input: arguments.clone(),
thought_signature: None,
},
)));
}
Err(error) => {
events.push(Ok(LanguageModelCompletionEvent::ToolUseJsonParseError {
id: LanguageModelToolUseId::from(call_id.clone()),
name: name.clone(),
is_input_complete: true,
input,
raw_input: arguments.clone(),
thought_signature: None,
},
)));
}
Err(error) => {
events.push(Ok(LanguageModelCompletionEvent::ToolUseJsonParseError {
id: LanguageModelToolUseId::from(call_id.clone()),
tool_name: name.clone(),
raw_input: Arc::<str>::from(arguments.clone()),
json_parse_error: error.to_string(),
}));
tool_name: name.clone(),
raw_input: Arc::<str>::from(arguments.clone()),
json_parse_error: error.to_string(),
}));
}
}
}
ResponseOutputItem::CustomToolCall(custom_tool_call) => {
events.extend(self.emit_custom_tool_call(custom_tool_call));
}
_ => {}
}
}
events
}
fn emit_custom_tool_call(
&mut self,
custom_tool_call: &crate::responses::ResponseCustomToolCall,
) -> Vec<Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
let Some(call_id) = custom_tool_call
.call_id
.clone()
.or_else(|| custom_tool_call.id.clone())
else {
log::error!(
"Custom tool call item missing both call_id and id: {:?}",
custom_tool_call
);
return Vec::new();
};
self.pending_stop_reason = Some(StopReason::ToolUse);
let input = custom_tool_call.input.clone();
vec![Ok(LanguageModelCompletionEvent::ToolUse(
LanguageModelToolUse {
id: LanguageModelToolUseId::from(call_id),
name: Arc::from(custom_tool_call.name.clone().unwrap_or_default()),
is_input_complete: true,
input: LanguageModelToolUseInput::Text(input.clone()),
raw_input: input,
thought_signature: None,
},
))]
}
fn finish_pending_custom_tool_call(
&mut self,
item_id: &str,
fallback: Option<&crate::responses::ResponseCustomToolCall>,
) -> Vec<Result<LanguageModelCompletionEvent, LanguageModelCompletionError>> {
let Some(mut entry) = self.custom_tool_calls_by_item.remove(item_id) else {
return Vec::new();
};
if let Some(fallback) = fallback
&& !fallback.input.is_empty()
{
entry.input = fallback.input.clone();
}
self.pending_stop_reason = Some(StopReason::ToolUse);
vec![Ok(LanguageModelCompletionEvent::ToolUse(
LanguageModelToolUse {
id: LanguageModelToolUseId::from(entry.call_id),
name: entry.name,
is_input_complete: true,
input: LanguageModelToolUseInput::Text(entry.input.clone()),
raw_input: entry.input,
thought_signature: None,
},
))]
}
fn capture_reasoning_items_from_output(
&mut self,
output: &[ResponseOutputItem],
@@ -1245,15 +1462,17 @@ fn response_reasoning_input_item_from_output(
#[cfg(test)]
mod tests {
use crate::responses::{
ReasoningSummaryPart, ResponseError, ResponseFunctionToolCall, ResponseIncompleteDetails,
ResponseInputTokensDetails, ResponseOutputItem, ResponseOutputMessage,
ResponseReasoningItem, ResponseSummary, ResponseUsage, StreamEvent as ResponsesStreamEvent,
ReasoningSummaryPart, ResponseCustomToolCall, ResponseError, ResponseFunctionToolCall,
ResponseIncompleteDetails, ResponseInputItem, ResponseInputTokensDetails,
ResponseOutputItem, ResponseOutputMessage, ResponseReasoningItem, ResponseSummary,
ResponseUsage, StreamEvent as ResponsesStreamEvent, ToolDefinition,
};
use futures::{StreamExt, executor::block_on};
use language_model_core::{
LanguageModelImage, LanguageModelRequestMessage, LanguageModelRequestTool,
LanguageModelCustomToolFormat, LanguageModelCustomToolGrammarSyntax, LanguageModelImage,
LanguageModelRequestMessage, LanguageModelRequestTool, LanguageModelRequestToolInput,
LanguageModelToolResult, LanguageModelToolResultContent, LanguageModelToolUse,
LanguageModelToolUseId, SharedString, Speed,
LanguageModelToolUseId, LanguageModelToolUseInput, SharedString, Speed,
};
use pretty_assertions::assert_eq;
use serde_json::json;
@@ -1306,6 +1525,16 @@ mod tests {
})
}
fn response_item_custom_tool_call(id: &str, input: &str) -> ResponseOutputItem {
ResponseOutputItem::CustomToolCall(ResponseCustomToolCall {
id: Some(id.to_string()),
status: Some("in_progress".to_string()),
name: Some("apply_patch".to_string()),
call_id: Some("call_abc".to_string()),
input: input.to_string(),
})
}
fn response_reasoning_item(
id: &str,
summary: Vec<ReasoningSummaryPart>,
@@ -1417,6 +1646,97 @@ mod tests {
Ok(())
}
#[test]
fn responses_custom_tool_wire_types_round_trip() -> Result<()> {
let tool_json = json!({
"type": "custom",
"name": "apply_patch",
"description": "Apply a patch",
"format": {
"type": "grammar",
"syntax": "lark",
"definition": "start: /.+/"
}
});
let tool: ToolDefinition = serde_json::from_value(tool_json.clone())?;
assert_eq!(serde_json::to_value(tool)?, tool_json);
let text_tool_json = json!({
"type": "custom",
"name": "write_text",
"format": { "type": "text" }
});
let text_tool: ToolDefinition = serde_json::from_value(text_tool_json.clone())?;
assert_eq!(serde_json::to_value(text_tool)?, text_tool_json);
let input_json = json!({
"type": "custom_tool_call",
"id": "ctc_1",
"call_id": "call_abc",
"name": "apply_patch",
"input": "*** Begin Patch\n*** End Patch"
});
let input: ResponseInputItem = serde_json::from_value(input_json.clone())?;
assert_eq!(serde_json::to_value(input)?, input_json);
let output_json = json!({
"type": "custom_tool_call_output",
"call_id": "call_abc",
"output": "ok"
});
let output: ResponseInputItem = serde_json::from_value(output_json.clone())?;
assert_eq!(serde_json::to_value(output)?, output_json);
let output_item_json = json!({
"id": "ctc_1",
"type": "custom_tool_call",
"status": "completed",
"call_id": "call_abc",
"name": "apply_patch",
"input": "*** Begin Patch\n*** End Patch"
});
let output_item: ResponseOutputItem = serde_json::from_value(output_item_json.clone())?;
assert_eq!(serde_json::to_value(output_item)?, output_item_json);
let delta_json = json!({
"type": "response.custom_tool_call_input.delta",
"output_index": 0,
"item_id": "ctc_1",
"sequence_number": 5,
"delta": "chunk"
});
let delta: ResponsesStreamEvent = serde_json::from_value(delta_json)?;
assert!(matches!(
delta,
ResponsesStreamEvent::CustomToolCallInputDelta {
output_index: 0,
sequence_number: Some(5),
ref item_id,
ref delta,
} if item_id == "ctc_1" && delta == "chunk"
));
let done_json = json!({
"type": "response.custom_tool_call_input.done",
"output_index": 0,
"item_id": "ctc_1",
"sequence_number": 6,
"input": "full text"
});
let done: ResponsesStreamEvent = serde_json::from_value(done_json)?;
assert!(matches!(
done,
ResponsesStreamEvent::CustomToolCallInputDone {
output_index: 0,
sequence_number: Some(6),
ref item_id,
ref input,
} if item_id == "ctc_1" && input == "full text"
));
Ok(())
}
#[test]
fn into_open_ai_response_builds_complete_payload() {
let tool_call_id = LanguageModelToolUseId::from("call-42");
@@ -1426,7 +1746,7 @@ mod tests {
id: tool_call_id.clone(),
name: Arc::from("get_weather"),
raw_input: tool_arguments.clone(),
input: tool_input,
input: LanguageModelToolUseInput::Json(tool_input),
is_input_complete: true,
thought_signature: None,
};
@@ -1478,12 +1798,12 @@ mod tests {
reasoning_details: None,
},
],
tools: vec![LanguageModelRequestTool {
name: "get_weather".into(),
description: "Fetches the weather".into(),
input_schema: json!({ "type": "object" }),
use_input_streaming: false,
}],
tools: vec![LanguageModelRequestTool::function(
"get_weather".into(),
"Fetches the weather".into(),
json!({ "type": "object" }),
false,
)],
tool_choice: Some(LanguageModelToolChoice::Any),
stop: vec!["<STOP>".into()],
temperature: None,
@@ -1562,6 +1882,166 @@ mod tests {
assert_eq!(serialized, expected);
}
#[test]
fn responses_stream_maps_custom_tool_input() {
let events = vec![
ResponsesStreamEvent::OutputItemAdded {
output_index: 0,
sequence_number: None,
item: response_item_custom_tool_call("ctc_1", ""),
},
ResponsesStreamEvent::CustomToolCallInputDelta {
item_id: "ctc_1".into(),
output_index: 0,
delta: "*** Begin".into(),
sequence_number: Some(1),
},
ResponsesStreamEvent::CustomToolCallInputDelta {
item_id: "ctc_1".into(),
output_index: 0,
delta: " Patch".into(),
sequence_number: Some(2),
},
ResponsesStreamEvent::CustomToolCallInputDone {
item_id: "ctc_1".into(),
output_index: 0,
input: "*** Begin Patch".into(),
sequence_number: Some(3),
},
ResponsesStreamEvent::OutputItemDone {
output_index: 0,
sequence_number: None,
item: response_item_custom_tool_call("ctc_1", "*** Begin Patch"),
},
ResponsesStreamEvent::Completed {
response: ResponseSummary::default(),
},
];
let mapped = map_response_events(events);
assert_eq!(
mapped,
vec![
LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse {
id: LanguageModelToolUseId::from("call_abc"),
name: Arc::from("apply_patch"),
raw_input: "*** Begin".into(),
input: LanguageModelToolUseInput::Text("*** Begin".into()),
is_input_complete: false,
thought_signature: None,
}),
LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse {
id: LanguageModelToolUseId::from("call_abc"),
name: Arc::from("apply_patch"),
raw_input: "*** Begin Patch".into(),
input: LanguageModelToolUseInput::Text("*** Begin Patch".into()),
is_input_complete: false,
thought_signature: None,
}),
LanguageModelCompletionEvent::ToolUse(LanguageModelToolUse {
id: LanguageModelToolUseId::from("call_abc"),
name: Arc::from("apply_patch"),
raw_input: "*** Begin Patch".into(),
input: LanguageModelToolUseInput::Text("*** Begin Patch".into()),
is_input_complete: true,
thought_signature: None,
}),
LanguageModelCompletionEvent::Stop(StopReason::ToolUse),
]
);
}
#[test]
fn into_open_ai_response_replays_custom_tool_calls() {
let tool_call_id = LanguageModelToolUseId::from("call_abc");
let raw_input = "*** Begin Patch\n*** End Patch".to_string();
let tool_use = LanguageModelToolUse {
id: tool_call_id.clone(),
name: Arc::from("apply_patch"),
raw_input: raw_input.clone(),
input: LanguageModelToolUseInput::Text(raw_input.clone()),
is_input_complete: true,
thought_signature: None,
};
let tool_result = LanguageModelToolResult {
tool_use_id: tool_call_id,
tool_name: Arc::from("apply_patch"),
is_error: false,
content: vec![LanguageModelToolResultContent::Text(Arc::from("ok"))],
output: None,
};
let request = LanguageModelRequest {
thread_id: None,
prompt_id: None,
intent: None,
messages: vec![LanguageModelRequestMessage {
role: Role::Assistant,
content: vec![
MessageContent::ToolUse(tool_use),
MessageContent::ToolResult(tool_result),
],
cache: false,
reasoning_details: None,
}],
tools: vec![LanguageModelRequestTool {
name: "apply_patch".into(),
description: "Apply a patch".into(),
input: LanguageModelRequestToolInput::Custom {
format: Some(LanguageModelCustomToolFormat::Grammar {
syntax: LanguageModelCustomToolGrammarSyntax::Lark,
definition: "start: /.+/".into(),
}),
},
}],
tool_choice: None,
stop: Vec::new(),
temperature: None,
thinking_allowed: false,
thinking_effort: None,
speed: None,
compact_at_tokens: None,
};
let response =
into_open_ai_response(request, "custom-model", false, false, None, None, false);
let serialized = serde_json::to_value(response).unwrap();
assert_eq!(
serialized,
json!({
"model": "custom-model",
"input": [
{
"type": "custom_tool_call",
"call_id": "call_abc",
"name": "apply_patch",
"input": raw_input
},
{
"type": "custom_tool_call_output",
"call_id": "call_abc",
"output": "ok"
}
],
"store": false,
"stream": true,
"parallel_tool_calls": false,
"tools": [
{
"type": "custom",
"name": "apply_patch",
"description": "Apply a patch",
"format": {
"type": "grammar",
"syntax": "lark",
"definition": "start: /.+/"
}
}
]
})
);
}
#[test]
fn into_open_ai_response_replays_encrypted_reasoning_details() {
let tool_call_id = LanguageModelToolUseId::from("call-42");
@@ -1570,7 +2050,7 @@ mod tests {
id: tool_call_id,
name: Arc::from("get_weather"),
raw_input: tool_arguments.clone(),
input: json!({ "city": "Boston" }),
input: LanguageModelToolUseInput::Json(json!({ "city": "Boston" })),
is_input_complete: true,
thought_signature: None,
};
@@ -1850,7 +2330,7 @@ mod tests {
ChatCompletionMaxTokensParameter::MaxCompletionTokens,
None,
false,
);
)?;
let serialized = serde_json::to_value(&chat)?;
assert_eq!(
@@ -1895,7 +2375,7 @@ mod tests {
ChatCompletionMaxTokensParameter::MaxTokens,
None,
false,
);
)?;
let serialized = serde_json::to_value(&chat)?;
assert_eq!(serialized.get("max_completion_tokens"), None);
@@ -2700,8 +3180,7 @@ mod tests {
}) if id.to_string() == "call_123"
&& name.as_ref() == "get_weather"
&& raw_input == ""
&& input.is_object()
&& input.as_object().unwrap().is_empty()
&& matches!(input, LanguageModelToolUseInput::Json(value) if value.as_object().is_some_and(|object| object.is_empty()))
));
assert!(matches!(
mapped[1],
@@ -3198,7 +3677,7 @@ mod tests {
id: tool_use_id.clone(),
name: Arc::from("search"),
raw_input: tool_arguments.clone(),
input: tool_input,
input: LanguageModelToolUseInput::Json(tool_input),
is_input_complete: true,
thought_signature: None,
};
@@ -3259,7 +3738,8 @@ mod tests {
ChatCompletionMaxTokensParameter::MaxCompletionTokens,
None,
true,
);
)
.unwrap();
assert_eq!(
serde_json::to_value(&result).unwrap()["messages"],
json!([
@@ -3283,7 +3763,8 @@ mod tests {
ChatCompletionMaxTokensParameter::MaxCompletionTokens,
None,
false,
);
)
.unwrap();
assert_eq!(
serde_json::to_value(&result).unwrap()["messages"],
json!([
+95 -12
View File
@@ -66,6 +66,8 @@ pub enum ResponseInputItem {
Message(ResponseMessageItem),
FunctionCall(ResponseFunctionCallItem),
FunctionCallOutput(ResponseFunctionCallOutputItem),
CustomToolCall(ResponseCustomToolCallItem),
CustomToolCallOutput(ResponseCustomToolCallOutputItem),
Reasoning(ResponseReasoningInputItem),
Compaction(ResponseCompactionItem),
}
@@ -98,6 +100,21 @@ pub struct ResponseFunctionCallOutputItem {
pub output: ResponseFunctionCallOutputContent,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ResponseCustomToolCallItem {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub call_id: String,
pub name: String,
pub input: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ResponseCustomToolCallOutputItem {
pub call_id: String,
pub output: ResponseFunctionCallOutputContent,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ResponseReasoningInputItem {
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -157,7 +174,7 @@ pub enum ReasoningSummaryMode {
Detailed,
}
#[derive(Serialize, Debug)]
#[derive(Serialize, Deserialize, Debug)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ToolDefinition {
Function {
@@ -169,9 +186,33 @@ pub enum ToolDefinition {
#[serde(skip_serializing_if = "Option::is_none")]
strict: Option<bool>,
},
Custom {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
format: Option<CustomToolFormat>,
},
}
#[derive(Deserialize, Debug, Clone)]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum CustomToolFormat {
Text,
Grammar {
syntax: CustomToolGrammarSyntax,
definition: String,
},
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum CustomToolGrammarSyntax {
Lark,
Regex,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ResponseError {
#[serde(default)]
pub code: Option<String>,
@@ -343,6 +384,22 @@ pub enum StreamEvent {
#[serde(default)]
sequence_number: Option<u64>,
},
#[serde(rename = "response.custom_tool_call_input.delta")]
CustomToolCallInputDelta {
item_id: String,
output_index: usize,
delta: String,
#[serde(default)]
sequence_number: Option<u64>,
},
#[serde(rename = "response.custom_tool_call_input.done")]
CustomToolCallInputDone {
item_id: String,
output_index: usize,
input: String,
#[serde(default)]
sequence_number: Option<u64>,
},
#[serde(rename = "response.completed")]
Completed { response: ResponseSummary },
#[serde(rename = "response.incomplete")]
@@ -360,7 +417,7 @@ pub enum StreamEvent {
Unknown,
}
#[derive(Deserialize, Debug, Default, Clone)]
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct ResponseSummary {
#[serde(default)]
pub id: Option<String>,
@@ -378,13 +435,13 @@ pub struct ResponseSummary {
pub service_tier: Option<crate::ServiceTier>,
}
#[derive(Deserialize, Debug, Default, Clone)]
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct ResponseIncompleteDetails {
#[serde(default)]
pub reason: Option<String>,
}
#[derive(Deserialize, Debug, Default, Clone)]
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct ResponseUsage {
#[serde(default)]
pub input_tokens: Option<u64>,
@@ -398,30 +455,32 @@ pub struct ResponseUsage {
pub total_tokens: Option<u64>,
}
#[derive(Deserialize, Debug, Default, Clone)]
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct ResponseInputTokensDetails {
#[serde(default)]
pub cached_tokens: u64,
}
#[derive(Deserialize, Debug, Default, Clone)]
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct ResponseOutputTokensDetails {
#[serde(default)]
pub reasoning_tokens: u64,
}
#[derive(Deserialize, Debug, Clone)]
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ResponseOutputItem {
Message(ResponseOutputMessage),
FunctionCall(ResponseFunctionToolCall),
CustomToolCall(ResponseCustomToolCall),
Reasoning(ResponseReasoningItem),
Compaction(ResponseCompactionItem),
/// Deserialization-only catch-all; must never be serialized back to the API.
#[serde(other)]
Unknown,
}
#[derive(Deserialize, Debug, Clone)]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ResponseReasoningItem {
#[serde(default)]
pub id: Option<String>,
@@ -435,7 +494,7 @@ pub struct ResponseReasoningItem {
pub status: Option<String>,
}
#[derive(Deserialize, Debug, Clone)]
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ReasoningSummaryPart {
SummaryText {
@@ -445,7 +504,7 @@ pub enum ReasoningSummaryPart {
Unknown,
}
#[derive(Deserialize, Debug, Clone)]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ResponseOutputMessage {
#[serde(default)]
pub id: Option<String>,
@@ -459,7 +518,7 @@ pub struct ResponseOutputMessage {
pub phase: Option<String>,
}
#[derive(Deserialize, Debug, Clone)]
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ResponseFunctionToolCall {
#[serde(default)]
pub id: Option<String>,
@@ -473,6 +532,20 @@ pub struct ResponseFunctionToolCall {
pub status: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ResponseCustomToolCall {
#[serde(default)]
pub id: Option<String>,
#[serde(default)]
pub status: Option<String>,
#[serde(default)]
pub call_id: Option<String>,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub input: String,
}
pub async fn stream_response(
client: &dyn HttpClient,
provider_name: &str,
@@ -580,6 +653,16 @@ pub async fn stream_response(
});
}
}
ResponseOutputItem::CustomToolCall(custom_tool_call) => {
if let Some(ref item_id) = custom_tool_call.id {
all_events.push(StreamEvent::CustomToolCallInputDone {
item_id: item_id.clone(),
output_index,
input: custom_tool_call.input.clone(),
sequence_number: None,
});
}
}
ResponseOutputItem::Reasoning(reasoning) => {
if let Some(ref item_id) = reasoning.id {
for part in &reasoning.summary {