From 3e976e89bc3767a776965ee4589266b63caac23d Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Thu, 9 Jul 2026 08:05:50 -0400 Subject: [PATCH] language_model: Add OpenAI Responses API custom tool support (#60638) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 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 --- crates/agent/src/db.rs | 4 +- crates/agent/src/tests/mod.rs | 138 ++-- crates/agent/src/thread.rs | 76 +- crates/agent/src/tools.rs | 12 +- crates/agent/src/tools/evals/edit_file.rs | 8 +- crates/agent/src/tools/evals/terminal_tool.rs | 4 +- crates/agent/src/tools/evals/write_file.rs | 8 +- crates/agent_ui/src/buffer_codegen.rs | 36 +- crates/anthropic/src/completion.rs | 150 ++-- crates/google_ai/src/completion.rs | 131 ++-- .../src/language_model_core.rs | 186 ++++- crates/language_model_core/src/request.rs | 71 +- .../language_models/src/provider/anthropic.rs | 7 +- .../src/provider/anthropic_compatible.rs | 7 +- .../language_models/src/provider/bedrock.rs | 62 +- .../src/provider/copilot_chat.rs | 71 +- .../language_models/src/provider/deepseek.rs | 51 +- crates/language_models/src/provider/google.rs | 7 +- .../language_models/src/provider/llama_cpp.rs | 67 +- .../language_models/src/provider/lmstudio.rs | 48 +- .../language_models/src/provider/mistral.rs | 60 +- crates/language_models/src/provider/ollama.rs | 52 +- .../language_models/src/provider/open_ai.rs | 7 +- .../src/provider/open_ai_compatible.rs | 10 +- .../src/provider/open_router.rs | 59 +- .../language_models/src/provider/opencode.rs | 19 +- .../src/provider/vercel_ai_gateway.rs | 7 +- crates/language_models/src/provider/x_ai.rs | 7 +- .../src/language_models_cloud.rs | 20 +- crates/open_ai/src/completion.rs | 661 +++++++++++++++--- crates/open_ai/src/responses.rs | 107 ++- 31 files changed, 1652 insertions(+), 501 deletions(-) diff --git a/crates/agent/src/db.rs b/crates/agent/src/db.rs index a09aa9c2c1..8e17b29054 100644 --- a/crates/agent/src/db.rs +++ b/crates/agent/src/db.rs @@ -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, }, diff --git a/crates/agent/src/tests/mod.rs b/crates/agent/src/tests/mod.rs index 4998a210b8..446dee9f46 100644 --- a/crates/agent/src/tests/mod.rs +++ b/crates/agent/src/tests/mod.rs @@ -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, }, diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 48b86de73b..2c8c5e3dea 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -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::>() } 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"), } diff --git a/crates/agent/src/tools.rs b/crates/agent/src/tools.rs index 452b4a01d0..85da6bbb21 100644 --- a/crates/agent/src/tools.rs +++ b/crates/agent/src/tools.rs @@ -153,12 +153,12 @@ macro_rules! tools { /// A list of all built-in tools pub fn built_in_tools() -> impl Iterator { fn language_model_tool() -> 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(), + ) } [ $( diff --git a/crates/agent/src/tools/evals/edit_file.rs b/crates/agent/src/tools/evals/edit_file.rs index eb690cdcdf..4a8537805d 100644 --- a/crates/agent/src/tools/evals/edit_file.rs +++ b/crates/agent/src/tools/evals/edit_file.rs @@ -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, }) diff --git a/crates/agent/src/tools/evals/terminal_tool.rs b/crates/agent/src/tools/evals/terminal_tool.rs index d5c3ac1ba8..31b35c0e53 100644 --- a/crates/agent/src/tools/evals/terminal_tool.rs +++ b/crates/agent/src/tools/evals/terminal_tool.rs @@ -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); } diff --git a/crates/agent/src/tools/evals/write_file.rs b/crates/agent/src/tools/evals/write_file.rs index 3fce2b0404..c0fb1de7de 100644 --- a/crates/agent/src/tools/evals/write_file.rs +++ b/crates/agent/src/tools/evals/write_file.rs @@ -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, }) diff --git a/crates/agent_ui/src/buffer_codegen.rs b/crates/agent_ui/src/buffer_codegen.rs index 1fee158f4a..022bf5c658 100644 --- a/crates/agent_ui/src/buffer_codegen.rs +++ b/crates/agent_ui/src/buffer_codegen.rs @@ -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 tags with your replacement_text.".to_string(), - input_schema: language_model::tool_schema::root_schema_for::(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::(tool_input_format).to_value(), - use_input_streaming: false, - }, + LanguageModelRequestTool::function( + REWRITE_SECTION_TOOL_NAME.to_string(), + "Replaces text in tags with your replacement_text.".to_string(), + language_model::tool_schema::root_schema_for::(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::(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::(tool_use.input) - else { + let Ok(input) = tool_use.input.parse::() 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::(tool_use.input) - else { + let Ok(mut input) = tool_use.input.parse::() 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, }) diff --git a/crates/anthropic/src/completion.rs b/crates/anthropic/src/completion.rs index 7c9a8c5369..884ed1a1dd 100644 --- a/crates/anthropic/src/completion.rs +++ b/crates/anthropic/src/completion.rs @@ -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 { +fn to_anthropic_content(content: MessageContent) -> Result> { 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 { 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 { 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 { 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 { let mut new_messages: Vec = 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 = 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 = 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::>()?; 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(), diff --git a/crates/google_ai/src/completion.rs b/crates/google_ai/src/completion.rs index ab07115648..16c4f86eb4 100644 --- a/crates/google_ai/src/completion.rs +++ b/crates/google_ai/src/completion.rs @@ -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) -> Vec { - content - .into_iter() - .flat_map(|content| match content { +) -> Result { + fn map_content(content: Vec) -> Result> { + 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::>()?, + }]) + }; + + 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::>>()? + .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"); diff --git a/crates/language_model_core/src/language_model_core.rs b/crates/language_model_core/src/language_model_core.rs index 26ef0867db..991046eab0 100644 --- a/crates/language_model_core/src/language_model_core.rs +++ b/crates/language_model_core/src/language_model_core.rs @@ -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, 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, } +#[derive(Debug, PartialEq, Eq, Hash, Clone)] +pub enum LanguageModelToolUseInput { + Json(serde_json::Value), + Text(String), +} + +impl Serialize for LanguageModelToolUseInput { + fn serialize(&self, serializer: S) -> Result + 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(deserializer: D) -> Result + 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(&self) -> Result { + 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 { + 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::() + .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, }; diff --git a/crates/language_model_core/src/request.rs b/crates/language_model_core/src/request.rs index 61696f4158..a661874b0e 100644 --- a/crates/language_model_core/src/request.rs +++ b/crates/language_model_core/src/request.rs @@ -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, + }, +} + +#[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, } +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, )] diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index 19ba2b4567..ffd8bf814b 100644 --- a/crates/language_models/src/provider/anthropic.rs +++ b/crates/language_models/src/provider/anthropic.rs @@ -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; } diff --git a/crates/language_models/src/provider/anthropic_compatible.rs b/crates/language_models/src/provider/anthropic_compatible.rs index 8a3ddd85da..633494e3a7 100644 --- a/crates/language_models/src/provider/anthropic_compatible.rs +++ b/crates/language_models/src/provider/anthropic_compatible.rs @@ -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; } diff --git a/crates/language_models/src/provider/bedrock.rs b/crates/language_models/src/provider/bedrock.rs index fe12a5eb3a..f0902dd9cb 100644 --- a/crates/language_models/src/provider/bedrock.rs +++ b/crates/language_models/src/provider/bedrock.rs @@ -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, guardrail_version: Option, ) -> Result { + if request.contains_custom_tool_input() { + anyhow::bail!("Bedrock does not support custom tools"); + } + let mut new_messages: Vec = 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::>()? } 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, }, )) diff --git a/crates/language_models/src/provider/copilot_chat.rs b/crates/language_models/src/provider/copilot_chat.rs index db845e8579..4074fab35c 100644 --- a/crates/language_models/src/provider/copilot_chat.rs +++ b/crates/language_models/src/provider/copilot_chat.rs @@ -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::>(); + .collect::>>()?; Ok(CopilotChatRequest { n: 1, @@ -1188,7 +1206,7 @@ fn intent_to_chat_location(intent: Option) -> ChatLocation { fn into_copilot_responses( model: &CopilotChatModel, request: LanguageModelRequest, -) -> copilot_responses::Request { +) -> Result { use copilot_responses as responses; let LanguageModelRequest { @@ -1356,13 +1374,20 @@ fn into_copilot_responses( let converted_tools: Vec = 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::>()?; 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"); diff --git a/crates/language_models/src/provider/deepseek.rs b/crates/language_models/src/provider/deepseek.rs index f448ae2be6..129ea1bd45 100644 --- a/crates/language_models/src/provider/deepseek.rs +++ b/crates/language_models/src/provider/deepseek.rs @@ -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, -) -> deepseek::Request { +) -> Result { + 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::>()?, + }) } 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, }, diff --git a/crates/language_models/src/provider/google.rs b/crates/language_models/src/provider/google.rs index 7906e8f301..e930405c11 100644 --- a/crates/language_models/src/provider/google.rs +++ b/crates/language_models/src/provider/google.rs @@ -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)?; diff --git a/crates/language_models/src/provider/llama_cpp.rs b/crates/language_models/src/provider/llama_cpp.rs index 19f0e9f296..297c1e4284 100644 --- a/crates/language_models/src/provider/llama_cpp.rs +++ b/crates/language_models/src/provider/llama_cpp.rs @@ -650,7 +650,7 @@ impl LlamaCppLanguageModel { fn to_llama_cpp_request( &self, request: LanguageModelRequest, - ) -> llama_cpp::ChatCompletionRequest { + ) -> Result { 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 { + 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::>()? } 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] { diff --git a/crates/language_models/src/provider/lmstudio.rs b/crates/language_models/src/provider/lmstudio.rs index 3f6e9fb46e..03b9451726 100644 --- a/crates/language_models/src/provider/lmstudio.rs +++ b/crates/language_models/src/provider/lmstudio.rs @@ -338,7 +338,11 @@ impl LmStudioLanguageModel { fn to_lmstudio_request( &self, request: LanguageModelRequest, - ) -> lmstudio::ChatCompletionRequest { + ) -> Result { + 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::>()?, 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, }, diff --git a/crates/language_models/src/provider/mistral.rs b/crates/language_models/src/provider/mistral.rs index f12f33a6f3..a45564b191 100644 --- a/crates/language_models/src/provider/mistral.rs +++ b/crates/language_models/src/provider/mistral.rs @@ -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, -) -> (mistral::Request, Option) { +) -> Result<(mistral::Request, Option)> { + 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::>()?, }, 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!( diff --git a/crates/language_models/src/provider/ollama.rs b/crates/language_models/src/provider/ollama.rs index f0166c6afb..9ef157f965 100644 --- a/crates/language_models/src/provider/ollama.rs +++ b/crates/language_models/src/provider/ollama.rs @@ -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 { + 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::>()? } 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 { + 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)] diff --git a/crates/language_models/src/provider/open_ai.rs b/crates/language_models/src/provider/open_ai.rs index c2c74a8553..368c1a6c3b 100644 --- a/crates/language_models/src/provider/open_ai.rs +++ b/crates/language_models/src/provider/open_ai.rs @@ -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(); diff --git a/crates/language_models/src/provider/open_ai_compatible.rs b/crates/language_models/src/provider/open_ai_compatible.rs index e69e847eab..ff67344ac5 100644 --- a/crates/language_models/src/provider/open_ai_compatible.rs +++ b/crates/language_models/src/provider/open_ai_compatible.rs @@ -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")); diff --git a/crates/language_models/src/provider/open_router.rs b/crates/language_models/src/provider/open_router.rs index fd5221c9b7..59d4aac3fb 100644 --- a/crates/language_models/src/provider/open_router.rs +++ b/crates/language_models/src/provider/open_router.rs @@ -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, -) -> open_router::Request { +) -> Result { + 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::>()?, 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) -> Option { @@ -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 { diff --git a/crates/language_models/src/provider/opencode.rs b/crates/language_models/src/provider/opencode.rs index c3e5c0f8da..6604995ffe 100644 --- a/crates/language_models/src/provider/opencode.rs +++ b/crates/language_models/src/provider/opencode.rs @@ -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(); diff --git a/crates/language_models/src/provider/vercel_ai_gateway.rs b/crates/language_models/src/provider/vercel_ai_gateway.rs index 212223eb85..23868b9374 100644 --- a/crates/language_models/src/provider/vercel_ai_gateway.rs +++ b/crates/language_models/src/provider/vercel_ai_gateway.rs @@ -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(); diff --git a/crates/language_models/src/provider/x_ai.rs b/crates/language_models/src/provider/x_ai.rs index 4556e439a3..834b97eb8b 100644 --- a/crates/language_models/src/provider/x_ai.rs +++ b/crates/language_models/src/provider/x_ai.rs @@ -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(); diff --git a/crates/language_models_cloud/src/language_models_cloud.rs b/crates/language_models_cloud/src/language_models_cloud.rs index 07be3150c0..46060e92c7 100644 --- a/crates/language_models_cloud/src/language_models_cloud.rs +++ b/crates/language_models_cloud/src/language_models_cloud.rs @@ -462,7 +462,7 @@ impl LanguageModel for CloudLanguageModel LanguageModel for CloudLanguageModel 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 LanguageModel for CloudLanguageModel { 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 LanguageModel for CloudLanguageModel 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 LanguageModel for CloudLanguageModel 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 { diff --git a/crates/open_ai/src/completion.rs b/crates/open_ai/src/completion.rs index 0638203d76..a8541a4ee1 100644 --- a/crates/open_ai/src/completion.rs +++ b/crates/open_ai/src/completion.rs @@ -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, interleaved_reasoning: bool, -) -> crate::Request { +) -> Result { + 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::>()?, 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, + tool_use_kinds_by_id: &mut HashMap, input_items: &mut Vec, ) { let mut content_parts: Vec = 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, @@ -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, + custom_tool_calls_by_item: HashMap, reasoning_items: Vec, current_message_phase: Option, pending_stop_reason: Option, @@ -748,10 +849,17 @@ struct PendingResponseFunctionCall { arguments: String, } +struct PendingResponseCustomToolCall { + call_id: String, + name: Arc, + 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::::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> { 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 = 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 = 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::::from(arguments.clone()), - json_parse_error: error.to_string(), - })); + tool_name: name.clone(), + raw_input: Arc::::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> { + 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> { + 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, @@ -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!["".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!([ diff --git a/crates/open_ai/src/responses.rs b/crates/open_ai/src/responses.rs index 33ec1dfba4..f508a27460 100644 --- a/crates/open_ai/src/responses.rs +++ b/crates/open_ai/src/responses.rs @@ -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, + 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, }, + Custom { + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + format: Option, + }, } -#[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, @@ -343,6 +384,22 @@ pub enum StreamEvent { #[serde(default)] sequence_number: Option, }, + #[serde(rename = "response.custom_tool_call_input.delta")] + CustomToolCallInputDelta { + item_id: String, + output_index: usize, + delta: String, + #[serde(default)] + sequence_number: Option, + }, + #[serde(rename = "response.custom_tool_call_input.done")] + CustomToolCallInputDone { + item_id: String, + output_index: usize, + input: String, + #[serde(default)] + sequence_number: Option, + }, #[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, @@ -378,13 +435,13 @@ pub struct ResponseSummary { pub service_tier: Option, } -#[derive(Deserialize, Debug, Default, Clone)] +#[derive(Serialize, Deserialize, Debug, Default, Clone)] pub struct ResponseIncompleteDetails { #[serde(default)] pub reason: Option, } -#[derive(Deserialize, Debug, Default, Clone)] +#[derive(Serialize, Deserialize, Debug, Default, Clone)] pub struct ResponseUsage { #[serde(default)] pub input_tokens: Option, @@ -398,30 +455,32 @@ pub struct ResponseUsage { pub total_tokens: Option, } -#[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, @@ -435,7 +494,7 @@ pub struct ResponseReasoningItem { pub status: Option, } -#[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, @@ -459,7 +518,7 @@ pub struct ResponseOutputMessage { pub phase: Option, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone)] pub struct ResponseFunctionToolCall { #[serde(default)] pub id: Option, @@ -473,6 +532,20 @@ pub struct ResponseFunctionToolCall { pub status: Option, } +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ResponseCustomToolCall { + #[serde(default)] + pub id: Option, + #[serde(default)] + pub status: Option, + #[serde(default)] + pub call_id: Option, + #[serde(default)] + pub name: Option, + #[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 {